@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.
- package/.claude-plugin/marketplace.json +15 -0
- package/README.md +15 -1
- package/config/templates/AGENTS.md +5 -3
- package/config/workflows/task-pipeline.yaml +103 -12
- package/package.json +15 -12
- package/plugins/README.md +655 -0
- package/plugins/sp/README.md +621 -0
- package/plugins/sp/agents/expert-spur.md +109 -0
- package/plugins/sp/agents/super-coder.md +126 -0
- package/plugins/sp/agents/super-planner.md +283 -0
- package/plugins/sp/agents/super-reviewer.md +154 -0
- package/plugins/sp/commands/dev-arch.md +30 -0
- package/plugins/sp/commands/dev-brainstorm.md +40 -0
- package/plugins/sp/commands/dev-changelog.md +28 -0
- package/plugins/sp/commands/dev-daily.md +29 -0
- package/plugins/sp/commands/dev-debug.md +29 -0
- package/plugins/sp/commands/dev-dogfood.md +33 -0
- package/plugins/sp/commands/dev-featurechange.md +130 -0
- package/plugins/sp/commands/dev-find-conflict.md +51 -0
- package/plugins/sp/commands/dev-find-issue.md +76 -0
- package/plugins/sp/commands/dev-find-next.md +60 -0
- package/plugins/sp/commands/dev-fixall.md +33 -0
- package/plugins/sp/commands/dev-gitmsg.md +28 -0
- package/plugins/sp/commands/dev-gtd.md +97 -0
- package/plugins/sp/commands/dev-handover.md +26 -0
- package/plugins/sp/commands/dev-idea.md +50 -0
- package/plugins/sp/commands/dev-next.md +31 -0
- package/plugins/sp/commands/dev-parallel.md +30 -0
- package/plugins/sp/commands/dev-plan.md +46 -0
- package/plugins/sp/commands/dev-refine.md +48 -0
- package/plugins/sp/commands/dev-refineall.md +65 -0
- package/plugins/sp/commands/dev-refresh.md +32 -0
- package/plugins/sp/commands/dev-reverse.md +32 -0
- package/plugins/sp/commands/dev-review.md +31 -0
- package/plugins/sp/commands/dev-run.md +63 -0
- package/plugins/sp/commands/dev-runall.md +80 -0
- package/plugins/sp/commands/dev-simplify.md +39 -0
- package/plugins/sp/commands/dev-unit.md +29 -0
- package/plugins/sp/commands/dev-verify.md +48 -0
- package/plugins/sp/commands/dev-verifyall.md +72 -0
- package/plugins/sp/commands/dev-wrap.md +30 -0
- package/plugins/sp/commands/dev-wrapall.md +33 -0
- package/plugins/sp/commands/rule-add.md +18 -0
- package/plugins/sp/commands/rule-refine.md +18 -0
- package/plugins/sp/commands/rule-scan.md +18 -0
- package/plugins/sp/commands/spur-init.md +22 -0
- package/plugins/sp/commands/workflow-add.md +18 -0
- package/plugins/sp/commands/workflow-refine.md +18 -0
- package/plugins/sp/hooks/agent-hint.ts +60 -0
- package/plugins/sp/hooks/careful-guard.ts +147 -0
- package/plugins/sp/hooks/context-post-tool.ts +325 -0
- package/plugins/sp/hooks/context-session-start.ts +182 -0
- package/plugins/sp/hooks/context-session-stop.ts +95 -0
- package/plugins/sp/hooks/hooks.json +51 -0
- package/plugins/sp/hooks/pi/guard-extension.ts +341 -0
- package/plugins/sp/hooks/task-write-guard.ts +84 -0
- package/plugins/sp/plugin.json +8 -0
- package/plugins/sp/scripts/batch-preflight.ts +204 -0
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +606 -0
- package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +363 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +168 -0
- package/plugins/sp/scripts/feature-sync-bounded.ts +451 -0
- package/plugins/sp/scripts/stage-registry-adapter.ts +1341 -0
- package/plugins/sp/scripts/task-size-precheck.ts +202 -0
- package/plugins/sp/scripts/validate-commands.ts +651 -0
- package/plugins/sp/scripts/validate-flag-contracts.ts +875 -0
- package/plugins/sp/skills/brainstorm/SKILL.md +361 -0
- package/plugins/sp/skills/brainstorm/agents/openai.yaml +14 -0
- package/plugins/sp/skills/brainstorm/examples/ideation-example.md +209 -0
- package/plugins/sp/skills/brainstorm/metadata.openclaw +7 -0
- package/plugins/sp/skills/brainstorm/references/grilling-interview.md +68 -0
- package/plugins/sp/skills/brainstorm/references/workflows.md +255 -0
- package/plugins/sp/skills/branch-workflow/SKILL.md +109 -0
- package/plugins/sp/skills/branch-workflow/references/branch-lifecycle.md +69 -0
- package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +100 -0
- package/plugins/sp/skills/code-implementation/SKILL.md +176 -0
- package/plugins/sp/skills/code-implementation/references/debugging.md +64 -0
- package/plugins/sp/skills/code-implementation/references/implementation-patterns.md +113 -0
- package/plugins/sp/skills/code-improvement/SKILL.md +231 -0
- package/plugins/sp/skills/code-improvement/references/deepening-signals.md +205 -0
- package/plugins/sp/skills/code-review/SKILL.md +112 -0
- package/plugins/sp/skills/code-review/references/receiving-code-review.md +70 -0
- package/plugins/sp/skills/code-review/references/review-lenses.md +117 -0
- package/plugins/sp/skills/code-review/references/self-review-checklist.md +53 -0
- package/plugins/sp/skills/code-review/references/verification-before-completion.md +68 -0
- package/plugins/sp/skills/code-simplification/SKILL.md +225 -0
- package/plugins/sp/skills/code-testing/SKILL.md +108 -0
- package/plugins/sp/skills/code-testing/references/stacks/bun-ts.md +165 -0
- package/plugins/sp/skills/code-testing/references/stacks/go.md +88 -0
- package/plugins/sp/skills/code-testing/references/stacks/python.md +117 -0
- package/plugins/sp/skills/code-testing/references/test-loop-breaker.md +37 -0
- package/plugins/sp/skills/code-testing/references/test-output-discipline.md +52 -0
- package/plugins/sp/skills/code-testing/references/unit-testing.md +212 -0
- package/plugins/sp/skills/code-verification/SKILL.md +535 -0
- package/plugins/sp/skills/code-verification/references/code-improvement.md +39 -0
- package/plugins/sp/skills/code-verification/references/secu-review.md +94 -0
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +141 -0
- package/plugins/sp/skills/conflict-finding/SKILL.md +254 -0
- package/plugins/sp/skills/conflict-finding/references/authority-resolution.md +258 -0
- package/plugins/sp/skills/conflict-finding/references/comparison-protocol.md +277 -0
- package/plugins/sp/skills/conflict-finding/references/finding-contract.md +346 -0
- package/plugins/sp/skills/conflict-finding/references/remediation-routing.md +276 -0
- package/plugins/sp/skills/daily-summary/SKILL.md +171 -0
- package/plugins/sp/skills/daily-summary/agents/openai.yaml +13 -0
- package/plugins/sp/skills/doc-evolve/SKILL.md +175 -0
- package/plugins/sp/skills/doc-evolve/references/operations.md +93 -0
- package/plugins/sp/skills/dogfood-testing/SKILL.md +608 -0
- package/plugins/sp/skills/dogfood-testing/references/monitor-ledger.md +211 -0
- package/plugins/sp/skills/dogfood-testing/references/report-template.md +351 -0
- package/plugins/sp/skills/doubt-driven-development/SKILL.md +136 -0
- package/plugins/sp/skills/functional-review/SKILL.md +364 -0
- package/plugins/sp/skills/functional-review/references/verdict-schema.md +133 -0
- package/plugins/sp/skills/indexed-context/SKILL.md +164 -0
- package/plugins/sp/skills/issue-finding/SKILL.md +424 -0
- package/plugins/sp/skills/issue-finding/agents/openai.yaml +15 -0
- package/plugins/sp/skills/issue-finding/examples/expected-findings.json +27 -0
- package/plugins/sp/skills/issue-finding/examples/session-test-loop.jsonl +23 -0
- package/plugins/sp/skills/issue-finding/references/session-formats.md +121 -0
- package/plugins/sp/skills/next-feature/SKILL.md +125 -0
- package/plugins/sp/skills/next-feature/references/handoff-routing.md +92 -0
- package/plugins/sp/skills/next-feature/references/proposal-contract.md +49 -0
- package/plugins/sp/skills/next-feature/references/ranking-rubric.md +52 -0
- package/plugins/sp/skills/next-feature/references/signal-derivation.md +67 -0
- package/plugins/sp/skills/next-router/SKILL.md +181 -0
- package/plugins/sp/skills/next-router/references/messages.md +90 -0
- package/plugins/sp/skills/next-router/references/routing-table.md +216 -0
- package/plugins/sp/skills/parallel-execution/SKILL.md +148 -0
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +100 -0
- package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +101 -0
- package/plugins/sp/skills/parallel-execution/references/result-synthesis.md +94 -0
- package/plugins/sp/skills/reverse-engineering/SKILL.md +375 -0
- package/plugins/sp/skills/reverse-engineering/agents/openai.yaml +18 -0
- package/plugins/sp/skills/source-driven-development/SKILL.md +124 -0
- package/plugins/sp/skills/spec-decomposition/SKILL.md +117 -0
- package/plugins/sp/skills/spec-decomposition/references/decomposition.md +539 -0
- package/plugins/sp/skills/spur-cli/SKILL.md +133 -0
- package/plugins/sp/skills/spur-cli/references/agent.md +179 -0
- package/plugins/sp/skills/spur-cli/references/features/acceptance-criteria.md +82 -0
- package/plugins/sp/skills/spur-cli/references/features/hierarchy-mece.md +177 -0
- package/plugins/sp/skills/spur-cli/references/features/roadmap-priority.md +84 -0
- package/plugins/sp/skills/spur-cli/references/features/verbs.md +122 -0
- package/plugins/sp/skills/spur-cli/references/features.md +234 -0
- package/plugins/sp/skills/spur-cli/references/init.md +119 -0
- package/plugins/sp/skills/spur-cli/references/message.md +98 -0
- package/plugins/sp/skills/spur-cli/references/rules/authoring-rules.md +212 -0
- package/plugins/sp/skills/spur-cli/references/rules/fine-tuning.md +148 -0
- package/plugins/sp/skills/spur-cli/references/rules/operations.md +228 -0
- package/plugins/sp/skills/spur-cli/references/rules/validation-and-extension.md +129 -0
- package/plugins/sp/skills/spur-cli/references/rules.md +209 -0
- package/plugins/sp/skills/spur-cli/references/serve.md +58 -0
- package/plugins/sp/skills/spur-cli/references/tasks/l3-guard-cheatsheet.md +98 -0
- package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +95 -0
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +361 -0
- package/plugins/sp/skills/spur-cli/references/tasks.md +278 -0
- package/plugins/sp/skills/spur-cli/references/team.md +133 -0
- package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +226 -0
- package/plugins/sp/skills/spur-cli/references/workflows/operations.md +211 -0
- package/plugins/sp/skills/spur-cli/references/workflows/validation-and-extension.md +129 -0
- package/plugins/sp/skills/spur-cli/references/workflows.md +355 -0
- package/plugins/sp/skills/spur-dev/SKILL.md +224 -0
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +196 -0
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +664 -0
- package/plugins/sp/skills/spur-dev/references/decision-brief.md +80 -0
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +546 -0
- package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +164 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +727 -0
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +350 -0
- package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +191 -0
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +417 -0
- package/plugins/sp/skills/spur-dev/references/gate-checklists.md +171 -0
- package/plugins/sp/skills/spur-dev/references/glossary.md +95 -0
- package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +80 -0
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +88 -0
- package/plugins/sp/skills/spur-dev/references/planning-workflow.md +302 -0
- package/plugins/sp/skills/spur-dev/references/product-planning.md +206 -0
- package/plugins/sp/skills/spur-dev/references/section-batching.md +24 -0
- package/plugins/sp/skills/sys-architecture/SKILL.md +155 -0
- package/plugins/sp/skills/sys-architecture/references/decision-method.md +137 -0
- package/plugins/sp/skills/sys-architecture/references/upkeep-survey.md +75 -0
- package/plugins/sp/skills/sys-debugging/SKILL.md +228 -0
- package/plugins/sp/skills/sys-debugging/references/debugging-protocol.md +62 -0
- package/plugins/sp/skills/test-driven-development/SKILL.md +248 -0
- package/plugins/sp/skills/wayfinder/SKILL.md +292 -0
- 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
|
+
}
|