Files
msd-core/tests/quick-plan-pre-capabilities.test.cjs
Dennis Alexis Valin Dittrich b848b23861 feat(#3778): dispatch plan:pre planner contributions before quick planning (#3934)
* feat(#3778): dispatch plan:pre planner contributions in quick.md

- Add plan:pre capability gate to quick.md Step 5, mirroring plan-phase.md's
  existing render + generic contribution dispatch pattern
- Inject planner-targeted contribution fragments into the planner prompt,
  after AGENT_SKILLS_PLANNER, matching D-08 ordering
- Add tests/quick-plan-pre-capabilities.test.cjs proving the dispatch is
  generic (D-01) via real scanWiredKinds/coveredKindsInRegion functions
- Record quick.md's byte-growth rationale in this commit trailer for every
  gsd-core-verbatim runtime

Emitted-Drift-Ack-Growth: quick.md — #3778: Step 5 (Spawn planner, quick mode) gains a `plan:pre` capability gate, mirroring `plan-phase.md:420-424` and `:797`. This is shipped shell and prose read by an agent at runtime, not compiled, so the reasoning has to travel with the feature rather than being deferred to a reference doc: (1) the dispatch paragraph phrases role routing possessively ("the role each entry's `into` names") rather than as an `into ==` equality, because `coveredKindsInRegion` (scripts/gen-loop-host-contract.cjs) voids a segment's `kind == "contribution"` coverage credit when a role or capability equality shares that same segment — an equality phrasing here would silently fail the generic-dispatch proof required by D-01; (2) `activeHooks` is read directly in-context from `PLAN_PRE_HOOKS_JSON`/`HOOKS_JSON` and the unfiltered `rendered` digest is explicitly forbidden from being pasted, because `rendered` carries every kind and role — including non-planner-targeted contributions such as a `into: "checker"` twin — and pasting it would leak checker-scoped guidance into the planner's prompt (T-01-02 in the threat model); (3) the injection block sits inside `<planning_context>` AFTER `${AGENT_SKILLS_PLANNER}` and after the Project skills line, matching plan-phase's `:741` -> `:797` ordering (D-08), so agent-skills content is never shadowed by capability-contributed prose. No prose was moved into an eagerly `@`-imported reference to shrink the measured file — @gsd-core/references/loop-hook-dispatch.md already existed before this change and is deferred to for the generic contract only, exactly as plan-phase.md already does.

* test(#3778): expand quick.md plan:pre dispatch coverage to all nine locked conditions

Extend tests/quick-plan-pre-capabilities.test.cjs with D-02 (silent
omit-when-empty), D-03 (single shared planner spawn), D-06 (array-order
dispatch phrasing), D-07 (planner-only into filter), and D-08 (render call
< agent-skills placeholder < injection block < spawn ordering) assertions,
all extracted via a brace-bounded slice anchored on the literal injection
instruction rather than a naive first-brace scan (${AGENT_SKILLS_PLANNER}
and the surrounding prompt's ${VALIDATE_MODE ? ...} ternaries also contain
brace pairs).

Add a capability-registry.test.cjs describe block proving the registry-wide
D-07 exclusion is meaningful: at least one plan:pre contribution exists,
every plan:pre contribution has a non-empty into/fragment.inline, and the
registry as a whole carries at least one non-planner-into contribution.

Add a loop-host-contract.test.cjs regression pin for D-09: quick.md stays
absent from STEP_WORKFLOWS, parseLoopHostBlock still throws on quick.md's
real content, and buildContract() still yields exactly 5 entries.

Verified red-without-Task-1 by temporarily reverting quick.md to its
pre-f30de9cc content and re-running these three suites (D-08 failed as
expected), then restored via git checkout and re-confirmed green.

* docs(#3778): note quick planning also renders plan:pre in the tutorial

The tutorial's Step 6 named only /gsd-plan-phase as the trigger for the
plan:pre hook set. Since quick.md now dispatches the same hook set
(f30de9cc), the sentence understated the capability's real reach.

* feat(#3778): add changeset fragment

* chore(#3778): reference the upstream issue in the changeset fragment

The fragment was the only one of 81 in .changeset/ without a trailing
(#NNNN) reference or a bold lead-in. serializeChangelog auto-appends
only the pr: field, so the rendered CHANGELOG entry carried no link
back to issue #3778.

* test(#3778): scope the D-07 registry assertion to what it actually proves

The registry-wide non-planner check was named "D-07 exclusion is
meaningful", which overclaims: it proves only that `into` takes
non-planner values somewhere in the registry, not that anything is
excluded at plan:pre. Every plan:pre contribution is currently
into: "planner", so the filter is a forward-looking safeguard there.

Narrowing the assertion to plan:pre (as review suggested) would fail
today. Asserting plan:pre is all-planner would be brittle — it would
break the day a legitimate non-planner plan:pre contribution lands,
which is exactly when the safeguard starts doing work. So the
assertion is unchanged and only the name and comment are corrected.

* chore(#3778): point the changeset fragment at the upstream PR

The fragment carried pr: 3, the fork staging PR. changeset lint derives
the real PR number from GITHUB_EVENT_PATH, so on the upstream PR that
would read as pr-field drift. Point it at open-gsd/gsd-core#3934.

* test(#3778): require contributions in Quick revision prompts

* test(loop-host): require Quick auxiliary registration

* fix(#3778): preserve contributions in Quick plan revisions

* fix(#3778): validate Quick as a planner contribution host

* fix(#3778): tighten Quick contribution contract

* test(#3778): drop unnecessary source-contract exemption

* fix(#3778): require Quick planner target coverage

* docs(#3778): describe targeted auxiliary coverage

---------

Co-authored-by: davdittrich <davdittrich@gmail.com>
Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-01 21:16:57 -04:00

107 lines
5.0 KiB
JavaScript

// Quick workflow Markdown is the installed orchestration contract.
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { readFileNormalized } = require('./helpers.cjs');
const WORKFLOW_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
const QUICK_PATH = path.join(WORKFLOW_DIR, 'quick.md');
const REVISION_PATH = path.join(WORKFLOW_DIR, 'quick', 'steps', 'plan-checker-loop.md');
function sliceBetween(content, startAnchor, endAnchor, label) {
const start = content.indexOf(startAnchor);
assert.notEqual(start, -1, `${label}: missing start anchor ${JSON.stringify(startAnchor)}`);
const bodyStart = start + startAnchor.length;
const end = content.indexOf(endAnchor, bodyStart);
assert.notEqual(end, -1, `${label}: missing end anchor ${JSON.stringify(endAnchor)}`);
assert.ok(end > bodyStart, `${label}: anchors must enclose non-empty text`);
return content.slice(bodyStart, end);
}
function initialPlannerPrompt(quick) {
const step = sliceBetween(
quick,
'**Step 5: Spawn planner (quick mode)**',
'<!-- gsd:section id="plan-checker-loop"',
'initial planner step',
);
return sliceBetween(
step,
'Agent(\n prompt="\n',
'\n",\n subagent_type="gsd-planner"',
'initial planner prompt',
);
}
function revisionPlannerPrompt(revision) {
return sliceBetween(
revision,
'Revision prompt:\n\nReuse the `PLAN_PRE_HOOKS_JSON` snapshot captured by Quick Step 5; do not render hooks again.\n\n```markdown\n',
'\n```\n\n```\nAgent(\n prompt=revision_prompt',
'revision planner prompt',
);
}
function contributionInstruction(prompt, label) {
const lines = prompt.split('\n');
const matches = lines.filter((line) => line.startsWith('{For each active entry in `PLAN_PRE_HOOKS_JSON`'));
assert.equal(matches.length, 1, `${label}: expected exactly one planner contribution instruction`);
return matches[0];
}
function assertPlannerContributionContract(prompt, label) {
const instruction = contributionInstruction(prompt, label);
const skillsIndex = prompt.indexOf('${AGENT_SKILLS_PLANNER}');
const instructionIndex = prompt.indexOf(instruction);
assert.notEqual(skillsIndex, -1, `${label}: missing planner skills anchor`);
assert.ok(skillsIndex < instructionIndex, `${label}: contribution instruction must follow planner skills`);
assert.match(instruction, /kind == "contribution"/, `${label}: must filter contribution kind`);
assert.match(instruction, /into == "planner"/, `${label}: must filter planner target`);
assert.match(instruction, /in array order/, `${label}: must preserve registry order`);
assert.match(instruction, /fragment\.inline/, `${label}: must inject materialised fragment text`);
assert.match(instruction, /configValues/, `${label}: must surface resolved config values`);
assert.match(
instruction,
/no active planner contributions exist.*omit this block entirely/i,
`${label}: empty or inactive input must be a silent no-op`,
);
assert.doesNotMatch(
instruction,
/(?:capId|plugin|runtime)\s*={2,3}/,
`${label}: generic dispatch must not specialize by capability or runtime`,
);
}
describe('quick workflow: plan:pre planner contributions (#3778)', () => {
test('initial and revision Quick planners reuse one ordered plan:pre contribution snapshot', () => {
const quick = readFileNormalized(QUICK_PATH);
const revision = readFileNormalized(REVISION_PATH);
const initialPrompt = initialPlannerPrompt(quick);
const revisionPrompt = revisionPlannerPrompt(revision);
assertPlannerContributionContract(initialPrompt, 'initial planner');
assertPlannerContributionContract(revisionPrompt, 'revision planner');
assert.equal(
contributionInstruction(revisionPrompt, 'revision planner'),
contributionInstruction(initialPrompt, 'initial planner'),
'both planner paths must apply the byte-identical filtering and injection contract',
);
assert.equal((quick.match(/loop render-hooks plan:pre --raw/g) || []).length, 1);
assert.equal((revision.match(/loop render-hooks plan:pre --raw/g) || []).length, 0);
const initialStep = quick.indexOf('**Step 5: Spawn planner (quick mode)**');
const revisionGate = quick.indexOf('<!-- gsd:section id="plan-checker-loop" when="flag:--validate" -->');
assert.notEqual(initialStep, -1, 'standard initial planner step must exist');
assert.ok(initialStep < revisionGate, 'initial planner must run before the optional revision gate');
assert.match(quick, /--full.*\$VALIDATE_MODE=true/, '--full must enable the validate path');
assert.match(quick, /--validate.*\$VALIDATE_MODE=true/, '--validate must enable the validate path');
assert.match(revision, /Revision loop \(max 2 iterations\)/, 'validate path must contain revision loop');
assert.equal((quick.match(/subagent_type="gsd-planner"/g) || []).length, 1);
assert.equal((revision.match(/subagent_type="gsd-planner"/g) || []).length, 1);
});
});