diff --git a/skill/SKILL.src.md b/skill/SKILL.src.md index 9afbab4cf..1ba01337b 100644 --- a/skill/SKILL.src.md +++ b/skill/SKILL.src.md @@ -15,7 +15,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node {{scripts_path}}/context.mjs` once per session; if the runtime shows this skill's loaded base directory, run `node /scripts/context.mjs` instead. Keep cwd/workdir at the user's project, not the skill directory. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and append `--target ` to the same command. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node {{scripts_path}}/context.mjs` once per session; if the runtime shows this skill's loaded base directory, run `node /scripts/context.mjs` instead. Keep cwd/workdir at the user's project, not the skill directory. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and append `--target ` to the same command. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`:** divert into `reference/init.md` first when the user invoked `init`, `teach`, `craft`, or `shape`, or when their wording clearly maps to one of those from-scratch build flows (for example: "build/create/make a landing page", "design a new app", or "shape a feature"). Captured product context is the point of those flows. For any other command, a scoped evaluate / refine / enhance / fix / iterate request against existing code, do **not** divert into init. The existing code is the context: proceed with the requested command, infer the register from the surface in focus (step 4), and offer `/impeccable init` once as a suggestion the user can take later. A missing PRODUCT.md must never block a scoped request. If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. @@ -150,7 +150,7 @@ Plus three management commands: `pin `, `unpin `, and `hooks < ### Routing rules -1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` you are already in init (setup), so finish that and skip this. Otherwise run `node {{scripts_path}}/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.** +1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` the project has no captured context yet, so lead the menu with `/impeccable init` as the top recommendation (one line on why) and still show the rest below; don't silently jump into init. Otherwise run `node {{scripts_path}}/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.** Reason over the signals; there is no score to obey: - `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system). @@ -169,7 +169,7 @@ Plus three management commands: `pin `, `unpin `, and `hooks < Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `{{command_prefix}}impeccable`. -If the first word is `craft`, setup still runs first, but [reference/craft.md](reference/craft.md) owns the rest of the flow. If setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target. +If the first word is `craft` or `shape`, or routing rule 3 clearly maps the user's intent to either command, setup still runs first, but the matching reference ([reference/craft.md](reference/craft.md) or [reference/shape.md](reference/shape.md)) owns the rest of the flow. Both are from-scratch build flows: if setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target. `teach` is a deprecated alias for `init`: if the user types it, load [reference/init.md](reference/init.md) and proceed as if they ran `init`. diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index adf03e0d4..1f22ae88b 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -1,8 +1,10 @@ /** * Context loader: prints PRODUCT.md (and DESIGN.md if present) as one - * markdown block on stdout, or exits with empty stdout when no PRODUCT.md - * is found anywhere. The skill keys off "empty stdout" to branch into the - * init flow. + * markdown block on stdout, or prints a `NO_PRODUCT_MD:` message when no + * PRODUCT.md is found anywhere. The skill keys off that message to branch: + * from-scratch build commands (init / teach / craft / shape) and clear + * build/shape intent divert into the init flow, while scoped commands proceed + * using the existing code as context. * * Path resolution (first match wins): * 1. Active project root, if PRODUCT.md or DESIGN.md is there @@ -860,8 +862,11 @@ async function cli() { // — cheap models miss the empty case more often than the explicit one. const parts = [ 'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' + - 'Stop the current task, load reference/init.md, and follow its ' + - 'instructions to write PRODUCT.md before resuming.', + 'Follow SKILL.md Setup step 1: for `init`, `teach`, `craft`, `shape`, ' + + 'or wording that clearly maps to a from-scratch build/shape flow, load ' + + 'reference/init.md and write PRODUCT.md first; for any other (scoped) ' + + 'command against existing code, proceed using the code as context and ' + + 'offer `/impeccable init` as a suggestion (do not block).', ]; parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { diff --git a/tests/skill-behavior/README.md b/tests/skill-behavior/README.md index 389346de7..2bf90ccd8 100644 --- a/tests/skill-behavior/README.md +++ b/tests/skill-behavior/README.md @@ -50,6 +50,10 @@ The trace is the source of truth, not the model's free-form reply. | 7 | same fixture; prompt is `/impeccable audit` | loads `reference/audit.md` | | 8 | PRODUCT.md + DESIGN.md + a SvelteKit scaffold (`src/app.css`, components, `+page.svelte`); prompt is `/impeccable polish src/routes/+page.svelte` | reads at least one project code file (CSS / component / page) — not just the skill's reference files | | 9 | PRODUCT.md + `index.html` + a seeded update cache with a newer version (`skillVersion` copy-mode so `context.mjs` has a `SKILL.md` to version-check against); prompt is `/impeccable polish index.html` | `context.mjs` runs and its output carries the `UPDATE_AVAILABLE` directive (proven via captured bash output); the agent does **not** auto-run `npx impeccable update` (it must ask first) | +| 10 | no PRODUCT.md + a minimal `index.html`; prompt is `/impeccable polish index.html` | runs `context.mjs`, loads `reference/polish.md`, and does **not** divert into `reference/init.md` | +| 11 | empty workspace; prompt is `/impeccable shape ...` | runs `context.mjs`, diverts into `reference/init.md`, and does **not** start writing HTML/CSS | +| 12 | empty workspace; prompt is natural-language build intent with no command word | runs `context.mjs`, diverts into `reference/init.md`, and does **not** start writing HTML/CSS | +| 13 | empty workspace; prompt is `/impeccable teach` | runs `context.mjs` and diverts into `reference/init.md` because `teach` aliases `init` | Scenario 9 passed on all three current-lineup providers (`claude-sonnet-4-6`, `gpt-5.5`, `gemini-3.1-flash-lite`) on 2026-05-28. diff --git a/tests/skill-behavior/scenarios.test.mjs b/tests/skill-behavior/scenarios.test.mjs index 1e743128e..ab47f3fb1 100644 --- a/tests/skill-behavior/scenarios.test.mjs +++ b/tests/skill-behavior/scenarios.test.mjs @@ -34,6 +34,9 @@ import { } from './fixtures.mjs'; const CRAFT_PROMPT = '/impeccable craft a landing page for the project in this workspace'; +const SHAPE_PROMPT = '/impeccable shape a landing page for the project in this workspace'; +const NATURAL_BUILD_PROMPT = 'Build a landing page for the project in this workspace.'; +const TEACH_PROMPT = '/impeccable teach'; const PRIMER_PROMPT = 'Take a quick look at the project. What register is this? Run the impeccable context loader once if you need to.'; @@ -397,5 +400,159 @@ for (const modelId of resolveModelList()) { cleanupWorkspace(workspace); } }); + + it('scenario 10: scoped command with no PRODUCT.md proceeds without forcing init', async () => { + // The counterpart to scenario 1. There, a from-scratch `craft` with no + // context correctly diverts into init. Here a *scoped* command against + // existing code must NOT: the code is the context. Missing PRODUCT.md is + // a suggestion to run init, never a blocker on the requested work. + const workspace = prepareWorkspace({ + files: { + 'index.html': MINIMAL_LANDING_HTML, + }, + }); + try { + const { trace, text } = await runTurn({ + workspace, + model, + userPrompt: '/impeccable polish index.html', + maxSteps: 6, + }); + logTrace('S10', 'scoped-no-product', modelId, trace, { textSample: text.slice(0, 400) }); + // Boot still runs. + assert.ok( + bashCommandsMatching(trace, 'context.mjs').length >= 1, + `expected agent to run context.mjs at least once.\n` + + `Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`, + ); + // It must load the scoped command's own reference and get on with it. + assert.ok( + fileLoaded(trace, 'polish.md'), + `agent should load polish.md and proceed with the scoped command.\n` + + `Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`, + ); + // The core property: a scoped command on existing code must not divert + // into init just because PRODUCT.md is absent. + const initLoaded = + readsMatching(trace, 'init.md').length > 0 || + bashCommandsMatching(trace, 'init.md').length > 0; + assert.equal( + initLoaded, + false, + `scoped /impeccable polish on existing code should not divert into init.md when PRODUCT.md is missing.\n` + + `Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`, + ); + } finally { + cleanupWorkspace(workspace); + } + }); + + it('scenario 11: shape with no PRODUCT.md still diverts into init', async () => { + // `shape` is a from-scratch build flow, like `craft` (scenario 1): with + // no captured context it must still divert into init before planning. + // This pins the third member of the init/craft/shape guard, so a future + // edit that drops `shape` from the list is caught here. + const workspace = prepareWorkspace({ files: {} }); + try { + const { trace, text } = await runTurn({ + workspace, + model, + userPrompt: SHAPE_PROMPT, + maxSteps: 6, + }); + logTrace('S11', 'shape-no-context', modelId, trace, { textSample: text.slice(0, 400) }); + assert.ok( + bashCommandsMatching(trace, 'context.mjs').length >= 1, + `expected agent to run context.mjs at least once.\n` + + `Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`, + ); + const initLoaded = + readsMatching(trace, 'init.md').length > 0 || + bashCommandsMatching(trace, 'init.md').length > 0; + assert.ok( + initLoaded, + `from-scratch /impeccable shape should divert into init.md when PRODUCT.md is missing.\n` + + `Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`, + ); + // Like craft, it must not barrel into writing implementation files first. + const wroteHtml = trace.writePaths.some((p) => /\.(html?|css|svelte|jsx?|tsx?)$/i.test(p)); + assert.equal( + wroteHtml, + false, + `agent should not write implementation files before resolving missing PRODUCT.md.\n` + + `wrote: ${trace.writePaths.join(', ')}`, + ); + } finally { + cleanupWorkspace(workspace); + } + }); + + it('scenario 12: intent-routed build with no PRODUCT.md still diverts into init', async () => { + // Setup runs before the routing table maps natural language like "build a + // landing page" to `craft`, so the NO_PRODUCT_MD guard itself must catch + // clearly-from-scratch build intent. + const workspace = prepareWorkspace({ files: {} }); + try { + const { trace, text } = await runTurn({ + workspace, + model, + userPrompt: NATURAL_BUILD_PROMPT, + maxSteps: 6, + }); + logTrace('S12', 'natural-build-no-context', modelId, trace, { textSample: text.slice(0, 400) }); + assert.ok( + bashCommandsMatching(trace, 'context.mjs').length >= 1, + `expected agent to run context.mjs at least once.\n` + + `Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`, + ); + const initLoaded = + readsMatching(trace, 'init.md').length > 0 || + bashCommandsMatching(trace, 'init.md').length > 0; + assert.ok( + initLoaded, + `natural-language build intent should divert into init.md when PRODUCT.md is missing.\n` + + `Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`, + ); + const wroteHtml = trace.writePaths.some((p) => /\.(html?|css|svelte|jsx?|tsx?)$/i.test(p)); + assert.equal( + wroteHtml, + false, + `agent should not write implementation files before resolving missing PRODUCT.md.\n` + + `wrote: ${trace.writePaths.join(', ')}`, + ); + } finally { + cleanupWorkspace(workspace); + } + }); + + it('scenario 13: teach alias with no PRODUCT.md diverts into init', async () => { + // `teach` is a deprecated alias for `init`, so it belongs to the same + // missing-PRODUCT.md blocker path instead of the scoped-command path. + const workspace = prepareWorkspace({ files: {} }); + try { + const { trace, text } = await runTurn({ + workspace, + model, + userPrompt: TEACH_PROMPT, + maxSteps: 6, + }); + logTrace('S13', 'teach-no-context', modelId, trace, { textSample: text.slice(0, 400) }); + assert.ok( + bashCommandsMatching(trace, 'context.mjs').length >= 1, + `expected agent to run context.mjs at least once.\n` + + `Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`, + ); + const initLoaded = + readsMatching(trace, 'init.md').length > 0 || + bashCommandsMatching(trace, 'init.md').length > 0; + assert.ok( + initLoaded, + `/impeccable teach should behave like init and load init.md when PRODUCT.md is missing.\n` + + `Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`, + ); + } finally { + cleanupWorkspace(workspace); + } + }); }); }