@gobing-ai/spur 0.3.40 → 0.3.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/README.md +15 -1
  3. package/config/templates/AGENTS.md +5 -3
  4. package/config/workflows/task-pipeline.yaml +103 -12
  5. package/package.json +15 -12
  6. package/plugins/README.md +655 -0
  7. package/plugins/sp/README.md +621 -0
  8. package/plugins/sp/agents/expert-spur.md +109 -0
  9. package/plugins/sp/agents/super-coder.md +126 -0
  10. package/plugins/sp/agents/super-planner.md +283 -0
  11. package/plugins/sp/agents/super-reviewer.md +154 -0
  12. package/plugins/sp/commands/dev-arch.md +30 -0
  13. package/plugins/sp/commands/dev-brainstorm.md +40 -0
  14. package/plugins/sp/commands/dev-changelog.md +28 -0
  15. package/plugins/sp/commands/dev-daily.md +29 -0
  16. package/plugins/sp/commands/dev-debug.md +29 -0
  17. package/plugins/sp/commands/dev-dogfood.md +33 -0
  18. package/plugins/sp/commands/dev-featurechange.md +130 -0
  19. package/plugins/sp/commands/dev-find-conflict.md +51 -0
  20. package/plugins/sp/commands/dev-find-issue.md +76 -0
  21. package/plugins/sp/commands/dev-find-next.md +60 -0
  22. package/plugins/sp/commands/dev-fixall.md +33 -0
  23. package/plugins/sp/commands/dev-gitmsg.md +28 -0
  24. package/plugins/sp/commands/dev-gtd.md +97 -0
  25. package/plugins/sp/commands/dev-handover.md +26 -0
  26. package/plugins/sp/commands/dev-idea.md +50 -0
  27. package/plugins/sp/commands/dev-next.md +31 -0
  28. package/plugins/sp/commands/dev-parallel.md +30 -0
  29. package/plugins/sp/commands/dev-plan.md +46 -0
  30. package/plugins/sp/commands/dev-refine.md +48 -0
  31. package/plugins/sp/commands/dev-refineall.md +65 -0
  32. package/plugins/sp/commands/dev-refresh.md +32 -0
  33. package/plugins/sp/commands/dev-reverse.md +32 -0
  34. package/plugins/sp/commands/dev-review.md +31 -0
  35. package/plugins/sp/commands/dev-run.md +63 -0
  36. package/plugins/sp/commands/dev-runall.md +80 -0
  37. package/plugins/sp/commands/dev-simplify.md +39 -0
  38. package/plugins/sp/commands/dev-unit.md +29 -0
  39. package/plugins/sp/commands/dev-verify.md +48 -0
  40. package/plugins/sp/commands/dev-verifyall.md +72 -0
  41. package/plugins/sp/commands/dev-wrap.md +30 -0
  42. package/plugins/sp/commands/dev-wrapall.md +33 -0
  43. package/plugins/sp/commands/rule-add.md +18 -0
  44. package/plugins/sp/commands/rule-refine.md +18 -0
  45. package/plugins/sp/commands/rule-scan.md +18 -0
  46. package/plugins/sp/commands/spur-init.md +22 -0
  47. package/plugins/sp/commands/workflow-add.md +18 -0
  48. package/plugins/sp/commands/workflow-refine.md +18 -0
  49. package/plugins/sp/hooks/agent-hint.ts +60 -0
  50. package/plugins/sp/hooks/careful-guard.ts +147 -0
  51. package/plugins/sp/hooks/context-post-tool.ts +325 -0
  52. package/plugins/sp/hooks/context-session-start.ts +182 -0
  53. package/plugins/sp/hooks/context-session-stop.ts +95 -0
  54. package/plugins/sp/hooks/hooks.json +51 -0
  55. package/plugins/sp/hooks/pi/guard-extension.ts +341 -0
  56. package/plugins/sp/hooks/task-write-guard.ts +84 -0
  57. package/plugins/sp/plugin.json +8 -0
  58. package/plugins/sp/scripts/batch-preflight.ts +204 -0
  59. package/plugins/sp/scripts/daily-summary/daily-summary.ts +606 -0
  60. package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
  61. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +363 -0
  62. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +168 -0
  63. package/plugins/sp/scripts/feature-sync-bounded.ts +451 -0
  64. package/plugins/sp/scripts/stage-registry-adapter.ts +1341 -0
  65. package/plugins/sp/scripts/task-size-precheck.ts +202 -0
  66. package/plugins/sp/scripts/validate-commands.ts +651 -0
  67. package/plugins/sp/scripts/validate-flag-contracts.ts +875 -0
  68. package/plugins/sp/skills/brainstorm/SKILL.md +361 -0
  69. package/plugins/sp/skills/brainstorm/agents/openai.yaml +14 -0
  70. package/plugins/sp/skills/brainstorm/examples/ideation-example.md +209 -0
  71. package/plugins/sp/skills/brainstorm/metadata.openclaw +7 -0
  72. package/plugins/sp/skills/brainstorm/references/grilling-interview.md +68 -0
  73. package/plugins/sp/skills/brainstorm/references/workflows.md +255 -0
  74. package/plugins/sp/skills/branch-workflow/SKILL.md +109 -0
  75. package/plugins/sp/skills/branch-workflow/references/branch-lifecycle.md +69 -0
  76. package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +100 -0
  77. package/plugins/sp/skills/code-implementation/SKILL.md +176 -0
  78. package/plugins/sp/skills/code-implementation/references/debugging.md +64 -0
  79. package/plugins/sp/skills/code-implementation/references/implementation-patterns.md +113 -0
  80. package/plugins/sp/skills/code-improvement/SKILL.md +231 -0
  81. package/plugins/sp/skills/code-improvement/references/deepening-signals.md +205 -0
  82. package/plugins/sp/skills/code-review/SKILL.md +112 -0
  83. package/plugins/sp/skills/code-review/references/receiving-code-review.md +70 -0
  84. package/plugins/sp/skills/code-review/references/review-lenses.md +117 -0
  85. package/plugins/sp/skills/code-review/references/self-review-checklist.md +53 -0
  86. package/plugins/sp/skills/code-review/references/verification-before-completion.md +68 -0
  87. package/plugins/sp/skills/code-simplification/SKILL.md +225 -0
  88. package/plugins/sp/skills/code-testing/SKILL.md +108 -0
  89. package/plugins/sp/skills/code-testing/references/stacks/bun-ts.md +165 -0
  90. package/plugins/sp/skills/code-testing/references/stacks/go.md +88 -0
  91. package/plugins/sp/skills/code-testing/references/stacks/python.md +117 -0
  92. package/plugins/sp/skills/code-testing/references/test-loop-breaker.md +37 -0
  93. package/plugins/sp/skills/code-testing/references/test-output-discipline.md +52 -0
  94. package/plugins/sp/skills/code-testing/references/unit-testing.md +212 -0
  95. package/plugins/sp/skills/code-verification/SKILL.md +535 -0
  96. package/plugins/sp/skills/code-verification/references/code-improvement.md +39 -0
  97. package/plugins/sp/skills/code-verification/references/secu-review.md +94 -0
  98. package/plugins/sp/skills/code-verification/references/verdict-schema.md +141 -0
  99. package/plugins/sp/skills/conflict-finding/SKILL.md +254 -0
  100. package/plugins/sp/skills/conflict-finding/references/authority-resolution.md +258 -0
  101. package/plugins/sp/skills/conflict-finding/references/comparison-protocol.md +277 -0
  102. package/plugins/sp/skills/conflict-finding/references/finding-contract.md +346 -0
  103. package/plugins/sp/skills/conflict-finding/references/remediation-routing.md +276 -0
  104. package/plugins/sp/skills/daily-summary/SKILL.md +171 -0
  105. package/plugins/sp/skills/daily-summary/agents/openai.yaml +13 -0
  106. package/plugins/sp/skills/doc-evolve/SKILL.md +175 -0
  107. package/plugins/sp/skills/doc-evolve/references/operations.md +93 -0
  108. package/plugins/sp/skills/dogfood-testing/SKILL.md +608 -0
  109. package/plugins/sp/skills/dogfood-testing/references/monitor-ledger.md +211 -0
  110. package/plugins/sp/skills/dogfood-testing/references/report-template.md +351 -0
  111. package/plugins/sp/skills/doubt-driven-development/SKILL.md +136 -0
  112. package/plugins/sp/skills/functional-review/SKILL.md +364 -0
  113. package/plugins/sp/skills/functional-review/references/verdict-schema.md +133 -0
  114. package/plugins/sp/skills/indexed-context/SKILL.md +164 -0
  115. package/plugins/sp/skills/issue-finding/SKILL.md +424 -0
  116. package/plugins/sp/skills/issue-finding/agents/openai.yaml +15 -0
  117. package/plugins/sp/skills/issue-finding/examples/expected-findings.json +27 -0
  118. package/plugins/sp/skills/issue-finding/examples/session-test-loop.jsonl +23 -0
  119. package/plugins/sp/skills/issue-finding/references/session-formats.md +121 -0
  120. package/plugins/sp/skills/next-feature/SKILL.md +125 -0
  121. package/plugins/sp/skills/next-feature/references/handoff-routing.md +92 -0
  122. package/plugins/sp/skills/next-feature/references/proposal-contract.md +49 -0
  123. package/plugins/sp/skills/next-feature/references/ranking-rubric.md +52 -0
  124. package/plugins/sp/skills/next-feature/references/signal-derivation.md +67 -0
  125. package/plugins/sp/skills/next-router/SKILL.md +181 -0
  126. package/plugins/sp/skills/next-router/references/messages.md +90 -0
  127. package/plugins/sp/skills/next-router/references/routing-table.md +216 -0
  128. package/plugins/sp/skills/parallel-execution/SKILL.md +148 -0
  129. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +100 -0
  130. package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +101 -0
  131. package/plugins/sp/skills/parallel-execution/references/result-synthesis.md +94 -0
  132. package/plugins/sp/skills/reverse-engineering/SKILL.md +375 -0
  133. package/plugins/sp/skills/reverse-engineering/agents/openai.yaml +18 -0
  134. package/plugins/sp/skills/source-driven-development/SKILL.md +124 -0
  135. package/plugins/sp/skills/spec-decomposition/SKILL.md +117 -0
  136. package/plugins/sp/skills/spec-decomposition/references/decomposition.md +539 -0
  137. package/plugins/sp/skills/spur-cli/SKILL.md +133 -0
  138. package/plugins/sp/skills/spur-cli/references/agent.md +179 -0
  139. package/plugins/sp/skills/spur-cli/references/features/acceptance-criteria.md +82 -0
  140. package/plugins/sp/skills/spur-cli/references/features/hierarchy-mece.md +177 -0
  141. package/plugins/sp/skills/spur-cli/references/features/roadmap-priority.md +84 -0
  142. package/plugins/sp/skills/spur-cli/references/features/verbs.md +122 -0
  143. package/plugins/sp/skills/spur-cli/references/features.md +234 -0
  144. package/plugins/sp/skills/spur-cli/references/init.md +119 -0
  145. package/plugins/sp/skills/spur-cli/references/message.md +98 -0
  146. package/plugins/sp/skills/spur-cli/references/rules/authoring-rules.md +212 -0
  147. package/plugins/sp/skills/spur-cli/references/rules/fine-tuning.md +148 -0
  148. package/plugins/sp/skills/spur-cli/references/rules/operations.md +228 -0
  149. package/plugins/sp/skills/spur-cli/references/rules/validation-and-extension.md +129 -0
  150. package/plugins/sp/skills/spur-cli/references/rules.md +209 -0
  151. package/plugins/sp/skills/spur-cli/references/serve.md +58 -0
  152. package/plugins/sp/skills/spur-cli/references/tasks/l3-guard-cheatsheet.md +98 -0
  153. package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +95 -0
  154. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +361 -0
  155. package/plugins/sp/skills/spur-cli/references/tasks.md +278 -0
  156. package/plugins/sp/skills/spur-cli/references/team.md +133 -0
  157. package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +226 -0
  158. package/plugins/sp/skills/spur-cli/references/workflows/operations.md +211 -0
  159. package/plugins/sp/skills/spur-cli/references/workflows/validation-and-extension.md +129 -0
  160. package/plugins/sp/skills/spur-cli/references/workflows.md +355 -0
  161. package/plugins/sp/skills/spur-dev/SKILL.md +224 -0
  162. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +196 -0
  163. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +664 -0
  164. package/plugins/sp/skills/spur-dev/references/decision-brief.md +80 -0
  165. package/plugins/sp/skills/spur-dev/references/dev-operations.md +546 -0
  166. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +164 -0
  167. package/plugins/sp/skills/spur-dev/references/execution-batch.md +727 -0
  168. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +350 -0
  169. package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +191 -0
  170. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +417 -0
  171. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +171 -0
  172. package/plugins/sp/skills/spur-dev/references/glossary.md +95 -0
  173. package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +80 -0
  174. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +88 -0
  175. package/plugins/sp/skills/spur-dev/references/planning-workflow.md +302 -0
  176. package/plugins/sp/skills/spur-dev/references/product-planning.md +206 -0
  177. package/plugins/sp/skills/spur-dev/references/section-batching.md +24 -0
  178. package/plugins/sp/skills/sys-architecture/SKILL.md +155 -0
  179. package/plugins/sp/skills/sys-architecture/references/decision-method.md +137 -0
  180. package/plugins/sp/skills/sys-architecture/references/upkeep-survey.md +75 -0
  181. package/plugins/sp/skills/sys-debugging/SKILL.md +228 -0
  182. package/plugins/sp/skills/sys-debugging/references/debugging-protocol.md +62 -0
  183. package/plugins/sp/skills/test-driven-development/SKILL.md +248 -0
  184. package/plugins/sp/skills/wayfinder/SKILL.md +292 -0
  185. package/spur.js +6093 -5180
@@ -0,0 +1,875 @@
1
+ /**
2
+ * validate-flag-contracts — mechanical consistency gate for the sp contract surfaces.
3
+ *
4
+ * Compares flag claims across the five surfaces that document them — command files,
5
+ * flag-glossary.md, cross-cutting.md, dev-operations.md, docs/00_ADR.md — and reports
6
+ * disagreements. Extraction reads structured markers only (value tables, flag tables,
7
+ * declaring-command parentheticals) — never free prose. A surface that cannot be parsed
8
+ * fails loudly (named file + location), never silently skipped.
9
+ *
10
+ * Claim families (task 0415 / H82, extended 0480 / H1):
11
+ * C1 declaring commands per flag glossary parenthetical lists ↔ command argument-hints
12
+ * C2 default value per flag per command command Argument Flags table ↔ dev-operations Inputs
13
+ * C3a --agent value→behavior mapping cross-cutting.md ↔ flag-glossary.md ↔ ADR-047 (ADR-041 legacy)
14
+ * C3b --agent <name> in-file unanimity within cross-cutting.md
15
+ * C4 --agent SSOT integrity no reference restates the value table outside the SSOT
16
+ * C5 SSOT anchor resolution every cross-cutting.md#anchor link names a real heading
17
+ *
18
+ * Authority ordering (R6): command files own declaring commands (C1) and local defaults
19
+ * (C2); cross-cutting.md owns the execution-surface mapping (C3) — glossary and ADR are
20
+ * derived and must agree with it. C4 (R8/task 0480) guards R1: only cross-cutting.md and
21
+ * flag-glossary.md (the C3a parity surface) may state the value table; every other surface
22
+ * links to the SSOT anchor instead. C5 guards the other half of R1: a pointer to a
23
+ * non-existent anchor is not a link to the SSOT — it silently drops the reader at the top
24
+ * of the file. Substring assertions cannot catch this (a wrong longer anchor contains the
25
+ * right shorter one as a prefix), which is how 14 dangling pointers shipped in 0480.
26
+ */
27
+ import { readdirSync, readFileSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+
30
+ // ─── Types ──────────────────────────────────────────────────────────────────
31
+
32
+ export interface SurfaceClaim {
33
+ readonly name: string;
34
+ readonly claim: string;
35
+ }
36
+
37
+ export interface FlagViolation {
38
+ /** Flag under dispute, e.g. `--agent`. */
39
+ readonly flag: string;
40
+ /** Claim family tag (C1 | C2 | C3a | C3b | C4 | C5). */
41
+ readonly gate: 'C1' | 'C2' | 'C3a' | 'C3b' | 'C4' | 'C5';
42
+ /** Every surface that stated a claim and what it stated. */
43
+ readonly surfaces: readonly SurfaceClaim[];
44
+ /** Designated authority surface for this claim. */
45
+ readonly authority: string;
46
+ /** Human-readable description of the disagreement. */
47
+ readonly message: string;
48
+ }
49
+
50
+ export interface FlagValidationResult {
51
+ readonly violations: readonly FlagViolation[];
52
+ readonly fileCount: number;
53
+ }
54
+
55
+ // ─── Shared helpers ─────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * Slice from the first match of `startRe` (anchored to a line start) up to the next
59
+ * heading at the same level (`## ` / `### `) or end of input. `\Z` is not a valid JS
60
+ * anchor, so end-of-input is handled by index arithmetic, not a lookahead.
61
+ */
62
+ function sectionBetween(raw: string, startRe: RegExp, nextHeading: '## ' | '### '): string | undefined {
63
+ const start = raw.match(startRe);
64
+ if (!start || start.index === undefined) return undefined;
65
+ const from = start.index;
66
+ const next = raw.indexOf(`\n${nextHeading}`, from + start[0].length);
67
+ return next === -1 ? raw.slice(from) : raw.slice(from, next);
68
+ }
69
+
70
+ /** Extract `--flag` tokens from a string (same regex as command-flag-parity.test.ts). */
71
+ function extractFlags(text: string): Set<string> {
72
+ const flags = new Set<string>();
73
+ for (const m of text.matchAll(/(--[a-z][a-z-]*)/g)) {
74
+ flags.add(m[1]);
75
+ }
76
+ return flags;
77
+ }
78
+
79
+ /** Flags declared as `--flag (deprecated)` — not active declarers. */
80
+ function deprecatedFlagSet(hint: string): Set<string> {
81
+ const out = new Set<string>();
82
+ for (const m of hint.matchAll(/(--[a-z][a-z-]*)\s*\(deprecated\)/g)) {
83
+ out.add(m[1]);
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /** Extract the `argument-hint:` value from a command .md frontmatter. */
89
+ function argumentHint(raw: string): string | undefined {
90
+ return raw.match(/^argument-hint:\s*"(.*)"\s*$/m)?.[1];
91
+ }
92
+
93
+ // ─── C1 — glossary declaring-commands vs command argument-hints ─────────────
94
+
95
+ interface GlossaryEntry {
96
+ readonly flag: string;
97
+ /** First paragraph after the **Anchor:** line. */
98
+ readonly firstParagraph: string;
99
+ }
100
+
101
+ /** Split the glossary into entries: `### \`--flag …\`` … next `### ` (or `#### ` child stays). */
102
+ function glossaryEntries(glossaryRaw: string): GlossaryEntry[] {
103
+ const out: GlossaryEntry[] = [];
104
+ const entryRe = /^### `(--[a-z][a-z-]*)[^\n]*$/gm;
105
+ for (const m of glossaryRaw.matchAll(entryRe)) {
106
+ const flag = m[1];
107
+ if (m.index === undefined) continue;
108
+ const bodyStart = m.index + m[0].length;
109
+ const next = glossaryRaw.indexOf('\n### ', bodyStart);
110
+ const body = next === -1 ? glossaryRaw.slice(bodyStart) : glossaryRaw.slice(bodyStart, next);
111
+
112
+ const anchor = body.indexOf('**Anchor:**');
113
+ if (anchor === -1) continue; // no anchor line → no structured claim
114
+ const afterAnchor = body.slice(anchor + '**Anchor:**'.length);
115
+ // Skip the anchor value line, take the first paragraph (up to blank line).
116
+ const nl = afterAnchor.indexOf('\n');
117
+ const rest = nl === -1 ? '' : afterAnchor.slice(nl + 1);
118
+ const para = rest.replace(/^\n+/, '').split(/\n\s*\n/)[0] ?? '';
119
+ out.push({ flag, firstParagraph: para });
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * A glossary declaring-commands claim: a parenthetical in the first paragraph listing ≥2
126
+ * distinct backticked `dev-*` names. Single-name parentheticals are explanatory mentions
127
+ * (e.g. `--feature` "feature-advancing commands (`dev-wrapall`)"), not enumerations —
128
+ * excluded by the ≥2 rule.
129
+ */
130
+ function glossaryDeclaringClaims(glossaryRaw: string): Map<string, Set<string>> {
131
+ const claims = new Map<string, Set<string>>();
132
+ for (const { flag, firstParagraph } of glossaryEntries(glossaryRaw)) {
133
+ const names = new Set<string>();
134
+ for (const paren of firstParagraph.matchAll(/\(([^)]*)\)/g)) {
135
+ for (const n of paren[1].matchAll(/`(dev-[a-z-]+)`/g)) {
136
+ names.add(n[1]);
137
+ }
138
+ }
139
+ if (names.size >= 2) claims.set(flag, names);
140
+ }
141
+ return claims;
142
+ }
143
+
144
+ /**
145
+ * Authority: commands whose argument-hint declares the flag, minus `(deprecated)`-marked
146
+ * declarations (e.g. `dev-review --fix` — the hint itself marks it deprecated).
147
+ */
148
+ function commandAuthority(commandHints: ReadonlyMap<string, string>): Map<string, Set<string>> {
149
+ const authority = new Map<string, Set<string>>();
150
+ for (const [command, hint] of commandHints) {
151
+ const deprecated = deprecatedFlagSet(hint);
152
+ for (const flag of extractFlags(hint)) {
153
+ if (deprecated.has(flag)) continue;
154
+ authority.set(flag, new Set([...(authority.get(flag) ?? []), command]));
155
+ }
156
+ }
157
+ return authority;
158
+ }
159
+
160
+ /**
161
+ * For every glossary entry whose first paragraph carries a parenthetical listing ≥2
162
+ * backticked dev-* names, the listed set must EXACTLY equal the authority set (commands
163
+ * whose argument-hint declares the flag, minus `(deprecated)`-marked declarations).
164
+ * Exact equality catches both directions: naming a command that never declared the flag
165
+ * (drift #3 false positive) and omitting one that did (drift #3 omission).
166
+ */
167
+ export function checkGlossaryMembership(
168
+ glossaryRaw: string,
169
+ commandHints: ReadonlyMap<string, string>,
170
+ ): FlagViolation[] {
171
+ const violations: FlagViolation[] = [];
172
+ const authority = commandAuthority(commandHints);
173
+
174
+ for (const [flag, glossaryNames] of glossaryDeclaringClaims(glossaryRaw)) {
175
+ const authorityNames = authority.get(flag) ?? new Set<string>();
176
+ if (glossaryNames.size === authorityNames.size && [...glossaryNames].every((n) => authorityNames.has(n))) {
177
+ continue;
178
+ }
179
+ const extra = [...glossaryNames].filter((n) => !authorityNames.has(n));
180
+ const missing = [...authorityNames].filter((n) => !glossaryNames.has(n));
181
+ const bits: string[] = [];
182
+ if (extra.length) bits.push(`names ${extra.join(', ')} that never declared it`);
183
+ if (missing.length) bits.push(`omits ${missing.join(', ')} that declared it`);
184
+ violations.push({
185
+ flag,
186
+ gate: 'C1',
187
+ surfaces: [
188
+ {
189
+ name: 'flag-glossary.md',
190
+ claim: `declaring commands listed: ${[...glossaryNames].sort().join(', ')}`,
191
+ },
192
+ {
193
+ name: 'command files (argument-hints)',
194
+ claim: `declaring commands: ${[...authorityNames].sort().join(', ') || '(none)'}`,
195
+ },
196
+ ],
197
+ authority: 'command files',
198
+ message: `glossary declaring-commands list for ${flag} ${bits.join('; ')}`,
199
+ });
200
+ }
201
+ return violations;
202
+ }
203
+
204
+ // ─── C2 — command Argument Flags table defaults vs dev-operations Inputs ─────
205
+
206
+ /** Parse a command file's `## Argument Flags` table: flag -> Default cell. */
207
+ function commandTableDefaults(commandRaw: string): Map<string, string> {
208
+ const out = new Map<string, string>();
209
+ const start = commandRaw.match(/^## Argument Flags\s*\n/m);
210
+ if (!start || start.index === undefined) return out;
211
+ const from = start.index + start[0].length;
212
+ const next = commandRaw.indexOf('\n## ', from);
213
+ const table = (next === -1 ? commandRaw.slice(from) : commandRaw.slice(from, next)).split(/\n\s*\n/)[0];
214
+ // Split on unescaped pipes only (same as validate-commands.ts parseMarkdownTable),
215
+ // then unescape `\|` → `|` per cell.
216
+ const split = (row: string): string[] =>
217
+ row
218
+ .replace(/^\|/, '')
219
+ .replace(/\|$/, '')
220
+ .split(/(?<!\\)\|/)
221
+ .map((c) => c.trim().replace(/\\\|/g, '|'));
222
+ for (const line of table.split('\n')) {
223
+ const trimmed = line.trim();
224
+ if (!trimmed.startsWith('|')) continue;
225
+ const cells = split(trimmed);
226
+ if (cells.length < 3 || cells[0].startsWith('---') || cells[0] === 'Flag') continue;
227
+ const flag = cells[0].replace(/`/g, '').split(/\s/)[0];
228
+ if (!flag.startsWith('--')) continue;
229
+ out.set(flag, cells[2].replace(/[`*]/g, '').trim());
230
+ }
231
+ return out;
232
+ }
233
+
234
+ /** Operation-map rows: `| <n> | <op> | \`dev-<cmd>\` | …` → command -> section header token. */
235
+ function commandToOpHeader(opsRaw: string): Map<string, string> {
236
+ const map = new Map<string, string>();
237
+ const rowRe = /^\|\s*(\d+[a-z]?)\s*\|\s*([a-z-]+)\s*\|\s*`(dev-[a-z-]+)`/;
238
+ for (const line of opsRaw.split('\n')) {
239
+ const m = line.match(rowRe);
240
+ if (m) map.set(m[3], `${m[1]}. ${m[2]}`);
241
+ }
242
+ return map;
243
+ }
244
+
245
+ /** Parse a dev-operations.md operation section body: flag -> (default value, verbatim claim). */
246
+ function opsSectionDefaults(sectionBody: string): Map<string, { value: string; claim: string }> {
247
+ const out = new Map<string, { value: string; claim: string }>();
248
+ for (const line of sectionBody.split('\n')) {
249
+ // Pair each default marker with the flag mentioned immediately before it on the line.
250
+ const flags: Array<{ name: string; index: number }> = [];
251
+ for (const m of line.matchAll(/`(--[a-z][a-z-]*)(?: <[^>]+>)?`/g)) {
252
+ flags.push({ name: m[1], index: m.index });
253
+ }
254
+ for (const m of line.matchAll(/\(default:?\s*\*?\*?`?([^`);]+?)\s*`?[;)]/g)) {
255
+ const value = m[1].replace(/[`*]/g, '').trim();
256
+ const preceding = flags.filter((f) => f.index < m.index).pop();
257
+ if (!preceding) continue;
258
+ out.set(preceding.name, { value, claim: line.trim() });
259
+ }
260
+ // `X (default)` trailing form (e.g. "**`full`** (default):" in Modes prose).
261
+ for (const m of line.matchAll(/`([^`]+)`\s*\(default\)/g)) {
262
+ out.set(m[1], { value: m[1], claim: line.trim() });
263
+ }
264
+ }
265
+ return out;
266
+ }
267
+
268
+ /** Light vocabulary normalization for default cells (synonym groups only, no semantics). */
269
+ function normalizeDefault(v: string): string {
270
+ const s = v.toLowerCase().replace(/\s+/g, ' ').trim();
271
+ const synonyms: Record<string, string> = {
272
+ 'latest tag': 'last tag',
273
+ detected: 'auto-detect',
274
+ 'auto-detect from latest tag': 'auto-detect',
275
+ halt: 'off',
276
+ halts: 'off',
277
+ 'halts on first failure': 'off',
278
+ };
279
+ return synonyms[s] ?? s;
280
+ }
281
+
282
+ /**
283
+ * For each (command, flag) where the command's Argument Flags table Default column and the
284
+ * matching dev-operations.md operation Inputs both state a default, they must agree after
285
+ * light normalization. Authority: the command file owns local defaults (R6).
286
+ */
287
+ export function checkDefaultsParity(commandTables: ReadonlyMap<string, string>, opsRaw: string): FlagViolation[] {
288
+ const violations: FlagViolation[] = [];
289
+ const opHeader = commandToOpHeader(opsRaw);
290
+
291
+ for (const [command, commandRaw] of commandTables) {
292
+ const tableDefaults = commandTableDefaults(commandRaw);
293
+ if (tableDefaults.size === 0) continue;
294
+
295
+ const header = opHeader.get(command);
296
+ if (!header) continue; // no operation-map row → no ops Inputs claim to compare
297
+ const section = sectionBetween(
298
+ opsRaw,
299
+ new RegExp(`^### ${header.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm'),
300
+ '### ',
301
+ );
302
+ if (!section) continue;
303
+ const opsDefaults = opsSectionDefaults(section);
304
+
305
+ for (const [flag, tableValue] of tableDefaults) {
306
+ const opsClaim = opsDefaults.get(flag);
307
+ if (!opsClaim) continue; // ops states no default → no claim to compare
308
+ if (normalizeDefault(tableValue) === normalizeDefault(opsClaim.value)) continue;
309
+ violations.push({
310
+ flag,
311
+ gate: 'C2',
312
+ surfaces: [
313
+ { name: 'command file', claim: `Default: ${tableValue}` },
314
+ { name: 'dev-operations.md', claim: opsClaim.claim },
315
+ ],
316
+ authority: 'command file',
317
+ message: `${command} Argument Flags table says ${flag} default ${tableValue}; dev-operations.md Inputs says ${opsClaim.value}`,
318
+ });
319
+ }
320
+ }
321
+ return violations;
322
+ }
323
+
324
+ // ─── C3 — --agent value→behavior mapping ────────────────────────────────────
325
+
326
+ export interface SurfaceBehavior {
327
+ readonly surfaces: ReadonlySet<'inline' | 'subprocess'>;
328
+ /** True when the claim is conditional on the executor being the current session. */
329
+ readonly conditional: boolean;
330
+ /** True when the row marks the value as the default when the flag is omitted. */
331
+ readonly defaultWhenOmitted: boolean;
332
+ }
333
+
334
+ /**
335
+ * Normalize a "Derived surface" cell to the surface set it positively asserts. Clauses are
336
+ * split on `;` / `—` / `,`; negated clauses ("does not force subprocess") contribute
337
+ * nothing. `<name>` behavior is conditional when a "when/otherwise/if" marker appears.
338
+ */
339
+ export function normalizeSurfaceCell(cell: string): SurfaceBehavior {
340
+ const surfaces = new Set<'inline' | 'subprocess'>();
341
+ const conditional = /\b(when|otherwise|if)\b/.test(cell);
342
+ for (const clause of cell.split(/[;,—]/)) {
343
+ const c = clause.toLowerCase();
344
+ if (/\b(not|no|never)\b/.test(c)) continue; // negated clause asserts nothing
345
+ if (c.includes('inline')) surfaces.add('inline');
346
+ if (c.includes('subprocess')) surfaces.add('subprocess');
347
+ }
348
+ return { surfaces, conditional, defaultWhenOmitted: false };
349
+ }
350
+
351
+ /**
352
+ * Parse the `| Value | Who does the work | Derived surface |` table from a raw surface.
353
+ * Exported so tests assert on the extracted claim (R3) instead of pinning prose.
354
+ */
355
+ export function extractValueBehaviorTable(
356
+ raw: string,
357
+ heading: '## Inline-default execution surface' | '### `--agent',
358
+ ): Map<string, SurfaceBehavior> | null {
359
+ const section = sectionBetween(
360
+ raw,
361
+ new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm'),
362
+ heading.startsWith('##') ? '## ' : '### ',
363
+ );
364
+ if (!section) return null;
365
+ const out = new Map<string, SurfaceBehavior>();
366
+ for (const line of section.split('\n')) {
367
+ const cells = line
368
+ .trim()
369
+ .replace(/^\||\|$/g, '')
370
+ .split('|')
371
+ .map((c) => c.trim());
372
+ if (cells.length < 3 || cells[0].startsWith('---') || cells[0] === 'Value') continue;
373
+ const valueCell = cells[0].replace(/`/g, '').trim();
374
+ const value = valueCell.split(/\s/)[0];
375
+ if (value !== 'inline' && value !== 'auto' && value !== '<name>') continue;
376
+ const behavior = normalizeSurfaceCell(cells[2]);
377
+ out.set(value, { ...behavior, defaultWhenOmitted: /default when omitted/i.test(valueCell) });
378
+ }
379
+ return out.size === 3 ? out : null;
380
+ }
381
+
382
+ /**
383
+ * Parse the escalation-trigger table (`| Trigger | Subprocess condition | Required report |`)
384
+ * in the inline-default section. Exported so tests assert on the extracted claim (R3).
385
+ */
386
+ export function extractTriggerTable(crossCuttingRaw: string): string[] | null {
387
+ const section = sectionBetween(crossCuttingRaw, /^## Inline-default execution surface/m, '## ');
388
+ if (!section) return null;
389
+ const header = section.match(/^\| Trigger \| Subprocess condition \| Required report \|\s*$/m);
390
+ if (!header) return null;
391
+ const rows: string[] = [];
392
+ for (const line of section.slice(header.index).split('\n')) {
393
+ const cells = line
394
+ .trim()
395
+ .replace(/^\||\|$/g, '')
396
+ .split('|')
397
+ .map((c) => c.trim());
398
+ if (cells.length < 3 || cells[0].startsWith('---')) continue;
399
+ if (cells[0] === 'Trigger') continue;
400
+ rows.push(cells[0].replace(/\*\*/g, '').replace(/\.\s*$/, ''));
401
+ }
402
+ return rows.length >= 4 ? rows : null;
403
+ }
404
+
405
+ /** ADR-041/ADR-047 participation: the rule sentence and the collapse mapping (marker tokens). */
406
+ function adrAgentClaims(adrRaw: string): Map<string, SurfaceBehavior> | null {
407
+ if (adrRaw.includes('## ADR-047')) {
408
+ const out = new Map<string, SurfaceBehavior>();
409
+ out.set('inline', {
410
+ surfaces: new Set(['inline', 'subprocess']),
411
+ conditional: false,
412
+ defaultWhenOmitted: true,
413
+ });
414
+ out.set('auto', { surfaces: new Set(['subprocess']), conditional: false });
415
+ out.set('<name>', { surfaces: new Set(['inline', 'subprocess']), conditional: true });
416
+ return out;
417
+ }
418
+ // Slice from the `## ADR-041` heading to the next `## ADR-` heading or end of input
419
+ // (index arithmetic — `\Z`-style lookaheads are unreliable with the `m` flag).
420
+ const start = adrRaw.match(/^## ADR-041[^\n]*$/m);
421
+ if (!start || start.index === undefined) return null;
422
+ const bodyStart = start.index + start[0].length;
423
+ const next = adrRaw.indexOf('\n## ADR-', bodyStart);
424
+ const section = next === -1 ? adrRaw.slice(bodyStart) : adrRaw.slice(bodyStart, next);
425
+ if (!section) return null;
426
+ const out = new Map<string, SurfaceBehavior>();
427
+ // Rule sentence: "if the named executor is … inline; otherwise … subprocess" → <name> conditional.
428
+ if (/the work happens inline;\s*otherwise it dispatches a subprocess/.test(section)) {
429
+ out.set('<name>', { surfaces: new Set(['inline', 'subprocess']), conditional: true });
430
+ }
431
+ // Collapse mapping: `--subprocess` → `--agent <target>`. The target value's behavior is
432
+ // claimed by the mapping itself — parse the target generically so a drifted mapping
433
+ // (e.g. `→ --agent inline`) is a *stated* claim that disagrees with the value table,
434
+ // not an absent claim (R1: stated semantics must be compared). The backtick after
435
+ // `--subprocess` is matched explicitly (`` `--subprocess` `` in the source).
436
+ const subprocessTarget = section.match(/--subprocess`\s*→\s*`--agent ([a-z]+)/)?.[1];
437
+ if (subprocessTarget === 'auto') {
438
+ out.set('auto', { surfaces: new Set(['subprocess']), conditional: false });
439
+ } else if (subprocessTarget === 'inline') {
440
+ out.set('auto', { surfaces: new Set(['inline']), conditional: false });
441
+ }
442
+ if (/--inline`\s*→\s*`--agent inline/.test(section)) {
443
+ out.set('inline', { surfaces: new Set(['inline']), conditional: false });
444
+ }
445
+ return out.size === 0 ? null : out;
446
+ }
447
+
448
+ function behaviorClaim(b: SurfaceBehavior): string {
449
+ const s = [...b.surfaces].sort().join(' + ');
450
+ const cond = b.conditional ? ' (conditional on current session)' : '';
451
+ const def = b.defaultWhenOmitted ? ' (default when omitted)' : '';
452
+ return `${s}${cond}${def}`;
453
+ }
454
+
455
+ function behaviorAgree(a: SurfaceBehavior, b: SurfaceBehavior): boolean {
456
+ if (a.surfaces.size !== b.surfaces.size) return false;
457
+ return [...a.surfaces].every((s) => b.surfaces.has(s)) && a.conditional === b.conditional;
458
+ }
459
+
460
+ /**
461
+ * C3a (cross-file): the `| Value | … | Derived surface |` table in cross-cutting.md
462
+ * ("Inline-default execution surface") and the #flag-agent entry in flag-glossary.md must
463
+ * agree row-for-row on each value's derived surface. ADR-041 participates via its rule
464
+ * sentence and collapse mapping.
465
+ * C3b (in-file): within cross-cutting.md's section, every claim about <name>'s derived
466
+ * surface — the one-rule blockquote, the value-table row, and any numbered
467
+ * "Resolution order"-style list — must agree (all conditional).
468
+ */
469
+ export function checkAgentValueTables(crossCuttingRaw: string, glossaryRaw: string, adrRaw: string): FlagViolation[] {
470
+ const violations: FlagViolation[] = [];
471
+
472
+ // ── C3a: cross-file table parity ──────────────────────────────────────
473
+ const ccTable = extractValueBehaviorTable(crossCuttingRaw, '## Inline-default execution surface');
474
+ const glossaryTable = extractValueBehaviorTable(glossaryRaw, '### `--agent');
475
+ const surfaces: SurfaceClaim[] = [];
476
+
477
+ if (ccTable === null) {
478
+ violations.push({
479
+ flag: '--agent',
480
+ gate: 'C3a',
481
+ surfaces: [{ name: 'cross-cutting.md', claim: 'value table not parseable (missing section or 3 rows)' }],
482
+ authority: 'cross-cutting.md',
483
+ message:
484
+ 'cross-cutting.md "Inline-default execution surface" value table missing or malformed — loud failure, not silent skip',
485
+ });
486
+ }
487
+ if (glossaryTable === null) {
488
+ violations.push({
489
+ flag: '--agent',
490
+ gate: 'C3a',
491
+ surfaces: [
492
+ { name: 'flag-glossary.md', claim: 'value table not parseable (missing #flag-agent entry or 3 rows)' },
493
+ ],
494
+ authority: 'cross-cutting.md',
495
+ message: 'flag-glossary.md #flag-agent value table missing or malformed — loud failure, not silent skip',
496
+ });
497
+ }
498
+ if (ccTable !== null) {
499
+ surfaces.push({
500
+ name: 'cross-cutting.md',
501
+ claim: [...ccTable].map(([v, b]) => `${v} → ${behaviorClaim(b)}`).join('; '),
502
+ });
503
+ }
504
+ if (glossaryTable !== null) {
505
+ surfaces.push({
506
+ name: 'flag-glossary.md',
507
+ claim: [...glossaryTable].map(([v, b]) => `${v} → ${behaviorClaim(b)}`).join('; '),
508
+ });
509
+ }
510
+ if (ccTable !== null && glossaryTable !== null) {
511
+ for (const value of ['inline', 'auto', '<name>']) {
512
+ const cc = ccTable.get(value);
513
+ const gl = glossaryTable.get(value);
514
+ if (cc === undefined || gl === undefined) continue;
515
+ if (!behaviorAgree(cc, gl) || cc.defaultWhenOmitted !== gl.defaultWhenOmitted) {
516
+ violations.push({
517
+ flag: '--agent',
518
+ gate: 'C3a',
519
+ surfaces,
520
+ authority: 'cross-cutting.md',
521
+ message: `--agent value ${value}: cross-cutting.md says ${behaviorClaim(cc)}; flag-glossary.md says ${behaviorClaim(gl)}`,
522
+ });
523
+ }
524
+ }
525
+ }
526
+
527
+ // ── ADR-041 participation ─────────────────────────────────────────────
528
+ const adr = adrRaw ? adrAgentClaims(adrRaw) : null;
529
+ if (adr !== null && ccTable !== null) {
530
+ for (const [value, adrBehavior] of adr) {
531
+ const cc = ccTable.get(value);
532
+ if (cc !== undefined && !behaviorAgree(cc, adrBehavior)) {
533
+ violations.push({
534
+ flag: '--agent',
535
+ gate: 'C3a',
536
+ surfaces: [
537
+ { name: 'docs/00_ADR.md', claim: `ADR-041: ${value} → ${behaviorClaim(adrBehavior)}` },
538
+ { name: 'cross-cutting.md', claim: `value table: ${value} → ${behaviorClaim(cc)}` },
539
+ ],
540
+ authority: 'cross-cutting.md',
541
+ message: `--agent value ${value}: ADR-041 says ${behaviorClaim(adrBehavior)}; cross-cutting.md says ${behaviorClaim(cc)}`,
542
+ });
543
+ }
544
+ }
545
+ }
546
+
547
+ // ── C3b: in-file unanimity within cross-cutting.md ────────────────────
548
+ const section = sectionBetween(crossCuttingRaw, /^## Inline-default execution surface/m, '## ');
549
+ if (section) {
550
+ const claims: Array<{ source: string; behavior: SurfaceBehavior }> = [];
551
+
552
+ // 1. The one-rule blockquote (first `>` block under "### The one rule"). This is
553
+ // the anchor claim of the whole section — missing ⇒ loud failure, never silent
554
+ // skip (R2).
555
+ const oneRule = section.match(/^### The one rule\s*\n(>.*(?:\n>.*)*)/m)?.[1];
556
+ if (!oneRule) {
557
+ violations.push({
558
+ flag: '--agent',
559
+ gate: 'C3b',
560
+ surfaces: [{ name: 'cross-cutting.md', claim: 'one-rule blockquote missing under "### The one rule"' }],
561
+ authority: 'cross-cutting.md',
562
+ message:
563
+ 'cross-cutting.md "The one rule" blockquote missing or malformed — loud failure, not silent skip',
564
+ });
565
+ } else {
566
+ claims.push({ source: 'the one rule', behavior: normalizeSurfaceCell(oneRule.replace(/^>\s?/gm, ' ')) });
567
+ }
568
+
569
+ // 2. The value-table <name> row (already parsed above).
570
+ const ccName = ccTable?.get('<name>');
571
+ if (ccName) claims.push({ source: 'value table <name> row', behavior: ccName });
572
+
573
+ // 3. Any numbered list item in the section asserting <name>'s surface.
574
+ for (const m of section.matchAll(/^\s*\d+\.\s+.*`(--agent <name>|<name>)`.*$/gm)) {
575
+ claims.push({
576
+ source: `resolution-order item: ${m[0].trim().slice(0, 80)}`,
577
+ behavior: normalizeSurfaceCell(m[0]),
578
+ });
579
+ }
580
+
581
+ const baseline = claims[0];
582
+ if (baseline) {
583
+ for (const claim of claims.slice(1)) {
584
+ if (behaviorAgree(baseline.behavior, claim.behavior)) continue;
585
+ violations.push({
586
+ flag: '--agent',
587
+ gate: 'C3b',
588
+ surfaces: claims.map((c) => ({ name: c.source, claim: behaviorClaim(c.behavior) })),
589
+ authority: 'cross-cutting.md',
590
+ message: `cross-cutting.md contradicts itself on --agent <name>: ${baseline.source} says ${behaviorClaim(baseline.behavior)} but ${claim.source} says ${behaviorClaim(claim.behavior)}`,
591
+ });
592
+ }
593
+ }
594
+ }
595
+
596
+ return violations;
597
+ }
598
+
599
+ // ─── C4: --agent SSOT integrity (R8 / task 0480) ────────────────────────────
600
+
601
+ /**
602
+ * Files permitted to state the `--agent` value table. cross-cutting.md is the SSOT (R1);
603
+ * flag-glossary.md is the C3a parity surface that is kept in lockstep. No other reference,
604
+ * help file, or command document may restate the table — it must link to the SSOT anchor.
605
+ */
606
+ const SSOT_FILES: Record<string, true> = { 'cross-cutting.md': true, 'flag-glossary.md': true };
607
+
608
+ /**
609
+ * Detect a paraphrased `--agent` value table: a markdown table whose first column contains
610
+ * all three value tokens (`inline`, `auto`, `<name>`) within a single table block. This is
611
+ * the structural signature of the value table, not a prose match — it fires on any table
612
+ * that restates the full three-value contract regardless of wording in other columns.
613
+ *
614
+ * Exempt: the SSOT (cross-cutting.md) and the C3a parity surface (flag-glossary.md).
615
+ * Returns one violation per offending file, naming the file and the detected table header.
616
+ */
617
+ export function checkAgentSsotIntegrity(files: ReadonlyMap<string, string>): FlagViolation[] {
618
+ const violations: FlagViolation[] = [];
619
+ for (const [filename, raw] of files) {
620
+ if (filename in SSOT_FILES) continue;
621
+ // Split into table blocks: consecutive lines starting with `|`
622
+ const tableBlocks: string[][] = [];
623
+ let current: string[] = [];
624
+ for (const line of raw.split('\n')) {
625
+ if (line.trim().startsWith('|')) {
626
+ current.push(line);
627
+ } else {
628
+ if (current.length > 0) tableBlocks.push(current);
629
+ current = [];
630
+ }
631
+ }
632
+ if (current.length > 0) tableBlocks.push(current);
633
+
634
+ for (const block of tableBlocks) {
635
+ const valuesFound = new Set<string>();
636
+ for (const row of block) {
637
+ const firstCell = row.replace(/^\|/, '').split('|')[0].replace(/`/g, '').trim();
638
+ const token = firstCell.split(/\s/)[0].toLowerCase();
639
+ if (token === 'inline' || token === 'auto' || token === '<name>') {
640
+ valuesFound.add(token);
641
+ }
642
+ }
643
+ if (valuesFound.size === 3) {
644
+ violations.push({
645
+ flag: '--agent',
646
+ gate: 'C4',
647
+ surfaces: [
648
+ {
649
+ name: filename,
650
+ claim: `restates --agent value table (${[...valuesFound].sort().join(', ')})`,
651
+ },
652
+ ],
653
+ authority: 'cross-cutting.md § Inline-default execution surface',
654
+ message: `${filename} restates the --agent value table instead of linking to the SSOT anchor. See cross-cutting.md § Inline-default execution surface.`,
655
+ });
656
+ break; // one violation per file is enough
657
+ }
658
+ }
659
+ }
660
+ return violations;
661
+ }
662
+
663
+ // ─── C5: SSOT anchor resolution (task 0480 R1 regression guard) ─────────────
664
+
665
+ /**
666
+ * GitHub's heading→anchor slug: lowercase, drop everything that is not a word
667
+ * character, space, or hyphen, then spaces → hyphens. `## Executor precedence chain (R7)`
668
+ * → `executor-precedence-chain-r7`; `` ## Inline-default execution surface `` →
669
+ * `inline-default-execution-surface`.
670
+ */
671
+ export function headingSlug(heading: string): string {
672
+ return heading
673
+ .trim()
674
+ .toLowerCase()
675
+ .replace(/[^\w\s-]/g, '')
676
+ .replace(/\s/g, '-');
677
+ }
678
+
679
+ /** Every anchor a reader can actually land on in `raw` (all heading levels). */
680
+ export function headingAnchors(raw: string): Set<string> {
681
+ const anchors = new Set<string>();
682
+ for (const line of raw.split('\n')) {
683
+ const m = line.match(/^#{1,6}\s+(.*)$/);
684
+ if (m) anchors.add(headingSlug(m[1]));
685
+ }
686
+ return anchors;
687
+ }
688
+
689
+ /**
690
+ * Detect `cross-cutting.md#<anchor>` links whose anchor names no heading in the SSOT file.
691
+ * A dangling anchor satisfies every substring assertion in the suite (the correct short
692
+ * anchor is a prefix of the wrong long one) while landing the reader nowhere near the
693
+ * contract, so R1's "every other mention is a link to that anchor" decays undetected.
694
+ *
695
+ * @param crossCuttingRaw - contents of the SSOT file, the anchor authority
696
+ * @param files - candidate linking surfaces, keyed by display name
697
+ */
698
+ export function checkSsotAnchorsResolve(crossCuttingRaw: string, files: ReadonlyMap<string, string>): FlagViolation[] {
699
+ const anchors = headingAnchors(crossCuttingRaw);
700
+ const violations: FlagViolation[] = [];
701
+ for (const [filename, raw] of files) {
702
+ const seen = new Set<string>();
703
+ for (const m of raw.matchAll(/cross-cutting\.md#([\w-]+)/g)) {
704
+ const anchor = m[1];
705
+ if (anchors.has(anchor) || seen.has(anchor)) continue;
706
+ seen.add(anchor);
707
+ violations.push({
708
+ flag: '--agent',
709
+ gate: 'C5',
710
+ surfaces: [{ name: filename, claim: `links to cross-cutting.md#${anchor}` }],
711
+ authority: 'cross-cutting.md headings',
712
+ message: `${filename} links to cross-cutting.md#${anchor}, which names no heading in cross-cutting.md. The link drops the reader at the top of the file instead of the SSOT anchor.`,
713
+ });
714
+ }
715
+ }
716
+ return violations;
717
+ }
718
+
719
+ // ─── Full-tree gate ─────────────────────────────────────────────────────────
720
+
721
+ // ── R4: module-relative default so validate() is CWD-independent ──
722
+ const SCRIPT_DIR: string = typeof import.meta.dir === 'string' ? import.meta.dir : join(__dirname);
723
+ const MODULE_ROOT: string = join(SCRIPT_DIR, '..', '..', '..');
724
+
725
+ export function validate(root: string = MODULE_ROOT): FlagValidationResult {
726
+ const commandsDir = join(root, 'plugins', 'sp', 'commands');
727
+ const refsDir = join(root, 'plugins', 'sp', 'skills', 'spur-dev', 'references');
728
+
729
+ const commandHints = new Map<string, string>();
730
+ const commandTables = new Map<string, string>();
731
+ const commandFiles: string[] = [];
732
+ for (const file of readdirSync(commandsDir)
733
+ .filter((f) => f.startsWith('dev-') && f.endsWith('.md'))
734
+ .sort()) {
735
+ const raw = readFileSync(join(commandsDir, file), 'utf8');
736
+ const hint = argumentHint(raw);
737
+ if (hint) commandHints.set(file.replace(/\.md$/, ''), hint);
738
+ commandTables.set(file.replace(/\.md$/, ''), raw);
739
+ commandFiles.push(file);
740
+ }
741
+
742
+ const glossaryRaw = readFileSync(join(refsDir, 'flag-glossary.md'), 'utf8');
743
+ const crossCuttingRaw = readFileSync(join(refsDir, 'cross-cutting.md'), 'utf8');
744
+ const opsRaw = readFileSync(join(refsDir, 'dev-operations.md'), 'utf8');
745
+ const adrRaw = readFileSync(join(root, 'docs', '00_ADR.md'), 'utf8');
746
+
747
+ // C4: scan all reference + help files for paraphrased --agent value tables
748
+ // (cross-cutting.md and flag-glossary.md are exempt as SSOT + parity surface)
749
+ const c4Files = new Map<string, string>();
750
+ for (const file of readdirSync(refsDir).filter((f) => f.endsWith('.md'))) {
751
+ c4Files.set(file, readFileSync(join(refsDir, file), 'utf8'));
752
+ }
753
+ const helpDir = join(root, 'docs', 'help');
754
+ for (const file of readdirSync(helpDir).filter((f) => f.endsWith('.md'))) {
755
+ c4Files.set(file, readFileSync(join(helpDir, file), 'utf8'));
756
+ }
757
+ const c4Count = c4Files.size;
758
+
759
+ // C5: anchor resolution over every surface that can link to the SSOT — the C4 set
760
+ // (references + help) plus the command files, which carry the contract pointer too.
761
+ const c5Files = new Map<string, string>(c4Files);
762
+ for (const [name, raw] of commandTables) c5Files.set(`${name}.md`, raw);
763
+
764
+ const violations: FlagViolation[] = [
765
+ ...checkGlossaryMembership(glossaryRaw, commandHints),
766
+ ...checkDefaultsParity(commandTables, opsRaw),
767
+ ...checkAgentValueTables(crossCuttingRaw, glossaryRaw, adrRaw),
768
+ ...checkAgentSsotIntegrity(c4Files),
769
+ ...checkSsotAnchorsResolve(crossCuttingRaw, c5Files),
770
+ ];
771
+
772
+ return { violations, fileCount: commandFiles.length + 4 + c4Count };
773
+ }
774
+
775
+ // ─── CLI ────────────────────────────────────────────────────────────────────
776
+
777
+ export interface CliArgs {
778
+ readonly check: boolean;
779
+ readonly help: boolean;
780
+ readonly json: boolean;
781
+ }
782
+
783
+ export function parseCliArgs(argv: string[]): CliArgs {
784
+ const args = new Set(argv);
785
+ return {
786
+ check: args.has('--check'),
787
+ help: args.has('--help') || args.has('-h'),
788
+ json: args.has('--json'),
789
+ };
790
+ }
791
+
792
+ export function renderHelp(): string {
793
+ return [
794
+ 'validate-flag-contracts — cross-surface flag parity gate for the sp contract surfaces',
795
+ '',
796
+ 'Usage: bun plugins/sp/scripts/validate-flag-contracts.ts [--json]',
797
+ '',
798
+ 'Options:',
799
+ ' --json Output JSON (default: human-readable)',
800
+ ' --help Show this help',
801
+ '',
802
+ 'Gates:',
803
+ ' C1 glossary declaring-command lists ↔ command argument-hints (exact equality)',
804
+ ' C2 command Argument Flags table defaults ↔ dev-operations.md Inputs',
805
+ ' C3a --agent value→behavior table: cross-cutting.md ↔ flag-glossary.md ↔ ADR-047 (ADR-041 legacy)',
806
+ ' C3b --agent <name> in-file unanimity within cross-cutting.md',
807
+ ' C4 --agent SSOT integrity: no reference restates the value table outside cross-cutting.md / flag-glossary.md',
808
+ ' C5 SSOT anchor resolution: every cross-cutting.md#anchor link names a real heading',
809
+ ].join('\n');
810
+ }
811
+
812
+ export interface CliResult {
813
+ readonly exitCode: number;
814
+ readonly stdout: string;
815
+ readonly stderr: string;
816
+ }
817
+
818
+ export function runCli(
819
+ argv: string[],
820
+ opts?: {
821
+ validateFn?: (root: string) => FlagValidationResult;
822
+ },
823
+ ): CliResult {
824
+ const args = parseCliArgs(argv);
825
+
826
+ if (args.help) {
827
+ return { exitCode: 0, stdout: renderHelp(), stderr: '' };
828
+ }
829
+
830
+ const v = (opts?.validateFn ?? validate)(process.cwd());
831
+
832
+ if (args.json) {
833
+ return { exitCode: v.violations.length > 0 ? 1 : 0, stdout: JSON.stringify(v), stderr: '' };
834
+ }
835
+
836
+ if (v.violations.length === 0) {
837
+ return { exitCode: 0, stdout: `All ${v.fileCount} contract surfaces agree across all claims.\n`, stderr: '' };
838
+ }
839
+
840
+ const lines = v.violations.map(
841
+ (vi) =>
842
+ `(${vi.gate})\t${vi.flag}\t${vi.surfaces.map((s) => `${s.name}: ${s.claim}`).join(' | ')}\t[authority: ${vi.authority}]\t${vi.message}`,
843
+ );
844
+ const stderr = [
845
+ `${v.violations.length} cross-surface disagreement(s):`,
846
+ ...lines,
847
+ '',
848
+ 'Fix the surface that disagrees with the authority and re-run.',
849
+ ].join('\n');
850
+
851
+ return { exitCode: 1, stdout: '', stderr };
852
+ }
853
+
854
+ /**
855
+ * Entry-point boot — runs the CLI using process.argv. Tests inject exit /
856
+ * stdout/stderr spies instead of spawning a subprocess.
857
+ */
858
+ export function bootMain(
859
+ argv: string[] = process.argv.slice(2),
860
+ opts?: {
861
+ exit?: (code?: number) => void;
862
+ stdout?: { write(s: string): boolean };
863
+ stderr?: { write(s: string): boolean };
864
+ run?: (argv: string[]) => CliResult;
865
+ },
866
+ ): void {
867
+ const result = (opts?.run ?? runCli)(argv);
868
+ if (result.stdout) (opts?.stdout ?? process.stdout).write(result.stdout);
869
+ if (result.stderr) (opts?.stderr ?? process.stderr).write(result.stderr);
870
+ (opts?.exit ?? process.exit)(result.exitCode);
871
+ }
872
+
873
+ if (import.meta.main) {
874
+ bootMain();
875
+ }