@gobing-ai/spur 0.3.46 → 0.3.48

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 (137) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.example.yaml +42 -11
  3. package/config/templates/docs/99_PROJECT_CONSTITUTION.md +16 -4
  4. package/config/transition-shims.json +33 -0
  5. package/config/workflows/basic.yaml +2 -0
  6. package/config/workflows/docs-pipeline.yaml +2 -0
  7. package/config/workflows/feature-dev.yaml +8 -0
  8. package/config/workflows/idea-pipeline.yaml +10 -0
  9. package/config/workflows/planning-pipeline.yaml +4 -0
  10. package/config/workflows/task-pipeline.yaml +8 -0
  11. package/config/workflows/wayfinder-resolution.yaml +4 -0
  12. package/config/workflows/wrapup-pipeline.yaml +18 -1
  13. package/package.json +2 -2
  14. package/plugins/sp/agents/expert-spur.md +1 -0
  15. package/plugins/sp/commands/dev-arch.md +2 -1
  16. package/plugins/sp/commands/dev-brainstorm.md +2 -1
  17. package/plugins/sp/commands/dev-changelog.md +1 -0
  18. package/plugins/sp/commands/dev-daily.md +1 -0
  19. package/plugins/sp/commands/dev-debug.md +2 -1
  20. package/plugins/sp/commands/dev-dogfood.md +2 -1
  21. package/plugins/sp/commands/dev-featurechange.md +1 -0
  22. package/plugins/sp/commands/dev-find-conflict.md +2 -1
  23. package/plugins/sp/commands/dev-find-issue.md +20 -32
  24. package/plugins/sp/commands/dev-find-next.md +2 -1
  25. package/plugins/sp/commands/dev-fixall.md +1 -0
  26. package/plugins/sp/commands/dev-gitmsg.md +1 -0
  27. package/plugins/sp/commands/dev-gtd.md +1 -0
  28. package/plugins/sp/commands/dev-handover.md +1 -0
  29. package/plugins/sp/commands/dev-idea.md +1 -0
  30. package/plugins/sp/commands/dev-next.md +2 -1
  31. package/plugins/sp/commands/dev-parallel.md +2 -1
  32. package/plugins/sp/commands/dev-plan.md +2 -1
  33. package/plugins/sp/commands/dev-refine.md +5 -3
  34. package/plugins/sp/commands/dev-refineall.md +2 -1
  35. package/plugins/sp/commands/dev-refresh.md +2 -1
  36. package/plugins/sp/commands/dev-reverse.md +2 -1
  37. package/plugins/sp/commands/dev-review.md +2 -1
  38. package/plugins/sp/commands/dev-run.md +3 -2
  39. package/plugins/sp/commands/dev-runall.md +3 -2
  40. package/plugins/sp/commands/dev-simplify.md +2 -1
  41. package/plugins/sp/commands/dev-unit.md +2 -1
  42. package/plugins/sp/commands/dev-verify.md +2 -1
  43. package/plugins/sp/commands/dev-verifyall.md +2 -1
  44. package/plugins/sp/commands/dev-wrap.md +7 -5
  45. package/plugins/sp/commands/dev-wrapall.md +7 -5
  46. package/plugins/sp/commands/rule-add.md +1 -0
  47. package/plugins/sp/commands/rule-refine.md +1 -0
  48. package/plugins/sp/commands/rule-scan.md +1 -0
  49. package/plugins/sp/commands/spur-init.md +1 -0
  50. package/plugins/sp/commands/workflow-add.md +1 -0
  51. package/plugins/sp/commands/workflow-refine.md +1 -0
  52. package/plugins/sp/hooks/careful-guard.ts +5 -80
  53. package/plugins/sp/hooks/destructive-policy.ts +146 -0
  54. package/plugins/sp/hooks/pi/guard-extension.ts +33 -46
  55. package/plugins/sp/hooks/task-file-policy.ts +31 -0
  56. package/plugins/sp/hooks/task-write-guard.ts +4 -0
  57. package/plugins/sp/plugin.json +1 -1
  58. package/plugins/sp/references/roles.md +93 -0
  59. package/plugins/sp/scripts/feature-sync-bounded.ts +28 -2
  60. package/plugins/sp/scripts/stage-registry-adapter.ts +66 -31
  61. package/plugins/sp/scripts/surface-drift-inventory.ts +908 -0
  62. package/plugins/sp/scripts/task-size-precheck.ts +30 -4
  63. package/plugins/sp/scripts/transition-shim-check.ts +238 -0
  64. package/plugins/sp/scripts/validate-flag-contracts.ts +5 -2
  65. package/plugins/sp/skills/code-verification/SKILL.md +2 -0
  66. package/plugins/sp/skills/issue-finding/SKILL.md +124 -143
  67. package/plugins/sp/skills/issue-finding/examples/expected-findings.json +1 -1
  68. package/plugins/sp/skills/issue-finding/references/session-formats.md +85 -83
  69. package/plugins/sp/skills/next-router/SKILL.md +1 -1
  70. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +40 -2
  71. package/plugins/sp/skills/spur-cli/SKILL.md +3 -0
  72. package/plugins/sp/skills/spur-cli/references/agent.md +12 -7
  73. package/plugins/sp/skills/spur-cli/references/features.md +3 -0
  74. package/plugins/sp/skills/spur-cli/references/tasks.md +3 -0
  75. package/plugins/sp/skills/spur-cli/references/team.md +10 -3
  76. package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
  77. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +17 -0
  78. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +44 -23
  79. package/plugins/sp/skills/spur-dev/references/dev-operations.md +14 -11
  80. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +14 -12
  81. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +14 -3
  82. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +2 -0
  83. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +12 -2
  84. package/schemas/spur-config.schema.json +27 -3
  85. package/spur.js +11648 -7595
  86. package/web/_astro/BoardApp.8hiqShQn.js +1 -0
  87. package/web/_astro/{BoardApp.DKyrGxdo.js → BoardApp.BjQUNhuj.js} +74 -74
  88. package/web/_astro/{TaskDetail.6-27_LMa.js → TaskDetail.CVBuD6dF.js} +1 -1
  89. package/web/_astro/{arc.Df-9AQvS.js → arc.BMMjdODi.js} +1 -1
  90. package/web/_astro/{architectureDiagram-3BPJPVTR.VAI_-paS.js → architectureDiagram-3BPJPVTR.BU5ShzXf.js} +1 -1
  91. package/web/_astro/{blockDiagram-GPEHLZMM.DFpUY1ue.js → blockDiagram-GPEHLZMM.Bj1iEqPD.js} +1 -1
  92. package/web/_astro/{c4Diagram-AAUBKEIU.CF8doOpg.js → c4Diagram-AAUBKEIU.vX8wepCL.js} +1 -1
  93. package/web/_astro/channel.EwdSemIC.js +1 -0
  94. package/web/_astro/{chunk-2J33WTMH.BnjK3fjt.js → chunk-2J33WTMH.BKAipTym.js} +1 -1
  95. package/web/_astro/{chunk-4BX2VUAB.x6ZDnJKq.js → chunk-4BX2VUAB.B68XkPG7.js} +1 -1
  96. package/web/_astro/{chunk-55IACEB6.zY-0uu7w.js → chunk-55IACEB6.BmeDLcrc.js} +1 -1
  97. package/web/_astro/{chunk-727SXJPM.BZxKg_Vi.js → chunk-727SXJPM.PDuBA3Kw.js} +1 -1
  98. package/web/_astro/{chunk-AQP2D5EJ.Cpi9G9Td.js → chunk-AQP2D5EJ.C7A044za.js} +1 -1
  99. package/web/_astro/{chunk-FMBD7UC4.DWTB-Pif.js → chunk-FMBD7UC4.BtzKKFqR.js} +1 -1
  100. package/web/_astro/{chunk-ND2GUHAM.BPDQbiOG.js → chunk-ND2GUHAM.BJuDeeOy.js} +1 -1
  101. package/web/_astro/{chunk-QZHKN3VN.BRWIcuoM.js → chunk-QZHKN3VN.DSeMDgcQ.js} +1 -1
  102. package/web/_astro/{classDiagram-4FO5ZUOK.mGTCZsDO.js → classDiagram-4FO5ZUOK.D53Q4tCw.js} +1 -1
  103. package/web/_astro/{classDiagram-v2-Q7XG4LA2.mGTCZsDO.js → classDiagram-v2-Q7XG4LA2.D53Q4tCw.js} +1 -1
  104. package/web/_astro/{cose-bilkent-S5V4N54A.D1GEut-z.js → cose-bilkent-S5V4N54A.c712AFRH.js} +1 -1
  105. package/web/_astro/{dagre-BM42HDAG.BV0XG9Do.js → dagre-BM42HDAG.D-idisph.js} +1 -1
  106. package/web/_astro/{diagram-2AECGRRQ.DzpYxsjo.js → diagram-2AECGRRQ.DLgnsJCU.js} +1 -1
  107. package/web/_astro/{diagram-5GNKFQAL.Cm9YzJh4.js → diagram-5GNKFQAL.BiaxBVqx.js} +1 -1
  108. package/web/_astro/{diagram-KO2AKTUF.BjhottUj.js → diagram-KO2AKTUF.C8HX1vd8.js} +1 -1
  109. package/web/_astro/{diagram-LMA3HP47.BFsQW5kb.js → diagram-LMA3HP47.CfqDLLes.js} +1 -1
  110. package/web/_astro/{diagram-OG6HWLK6.8pdpzSWO.js → diagram-OG6HWLK6.15SDiEed.js} +1 -1
  111. package/web/_astro/{erDiagram-TEJ5UH35.Bd7KUJmJ.js → erDiagram-TEJ5UH35.DksYtOYM.js} +1 -1
  112. package/web/_astro/{flowDiagram-I6XJVG4X.7LWffkaE.js → flowDiagram-I6XJVG4X.DR_Au-HV.js} +1 -1
  113. package/web/_astro/{ganttDiagram-6RSMTGT7.BeDcO5tI.js → ganttDiagram-6RSMTGT7.CHhHrffI.js} +1 -1
  114. package/web/_astro/{gitGraphDiagram-PVQCEYII.Ca4n730A.js → gitGraphDiagram-PVQCEYII.B2Xehvam.js} +1 -1
  115. package/web/_astro/{index.Dbvuw6d4.css → index.DAxu50UF.css} +1 -1
  116. package/web/_astro/{infoDiagram-5YYISTIA.B0OakQYb.js → infoDiagram-5YYISTIA.C9c3CNNN.js} +1 -1
  117. package/web/_astro/{ishikawaDiagram-YF4QCWOH.DSmNQe-1.js → ishikawaDiagram-YF4QCWOH.BibUHkh8.js} +1 -1
  118. package/web/_astro/{journeyDiagram-JHISSGLW.Cy5ruEUu.js → journeyDiagram-JHISSGLW.BYoVHiyO.js} +1 -1
  119. package/web/_astro/{kanban-definition-UN3LZRKU.CUJXub0p.js → kanban-definition-UN3LZRKU.CM1K5wHE.js} +1 -1
  120. package/web/_astro/{linear.DC1jCCXn.js → linear.SPpjJUb-.js} +1 -1
  121. package/web/_astro/{mermaid.core.DxVP99Ab.js → mermaid.core.BAgx3nnb.js} +4 -4
  122. package/web/_astro/{mindmap-definition-RKZ34NQL.D0MaV6sJ.js → mindmap-definition-RKZ34NQL.D35oPG1R.js} +1 -1
  123. package/web/_astro/{pieDiagram-4H26LBE5.DCC6_q32.js → pieDiagram-4H26LBE5.DiWuRwk7.js} +1 -1
  124. package/web/_astro/{quadrantDiagram-W4KKPZXB.BeUOAM7C.js → quadrantDiagram-W4KKPZXB.B9PBzTWn.js} +1 -1
  125. package/web/_astro/{requirementDiagram-4Y6WPE33.Dbl4MASO.js → requirementDiagram-4Y6WPE33.CYuuamFN.js} +1 -1
  126. package/web/_astro/{sankeyDiagram-5OEKKPKP.HsLg0VS4.js → sankeyDiagram-5OEKKPKP.W24UhhtD.js} +1 -1
  127. package/web/_astro/{sequenceDiagram-3UESZ5HK.DT7DJTnZ.js → sequenceDiagram-3UESZ5HK.BpbNjA51.js} +1 -1
  128. package/web/_astro/{stateDiagram-AJRCARHV.d_ju1Vr1.js → stateDiagram-AJRCARHV.DqVsHudf.js} +1 -1
  129. package/web/_astro/{stateDiagram-v2-BHNVJYJU.DMCAjMJ4.js → stateDiagram-v2-BHNVJYJU.CzwHYX81.js} +1 -1
  130. package/web/_astro/{timeline-definition-PNZ67QCA.DNOHr62_.js → timeline-definition-PNZ67QCA.Bc3B6djw.js} +1 -1
  131. package/web/_astro/{vennDiagram-CIIHVFJN.B7dUy-1W.js → vennDiagram-CIIHVFJN.C-D5rh8O.js} +1 -1
  132. package/web/_astro/{wardley-L42UT6IY.DEqOXvBh.js → wardley-L42UT6IY.D7PdYCqn.js} +1 -1
  133. package/web/_astro/{wardleyDiagram-YWT4CUSO.BCRb2p6x.js → wardleyDiagram-YWT4CUSO.CwmJKXF3.js} +1 -1
  134. package/web/_astro/{xychartDiagram-2RQKCTM6.NxVQLdBh.js → xychartDiagram-2RQKCTM6.avDYnLsb.js} +1 -1
  135. package/web/index.html +2 -2
  136. package/web/_astro/BoardApp.Ce6zJYAH.js +0 -1
  137. package/web/_astro/channel.Uhm9O3UV.js +0 -1
@@ -0,0 +1,908 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * surface-drift-inventory — re-runnable drift inventory for `plugins/sp` and
4
+ * the tracked workflow-YAML SSOT tree (`.spur/workflows` symlink target)
5
+ * against the live source-local `spur` CLI
6
+ * (feature I3, task 0539 R1/R2).
7
+ *
8
+ * Method (mechanical first, prose second — task 0539 Design):
9
+ * A. Plugin assertions: extract every `spur`-invocation (backticked spans, fenced
10
+ * code lines, YAML scalars), every `| Verb |` table row, and every flag span
11
+ * under a verb heading in the noun-mapped `spur-cli` references; check
12
+ * nouns/verbs/flags against live `--help` captures (source-local entry via
13
+ * the I2 helper). `--json` shapes are captured by EXECUTING a read-only probe
14
+ * list; mutating commands are recorded unverified, never passing.
15
+ * B. Scripts: extract the argv arrays `runSpur`/`runSpurJson` build and
16
+ * help-check them; execute the two spur-shelling scripts against a fake bin.
17
+ * C. Hooks: `hooks.json` parses, every referenced hook script exists, the `pi`
18
+ * extension from plugin.json exists. Host-side event-name contract is not
19
+ * in this repo — recorded unverified.
20
+ * D. Workflows: `spur workflow validate` (live engine, schema + semantic) and a
21
+ * `--dry-run` transition walk for all ten definitions; `.spur/workflows`
22
+ * symlink realpath vs its tracked SSOT target tree.
23
+ *
24
+ * Output: markdown inventory on stdout-path via `--out`; exit 1 when any
25
+ * CONFIRMED mismatch remains (ok after repair); unverified entries never fail.
26
+ *
27
+ * Usage: bun plugins/sp/scripts/surface-drift-inventory.ts [--out docs/tasks2/0539-inventory.md]
28
+ */
29
+
30
+ import { execFileSync } from 'node:child_process';
31
+ import {
32
+ chmodSync,
33
+ existsSync,
34
+ mkdirSync,
35
+ mkdtempSync,
36
+ readdirSync,
37
+ readFileSync,
38
+ realpathSync,
39
+ rmSync,
40
+ writeFileSync,
41
+ } from 'node:fs';
42
+ import { tmpdir } from 'node:os';
43
+ import { dirname, join, relative, resolve } from 'node:path';
44
+ import { captureCliSurface } from '../tests/helpers/cli-surface';
45
+
46
+ const REPO_ROOT = resolve(import.meta.dir, '..', '..', '..');
47
+ const PLUGIN_ROOT = join(REPO_ROOT, 'plugins', 'sp');
48
+ const CLI_ENTRY = join(REPO_ROOT, 'apps', 'cli', 'src', 'index.ts');
49
+ const rel = (p: string): string => relative(REPO_ROOT, p);
50
+
51
+ // ─── Rows ───────────────────────────────────────────────────────────────────
52
+
53
+ interface Row {
54
+ asserted: string;
55
+ method: string;
56
+ status: 'ok' | 'mismatch' | 'unverified';
57
+ actual: string;
58
+ occurrences: { file: string; line: number }[];
59
+ }
60
+
61
+ export const rows: Row[] = [];
62
+
63
+ export function record(
64
+ asserted: string,
65
+ method: string,
66
+ status: Row['status'],
67
+ actual: string,
68
+ occ: { file: string; line: number },
69
+ ): Row {
70
+ let row = rows.find((r) => r.asserted === asserted && r.method === method);
71
+ if (!row) {
72
+ row = { asserted, method, status, actual, occurrences: [] };
73
+ rows.push(row);
74
+ }
75
+ if (!row.occurrences.some((o) => o.file === occ.file && o.line === occ.line)) {
76
+ row.occurrences.push(occ);
77
+ }
78
+ if (status === 'mismatch') {
79
+ row.status = 'mismatch';
80
+ row.actual = actual;
81
+ }
82
+ return row;
83
+ }
84
+
85
+ // ─── Live surface captures (cached) ─────────────────────────────────────────
86
+
87
+ /** Exported so unit tests can seed a fake live surface and keep the verdict logic hermetic. */
88
+ export const surfaceCache = new Map<string, { commands: string[]; flags: string[] }>();
89
+
90
+ function surface(path: string[]): { commands: string[]; flags: string[] } {
91
+ const key = path.join(' ');
92
+ let s = surfaceCache.get(key);
93
+ if (!s) {
94
+ const cap = captureCliSurface(path);
95
+ s = { commands: cap.commands, flags: cap.flags };
96
+ surfaceCache.set(key, s);
97
+ }
98
+ return s;
99
+ }
100
+
101
+ /** How the live-CLI sweeps below reach the CLI. Injectable so unit tests can drive them hermetically. */
102
+ export type CliRunner = (args: string[], timeoutMs?: number) => { exit: number; out: string; err: string };
103
+
104
+ export function runCli(args: string[], timeoutMs = 30_000): { exit: number; out: string; err: string } {
105
+ try {
106
+ const out = execFileSync(process.execPath, ['run', CLI_ENTRY, ...args], {
107
+ cwd: REPO_ROOT,
108
+ encoding: 'utf8',
109
+ timeout: timeoutMs,
110
+ stdio: ['ignore', 'pipe', 'pipe'],
111
+ });
112
+ return { exit: 0, out, err: '' };
113
+ } catch (e) {
114
+ const err = e as { status?: number; stdout?: string; stderr?: string; killed?: boolean; code?: string };
115
+ return {
116
+ // A timeout must map to 124 — sweepWorkflows reads that as "did not terminate"
117
+ // (unverified) rather than reporting a hung dry-run as a clean walk. Bun signals it
118
+ // as code ETIMEDOUT with `killed` undefined and a null status; Node sets `killed`.
119
+ exit: err.killed || err.code === 'ETIMEDOUT' ? 124 : (err.status ?? 1),
120
+ out: err.stdout ?? '',
121
+ err: (err.stderr ?? '').slice(0, 400),
122
+ };
123
+ }
124
+ }
125
+
126
+ interface Parsed {
127
+ nouns: string[];
128
+ verbs: string[];
129
+ flags: string[];
130
+ }
131
+
132
+ const PLACEHOLDER = /^([<[{($#"'`]|…|\.\.\.)/;
133
+
134
+ /** Normalize an asserted invocation span into nouns / verbs / flags. Null when it asserts nothing. */
135
+ export function parseInvocation(spanRaw: string): Parsed | null {
136
+ let s = spanRaw.trim().replace(/[.,;:]+$/, '');
137
+ s = s.replace(/^(?:bun|bunx|npx)\s+(?:run\s+)?/, '');
138
+ // The monorepo dev form (`bun run apps/cli/src/index.ts <noun> <verb>`) names no
139
+ // `spur` binary once the entry path is stripped, so the `^spur` gate below must not
140
+ // apply to it — it used to, which dropped every claim written in the form AGENTS.md
141
+ // mandates for local development.
142
+ const viaEntryPath = /^apps\/cli\/src\/index\.ts\s*/.test(s);
143
+ s = s.replace(/^apps\/cli\/src\/index\.ts\s*/, '');
144
+ if (!viaEntryPath) {
145
+ if (!/^spur\b/.test(s) || s === 'spur') return null;
146
+ s = s.replace(/^spur\s+/, '');
147
+ }
148
+ if (!s) return null;
149
+ // Split on shell separators only. `>` is deliberately excluded: it appears inside
150
+ // the `<wbs>` / `<path>` placeholders this corpus documents flags with, and
151
+ // truncating there silently discarded every flag after the first placeholder.
152
+ s = (s.split(/[;&|]/)[0] ?? s).trim();
153
+ if (!s) return null;
154
+ const tokens = s
155
+ .split(/\s+/)
156
+ .filter(Boolean)
157
+ .filter((t) => t === '…' || t === '...' || (!PLACEHOLDER.test(t) && !/^[A-Z]/.test(t) && !/["']/.test(t)));
158
+ const expand = (t: string): string[] => t.split(/[|/]/).filter((p) => /^[a-z][a-z0-9-]*$/.test(p));
159
+ const nouns: string[] = [];
160
+ const verbs: string[] = [];
161
+ const flags: string[] = [];
162
+ let positional = 0;
163
+ for (const t of tokens) {
164
+ if (t === '…' || t === '...') {
165
+ positional = 99;
166
+ continue;
167
+ } // elided prose — later tokens are not nouns/verbs
168
+ const flag = t.match(/^(-{1,2}[A-Za-z][A-Za-z0-9-]*)/);
169
+ if (flag) {
170
+ flags.push(flag[1] ?? '');
171
+ positional = 99;
172
+ continue;
173
+ }
174
+ if (positional === 0) {
175
+ nouns.push(...expand(t));
176
+ positional = 1;
177
+ } else if (positional === 1) {
178
+ verbs.push(...expand(t));
179
+ positional = 2;
180
+ }
181
+ }
182
+ if (nouns.length === 0 && verbs.length === 0 && flags.length === 0) return null;
183
+ if (process.env.DEBUG_SPANS === '1') {
184
+ process.stderr.write(`SPAN ${JSON.stringify({ spanRaw, nouns, verbs, flags })}\n`);
185
+ }
186
+ return { nouns, verbs, flags };
187
+ }
188
+
189
+ /** All backticked spans in one line. */
190
+ export function backticks(line: string): string[] {
191
+ return [...line.matchAll(/`([^`\n]+)`/g)].map((m) => m[1] ?? '');
192
+ }
193
+
194
+ /** Candidate invocation spans from one line: backticked + bare shell-style matches. */
195
+ export function lineInvocationSpans(line: string): string[] {
196
+ const spans = backticks(line).filter((s) => /(?:^|\s)(?:spur|apps\/cli\/src\/index\.ts)\s/.test(s));
197
+ const bare = [
198
+ ...line.matchAll(
199
+ /(?:^|[`"'=;&|;(]\s*)(?:bun(?:\s+run)?\s+)?(?:apps\/cli\/src\/index\.ts|spur)\s+[^\s`"'|;&)]+/g,
200
+ ),
201
+ ].map((m) => (m[0] ?? '').trim());
202
+ return [...spans, ...bare];
203
+ }
204
+
205
+ /** A pure flag list (backticked "--from-answer <path> --folder" spans). */
206
+ export function isFlagSpan(span: string): boolean {
207
+ const toks = span.trim().split(/\s+/);
208
+ return toks.length > 0 && toks.every((t) => /^-{1,2}[A-Za-z][A-Za-z0-9-]*/.test(t) || PLACEHOLDER.test(t));
209
+ }
210
+
211
+ export function flagNames(span: string): string[] {
212
+ return [...span.matchAll(/-{1,2}[A-Za-z][A-Za-z0-9-]*/g)].map((m) => m[0] ?? '');
213
+ }
214
+
215
+ /** Files in the R1 surfaces: commands/, skills/ (incl. references), scripts/, hooks/. */
216
+ export function walk(dir: string, exts: string[], out: string[] = []): string[] {
217
+ if (!existsSync(dir)) return out;
218
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
219
+ const p = join(dir, e.name);
220
+ if (e.isDirectory()) {
221
+ if (e.name === 'tests' || e.name === 'evals') continue; // the harness itself is out of R1 scope
222
+ walk(p, exts, out);
223
+ } else if (exts.some((x) => e.name.endsWith(x))) {
224
+ out.push(p);
225
+ }
226
+ }
227
+ return out;
228
+ }
229
+
230
+ /** Noun mapped from a spur-cli reference path (references/tasks.md, references/tasks/x.md -> task). */
231
+ export function nounOfReference(file: string): string | null {
232
+ const map: Record<string, string> = {
233
+ tasks: 'task',
234
+ features: 'feature',
235
+ rules: 'rule',
236
+ workflows: 'workflow',
237
+ agent: 'agent',
238
+ message: 'message',
239
+ team: 'team',
240
+ init: 'init',
241
+ serve: 'serve',
242
+ };
243
+ const parts = file.split(/[\\/]/);
244
+ const base = (parts.pop() ?? '').replace(/\.md$/, '');
245
+ const parent = parts.at(-1) ?? '';
246
+ const refsIdx = parts.indexOf('references');
247
+ const refsChild = refsIdx >= 0 ? (parts[refsIdx + 1] ?? '') : '';
248
+ return map[base] ?? map[parent] ?? map[refsChild] ?? null;
249
+ }
250
+
251
+ // ─── A. Plugin assertion checks ─────────────────────────────────────────────
252
+
253
+ export function checkNounVerbFlags(
254
+ nouns: string[],
255
+ verbs: string[],
256
+ flags: string[],
257
+ occ: { file: string; line: number },
258
+ ): void {
259
+ const root = surface([]);
260
+ for (const noun of nouns) {
261
+ if (noun === 'help' || /^(foo|bar|baz)$/.test(noun)) continue;
262
+ if (!root.commands.includes(noun)) {
263
+ record(
264
+ `spur ${noun}`,
265
+ 'help-capture(root)',
266
+ 'mismatch',
267
+ `noun absent from live root commands (${root.commands.join(', ')})`,
268
+ occ,
269
+ );
270
+ continue;
271
+ }
272
+ const ns = surface([noun]);
273
+ for (const verb of verbs) {
274
+ if (verb === 'help') continue;
275
+ if (!ns.commands.includes(verb)) {
276
+ record(
277
+ `spur ${noun} ${verb}`,
278
+ `help-capture(spur ${noun})`,
279
+ 'mismatch',
280
+ `verb absent (live verbs: ${ns.commands.join(', ')})`,
281
+ occ,
282
+ );
283
+ continue;
284
+ }
285
+ const vs = surface([noun, verb]);
286
+ record(`spur ${noun} ${verb}`, `help-capture(spur ${noun})`, 'ok', 'verb present', occ);
287
+ for (const f of flags) {
288
+ record(
289
+ `spur ${noun} ${verb} ${f}`,
290
+ `help-capture(spur ${noun} ${verb})`,
291
+ vs.flags.includes(f) ? 'ok' : 'mismatch',
292
+ vs.flags.includes(f) ? 'flag present' : `flag absent (live flags: ${vs.flags.join(', ')})`,
293
+ occ,
294
+ );
295
+ }
296
+ }
297
+ if (verbs.length === 0) {
298
+ record(`spur ${noun} (noun)`, `help-capture(spur ${noun})`, 'ok', `${ns.commands.length} live verbs`, occ);
299
+ for (const f of flags) {
300
+ if (ns.flags.includes(f)) {
301
+ record(`spur ${noun} ${f}`, `help-capture(spur ${noun})`, 'ok', 'noun-level flag present', occ);
302
+ } else {
303
+ const withFlag = ns.commands.filter((v) => surface([noun, v]).flags.includes(f));
304
+ record(
305
+ `spur ${noun} ${f}`,
306
+ `help-capture(spur ${noun})`,
307
+ withFlag.length > 0 ? 'unverified' : 'mismatch',
308
+ withFlag.length > 0
309
+ ? `noun-level shorthand — flag present on ${withFlag.length}/${ns.commands.length} verbs`
310
+ : `flag absent (noun and every verb; live noun flags: ${ns.flags.join(', ')})`,
311
+ occ,
312
+ );
313
+ }
314
+ }
315
+ }
316
+ }
317
+ for (const verb of verbs) {
318
+ if (nouns.length === 0 && root.commands.includes(verb)) {
319
+ record(`spur ${verb}`, 'help-capture(root)', 'ok', 'root verb present', occ);
320
+ }
321
+ }
322
+ }
323
+
324
+ export function sweepPluginTrees(root: string = PLUGIN_ROOT): void {
325
+ const files = [
326
+ ...walk(join(root, 'commands'), ['.md']),
327
+ ...walk(join(root, 'skills'), ['.md']),
328
+ ...walk(join(root, 'scripts'), ['.ts']),
329
+ ...walk(join(root, 'hooks'), ['.ts', '.json']),
330
+ ];
331
+ for (const file of files) {
332
+ const text = readFileSync(file, 'utf8');
333
+ const lines = text.split(/\r?\n/);
334
+ const isRef = file.includes(join('skills', 'spur-cli', 'references'));
335
+ const refNoun = isRef ? nounOfReference(file) : null;
336
+ let headingVerb: string | null = null;
337
+ let inVerbTable = false;
338
+ let inFence = false;
339
+ lines.forEach((line: string, i: number) => {
340
+ const occ = { file: rel(file), line: i + 1 };
341
+ if (/^\s*```/.test(line)) {
342
+ inFence = !inFence;
343
+ return;
344
+ }
345
+ if (!inFence) {
346
+ const heading = line.match(/^#{1,6}\s+([a-z][a-z0-9-]*)\b/);
347
+ if (heading) headingVerb = heading[1] ?? null;
348
+ if (/^\|\s*Verb\s*\|/.test(line)) {
349
+ inVerbTable = true;
350
+ return;
351
+ }
352
+ if (inVerbTable && !/^\|/.test(line)) inVerbTable = false;
353
+ const negated = /\bno\s+`spur [^`]+`\s+verb exists\b/i.test(line);
354
+ for (const span of lineInvocationSpans(line)) {
355
+ const parsed = parseInvocation(span);
356
+ if (!parsed) continue;
357
+ if (negated) {
358
+ // Inverted assertion: the line claims absence. Confirm against the live surface.
359
+ for (const noun of parsed.nouns) {
360
+ const ns = surface([noun]);
361
+ for (const verb of parsed.verbs) {
362
+ record(
363
+ `spur ${noun} ${verb}`,
364
+ `help-capture(spur ${noun})`,
365
+ ns.commands.includes(verb) ? 'mismatch' : 'ok',
366
+ ns.commands.includes(verb)
367
+ ? 'line asserts absence but the verb EXISTS on the live surface (stale negation)'
368
+ : 'documented absence confirmed against live surface',
369
+ occ,
370
+ );
371
+ }
372
+ }
373
+ continue;
374
+ }
375
+ checkNounVerbFlags(parsed.nouns, parsed.verbs, parsed.flags, occ);
376
+ }
377
+ if (file.endsWith('.ts')) return;
378
+ if (inVerbTable && refNoun && /^\|/.test(line)) {
379
+ const first = (line.match(/^\|\s*`?([a-z][a-z0-9-]*)/)?.[1] ?? '').replace(/`/g, '');
380
+ const rootCommands = surface([]).commands;
381
+ // Flags live in the LAST cell (Key flags) — Purpose prose contains
382
+ // hyphenated words that are not flags.
383
+ const cells = line.split('|').map((c) => c.trim());
384
+ const lastCell = cells[cells.length - 2] ?? line;
385
+ if (first && first !== 'Verb' && first !== 'spur') {
386
+ if (rootCommands.includes(first)) {
387
+ checkNounVerbFlags([], [first], flagNames(lastCell), occ);
388
+ } else {
389
+ checkNounVerbFlags([refNoun], [first], flagNames(lastCell), occ);
390
+ }
391
+ }
392
+ return;
393
+ }
394
+ const perVerbPage = file.endsWith(join('verbs.md'));
395
+ if (perVerbPage && refNoun && headingVerb && surface([refNoun]).commands.includes(headingVerb)) {
396
+ for (const span of backticks(line)) {
397
+ if (isFlagSpan(span) && flagNames(span).length > 0) {
398
+ checkNounVerbFlags([refNoun], [headingVerb], flagNames(span), occ);
399
+ }
400
+ }
401
+ }
402
+ } else {
403
+ for (const span of lineInvocationSpans(line)) {
404
+ const parsed = parseInvocation(span);
405
+ if (parsed) checkNounVerbFlags(parsed.nouns, parsed.verbs, parsed.flags, occ);
406
+ }
407
+ }
408
+ });
409
+ }
410
+ }
411
+
412
+ // ─── B. Scripts: argv extraction + execution ────────────────────────────────
413
+
414
+ export function sweepScriptArgv(root: string = PLUGIN_ROOT): void {
415
+ for (const file of walk(join(root, 'scripts'), ['.ts'])) {
416
+ readFileSync(file, 'utf8')
417
+ .split(/\r?\n/)
418
+ .forEach((line: string, i: number) => {
419
+ for (const m of line.matchAll(/\brunSpur(?:Json)?\(\s*\w+\s*,\s*\[([^\]]*)\]/g)) {
420
+ const toks = [...(m[1] ?? '').matchAll(/'([^']*)'|"([^"]*)"/g)].map((t) => t[1] ?? t[2] ?? '');
421
+ const ids = toks.filter((t) => /^[a-z][a-z0-9-]*$/.test(t));
422
+ const nouns = ids.slice(0, 1);
423
+ const verbs = ids.slice(1, 2);
424
+ const flags = toks.filter((t) => /^--/.test(t));
425
+ if (nouns.length || verbs.length || flags.length) {
426
+ checkNounVerbFlags(nouns, verbs, flags, {
427
+ file: rel(file),
428
+ line: i + 1,
429
+ });
430
+ }
431
+ }
432
+ });
433
+ }
434
+ }
435
+
436
+ export function executeScripts(root: string = PLUGIN_ROOT): void {
437
+ const dir = mkdtempSync(join(tmpdir(), 'spur-drift-'));
438
+ try {
439
+ // task-size-precheck against a fake bin — argument construction under execution.
440
+ const fake = join(dir, 'spur');
441
+ writeFileSync(
442
+ fake,
443
+ `#!/bin/sh
444
+ if [ "$1" = task ] && [ "$2" = show ]; then
445
+ printf '%s' '{"content":"### Requirements\\n- [ ] R1. x\\n### Plan\\n- [ ] p1"}'
446
+ else
447
+ printf '%s' '{"agents":[{"capabilityTier":"standard"}]}'
448
+ fi
449
+ `,
450
+ );
451
+ chmodSync(fake, 0o755);
452
+ const statusPath = join(dir, '.spur', 'run', '0487-precheck-size.status');
453
+ try {
454
+ execFileSync(
455
+ process.execPath,
456
+ [join(root, 'scripts', 'task-size-precheck.ts'), '0487', '--spur-bin', fake, '--executor', 'standard'],
457
+ { cwd: dir, encoding: 'utf8', timeout: 30_000, stdio: 'pipe' },
458
+ );
459
+ const content = readFileSync(statusPath, 'utf8');
460
+ record(
461
+ 'task-size-precheck --spur-bin <bin> (executed)',
462
+ 'script-exec(fake-bin)',
463
+ content.trim() === 'PASS' ? 'ok' : 'mismatch',
464
+ `status file: ${content.trim()}`,
465
+ { file: 'plugins/sp/scripts/task-size-precheck.ts', line: 1 },
466
+ );
467
+ } catch (e) {
468
+ record(
469
+ 'task-size-precheck --spur-bin <bin> (executed)',
470
+ 'script-exec(fake-bin)',
471
+ 'mismatch',
472
+ `script exited non-zero: ${String(e).slice(0, 200)}`,
473
+ { file: 'plugins/sp/scripts/task-size-precheck.ts', line: 1 },
474
+ );
475
+ }
476
+
477
+ // feature-sync-bounded against a fake bin — feature show / task list / feature sync argv.
478
+ const fake2 = join(dir, 'spur2');
479
+ writeFileSync(
480
+ fake2,
481
+ `#!/bin/sh
482
+ if [ "$1" = feature ] && [ "$2" = show ]; then printf '%s' '{"content":"x"}';
483
+ elif [ "$1" = task ]; then printf '%s' '[{"wbs":"0001","status":"done"}]';
484
+ elif [ "$1" = feature ] && [ "$2" = sync ]; then printf '%s' '{"status":"ok","changed":0}';
485
+ fi
486
+ `,
487
+ );
488
+ chmodSync(fake2, 0o755);
489
+ try {
490
+ execFileSync(
491
+ process.execPath,
492
+ [
493
+ join(root, 'scripts', 'feature-sync-bounded.ts'),
494
+ '--feature',
495
+ 'I3',
496
+ '--spur-bin',
497
+ fake2,
498
+ '--run-dir',
499
+ join(dir, 'run'),
500
+ ],
501
+ { cwd: dir, encoding: 'utf8', timeout: 30_000, stdio: 'pipe' },
502
+ );
503
+ record(
504
+ 'feature-sync-bounded --feature <id> --spur-bin <bin> (executed)',
505
+ 'script-exec(fake-bin)',
506
+ 'ok',
507
+ 'exited 0 against fake bin',
508
+ { file: 'plugins/sp/scripts/feature-sync-bounded.ts', line: 1 },
509
+ );
510
+ } catch (e) {
511
+ record(
512
+ 'feature-sync-bounded --feature <id> --spur-bin <bin> (executed)',
513
+ 'script-exec(fake-bin)',
514
+ 'unverified',
515
+ `fake-bin execution errored: ${String(e).slice(0, 200)} — argv still checked by extraction`,
516
+ { file: 'plugins/sp/scripts/feature-sync-bounded.ts', line: 1 },
517
+ );
518
+ }
519
+ } finally {
520
+ rmSync(dir, { recursive: true, force: true });
521
+ }
522
+ }
523
+
524
+ // ─── C. Hook contract ───────────────────────────────────────────────────────
525
+
526
+ export function sweepHooks(root: string = PLUGIN_ROOT): void {
527
+ const hooksJsonPath = join(root, 'hooks', 'hooks.json');
528
+ let parsed: {
529
+ hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ type?: string; command?: string }> }>>;
530
+ };
531
+ try {
532
+ parsed = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
533
+ record('hooks.json parses', 'json-parse', 'ok', `${Object.keys(parsed.hooks ?? {}).length} events`, {
534
+ file: rel(hooksJsonPath),
535
+ line: 1,
536
+ });
537
+ } catch (e) {
538
+ record('hooks.json parses', 'json-parse', 'mismatch', String(e).slice(0, 200), {
539
+ file: rel(hooksJsonPath),
540
+ line: 1,
541
+ });
542
+ return;
543
+ }
544
+ for (const [event, entries] of Object.entries(parsed.hooks ?? {})) {
545
+ record(
546
+ `event ${event}`,
547
+ 'host-contract',
548
+ 'unverified',
549
+ 'superskill host hook-event schema is not in this repo — cannot be checked mechanically here',
550
+ { file: rel(hooksJsonPath), line: 1 },
551
+ );
552
+ for (const entry of entries) {
553
+ if (/^(PreToolUse|PostToolUse)$/.test(event) && !entry.matcher) {
554
+ record(
555
+ `event ${event} matcher`,
556
+ 'hooks.json structure',
557
+ 'mismatch',
558
+ 'tool-scoped event has no matcher',
559
+ { file: rel(hooksJsonPath), line: 1 },
560
+ );
561
+ }
562
+ for (const h of entry.hooks ?? []) {
563
+ const m = (h.command ?? '').match(/^superskill hook run sp ([a-z-]+)$/);
564
+ if (!m) {
565
+ record(
566
+ `hook command ${h.command}`,
567
+ 'hooks.json structure',
568
+ 'unverified',
569
+ "command does not match 'superskill hook run sp <name>' — not mechanically resolvable",
570
+ { file: rel(hooksJsonPath), line: 1 },
571
+ );
572
+ continue;
573
+ }
574
+ const script = join(root, 'hooks', `${m[1]}.ts`);
575
+ record(
576
+ `hook ${m[1]} script exists`,
577
+ 'file-resolution',
578
+ existsSync(script) ? 'ok' : 'mismatch',
579
+ existsSync(script) ? rel(script) : `${rel(script)} missing`,
580
+ { file: rel(hooksJsonPath), line: 1 },
581
+ );
582
+ }
583
+ }
584
+ }
585
+ // pi-lens-ignore: unchecked-throwing-call
586
+ const plugin = JSON.parse(readFileSync(join(root, 'plugin.json'), 'utf8')) as {
587
+ extensions?: { pi?: string[] };
588
+ };
589
+ for (const ext of plugin.extensions?.pi ?? []) {
590
+ const p = join(root, ext.replace(/^\.\//, ''));
591
+ record(
592
+ `pi extension ${ext}`,
593
+ 'file-resolution',
594
+ existsSync(p) ? 'ok' : 'mismatch',
595
+ existsSync(p) ? rel(p) : `${rel(p)} missing`,
596
+ { file: 'plugins/sp/plugin.json', line: 1 },
597
+ );
598
+ }
599
+ }
600
+
601
+ // ─── D. --json envelope probes ──────────────────────────────────────────────
602
+
603
+ /** Read-only commands executed live to capture actual --json envelope shapes. */
604
+ const JSON_PROBES: string[][] = [
605
+ ['task', 'list', '--json'],
606
+ ['task', 'show', '0539', '--json'],
607
+ ['task', 'check', '0539', '--json'],
608
+ ['feature', 'list', '--json'],
609
+ ['feature', 'show', 'I3', '--json'],
610
+ ['rule', 'list', '--json'],
611
+ ['agent', 'list', '--json'],
612
+ ['agent', 'doctor', 'omp', '--json'],
613
+ ['workflow', 'list', '--json'],
614
+ ['workflow', 'validate', '.spur/workflows/basic.yaml', '--json'],
615
+ ['status', '--json'],
616
+ ['team', 'status', '--json'],
617
+ ['projects', 'list', '--json'],
618
+ ];
619
+
620
+ export function flattenKeys(v: unknown, prefix = ''): string[] {
621
+ if (Array.isArray(v)) {
622
+ const inner = [...new Set(v.flatMap((x) => flattenKeys(x, '[]')))];
623
+ return inner.map((k) => (prefix ? `${prefix}.${k}` : k));
624
+ }
625
+ if (v && typeof v === 'object') {
626
+ return Object.entries(v).flatMap(([k, val]) => {
627
+ const path = prefix ? `${prefix}.${k}` : k;
628
+ return typeof val === 'object' && val !== null ? flattenKeys(val, path) : [path];
629
+ });
630
+ }
631
+ return [];
632
+ }
633
+
634
+ const jsonEnvelopeShapes: Record<string, { exit: number; keys: string[] }> = {};
635
+
636
+ export function probeJsonShapes(run: CliRunner = runCli): void {
637
+ for (const args of JSON_PROBES) {
638
+ const r = run(args);
639
+ let keys: string[] = [];
640
+ try {
641
+ keys = flattenKeys(JSON.parse(r.out)).sort();
642
+ } catch {
643
+ keys = [];
644
+ }
645
+ const label = `spur ${args.slice(0, -1).join(' ')}`;
646
+ jsonEnvelopeShapes[label] = { exit: r.exit, keys };
647
+ const ok = r.exit === 0 && keys.length > 0;
648
+ record(
649
+ `${args.join(' ')} (envelope)`,
650
+ 'json-exec',
651
+ ok ? 'ok' : 'unverified',
652
+ ok
653
+ ? `keys: ${keys.slice(0, 12).join(', ')}${keys.length > 12 ? ' …' : ''}`
654
+ : `exit ${r.exit}${r.err ? ` — ${r.err.slice(0, 120)}` : ' — no parseable JSON'}`,
655
+ { file: 'plugins/sp/scripts/surface-drift-inventory.ts', line: 1 },
656
+ );
657
+ }
658
+ const doctor = jsonEnvelopeShapes['spur agent doctor omp'];
659
+ const capOk = (doctor?.keys ?? []).some((k) => k.endsWith('.capabilityTier'));
660
+ record(
661
+ 'agent doctor <name> --json -> agents[0].capabilityTier (asserted by task-size-precheck.ts:130)',
662
+ 'json-exec(field-presence)',
663
+ capOk ? 'ok' : 'mismatch',
664
+ capOk ? 'field present in live envelope' : 'field ABSENT from live envelope',
665
+ { file: 'plugins/sp/scripts/task-size-precheck.ts', line: 130 },
666
+ );
667
+ // Curated prose flag-claims: assertions phrased as prose ("no explicit `--flag`") that the
668
+ // generic backtick-span extractor cannot scope to a command. Extend this list when a prose
669
+ // claim is found; each entry is verified against the live help capture.
670
+ const proseFlagClaims: {
671
+ flag: string;
672
+ path: string[];
673
+ expect: boolean;
674
+ where: { file: string; line: number };
675
+ note: string;
676
+ }[] = [
677
+ {
678
+ flag: '--stage',
679
+ path: ['agent', 'run'],
680
+ expect: false,
681
+ where: { file: 'plugins/sp/skills/parallel-execution/references/dispatch-surface.md', line: 116 },
682
+ note: 'prose claimed an explicit direct-CLI --stage (0539: corrected - stage context is engine-internal)',
683
+ },
684
+ ];
685
+ for (const c of proseFlagClaims) {
686
+ const vs = surface(c.path);
687
+ const has = vs.flags.includes(c.flag);
688
+ const ok = has === c.expect;
689
+ record(
690
+ `spur ${c.path.join(' ')} ${c.flag} (prose claim: ${c.where.file}:${c.where.line})`,
691
+ 'help-capture(prose-claim)',
692
+ ok ? 'ok' : 'mismatch',
693
+ ok
694
+ ? `live ${has ? 'exposes' : 'does not expose'} ${c.flag} as expected - ${c.note}`
695
+ : `live ${has ? 'exposes' : 'does not expose'} ${c.flag} but prose claims otherwise - ${c.note}`,
696
+ c.where,
697
+ );
698
+ }
699
+ record(
700
+ 'agent run --json -> roleOrigin (asserted by dispatch-surface.md:112)',
701
+ 'json-exec',
702
+ 'unverified',
703
+ "executing 'spur agent run' launches an external coding agent — cannot be executed mechanically here",
704
+ { file: 'plugins/sp/skills/parallel-execution/references/dispatch-surface.md', line: 112 },
705
+ );
706
+ record(
707
+ 'task list --json -> bare array of {wbs,status,…} (asserted by feature-sync-bounded.ts:291)',
708
+ 'json-exec(field-presence)',
709
+ jsonEnvelopeShapes['spur task list']?.exit === 0 ? 'ok' : 'mismatch',
710
+ jsonEnvelopeShapes['spur task list']?.exit === 0 ? 'array envelope confirmed' : 'probe failed',
711
+ { file: 'plugins/sp/scripts/feature-sync-bounded.ts', line: 291 },
712
+ );
713
+ }
714
+
715
+ // ─── E. Workflows + symlink ─────────────────────────────────────────────────
716
+
717
+ export function sweepWorkflows(opts: { run?: CliRunner; wfDir?: string; link?: string } = {}): void {
718
+ const run = opts.run ?? runCli;
719
+ const wfDir = opts.wfDir ?? join(REPO_ROOT, 'config', 'workflows');
720
+ const files = readdirSync(wfDir)
721
+ .filter((f) => f.endsWith('.yaml'))
722
+ .sort();
723
+ for (const f of files) {
724
+ const path = rel(join(wfDir, f));
725
+ const v = run(['workflow', 'validate', path]);
726
+ record(
727
+ `workflow validate ${f}`,
728
+ 'workflow-validate(live engine)',
729
+ v.exit === 0 ? 'ok' : 'mismatch',
730
+ `exit ${v.exit}${v.err ? ` — ${v.err.slice(0, 160)}` : ''}`,
731
+ { file: path, line: 1 },
732
+ );
733
+ const dryVars = f === 'task-pipeline.yaml' ? ['--vars', JSON.stringify({ wbs: '0539', profile: 'auto' })] : [];
734
+ const d = run(['workflow', 'run', path, '--dry-run', ...dryVars, '--quiet'], 45_000);
735
+ const tail = (d.out.trim().split('\n').pop() ?? d.err.trim().split('\n').pop() ?? '').slice(0, 160);
736
+ // A dry-run that terminates (any exit) proves the engine walks the definition; schema
737
+ // divergence is validate's job. Only a timeout is unverified.
738
+ record(
739
+ `workflow run ${f} --dry-run`,
740
+ 'workflow-dry-run(live engine)',
741
+ d.exit === 124 ? 'unverified' : 'ok',
742
+ d.exit === 124
743
+ ? 'dry-run did not terminate in 45s (paused on HITL?)'
744
+ : `walked to termination — ${tail || `exit ${d.exit}`}`,
745
+ { file: path, line: 1 },
746
+ );
747
+ // CLI invocations asserted inside the YAML (shell guards/actions).
748
+ readFileSync(join(wfDir, f), 'utf8')
749
+ .split(/\r?\n/)
750
+ .forEach((line: string, i: number) => {
751
+ for (const span of lineInvocationSpans(line)) {
752
+ const parsed = parseInvocation(span);
753
+ if (parsed)
754
+ checkNounVerbFlags(parsed.nouns, parsed.verbs, parsed.flags, {
755
+ file: path,
756
+ line: i + 1,
757
+ });
758
+ }
759
+ });
760
+ }
761
+ const link = opts.link ?? join(REPO_ROOT, '.spur', 'workflows');
762
+ let status: Row['status'] = 'mismatch';
763
+ let actual = '';
764
+ try {
765
+ const rp = realpathSync(link);
766
+ const tracked = realpathSync(wfDir);
767
+ status = rp === tracked ? 'ok' : 'mismatch';
768
+ actual = `resolves to ${rel(rp)}${status === 'ok' ? ' (== tracked SSOT target)' : ` — expected ${rel(tracked)}`}`;
769
+ } catch (e) {
770
+ actual = `cannot resolve .spur/workflows: ${String(e).slice(0, 120)}`;
771
+ }
772
+ record('.spur/workflows symlink', 'symlink-realpath', status, actual, { file: '.spur/workflows', line: 1 });
773
+ }
774
+
775
+ // ─── Report ─────────────────────────────────────────────────────────────────
776
+
777
+ export function render(): string {
778
+ const prov = surface([]);
779
+ const byMethod = new Map<string, Row[]>();
780
+ for (const r of rows) {
781
+ const key = r.method.split('(')[0] ?? r.method;
782
+ byMethod.set(key, [...(byMethod.get(key) ?? []), r]);
783
+ }
784
+ const counts = {
785
+ ok: rows.filter((r) => r.status === 'ok').length,
786
+ mismatch: rows.filter((r) => r.status === 'mismatch').length,
787
+ unverified: rows.filter((r) => r.status === 'unverified').length,
788
+ };
789
+ const esc = (s: string): string => s.replaceAll('|', '\\|');
790
+ const L: string[] = [];
791
+ L.push('# 0539 — Plugin & Workflow Surface-Drift Inventory (feature I3)');
792
+ L.push('');
793
+ L.push(
794
+ 'Generated by `bun plugins/sp/scripts/surface-drift-inventory.ts` — re-runnable; every entry names its check method.',
795
+ );
796
+ // pi-lens-ignore: unchecked-throwing-call
797
+ const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'apps', 'cli', 'package.json'), 'utf8')) as { version: string };
798
+ L.push(`CLI provenance: source-local \`apps/cli/src/index.ts\` @ ${pkg.version} (never a PATH \`spur\`).`);
799
+ L.push(`Root nouns: ${prov.commands.join(', ')}.`);
800
+ L.push('');
801
+ L.push(
802
+ `**Totals: ${counts.ok} ok · ${counts.mismatch} mismatch · ${counts.unverified} unverified** (unverified = no mechanical check can reach it — recorded, never passed).`,
803
+ );
804
+ L.push('');
805
+ L.push(
806
+ "R1 scope: `plugins/sp/{commands,skills,scripts,hooks}` (tests/ and evals/ are the harness itself; agents/ prose is outside R1's named surface list).",
807
+ );
808
+ L.push('R2 scope: all workflow YAML via the `.spur/workflows` runtime symlink (tracked SSOT tree).');
809
+ L.push('');
810
+ L.push('## Check methods');
811
+ L.push('');
812
+ L.push('| Method | What it does |');
813
+ L.push('| --- | --- |');
814
+ L.push(
815
+ '| `help-capture` | Parses live Commander `Commands:`/`Options:` blocks from `spur <path> --help` (source-local entry) and diffs asserted noun/verb/flag |',
816
+ );
817
+ L.push(
818
+ '| `json-exec` | Executes read-only commands with `--json` and records the actual envelope; mutating commands stay unverified |',
819
+ );
820
+ L.push(
821
+ '| `script-exec` | Executes the spur-shelling plugin scripts against a fake bin (argument construction under execution) |',
822
+ );
823
+ L.push('| `file-resolution` / `json-parse` | Referenced files exist / JSON parses |');
824
+ L.push('| `host-contract` | Host-owned contract — unverified in-repo, reason recorded |');
825
+ L.push(
826
+ '| `workflow-validate` / `workflow-dry-run` | Live engine: schema+semantic validate; `--dry-run` transition walk |',
827
+ );
828
+ L.push('| `symlink-realpath` | realpath of `.spur/workflows` vs its tracked SSOT target |');
829
+ L.push('');
830
+ for (const [method, list] of byMethod) {
831
+ const mm = list.filter((r) => r.status === 'mismatch').length;
832
+ L.push(`## ${method} — ${mm} mismatch / ${list.length} entries`);
833
+ L.push('');
834
+ L.push('| Asserted | Status | Actual | Occurrences |');
835
+ L.push('| --- | --- | --- | --- |');
836
+ for (const r of [...list].sort(
837
+ (a, b) => a.status.localeCompare(b.status) || a.asserted.localeCompare(b.asserted),
838
+ )) {
839
+ const occ =
840
+ r.occurrences
841
+ .map((o) => `${o.file}:${o.line}`)
842
+ .slice(0, 4)
843
+ .join('<br>') + (r.occurrences.length > 4 ? `<br>…+${r.occurrences.length - 4}` : '');
844
+ L.push(`| \`${esc(r.asserted)}\` | **${r.status}** | ${esc(r.actual)} | ${occ} |`);
845
+ }
846
+ L.push('');
847
+ }
848
+ L.push('## Live --json envelope shapes (captured by execution)');
849
+ L.push('');
850
+ L.push('| Command | Exit | Flattened keys |');
851
+ L.push('| --- | --- | --- |');
852
+ for (const [cmd, s] of Object.entries(jsonEnvelopeShapes)) {
853
+ L.push(
854
+ `| \`${esc(cmd)}\` | ${s.exit} | ${esc(s.keys.slice(0, 14).join(', '))}${s.keys.length > 14 ? ', …' : ''} |`,
855
+ );
856
+ }
857
+ L.push('');
858
+ L.push('## Confirmed mismatches (R3 disposition)');
859
+ L.push('');
860
+ const mism = rows.filter((r) => r.status === 'mismatch');
861
+ if (mism.length === 0) {
862
+ L.push('None — every mechanically-checked assertion matches the live surface.');
863
+ } else {
864
+ L.push('| # | Entry | Disposition |');
865
+ L.push('| --- | --- | --- |');
866
+ mism.forEach((r, n) => {
867
+ L.push(
868
+ `| ${n + 1} | \`${esc(r.asserted)}\` (${r.method}) — ${esc(r.actual)} | see task 0539 Solution (fixed here / owning WBS) |`,
869
+ );
870
+ });
871
+ }
872
+ L.push('');
873
+ return L.join('\n');
874
+ }
875
+
876
+ // ─── Main ───────────────────────────────────────────────────────────────────
877
+
878
+ /**
879
+ * Entry point. Guarded by `import.meta.main` so the pure span/flag parsers above
880
+ * can be imported and unit-tested without running the full multi-minute sweep
881
+ * (which shells the live CLI and writes files).
882
+ */
883
+ function main(): void {
884
+ const outIdx = process.argv.indexOf('--out');
885
+ sweepPluginTrees();
886
+ sweepScriptArgv();
887
+ executeScripts();
888
+ sweepHooks();
889
+ probeJsonShapes();
890
+ sweepWorkflows();
891
+ const report = render();
892
+ if (outIdx !== -1 && process.argv[outIdx + 1]) {
893
+ const outPath = resolve(process.argv[outIdx + 1] ?? '.');
894
+ mkdirSync(dirname(outPath), { recursive: true });
895
+ writeFileSync(outPath, report);
896
+ process.stderr.write(`wrote ${rel(outPath)}\n`);
897
+ }
898
+ const mismatches = rows.filter((r) => r.status === 'mismatch');
899
+ if (mismatches.length > 0) {
900
+ process.stderr.write(`\nCONFIRMED MISMATCHES (${mismatches.length}):\n`);
901
+ for (const m of mismatches) process.stderr.write(` - ${m.asserted} [${m.method}] ${m.actual}\n`);
902
+ process.exitCode = 1;
903
+ } else {
904
+ process.stderr.write('\nNo confirmed mismatches.\n');
905
+ }
906
+ }
907
+
908
+ if (import.meta.main) main();