feat(#1135): migrate planning hooks to capabilities (#1141)

* feat(#1135): migrate planning hooks to capabilities

* chore(#1135): add phase 6 planning capabilities changeset

* fix(#1135): satisfy lint for agent hook rendering
This commit is contained in:
Tom Boucher
2026-06-12 19:51:54 -04:00
committed by GitHub
parent fd01e7a12e
commit 4ab5c7b3f2
20 changed files with 1025 additions and 176 deletions

View File

@@ -360,12 +360,11 @@ describe('WORKFLOW: plan-phase.md AI nudge integration', () => {
);
});
test('plan-phase.md references workflow.ai_integration_phase config toggle', () => {
test('ai-integration capability owns workflow.ai_integration_phase config toggle', () => {
const content = fs.readFileSync(planPhasePath, 'utf-8');
assert.ok(
content.includes('ai_integration_phase'),
'plan-phase.md should check workflow.ai_integration_phase config'
);
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
assert.strictEqual(registry.configKeys['workflow.ai_integration_phase'], 'ai-integration');
assert.doesNotMatch(content, /config-get workflow\.ai_integration_phase/);
});
});

View File

@@ -630,6 +630,37 @@ describe('registry structure', () => {
});
});
describe('ADR-857 phase 6 planning feature capabilities', () => {
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
test('real registry declares research, ai-integration, and pattern-mapper capabilities', () => {
for (const capId of ['research', 'ai-integration', 'pattern-mapper']) {
assert.ok(realRegistry.capabilities[capId], `${capId} capability must be declared`);
assert.strictEqual(realRegistry.capabilities[capId].role, 'feature');
}
});
test('planning feature capabilities own their workflow config keys', () => {
assert.strictEqual(realRegistry.configKeys['workflow.research'], 'research');
assert.strictEqual(realRegistry.configKeys['workflow.ai_integration_phase'], 'ai-integration');
assert.strictEqual(realRegistry.configKeys['workflow.pattern_mapper'], 'pattern-mapper');
});
test('planning feature capabilities register plan:pre hooks', () => {
const hooks = [
...realRegistry.byLoopPoint['plan:pre'].steps,
...realRegistry.byLoopPoint['plan:pre'].contributions,
...realRegistry.byLoopPoint['plan:pre'].gates,
];
for (const capId of ['research', 'ai-integration', 'pattern-mapper']) {
assert.ok(
hooks.some((hook) => hook.capId === capId),
`${capId} must participate in plan:pre through the Capability Registry`,
);
}
});
});
// ─── 6. Fix regression guards ────────────────────────────────────────────────
describe('Fix #1: consumes-satisfiability is point-order-aware', () => {
@@ -1122,6 +1153,23 @@ describe('S1: fragment.path traversal guard', () => {
assert.ok(errors.some((e) => e.includes('fragment.path')));
});
test('array fragment shape is rejected', () => {
const cap = {
...UI_CAP,
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: [],
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.some((e) => e.includes('fragment') && e.includes('object')));
});
test('non-string fragment.inline is rejected', () => {
const cap = {
...UI_CAP,
@@ -1193,6 +1241,94 @@ describe('S1: fragment.path traversal guard', () => {
const errors = validateCapability(cap, 'ui');
assert.ok(errors.some((e) => e.includes('consumes entries') && e.includes('strings')));
});
test('fragment.path is materialized into inline registry content', (t) => {
const capsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-fragment-'));
t.after(() => cleanup(capsDir));
const capDir = path.join(capsDir, 'planning-advice');
fs.mkdirSync(path.join(capDir, 'fragments'), { recursive: true });
fs.writeFileSync(
path.join(capDir, 'fragments', 'plan-pre.md'),
'Use the capability-owned planning fragment.\n',
);
fs.writeFileSync(
path.join(capDir, 'capability.json'),
JSON.stringify({
id: 'planning-advice',
role: 'feature',
title: 'Planning advice',
description: 'Synthetic fixture for fragment path materialization.',
tier: 'full',
requires: [],
skills: [],
agents: [],
hooks: [],
config: {},
steps: [],
contributions: [{
point: 'plan:pre',
into: 'planner',
fragment: { path: 'fragments/plan-pre.md' },
produces: [],
consumes: ['CONTEXT.md'],
onError: 'skip',
}],
gates: [],
}),
);
const { capMap, errors } = loadAndValidate(new Set(), capsDir);
assert.deepEqual(errors, []);
const registry = buildRegistry(capMap);
assert.strictEqual(
registry.byLoopPoint['plan:pre'].contributions[0].fragment.inline,
'Use the capability-owned planning fragment.\n',
);
});
test('step fragment.path is materialized into inline registry content', (t) => {
const capsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-step-fragment-'));
t.after(() => cleanup(capsDir));
const capDir = path.join(capsDir, 'research');
fs.mkdirSync(path.join(capDir, 'fragments'), { recursive: true });
fs.writeFileSync(
path.join(capDir, 'fragments', 'plan-pre.md'),
'Research prompt owned by the capability.\n',
);
fs.writeFileSync(
path.join(capDir, 'capability.json'),
JSON.stringify({
id: 'research',
role: 'feature',
title: 'Research',
description: 'Synthetic fixture for step fragment materialization.',
tier: 'standard',
requires: [],
skills: [],
agents: ['gsd-phase-researcher'],
hooks: [],
config: {},
steps: [{
point: 'plan:pre',
ref: { agent: 'gsd-phase-researcher' },
fragment: { path: 'fragments/plan-pre.md' },
produces: ['RESEARCH.md'],
consumes: ['CONTEXT.md'],
onError: 'skip',
}],
contributions: [],
gates: [],
}),
);
const { capMap, errors } = loadAndValidate(new Set(), capsDir);
assert.deepEqual(errors, []);
const registry = buildRegistry(capMap);
assert.strictEqual(
registry.byLoopPoint['plan:pre'].steps[0].fragment.inline,
'Research prompt owned by the capability.\n',
);
});
});
// ─── 8. Security: prototype pollution (S2) ────────────────────────────────────

View File

@@ -109,15 +109,15 @@ function extractSection(content, heading) {
// "Check AI-SPEC" is skipped on every non-AI phase (ai_integration_phase_enabled false /
// --skip-ai-spec), so a resolution placed there leaves SPEC_PATH unbound and the planner
// never receives the SPEC — exactly the #550 silent no-op. Assert it is NOT in §4.5.
test('RR-01 reachability: SPEC_FILE resolution is NOT gated inside the skippable "## 4.5 Check AI-SPEC" section', () => {
test('RR-01 reachability: SPEC_FILE resolution is NOT gated inside the AI-SPEC artifact section', () => {
const content = readPlanPhase();
const aiSpecSection = extractSection(content, '## 4.5. Check AI-SPEC');
const aiSpecSection = extractSection(content, '## 4.5. Resolve AI-SPEC Artifact');
const specResolution = new RegExp('SPEC_FILE=\\$\\(ls "\\$\\{[A-Z_]*PHASE_DIR[A-Z_]*\\}"[/][*]-SPEC\\.md');
assert.ok(aiSpecSection.length > 0, 'sanity: the §4.5 Check AI-SPEC section must exist to scope this test');
assert.ok(aiSpecSection.length > 0, 'sanity: the §4.5 AI-SPEC artifact section must exist to scope this test');
assert.doesNotMatch(
aiSpecSection,
specResolution,
'SPEC_FILE resolution must NOT live inside the skippable §4.5 "Check AI-SPEC" block — gating it there silently starves the planner of the SPEC on non-AI phases (the original #550 no-op)'
'SPEC_FILE resolution must NOT live inside the §4.5 AI-SPEC artifact block — gating it there silently starves the planner of the SPEC on non-AI phases (the original #550 no-op)'
);
assert.match(content, specResolution, 'SPEC_FILE resolution must still exist on an un-gated path elsewhere in plan-phase.md');
});

View File

@@ -29,15 +29,19 @@ const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
// ─── hostConsume helper ───────────────────────────────────────────────────────
//
// Models host consumption of the resolved+rendered envelope:
// activeCount — number of step-kind hooks (the host's execution list length)
// activeCount — number of step-kind hooks for the selected capability
// skillsToInvoke — ordered skill refs from step hooks (the host's dispatch list)
// rendered — the markdown string the host would embed in its prompt
//
// This is the aggregate that must be IDENTICAL to the zero-hooks base when
// the capability is off (structural "off means off" — no host-source mutation).
// This is the aggregate that must be IDENTICAL to the same extension point
// with the selected capability removed when that capability is off (structural
// "off means off" — no host-source mutation). Other Phase 6 capabilities may
// still be active at the same Loop Extension Point.
function hostConsume(envelope) {
const steps = envelope.activeHooks.filter(h => h.kind === 'step');
function hostConsume(envelope, capId) {
const steps = envelope.activeHooks.filter(
h => h.kind === 'step' && (!capId || h.capId === capId),
);
return {
activeCount: steps.length,
skillsToInvoke: steps.map(h => h.ref && h.ref.skill).filter(Boolean),
@@ -45,20 +49,33 @@ function hostConsume(envelope) {
};
}
// ─── Compute the zero-hooks base for comparison ───────────────────────────────
// ─── Compute bases for comparison ─────────────────────────────────────────────
//
// The base is what hostConsume produces when activeHooks is empty.
// We derive it from a fresh call with an empty registry so it is computed, not
// hand-coded — if renderLoopHooks ever changes its empty-string format, the
// base updates automatically and the "off = base" assertion still holds.
// The zero-hooks base is what hostConsume produces when activeHooks is empty.
// The capability-removed base is what the same extension point produces when
// just one capability's hooks are removed. Phase 6 uses the latter for UI,
// because research / AI / pattern-mapper can be active at plan:pre too.
function makeBaseEnvelope(point) {
const emptyByLoopPoint = {};
function makeRegistryWithoutCapability(registry, capId) {
const byLoopPoint = {};
for (const p of CANONICAL_POINTS) {
emptyByLoopPoint[p] = { steps: [], contributions: [], gates: [] };
const point = registry.byLoopPoint[p] || {};
byLoopPoint[p] = {
steps: Array.isArray(point.steps) ? point.steps.filter(h => h.capId !== capId) : [],
contributions: Array.isArray(point.contributions) ? point.contributions.filter(h => h.capId !== capId) : [],
gates: Array.isArray(point.gates) ? point.gates.filter(h => h.capId !== capId) : [],
};
}
const emptyRegistry = { byLoopPoint: emptyByLoopPoint, configSchema: {} };
const resolved = resolveLoopHooks({ point, registry: emptyRegistry, config: {} });
const configSchema = {};
for (const [key, slice] of Object.entries(registry.configSchema || {})) {
if (!slice || slice.owner !== capId) configSchema[key] = slice;
}
return { ...registry, byLoopPoint, configSchema };
}
function makeCapabilityRemovedEnvelope(point, capId, config) {
const registry = makeRegistryWithoutCapability(realRegistry, capId);
const resolved = resolveLoopHooks({ point, registry, config });
return {
activeHooks: resolved.activeHooks,
rendered: renderLoopHooks(resolved),
@@ -82,25 +99,25 @@ describe('spike #1018 — off means off (structural proof)', () => {
// ── Case 1: UI active by default ──────────────────────────────────────────
//
// config {} → workflow.ui_phase falls to configSchema default true → step active.
// hostConsume must report activeCount=1, skillsToInvoke=['ui-phase'], and
// rendered must include the ui-phase block.
// config {} → workflow.ui_phase falls to configSchema default true → UI step active.
// hostConsume for capId=ui must report activeCount=1, skillsToInvoke=['ui-phase'],
// and rendered must include the ui-phase block.
// The hook's onError must be carried through to activeHooks.
test('UI active by default: config {} → activeCount=1, skillsToInvoke=[ui-phase], rendered includes ui-phase block', () => {
test('UI active by default: config {} → ui activeCount=1, skillsToInvoke=[ui-phase], rendered includes ui-phase block', () => {
const resolved = resolveLoopHooks({
point: 'plan:pre',
registry: realRegistry,
config: {},
});
const envelope = { activeHooks: resolved.activeHooks, rendered: renderLoopHooks(resolved) };
const consumed = hostConsume(envelope);
const consumed = hostConsume(envelope, 'ui');
assert.strictEqual(consumed.activeCount, 1,
'Expected exactly 1 active step when ui_phase defaults to true');
'Expected exactly 1 active UI step when ui_phase defaults to true');
assert.deepEqual(consumed.skillsToInvoke, ['ui-phase'],
'skillsToInvoke must be [ui-phase]');
assert.match(consumed.rendered, /### Step 1: skill:ui-phase \(ui\)/,
'UI skillsToInvoke must be [ui-phase]');
assert.match(consumed.rendered, /### Step \d+: skill:ui-phase \(ui\)/,
'rendered must include the properly-structured Step block heading for ui-phase');
// onError='skip' must be carried into the active hook entry
@@ -117,8 +134,8 @@ describe('spike #1018 — off means off (structural proof)', () => {
// Turning off ui_phase suppresses the step but the gate (ui_safety_gate=true)
// still fires → rendered is NOT the zero-hooks base (it contains the gate block).
//
// This case proves the step surface is a pure function of step-kind hooks:
// activeCount=0, skillsToInvoke=[] — "step off means step off".
// This case proves the UI step surface is a pure function of UI step-kind hooks:
// activeCount=0, skillsToInvoke=[] — "UI step off means UI step off".
// It also asserts the gate IS present in activeHooks and rendered differs from
// the empty base, documenting that the two toggles are genuinely independent.
@@ -130,23 +147,23 @@ describe('spike #1018 — off means off (structural proof)', () => {
config: stepOffConfig,
});
const envelope = { activeHooks: resolved.activeHooks, rendered: renderLoopHooks(resolved) };
const consumed = hostConsume(envelope);
const consumed = hostConsume(envelope, 'ui');
// Step-level aggregate: step is off
assert.strictEqual(consumed.activeCount, 0,
'Expected 0 active steps when ui_phase=false');
'Expected 0 active UI steps when ui_phase=false');
assert.deepEqual(consumed.skillsToInvoke, [],
'skillsToInvoke must be [] when ui_phase=false');
'UI skillsToInvoke must be [] when ui_phase=false');
// Gate is still active: activeHooks is NOT empty (contains the gate hook)
const gateHooks = resolved.activeHooks.filter(h => h.kind === 'gate');
const gateHooks = resolved.activeHooks.filter(h => h.kind === 'gate' && h.capId === 'ui');
assert.ok(gateHooks.length > 0,
'Gate hook must still be present in activeHooks when ui_safety_gate=true');
'UI gate hook must still be present in activeHooks when ui_safety_gate=true');
// Rendered is NOT the zero-hooks base because the gate block is present
const base = makeBaseEnvelope('plan:pre');
// Rendered is NOT the UI-removed base because the UI gate block is present
const base = makeCapabilityRemovedEnvelope('plan:pre', 'ui', stepOffConfig);
assert.notStrictEqual(consumed.rendered, base.rendered,
'rendered must NOT equal the zero-hooks base when the gate is still active (ui_safety_gate=true)');
'rendered must NOT equal the UI-removed base when the UI gate is still active (ui_safety_gate=true)');
// Rendered must NOT include a step block for ui-phase
assert.ok(
@@ -157,15 +174,16 @@ describe('spike #1018 — off means off (structural proof)', () => {
// ── Case 2b: ALL-OFF (ui_phase=false, ui_safety_gate=false) ─────────────────
//
// Both toggles off → activeHooks is genuinely empty → rendered is byte-identical
// to the zero-hooks base produced by the empty-registry helper.
// Both UI toggles off → no UI activeHooks → rendered is byte-identical
// to the same registry with UI hooks removed. Other plan:pre capabilities may
// still be active, and that is correct after Phase 6.
//
// THIS is the clean structural "off means off → base output" proof.
// THIS is the clean structural "UI off means no UI output" proof.
// It must exist as a concrete, computable assertion — not be elided because a
// partial-off case happens to have a gate. The empty base is computed, not
// hand-coded, so if renderLoopHooks ever changes its empty format this still holds.
test('ALL-OFF: config {workflow:{ui_phase:false, ui_safety_gate:false}} → activeHooks empty AND rendered === base', () => {
test('ALL-OFF: config {workflow:{ui_phase:false, ui_safety_gate:false}} → UI hooks empty AND rendered === UI-removed base', () => {
const allOffConfig = { workflow: { ui_phase: false, ui_safety_gate: false } };
const resolved = resolveLoopHooks({
point: 'plan:pre',
@@ -175,16 +193,16 @@ describe('spike #1018 — off means off (structural proof)', () => {
const envelope = { activeHooks: resolved.activeHooks, rendered: renderLoopHooks(resolved) };
// STRUCTURAL ASSERTION (the spike's core proof — restored):
// When every hook at this point is toggled off, activeHooks must be empty
// and rendered must be byte-identical to the computed zero-hooks base.
// When every UI hook at this point is toggled off, UI activeHooks must be empty
// and rendered must be byte-identical to the computed UI-removed base.
// This proves the host aggregate is a pure function of activeHooks — no
// capability leaks through when all its controlling config keys are false.
assert.strictEqual(resolved.activeHooks.length, 0,
'ALL-OFF: activeHooks must be empty when both ui_phase and ui_safety_gate are false');
// UI capability leaks through when all its controlling config keys are false.
assert.strictEqual(resolved.activeHooks.filter(h => h.capId === 'ui').length, 0,
'ALL-OFF: UI activeHooks must be empty when both ui_phase and ui_safety_gate are false');
const base = makeBaseEnvelope('plan:pre');
const base = makeCapabilityRemovedEnvelope('plan:pre', 'ui', allOffConfig);
assert.strictEqual(envelope.rendered, base.rendered,
'ALL-OFF: rendered must be byte-identical to the zero-hooks base when activeHooks is empty');
'ALL-OFF: rendered must be byte-identical to the UI-removed base when UI activeHooks are empty');
});
// ── Case 3: Synthetic multi-hook ──────────────────────────────────────────
@@ -255,11 +273,11 @@ describe('spike #1018 — off means off (structural proof)', () => {
config: { workflow: { ui_phase: true } },
});
const offConsumed = hostConsume({ activeHooks: offResolved.activeHooks, rendered: renderLoopHooks(offResolved) });
const onConsumed = hostConsume({ activeHooks: onResolved.activeHooks, rendered: renderLoopHooks(onResolved) });
const offConsumed = hostConsume({ activeHooks: offResolved.activeHooks, rendered: renderLoopHooks(offResolved) }, 'ui');
const onConsumed = hostConsume({ activeHooks: onResolved.activeHooks, rendered: renderLoopHooks(onResolved) }, 'ui');
assert.strictEqual(offConsumed.activeCount, 0, 'OFF: activeCount must be 0');
assert.strictEqual(onConsumed.activeCount, 1, 'ON: activeCount must be 1');
assert.strictEqual(offConsumed.activeCount, 0, 'OFF: UI activeCount must be 0');
assert.strictEqual(onConsumed.activeCount, 1, 'ON: UI activeCount must be 1');
assert.notStrictEqual(onConsumed.rendered, offConsumed.rendered,
'ON and OFF rendered outputs must differ');
});

View File

@@ -643,6 +643,27 @@ describe('renderLoopHooks', () => {
assert.match(rendered, /skip/);
});
test('step hook renders agent ref and inline prompt fragment', () => {
const registry = makeRegistry({
point: 'plan:pre',
steps: [{
capId: 'research',
point: 'plan:pre',
ref: { agent: 'gsd-phase-researcher' },
fragment: { inline: 'Research the phase before planning.' },
produces: ['RESEARCH.md'],
consumes: ['CONTEXT.md'],
onError: 'skip',
}],
});
const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
assert.deepEqual(resolved.activeHooks[0].fragment, { inline: 'Research the phase before planning.' });
const rendered = renderLoopHooks(resolved);
assert.match(rendered, /agent:gsd-phase-researcher/);
assert.match(rendered, /Research the phase before planning\./);
});
test('contribution hook renders into role', () => {
const resolved = {
point: 'plan:pre',

View File

@@ -0,0 +1,56 @@
// allow-test-rule: source-text-is-the-product
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const MANUAL_PATH = path.join(ROOT, 'docs', 'how-to', 'develop-a-capability.md');
const DOCS_README_PATH = path.join(ROOT, 'docs', 'README.md');
function readManual() {
return fs.readFileSync(MANUAL_PATH, 'utf8');
}
describe('ADR-857 phase 6 capability documentation', () => {
test('Capability developer manual exists', () => {
assert.ok(fs.existsSync(MANUAL_PATH), 'docs/how-to/develop-a-capability.md must exist');
});
test('docs index links the Capability developer manual', () => {
const readme = fs.readFileSync(DOCS_README_PATH, 'utf8');
assert.match(readme, /how-to\/develop-a-capability\.md/);
});
test('manual uses ADR-857 terminology and names plugin as packaging only', () => {
const manual = readManual();
assert.match(manual, /Capability/);
assert.match(manual, /ADR-857/);
assert.match(manual, /plugin is a packaging or host-runtime term/i);
});
test('manual documents the GSD 1.5 capability development loop', () => {
const manual = readManual();
assert.match(manual, /GSD 1\.5/);
assert.match(manual, /capabilities\/<id>\/capability\.json/);
assert.match(manual, /node scripts\/gen-capability-registry\.cjs --write/);
assert.match(manual, /node scripts\/gen-capability-registry\.cjs --check/);
});
test('manual documents plan:pre hook fragments and render-hooks verification', () => {
const manual = readManual();
assert.match(manual, /plan:pre/);
assert.match(manual, /fragment\.path/);
assert.match(manual, /fragment\.inline/);
assert.match(manual, /gsd-tools loop render-hooks plan:pre/);
});
test('manual documents phase 6 planning capability cutovers', () => {
const manual = readManual();
assert.match(manual, /research/);
assert.match(manual, /ai-integration/);
assert.match(manual, /pattern-mapper/);
});
});

View File

@@ -0,0 +1,72 @@
// allow-test-rule: source-text-is-the-product
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const PLAN_PHASE_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'plan-phase.md');
const REGISTRY = require('../gsd-core/bin/lib/capability-registry.cjs');
function readPlanPhase() {
return fs.readFileSync(PLAN_PHASE_PATH, 'utf8');
}
function extractSection(content, heading, nextHeading) {
const start = content.indexOf(heading);
assert.notStrictEqual(start, -1, `${heading} must exist`);
const end = content.indexOf(nextHeading, start);
assert.notStrictEqual(end, -1, `${nextHeading} must follow ${heading}`);
return content.slice(start, end);
}
describe('ADR-857 phase 6 planning capability migration', () => {
test('plan-phase delegates AI integration activation to plan:pre capability hooks', () => {
const content = readPlanPhase();
assert.doesNotMatch(
content,
/config-get workflow\.ai_integration_phase/,
'plan-phase must not read workflow.ai_integration_phase directly after capability cutover',
);
assert.match(content, /ai-integration/);
assert.match(content, /loop render-hooks plan:pre/);
});
test('plan-phase delegates pattern mapper activation to plan:pre capability hooks', () => {
const content = readPlanPhase();
assert.doesNotMatch(
content,
/config-get workflow\.pattern_mapper/,
'plan-phase must not read workflow.pattern_mapper directly after capability cutover',
);
const section = extractSection(content, '## 7.8.', '## 7.9.');
assert.match(section, /PLAN_PRE_HOOKS_JSON/);
assert.match(section, /pattern-mapper/);
assert.match(section, /ref\.agent/);
});
test('plan-phase generic plan:pre dispatch supports skill and agent step hooks', () => {
const content = readPlanPhase();
const section = extractSection(content, '## 5.6.', '## 5.7.');
assert.match(section, /ref\.skill/);
assert.match(section, /ref\.agent/);
assert.match(section, /Agent\(/);
assert.match(section, /Skill\(/);
});
test('research and pattern mapper prompts are capability-owned fragments', () => {
const planPreSteps = REGISTRY.byLoopPoint['plan:pre'].steps;
const research = planPreSteps.find((step) => step.capId === 'research');
assert.ok(research, 'research capability must register a plan:pre step');
assert.equal(research.ref.agent, 'gsd-phase-researcher');
assert.match(research.fragment.inline, /Research how to implement Phase/);
const patternMapper = planPreSteps.find((step) => step.capId === 'pattern-mapper');
assert.ok(patternMapper, 'pattern-mapper capability must register a plan:pre step');
assert.equal(patternMapper.ref.agent, 'gsd-pattern-mapper');
assert.match(patternMapper.fragment.inline, /Extract the list of files to be created\/modified/);
});
});

View File

@@ -51,7 +51,7 @@
"note.md": 6563,
"pause-work.md": 13654,
"plan-milestone-gaps.md": 11765,
"plan-phase.md": 94343,
"plan-phase.md": 94326,
"plan-review-convergence.md": 22949,
"plant-seed.md": 11741,
"pr-branch.md": 4994,