Don't force init on scoped commands when PRODUCT.md is missing (#277)

* Don't force init on scoped commands when PRODUCT.md is missing

Setup step 1 told the agent: "If it reports NO_PRODUCT_MD, stop and
follow reference/init.md before doing anything else." For a project with
no PRODUCT.md, that turned every scoped request (polish, critique, audit,
layout, ...) into a full from-scratch init detour. The user asks to
polish one button and the skill instead starts writing PRODUCT.md from
the beginning. Faced with that gate, agents also frequently abandon the
command and do an ad-hoc pass without loading the command reference.

Make the gate command-aware. A missing PRODUCT.md still routes into init
for the from-scratch build flows where captured product context is the
point (init, craft, shape). For any other command, a scoped request
against existing code, the code is the context: proceed with the
requested command, infer the register from the surface in focus, and
offer /impeccable init once as a suggestion rather than a blocker.

- skill/SKILL.src.md: rewrite the step 1 NO_PRODUCT_MD rule; reconcile
  the no-argument routing rule so it leads the menu with init instead of
  silently jumping into it; extend the craft init-then-resume footnote to
  cover shape, now also a from-scratch flow.
- skill/scripts/context.mjs: soften the NO_PRODUCT_MD message to defer to
  the step 1 rule instead of "Stop the current task"; refresh the stale
  file-level JSDoc that still described the old empty-stdout signal.
- tests/skill-behavior/scenarios.test.mjs: add scenario 10 (scoped
  command + no PRODUCT.md proceeds without forcing init) and scenario 11
  (shape + no PRODUCT.md still diverts into init). Scenario 1 (craft
  diverts) stays green and pins the build path.

Source-only per repo convention; provider and plugin copies are
regenerated by the maintainer's build:skills sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fix missing-context routing for build intent

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Paul Bakaus <paul.bakaus@gmail.com>
This commit is contained in:
makappen-collab
2026-07-07 18:02:30 -07:00
committed by GitHub
co-authored by Claude Opus 4.8 Paul Bakaus
parent e813a16d22
commit 1a46353b29
4 changed files with 174 additions and 8 deletions
+3 -3
View File
@@ -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 <skill-base-dir>/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 <path>` 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 <skill-base-dir>/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 <path>` 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/<command>.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 <command>`, `unpin <command>`, 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 <command>`, `unpin <command>`, 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`.
+10 -5
View File
@@ -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)) {
+4
View File
@@ -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.
+157
View File
@@ -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);
}
});
});
}