diff --git a/.changeset/lively-quails-wave.md b/.changeset/lively-quails-wave.md
new file mode 100644
index 000000000..0525f10d7
--- /dev/null
+++ b/.changeset/lively-quails-wave.md
@@ -0,0 +1,5 @@
+---
+type: Changed
+pr: 1141
+---
+Planning-time research, AI integration, and pattern mapping now participate through Capability declarations and rendered plan:pre hooks, with developer documentation for building GSD capabilities.
diff --git a/capabilities/ai-integration/capability.json b/capabilities/ai-integration/capability.json
new file mode 100644
index 000000000..5805dcd87
--- /dev/null
+++ b/capabilities/ai-integration/capability.json
@@ -0,0 +1,35 @@
+{
+ "id": "ai-integration",
+ "role": "feature",
+ "title": "AI design contract",
+ "description": "AI-SPEC design contract workflow for phases that build AI systems; owns the AI integration command, agents, and workflow.ai_integration_phase activation key.",
+ "tier": "full",
+ "requires": [],
+ "skills": ["ai-integration-phase"],
+ "agents": [
+ "gsd-framework-selector",
+ "gsd-ai-researcher",
+ "gsd-domain-researcher",
+ "gsd-eval-planner"
+ ],
+ "hooks": [],
+ "config": {
+ "workflow.ai_integration_phase": {
+ "type": "boolean",
+ "default": true,
+ "description": "Prompt for an AI-SPEC design contract before planning phases that involve AI systems."
+ }
+ },
+ "steps": [
+ {
+ "point": "plan:pre",
+ "ref": { "skill": "ai-integration-phase" },
+ "produces": ["AI-SPEC.md"],
+ "consumes": ["CONTEXT.md"],
+ "when": "workflow.ai_integration_phase",
+ "onError": "skip"
+ }
+ ],
+ "contributions": [],
+ "gates": []
+}
diff --git a/capabilities/pattern-mapper/capability.json b/capabilities/pattern-mapper/capability.json
new file mode 100644
index 000000000..f79087fca
--- /dev/null
+++ b/capabilities/pattern-mapper/capability.json
@@ -0,0 +1,31 @@
+{
+ "id": "pattern-mapper",
+ "role": "feature",
+ "title": "Pattern mapping",
+ "description": "Optional codebase-pattern mapping before planning; owns the pattern mapper agent and workflow.pattern_mapper activation key.",
+ "tier": "full",
+ "requires": ["research"],
+ "skills": [],
+ "agents": ["gsd-pattern-mapper"],
+ "hooks": [],
+ "config": {
+ "workflow.pattern_mapper": {
+ "type": "boolean",
+ "default": true,
+ "description": "Run the pattern mapper before planning when context or research is available."
+ }
+ },
+ "steps": [
+ {
+ "point": "plan:pre",
+ "ref": { "agent": "gsd-pattern-mapper" },
+ "fragment": { "path": "fragments/plan-pre.md" },
+ "produces": ["PATTERNS.md"],
+ "consumes": ["RESEARCH.md"],
+ "when": "workflow.pattern_mapper",
+ "onError": "skip"
+ }
+ ],
+ "contributions": [],
+ "gates": []
+}
diff --git a/capabilities/pattern-mapper/fragments/plan-pre.md b/capabilities/pattern-mapper/fragments/plan-pre.md
new file mode 100644
index 000000000..eca9f14d9
--- /dev/null
+++ b/capabilities/pattern-mapper/fragments/plan-pre.md
@@ -0,0 +1,14 @@
+
+**Phase:** {phase_number} - {phase_name}
+**Phase directory:** {phase_dir}
+**Padded phase:** {padded_phase}
+
+
+- {context_path} (USER DECISIONS from /gsd:discuss-phase)
+- {research_path} (Technical Research)
+
+
+**Output file:** {phase_dir}/{padded_phase}-PATTERNS.md
+
+Extract the list of files to be created/modified from CONTEXT.md and RESEARCH.md. For each file, classify by role and data flow, find the closest existing analog in the codebase, extract concrete code excerpts, and produce PATTERNS.md.
+
diff --git a/capabilities/research/capability.json b/capabilities/research/capability.json
new file mode 100644
index 000000000..dbe9663f1
--- /dev/null
+++ b/capabilities/research/capability.json
@@ -0,0 +1,31 @@
+{
+ "id": "research",
+ "role": "feature",
+ "title": "Phase research",
+ "description": "Optional phase research before planning; owns the phase researcher agent and workflow.research activation key.",
+ "tier": "standard",
+ "requires": [],
+ "skills": [],
+ "agents": ["gsd-phase-researcher"],
+ "hooks": [],
+ "config": {
+ "workflow.research": {
+ "type": "boolean",
+ "default": true,
+ "description": "Run phase research before planning when research artifacts are missing or explicitly refreshed."
+ }
+ },
+ "steps": [
+ {
+ "point": "plan:pre",
+ "ref": { "agent": "gsd-phase-researcher" },
+ "fragment": { "path": "fragments/plan-pre.md" },
+ "produces": ["RESEARCH.md"],
+ "consumes": ["CONTEXT.md"],
+ "when": "workflow.research",
+ "onError": "skip"
+ }
+ ],
+ "contributions": [],
+ "gates": []
+}
diff --git a/capabilities/research/fragments/plan-pre.md b/capabilities/research/fragments/plan-pre.md
new file mode 100644
index 000000000..ab08ebcfc
--- /dev/null
+++ b/capabilities/research/fragments/plan-pre.md
@@ -0,0 +1,24 @@
+
+Research how to implement Phase {phase_number}: {phase_name}
+Answer: "What do I need to know to PLAN this phase well?"
+
+
+
+- {context_path} (USER DECISIONS from /gsd:discuss-phase)
+- {requirements_path} (Project requirements)
+- {state_path} (Project decisions and history)
+
+
+${AGENT_SKILLS_RESEARCHER}
+
+
+**Phase description:** {phase_description}
+**Phase requirement IDs (MUST address):** {phase_req_ids}
+
+**Project instructions:** Read ./CLAUDE.md or ./.claude/CLAUDE.md if either exists; follow project-specific guidelines.
+**Project skills:** Check .claude/skills/ or .agents/skills/ directory if either exists. Read SKILL.md files and account for project skill patterns.
+
+
+
diff --git a/docs/README.md b/docs/README.md
index 7d169e842..29f0d27ae 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -31,6 +31,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
- [Debug a failed execution](how-to/debug-a-failed-execution.md) — diagnose and recover from broken or incomplete phase execution
- [Spike and sketch](how-to/spike-and-sketch.md) — use `/gsd-spike` and `/gsd-sketch` for exploratory work before committing to a plan
- [Design a UI phase](how-to/design-a-ui-phase.md) — use the UI phase loop for frontend and visual work
+- [Develop a Capability for GSD 1.5+](how-to/develop-a-capability.md) — add feature Capabilities, hook fragments, and registry entries
- [Drive GSD from a tracker issue](how-to/drive-gsd-from-a-tracker-issue.md) — start a phase from a GitHub, Linear, or Jira issue
- [Migrate from GSD 2](how-to/migrate-from-gsd-2.md) — upgrade an existing GSD 2 project to GSD Core
- [Update GSD](how-to/update-gsd.md) — re-run the installer to pick up the latest release
diff --git a/docs/how-to/develop-a-capability.md b/docs/how-to/develop-a-capability.md
new file mode 100644
index 000000000..3dc349478
--- /dev/null
+++ b/docs/how-to/develop-a-capability.md
@@ -0,0 +1,165 @@
+# Develop a Capability for GSD 1.5+
+
+This guide shows you how to add or change a first-party GSD Capability after the ADR-857 cutover. In GSD terms, the extension unit is a **Capability**. A plugin is a packaging or host-runtime term, for example a Claude Code plugin or Gemini extension, not the unit that owns a GSD feature.
+
+A Capability is right when the feature can be toggled as one unit and owns its own skills, agents, hooks, config keys, or command family. Keep verifier predicate contracts, the five-step loop spine, and shared infrastructure in core unless the ADRs explicitly move that boundary.
+
+## Start from the boundary
+
+Before writing a manifest, decide whether the work is core or a Capability.
+
+Use a Capability when the feature:
+
+- Can be enabled or disabled without changing the meaning of the base loop.
+- Owns a stable feature name such as `research`, `ui`, `graphify`, `ai-integration`, or `pattern-mapper`.
+- Adds a step, gate, or contribution at a Loop Extension Point.
+- Owns one or more feature config keys.
+- Owns a command family that can route through the capability registry.
+
+Keep the work in core when the feature:
+
+- Defines the reliability substrate of the loop, such as the verifier predicate contract described by ADR-550 and ADR-857.
+- Is required for every installation profile.
+- Mutates shared host workflow state rather than adding a declared hook contribution.
+
+## Create the folder
+
+Create one folder per Capability:
+
+```text
+capabilities//
+ capability.json
+ fragments/
+ plan-pre.md
+```
+
+The manifest path is `capabilities//capability.json`.
+
+The `` must match the `id` field in `capability.json`. Co-locate prompt fragments, owned skills, owned agents, and other owned artefacts under the Capability folder when the schema allows it. Shared host artefacts can be referenced by name, but ownership should remain clear in the manifest.
+
+## Write `capability.json`
+
+Use the existing manifests as the source of truth while the schema is still first-party:
+
+- `capabilities/research/capability.json`
+- `capabilities/ai-integration/capability.json`
+- `capabilities/pattern-mapper/capability.json`
+- `capabilities/ui/capability.json`
+- `capabilities/graphify/capability.json`
+
+At minimum, a feature Capability declares:
+
+```json
+{
+ "id": "example",
+ "role": "feature",
+ "title": "Example",
+ "description": "Adds an example planning step.",
+ "tier": "standard",
+ "requires": [],
+ "skills": [],
+ "agents": ["gsd-example-agent"],
+ "hooks": [],
+ "config": {},
+ "steps": [
+ {
+ "point": "plan:pre",
+ "ref": { "agent": "gsd-example-agent" },
+ "fragment": { "path": "fragments/plan-pre.md" },
+ "produces": ["EXAMPLE.md"],
+ "consumes": ["CONTEXT.md"],
+ "onError": "skip"
+ }
+ ],
+ "contributions": [],
+ "gates": []
+}
+```
+
+The registry generator validates the shape, ownership, and cross-capability contracts. `ref.skill` must name a skill declared by the same Capability. `ref.agent` must name an agent declared by the same Capability.
+
+## Add hooks
+
+Loop Extension Points are the stable sites where Capabilities attach to the host loop. Phase 6 planning-time features use `plan:pre` so the core planner can ask the registry for active planning hooks instead of reading feature config directly.
+
+Choose the hook kind that matches the behaviour:
+
+- `steps` add a sequenced unit of work, such as running `gsd-phase-researcher` or `gsd-pattern-mapper`.
+- `contributions` add labelled context to a host prompt.
+- `gates` check a condition and may block when `blocking` is true.
+
+Declare file artefact flow with `produces` and `consumes`. The registry uses those arrays to order hooks and to reject unsatisfied dependencies.
+
+## Use prompt fragments
+
+Use `fragment.path` for prompt text longer than a short sentence:
+
+```json
+"fragment": { "path": "fragments/plan-pre.md" }
+```
+
+The generator materialises that file into `fragment.inline` in the generated registry. Paths must be relative to the Capability folder, must not be absolute, and must not contain `..`.
+
+Use `fragment.inline` only for short, stable text:
+
+```json
+"fragment": { "inline": "Add the generated example context to the planner input." }
+```
+
+## Generate and verify the registry
+
+After editing any Capability manifest or fragment, regenerate the committed registry:
+
+```bash
+node scripts/gen-capability-registry.cjs --write
+```
+
+Then run the drift check:
+
+```bash
+node scripts/gen-capability-registry.cjs --check
+```
+
+For a planning hook, verify the rendered output:
+
+```bash
+node gsd-core/bin/gsd-tools.cjs loop render-hooks plan:pre --raw
+```
+
+In installed workflow prose, the same resolver surface appears as `gsd-tools loop render-hooks plan:pre`.
+
+The rendered JSON should include the active hook, the declared `ref`, and the materialised `fragment.inline`.
+
+## Wire the workflow through the registry
+
+Host workflows should ask the resolver for active hooks and then dispatch from the resolved data. Do not add new direct `config-get workflow.` checks to a host workflow for a migrated feature.
+
+The Phase 6 planning migration follows this pattern:
+
+- `research` registers a `plan:pre` step that invokes `gsd-phase-researcher`.
+- `ai-integration` owns the AI-SPEC planning activation and config key.
+- `pattern-mapper` registers a `plan:pre` step that invokes `gsd-pattern-mapper`.
+- `plan-phase.md` reads the resolved `PLAN_PRE_HOOKS_JSON` and dispatches `ref.agent` or `ref.skill` from that data.
+
+This keeps "off means off" enforceable by construction: disabled Capabilities are absent from the active hook set, so the host workflow has nothing feature-specific to run.
+
+## Test the Capability
+
+Add focused tests before changing behaviour:
+
+- Registry tests for manifest validation, ordering, config ownership, command family dispatch, or fragment materialisation.
+- Workflow text tests for host workflow cutovers when a workflow stops reading direct feature config.
+- Behavioural command tests when a command family moves behind `dispatchCapabilityCommand`.
+- Documentation tests when the feature changes developer-facing behaviour.
+
+Run the smallest affected tests first, then the full suite before opening a ready PR:
+
+```bash
+node --test tests/capability-registry.test.cjs
+node --test tests/phase6-planning-capabilities.test.cjs
+npm test
+```
+
+## Keep the docs with the slice
+
+Every Phase 6 slice that changes capability behaviour must update the relevant docs in the same PR. Use this manual for developer-facing Capability authoring facts, use how-to guides for task flows, and use ADRs only for decisions and trade-offs.
diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs
index 649eda3af..76fb23681 100644
--- a/gsd-core/bin/lib/capability-registry.cjs
+++ b/gsd-core/bin/lib/capability-registry.cjs
@@ -7,6 +7,49 @@
*/
const capabilities = {
+ "ai-integration": {
+ "id": "ai-integration",
+ "role": "feature",
+ "title": "AI design contract",
+ "description": "AI-SPEC design contract workflow for phases that build AI systems; owns the AI integration command, agents, and workflow.ai_integration_phase activation key.",
+ "tier": "full",
+ "requires": [],
+ "skills": [
+ "ai-integration-phase"
+ ],
+ "agents": [
+ "gsd-framework-selector",
+ "gsd-ai-researcher",
+ "gsd-domain-researcher",
+ "gsd-eval-planner"
+ ],
+ "hooks": [],
+ "config": {
+ "workflow.ai_integration_phase": {
+ "type": "boolean",
+ "default": true,
+ "description": "Prompt for an AI-SPEC design contract before planning phases that involve AI systems."
+ }
+ },
+ "steps": [
+ {
+ "point": "plan:pre",
+ "ref": {
+ "skill": "ai-integration-phase"
+ },
+ "produces": [
+ "AI-SPEC.md"
+ ],
+ "consumes": [
+ "CONTEXT.md"
+ ],
+ "when": "workflow.ai_integration_phase",
+ "onError": "skip"
+ }
+ ],
+ "contributions": [],
+ "gates": []
+ },
"antigravity": {
"id": "antigravity",
"role": "runtime",
@@ -832,6 +875,50 @@ const capabilities = {
"extendedHookEvents": []
}
},
+ "pattern-mapper": {
+ "id": "pattern-mapper",
+ "role": "feature",
+ "title": "Pattern mapping",
+ "description": "Optional codebase-pattern mapping before planning; owns the pattern mapper agent and workflow.pattern_mapper activation key.",
+ "tier": "full",
+ "requires": [
+ "research"
+ ],
+ "skills": [],
+ "agents": [
+ "gsd-pattern-mapper"
+ ],
+ "hooks": [],
+ "config": {
+ "workflow.pattern_mapper": {
+ "type": "boolean",
+ "default": true,
+ "description": "Run the pattern mapper before planning when context or research is available."
+ }
+ },
+ "steps": [
+ {
+ "point": "plan:pre",
+ "ref": {
+ "agent": "gsd-pattern-mapper"
+ },
+ "fragment": {
+ "path": "fragments/plan-pre.md",
+ "inline": "\n**Phase:** {phase_number} - {phase_name}\n**Phase directory:** {phase_dir}\n**Padded phase:** {padded_phase}\n\n\n- {context_path} (USER DECISIONS from /gsd:discuss-phase)\n- {research_path} (Technical Research)\n\n\n**Output file:** {phase_dir}/{padded_phase}-PATTERNS.md\n\nExtract the list of files to be created/modified from CONTEXT.md and RESEARCH.md. For each file, classify by role and data flow, find the closest existing analog in the codebase, extract concrete code excerpts, and produce PATTERNS.md.\n\n"
+ },
+ "produces": [
+ "PATTERNS.md"
+ ],
+ "consumes": [
+ "RESEARCH.md"
+ ],
+ "when": "workflow.pattern_mapper",
+ "onError": "skip"
+ }
+ ],
+ "contributions": [],
+ "gates": []
+ },
"qwen": {
"id": "qwen",
"role": "runtime",
@@ -885,6 +972,48 @@ const capabilities = {
]
}
},
+ "research": {
+ "id": "research",
+ "role": "feature",
+ "title": "Phase research",
+ "description": "Optional phase research before planning; owns the phase researcher agent and workflow.research activation key.",
+ "tier": "standard",
+ "requires": [],
+ "skills": [],
+ "agents": [
+ "gsd-phase-researcher"
+ ],
+ "hooks": [],
+ "config": {
+ "workflow.research": {
+ "type": "boolean",
+ "default": true,
+ "description": "Run phase research before planning when research artifacts are missing or explicitly refreshed."
+ }
+ },
+ "steps": [
+ {
+ "point": "plan:pre",
+ "ref": {
+ "agent": "gsd-phase-researcher"
+ },
+ "fragment": {
+ "path": "fragments/plan-pre.md",
+ "inline": "\nResearch how to implement Phase {phase_number}: {phase_name}\nAnswer: \"What do I need to know to PLAN this phase well?\"\n\n\n\n- {context_path} (USER DECISIONS from /gsd:discuss-phase)\n- {requirements_path} (Project requirements)\n- {state_path} (Project decisions and history)\n\n\n${AGENT_SKILLS_RESEARCHER}\n\n\n**Phase description:** {phase_description}\n**Phase requirement IDs (MUST address):** {phase_req_ids}\n\n**Project instructions:** Read ./CLAUDE.md or ./.claude/CLAUDE.md if either exists; follow project-specific guidelines.\n**Project skills:** Check .claude/skills/ or .agents/skills/ directory if either exists. Read SKILL.md files and account for project skill patterns.\n\n\n\n"
+ },
+ "produces": [
+ "RESEARCH.md"
+ ],
+ "consumes": [
+ "CONTEXT.md"
+ ],
+ "when": "workflow.research",
+ "onError": "skip"
+ }
+ ],
+ "contributions": [],
+ "gates": []
+ },
"trae": {
"id": "trae",
"role": "runtime",
@@ -1070,12 +1199,19 @@ const capabilities = {
};
const bySkill = {
+ "ai-integration-phase": "ai-integration",
"graphify": "graphify",
"ui-phase": "ui",
"ui-review": "ui"
};
const byAgent = {
+ "gsd-framework-selector": "ai-integration",
+ "gsd-ai-researcher": "ai-integration",
+ "gsd-domain-researcher": "ai-integration",
+ "gsd-eval-planner": "ai-integration",
+ "gsd-pattern-mapper": "pattern-mapper",
+ "gsd-phase-researcher": "research",
"gsd-ui-checker": "ui",
"gsd-ui-auditor": "ui"
};
@@ -1093,6 +1229,40 @@ const byLoopPoint = {
},
"plan:pre": {
"steps": [
+ {
+ "capId": "ai-integration",
+ "point": "plan:pre",
+ "ref": {
+ "skill": "ai-integration-phase"
+ },
+ "produces": [
+ "AI-SPEC.md"
+ ],
+ "consumes": [
+ "CONTEXT.md"
+ ],
+ "when": "workflow.ai_integration_phase",
+ "onError": "skip"
+ },
+ {
+ "capId": "research",
+ "point": "plan:pre",
+ "ref": {
+ "agent": "gsd-phase-researcher"
+ },
+ "fragment": {
+ "path": "fragments/plan-pre.md",
+ "inline": "\nResearch how to implement Phase {phase_number}: {phase_name}\nAnswer: \"What do I need to know to PLAN this phase well?\"\n\n\n\n- {context_path} (USER DECISIONS from /gsd:discuss-phase)\n- {requirements_path} (Project requirements)\n- {state_path} (Project decisions and history)\n\n\n${AGENT_SKILLS_RESEARCHER}\n\n\n**Phase description:** {phase_description}\n**Phase requirement IDs (MUST address):** {phase_req_ids}\n\n**Project instructions:** Read ./CLAUDE.md or ./.claude/CLAUDE.md if either exists; follow project-specific guidelines.\n**Project skills:** Check .claude/skills/ or .agents/skills/ directory if either exists. Read SKILL.md files and account for project skill patterns.\n\n\n\n"
+ },
+ "produces": [
+ "RESEARCH.md"
+ ],
+ "consumes": [
+ "CONTEXT.md"
+ ],
+ "when": "workflow.research",
+ "onError": "skip"
+ },
{
"capId": "ui",
"point": "plan:pre",
@@ -1107,6 +1277,25 @@ const byLoopPoint = {
],
"when": "workflow.ui_phase",
"onError": "skip"
+ },
+ {
+ "capId": "pattern-mapper",
+ "point": "plan:pre",
+ "ref": {
+ "agent": "gsd-pattern-mapper"
+ },
+ "fragment": {
+ "path": "fragments/plan-pre.md",
+ "inline": "\n**Phase:** {phase_number} - {phase_name}\n**Phase directory:** {phase_dir}\n**Padded phase:** {padded_phase}\n\n\n- {context_path} (USER DECISIONS from /gsd:discuss-phase)\n- {research_path} (Technical Research)\n\n\n**Output file:** {phase_dir}/{padded_phase}-PATTERNS.md\n\nExtract the list of files to be created/modified from CONTEXT.md and RESEARCH.md. For each file, classify by role and data flow, find the closest existing analog in the codebase, extract concrete code excerpts, and produce PATTERNS.md.\n\n"
+ },
+ "produces": [
+ "PATTERNS.md"
+ ],
+ "consumes": [
+ "RESEARCH.md"
+ ],
+ "when": "workflow.pattern_mapper",
+ "onError": "skip"
}
],
"contributions": [],
@@ -1198,14 +1387,23 @@ const byLoopPoint = {
};
const configKeys = {
+ "workflow.ai_integration_phase": "ai-integration",
"graphify.enabled": "graphify",
"intel.enabled": "intel",
+ "workflow.pattern_mapper": "pattern-mapper",
+ "workflow.research": "research",
"workflow.ui_phase": "ui",
"workflow.ui_review": "ui",
"workflow.ui_safety_gate": "ui"
};
const configSchema = {
+ "workflow.ai_integration_phase": {
+ "owner": "ai-integration",
+ "type": "boolean",
+ "default": true,
+ "description": "Prompt for an AI-SPEC design contract before planning phases that involve AI systems."
+ },
"graphify.enabled": {
"owner": "graphify",
"type": "boolean",
@@ -1218,6 +1416,18 @@ const configSchema = {
"default": false,
"description": "Enable the intel code-intelligence command."
},
+ "workflow.pattern_mapper": {
+ "owner": "pattern-mapper",
+ "type": "boolean",
+ "default": true,
+ "description": "Run the pattern mapper before planning when context or research is available."
+ },
+ "workflow.research": {
+ "owner": "research",
+ "type": "boolean",
+ "default": true,
+ "description": "Run phase research before planning when research artifacts are missing or explicitly refreshed."
+ },
"workflow.ui_phase": {
"owner": "ui",
"type": "boolean",
@@ -2155,6 +2365,9 @@ const commandFamilies = {
};
const capabilityClusters = {
+ "ai-integration": [
+ "ai-integration-phase"
+ ],
"graphify": [
"graphify"
],
@@ -2165,6 +2378,12 @@ const capabilityClusters = {
};
const profileMembership = {
+ "ai-integration": {
+ "tier": "full",
+ "profiles": [
+ "full"
+ ]
+ },
"graphify": {
"tier": "full",
"profiles": [
@@ -2180,6 +2399,7 @@ const profileMembership = {
};
const _requiresGraph = {
+ "ai-integration": [],
"antigravity": [],
"audit": [],
"augment": [],
@@ -2196,7 +2416,11 @@ const _requiresGraph = {
"kilo": [],
"kimi": [],
"opencode": [],
+ "pattern-mapper": [
+ "research"
+ ],
"qwen": [],
+ "research": [],
"trae": [],
"ui": [],
"windsurf": []
diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md
index 5713ac7d7..a8bdf0b5e 100644
--- a/gsd-core/workflows/plan-phase.md
+++ b/gsd-core/workflows/plan-phase.md
@@ -411,45 +411,20 @@ If "Run discuss-phase first":
```
**Exit the plan-phase workflow. Do not continue.**
-## 4.5. Check AI-SPEC
+## 4.5. Resolve AI-SPEC Artifact
-**Skip if:** `ai_integration_phase_enabled` from config is false, or `--skip-ai-spec` flag provided.
+AI integration activation is owned by the `ai-integration` capability's `plan:pre` step hook. The plan-phase host only discovers existing artifacts here so the planner can consume them; it must not read the capability's config key directly.
```bash
AI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-AI-SPEC.md 2>/dev/null | head -1)
-AI_PHASE_CFG=$(gsd_run query config-get workflow.ai_integration_phase 2>/dev/null || echo "true")
+AI_SPEC_PATH="${AI_SPEC_FILE}"
+FRAMEWORK_LINE=""
+if [ -n "$AI_SPEC_FILE" ]; then
+ FRAMEWORK_LINE=$(grep "Selected Framework:" "${AI_SPEC_FILE}" | head -1)
+fi
```
-**Skip if `AI_PHASE_CFG` is `false`.**
-
-**If `AI_SPEC_FILE` is empty:** Check phase goal for AI keywords:
-```bash
-echo "${phase_goal}" | grep -qi "agent\|llm\|rag\|chatbot\|embedding\|langchain\|llamaindex\|crewai\|langgraph\|openai\|anthropic\|vector\|eval\|ai system"
-```
-
-**If AI keywords detected AND no AI-SPEC.md:**
-```
-◆ Note: This phase appears to involve AI system development.
- Consider running /gsd:ai-integration-phase {N} before planning to:
- - Select the right framework for your use case
- - Research its docs and best practices
- - Design an evaluation strategy
-
- Continue planning without AI-SPEC? (non-blocking — /gsd:ai-integration-phase can be run after)
-```
-
-Use AskUserQuestion with options:
-- "Continue — plan without AI-SPEC"
-- "Stop — I'll run /gsd:ai-integration-phase {N} first"
-
-If "Stop": Exit with `/gsd:ai-integration-phase {N}` reminder.
-If "Continue": Proceed. (Non-blocking — planner will note AI-SPEC is absent.)
-
-**If `AI_SPEC_FILE` is non-empty:** Extract framework for planner context:
-```bash
-FRAMEWORK_LINE=$(grep "Selected Framework:" "${AI_SPEC_FILE}" | head -1)
-```
-Pass `ai_spec_path` and `framework_line` to planner in step 7 so it can reference the AI design contract.
+If `AI_SPEC_FILE` is non-empty, pass `AI_SPEC_PATH` and `FRAMEWORK_LINE` to the planner in step 8 so it can reference the AI design contract. If it is empty, the active `ai-integration` capability hook in step 5.6 handles any AI-system nudge or `/gsd:ai-integration-phase` dispatch.
## 5. Handle Research
@@ -525,41 +500,21 @@ Display banner:
```bash
PHASE_DESC=$(gsd_run query roadmap.get-phase "${PHASE}" --pick section)
+if [ -z "${PLAN_PRE_HOOKS_JSON:-}" ]; then
+ PLAN_PRE_HOOKS_JSON=$(gsd_run loop render-hooks plan:pre --raw)
+fi
```
-Research prompt:
+Find the active `research` step hook in `PLAN_PRE_HOOKS_JSON`. Use the hook's `fragment.inline` as the prompt template and substitute the phase fields below before spawning its declared `ref.agent`.
```markdown
-
-Research how to implement Phase {phase_number}: {phase_name}
-Answer: "What do I need to know to PLAN this phase well?"
-
-
-
-- {context_path} (USER DECISIONS from /gsd:discuss-phase)
-- {requirements_path} (Project requirements)
-- {state_path} (Project decisions and history)
-
-
-${AGENT_SKILLS_RESEARCHER}
-
-
-**Phase description:** {phase_description}
-**Phase requirement IDs (MUST address):** {phase_req_ids}
-
-**Project instructions:** Read ./CLAUDE.md or ./.claude/CLAUDE.md if either exists — follow project-specific guidelines
-**Project skills:** Check .claude/skills/ or .agents/skills/ directory (if either exists) — read SKILL.md files, research should account for project skill patterns
-
-
-
+{research_hook.fragment.inline}
```
```
Agent(
- prompt=research_prompt,
- subagent_type="gsd-phase-researcher",
+ prompt=filled_research_hook_fragment,
+ subagent_type=research_hook.ref.agent,
model="{researcher_model}",
description="Research Phase {phase}"
)
@@ -646,21 +601,31 @@ Opt out: set security_enforcement: false in .planning/config.json
Continue to step 5.6. Security config is passed to the planner in step 8.
-## 5.6. UI Design Contract Gate
+## 5.6. Plan:Pre Capability Dispatch and UI Design Contract Gate
-> Capability-driven dispatch. Resolves active `plan:pre` hooks via the capability registry; each hook's `when` condition (`workflow.ui_phase` for step hooks, `workflow.ui_safety_gate` for gate hooks) is evaluated by the registry — no inline config-get needed.
+> Capability-driven dispatch. Resolves active `plan:pre` hooks via the capability registry; each hook's `when` condition is evaluated by the registry — no inline config-get needed. This section handles skill-based planning preflights such as `ai-integration`, agent-backed hooks through `ref.agent`, and the UI gate whose deterministic check comes from `check.query`.
>
> **Config semantics (cutover fix):** `workflow.ui_phase` gates UI-SPEC *generation* (step); `workflow.ui_safety_gate` gates the *planning block* (gate). Both-on = identical to OLD §5.6. Intended change: `{ui_phase:true, ui_safety_gate:false}` now auto-generates in pipelines but does NOT block manual planning (each key controls exactly what its description says).
```bash
-HOOKS_JSON=$(gsd_run loop render-hooks plan:pre --raw)
+PLAN_PRE_HOOKS_JSON=${PLAN_PRE_HOOKS_JSON:-$(gsd_run loop render-hooks plan:pre --raw)}
+HOOKS_JSON="$PLAN_PRE_HOOKS_JSON"
```
-Read the `activeHooks` array directly from `HOOKS_JSON` (in-context — do NOT invoke a shell pipeline).
+Read the `activeHooks` array directly from `PLAN_PRE_HOOKS_JSON` / `HOOKS_JSON` (in-context — do NOT invoke a shell pipeline).
-**Branch 1 — both toggles off (`activeHooks` is empty or absent):** Skip to step 6.
+**Branch 1 — all plan:pre hooks inactive (`activeHooks` is empty or absent):** Skip to step 6.
-Run whenever **any** `plan:pre` UI hook is active — including the step-only case (`workflow.ui_safety_gate` off). (`check.query` = `"ui.plan-gate"`; router normalizes dots→hyphens.)
+**Generic step hook dispatch contract:** For each active entry where `kind == "step"`:
+- If `ref.skill` is set, dispatch with `Skill(skill="gsd-${ref.skill}", args="${PHASE} --auto ${GSD_WS}")` when pipeline mode allows auto-chaining. Prepend `gsd-` to `ref.skill` — `ui-phase` → `gsd-ui-phase`.
+- If `ref.agent` is set, dispatch with `Agent(prompt=filled_hook_fragment, subagent_type=ref.agent, model="{researcher_model}")`. Use the hook's `fragment.inline` as the prompt body and fill phase fields before spawning.
+- The `research` hook is handled by §5.1's research decision. The `pattern-mapper` hook is handled by §7.8 after `RESEARCH_PATH` is known. Future plan:pre agent hooks use the same `ref.agent` fragment contract.
+
+**AI integration capability:** If the active `ai-integration` step hook is present, `AI_SPEC_PATH` is empty, and the phase goal contains AI keywords (`agent`, `llm`, `rag`, `chatbot`, `embedding`, `langchain`, `llamaindex`, `crewai`, `langgraph`, `openai`, `anthropic`, `vector`, `eval`, `ai system`), then:
+- In pipeline / `--auto` mode, invoke the hook's `ref.skill` via `Skill(skill="gsd-${ref.skill}", args="${PHASE} --auto ${GSD_WS}")`.
+- In manual mode, display the existing non-blocking `/gsd:ai-integration-phase {N}` recommendation and let the user continue planning without AI-SPEC or stop to run the capability workflow first.
+
+Run the UI deterministic gate whenever **any** `plan:pre` UI hook is active — including the step-only case (`workflow.ui_safety_gate` off). (`check.query` = `"ui.plan-gate"`; router normalizes dots→hyphens.)
```bash
GATE=$(gsd_run check ui-plan-gate "${PHASE}" --raw)
@@ -689,13 +654,13 @@ Read the ephemeral auto-chain flag:
AUTO_CHAIN=$(gsd_run query check auto-mode --pick auto_chain_active 2>/dev/null || echo "false")
```
-**Branch 5 — `AUTO_CHAIN` is `true` (pipeline / `--auto`):** Fire each active **step** hook — runs independently of whether a gate is active (covers `{ui_phase:true, ui_safety_gate:false}`). For each entry in `activeHooks` (in array order) where `kind == "step"` and `ref.skill` is set:
+**Branch 5 — `AUTO_CHAIN` is `true` (pipeline / `--auto`):** Fire each active UI **step** hook — runs independently of whether a gate is active (covers `{ui_phase:true,ui_safety_gate:false}`). For each entry in `activeHooks` (in array order) where `kind == "step"` and `ref.skill` is set:
```
Skill(skill="gsd-${ref.skill}", args="${PHASE} --auto ${GSD_WS}")
```
-(prepend `gsd-` to `ref.skill` — `ui-phase` → `gsd-ui-phase`.) After all step hooks return, re-read:
+After all UI step hooks return, re-read:
```bash
UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1)
@@ -845,14 +810,7 @@ Proceed to Step 7.8 (or Step 8 if pattern mapper is disabled) only if user selec
## 7.8. Spawn gsd-pattern-mapper Agent (Optional)
-**Skip if** `workflow.pattern_mapper` is explicitly set to `false` in config.json (absent key = enabled). Also skip if no CONTEXT.md and no RESEARCH.md exist for this phase (nothing to extract file lists from).
-
-Check config:
-```bash
-PATTERN_MAPPER_CFG=$(gsd_run query config-get workflow.pattern_mapper 2>/dev/null || echo "true")
-```
-
-**If `PATTERN_MAPPER_CFG` is `false`:** Skip to step 8.
+Pattern mapper activation is owned by the `pattern-mapper` capability's `plan:pre` step hook. Read `PLAN_PRE_HOOKS_JSON` and skip if no active step hook has `capId == "pattern-mapper"` and `ref.agent == "gsd-pattern-mapper"`. Also skip if no CONTEXT.md and no RESEARCH.md exist for this phase (nothing to extract file lists from).
**If PATTERNS.md already exists** (`PATTERNS_PATH` is non-empty from step 7): Skip to step 8 (use existing).
@@ -865,30 +823,17 @@ Display banner:
◆ Spawning pattern mapper... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)
```
-Pattern mapper prompt:
+Use the active `pattern-mapper` hook's `fragment.inline` as the prompt template and substitute the phase fields below before spawning its declared `ref.agent`.
```markdown
-
-**Phase:** {phase_number} - {phase_name}
-**Phase directory:** {phase_dir}
-**Padded phase:** {padded_phase}
-
-
-- {context_path} (USER DECISIONS from /gsd:discuss-phase)
-- {research_path} (Technical Research)
-
-
-**Output file:** {phase_dir}/{padded_phase}-PATTERNS.md
-
-Extract the list of files to be created/modified from CONTEXT.md and RESEARCH.md. For each file, classify by role and data flow, find the closest existing analog in the codebase, extract concrete code excerpts, and produce PATTERNS.md.
-
+{pattern_mapper_hook.fragment.inline}
```
Spawn with:
```
Agent(
- prompt="{above}",
- subagent_type="gsd-pattern-mapper",
+ prompt=filled_pattern_mapper_hook_fragment,
+ subagent_type=pattern_mapper_hook.ref.agent,
model="{researcher_model}",
)
```
@@ -944,6 +889,7 @@ Planner prompt:
- {verification_path} (Verification Gaps - if --gaps)
- {uat_path} (UAT Gaps - if --gaps)
- {reviews_path} (Cross-AI Review Feedback - if --reviews; actionable findings must be incorporated or explicitly deferred/rejected in PLAN.md)
+- {AI_SPEC_PATH} (AI Design Contract — framework and evaluation strategy, if exists)
- {UI_SPEC_PATH} (UI Design Contract — visual/interaction specs, if exists)
- {SPEC_PATH} (Phase SPEC — carries the ## Edge Coverage section to lift covered/backstop edges from, if exists)
- {SPIKE_FINDINGS_PATH} (Spike Findings — validated patterns, constraints, landmines from experiments, if exists)
diff --git a/scripts/gen-capability-registry.cjs b/scripts/gen-capability-registry.cjs
index c36490a21..1795dfd2a 100644
--- a/scripts/gen-capability-registry.cjs
+++ b/scripts/gen-capability-registry.cjs
@@ -873,6 +873,78 @@ function validateRuntimeBody(cap) {
return errors;
}
+function materializeHookFragments(cap, capDir) {
+ const errors = [];
+ const hookGroups = [
+ ['steps', Array.isArray(cap.steps) ? cap.steps : []],
+ ['contributions', Array.isArray(cap.contributions) ? cap.contributions : []],
+ ];
+
+ for (const [groupName, hooks] of hookGroups) {
+ for (let i = 0; i < hooks.length; i++) {
+ const hook = hooks[i];
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)) continue;
+ const fragment = hook.fragment;
+ if (!fragment || typeof fragment !== 'object' || Array.isArray(fragment)) continue;
+ if (typeof fragment.inline === 'string') continue;
+ if (typeof fragment.path !== 'string') continue;
+
+ const abs = path.resolve(capDir, fragment.path);
+ const capRoot = path.resolve(capDir);
+ if (abs !== capRoot && !abs.startsWith(capRoot + path.sep)) {
+ errors.push(
+ cap.id + '/' + groupName + '[' + i + '].fragment.path escapes capability directory: ' +
+ fragment.path,
+ );
+ continue;
+ }
+
+ try {
+ fragment.inline = fs.readFileSync(abs, 'utf8');
+ } catch (err) {
+ errors.push(
+ cap.id + '/' + groupName + '[' + i + '].fragment.path could not be read: ' +
+ fragment.path + ' (' + err.message + ')',
+ );
+ }
+ }
+ }
+
+ return errors;
+}
+
+function validateFragment(fragment, prefix) {
+ const errors = [];
+
+ if (typeof fragment !== 'object' || fragment === null || Array.isArray(fragment)) {
+ errors.push(prefix + ' must be an object with path or inline key');
+ return errors;
+ }
+
+ const hasPath = Object.prototype.hasOwnProperty.call(fragment, 'path');
+ const hasInline = Object.prototype.hasOwnProperty.call(fragment, 'inline');
+ if (!hasPath && !hasInline) {
+ errors.push(prefix + ' must have a "path" or "inline" key');
+ }
+ if (hasInline) {
+ const inline = fragment.inline;
+ if (typeof inline !== 'string') {
+ errors.push(prefix + '.inline must be a string');
+ } else if (inline === '') {
+ errors.push(prefix + '.inline must be a non-empty string');
+ }
+ }
+ // S1: fragment.path traversal guard — must be a relative path with no ".." segments
+ if (hasPath) {
+ const p = fragment.path;
+ if (typeof p !== 'string' || p === '' || path.isAbsolute(p) || p.split(/[\\/]/).includes('..')) {
+ errors.push(prefix + '.path must be a relative path with no ".." segments');
+ }
+ }
+
+ return errors;
+}
+
/**
* Validate a single step entry.
*
@@ -952,6 +1024,10 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
errors.push(prefix + '.when must be a string if present');
}
+ if (step.fragment !== undefined) {
+ errors.push(...validateFragment(step.fragment, prefix + '.fragment'));
+ }
+
if (!VALID_ON_ERROR.has(step.onError)) {
errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + step.onError + ')');
}
@@ -986,30 +1062,7 @@ function validateContribution(contrib, prefix) {
}
}
- if (typeof contrib.fragment !== 'object' || contrib.fragment === null) {
- errors.push(prefix + '.fragment must be an object with path or inline key');
- } else {
- const hasPath = Object.prototype.hasOwnProperty.call(contrib.fragment, 'path');
- const hasInline = Object.prototype.hasOwnProperty.call(contrib.fragment, 'inline');
- if (!hasPath && !hasInline) {
- errors.push(prefix + '.fragment must have a "path" or "inline" key');
- }
- if (hasInline) {
- const inline = contrib.fragment.inline;
- if (typeof inline !== 'string') {
- errors.push(prefix + '.fragment.inline must be a string');
- } else if (inline === '') {
- errors.push(prefix + '.fragment.inline must be a non-empty string');
- }
- }
- // S1: fragment.path traversal guard — must be a relative path with no ".." segments
- if (hasPath) {
- const p = contrib.fragment.path;
- if (typeof p !== 'string' || p === '' || path.isAbsolute(p) || p.split(/[\\/]/).includes('..')) {
- errors.push(prefix + '.fragment.path must be a relative path with no ".." segments');
- }
- }
- }
+ errors.push(...validateFragment(contrib.fragment, prefix + '.fragment'));
if (contrib.when !== undefined && typeof contrib.when !== 'string') {
errors.push(prefix + '.when must be a string if present');
@@ -1856,6 +1909,12 @@ function loadAndValidate(centralKeys, capabilitiesDir) {
continue;
}
+ const fragmentErrors = materializeHookFragments(cap, path.dirname(capPath));
+ if (fragmentErrors.length > 0) {
+ for (const e of fragmentErrors) errors.push(folderId + '/capability.json: ' + e);
+ continue;
+ }
+
capMap.set(cap.id, cap);
}
diff --git a/src/loop-resolver.cts b/src/loop-resolver.cts
index 6087698b9..0f292f572 100644
--- a/src/loop-resolver.cts
+++ b/src/loop-resolver.cts
@@ -223,6 +223,7 @@ function _resolveActivationValue(
interface HookRef {
skill?: string;
+ agent?: string;
[key: string]: unknown;
}
@@ -347,11 +348,13 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
? (hook['ref'] as HookRef)
: undefined;
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
+ const fragment = toFragment(hook['fragment']);
const produces = toStringArray(hook['produces']);
const consumes = toStringArray(hook['consumes']);
const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined;
const active: ActiveHook = { capId, kind: 'step' };
if (ref !== undefined) active.ref = ref;
+ if (fragment !== undefined) active.fragment = fragment;
if (when !== undefined) active.when = when;
if (produces.length > 0) active.produces = produces;
if (consumes.length > 0) active.consumes = consumes;
@@ -429,7 +432,9 @@ function renderLoopHooks(resolved: ResolveLoopHooksResult): string {
stepOrdinal += 1;
const refStr = hook.ref?.skill
? `skill:${hook.ref.skill}`
- : JSON.stringify(hook.ref ?? {});
+ : hook.ref?.agent
+ ? `agent:${hook.ref.agent}`
+ : JSON.stringify(hook.ref ?? {});
lines.push(`### Step ${stepOrdinal}: ${refStr} (${hook.capId})`);
if (hook.produces && hook.produces.length > 0) {
lines.push(`- produces: ${hook.produces.join(', ')}`);
@@ -443,6 +448,13 @@ function renderLoopHooks(resolved: ResolveLoopHooksResult): string {
if (hook.onError) {
lines.push(`- onError: ${hook.onError}`);
}
+ if (hook.fragment?.inline) {
+ lines.push('');
+ lines.push(hook.fragment.inline);
+ } else if (hook.fragment?.path) {
+ lines.push('');
+ lines.push(`_Step fragment path is declared but not rendered by loop-resolver: ${hook.fragment.path}_`);
+ }
lines.push('');
} else if (hook.kind === 'contribution') {
lines.push(``);
diff --git a/tests/ai-evals.test.cjs b/tests/ai-evals.test.cjs
index da46d87dd..1673f362b 100644
--- a/tests/ai-evals.test.cjs
+++ b/tests/ai-evals.test.cjs
@@ -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/);
});
});
diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs
index 607aca6d5..29a4b6895 100644
--- a/tests/capability-registry.test.cjs
+++ b/tests/capability-registry.test.cjs
@@ -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) ────────────────────────────────────
diff --git a/tests/edge-probe-planner-contract.test.cjs b/tests/edge-probe-planner-contract.test.cjs
index 592ee9c22..59bf36094 100644
--- a/tests/edge-probe-planner-contract.test.cjs
+++ b/tests/edge-probe-planner-contract.test.cjs
@@ -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');
});
diff --git a/tests/loop-hook-firing-spike.test.cjs b/tests/loop-hook-firing-spike.test.cjs
index 6768a9de8..59a40a900 100644
--- a/tests/loop-hook-firing-spike.test.cjs
+++ b/tests/loop-hook-firing-spike.test.cjs
@@ -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');
});
diff --git a/tests/loop-render-hooks.test.cjs b/tests/loop-render-hooks.test.cjs
index 959497538..f3bffad52 100644
--- a/tests/loop-render-hooks.test.cjs
+++ b/tests/loop-render-hooks.test.cjs
@@ -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',
diff --git a/tests/phase6-capability-docs.test.cjs b/tests/phase6-capability-docs.test.cjs
new file mode 100644
index 000000000..44f7f280b
--- /dev/null
+++ b/tests/phase6-capability-docs.test.cjs
@@ -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\/\/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/);
+ });
+});
diff --git a/tests/phase6-planning-capabilities.test.cjs b/tests/phase6-planning-capabilities.test.cjs
new file mode 100644
index 000000000..1ab15e4cb
--- /dev/null
+++ b/tests/phase6-planning-capabilities.test.cjs
@@ -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/);
+ });
+});
diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json
index c5e229977..9e8790ea7 100644
--- a/tests/workflow-size-baseline.json
+++ b/tests/workflow-size-baseline.json
@@ -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,