* 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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) ────────────────────────────────────
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
56
tests/phase6-capability-docs.test.cjs
Normal file
56
tests/phase6-capability-docs.test.cjs
Normal 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/);
|
||||
});
|
||||
});
|
||||
72
tests/phase6-planning-capabilities.test.cjs
Normal file
72
tests/phase6-planning-capabilities.test.cjs
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user