
Sdd Derive
- 10 installs
- 5 repo stars
- Updated August 1, 2026
- ahgraber/skills
Derives spec-driven-development specs from existing code or retroactively documents implemented behavior, dispatching subagents for scoped spec generation.
About
An SDD skill that orchestrates spec generation from existing code, producing either a change directory for new behavior or retroactive baseline specs. A developer uses it to spec out or document code that is already built.
- Handles both change-directory derivation and retroactive baseline documentation
- Orchestrator holds context while dispatching subagents for scoped work
Sdd Derive by the numbers
- 10 all-time installs (skills.sh)
- Ranked #1,137 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ahgraber/skills --skill sdd-deriveAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 10 |
|---|---|
| repo stars | ★ 5 |
| Last updated | August 1, 2026 |
| Repository | ahgraber/skills ↗ |
What it does
Derives spec-driven-development specs from existing code or retroactively documents implemented behavior, dispatching subagents for scoped spec generation.
Files
SDD Derive
Orchestrate spec generation from existing code: a change directory (new or modified behavior) or baseline specs (retroactive documentation).
The orchestrator holds context across phases; subagents are dispatched for scoped work that benefits from context isolation.
SPECS_ROOTis resolved by thesddrouter before this skill runs.
Replace .specs/ with the project's actual specs root.Invocation Notice
- Inform the user when this skill is being invoked by name:
sdd-derive.
When to Use
- Deriving specs from existing code ("derive SDD specs for the auth flow")
- Documenting implemented behavior retroactively into baseline specs
- Producing a change directory for behavior already implemented
- User phrases: "derive specs", "generate specs from code", "retrofit specs", "document this code in SDD"
When Not to Use
- Translating specs from another tool or format — use
sdd-translate - No codebase and no existing behavior to anchor against — use
sdd-propose - Exploring a problem before deciding what to spec — use
sdd-explore
Determine Output Type
Baseline specs (.specs/specs/) document implemented behavior. Write baseline specs only when existing code anchors them; otherwise generate a change directory.
digraph output_type {
".specs/specs/ exists?" [shape=diamond];
"Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)" [shape=diamond];
"New or existing behavior?" [shape=diamond];
"Generate change directory\n(.specs/changes/<name>/)\nADDED-only delta specs" [shape=box];
"Generate change directory\n(.specs/changes/<name>/)" [shape=box];
"Generate baseline specs\n(.specs/specs/)" [shape=box];
".specs/specs/ exists?" -> "Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)" [label="no"];
"Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)" -> "Generate baseline specs\n(.specs/specs/)" [label="yes — retroactive doc"];
"Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)" -> "Generate change directory\n(.specs/changes/<name>/)\nADDED-only delta specs" [label="no — greenfield"];
".specs/specs/ exists?" -> "New or existing behavior?" [label="yes"];
"New or existing behavior?" -> "Generate change directory\n(.specs/changes/<name>/)" [label="new or modified"];
"New or existing behavior?" -> "Generate baseline specs\n(.specs/specs/)" [label="retroactive doc"];
}Greenfield check: when .specs/specs/ does not exist, run discovery (Phase 2) before deciding. If discovery finds no relevant implementation, generate a change directory with ADDED-only delta specs — never assert behavior in .specs/specs/ that has not been built.
Workflow
Checklist
- [ ] Phase 1: Understand User Intent
- [ ] Phase 2: Discovery (explore + synthesize)
- [ ] Phase 3: Pre-flight Consent
- [ ] Phase 4: Per-Capability Derive (Observe + Lift)
- [ ] Phase 5: Validate
- [ ] Phase 6: Generate Output
Subagent Protocol
Persist before returning. Every subagent MUST write its output to disk unconditionally.
Resolve paths before dispatch. The orchestrator MUST expand $TMPDIR and other shell variables in the prompt; subagents do not consistently expand them, and literal $TMPDIR produces silent write failures.
Verify writes inline. Every subagent prompt MUST require ls -la <path> after writing and report byte count. The orchestrator confirms the file exists before treating dispatch as successful.
Dispatch by reference, not inline duplication. Role discipline lives in references/<role>.md. Resolve the skill's install path (e.g., ~/.claude/skills/sdd-derive/, .claude/skills/sdd-derive/, or a plugin directory) and substitute it into the template. Subagents inherit Read permissions from the parent.
Read `<resolved_skill_root>/references/<role>.md` and follow it as your job description.
Below is your specific scope.
Capability: `<name>` \
Files in scope: `<list>` \
External-surface candidates: `<list>` \
Output path: `<literal absolute path>` \
After writing, run `ls -la <path>` and report byte count.Return decision-relevant data, not artifact content. Full artifacts live on disk; the orchestrator reads the file when it needs detail. The synthesizer is the exception — its capability menu is structured, bounded, and required for Phase 3.
| Subagent | Return inline |
|---|---|
| Explorer | path written, byte count, technique, finding count, anomalies |
| Synthesizer | full capability menu (Phase 3 needs it), path written, byte count |
| Observer | path written, byte count, observation count, surface item count, anomalies |
| Lifter | path written, byte count, requirement count, scenario count, uncertainty count, anomalies |
Track a manifest, not content. Decide what runs, track completed paths + counts + status, detect failures, trigger the next phase. Never re-ingest all artifacts at once.
digraph subagent_dispatch {
need [label="Phase needs subagent", shape=ellipse];
resolve [label="Resolve absolute paths\n($TMPDIR, skill_root)", shape=box];
compose [label="Compose prompt:\nread references/<role>.md\n+ scope + literal output path", shape=box];
dispatch [label="Dispatch subagent", shape=box];
work [label="Subagent reads role doc,\ndoes scoped work,\nwrites artifact to disk", shape=box];
verify_cmd [label="ls -la <output_path>", shape=plaintext];
report [label="Subagent reports path + bytes\n+ counts inline", shape=box];
check [label="File exists and non-empty?", shape=diamond];
manifest [label="Update manifest:\npath + counts + status=ok", shape=box];
fail [label="Mark dispatch failed;\nre-dispatch or escalate", shape=octagon, style=filled, fillcolor=pink];
next [label="Trigger next phase", shape=doublecircle];
need -> resolve;
resolve -> compose;
compose -> dispatch;
dispatch -> work;
work -> verify_cmd;
verify_cmd -> report;
report -> check;
check -> manifest [label="yes"];
check -> fail [label="no"];
manifest -> next;
}Phase 1: Understand User Intent
Extract from the request:
- Which capability(s) does this touch?
(auth, payments, UI, etc.)
- What behavior is being specified?
(new, modified, retroactive)
- In scope vs. out of scope?
Ask one targeted question if truly ambiguous.
Don't speculate on large surface areas — confirm scope before generating anything.
Phase 2: Discovery
Two sequential calls:
1. `discovery-explore` — fan out parallel explorers, one per technique (call graph, naive AST, data-flow / channel, port/interface, schema artifacts, test-suite). Each emits structured findings. 2. `discovery-synthesize` — single synthesizer consumes all explorer outputs and produces a capability menu (candidates with file scope and cost, overlaps, external-surface candidates, axis disagreements, gotchas).
See references/discovery.md for the explorer schema, synthesizer expectations, and loop semantics.
One-time tooling suggestions. Check .specs/.sdd/suggested-tools and present each suggestion below at most once (append the marker after presenting; create the file/dir if needed). If declined or already listed, skip and proceed with reduced fidelity.
`code-review-graph` (first run only):
CLI that builds a structural AST graph of the codebase, improving call-graph discovery with communities, bridge nodes, and impact-radius signals. Install: uv tool install code-review-graph. Without it, the call-graph explorer falls back to naive AST traversal. Say "skip" to dismiss.>
`schema-config` (first run only when schemas detected):
Detected schema artifacts:<files>. Create.specs/.sdd/schema-config.yamlto configure schema extraction commands — enables snapshot generation, drift detection, and authored-vs-generated diffs. Seereferences/sdd-schema.md§ 3 for the format. Without it, the schema-artifact explorer runs detection-only. Say "skip" to dismiss.
Phase 3: Pre-flight Consent
Users typically lack architectural ground-truth for overlap-ownership and external-surface classification. The orchestrator commits sensible defaults silently and only escalates on flagged conditions.
Defaults (applied silently unless escalation fires):
- Pre-select all candidates with
confidence >= medium. - Overlap primary-owner: capability with most edges to the bridge wins; ties broken alphabetically.
- External-surface owned vs 3rd-party: take each candidate's classification when explorer confidence ≥ medium.
Escalation conditions (orchestrator MUST prompt):
- Single-cluster degeneracy flagged by the synthesizer.
- Axis disagreements between explorers on capability boundaries.
- Universally low confidence (< medium across the menu).
- Cost threshold exceeded — capability count > 6 OR file count > 100.
- External-surface classification has medium-vs-high split.
When no escalation fires, present a brief summary (capabilities + counts + cost) and proceed. When any fires, present only the flagged item and ask one targeted question.
Loop semantics: if the user requests refinement, clarify the request unless unambiguous. Refinement options:
- Re-run synthesizer alone — cheap; preferred for "treat A and B as one"
- Re-run a specific explorer with adjusted scope — medium; for "ignore vendor/"
- Re-run all explorers — expensive; only for substantive scope changes
Phase 4: Per-Capability Derive
For each selected capability, dispatch sequentially:
1. Observer — reads the capability's file scope. Emits observations list (behavior-grain, with code references, evidence-class tags, confidence) and surface inventory. See references/observer.md. 2. Lifter — reads observations + surface inventory + capability metadata. Has bounded source access for verification only. Emits lifted contracts and spec content (delta or baseline format), plus optional ## Uncertainties section. See references/lifter.md.
Capabilities run in parallel across each other; observer/lifter is sequential within a capability.
digraph phase_4_per_capability {
selected [label="Capability selected\nfrom menu", shape=ellipse];
obs_dispatch [label="Dispatch observer\n(file scope, surface candidates,\noutput path)", shape=box];
obs_out [label="Observer writes observations.yaml\n(behaviors + tags + surface inventory)", shape=box];
lift_dispatch [label="Dispatch lifter\n(observations path, metadata,\noutput type, as-of anchor)", shape=box];
lift_work [label="Lifter consumes observations\n+ verifies source as needed", shape=box];
lift_out [label="Lifter writes spec.md", shape=box];
validate [label="uv run validate.py --single\n<observations> <spec>", shape=plaintext];
pass [label="PASS?", shape=diamond];
fix [label="Lifter fixes failures in spec", shape=box];
done [label="Capability complete:\nreturn path + counts", shape=doublecircle];
selected -> obs_dispatch;
obs_dispatch -> obs_out;
obs_out -> lift_dispatch;
lift_dispatch -> lift_work;
lift_work -> lift_out;
lift_out -> validate;
validate -> pass;
pass -> done [label="yes"];
pass -> fix [label="no"];
fix -> validate;
}Phase 5: Validate
Validation runs in two places.
Per-capability — each lifter runs the validator before returning:
uv run --quiet <skill_root>/references/validate.py --single <observations.yaml> <spec.md>If FAIL, the lifter fixes and re-runs until PASS — catching format drift at write time avoids round-tripping a corrective dispatch. See references/lifter.md § Self-check.
Aggregate — the orchestrator runs across the whole run:
uv run --quiet <skill_root>/references/validate.py <observations_dir> <specs_dir>The aggregate report covers format, YAML parse, kind-aware surface coverage diff, and uncertainty review. If lifters did their self-check, format/YAML failures should be zero — failures here indicate a skipped self-check. Surface gaps and uncertainty totals are the substantive output.
If a spec fails format at this stage, dispatch a corrective lifter pass with the failures cited. If an observation YAML fails parse, dispatch a corrective observer pass. See references/validate.md for severity rules and report format.
Phase 6: Generate Output
Write the spec artifacts. Add a generation note at the top:
Generated from code analysis on {date}, as-of commit {sha}
Clear ephemeral observations once specs are written. The commit SHA is the canonical anchor for re-derivation.
See references/derive-spec-additions.md for the ## Uncertainties section format and as-of anchor placement.
Output
Change directory (new/modified behavior):
.specs/changes/<name>/proposal.md.specs/changes/<name>/specs/<capability>/spec.md(delta format).specs/changes/<name>/tasks.md(when applicable)
Baseline specs (retroactive):
.specs/specs/<capability>/spec.mdper capability
sdd-derive produces a partial change directory — no design.md. Use sdd-propose for a full artifact set.
Report after generation: capabilities covered, requirement count, uncertainties count, surface coverage gaps.
Common Mistakes
- Skipping the lift step — writing requirements directly from observations.
The lifter translates "what code does" to "what property the code maintains" per references/evidence-class-taxonomy.md.
- Promoting an algorithm to a contract — when
algorithmicis set, apply the strategy check and emit an Uncertainty. - One massive spec for a large surface — discovery's capability menu is the decomposition; respect it.
- Lifter exploring instead of verifying — source access is reactive, not proactive.
See references/lifter.md § Verification Discipline.
- Wrong format for the output type — delta in
.specs/specs/, or baseline in a change dir. - Baseline specs in greenfield —
.specs/specs/asserts implemented behavior; if nothing is built, use a change directory with ADDED-only delta specs. - Mid-run truncation — if a capability is too large to observe in one pass, split before dispatch (Phase 3).
Never silently truncate.
References
references/discovery.md— explorer schema, synthesizer expectations, loop semanticsreferences/evidence-class-taxonomy.md— tag definitions and composition rulesreferences/observer.md— observation entry shape, surface inventory, observer promptreferences/lifter.md— lift rules per tag, verification discipline, lifter promptreferences/validate.md— surface coverage diff, Phase 7 checklistreferences/derive-spec-additions.md—## Uncertaintiesand as-of anchor (derive-specific)references/sdd-spec-formats.md— baseline, delta, scenario formats (shared)references/sdd-change-formats.md— proposal, design, tasks formats (shared)references/sdd-schema.md— schema artifacts and lifecycle (shared)references/sdd-derive-output-type.dot— DOT source for the output type decisionreferences/sdd-derive-subagent-dispatch.dot— DOT source for the subagent dispatch lifecyclereferences/sdd-derive-phase-2-discovery.dot— DOT source for the Phase 2 explore/synthesize/refinement loopreferences/sdd-derive-phase-4-per-capability.dot— DOT source for the Phase 4 observer/lifter/self-validate flow
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.5 (20260411.2331)
-->
<!-- Title: output_type Pages: 1 -->
<svg width="726pt" height="322pt"
viewBox="0.00 0.00 726.00 322.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 317.5)">
<title>output_type</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-317.5 721.66,-317.5 721.66,4 -4,4"/>
<!-- .specs/specs/ exists? -->
<g id="node1" class="node">
<title>.specs/specs/ exists?</title>
<polygon fill="none" stroke="black" points="378.25,-313.5 256.41,-295.5 378.25,-277.5 500.09,-295.5 378.25,-313.5"/>
<text xml:space="preserve" text-anchor="middle" x="378.25" y="-290.45" font-family="Times,serif" font-size="14.00">.specs/specs/ exists?</text>
</g>
<!-- Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery) -->
<g id="node2" class="node">
<title>Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)</title>
<polygon fill="none" stroke="black" points="195.25,-225 0,-167.5 195.25,-110 390.5,-167.5 195.25,-225"/>
<text xml:space="preserve" text-anchor="middle" x="195.25" y="-178.95" font-family="Times,serif" font-size="14.00">Codebase has relevant</text>
<text xml:space="preserve" text-anchor="middle" x="195.25" y="-162.45" font-family="Times,serif" font-size="14.00">implementation?</text>
<text xml:space="preserve" text-anchor="middle" x="195.25" y="-145.95" font-family="Times,serif" font-size="14.00">(answered by Phase 2 discovery)</text>
</g>
<!-- .specs/specs/ exists?->Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery) -->
<g id="edge1" class="edge">
<title>.specs/specs/ exists?->Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)</title>
<path fill="none" stroke="black" d="M357.61,-280.29C334.54,-264.41 295.99,-237.86 262.24,-214.62"/>
<polygon fill="black" stroke="black" points="264.33,-211.82 254.11,-209.03 260.36,-217.58 264.33,-211.82"/>
<text xml:space="preserve" text-anchor="middle" x="330.93" y="-246.2" font-family="Times,serif" font-size="14.00">no</text>
</g>
<!-- New or existing behavior? -->
<g id="node3" class="node">
<title>New or existing behavior?</title>
<polygon fill="none" stroke="black" points="563.25,-185.5 408.84,-167.5 563.25,-149.5 717.66,-167.5 563.25,-185.5"/>
<text xml:space="preserve" text-anchor="middle" x="563.25" y="-162.45" font-family="Times,serif" font-size="14.00">New or existing behavior?</text>
</g>
<!-- .specs/specs/ exists?->New or existing behavior? -->
<g id="edge4" class="edge">
<title>.specs/specs/ exists?->New or existing behavior?</title>
<path fill="none" stroke="black" d="M399.11,-280.29C431.61,-258.16 494.49,-215.33 532.28,-189.59"/>
<polygon fill="black" stroke="black" points="533.77,-192.81 540.07,-184.29 529.83,-187.02 533.77,-192.81"/>
<text xml:space="preserve" text-anchor="middle" x="457.74" y="-246.2" font-family="Times,serif" font-size="14.00">yes</text>
</g>
<!-- Generate change directory\n(.specs/changes/<name>/)\nADDED-only delta specs -->
<g id="node4" class="node">
<title>Generate change directory\n(.specs/changes/<name>/)\nADDED-only delta specs</title>
<polygon fill="none" stroke="black" points="274.88,-57.5 115.62,-57.5 115.62,0 274.88,0 274.88,-57.5"/>
<text xml:space="preserve" text-anchor="middle" x="195.25" y="-40.2" font-family="Times,serif" font-size="14.00">Generate change directory</text>
<text xml:space="preserve" text-anchor="middle" x="195.25" y="-23.7" font-family="Times,serif" font-size="14.00">(.specs/changes/<name>/)</text>
<text xml:space="preserve" text-anchor="middle" x="195.25" y="-7.2" font-family="Times,serif" font-size="14.00">ADDED-only delta specs</text>
</g>
<!-- Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)->Generate change directory\n(.specs/changes/<name>/)\nADDED-only delta specs -->
<g id="edge3" class="edge">
<title>Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)->Generate change directory\n(.specs/changes/<name>/)\nADDED-only delta specs</title>
<path fill="none" stroke="black" d="M195.25,-109.69C195.25,-95.98 195.25,-81.64 195.25,-68.94"/>
<polygon fill="black" stroke="black" points="198.75,-69.33 195.25,-59.33 191.75,-69.33 198.75,-69.33"/>
<text xml:space="preserve" text-anchor="middle" x="240.25" y="-78.7" font-family="Times,serif" font-size="14.00">no — greenfield</text>
</g>
<!-- Generate baseline specs\n(.specs/specs/) -->
<g id="node6" class="node">
<title>Generate baseline specs\n(.specs/specs/)</title>
<polygon fill="none" stroke="black" points="452.12,-49.25 306.38,-49.25 306.38,-8.25 452.12,-8.25 452.12,-49.25"/>
<text xml:space="preserve" text-anchor="middle" x="379.25" y="-31.95" font-family="Times,serif" font-size="14.00">Generate baseline specs</text>
<text xml:space="preserve" text-anchor="middle" x="379.25" y="-15.45" font-family="Times,serif" font-size="14.00">(.specs/specs/)</text>
</g>
<!-- Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)->Generate baseline specs\n(.specs/specs/) -->
<g id="edge2" class="edge">
<title>Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)->Generate baseline specs\n(.specs/specs/)</title>
<path fill="none" stroke="black" d="M248.36,-125.31C268.9,-109.56 292.67,-91.53 314.5,-75.5 323.14,-69.16 332.51,-62.44 341.37,-56.17"/>
<polygon fill="black" stroke="black" points="343.33,-59.07 349.49,-50.44 339.3,-53.34 343.33,-59.07"/>
<text xml:space="preserve" text-anchor="middle" x="374.88" y="-78.7" font-family="Times,serif" font-size="14.00">yes — retroactive doc</text>
</g>
<!-- Generate change directory\n(.specs/changes/<name>/) -->
<g id="node5" class="node">
<title>Generate change directory\n(.specs/changes/<name>/)</title>
<polygon fill="none" stroke="black" points="642.88,-49.25 483.62,-49.25 483.62,-8.25 642.88,-8.25 642.88,-49.25"/>
<text xml:space="preserve" text-anchor="middle" x="563.25" y="-31.95" font-family="Times,serif" font-size="14.00">Generate change directory</text>
<text xml:space="preserve" text-anchor="middle" x="563.25" y="-15.45" font-family="Times,serif" font-size="14.00">(.specs/changes/<name>/)</text>
</g>
<!-- New or existing behavior?->Generate change directory\n(.specs/changes/<name>/) -->
<g id="edge5" class="edge">
<title>New or existing behavior?->Generate change directory\n(.specs/changes/<name>/)</title>
<path fill="none" stroke="black" d="M563.25,-149.18C563.25,-127.02 563.25,-88.23 563.25,-60.88"/>
<polygon fill="black" stroke="black" points="566.75,-61.1 563.25,-51.1 559.75,-61.1 566.75,-61.1"/>
<text xml:space="preserve" text-anchor="middle" x="608.62" y="-78.7" font-family="Times,serif" font-size="14.00">new or modified</text>
</g>
<!-- New or existing behavior?->Generate baseline specs\n(.specs/specs/) -->
<g id="edge6" class="edge">
<title>New or existing behavior?->Generate baseline specs\n(.specs/specs/)</title>
<path fill="none" stroke="black" d="M543.21,-151.61C512.42,-128.73 453.06,-84.61 414.85,-56.21"/>
<polygon fill="black" stroke="black" points="417.42,-53.76 407.3,-50.6 413.24,-59.37 417.42,-53.76"/>
<text xml:space="preserve" text-anchor="middle" x="500.73" y="-78.7" font-family="Times,serif" font-size="14.00">retroactive doc</text>
</g>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.5 (20260411.2331)
-->
<!-- Title: phase_2_discovery Pages: 1 -->
<svg width="678pt" height="1052pt"
viewBox="0.00 0.00 678.00 1052.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 1048.23)">
<title>phase_2_discovery</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-1048.23 673.88,-1048.23 673.88,4 -4,4"/>
<!-- start -->
<g id="node1" class="node">
<title>start</title>
<ellipse fill="none" stroke="black" cx="583.88" cy="-978.64" rx="61.59" ry="61.59"/>
<ellipse fill="none" stroke="black" cx="583.88" cy="-978.64" rx="65.59" ry="65.59"/>
<text xml:space="preserve" text-anchor="middle" x="583.88" y="-973.59" font-family="Times,serif" font-size="14.00">Phase 2 starts</text>
</g>
<!-- explorers -->
<g id="node2" class="node">
<title>explorers</title>
<polygon fill="none" stroke="black" points="669.88,-876.05 497.88,-876.05 497.88,-818.55 669.88,-818.55 669.88,-876.05"/>
<text xml:space="preserve" text-anchor="middle" x="583.88" y="-858.75" font-family="Times,serif" font-size="14.00">Dispatch parallel explorers</text>
<text xml:space="preserve" text-anchor="middle" x="583.88" y="-842.25" font-family="Times,serif" font-size="14.00">(call-graph, data-flow, ports,</text>
<text xml:space="preserve" text-anchor="middle" x="583.88" y="-825.75" font-family="Times,serif" font-size="14.00">schema, tests)</text>
</g>
<!-- start->explorers -->
<g id="edge1" class="edge">
<title>start->explorers</title>
<path fill="none" stroke="black" d="M583.88,-912.59C583.88,-904.15 583.88,-895.73 583.88,-887.88"/>
<polygon fill="black" stroke="black" points="587.38,-888.01 583.88,-878.01 580.38,-888.01 587.38,-888.01"/>
</g>
<!-- findings -->
<g id="node3" class="node">
<title>findings</title>
<polygon fill="none" stroke="black" points="616.38,-781.55 429.38,-781.55 429.38,-745.55 616.38,-745.55 616.38,-781.55"/>
<text xml:space="preserve" text-anchor="middle" x="522.88" y="-758.5" font-family="Times,serif" font-size="14.00">Explorers emit findings YAML</text>
</g>
<!-- explorers->findings -->
<g id="edge2" class="edge">
<title>explorers->findings</title>
<path fill="none" stroke="black" d="M562.88,-818.17C556.3,-809.35 549.07,-799.65 542.58,-790.96"/>
<polygon fill="black" stroke="black" points="545.59,-789.14 536.8,-783.22 539.98,-793.33 545.59,-789.14"/>
</g>
<!-- synth -->
<g id="node4" class="node">
<title>synth</title>
<polygon fill="none" stroke="black" points="462,-708.55 323.75,-708.55 323.75,-667.55 462,-667.55 462,-708.55"/>
<text xml:space="preserve" text-anchor="middle" x="392.88" y="-691.25" font-family="Times,serif" font-size="14.00">Dispatch synthesizer</text>
<text xml:space="preserve" text-anchor="middle" x="392.88" y="-674.75" font-family="Times,serif" font-size="14.00">with all explorer paths</text>
</g>
<!-- findings->synth -->
<g id="edge3" class="edge">
<title>findings->synth</title>
<path fill="none" stroke="black" d="M492.07,-745.13C475.85,-735.97 455.66,-724.55 437.74,-714.42"/>
<polygon fill="black" stroke="black" points="439.8,-711.56 429.37,-709.69 436.35,-717.66 439.8,-711.56"/>
</g>
<!-- menu -->
<g id="node5" class="node">
<title>menu</title>
<polygon fill="none" stroke="black" points="495,-630.55 290.75,-630.55 290.75,-589.55 495,-589.55 495,-630.55"/>
<text xml:space="preserve" text-anchor="middle" x="392.88" y="-613.25" font-family="Times,serif" font-size="14.00">Synthesizer emits capability menu</text>
<text xml:space="preserve" text-anchor="middle" x="392.88" y="-596.75" font-family="Times,serif" font-size="14.00">+ escalation flags</text>
</g>
<!-- synth->menu -->
<g id="edge4" class="edge">
<title>synth->menu</title>
<path fill="none" stroke="black" d="M392.88,-667.08C392.88,-659.49 392.88,-650.65 392.88,-642.26"/>
<polygon fill="black" stroke="black" points="396.38,-642.4 392.88,-632.4 389.38,-642.4 396.38,-642.4"/>
</g>
<!-- flagged -->
<g id="node6" class="node">
<title>flagged</title>
<polygon fill="none" stroke="black" points="392.88,-552.55 274.65,-534.55 392.87,-516.55 511.1,-534.55 392.88,-552.55"/>
<text xml:space="preserve" text-anchor="middle" x="392.88" y="-529.5" font-family="Times,serif" font-size="14.00">Escalation flagged?</text>
</g>
<!-- menu->flagged -->
<g id="edge5" class="edge">
<title>menu->flagged</title>
<path fill="none" stroke="black" d="M392.88,-589.36C392.88,-581.61 392.88,-572.57 392.88,-564.13"/>
<polygon fill="black" stroke="black" points="396.38,-564.31 392.88,-554.31 389.38,-564.31 396.38,-564.31"/>
</g>
<!-- defaults -->
<g id="node7" class="node">
<title>defaults</title>
<polygon fill="none" stroke="black" points="555.75,-405.05 410,-405.05 410,-347.55 555.75,-347.55 555.75,-405.05"/>
<text xml:space="preserve" text-anchor="middle" x="482.88" y="-387.75" font-family="Times,serif" font-size="14.00">Apply defaults silently</text>
<text xml:space="preserve" text-anchor="middle" x="482.88" y="-371.25" font-family="Times,serif" font-size="14.00">(confidence>=medium,</text>
<text xml:space="preserve" text-anchor="middle" x="482.88" y="-354.75" font-family="Times,serif" font-size="14.00">alpha tiebreak)</text>
</g>
<!-- flagged->defaults -->
<g id="edge6" class="edge">
<title>flagged->defaults</title>
<path fill="none" stroke="black" d="M402,-517.71C415.73,-493.88 442.1,-448.09 461,-415.28"/>
<polygon fill="black" stroke="black" points="463.89,-417.28 465.85,-406.86 457.82,-413.78 463.89,-417.28"/>
<text xml:space="preserve" text-anchor="middle" x="427.65" y="-485.25" font-family="Times,serif" font-size="14.00">no</text>
</g>
<!-- ask -->
<g id="node8" class="node">
<title>ask</title>
<polygon fill="none" stroke="black" points="420.88,-464.05 266.88,-464.05 266.88,-423.05 420.88,-423.05 420.88,-464.05"/>
<text xml:space="preserve" text-anchor="middle" x="343.88" y="-446.75" font-family="Times,serif" font-size="14.00">Present flagged item;</text>
<text xml:space="preserve" text-anchor="middle" x="343.88" y="-430.25" font-family="Times,serif" font-size="14.00">ask one targeted question</text>
</g>
<!-- flagged->ask -->
<g id="edge7" class="edge">
<title>flagged->ask</title>
<path fill="none" stroke="black" d="M384.11,-517.64C377.51,-505.65 368.27,-488.86 360.31,-474.4"/>
<polygon fill="black" stroke="black" points="363.41,-472.77 355.52,-465.7 357.28,-476.15 363.41,-472.77"/>
<text xml:space="preserve" text-anchor="middle" x="381.76" y="-485.25" font-family="Times,serif" font-size="14.00">yes</text>
</g>
<!-- phase3 -->
<g id="node10" class="node">
<title>phase3</title>
<ellipse fill="none" stroke="black" cx="504.88" cy="-167.28" rx="69.78" ry="69.78"/>
<ellipse fill="none" stroke="black" cx="504.88" cy="-167.28" rx="73.78" ry="73.78"/>
<text xml:space="preserve" text-anchor="middle" x="504.88" y="-162.23" font-family="Times,serif" font-size="14.00">Trigger Phase 3</text>
</g>
<!-- defaults->phase3 -->
<g id="edge8" class="edge">
<title>defaults->phase3</title>
<path fill="none" stroke="black" d="M485.84,-347.38C488.43,-322.99 492.34,-286.28 495.93,-252.44"/>
<polygon fill="black" stroke="black" points="499.4,-252.91 496.98,-242.59 492.44,-252.17 499.4,-252.91"/>
</g>
<!-- decide -->
<g id="node9" class="node">
<title>decide</title>
<polygon fill="none" stroke="black" points="323.88,-329.55 170.91,-311.55 323.87,-293.55 476.84,-311.55 323.88,-329.55"/>
<text xml:space="preserve" text-anchor="middle" x="323.88" y="-306.5" font-family="Times,serif" font-size="14.00">User continues or refines?</text>
</g>
<!-- ask->decide -->
<g id="edge9" class="edge">
<title>ask->decide</title>
<path fill="none" stroke="black" d="M340.84,-422.81C337.48,-400.96 332.03,-365.56 328.22,-340.78"/>
<polygon fill="black" stroke="black" points="331.7,-340.42 326.72,-331.07 324.79,-341.49 331.7,-340.42"/>
</g>
<!-- decide->phase3 -->
<g id="edge10" class="edge">
<title>decide->phase3</title>
<path fill="none" stroke="black" d="M346.14,-295.73C366.32,-282.07 396.66,-260.96 421.88,-241.05 428.54,-235.79 435.38,-230.17 442.14,-224.48"/>
<polygon fill="black" stroke="black" points="444.14,-227.37 449.49,-218.22 439.6,-222.04 444.14,-227.37"/>
<text xml:space="preserve" text-anchor="middle" x="418.84" y="-262.25" font-family="Times,serif" font-size="14.00">continue</text>
</g>
<!-- cost -->
<g id="node11" class="node">
<title>cost</title>
<polygon fill="none" stroke="black" points="251.88,-185.28 90.95,-167.28 251.87,-149.28 412.8,-167.28 251.88,-185.28"/>
<text xml:space="preserve" text-anchor="middle" x="251.88" y="-162.23" font-family="Times,serif" font-size="14.00">Choose refinement cost tier</text>
</g>
<!-- decide->cost -->
<g id="edge11" class="edge">
<title>decide->cost</title>
<path fill="none" stroke="black" d="M315.62,-294.23C303.3,-269.9 280,-223.84 265.24,-194.68"/>
<polygon fill="black" stroke="black" points="268.36,-193.1 260.72,-185.76 262.11,-196.26 268.36,-193.1"/>
<text xml:space="preserve" text-anchor="middle" x="320.32" y="-262.25" font-family="Times,serif" font-size="14.00">refine</text>
</g>
<!-- cheap -->
<g id="node12" class="node">
<title>cheap</title>
<polygon fill="none" stroke="black" points="145.75,-41 0,-41 0,0 145.75,0 145.75,-41"/>
<text xml:space="preserve" text-anchor="middle" x="72.88" y="-23.7" font-family="Times,serif" font-size="14.00">Re-run synthesizer only</text>
<text xml:space="preserve" text-anchor="middle" x="72.88" y="-7.2" font-family="Times,serif" font-size="14.00">(cheap)</text>
</g>
<!-- cost->cheap -->
<g id="edge12" class="edge">
<title>cost->cheap</title>
<path fill="none" stroke="black" d="M233.06,-151.06C202.89,-126.66 143.32,-78.47 105.93,-48.24"/>
<polygon fill="black" stroke="black" points="108.55,-45.85 98.58,-42.29 104.15,-51.3 108.55,-45.85"/>
<text xml:space="preserve" text-anchor="middle" x="186.51" y="-62.2" font-family="Times,serif" font-size="14.00">treat A & B as one</text>
</g>
<!-- medium -->
<g id="node13" class="node">
<title>medium</title>
<polygon fill="none" stroke="black" points="376.88,-41 222.88,-41 222.88,0 376.88,0 376.88,-41"/>
<text xml:space="preserve" text-anchor="middle" x="299.88" y="-23.7" font-family="Times,serif" font-size="14.00">Re-run one explorer with</text>
<text xml:space="preserve" text-anchor="middle" x="299.88" y="-7.2" font-family="Times,serif" font-size="14.00">adjusted scope (medium)</text>
</g>
<!-- cost->medium -->
<g id="edge13" class="edge">
<title>cost->medium</title>
<path fill="none" stroke="black" d="M257.38,-149.67C265.29,-125.83 279.98,-81.5 289.85,-51.75"/>
<polygon fill="black" stroke="black" points="293.06,-53.16 292.89,-42.57 286.42,-50.96 293.06,-53.16"/>
<text xml:space="preserve" text-anchor="middle" x="326.75" y="-62.2" font-family="Times,serif" font-size="14.00">ignore vendor/</text>
</g>
<!-- expensive -->
<g id="node14" class="node">
<title>expensive</title>
<polygon fill="none" stroke="black" points="562.25,-41 437.5,-41 437.5,0 562.25,0 562.25,-41"/>
<text xml:space="preserve" text-anchor="middle" x="499.88" y="-23.7" font-family="Times,serif" font-size="14.00">Re-run all explorers</text>
<text xml:space="preserve" text-anchor="middle" x="499.88" y="-7.2" font-family="Times,serif" font-size="14.00">(expensive)</text>
</g>
<!-- cost->expensive -->
<g id="edge14" class="edge">
<title>cost->expensive</title>
<path fill="none" stroke="black" d="M277.02,-151.6C319.04,-127.07 404.05,-77.44 456.07,-47.07"/>
<polygon fill="black" stroke="black" points="457.83,-50.1 464.71,-42.03 454.31,-44.05 457.83,-50.1"/>
<text xml:space="preserve" text-anchor="middle" x="501.99" y="-62.2" font-family="Times,serif" font-size="14.00">substantive scope change</text>
</g>
<!-- cheap->synth -->
<g id="edge15" class="edge">
<title>cheap->synth</title>
<path fill="none" stroke="black" d="M70.66,-41.14C67.71,-68.95 62.88,-121.43 62.88,-166.28 62.88,-611.05 62.88,-611.05 62.88,-611.05 62.88,-662.02 214.25,-678.81 311.95,-684.34"/>
<polygon fill="black" stroke="black" points="311.61,-687.83 321.78,-684.87 311.98,-680.84 311.61,-687.83"/>
</g>
<!-- medium->findings -->
<g id="edge16" class="edge">
<title>medium->findings</title>
<path fill="none" stroke="black" d="M377.25,-33.55C394.24,-36.1 412.16,-38.71 428.88,-41 461.26,-45.44 549.18,-38.79 574.88,-59 613.98,-89.76 606.88,-116.52 606.88,-166.28 606.88,-689.05 606.88,-689.05 606.88,-689.05 606.88,-710.67 590.92,-727.4 572.92,-739.41"/>
<polygon fill="black" stroke="black" points="571.38,-736.25 564.69,-744.46 575.04,-742.21 571.38,-736.25"/>
</g>
<!-- expensive->explorers -->
<g id="edge17" class="edge">
<title>expensive->explorers</title>
<path fill="none" stroke="black" d="M562.58,-39.43C574.68,-44.58 586.72,-51.02 596.88,-59 637.95,-91.27 644.88,-114.04 644.88,-166.28 644.88,-764.55 644.88,-764.55 644.88,-764.55 644.88,-781.24 636.61,-796.72 626.16,-809.63"/>
<polygon fill="black" stroke="black" points="623.65,-807.18 619.65,-816.99 628.9,-811.81 623.65,-807.18"/>
</g>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.5 (20260411.2331)
-->
<!-- Title: phase_4_per_capability Pages: 1 -->
<svg width="380pt" height="879pt"
viewBox="0.00 0.00 380.00 879.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 875.33)">
<title>phase_4_per_capability</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-875.33 376.42,-875.33 376.42,4 -4,4"/>
<!-- selected -->
<g id="node1" class="node">
<title>selected</title>
<ellipse fill="none" stroke="black" cx="250.92" cy="-842.34" rx="84.5" ry="28.99"/>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-845.54" font-family="Times,serif" font-size="14.00">Capability selected</text>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-829.04" font-family="Times,serif" font-size="14.00">from menu</text>
</g>
<!-- obs_dispatch -->
<g id="node2" class="node">
<title>obs_dispatch</title>
<polygon fill="none" stroke="black" points="342.92,-776.35 158.92,-776.35 158.92,-718.85 342.92,-718.85 342.92,-776.35"/>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-759.05" font-family="Times,serif" font-size="14.00">Dispatch observer</text>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-742.55" font-family="Times,serif" font-size="14.00">(file scope, surface candidates,</text>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-726.05" font-family="Times,serif" font-size="14.00">output path)</text>
</g>
<!-- selected->obs_dispatch -->
<g id="edge1" class="edge">
<title>selected->obs_dispatch</title>
<path fill="none" stroke="black" d="M250.92,-813.01C250.92,-805.19 250.92,-796.56 250.92,-788.22"/>
<polygon fill="black" stroke="black" points="254.42,-788.27 250.92,-778.27 247.42,-788.27 254.42,-788.27"/>
</g>
<!-- obs_out -->
<g id="node3" class="node">
<title>obs_out</title>
<polygon fill="none" stroke="black" points="363.55,-681.85 138.3,-681.85 138.3,-640.85 363.55,-640.85 363.55,-681.85"/>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-664.55" font-family="Times,serif" font-size="14.00">Observer writes observations.yaml</text>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-648.05" font-family="Times,serif" font-size="14.00">(behaviors + tags + surface inventory)</text>
</g>
<!-- obs_dispatch->obs_out -->
<g id="edge2" class="edge">
<title>obs_dispatch->obs_out</title>
<path fill="none" stroke="black" d="M250.92,-718.54C250.92,-710.57 250.92,-701.83 250.92,-693.67"/>
<polygon fill="black" stroke="black" points="254.42,-693.81 250.92,-683.81 247.42,-693.81 254.42,-693.81"/>
</g>
<!-- lift_dispatch -->
<g id="node4" class="node">
<title>lift_dispatch</title>
<polygon fill="none" stroke="black" points="339.17,-603.85 162.67,-603.85 162.67,-546.35 339.17,-546.35 339.17,-603.85"/>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-586.55" font-family="Times,serif" font-size="14.00">Dispatch lifter</text>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-570.05" font-family="Times,serif" font-size="14.00">(observations path, metadata,</text>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-553.55" font-family="Times,serif" font-size="14.00">output type, as-of anchor)</text>
</g>
<!-- obs_out->lift_dispatch -->
<g id="edge3" class="edge">
<title>obs_out->lift_dispatch</title>
<path fill="none" stroke="black" d="M250.92,-640.38C250.92,-632.95 250.92,-624.24 250.92,-615.67"/>
<polygon fill="black" stroke="black" points="254.42,-615.72 250.92,-605.72 247.42,-615.72 254.42,-615.72"/>
</g>
<!-- lift_work -->
<g id="node5" class="node">
<title>lift_work</title>
<polygon fill="none" stroke="black" points="339.17,-509.35 162.67,-509.35 162.67,-468.35 339.17,-468.35 339.17,-509.35"/>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-492.05" font-family="Times,serif" font-size="14.00">Lifter consumes observations</text>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-475.55" font-family="Times,serif" font-size="14.00">+ verifies source as needed</text>
</g>
<!-- lift_dispatch->lift_work -->
<g id="edge4" class="edge">
<title>lift_dispatch->lift_work</title>
<path fill="none" stroke="black" d="M250.92,-546.04C250.92,-538.07 250.92,-529.33 250.92,-521.17"/>
<polygon fill="black" stroke="black" points="254.42,-521.31 250.92,-511.31 247.42,-521.31 254.42,-521.31"/>
</g>
<!-- lift_out -->
<g id="node6" class="node">
<title>lift_out</title>
<polygon fill="none" stroke="black" points="317.42,-431.35 184.42,-431.35 184.42,-395.35 317.42,-395.35 317.42,-431.35"/>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-408.3" font-family="Times,serif" font-size="14.00">Lifter writes spec.md</text>
</g>
<!-- lift_work->lift_out -->
<g id="edge5" class="edge">
<title>lift_work->lift_out</title>
<path fill="none" stroke="black" d="M250.92,-468.15C250.92,-460.41 250.92,-451.36 250.92,-442.92"/>
<polygon fill="black" stroke="black" points="254.42,-443.11 250.92,-433.11 247.42,-443.11 254.42,-443.11"/>
</g>
<!-- validate -->
<g id="node7" class="node">
<title>validate</title>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-341.05" font-family="Times,serif" font-size="14.00">uv run validate.py --single</text>
<text xml:space="preserve" text-anchor="middle" x="250.92" y="-324.55" font-family="Times,serif" font-size="14.00"><observations> <spec></text>
</g>
<!-- lift_out->validate -->
<g id="edge6" class="edge">
<title>lift_out->validate</title>
<path fill="none" stroke="black" d="M250.92,-394.93C250.92,-387.34 250.92,-378.22 250.92,-369.55"/>
<polygon fill="black" stroke="black" points="254.42,-369.72 250.92,-359.72 247.42,-369.72 254.42,-369.72"/>
</g>
<!-- pass -->
<g id="node8" class="node">
<title>pass</title>
<polygon fill="none" stroke="black" points="210.92,-280.35 158.57,-262.35 210.92,-244.35 263.28,-262.35 210.92,-280.35"/>
<text xml:space="preserve" text-anchor="middle" x="210.92" y="-257.3" font-family="Times,serif" font-size="14.00">PASS?</text>
</g>
<!-- validate->pass -->
<g id="edge7" class="edge">
<title>validate->pass</title>
<path fill="none" stroke="black" d="M240.41,-317.54C235.45,-308.42 229.47,-297.43 224.2,-287.74"/>
<polygon fill="black" stroke="black" points="227.41,-286.33 219.56,-279.21 221.27,-289.67 227.41,-286.33"/>
</g>
<!-- fix -->
<g id="node9" class="node">
<title>fix</title>
<polygon fill="none" stroke="black" points="372.42,-113.92 209.42,-113.92 209.42,-77.92 372.42,-77.92 372.42,-113.92"/>
<text xml:space="preserve" text-anchor="middle" x="290.92" y="-90.87" font-family="Times,serif" font-size="14.00">Lifter fixes failures in spec</text>
</g>
<!-- pass->fix -->
<g id="edge9" class="edge">
<title>pass->fix</title>
<path fill="none" stroke="black" d="M218.16,-246.47C231.62,-218.81 260.65,-159.14 277.7,-124.11"/>
<polygon fill="black" stroke="black" points="280.68,-125.98 281.91,-115.45 274.39,-122.91 280.68,-125.98"/>
<text xml:space="preserve" text-anchor="middle" x="241.31" y="-213.05" font-family="Times,serif" font-size="14.00">no</text>
</g>
<!-- done -->
<g id="node10" class="node">
<title>done</title>
<ellipse fill="none" stroke="black" cx="95.92" cy="-95.92" rx="91.92" ry="91.92"/>
<ellipse fill="none" stroke="black" cx="95.92" cy="-95.92" rx="95.92" ry="95.92"/>
<text xml:space="preserve" text-anchor="middle" x="95.92" y="-99.12" font-family="Times,serif" font-size="14.00">Capability complete:</text>
<text xml:space="preserve" text-anchor="middle" x="95.92" y="-82.62" font-family="Times,serif" font-size="14.00">return path + counts</text>
</g>
<!-- pass->done -->
<g id="edge8" class="edge">
<title>pass->done</title>
<path fill="none" stroke="black" d="M201.12,-247.33C191,-232.87 174.33,-209.03 157.21,-184.55"/>
<polygon fill="black" stroke="black" points="160.11,-182.58 151.51,-176.4 154.37,-186.6 160.11,-182.58"/>
<text xml:space="preserve" text-anchor="middle" x="195.08" y="-213.05" font-family="Times,serif" font-size="14.00">yes</text>
</g>
<!-- fix->validate -->
<g id="edge10" class="edge">
<title>fix->validate</title>
<path fill="none" stroke="black" d="M290.58,-114.36C289.6,-147.24 285.91,-220.49 271.92,-280.35 269.87,-289.13 266.82,-298.41 263.67,-306.85"/>
<polygon fill="black" stroke="black" points="260.5,-305.36 260.11,-315.94 267.02,-307.91 260.5,-305.36"/>
</g>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.5 (20260411.2331)
-->
<!-- Title: subagent_dispatch Pages: 1 -->
<svg width="402pt" height="929pt"
viewBox="0.00 0.00 402.00 929.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 925.31)">
<title>subagent_dispatch</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-925.31 397.56,-925.31 397.56,4 -4,4"/>
<!-- need -->
<g id="node1" class="node">
<title>need</title>
<ellipse fill="none" stroke="black" cx="235.52" cy="-903.31" rx="91.27" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-898.26" font-family="Times,serif" font-size="14.00">Phase needs subagent</text>
</g>
<!-- resolve -->
<g id="node2" class="node">
<title>resolve</title>
<polygon fill="none" stroke="black" points="306.9,-848.31 164.15,-848.31 164.15,-807.31 306.9,-807.31 306.9,-848.31"/>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-831.01" font-family="Times,serif" font-size="14.00">Resolve absolute paths</text>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-814.51" font-family="Times,serif" font-size="14.00">($TMPDIR, skill_root)</text>
</g>
<!-- need->resolve -->
<g id="edge1" class="edge">
<title>need->resolve</title>
<path fill="none" stroke="black" d="M235.52,-884.89C235.52,-877.5 235.52,-868.66 235.52,-860.2"/>
<polygon fill="black" stroke="black" points="239.02,-860.21 235.52,-850.21 232.02,-860.21 239.02,-860.21"/>
</g>
<!-- compose -->
<g id="node3" class="node">
<title>compose</title>
<polygon fill="none" stroke="black" points="321.15,-770.31 149.9,-770.31 149.9,-712.81 321.15,-712.81 321.15,-770.31"/>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-753.01" font-family="Times,serif" font-size="14.00">Compose prompt:</text>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-736.51" font-family="Times,serif" font-size="14.00">read references/<role>.md</text>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-720.01" font-family="Times,serif" font-size="14.00">+ scope + literal output path</text>
</g>
<!-- resolve->compose -->
<g id="edge2" class="edge">
<title>resolve->compose</title>
<path fill="none" stroke="black" d="M235.52,-806.84C235.52,-799.42 235.52,-790.7 235.52,-782.13"/>
<polygon fill="black" stroke="black" points="239.02,-782.18 235.52,-772.18 232.02,-782.18 239.02,-782.18"/>
</g>
<!-- dispatch -->
<g id="node4" class="node">
<title>dispatch</title>
<polygon fill="none" stroke="black" points="293.4,-675.81 177.65,-675.81 177.65,-639.81 293.4,-639.81 293.4,-675.81"/>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-652.76" font-family="Times,serif" font-size="14.00">Dispatch subagent</text>
</g>
<!-- compose->dispatch -->
<g id="edge3" class="edge">
<title>compose->dispatch</title>
<path fill="none" stroke="black" d="M235.52,-712.43C235.52,-704.45 235.52,-695.76 235.52,-687.74"/>
<polygon fill="black" stroke="black" points="239.02,-687.78 235.52,-677.78 232.02,-687.78 239.02,-687.78"/>
</g>
<!-- work -->
<g id="node5" class="node">
<title>work</title>
<polygon fill="none" stroke="black" points="310.65,-602.81 160.4,-602.81 160.4,-545.31 310.65,-545.31 310.65,-602.81"/>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-585.51" font-family="Times,serif" font-size="14.00">Subagent reads role doc,</text>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-569.01" font-family="Times,serif" font-size="14.00">does scoped work,</text>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-552.51" font-family="Times,serif" font-size="14.00">writes artifact to disk</text>
</g>
<!-- dispatch->work -->
<g id="edge4" class="edge">
<title>dispatch->work</title>
<path fill="none" stroke="black" d="M235.52,-639.47C235.52,-632.17 235.52,-623.35 235.52,-614.61"/>
<polygon fill="black" stroke="black" points="239.02,-614.82 235.52,-604.82 232.02,-614.82 239.02,-614.82"/>
</g>
<!-- verify_cmd -->
<g id="node6" class="node">
<title>verify_cmd</title>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-485.26" font-family="Times,serif" font-size="14.00">ls -la <output_path></text>
</g>
<!-- work->verify_cmd -->
<g id="edge5" class="edge">
<title>work->verify_cmd</title>
<path fill="none" stroke="black" d="M235.52,-544.93C235.52,-536.73 235.52,-527.78 235.52,-519.58"/>
<polygon fill="black" stroke="black" points="239.02,-519.79 235.52,-509.79 232.02,-519.79 239.02,-519.79"/>
</g>
<!-- report -->
<g id="node7" class="node">
<title>report</title>
<polygon fill="none" stroke="black" points="324.9,-435.31 146.15,-435.31 146.15,-394.31 324.9,-394.31 324.9,-435.31"/>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-418.01" font-family="Times,serif" font-size="14.00">Subagent reports path + bytes</text>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-401.51" font-family="Times,serif" font-size="14.00">+ counts inline</text>
</g>
<!-- verify_cmd->report -->
<g id="edge6" class="edge">
<title>verify_cmd->report</title>
<path fill="none" stroke="black" d="M235.52,-472.63C235.52,-464.97 235.52,-455.64 235.52,-446.77"/>
<polygon fill="black" stroke="black" points="239.02,-447.06 235.52,-437.06 232.02,-447.06 239.02,-447.06"/>
</g>
<!-- check -->
<g id="node8" class="node">
<title>check</title>
<polygon fill="none" stroke="black" points="235.52,-357.31 77.49,-339.31 235.52,-321.31 393.56,-339.31 235.52,-357.31"/>
<text xml:space="preserve" text-anchor="middle" x="235.52" y="-334.26" font-family="Times,serif" font-size="14.00">File exists and non-empty?</text>
</g>
<!-- report->check -->
<g id="edge7" class="edge">
<title>report->check</title>
<path fill="none" stroke="black" d="M235.52,-394.11C235.52,-386.37 235.52,-377.32 235.52,-368.88"/>
<polygon fill="black" stroke="black" points="239.02,-369.07 235.52,-359.07 232.02,-369.07 239.02,-369.07"/>
</g>
<!-- manifest -->
<g id="node9" class="node">
<title>manifest</title>
<polygon fill="none" stroke="black" points="163.77,-257.93 5.27,-257.93 5.27,-216.93 163.77,-216.93 163.77,-257.93"/>
<text xml:space="preserve" text-anchor="middle" x="84.52" y="-240.63" font-family="Times,serif" font-size="14.00">Update manifest:</text>
<text xml:space="preserve" text-anchor="middle" x="84.52" y="-224.13" font-family="Times,serif" font-size="14.00">path + counts + status=ok</text>
</g>
<!-- check->manifest -->
<g id="edge8" class="edge">
<title>check->manifest</title>
<path fill="none" stroke="black" d="M213.24,-323.57C189.81,-308.07 152.44,-283.36 124,-264.54"/>
<polygon fill="black" stroke="black" points="126.26,-261.84 115.99,-259.24 122.4,-267.68 126.26,-261.84"/>
<text xml:space="preserve" text-anchor="middle" x="191.37" y="-290.01" font-family="Times,serif" font-size="14.00">yes</text>
</g>
<!-- fail -->
<g id="node10" class="node">
<title>fail</title>
<polygon fill="pink" stroke="black" points="391.76,-224.43 391.76,-250.43 330.12,-268.81 242.93,-268.81 181.29,-250.43 181.29,-224.43 242.93,-206.05 330.12,-206.05 391.76,-224.43"/>
<text xml:space="preserve" text-anchor="middle" x="286.52" y="-240.63" font-family="Times,serif" font-size="14.00">Mark dispatch failed;</text>
<text xml:space="preserve" text-anchor="middle" x="286.52" y="-224.13" font-family="Times,serif" font-size="14.00">re-dispatch or escalate</text>
</g>
<!-- check->fail -->
<g id="edge9" class="edge">
<title>check->fail</title>
<path fill="none" stroke="black" d="M243.95,-321.82C249.88,-310.2 258.09,-294.12 265.72,-279.17"/>
<polygon fill="black" stroke="black" points="268.76,-280.93 270.19,-270.43 262.52,-277.74 268.76,-280.93"/>
<text xml:space="preserve" text-anchor="middle" x="267.77" y="-290.01" font-family="Times,serif" font-size="14.00">no</text>
</g>
<!-- next -->
<g id="node11" class="node">
<title>next</title>
<ellipse fill="none" stroke="black" cx="84.52" cy="-84.52" rx="80.52" ry="80.52"/>
<ellipse fill="none" stroke="black" cx="84.52" cy="-84.52" rx="84.52" ry="84.52"/>
<text xml:space="preserve" text-anchor="middle" x="84.52" y="-79.47" font-family="Times,serif" font-size="14.00">Trigger next phase</text>
</g>
<!-- manifest->next -->
<g id="edge10" class="edge">
<title>manifest->next</title>
<path fill="none" stroke="black" d="M84.52,-216.7C84.52,-206.96 84.52,-194.43 84.52,-180.98"/>
<polygon fill="black" stroke="black" points="88.02,-181.03 84.52,-171.03 81.02,-181.03 88.02,-181.03"/>
</g>
</g>
</svg>
Derive-Specific Spec Additions
Two spec-format additions specific to sdd-derive output (do not apply to sdd-propose). They reflect derive's "snapshot lift" philosophy: derived specs document a specific commit's behavior with honest gaps.
As-of anchor
Record the commit each derived spec was lifted from. This anchor is the canonical reference for re-derivation — future runs diff against newer commits and re-lift the delta.
Format
Include the as-of commit SHA in the generation note at the top of each generated spec:
> Generated from code analysis on 2026-04-29, as-of commit a1b2c3d4e5f6- Use the full or short Git commit hash from repo HEAD at derivation time.
- Short hashes (7-12 chars) are acceptable; full hashes preferred for unambiguous re-derivation.
- Commit SHA + capability name reproduces the exact file set (discovery is deterministic given the same commit).
- Do NOT include a
Source files: {list}line — file lists age poorly across renames.
## Uncertainties section
Append this section when the lifter cannot lift confidently. Omit entirely when empty — a spec with no uncertainties has no section at all.
For when to emit and per-tag rules, see lifter.md § Uncertainty discipline.
Format
## Uncertainties
- **<brief anchor>** (Req #N | file:line | phrase): <reason>.
Resolve: <suggestion>.Each entry has three components, fitting on 2-3 lines:
1. Anchor — what the uncertainty attaches to. Free-form parens content. Examples:
(Req #5)— a specific requirement(Scenario in Req #7)— a scenario within a requirement(during verification of src/search/scoring.py:80)— Observer Gaps(custom tag: foo_bar)— unknown tag rules
2. Reason — one sentence or fragment explaining why the lifter couldn't lift cleanly. 3. Resolution suggestion — brief; what the user can do.
Worked examples
## Uncertainties
- **Search ranking strategy** (Req #5): TF-IDF with threshold 0.3 used for ranking.
Strategy ownership unclear.
Resolve: confirm if TF-IDF is intended strategy (preserve verbatim) or replaceable internal optimization (lift "ranked by relevance" only).
- **Verified gap in `src/search/scoring.py:80`**: Found `boost_recent` modifier during verification, not described in observations.
Resolve: re-derive with observation covering recency boost, or accept as out-of-scope.
- **Retry behavior** (Req #7): Two plausible properties — "retry idempotently" vs "retry only on timeout."
Resolve: pick one or add observation distinguishing the cases.
- **Custom tag `pii_handling`** (Req #11): Lifter has no rule for this tag.
Resolve: define the rule in evidence-class-taxonomy.md and re-derive, or remove the tag.Resolution lifecycle
1. Lifter emits the section (only when items exist). 2. Validate counts and surfaces entries. 3. User resolves manually by either:
- Editing the spec to integrate the chosen resolution and removing the entry, or
- Re-deriving (for Observer Gap or custom tag cases).
4. Remove the section once no entries remain.
No programmatic "resolve" tool exists — resolve uncertainties by editing the spec.
Placement in spec file
Order sections in a derived spec (delta or baseline) as:
1. Generation note blockquote (with as-of commit SHA) 2. ## Purpose (baseline only) 3. ADDED/MODIFIED/REMOVED sections (delta) OR Requirements (baseline) 4. ## Uncertainties (only when present)
## Uncertainties always comes last for easy scan-and-resolve in isolation.
Discovery Phase
Discovery is a phase, not a single subagent. The orchestrator makes two sequential calls:
1. `discovery-explore` — fan out parallel explorers, each running a methodologically distinct technique. 2. `discovery-synthesize` — single synthesizer reconciles all explorer outputs into a capability menu.
The orchestrator then presents the menu for Pre-flight Consent (SKILL.md Phase 3).
Why parallel explorers + synthesizer
Different techniques surface different signals:
- Call graph misses state coupling.
- AST misses runtime DI.
- Schema artifacts miss undocumented behavior.
Fan-out gives each technique its own context budget. The synthesizer reconciles; it does not explore.
The discriminator is methodological distinctness: each explorer must use a different _technique_, not just a different label.
Explorer set
Default explorers (extensible):
| Explorer | Technique | When to run |
|---|---|---|
call-graph | code-review-graph (preferred) or naive AST traversal | Always (graph if available, AST fallback) |
data-flow | String-literal scanning for table names, queue names, topic names, file paths, env keys | Always |
port-interface | Framework-aware extraction of interface declarations, DI registrations, route decorators | Always (framework heuristics applied conditionally) |
schema-artifact | Detect and parse OpenAPI, GraphQL, Protobuf, SQL schemas | Conditional on artifact presence |
test-suite | Identify test files, link to capability scope via filename and import patterns | Conditional on test directory presence |
Each explorer reports status: ran | not_applicable | failed with a one-line status_reason when not run.
Per-explorer instructions
call-graph
Prefer code-review-graph (CLI tool building structural AST graph with communities, bridges, hubs, impact radius). Fall back to naive AST traversal only if unavailable.
With `code-review-graph`:
1. Build/update graph: code-review-graph build. 2. Query communities, hub nodes, and bridges in regions matching user intent. 3. Query impact radius from entry points to detect cross-region blast. 4. Drive references from graph findings (hub objects, community members, bridge nodes). 5. Skip files the graph shows are unrelated.
AST fallback:
1. Walk imports and call edges from likely entry points (main, route handlers, CLI entries, test fixtures). 2. Cluster files by import locality + filename heuristics. 3. Mark confidence: medium or low on heuristic-only candidates. 4. Surface a gotcha for language-coverage gaps.
Fallback findings are strictly less rich; the synthesizer weights accordingly.
data-flow
Catches cross-capability state coupling that the call graph misses. Primary defense against the "call graph misses shared writes to the same DB row" failure mode.
1. Extract string literals: SQL table/column names, queue/topic names, file paths, env vars, cache keys, config paths. 2. Group by literal — sites sharing a literal across capability candidates are _synthetic bridges_. 3. Emit kind: overlap with kind: state for each synthetic bridge (distinct from call-graph kind: call). 4. Emit kind: external_surface_candidate for literals matching known external system patterns.
port-interface
Framework-aware extraction of interface declarations and registrations.
1. Detect framework patterns (Spring @Module/@Component, NestJS controllers/providers, FastAPI route decorators, Rails controllers, Django views, gRPC service definitions). 2. Extract declared interfaces, ports, and DI bindings. 3. Emit kind: capability_candidate based on interface boundaries. 4. Emit kind: external_surface_candidate for declared public APIs (HTTP routes, gRPC methods, CLI commands, library exports). 5. Skip framework steps for frameworks not detected.
When call-graph and port-interface disagree on boundaries, the synthesizer surfaces an axis_disagreement rather than picking.
schema-artifact
Canonical home for schema discovery. Runs the snapshot lifecycle.
1. Detect artifacts. Look for committed specs (openapi.yaml, swagger.json, docs/api/, openapi/), schema files (.proto, .graphql, .prisma, .avsc, schema.sql, migrations), or framework markers implying runtime schema generation (FastAPI, NestJS, Spring Boot, DRF, Rails API, Echo/Gin, Laravel, GraphQL, gRPC). If none, report status: not_applicable.
2. Check `.specs/.sdd/schema-config.yaml`. If present, use configured extraction commands. If absent and artifacts were detected, emit a kind: anomaly finding so the orchestrator can prompt for one (one-time suggestion, see SKILL.md). Don't block; proceed with detection-only. See sdd-schema.md for config format.
3. Generate snapshots (when extraction is configured). Run configured commands, store output in .specs/schemas/.
4. Diff authored vs generated. When both exist:
- Authored ∖ generated → aspirational →
kind: capability_candidate,confidence: low, signalaspirational_only. - Generated ∖ authored → undocumented drift →
kind: anomaly, signalundocumented_drift. - Type/shape mismatches →
kind: anomaly, signalschema_mismatch.
5. Surface schema-anchored findings. For each schema path mapping to a capability candidate, include the path in signals (e.g., signals: [schema_path:/users/{id}]). The lifter uses these for **Schema reference:** annotations — see lifter.md.
test-suite
Corroborates other explorers; rarely emits standalone candidates.
1. Detect test directories (tests/, __tests__/, *_test.go, *.spec.ts, etc.). 2. Match test files to candidates via filename, import patterns, fixtures. 3. Emit findings as kind: capability_candidate evidence — test files appear in references with relationship: test. 4. Weight tests asserting specific behaviors higher than tests that only exercise the code path.
Explorer output schema
A finding is one cohesive observation ("there's a search capability here," "algorithmic region in ranker.py"). One explorer typically emits multiple findings of mixed kinds.
explorer: <name>
status: ran | not_applicable | failed
status_reason: <one-line, only when not ran>
findings:
- kind: capability_candidate | overlap | external_surface_candidate |
algorithmic_region | infrastructure | anomaly | <custom string>
references:
- path: <relative path>
object: <optional — class, function, class.method>
lines: [<start>, <end>] # optional
relationship: primary_implementation | entry_point | caller | callee |
consumer | producer | test | config | schema | bridge | <custom>
rationale: <optional, brief — why this specific reference>
rationale: <required, brief — overall reasoning>
signals: [<explorer-specific signal names>]
confidence: high | medium | lowField rules
- `kind` — use canonical values; emit custom strings only when something genuinely novel surfaces.
Synthesizer treats unknown kinds as anomaly-class but preserves the original label.
- `references` — file-level alone is fine; object-level preferred when available.
- `relationship` — the reference's role in _this finding_; custom values allowed.
- Capability naming — do NOT commit to capability names.
The synthesizer assigns canonical names from cross-explorer heuristics. Naming via the synthesizer keeps overlap detection cleaner (file/object intersection > name match).
- `signals` — free-form per explorer; the synthesizer reads them across findings to detect alignment.
- `confidence` — explorer self-assessment.
Cross-explorer agreement count is the real confidence signal.
Worked example
Call-graph explorer output (excerpt):
explorer: call-graph
status: ran
findings:
- kind: capability_candidate
references:
- path: src/search/service.py
object: SearchService
relationship: primary_implementation
- path: src/search/scoring.py
object: tfidf
relationship: callee
- path: src/api/handlers.py
object: handle_search
relationship: entry_point
rationale: Tight community of 3 nodes with hub at SearchService; entry point
identified.
signals: [community_id_3, hub_density_high, modularity_score_0.42]
confidence: high
- kind: algorithmic_region
references:
- path: src/search/scoring.py
object: tfidf
lines: [42, 80]
relationship: primary_implementation
rationale: Threshold and decay constants
rationale: TF-IDF computation with hand-tuned thresholds.
signals: [hand_tuned_constant_count_2]
confidence: mediumSynthesizer
Consume all explorer outputs; produce the capability menu. Tasks:
- Reconcile cross-explorer findings via reference overlap and signal co-occurrence.
- Assign canonical capability names.
- Surface axis disagreements (explorers proposed conflicting boundaries).
- Identify gotchas (god-modules, single-cluster degeneracy, missing language coverage).
- Estimate per-capability cost (file count, line count, token estimate).
- Propose primary owners for overlaps (default: most edges to bridge wins; ties alphabetical).
Capability menu
capability_menu:
- name: <canonical name>
files_in_scope:
- <path>
evidence_per_axis:
call_graph: <summary>
port_interface: <summary>
...
overlaps:
- with: <other capability>
kind: call | state | port
proposed_owner: <capability>
bridge_references: [<path/object>]
external_surfaces:
- kind: consumed | exposed
identifier: <e.g., "stripe.charges.create" or "POST /users">
confidence: high | medium | low
cost:
file_count: <int>
line_count: <int>
token_estimate: <int>
axis_disagreements:
- description: <e.g., "graph clusters A+B together; ports separate them">
explorers: [call-graph, port-interface]
resolution_prompt: <suggested user question>
gotchas:
- kind: god_module | single_cluster | language_uncovered | explorer_failed | <custom>
description: <prose>
affected_capabilities: [<names>]Emit structured markdown matching this conceptual shape; a JSON/YAML schema is not required.
Escalation flags
Output MUST surface conditions warranting user prompt. The orchestrator reads these and decides whether Phase 3 escalates.
escalation: single_cluster_degeneracy— call-graph found one giant community; synthesizer fell back to alternative partitioning.
User confirms axis.
escalation: axis_disagreement— explorers proposed materially different boundaries.
User picks resolution.
escalation: low_confidence— average confidence belowmedium.
User confirms menu is usable.
escalation: external_surface_split— explorer-confidence split on owned vs 3rd-party.
User classifies.
escalation: cost_threshold— capability count > 6 OR file count > 100.
User confirms cost.
Absent any flag, the orchestrator proceeds with all defaults applied.
Single-cluster degeneracy
When call-graph reports one giant community covering most of the codebase, do NOT silently fall back to single-pass derive. Switch the partitioning axis:
- File-system structure (directory boundaries).
- Module/package boundaries (language-aware).
- Hub-node ego-networks (top-K hubs, each as a synthetic capability with its 1-hop neighborhood).
For polyglot codebases, refuse single-cluster fallback entirely: emit one capability per language root; treat inter-language calls as external surfaces.
Surface this as a gotcha.
Loop semantics
The orchestrator presents the capability menu only when an escalation condition fires (see SKILL.md § Phase 3). Otherwise it commits the synthesizer's defaults silently.
When prompted, the user may continue or request refinement. Clarify ambiguous refinement requests before dispatching.
Refinement options, by cost:
- Synthesizer-only re-run (cheap) — apply new synthesis instructions to existing explorer outputs (e.g., "treat A and B as one capability").
- Single-explorer re-run (medium) — re-run one explorer with adjusted scope (e.g., "ignore
vendor/"). - Full re-explore (expensive) — re-run all explorers; for substantive scope changes.
Confirm the chosen refinement type before dispatching.
digraph phase_2_discovery {
start [label="Phase 2 starts", shape=doublecircle];
explorers [label="Dispatch parallel explorers\n(call-graph, data-flow, ports,\nschema, tests)", shape=box];
findings [label="Explorers emit findings YAML", shape=box];
synth [label="Dispatch synthesizer\nwith all explorer paths", shape=box];
menu [label="Synthesizer emits capability menu\n+ escalation flags", shape=box];
flagged [label="Escalation flagged?", shape=diamond];
defaults [label="Apply defaults silently\n(confidence>=medium,\nalpha tiebreak)", shape=box];
ask [label="Present flagged item;\nask one targeted question", shape=box];
decide [label="User continues or refines?", shape=diamond];
phase3 [label="Trigger Phase 3", shape=doublecircle];
cost [label="Choose refinement cost tier", shape=diamond];
cheap [label="Re-run synthesizer only\n(cheap)", shape=box];
medium [label="Re-run one explorer with\nadjusted scope (medium)", shape=box];
expensive [label="Re-run all explorers\n(expensive)", shape=box];
start -> explorers;
explorers -> findings;
findings -> synth;
synth -> menu;
menu -> flagged;
flagged -> defaults [label="no"];
flagged -> ask [label="yes"];
defaults -> phase3;
ask -> decide;
decide -> phase3 [label="continue"];
decide -> cost [label="refine"];
cost -> cheap [label="treat A & B as one"];
cost -> medium [label="ignore vendor/"];
cost -> expensive [label="substantive scope change"];
cheap -> synth;
medium -> findings;
expensive -> explorers;
}Pre-flight cost accounting
Total dispatches for a typical run:
N explorers + 1 synthesizer + 2 × selected_capabilitiesThe orchestrator surfaces this in Pre-flight Consent when capability count > 3 OR file count > 50.
Evidence-Class Taxonomy
Tags attached to observer entries that drive lift discipline. Multi-tag is allowed and expected; rules compose without conflict.
Canonical set
Seven named tags + custom string fallthrough. Untagged observations get default lift discipline: translate "what code does" to "what property the code maintains."
| Tag | Trigger | Provenance default |
|---|---|---|
algorithmic | A specific algorithm, threshold, or hand-tuned constant produces an observable property | ON |
security | Auth, crypto, secrets, access control, or attack-surface validation | OFF (opt-in) |
reliability | Error handling, retries, fallbacks, timeouts, idempotency guarantees | ON |
external_surface | Call/write to a non-owned API, schema, topic, or file format | ON |
state_coupling | Shared mutable state crossing capability boundaries (DB rows, cache keys, files) | ON |
framework_recognized | Code maps to a known framework pattern (Django Model, FastAPI route, Spring DI) | ON |
public_api | Code defines a publicly-consumed interface (HTTP/gRPC/CLI/library/module export) | ON |
Per-tag rules
algorithmic
- Apply when: observed behavior is produced by a specific algorithm, threshold, scoring rule, or hand-tuned constant — and the user-facing property is a _consequence_ of that algorithm, not the algorithm itself.
- Lift rule: do not promote the algorithm to a contract.
Apply the strategy check:
- _Intended strategy_ (e.g., system is _defined_ by using PageRank): lift the property AND record the algorithm verbatim as a strategy note.
Emit Uncertainty asking the user to confirm strategy ownership.
- _Internal optimization_ (e.g., TF-IDF as one of many possible relevance scorers): lift only the property.
Emit Uncertainty asking the user to confirm replaceability.
- Validate: list all algorithm-related Uncertainties for user disposition.
- Combine with `external_surface` (3rd-party API specifies the algorithm): preserve verbatim under External System Exception AND emit Uncertainty.
- Example signals:
tfidf_score > 0.3;decay_factor=0.85;PriorityQueue with custom compare; specific retry backoff curves.
security
- Apply when: auth, authorization, cryptography, secret handling, access control, input validation against injection/XSS/SSRF/path traversal, or rate limiting against abuse.
- Lift rule: lift to _strong specificity_.
Name actor, resource, predicate. Not "SHALL enforce access control" but "SHALL deny non-admin users from modifying foreign user records."
- Provenance default OFF.
File:line references can leak crypto/secret/auth locations. Opt-in retention requires explicit warning.
- Validate: emit a security-tagged section in the report; user confirms before output.
- Example signals:
hmac.compare_digest; password hashing functions;@requires_role; SQL parameterization; rate-limit decorators.
reliability
- Apply when: error handling, retries, fallbacks, timeouts, circuit breakers, deduplication, idempotency keys, recovery sequences.
- Lift rule: produce explicit failure-path scenarios; include the recovery property explicitly.
Not "SHALL handle errors" but **WHEN** call fails / **THEN** retry up to N / **THEN** if exhausted, surface to caller without state corruption.
- Validate: flag tagged findings whose lifted contract has no failure scenario.
- Combine with
external_surface(retry on external call);state_coupling(recovery without corruption). - Example signals:
tenacity.retry;try/exceptwith rollback; idempotency keys; circuit breakers;with timeout().
external_surface
- Apply when: call to a 3rd-party API, write to a schema not owned by this codebase, message bus topic with consumers outside, or production of an externally-defined file format.
- Lift rule: apply External System Exception.
Preserve the interface verbatim (endpoint, columns, topic name, schema) alongside the property. Example: "SHALL persist user state to the shared users table with columns id, email, created_at" — not just "SHALL persist user state."
- Validate: cross-check against external-surface candidates from discovery; warn on mismatch.
- Combine with
reliability(retry policy);security(3rd-party auth);state_coupling(shared external state). - Example signals:
stripe.charges.create; Kafkaproducer.send; OpenAPI-generated client calls; writes to avendor/schema.
state_coupling
- Apply when: shared mutable state crosses capability boundaries — same DB row written by multiple capabilities, same cache key, same file path, shared global, message topic with internal consumers.
- Lift rule: name the shared resource and lift invariants about it (write order, read consistency, partition tolerance, idempotency of overwrites).
The shared resource appears in the contract.
- Validate: ensure the resource is named, not just hinted.
- Combine with
external_surfaceif state lives in a 3rd-party system (preserve external schema verbatim AND name invariants). - Example signals:
db.users.updatefrom multiple services;redis.set("session:*")from auth and session-management; shared config-file writes.
framework_recognized
- Apply when: code matches a recognized framework pattern (Django Model; FastAPI route decorator; Spring DI; NestJS module/controller; Rails ActiveRecord; SQLAlchemy declarative).
- Lift rule: lift framework-derived contracts, not literal-code contracts.
class User(Model): name = CharField()→ "Users SHALL have a name attribute available for create/read/update per Django ORM lifecycle" (the _meaning_), not "User SHALL declare a name field." - Background lens: framework name lives in
signalsso the lifter knows which lens to apply.
If combined with security (framework auth middleware), security rules dominate.
- Example signals:
django_model_declaration;fastapi_route;nestjs_controller;sqlalchemy_declarative.
public_api
- Apply when: code defines a publicly-consumed interface — HTTP/gRPC/GraphQL endpoint, CLI command/flag set, library export, or a module's public API consumed by other modules within the codebase.
- Lift rule: preserve interface details as part of the contract — route + method + request/response shape, CLI command + flag semantics, exported symbol + signature, module's public function set.
The interface IS the contract; do not abstract it away.
- Validate: cross-check against the port/interface inventory from discovery; warn on mismatch.
- Combine with:
external_surfacefor _bridging_ code (webhooks, gateways, library facades).
Preserve both shapes.
securityfor auth-protected endpoints; security's strong-specificity sharpens the public-API contract.state_couplingfor endpoints that mutate shared state; contract names both interface and resource invariants.reliabilityfor endpoints with retry/idempotency guarantees.- Layer signals:
http_route,grpc_method,cli_command,library_export,module_export.module_exportdistinguishes internal-public from external-public; same preservation rule, but validate may apply different stringency (external-public deserves explicit deprecation policy; module-public is more refactorable).
Composition rules
When multiple tags apply, resolve in this order:
1. `algorithmic` dominates lift output. Uncertainty emission cannot be suppressed. 2. `security` dominates provenance. Provenance-OFF wins. Other tags' lift rules still apply. 3. `external_surface` + `state_coupling` — both apply; contract names both the external interface and the shared-resource invariants. 4. `reliability` modifies whatever else applies; never replaces. 5. `framework_recognized` is background lens; never overrides another tag's rule. 6. Unknown / custom tags — lifter emits an Uncertainty with the tag name; applies default lift discipline; does not guess.
Custom tags
Observers may emit tags outside the canonical set when a finding genuinely doesn't fit. The lifter applies default lift discipline AND emits a brief Uncertainty noting the tag name. This preserves the signal without forcing a guess.
If a custom tag recurs, promote it to the canonical set and document its rule here.
Lifter Subagent
Canonical job description for the lifter subagent.
Job
Consume observer output (observations + surface inventory) and capability metadata, apply the lift step, and emit a spec (baseline or delta) to the provided output path.
Source access is bounded: capability files only, for verification — not exploration.
Inputs (in dispatch prompt)
- Observations YAML path — read first; mechanism-level entries tagged per evidence-class taxonomy.
Includes embedded surface inventory (validator consumes it).
- Capability metadata — name, scope, ownership, overlap notes, optional schema paths.
- Output type —
baselineordelta. - As-of anchor — date + short commit SHA for the generation note.
- Source files — read-only, verification scope only (see § Verification discipline).
- Output path — absolute path (orchestrator resolved
$TMPDIR).
Output
Write one markdown spec file to the output path.
baseline→sdd-spec-formats.md§ 3.delta→sdd-spec-formats.md§ 4.- Both: § 1 (requirement shape — contracts, not narration), § 5 (scenario format).
- Derive-specific additions (generation note,
## Uncertainties) →derive-spec-additions.md.
Emit ## Uncertainties ONLY when non-empty. Never emit ## Uncertainties\\n\\nNone identified.
The lift step
For each observation, translate "what the code does" (mechanism) into "what property the code maintains" (contract).
| Observation (mechanism) | Naive echo (wrong) | Lifted contract (right) |
|---|---|---|
UserService.activate() runs db.users.update(is_active=True, updated_at=now()) and enqueues a confirmation email job | "The system SHALL update users.is_active to true and send a confirmation email on activate()." | "Given an inactive user account, when the account is activated, the account SHALL be in the active state and the user SHALL be notified." |
search() filters by tfidf_score > 0.3 then sorts descending | "The search SHALL filter terms by TF-IDF > 0.3 and sort descending." | "The search SHALL return documents ranked by relevance to the query, with the most relevant first." |
Rules:
- Pair each behavior with the property it serves.
One observation may serve multiple properties.
- Name the property, not the path.
If you cannot articulate the property, emit an Uncertainty — you do not yet have a requirement.
- If
algorithmicis tagged AND a chosen algorithm/threshold/data structure appears, apply the strategy check (below). - State universal properties universally: "for any {input class}, the system SHALL {outcome}."
- For each universal SHALL, apply the partition heuristic (
sdd-spec-formats.md§ 1.6).
When observations describe multiple write-sites, branches, or stages for the same contract-asserted value, do NOT enumerate write-sites in scenarios — that leaks mechanism into the spec. Instead, identify the _semantic_ partitions the spec already names (lifecycle states, identity/equivalence, multi-source composition, derived-pair) and write one scenario per partition. If observations show partition-relevant behavior the spec does not yet name, emit an Uncertainty rather than fabricate scenarios.
Tag-driven lift rules
The evidence-class taxonomy (evidence-class-taxonomy.md) defines per-tag rules and composition. Summary:
| Tag | Lift rule |
|---|---|
algorithmic | Apply strategy check; do NOT promote algorithm to contract; emit Uncertainty for human strategy decision |
security | Lift to strong specificity (name actor, resource, predicate); provenance default OFF |
reliability | Produce explicit failure-path scenarios with recovery property |
external_surface | External System Exception — preserve interface verbatim alongside the property |
state_coupling | Name the shared resource; lift invariants about it |
framework_recognized | Lift framework-derived contracts, not literal-code contracts |
public_api | Preserve interface details (route + method + shape, command + flags, etc.) as part of the contract |
| (no tag) | Default lift discipline |
For multi-tag composition, see evidence-class-taxonomy.md § Composition rules.
The strategy check (for algorithmic)
Two questions you cannot answer reliably from code alone:
1. Is this algorithm the _intended strategy_ (the system is _defined_ by using it), or an _internal optimization_ (interchangeable with other valid implementations)? 2. Is the user the right person to decide, or is a domain expert needed?
Therefore: every algorithmic-tagged observation gets an Uncertainty offering both lift options:
- "If <algorithm> is the intended strategy → preserve verbatim as a strategy note."
- "If <algorithm> is replaceable → confirm '<lifted property>' is the contract."
Verification discipline
Source access is reactive (verification), never proactive (exploration). Scope is the capability's file scope only; files outside scope are unavailable.
Consult source when:
- Observation
behavioris genuinely ambiguous, blocking a clean lift. - A criticality-tagged observation needs code-level confirmation:
security— name exact actors/resources/predicates.algorithmic— strategy check.external_surface— preserve exact endpoint/schema/topic.- A
confidence: lowobservation cannot be lifted without verification.
Do NOT consult source out of curiosity, to re-synthesize, or to find behaviors observations did not describe (see § Observer Gap).
Authority — correct, don't expand:
- Wrong observation (says
> 0.3, code is> 0.5) → correct it; lift the corrected behavior. - New behavior found (Observer Gap) → do NOT introduce a contract; see below.
Observer Gap
Behavior in source that observations do not describe:
- Do NOT silently introduce a contract.
- Do NOT silently ignore — that loses the signal.
- Emit an Uncertainty anchored
(during verification of <path:line>)with what was found and the likely property.
Note whether broader re-derivation or out-of-scope. User decides at validate review.
Schema reference annotations
When a lifted requirement or scenario maps to a specific schema path (provided in capability metadata), add a **Schema reference:** annotation immediately after the requirement title or within the relevant scenario. This anchors the contract to the machine-readable schema and tightens downstream sdd-verify. Canonical format: sdd-schema.md § 1.
Brief shape:
1. **User Creation API**
**Schema reference:** `openapi.yaml#/paths/~1users/post`
The system SHALL create a user with the provided email and hashed password,
returning the canonical user representation with a system-assigned ID.Apply when any of:
- An observation's
signalsincludes aschema_path:entry. - A capability's
external_surfacesnames a schema-anchored endpoint and the requirement maps to it. - The schema-artifact explorer flagged drift on a path the requirement covers (note in the annotation if relevant).
If the mapping is uncertain, OMIT the annotation.
Uncertainty discipline
Uncertainty is the EXCEPTION, not the default. With observer + user refinement upstream, lift confidently most of the time.
Emit an Uncertainty when:
algorithmictag is set (always).- An observation has multiple plausible properties or is genuinely ambiguous.
- A criticality-tag rule cannot be satisfied from inputs.
- An observation has a custom tag you have no rule for.
- Verification revealed an Observer Gap.
Format (see derive-spec-additions.md):
## Uncertainties
- **<brief anchor>** (Req #N | file:line | phrase): <reason>.
Resolve: <suggestion>.OMIT the entire ## Uncertainties section when zero uncertainties. Never emit ## Uncertainties\\n\\nNone identified.
Self-check before returning
After writing the spec, run the deterministic validator:
uv run --quiet <skill_root>/references/validate.py --single <observations_yaml_path> <spec_md_path><skill_root> is the absolute path the orchestrator provided — the directory containing this file.
Outcomes:
PASS <capability> reqs=N scenarios=N uncertainties=N surface_gaps=N acknowledged=N(exit 0) — done.FAIL <capability>(exit 1) — fix each listed failure and re-run.
Iterate until PASS.
You are the cheapest place to fix format drift; a corrective dispatch costs an entire round-trip.
Common format failures the validator catches:
- Missing generation note (
> Generated from code analysis on YYYY-MM-DD, as-of commit <sha>). - Missing
## Purpose(baseline only). - Non-canonical requirement headings (
### R1,### REQ-1,### Req #1→ must be### Requirement: <Name>). - Missing or insufficient bold
**GIVEN**/**WHEN**/**THEN**markers. - Missing RFC 2119 keywords.
- Delta markers in a baseline spec.
## Uncertaintiespresent but empty or stubbed.
Once PASS:
- Run
ls -la <output_path>and report the byte count. - Return inline (under 200 words): path + byte count, requirement / scenario / uncertainty counts, anomalies, one-line summary.
Do NOT inline spec content.
Common mistakes
- Promoting an algorithm to a contract —
algorithmicforbids it; emit an Uncertainty. - Re-doing observer's job — verification is reactive, not exploratory.
- Contracts from Observer Gap — new behavior → Uncertainty, not a contract.
- Vague properties under `reliability` — "SHALL handle errors" instead of explicit failure-path scenarios.
- Stripping `external_surface` detail — "SHALL persist user state" loses table/columns; preserve verbatim.
- Defaulting to Uncertainty when a clean lift exists — Uncertainty is the exception.
- Treating Uncertainty as failure — it is honest signaling.
Two targeted Uncertainties beat five confident-but-wrong contracts.
- Skipping the format self-check — drift is the top bounce reason; 30 seconds prevents re-dispatch.
- Empty `## Uncertainties` sections — omit entirely when empty.
Observer Subagent
Job description for the observer subagent. Orchestrator dispatches with: _"Read this reference and follow it as your job description; below is your scope."_
Job
Read source files for one capability and emit two structured artifacts:
1. Observations list — behavior-grain entries describing what the code does (mechanism), tagged per the canonical evidence-class taxonomy. 2. Surface inventory — env vars, CLI flags, HTTP routes, exported symbols, etc.
Do NOT lift, translate, or propose contracts. Translating "what the code does" into "what property it maintains" is the lifter's job. Hand the lifter raw, faithful, mechanism-level material.
Inputs (from dispatch prompt)
- Capability scope — file list (community + bridges + schema/test artifacts).
- Capability metadata — name, evidence-per-axis summary, overlap notes, ownership claims.
- External-surface candidates — confirmed by user during pre-flight consent.
Tag observations touching these as external_surface.
- Output path — absolute path (orchestrator pre-resolves
$TMPDIR).
Output schema
Write a single YAML file to the provided output path:
capability: <name>
observations:
- behavior: |
<brief mechanism-level description: what the code does, not what
property it maintains; the lift hasn't happened yet>
references:
- path: <relative path>
object: <optional — class, function, class.method>
lines: [<start>, <end>] # optional
relationship: primary_implementation | entry_point | caller | callee |
consumer | producer | test | config | schema | bridge | <custom>
tags: [<from canonical taxonomy or custom>]
confidence: high | medium | low
notes: <optional, brief — hint for lifter>
surface_inventory:
- kind: env_var | cli_flag | config_key | http_route | grpc_method |
cli_command | published_event | exported_symbol | <custom>
name: <surface identifier> # e.g., "DATABASE_URL", "--verbose", "POST /users"
references:
- path: <relative path>
object: <optional>
lines: [<int>] # optional
relationship: producer | consumer | <custom>
notes: <optional, brief>Field rules
- `behavior` — only required prose field.
Mechanism-level: "ranks documents by TF-IDF, filters scores > 0.3, returns top N descending." NOT "returns documents ranked by relevance" (that's the lift).
- `references` — file-only is fine when behavior spans the whole file; otherwise include
objectandlines. - `tags` — zero or more from the canonical taxonomy plus custom strings when warranted.
See evidence-class-taxonomy.md.
- `confidence` — honest self-assessment.
lowis signal; the lifter may emit an Uncertainty rather than lift it. - `notes` — brief, optional.
E.g., "one of three sites enforcing this invariant; see also obs #14."
YAML must parse
Output is consumed by automated tooling. Before returning, ensure:
- File is valid YAML —
yaml.safe_loadloads cleanly. - Strings containing
:,#,',", or leading-are quoted. - Multi-line
behaviorvalues use the|block scalar form. - Lists and mappings are consistently indented.
Malformed YAML wastes the orchestrator's correction pass.
Behavior-grain principle
Group related code-level mechanisms into cohesive behaviors. arr.filter(x => x > 0.3).sort() is one observation, not three.
| Grain | Description | When |
|---|---|---|
| Statement-grain | One observation per side-effect | NEVER — too noisy |
| Function-grain | One observation per function/method | Rare — only when functions don't compose |
| Behavior-grain | One observation per cohesive behavior | DEFAULT |
| Capability-grain | One observation for the whole capability | NEVER — collapses to single-pass derive |
When a behavior spans multiple functions, list all relevant ones in references with appropriate relationship values.
Tag assignment
- Apply tags when the observation matches canonical triggers (see
evidence-class-taxonomy.md). - Multi-tag is expected — an external API call with retry logic gets
[external_surface, reliability]. - When uncertain, prefer applying.
Over-tagging yields richer contracts; under-tagging drops critical discipline.
- For genuinely novel patterns, emit a custom tag (any string).
The lifter surfaces it as an Uncertainty.
Discipline rules
- No lifting.
Mechanism only; property translation is the lifter's job.
- No new contracts.
Report observations; don't propose requirements, scenarios, or properties.
- No silent abstraction.
If code uses tfidf_score > 0.3, write that — name the algorithm and threshold. The lifter decides whether to abstract.
- Honest confidence.
Mark low when unclear; the lifter may emit Uncertainty rather than guess.
- Specific references.
File-only is fine for whole-file behaviors; otherwise include object and lines.
- Tests are evidence, not observations.
Include test files in references with relationship: test. Do not emit "search has tests" as a standalone observation.
Capability ownership
Your dispatch lists what this capability owns vs what overlaps. Observe only behaviors under this capability's ownership. For shared bridge files, observe only the aspects this capability owns; behaviors owned elsewhere appear in their owner's output.
Budget enforcement
The orchestrator enforces token/file budgets at pre-flight (Phase 3). If your dispatched capability exceeds budget mid-run:
- Terminate with a "too large" status.
- Do NOT silently truncate.
- Return a brief diagnosis so the orchestrator can re-split and re-dispatch.
Before returning
After writing the YAML:
1. Run ls -la <output_path> and capture the byte count. 2. Confirm the file is non-empty. 3. Mentally verify yaml.safe_load would parse it (quoting, indentation).
Return inline (under 200 words):
- Path written + verified byte count
- Observation count
- Surface inventory count
- Anomalies (if any)
- One-line summary
Do NOT inline the YAML content. The orchestrator reads the file when it needs detail.
Common mistakes
- Pre-emptive lifting — writing
behavioras property ("ranked by relevance") instead of mechanism ("ranks by TF-IDF, filters > 0.3"). - Wrong grain — statement-grain (too noisy) or capability-grain (collapses workflow).
- Vague references —
path: src/auth/with noobjectorlineswhen more specificity is available. - Tag avoidance — empty
tags: []on auth/retry/external observations.
Missing tags = missing discipline.
- False confidence —
highon behaviors you didn't fully understand.
Honest medium/low lets the lifter emit Uncertainty.
- Malformed YAML — unquoted colons, broken block scalars.
The validator rejects these.
digraph output_type {
// Decision tree for sdd-derive output type
// WHEN: Determining whether to generate change directory or baseline specs
// KEY INVARIANT: .specs/specs/ only documents implemented behavior.
// Greenfield projects (no code) always use a change directory.
".specs/specs/ exists?" [shape=diamond];
"Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)" [shape=diamond];
"New or existing behavior?" [shape=diamond];
"Generate change directory\n(.specs/changes/<name>/)\nADDED-only delta specs" [shape=box];
"Generate change directory\n(.specs/changes/<name>/)" [shape=box];
"Generate baseline specs\n(.specs/specs/)" [shape=box];
".specs/specs/ exists?" -> "Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)" [label="no"];
"Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)" -> "Generate baseline specs\n(.specs/specs/)" [label="yes — retroactive doc"];
"Codebase has relevant\nimplementation?\n(answered by Phase 2 discovery)" -> "Generate change directory\n(.specs/changes/<name>/)\nADDED-only delta specs" [label="no — greenfield"];
".specs/specs/ exists?" -> "New or existing behavior?" [label="yes"];
"New or existing behavior?" -> "Generate change directory\n(.specs/changes/<name>/)" [label="new or modified"];
"New or existing behavior?" -> "Generate baseline specs\n(.specs/specs/)" [label="retroactive doc"];
}
digraph phase_2_discovery {
// Phase 2 explore -> synthesize -> escalation/refinement loop.
// KEY INVARIANT: orchestrator commits synthesizer defaults silently
// unless an escalation condition fires. Refinement loops choose by cost tier.
start [label="Phase 2 starts", shape=doublecircle];
explorers [label="Dispatch parallel explorers\n(call-graph, data-flow, ports,\nschema, tests)", shape=box];
findings [label="Explorers emit findings YAML", shape=box];
synth [label="Dispatch synthesizer\nwith all explorer paths", shape=box];
menu [label="Synthesizer emits capability menu\n+ escalation flags", shape=box];
flagged [label="Escalation flagged?", shape=diamond];
defaults [label="Apply defaults silently\n(confidence>=medium,\nalpha tiebreak)", shape=box];
ask [label="Present flagged item;\nask one targeted question", shape=box];
decide [label="User continues or refines?", shape=diamond];
phase3 [label="Trigger Phase 3", shape=doublecircle];
cost [label="Choose refinement cost tier", shape=diamond];
cheap [label="Re-run synthesizer only\n(cheap)", shape=box];
medium [label="Re-run one explorer with\nadjusted scope (medium)", shape=box];
expensive [label="Re-run all explorers\n(expensive)", shape=box];
start -> explorers;
explorers -> findings;
findings -> synth;
synth -> menu;
menu -> flagged;
flagged -> defaults [label="no"];
flagged -> ask [label="yes"];
defaults -> phase3;
ask -> decide;
decide -> phase3 [label="continue"];
decide -> cost [label="refine"];
cost -> cheap [label="treat A & B as one"];
cost -> medium [label="ignore vendor/"];
cost -> expensive [label="substantive scope change"];
cheap -> synth;
medium -> findings;
expensive -> explorers;
}
digraph phase_4_per_capability {
// Phase 4 per-capability flow: observer -> lifter (with self-validate loop).
// KEY INVARIANT: lifter self-validates and fixes format drift in place;
// the orchestrator never round-trips a corrective dispatch for format failures.
selected [label="Capability selected\nfrom menu", shape=ellipse];
obs_dispatch [label="Dispatch observer\n(file scope, surface candidates,\noutput path)", shape=box];
obs_out [label="Observer writes observations.yaml\n(behaviors + tags + surface inventory)", shape=box];
lift_dispatch [label="Dispatch lifter\n(observations path, metadata,\noutput type, as-of anchor)", shape=box];
lift_work [label="Lifter consumes observations\n+ verifies source as needed", shape=box];
lift_out [label="Lifter writes spec.md", shape=box];
validate [label="uv run validate.py --single\n<observations> <spec>", shape=plaintext];
pass [label="PASS?", shape=diamond];
fix [label="Lifter fixes failures in spec", shape=box];
done [label="Capability complete:\nreturn path + counts", shape=doublecircle];
selected -> obs_dispatch;
obs_dispatch -> obs_out;
obs_out -> lift_dispatch;
lift_dispatch -> lift_work;
lift_work -> lift_out;
lift_out -> validate;
validate -> pass;
pass -> done [label="yes"];
pass -> fix [label="no"];
fix -> validate;
}
digraph subagent_dispatch {
// Lifecycle for any sdd-derive subagent dispatch.
// KEY INVARIANT: orchestrator tracks a manifest of paths + counts;
// never re-ingests artifact content. Verification gates the next phase.
need [label="Phase needs subagent", shape=ellipse];
resolve [label="Resolve absolute paths\n($TMPDIR, skill_root)", shape=box];
compose [label="Compose prompt:\nread references/<role>.md\n+ scope + literal output path", shape=box];
dispatch [label="Dispatch subagent", shape=box];
work [label="Subagent reads role doc,\ndoes scoped work,\nwrites artifact to disk", shape=box];
verify_cmd [label="ls -la <output_path>", shape=plaintext];
report [label="Subagent reports path + bytes\n+ counts inline", shape=box];
check [label="File exists and non-empty?", shape=diamond];
manifest [label="Update manifest:\npath + counts + status=ok", shape=box];
fail [label="Mark dispatch failed;\nre-dispatch or escalate", shape=octagon, style=filled, fillcolor=pink];
next [label="Trigger next phase", shape=doublecircle];
need -> resolve;
resolve -> compose;
compose -> dispatch;
dispatch -> work;
work -> verify_cmd;
verify_cmd -> report;
report -> check;
check -> manifest [label="yes"];
check -> fail [label="no"];
manifest -> next;
}
Validate Phase
Check generated specs against three signals: surface coverage, uncertainty count, Phase 7 quality (format compliance). Deterministic — runs as a Python script, not a subagent.
Where validate runs
- Per-capability (lifter, Phase 4): each lifter runs
validate.py --singleon its own output, fixes failures in place, re-runs until PASS.
Catches format drift at write time; avoids round-tripping a corrective dispatch.
- Aggregate (orchestrator, Phase 5): once all capabilities complete, run
validate.pyacross the whole run.
Surface gaps and uncertainty totals are the substantive output; format/YAML failures should be zero if lifters self-checked.
# Lifter mode (single capability; Phase 4)
uv run --quiet <skill_root>/references/validate.py --single <observations.yaml> <spec.md>
# Aggregate mode (whole run; Phase 5)
uv run --quiet <skill_root>/references/validate.py <observations_dir> <specs_dir>Inputs
- Generated spec(s) — delta or baseline format.
- Surface inventory (per capability, from observer YAML).
## Uncertaintiessection content (per spec, when present).
Surface coverage diff
Diff is kind-aware: surface kinds have different coverage expectations.
- Public-consumer (callers depend on these directly):
http_route,grpc_method,cli_command,published_event,exported_symbol. - Internal-knob (operator-tunable; lift correctly excludes most per
sdd-spec-formats.md§ 1.3):env_var,config_key,cli_flag.
Severity:
| Surface kind | Absent from spec entirely | Mentioned but no scenario |
|---|---|---|
| Public-consumer | Gap — flag for user review | Acknowledged-without-scenario |
| Internal-knob | Acknowledged-without-scenario (default — lift correctly excluded) | Acknowledged-without-scenario |
Rationale: contracts state what callers depend on, not internal knobs. A POST /users route absent from a conversion-api baseline is a real gap. An env var like AIZK_WORKER_POLL_INTERVAL_SECONDS absent is the lifter doing its job — the contract is "a worker SHALL begin processing within bounded latency", not "polls every 2 seconds."
Examples:
POST /usersin inventory but not in spec → Gap.--verboseCLI flag absent → Acknowledged-without-scenario.- Exported symbol
WorkspaceEscapeabsent → Gap (callers depend on the exception type).
Group gaps by capability. Gaps require user review; acknowledged-without-scenario is informational.
Uncertainty review
Count items in each spec's ## Uncertainties section (if present). Surface grouped by capability:
Capability "search": 1 uncertainty
- Search ranking strategy (Req #5): TF-IDF strategy ownership unclear
Capability "billing": 0 uncertainties
Capability "auth": 2 uncertainties
- Token validation specificity (Req #2): ...
- Verified gap in src/auth/middleware.py:42: ...Zero uncertainties is the typical, healthy outcome. >5 uncertainties in one capability suggests insufficient observer scope or unclear capability boundaries — surface as a meta-concern.
Phase 7 quality checklist
- [ ] Requirements use RFC 2119 keywords (SHALL/MUST/SHOULD/MAY)
- [ ] Scenarios use
#### Scenario:with GIVEN/WHEN/THEN (bold, exact casing) - [ ] Each requirement is a lifted contract, not a restatement of code structure (see
evidence-class-taxonomy.mdfor tag-driven rules) - [ ] Algorithm names, thresholds, and hand-tuned constants do NOT appear in contracts unless
algorithmicstrategy was explicitly preserved (with corresponding Uncertainty resolution) - [ ] External-surface contracts preserve the external interface verbatim (endpoints, table columns, topic names)
- [ ] Public-API contracts preserve interface details (route + method, command + flags, exported signatures)
- [ ] Reliability-tagged contracts include explicit failure-path scenarios
- [ ] Security-tagged contracts use strong specificity (named actor, resource, predicate)
- [ ] State-coupling contracts name the shared resource with invariants
- [ ] Delta specs (change directory) use ADDED/MODIFIED/REMOVED sections
- [ ] Baseline specs have no delta markers
- [ ] Baseline specs include a
## Purposesection - [ ] Each generated spec has a generation note blockquote with date and as-of commit SHA
- [ ] Large surface areas were decomposed into multiple capability specs
- [ ]
## Uncertaintiessection is omitted when empty; present and non-empty when uncertainties exist
Validate report format
SDD Derive — Validate Report
============================
Capabilities derived: 3 (search, billing, auth)
As-of commit: a1b2c3d
Total requirements: 24
Total uncertainties: 3
Surface coverage:
- search: 0 gaps, 1 acknowledged-without-scenario
- billing: 1 gap (RATE_LIMIT_PER_MINUTE env var)
- auth: 0 gaps
Uncertainties:
- search/spec.md: 1 (algorithmic strategy)
- auth/spec.md: 2 (verified gap, security strong-specificity)
Phase 7 checklist: 14/15 items pass
- Failed: "Each generated spec has generation note" — billing/spec.md missing as-of SHA
Action items for user:
1. Decide on billing rate-limit env var (gap)
2. Resolve 3 uncertainties in search and auth
3. Add generation note to billing specAction items in priority order: gaps → uncertainties → quality issues.
When validate triggers re-derive
Validate may surface conditions warranting re-derivation:
- Many uncertainties in one capability (>5) — observer scope likely insufficient.
- Many surface coverage gaps — observer missed enumeration.
- Phase 7 quality failures concentrated in one capability — lift discipline failure.
The orchestrator does NOT auto-trigger re-derive. Re-derive is user-initiated.
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""
Deterministic validator for sdd-derive.
Runs three checks against observation YAMLs and spec markdown files:
1. **YAML parse check** — every observation YAML must yaml.safe_load cleanly.
2. **Format check** — every spec.md matches the canonical baseline/delta shape from
references/sdd-spec-formats.md (generation note, ## Purpose for baseline,
### Requirement: <Name> headings, #### Scenario: blocks with bold GIVEN/WHEN/THEN,
no delta markers in baseline, ## Uncertainties only-when-non-empty).
3. **Surface coverage diff** — kind-aware: public-consumer surfaces absent from spec
are gaps; internal-knob surfaces (env_var, config_key, cli_flag) absent default to
acknowledged-without-scenario per references/validate.md.
Two modes:
- **Single mode** — used by the lifter against its own output before returning.
Catches format failures at write time (no orchestrator round-trip).
Format check + YAML parse check + per-capability surface coverage.
uv run --quiet validate.py --single <observations.yaml> <spec.md>
- **Aggregate mode** — used by the orchestrator at Phase 5 across the whole run.
All capabilities at once; cross-capability totals; final report.
uv run --quiet validate.py <observations_dir> <specs_dir>
Exit code is non-zero if any spec fails format or any YAML fails to parse.
Surface gaps and uncertainties are informational; they do not fail the run.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import json
from pathlib import Path
import re
import sys
import yaml
# --- Surface kind classification per references/validate.md -----------------
PUBLIC_CONSUMER_KINDS = frozenset(
{"http_route", "grpc_method", "cli_command", "published_event", "exported_symbol"}
)
INTERNAL_KNOB_KINDS = frozenset({"env_var", "config_key", "cli_flag"})
# --- Spec format regexes ----------------------------------------------------
GENERATION_NOTE = re.compile(
r"^>\s+Generated from code analysis on \d{4}-\d{2}-\d{2}, as-of commit [0-9a-f]{7,40}\b",
re.MULTILINE,
)
PURPOSE_HEADING = re.compile(r"^##\s+Purpose\s*$", re.MULTILINE)
REQUIREMENTS_HEADING = re.compile(r"^##\s+Requirements\s*$", re.MULTILINE)
REQUIREMENT_HEADING = re.compile(r"^###\s+Requirement:\s+\S", re.MULTILINE)
SCENARIO_HEADING = re.compile(r"^####\s+Scenario:\s+\S", re.MULTILINE)
GIVEN_BOLD = re.compile(r"\*\*GIVEN\*\*")
WHEN_BOLD = re.compile(r"\*\*WHEN\*\*")
THEN_BOLD = re.compile(r"\*\*THEN\*\*")
DELTA_MARKER = re.compile(
r"^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+(Requirements|Capabilities)\s*$",
re.MULTILINE,
)
UNCERTAINTIES_HEADING = re.compile(r"^##\s+Uncertainties\s*$", re.MULTILINE)
RFC_2119 = re.compile(r"\b(SHALL|MUST|SHOULD|MAY)\b")
# Heuristic patterns indicating a non-canonical requirement heading shape
NONCANONICAL_REQ = re.compile(
r"^###\s+(R\d+|REQ-\d+|Req\s*#?\d+|Requirement\s*\d+)[:\s]", re.MULTILINE
)
# --- Result types -----------------------------------------------------------
@dataclass
class FormatResult:
capability: str
spec_path: Path
failures: list[str] = field(default_factory=list)
requirement_count: int = 0
scenario_count: int = 0
uncertainty_present: bool = False
uncertainty_count: int = 0
@property
def passed(self) -> bool:
"""True when the spec produced no format failures."""
return not self.failures
@dataclass
class YamlResult:
capability: str
yaml_path: Path
parsed: bool
error: str | None = None
observation_count: int = 0
surface_count: int = 0
@dataclass
class CoverageResult:
capability: str
gaps: list[dict] = field(default_factory=list)
acknowledged: list[dict] = field(default_factory=list)
surfaces_total: int = 0
# --- Format check -----------------------------------------------------------
def check_format(spec_path: Path, output_type: str = "baseline") -> FormatResult:
"""Run regex-based format checks against a spec file.
output_type: "baseline" or "delta"
"""
cap = spec_path.parent.name
result = FormatResult(capability=cap, spec_path=spec_path)
text = spec_path.read_text()
# 1. Generation note
if not GENERATION_NOTE.search(text):
result.failures.append(
"missing generation note ('> Generated from code analysis on YYYY-MM-DD, as-of commit <sha>')"
)
# 2. Purpose section (baseline only)
if output_type == "baseline" and not PURPOSE_HEADING.search(text):
result.failures.append("missing '## Purpose' section (required in baseline)")
# 3. Requirement headings — canonical shape
req_count = len(REQUIREMENT_HEADING.findall(text))
noncanonical_reqs = NONCANONICAL_REQ.findall(text)
if noncanonical_reqs:
result.failures.append(
f"non-canonical requirement headings: found {len(noncanonical_reqs)} entries "
f"matching '### R<n>:' / '### REQ-<n>:' / '### Req #<n>:'; expected '### Requirement: <Name>'"
)
if req_count == 0 and not noncanonical_reqs:
result.failures.append("no requirement headings found")
result.requirement_count = req_count
# 4. Scenario headings + bold GIVEN/WHEN/THEN
scen_count = len(SCENARIO_HEADING.findall(text))
given = len(GIVEN_BOLD.findall(text))
when = len(WHEN_BOLD.findall(text))
then = len(THEN_BOLD.findall(text))
if scen_count > 0:
# Each scenario should have at least one GIVEN, one WHEN, one THEN.
# Allow for compound scenarios (multiple WHEN/THEN clauses).
if given < scen_count:
result.failures.append(
f"only {given} bold **GIVEN** markers for {scen_count} scenarios"
)
if when < scen_count:
result.failures.append(
f"only {when} bold **WHEN** markers for {scen_count} scenarios"
)
if then < scen_count:
result.failures.append(
f"only {then} bold **THEN** markers for {scen_count} scenarios"
)
result.scenario_count = scen_count
# 5. No delta markers in baseline
if output_type == "baseline":
delta_hits = DELTA_MARKER.findall(text)
if delta_hits:
result.failures.append(
f"baseline spec contains delta markers: {[m[0] + ' ' + m[1] for m in delta_hits]}"
)
# 6. RFC 2119 keyword usage
if not RFC_2119.search(text):
result.failures.append(
"no RFC 2119 keywords (SHALL/MUST/SHOULD/MAY) found"
)
# 7. Uncertainties section: present iff non-empty
if UNCERTAINTIES_HEADING.search(text):
# Count uncertainty entries (top-level bullet items under the heading)
m = UNCERTAINTIES_HEADING.search(text)
body = text[m.end() :] if m else ""
# Stop at next H2
next_h2 = re.search(r"^##\s+\S", body, re.MULTILINE)
if next_h2:
body = body[: next_h2.start()]
# Top-level bullets start with "- **"
uncertainty_entries = re.findall(
r"^\s*-\s+\*\*", body, re.MULTILINE
)
result.uncertainty_present = True
result.uncertainty_count = len(uncertainty_entries)
# Detect "None identified" / "No uncertainties" prose stub
stub_words = re.search(
r"\b(none identified|no uncertainties|nothing flagged|n/a)\b",
body,
re.IGNORECASE,
)
if result.uncertainty_count == 0 or stub_words:
result.failures.append(
"'## Uncertainties' section is present but empty or stubbed; OMIT entirely when empty"
)
return result
# --- YAML parse check -------------------------------------------------------
def check_yaml(yaml_path: Path) -> YamlResult:
"""Parse an observation YAML and report observation and surface counts.
Returns a YamlResult with parsed=False and an error string when YAML
parsing fails or the top-level value is not a mapping.
"""
cap = yaml_path.stem
try:
data = yaml.safe_load(yaml_path.read_text())
except yaml.YAMLError as e:
return YamlResult(
capability=cap, yaml_path=yaml_path, parsed=False, error=str(e)
)
if not isinstance(data, dict):
return YamlResult(
capability=cap,
yaml_path=yaml_path,
parsed=False,
error="top-level YAML is not a mapping",
)
obs = data.get("observations") or []
inv = data.get("surface_inventory") or []
return YamlResult(
capability=cap,
yaml_path=yaml_path,
parsed=True,
observation_count=len(obs) if isinstance(obs, list) else 0,
surface_count=len(inv) if isinstance(inv, list) else 0,
)
# --- Surface coverage diff --------------------------------------------------
def _mentioned(name: str, text: str) -> bool:
"""Whether a surface name occurs in text as a whole token.
Boundary-aware and case-sensitive. Plain substring matching reports
``ping`` inside ``shipping``; a bare word boundary mishandles names that
begin or end with ``/`` (routes). Case is significant for surfaces (HTTP
verbs, Go export visibility, event names), so a convention mismatch
surfaces as a gap rather than being silently treated as covered.
"""
return re.search(rf"(?:^|(?<=[^\w./-])){re.escape(name)}(?=$|[^\w./-])", text) is not None
def check_coverage(yaml_path: Path, spec_path: Path) -> CoverageResult:
"""Diff the surface inventory against the spec, kind-aware.
Public-consumer surfaces absent from the spec are gaps; internal-knob
surfaces (env_var, config_key, cli_flag) absent default to
acknowledged-without-scenario per references/validate.md. Surfaces
mentioned in the spec but not inside any `#### Scenario:` block are
also acknowledged.
"""
cap = yaml_path.stem
result = CoverageResult(capability=cap)
try:
data = yaml.safe_load(yaml_path.read_text())
except yaml.YAMLError:
return result # YAML parse failure already recorded
if not isinstance(data, dict):
return result
inv = data.get("surface_inventory") or []
if not isinstance(inv, list):
return result
spec_text = spec_path.read_text() if spec_path.exists() else ""
# Collect scenario blocks for "mentioned but no scenario" detection
scen_blocks = re.findall(
r"####\s*Scenario:.*?(?=####\s*Scenario:|^###\s|\Z)",
spec_text,
re.DOTALL | re.MULTILINE,
)
scen_text = "\n".join(scen_blocks)
for item in inv:
if not isinstance(item, dict):
continue
name = item.get("name", "")
kind = item.get("kind", "unknown")
if not name:
continue
result.surfaces_total += 1
is_internal_knob = kind in INTERNAL_KNOB_KINDS
is_public = kind in PUBLIC_CONSUMER_KINDS
in_spec = _mentioned(name, spec_text)
in_scenario = _mentioned(name, scen_text) if scen_blocks else False
entry = {"kind": kind, "name": name}
if not in_spec:
# Internal knobs default to acknowledged-without-scenario when absent
if is_internal_knob:
result.acknowledged.append(entry | {"reason": "internal-knob; lift discipline excludes"})
elif is_public:
result.gaps.append(entry | {"reason": "public-consumer surface absent from spec"})
else:
# Unknown kind — conservatively flag as gap so user reviews
result.gaps.append(entry | {"reason": f"surface kind '{kind}' absent; unrecognized — review"})
elif in_spec and not in_scenario:
# Mentioned but no scenario covers it
result.acknowledged.append(entry | {"reason": "mentioned in spec; no scenario covers it"})
return result
# --- Driver -----------------------------------------------------------------
def run_single(yaml_path: Path, spec_path: Path) -> int:
"""Validate one capability — used by the lifter before returning.
Output is tight and actionable: lists exact failures or prints PASS.
Exit code is non-zero on any format or YAML failure.
"""
if not yaml_path.exists():
print(f"FAIL observations YAML not found: {yaml_path}", file=sys.stderr)
return 2
if not spec_path.exists():
print(f"FAIL spec not found: {spec_path}", file=sys.stderr)
return 2
failures: list[str] = []
# YAML parse
yr = check_yaml(yaml_path)
if not yr.parsed:
failures.append(f"YAML parse error in {yaml_path.name}: {yr.error}")
# Format
fr = check_format(spec_path, output_type="baseline")
if not fr.passed:
failures.extend(fr.failures)
# Coverage (per-cap, informational only)
cov = check_coverage(yaml_path, spec_path)
cap = yaml_path.stem
if failures:
print(f"FAIL {cap}")
for msg in failures:
print(f" - {msg}")
if cov.gaps:
print(f" surface gaps (informational): {len(cov.gaps)}")
return 1
print(
f"PASS {cap} reqs={fr.requirement_count} scenarios={fr.scenario_count} "
f"uncertainties={fr.uncertainty_count} surface_gaps={len(cov.gaps)} "
f"acknowledged={len(cov.acknowledged)}"
)
return 0
def main() -> int:
"""Parse argv, dispatch to single or aggregate mode, return exit code."""
args = sys.argv[1:]
if len(args) >= 1 and args[0] == "--single":
if len(args) != 3:
print(
"Usage: uv run --quiet validate.py --single <observations.yaml> <spec.md>",
file=sys.stderr,
)
return 2
return run_single(Path(args[1]), Path(args[2]))
if len(args) != 2:
print(
"Usage: uv run --quiet validate.py <observations_dir> <specs_dir>\n"
" uv run --quiet validate.py --single <observations.yaml> <spec.md>",
file=sys.stderr,
)
return 2
obs_dir = Path(args[0])
spec_dir = Path(args[1])
if not obs_dir.is_dir():
print(f"observations dir not found: {obs_dir}", file=sys.stderr)
return 2
if not spec_dir.is_dir():
print(f"specs dir not found: {spec_dir}", file=sys.stderr)
return 2
yaml_files = sorted(obs_dir.glob("*.yaml"))
if not yaml_files:
print(f"no *.yaml files in {obs_dir}", file=sys.stderr)
return 2
yaml_results: list[YamlResult] = []
format_results: list[FormatResult] = []
coverage_results: list[CoverageResult] = []
for yp in yaml_files:
cap = yp.stem
sp = spec_dir / cap / "spec.md"
yaml_results.append(check_yaml(yp))
if sp.exists():
format_results.append(check_format(sp, output_type="baseline"))
coverage_results.append(check_coverage(yp, sp))
else:
# Spec missing — record a synthetic failure
r = FormatResult(capability=cap, spec_path=sp)
r.failures.append(f"spec file not found at {sp}")
format_results.append(r)
# ------------------------------------------------------------------ report
any_failure = False
print("=" * 78)
print("SDD-Derive Validate Report")
print("=" * 78)
# YAML
print("\n## YAML parse check")
yaml_failed = [r for r in yaml_results if not r.parsed]
print(f" {len(yaml_results) - len(yaml_failed)}/{len(yaml_results)} observation YAMLs parsed cleanly")
for r in yaml_failed:
any_failure = True
print(f" FAIL {r.capability}: {r.error}")
# Format
print("\n## Format check")
fmt_failed = [r for r in format_results if not r.passed]
print(
f" {len(format_results) - len(fmt_failed)}/{len(format_results)} specs match canonical format"
)
for r in format_results:
status = "PASS" if r.passed else "FAIL"
print(
f" {status} {r.capability:<42} reqs={r.requirement_count:<3} scenarios={r.scenario_count:<3} uncertainties={r.uncertainty_count}"
)
if not r.passed:
any_failure = True
for f in r.failures:
print(f" - {f}")
# Coverage
print("\n## Surface coverage diff")
total_surfaces = sum(c.surfaces_total for c in coverage_results)
total_gaps = sum(len(c.gaps) for c in coverage_results)
total_acked = sum(len(c.acknowledged) for c in coverage_results)
print(
f" surfaces={total_surfaces} gaps={total_gaps} acknowledged={total_acked}"
)
for c in coverage_results:
if c.gaps:
print(f" {c.capability}: {len(c.gaps)} gap(s)")
for g in c.gaps[:8]:
print(f" - [{g['kind']}] {g['name']} — {g['reason']}")
if len(c.gaps) > 8:
print(f" ... +{len(c.gaps) - 8} more")
# Summary
print("\n## Summary")
print(
f" YAML parse failures: {len(yaml_failed)} of {len(yaml_results)}"
)
print(
f" Format failures: {len(fmt_failed)} of {len(format_results)}"
)
print(
f" Surface gaps: {total_gaps} (informational; not blocking)"
)
print(
f" Acknowledged: {total_acked} (informational; internal knobs OK)"
)
# JSON output for machine consumption (last line)
summary = {
"yaml_total": len(yaml_results),
"yaml_failures": len(yaml_failed),
"format_total": len(format_results),
"format_failures": len(fmt_failed),
"surfaces_total": total_surfaces,
"surface_gaps": total_gaps,
"surfaces_acknowledged": total_acked,
"uncertainties_total": sum(r.uncertainty_count for r in format_results),
}
print("\n## Machine-readable summary")
print(json.dumps(summary))
return 1 if any_failure else 0
if __name__ == "__main__":
sys.exit(main())