@gobing-ai/spur 0.3.94 → 0.3.95

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 (140) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +42 -0
  3. package/board/index.d.ts +38 -0
  4. package/config/plugin-scripts.json +5 -0
  5. package/config/workflows/feature-verification.yaml +7 -4
  6. package/config/workflows/idea-pipeline.yaml +10 -4
  7. package/config/workflows/task-pipeline.yaml +54 -26
  8. package/config/workflows/wrapup-pipeline.yaml +92 -17
  9. package/package.json +15 -11
  10. package/plugins/sp/commands/dev-verify.md +7 -4
  11. package/plugins/sp/commands/dev-verifyall.md +4 -3
  12. package/plugins/sp/commands/dev-wrap.md +1 -1
  13. package/plugins/sp/hooks/context-post-tool.ts +2 -2
  14. package/plugins/sp/hooks/context-session-start.ts +2 -1
  15. package/plugins/sp/hooks/context-session-stop.ts +22 -14
  16. package/plugins/sp/hooks/pi/guard-extension.ts +109 -150
  17. package/plugins/sp/lib/idea-handoff.generated.mjs +2 -2
  18. package/plugins/sp/lib/inline-run.generated.d.mts +2 -2
  19. package/plugins/sp/lib/inline-run.generated.mjs +10 -10
  20. package/plugins/sp/plugin.json +1 -1
  21. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +3 -3
  22. package/plugins/sp/scripts/daily-summary/daily-summary.ts +3 -3
  23. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -0
  24. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +1 -0
  25. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +9 -7
  26. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +8 -7
  27. package/plugins/sp/scripts/feature-sync-bounded.mjs +5 -2
  28. package/plugins/sp/scripts/feature-sync-bounded.ts +3 -1
  29. package/plugins/sp/scripts/feature-verification-steps.ts +6 -6
  30. package/plugins/sp/scripts/history-anatomy-cache.mjs +3 -3
  31. package/plugins/sp/scripts/history-anatomy-cache.ts +4 -4
  32. package/plugins/sp/scripts/inline-run-setup.mjs +23 -13
  33. package/plugins/sp/scripts/inline-run-setup.ts +60 -27
  34. package/plugins/sp/scripts/pr-reviewing.mjs +2 -0
  35. package/plugins/sp/scripts/pr-reviewing.ts +1 -0
  36. package/plugins/sp/scripts/quality-gate.mjs +4 -2
  37. package/plugins/sp/scripts/quality-gate.ts +8 -2
  38. package/plugins/sp/scripts/residual-scan.mjs +3 -3
  39. package/plugins/sp/scripts/residual-scan.ts +3 -3
  40. package/plugins/sp/scripts/script-contract-check.ts +175 -46
  41. package/plugins/sp/scripts/script-root.mjs +125 -0
  42. package/plugins/sp/scripts/script-root.ts +195 -0
  43. package/plugins/sp/scripts/stage-registry-adapter.ts +1 -1
  44. package/plugins/sp/scripts/task-evidence-precheck.ts +4 -2
  45. package/plugins/sp/scripts/task-size-precheck.ts +3 -1
  46. package/plugins/sp/scripts/validate-commands.ts +12 -12
  47. package/plugins/sp/scripts/validate-flag-contracts.ts +42 -30
  48. package/plugins/sp/scripts/verify-answer-lint.ts +3 -1
  49. package/plugins/sp/scripts/workflow-step-profile.mjs +5 -2
  50. package/plugins/sp/scripts/workflow-step-profile.ts +3 -1
  51. package/plugins/sp/scripts/wrapup-drift-probe.mjs +5 -2
  52. package/plugins/sp/scripts/wrapup-drift-probe.ts +4 -2
  53. package/plugins/sp/scripts/wrapup-steps.mjs +32 -8
  54. package/plugins/sp/scripts/wrapup-steps.ts +56 -10
  55. package/plugins/sp/skills/code-verification/SKILL.md +12 -7
  56. package/plugins/sp/skills/code-verification/references/verdict-schema.md +11 -0
  57. package/plugins/sp/skills/functional-review/SKILL.md +1 -1
  58. package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
  59. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +9 -1
  60. package/plugins/sp/skills/spur-dev/references/execution-batch.md +30 -10
  61. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +6 -3
  62. package/plugins/sp/tsconfig.json +8 -0
  63. package/spur.js +5194 -4457
  64. package/web/_astro/{BoardApp.FTEs3-N8.js → BoardApp.BxJuwD7I.js} +150 -153
  65. package/web/_astro/BoardApp.Cpxntzad.js +1 -0
  66. package/web/_astro/TaskDetail.C5bW4WGV.js +1 -0
  67. package/web/_astro/_commonjsHelpers.CqkleIqs.js +1 -0
  68. package/web/_astro/{arc.uuAf51IT.js → arc.CyjRvNMY.js} +1 -1
  69. package/web/_astro/architectureDiagram-3BPJPVTR.B4lWRzJA.js +36 -0
  70. package/web/_astro/{blockDiagram-GPEHLZMM.D1mGCq3p.js → blockDiagram-GPEHLZMM.Be38USQ2.js} +1 -1
  71. package/web/_astro/board-facade-react-dom-client.B2U5wMvk.js +1 -0
  72. package/web/_astro/board-facade-react-dom.Cd2MuMhF.js +1 -0
  73. package/web/_astro/board-facade-react-jsx-dev-runtime.B_L3JzfC.js +1 -0
  74. package/web/_astro/board-facade-react-jsx-runtime.BY2yfHRi.js +1 -0
  75. package/web/_astro/board-facade-react-router-dom.Dy5pjzOw.js +1 -0
  76. package/web/_astro/board-facade-react-router.DjjqU27_.js +1 -0
  77. package/web/_astro/board-facade-react.Dgf4GRyj.js +1 -0
  78. package/web/_astro/{c4Diagram-AAUBKEIU.CMsolcde.js → c4Diagram-AAUBKEIU.DT9Fj5Qx.js} +1 -1
  79. package/web/_astro/channel.CI6N_tCg.js +1 -0
  80. package/web/_astro/{chunk-2J33WTMH.CrGA3fik.js → chunk-2J33WTMH.SeSBWLg5.js} +1 -1
  81. package/web/_astro/{chunk-4BX2VUAB.DsLVVla0.js → chunk-4BX2VUAB.DoO4VE14.js} +1 -1
  82. package/web/_astro/{chunk-55IACEB6.Dxc59Tfi.js → chunk-55IACEB6.DrmTFA53.js} +1 -1
  83. package/web/_astro/{chunk-727SXJPM.CaEVE2Wy.js → chunk-727SXJPM.Bi-TUb_V.js} +1 -1
  84. package/web/_astro/{chunk-AQP2D5EJ.BiJ4HXeI.js → chunk-AQP2D5EJ.B-xp8Uhi.js} +1 -1
  85. package/web/_astro/{chunk-FMBD7UC4.Mxf1fru5.js → chunk-FMBD7UC4.DGWrDnUU.js} +1 -1
  86. package/web/_astro/chunk-JMJ3UQ3L.BQJEJHu4.js +34 -0
  87. package/web/_astro/{chunk-ND2GUHAM.pyOWQixH.js → chunk-ND2GUHAM._BagvPDy.js} +1 -1
  88. package/web/_astro/{chunk-QZHKN3VN.BmEsg4vr.js → chunk-QZHKN3VN.VOboYWQ0.js} +1 -1
  89. package/web/_astro/chunk-YNUBSHFH.CxeAygfi.js +7 -0
  90. package/web/_astro/{classDiagram-4FO5ZUOK.BHhhMFTO.js → classDiagram-4FO5ZUOK.D_apfHS7.js} +1 -1
  91. package/web/_astro/{classDiagram-v2-Q7XG4LA2.BHhhMFTO.js → classDiagram-v2-Q7XG4LA2.D_apfHS7.js} +1 -1
  92. package/web/_astro/client.BhluLQqe.js +1 -0
  93. package/web/_astro/client.DBpSRLnx.js +9 -0
  94. package/web/_astro/cose-bilkent-S5V4N54A.DNLU_L-x.js +1 -0
  95. package/web/_astro/{cynefin-OW5HDTMX.C2j1_lKL.js → cynefin-OW5HDTMX.D8borhV-.js} +1 -1
  96. package/web/_astro/{dagre-BM42HDAG.hZ2NCTdT.js → dagre-BM42HDAG.Uc0lvjcV.js} +1 -1
  97. package/web/_astro/{diagram-2AECGRRQ.Bfw5_EzK.js → diagram-2AECGRRQ.DDIxtDBo.js} +1 -1
  98. package/web/_astro/{diagram-5GNKFQAL.Bt1V_Tmk.js → diagram-5GNKFQAL.DVoDnD3H.js} +1 -1
  99. package/web/_astro/{diagram-KO2AKTUF.DEk-YFwp.js → diagram-KO2AKTUF.CRubTJ-0.js} +1 -1
  100. package/web/_astro/{diagram-LMA3HP47.CDjndtQm.js → diagram-LMA3HP47.m9SeYcXQ.js} +1 -1
  101. package/web/_astro/{diagram-OG6HWLK6.cFnUHScG.js → diagram-OG6HWLK6.C6s2ZjV5.js} +1 -1
  102. package/web/_astro/{erDiagram-TEJ5UH35.DlhYp7NV.js → erDiagram-TEJ5UH35.nSzr5KTj.js} +1 -1
  103. package/web/_astro/{flowDiagram-I6XJVG4X.DNTpsxfx.js → flowDiagram-I6XJVG4X.DrZcys16.js} +1 -1
  104. package/web/_astro/ganttDiagram-6RSMTGT7.Do4M1b-K.js +292 -0
  105. package/web/_astro/{gitGraphDiagram-PVQCEYII.DkEqNI0P.js → gitGraphDiagram-PVQCEYII.BWc9oJ4l.js} +1 -1
  106. package/web/_astro/index.5dn-Zm-2.js +1 -0
  107. package/web/_astro/index.C-IqPpW2.js +1 -0
  108. package/web/_astro/index.CENnIEqT.css +1 -0
  109. package/web/_astro/index.Cyc_GA4f.js +1 -0
  110. package/web/_astro/index.tZfqwSut.js +1 -0
  111. package/web/_astro/{infoDiagram-5YYISTIA.CrjioCTG.js → infoDiagram-5YYISTIA.BIdOctFk.js} +1 -1
  112. package/web/_astro/{ishikawaDiagram-YF4QCWOH.BcK0CN8n.js → ishikawaDiagram-YF4QCWOH.CV5SuG2t.js} +1 -1
  113. package/web/_astro/{journeyDiagram-JHISSGLW.p0CbBDX1.js → journeyDiagram-JHISSGLW.CxE-rEJ-.js} +1 -1
  114. package/web/_astro/jsx-runtime.DWOiEOII.js +1 -0
  115. package/web/_astro/{kanban-definition-UN3LZRKU.puHIFt6J.js → kanban-definition-UN3LZRKU.BPRdKWs_.js} +1 -1
  116. package/web/_astro/{linear.Bb-3a1d7.js → linear.zRsuuDTE.js} +1 -1
  117. package/web/_astro/{mermaid.core.CJDgXOJs.js → mermaid.core.jAJTcMKc.js} +4 -4
  118. package/web/_astro/{mindmap-definition-RKZ34NQL.BoAZEI9t.js → mindmap-definition-RKZ34NQL.BffJxfCr.js} +1 -1
  119. package/web/_astro/{pieDiagram-4H26LBE5.CJNSdqY7.js → pieDiagram-4H26LBE5.CacwWaE3.js} +1 -1
  120. package/web/_astro/{quadrantDiagram-W4KKPZXB.DtTy7_0Z.js → quadrantDiagram-W4KKPZXB.hXELD06-.js} +1 -1
  121. package/web/_astro/{requirementDiagram-4Y6WPE33.BNYV98Xg.js → requirementDiagram-4Y6WPE33.DdEbPcqT.js} +1 -1
  122. package/web/_astro/{sankeyDiagram-5OEKKPKP.DRq9DGHp.js → sankeyDiagram-5OEKKPKP.DgaDocKv.js} +1 -1
  123. package/web/_astro/{sequenceDiagram-3UESZ5HK.B7KdBFCm.js → sequenceDiagram-3UESZ5HK.B0KScnAu.js} +1 -1
  124. package/web/_astro/{stateDiagram-AJRCARHV.CD62ZJ2H.js → stateDiagram-AJRCARHV.CV9M_WNc.js} +1 -1
  125. package/web/_astro/{stateDiagram-v2-BHNVJYJU.CeVk1TAj.js → stateDiagram-v2-BHNVJYJU.BOAo74Et.js} +1 -1
  126. package/web/_astro/{timeline-definition-PNZ67QCA.C_ZPA5tT.js → timeline-definition-PNZ67QCA.CqqepUMe.js} +1 -1
  127. package/web/_astro/{vennDiagram-CIIHVFJN.CrThO_FQ.js → vennDiagram-CIIHVFJN.DDqe4dOG.js} +1 -1
  128. package/web/_astro/{wardleyDiagram-YWT4CUSO.DSZCA5nl.js → wardleyDiagram-YWT4CUSO.B3R9OPdO.js} +1 -1
  129. package/web/_astro/{xychartDiagram-2RQKCTM6.KI7baTuk.js → xychartDiagram-2RQKCTM6.B3SvgXpQ.js} +1 -1
  130. package/web/board-runtime.json +69 -0
  131. package/web/index.html +2 -2
  132. package/web/_astro/BoardApp.C02hAHPO.js +0 -1
  133. package/web/_astro/TaskDetail.C-GdsS-t.js +0 -1
  134. package/web/_astro/architectureDiagram-3BPJPVTR.CGe629A1.js +0 -36
  135. package/web/_astro/channel.fsgl7o5j.js +0 -1
  136. package/web/_astro/client.yhYJvxCU.js +0 -9
  137. package/web/_astro/cose-bilkent-S5V4N54A.2fH4YOlp.js +0 -1
  138. package/web/_astro/ganttDiagram-6RSMTGT7.DC_p36PI.js +0 -292
  139. package/web/_astro/index.De90oHcH.js +0 -1
  140. package/web/_astro/index.Hjbr15fG.css +0 -1
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * script-root — run-start script-resolution identity for the sp pipelines (task 0960 R2).
4
+ *
5
+ * WHY: the pipeline YAML ships to every consumer, but its script probes resolved
6
+ * `plugins/sp/scripts/` **project-first**. A consumer that hand-vendored a stale copy then ran
7
+ * a different revision than the installed twin, mixing revisions across steps that write and
8
+ * read the same cross-step artifacts (status files, verdicts, proof digests). The probe is
9
+ * dogfooding-only, so it is now gated on `config/plugin-scripts.json` — a marker that lives
10
+ * outside `plugins/sp/`, so copying the plugin tree cannot reproduce it.
11
+ *
12
+ * This script records *which* tree a run resolved, once, before any step executes:
13
+ * `.spur/run/<runId>-script-root.json` `{ mode, source, dir, scriptSetDigest }`
14
+ *
15
+ * - `source-repo` — the marker is present; the project's live `plugins/sp/scripts/` is the
16
+ * resolved tree (the dogfood path).
17
+ * - `installed` — no marker; the tree is `dirname(superskill script path sp script-root.mjs
18
+ * --json .path)`, with `source` from the same probe. One stderr warning names a dead
19
+ * vendored `plugins/sp/scripts/` dir that is no longer executed.
20
+ * - `unresolved` — nothing resolved. Written with an `error` and **exit 0**: a missing
21
+ * optional script must stay a per-step fail-closed outcome, never a run-wide abort (R3).
22
+ *
23
+ * `scriptSetDigest` is one sha256 over the sorted `name:sha256` list of the resolved dir's
24
+ * regular files (non-recursive) — the installed staging has no version file, so this answers
25
+ * "which revision produced this run?" without a Superskill change.
26
+ *
27
+ * Node/bun builtins and the vendored `../lib/env` gateway only — bundled standalone by
28
+ * `superskill install`, so no `@gobing-ai/*` value imports (task 0669).
29
+ */
30
+
31
+ import { spawnSync } from 'node:child_process';
32
+ import { createHash } from 'node:crypto';
33
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
34
+ import { dirname, join } from 'node:path';
35
+ import { getEnvVars } from '../lib/env';
36
+
37
+ export type ScriptRootMode = 'source-repo' | 'installed' | 'unresolved';
38
+
39
+ export interface ScriptRoot {
40
+ mode: ScriptRootMode;
41
+ source?: string;
42
+ dir?: string;
43
+ scriptSetDigest?: string;
44
+ error?: string;
45
+ }
46
+
47
+ export interface ScriptRootResult extends ScriptRoot {
48
+ resultFile: string;
49
+ exitCode: number;
50
+ }
51
+
52
+ export interface ScriptRootEnv {
53
+ __runId?: string;
54
+ [key: string]: string | undefined;
55
+ }
56
+
57
+ /** Resolved script descriptor from `superskill script path sp <rel> --json`. */
58
+ export interface ResolvedScript {
59
+ path?: string;
60
+ source?: string;
61
+ }
62
+
63
+ export interface ScriptRootOptions {
64
+ /** Base directory for `.spur/run` and the project marker; defaults to process cwd. */
65
+ cwd?: string;
66
+ /** Test seam: run `superskill script path sp script-root.mjs --json`. */
67
+ resolveScript?: (rel: string) => ResolvedScript | null;
68
+ /** Test seam: the one-warning sink. */
69
+ warn?: (message: string) => void;
70
+ }
71
+
72
+ /** Marker that identifies the Spur source repo (owned by script-contract-check, outside plugins/sp). */
73
+ export const SOURCE_REPO_MARKER = join('config', 'plugin-scripts.json');
74
+ /** The project-first tree the dogfood probes execute. */
75
+ export const PROJECT_SCRIPTS_DIR = join('plugins', 'sp', 'scripts');
76
+ /** The rel the installed twin resolves under (its own staging name). */
77
+ export const SCRIPT_ROOT_TWIN_REL = 'script-root.mjs';
78
+
79
+ function defaultResolveScript(cwd?: string): (rel: string) => ResolvedScript | null {
80
+ return (rel: string): ResolvedScript | null => {
81
+ const run = spawnSync('superskill', ['script', 'path', 'sp', rel, '--json'], {
82
+ cwd,
83
+ encoding: 'utf8',
84
+ env: getEnvVars(),
85
+ });
86
+ if (run.status !== 0 || !run.stdout) return null;
87
+ const lastLine = run.stdout.trim().split('\n').pop() ?? '';
88
+ try {
89
+ const parsed = JSON.parse(lastLine) as ResolvedScript;
90
+ return typeof parsed.path === 'string' && parsed.path.length > 0 ? parsed : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ };
95
+ }
96
+
97
+ /** sha256 over the sorted `name:sha256` list of `dir`'s regular files (non-recursive). */
98
+ export function digestScriptSet(dir: string, cwd?: string): string {
99
+ const abs = cwd ? join(cwd, dir) : dir;
100
+ const names = readdirSync(abs)
101
+ .filter((name) => {
102
+ try {
103
+ return statSync(join(abs, name)).isFile();
104
+ } catch {
105
+ return false;
106
+ }
107
+ })
108
+ .sort();
109
+ const list = names.map(
110
+ (name) =>
111
+ `${name}:${createHash('sha256')
112
+ .update(readFileSync(join(abs, name)))
113
+ .digest('hex')}`,
114
+ );
115
+ return `sha256:${createHash('sha256').update(list.join('\n')).digest('hex')}`;
116
+ }
117
+
118
+ /**
119
+ * Resolve the run's script root and write `.spur/run/<runId>-script-root.json`.
120
+ * Never aborts: every failure is recorded as `mode: "unresolved"` with exit 0 (R3).
121
+ */
122
+ export function runScriptRoot(env: ScriptRootEnv, options: ScriptRootOptions = {}): ScriptRootResult {
123
+ const cwd = options.cwd;
124
+ const runId = env.__runId ?? env.runId ?? '';
125
+ const relResult = join('.spur', 'run', `${runId}-script-root.json`);
126
+ const abs = (p: string): string => (cwd ? join(cwd, p) : p);
127
+ const warn = options.warn ?? ((message: string): void => void process.stderr.write(`${message}\n`));
128
+ mkdirSync(abs(join('.spur', 'run')), { recursive: true });
129
+
130
+ const write = (row: ScriptRoot): ScriptRootResult => {
131
+ writeFileSync(abs(relResult), `${JSON.stringify(row)}\n`);
132
+ return { ...row, resultFile: relResult, exitCode: 0 };
133
+ };
134
+
135
+ // Source repo: the project's live tree is the resolved tree (R6).
136
+ if (existsSync(abs(SOURCE_REPO_MARKER))) {
137
+ try {
138
+ return write({
139
+ mode: 'source-repo',
140
+ source: 'project',
141
+ dir: PROJECT_SCRIPTS_DIR,
142
+ scriptSetDigest: digestScriptSet(PROJECT_SCRIPTS_DIR, cwd),
143
+ });
144
+ } catch (error) {
145
+ return write({ mode: 'unresolved', error: `source-repo digest failed: ${String(error)}` });
146
+ }
147
+ }
148
+
149
+ // Installed: resolve the twin, and name a dead vendored dir that is no longer executed.
150
+ if (existsSync(abs(PROJECT_SCRIPTS_DIR))) {
151
+ warn(
152
+ `script-root: ignoring vendored ${PROJECT_SCRIPTS_DIR}/ (no ${SOURCE_REPO_MARKER} marker) — ` +
153
+ `steps resolve the installed twin via 'superskill script path sp <rel>'`,
154
+ );
155
+ }
156
+ const resolve = options.resolveScript ?? defaultResolveScript(cwd);
157
+ const resolved = resolve(SCRIPT_ROOT_TWIN_REL);
158
+ if (!resolved?.path) {
159
+ return write({
160
+ mode: 'unresolved',
161
+ error: `'superskill script path sp ${SCRIPT_ROOT_TWIN_REL}' resolved nothing`,
162
+ });
163
+ }
164
+ const dir = dirname(resolved.path);
165
+ try {
166
+ return write({
167
+ mode: 'installed',
168
+ source: resolved.source ?? 'unknown',
169
+ dir,
170
+ scriptSetDigest: digestScriptSet(dir),
171
+ });
172
+ } catch (error) {
173
+ return write({ mode: 'unresolved', error: `installed digest failed: ${String(error)}` });
174
+ }
175
+ }
176
+
177
+ export const SCRIPT_ROOT_USAGE = 'usage: script-root --run-id <id>';
178
+
179
+ export function main(argv: string[], env: ScriptRootEnv = getEnvVars(), options: ScriptRootOptions = {}): number {
180
+ let runId = env.__runId ?? '';
181
+ for (let i = 0; i < argv.length; i++) {
182
+ if (argv[i] === '--run-id') {
183
+ runId = argv[i + 1] ?? '';
184
+ i++;
185
+ continue;
186
+ }
187
+ process.stderr.write(`${SCRIPT_ROOT_USAGE}\n`);
188
+ return 2;
189
+ }
190
+ return runScriptRoot({ ...env, __runId: runId }, options).exitCode;
191
+ }
192
+
193
+ if (import.meta.main) {
194
+ process.exit(main(process.argv.slice(2)));
195
+ }
@@ -1281,7 +1281,7 @@ function resolveFeature(input: ResolutionInput): StageResolution {
1281
1281
  reasonKind: 'dispatch',
1282
1282
  requiresConfirmation: row.requiresConfirmation,
1283
1283
  chain: false,
1284
- dispatchCommand: dispatchCmd,
1284
+ dispatchCommand: dispatchCmd ?? null,
1285
1285
  nextObservableOutcome: row.rowId === 'B6' ? 'feature transition or wrap' : undefined,
1286
1286
  };
1287
1287
  }
@@ -59,7 +59,8 @@ function usage(): never {
59
59
  }
60
60
 
61
61
  function defaultSpurBin(): string {
62
- if (getEnvVar('SPUR_BIN')) return getEnvVar('SPUR_BIN');
62
+ const fromEnv = getEnvVar('SPUR_BIN');
63
+ if (fromEnv) return fromEnv;
63
64
  const local = fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url));
64
65
  if (existsSync(local)) return `bun ${local}`;
65
66
  return 'spur';
@@ -71,6 +72,7 @@ function parseArgs(argv: string[]): { wbs: string; spurBin: string } {
71
72
  let i = 0;
72
73
  while (i < argv.length) {
73
74
  const arg = argv[i];
75
+ if (arg === undefined) break;
74
76
  if (arg === '--spur-bin') {
75
77
  spurBin = argv[i + 1] ?? defaultSpurBin();
76
78
  i += 2;
@@ -109,7 +111,7 @@ function writeStatus(wbs: string, status: 'PASS' | 'FAIL'): void {
109
111
  writeFileSync(join(statusDir, `${wbs}-precheck-evidence.status`), `${status}\n`);
110
112
  }
111
113
 
112
- function fail(wbs: string, reasons: string[]): void {
114
+ function fail(wbs: string, reasons: string[]): never {
113
115
  writeStatus(wbs, 'FAIL');
114
116
  console.error(`task-evidence-precheck: FAIL`);
115
117
  for (const r of reasons) {
@@ -89,7 +89,8 @@ function usage(): never {
89
89
  * keeps ad-hoc invocations from silently hitting a stale PATH install.
90
90
  */
91
91
  function defaultSpurBin(): string {
92
- if (getEnvVar('SPUR_BIN')) return getEnvVar('SPUR_BIN');
92
+ const fromEnv = getEnvVar('SPUR_BIN');
93
+ if (fromEnv) return fromEnv;
93
94
  // scripts/ -> plugins/sp/ -> <repo>/apps/cli/src/index.ts (fileURLToPath — raw pathname breaks
94
95
  // on %-encoded paths, e.g. spaces in the checkout directory)
95
96
  const local = fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url));
@@ -115,6 +116,7 @@ function parseArgs(argv: string[]): {
115
116
  let i = 0;
116
117
  while (i < argv.length) {
117
118
  const arg = argv[i];
119
+ if (arg === undefined) break;
118
120
  if (arg === '--spur-bin') {
119
121
  spurBin = argv[i + 1] ?? defaultSpurBin();
120
122
  i += 2;
@@ -66,7 +66,7 @@ function parseCommand(filePath: string, name: string): ParsedCommand {
66
66
  // Extract frontmatter between --- delimiters
67
67
  const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
68
68
  const body = fmMatch ? raw.slice(fmMatch[0].length).trim() : raw;
69
- const fm = fmMatch ? fmMatch[1] : '';
69
+ const fm = fmMatch?.[1] ?? '';
70
70
 
71
71
  const description = extractYamlField(fm, 'description');
72
72
  const argumentHint = extractYamlField(fm, 'argument-hint');
@@ -124,9 +124,9 @@ function parseCommand(filePath: string, name: string): ParsedCommand {
124
124
  /** Extract a plain YAML string field (handles quoted and unquoted). */
125
125
  function extractYamlField(fm: string, key: string): string | undefined {
126
126
  const re = new RegExp(`^${key}:\\s*(.+)$`, 'm');
127
- const m = fm.match(re);
128
- if (!m) return undefined;
129
- const value = m[1].trim();
127
+ const m = fm.match(re)?.[1];
128
+ if (m === undefined) return undefined;
129
+ const value = m.trim();
130
130
  // Unquote double-quoted YAML scalars
131
131
  if (value.startsWith('"') && value.endsWith('"')) {
132
132
  return value.slice(1, -1).replace(/\\"/g, '"');
@@ -137,9 +137,9 @@ function extractYamlField(fm: string, key: string): string | undefined {
137
137
  /** Extract a YAML flow list ["A", "B"]. */
138
138
  function extractYamlList(fm: string, key: string): string[] | undefined {
139
139
  const re = new RegExp(`^${key}:\\s*\\[(.*)\\]$`, 'm');
140
- const m = fm.match(re);
141
- if (!m) return undefined;
142
- return [...m[1].matchAll(/"([^"]*)"/g)].map((q) => q[1]);
140
+ const m = fm.match(re)?.[1];
141
+ if (m === undefined) return undefined;
142
+ return [...m.matchAll(/"([^"]*)"/g)].map((q) => q[1] ?? '');
143
143
  }
144
144
 
145
145
  const ALLOWED_HEADINGS: Record<string, true> = { Usage: true, Implementation: true };
@@ -262,7 +262,7 @@ function checkTargetResolution(cmd: ParsedCommand, skillsDir: string, root: stri
262
262
 
263
263
  // Extract ## Implementation section content
264
264
  const implPart = cmd.body.split(/^## Implementation\n/m)[1] ?? '';
265
- const implSection = implPart.split(/\n## (?!Usage)/m)[0].trim();
265
+ const implSection = (implPart.split(/\n## (?!Usage)/m)[0] ?? '').trim();
266
266
 
267
267
  // Skill() calls: Skill(skill="sp:NAME",
268
268
  // The name charset must stay at least as wide as any name a skill directory can
@@ -270,7 +270,7 @@ function checkTargetResolution(cmd: ParsedCommand, skillsDir: string, root: stri
270
270
  // would pass a genuinely unresolved reference rather than report it.
271
271
  const skillCalls = [...implSection.matchAll(/Skill\(skill="sp:([a-z][a-z0-9-]*)"/g)];
272
272
  for (const ref of skillCalls) {
273
- const skillName = ref[1];
273
+ const skillName = ref[1] ?? '';
274
274
  const skillPath = join(skillsDir, skillName, 'SKILL.md');
275
275
  if (!existsSync(skillPath)) {
276
276
  violations.push({
@@ -304,7 +304,7 @@ function checkTargetResolution(cmd: ParsedCommand, skillsDir: string, root: stri
304
304
  // Procedure reference + anchor — markdown links with anchors in Implementation
305
305
  const anchorRefs = [...implSection.matchAll(/\[([^\]]*)\]\(([^)]*#([^)]*))\)/g)];
306
306
  for (const ref of anchorRefs) {
307
- const filePart = ref[2].split('#')[0];
307
+ const filePart = (ref[2] ?? '').split('#')[0] ?? '';
308
308
  const anchor = ref[3];
309
309
  // Resolve relative paths from commands/ directory
310
310
  const resolved = resolve(join(root, 'plugins', 'sp', 'commands'), filePart);
@@ -414,7 +414,7 @@ function parseMarkdownTable(section: string): MarkdownTable | null {
414
414
  .replace(/\|$/, '')
415
415
  .split(/(?<!\\)\|/)
416
416
  .map((c) => c.trim().replace(/\\\|/g, '|'));
417
- const header = split(tableLines[0]);
417
+ const header = split(tableLines[0] ?? '');
418
418
  // Skip the separator row (--- cells).
419
419
  const dataRows = tableLines
420
420
  .slice(1)
@@ -430,7 +430,7 @@ function parseMarkdownTable(section: string): MarkdownTable | null {
430
430
  function extractHintTokens(hint: string): { positionals: string[]; flags: string[] } {
431
431
  const positionals = [...hint.matchAll(/<[^>]+>/g)].map((m) => m[0]);
432
432
  // Flags: capture the `--flag` literal, ignoring the value placeholder that follows.
433
- const flags = [...hint.matchAll(/(--[a-z][a-z0-9-]*)/g)].map((m) => m[1]);
433
+ const flags = [...hint.matchAll(/(--[a-z][a-z0-9-]*)/g)].map((m) => m[1] ?? '');
434
434
  // Deduplicate while preserving order.
435
435
  return {
436
436
  positionals: [...new Set(positionals)],
@@ -71,7 +71,7 @@ function sectionBetween(raw: string, startRe: RegExp, nextHeading: '## ' | '###
71
71
  function extractFlags(text: string): Set<string> {
72
72
  const flags = new Set<string>();
73
73
  for (const m of text.matchAll(/(--[a-z][a-z-]*)/g)) {
74
- flags.add(m[1]);
74
+ flags.add(m[1] ?? '');
75
75
  }
76
76
  return flags;
77
77
  }
@@ -80,7 +80,7 @@ function extractFlags(text: string): Set<string> {
80
80
  function deprecatedFlagSet(hint: string): Set<string> {
81
81
  const out = new Set<string>();
82
82
  for (const m of hint.matchAll(/(--[a-z][a-z-]*)\s*\(deprecated\)/g)) {
83
- out.add(m[1]);
83
+ out.add(m[1] ?? '');
84
84
  }
85
85
  return out;
86
86
  }
@@ -104,7 +104,7 @@ function glossaryEntries(glossaryRaw: string): GlossaryEntry[] {
104
104
  const entryRe = /^### `(--[a-z][a-z-]*)[^\n]*$/gm;
105
105
  for (const m of glossaryRaw.matchAll(entryRe)) {
106
106
  const flag = m[1];
107
- if (m.index === undefined) continue;
107
+ if (flag === undefined || m.index === undefined) continue;
108
108
  const bodyStart = m.index + m[0].length;
109
109
  const next = glossaryRaw.indexOf('\n### ', bodyStart);
110
110
  const body = next === -1 ? glossaryRaw.slice(bodyStart) : glossaryRaw.slice(bodyStart, next);
@@ -132,8 +132,8 @@ function glossaryDeclaringClaims(glossaryRaw: string): Map<string, Set<string>>
132
132
  for (const { flag, firstParagraph } of glossaryEntries(glossaryRaw)) {
133
133
  const names = new Set<string>();
134
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]);
135
+ for (const n of (paren[1] ?? '').matchAll(/`(dev-[a-z-]+)`/g)) {
136
+ names.add(n[1] ?? '');
137
137
  }
138
138
  }
139
139
  if (names.size >= 2) claims.set(flag, names);
@@ -210,7 +210,7 @@ function commandTableDefaults(commandRaw: string): Map<string, string> {
210
210
  if (!start || start.index === undefined) return out;
211
211
  const from = start.index + start[0].length;
212
212
  const next = commandRaw.indexOf('\n## ', from);
213
- const table = (next === -1 ? commandRaw.slice(from) : commandRaw.slice(from, next)).split(/\n\s*\n/)[0];
213
+ const table = (next === -1 ? commandRaw.slice(from) : commandRaw.slice(from, next)).split(/\n\s*\n/)[0] ?? '';
214
214
  // Split on unescaped pipes only (same as validate-commands.ts parseMarkdownTable),
215
215
  // then unescape `\|` → `|` per cell.
216
216
  const split = (row: string): string[] =>
@@ -223,10 +223,11 @@ function commandTableDefaults(commandRaw: string): Map<string, string> {
223
223
  const trimmed = line.trim();
224
224
  if (!trimmed.startsWith('|')) continue;
225
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];
226
+ const first = cells[0] ?? '';
227
+ if (cells.length < 3 || first.startsWith('---') || first === 'Flag') continue;
228
+ const flag = first.replace(/`/g, '').split(/\s/)[0] ?? '';
228
229
  if (!flag.startsWith('--')) continue;
229
- out.set(flag, cells[2].replace(/[`*]/g, '').trim());
230
+ out.set(flag, (cells[2] ?? '').replace(/[`*]/g, '').trim());
230
231
  }
231
232
  return out;
232
233
  }
@@ -237,7 +238,7 @@ function commandToOpHeader(opsRaw: string): Map<string, string> {
237
238
  const rowRe = /^\|\s*(\d+[a-z]?)\s*\|\s*([a-z-]+)\s*\|\s*`(dev-[a-z-]+)`/;
238
239
  for (const line of opsRaw.split('\n')) {
239
240
  const m = line.match(rowRe);
240
- if (m) map.set(m[3], `${m[1]}. ${m[2]}`);
241
+ if (m) map.set(m[3] ?? '', `${m[1] ?? ''}. ${m[2] ?? ''}`);
241
242
  }
242
243
  return map;
243
244
  }
@@ -249,17 +250,18 @@ function opsSectionDefaults(sectionBody: string): Map<string, { value: string; c
249
250
  // Pair each default marker with the flag mentioned immediately before it on the line.
250
251
  const flags: Array<{ name: string; index: number }> = [];
251
252
  for (const m of line.matchAll(/`(--[a-z][a-z-]*)(?: <[^>]+>)?`/g)) {
252
- flags.push({ name: m[1], index: m.index });
253
+ flags.push({ name: m[1] ?? '', index: m.index });
253
254
  }
254
255
  for (const m of line.matchAll(/\(default:?\s*\*?\*?`?([^`);]+?)\s*`?[;)]/g)) {
255
- const value = m[1].replace(/[`*]/g, '').trim();
256
+ const value = (m[1] ?? '').replace(/[`*]/g, '').trim();
256
257
  const preceding = flags.filter((f) => f.index < m.index).pop();
257
258
  if (!preceding) continue;
258
259
  out.set(preceding.name, { value, claim: line.trim() });
259
260
  }
260
261
  // `X (default)` trailing form (e.g. "**`full`** (default):" in Modes prose).
261
262
  for (const m of line.matchAll(/`([^`]+)`\s*\(default\)/g)) {
262
- out.set(m[1], { value: m[1], claim: line.trim() });
263
+ const v = m[1] ?? '';
264
+ out.set(v, { value: v, claim: line.trim() });
263
265
  }
264
266
  }
265
267
  return out;
@@ -369,11 +371,12 @@ export function extractValueBehaviorTable(
369
371
  .replace(/^\||\|$/g, '')
370
372
  .split('|')
371
373
  .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];
374
+ const first = cells[0] ?? '';
375
+ if (cells.length < 3 || first.startsWith('---') || first === 'Value') continue;
376
+ const valueCell = first.replace(/`/g, '').trim();
377
+ const value = valueCell.split(/\s/)[0] ?? '';
375
378
  if (value !== 'inline' && value !== 'auto' && value !== '<name>') continue;
376
- const behavior = normalizeSurfaceCell(cells[2]);
379
+ const behavior = normalizeSurfaceCell(cells[2] ?? '');
377
380
  out.set(value, { ...behavior, defaultWhenOmitted: /default when omitted/i.test(valueCell) });
378
381
  }
379
382
  return out.size === 3 ? out : null;
@@ -395,9 +398,10 @@ export function extractTriggerTable(crossCuttingRaw: string): string[] | null {
395
398
  .replace(/^\||\|$/g, '')
396
399
  .split('|')
397
400
  .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
+ const first = cells[0] ?? '';
402
+ if (cells.length < 3 || first.startsWith('---')) continue;
403
+ if (first === 'Trigger') continue;
404
+ rows.push(first.replace(/\*\*/g, '').replace(/\.\s*$/, ''));
401
405
  }
402
406
  return rows.length >= 4 ? rows : null;
403
407
  }
@@ -414,8 +418,12 @@ function adrAgentClaims(adrRaw: string): Map<string, SurfaceBehavior> | null {
414
418
  conditional: false,
415
419
  defaultWhenOmitted: false,
416
420
  });
417
- out.set('auto', { surfaces: new Set(['subprocess']), conditional: false });
418
- out.set('<name>', { surfaces: new Set(['inline', 'subprocess']), conditional: true });
421
+ out.set('auto', { surfaces: new Set(['subprocess']), conditional: false, defaultWhenOmitted: false });
422
+ out.set('<name>', {
423
+ surfaces: new Set(['inline', 'subprocess']),
424
+ conditional: true,
425
+ defaultWhenOmitted: false,
426
+ });
419
427
  return out;
420
428
  }
421
429
  // Slice from the `## ADR-041` heading to the next `## ADR-` heading or end of input
@@ -429,7 +437,11 @@ function adrAgentClaims(adrRaw: string): Map<string, SurfaceBehavior> | null {
429
437
  const out = new Map<string, SurfaceBehavior>();
430
438
  // Rule sentence: "if the named executor is … inline; otherwise … subprocess" → <name> conditional.
431
439
  if (/the work happens inline;\s*otherwise it dispatches a subprocess/.test(section)) {
432
- out.set('<name>', { surfaces: new Set(['inline', 'subprocess']), conditional: true });
440
+ out.set('<name>', {
441
+ surfaces: new Set(['inline', 'subprocess']),
442
+ conditional: true,
443
+ defaultWhenOmitted: false,
444
+ });
433
445
  }
434
446
  // Collapse mapping: `--subprocess` → `--agent <target>`. The target value's behavior is
435
447
  // claimed by the mapping itself — parse the target generically so a drifted mapping
@@ -438,12 +450,12 @@ function adrAgentClaims(adrRaw: string): Map<string, SurfaceBehavior> | null {
438
450
  // `--subprocess` is matched explicitly (`` `--subprocess` `` in the source).
439
451
  const subprocessTarget = section.match(/--subprocess`\s*→\s*`--agent ([a-z]+)/)?.[1];
440
452
  if (subprocessTarget === 'auto') {
441
- out.set('auto', { surfaces: new Set(['subprocess']), conditional: false });
453
+ out.set('auto', { surfaces: new Set(['subprocess']), conditional: false, defaultWhenOmitted: false });
442
454
  } else if (subprocessTarget === 'inline') {
443
- out.set('auto', { surfaces: new Set(['inline']), conditional: false });
455
+ out.set('auto', { surfaces: new Set(['inline']), conditional: false, defaultWhenOmitted: false });
444
456
  }
445
457
  if (/--inline`\s*→\s*`--agent inline/.test(section)) {
446
- out.set('inline', { surfaces: new Set(['inline']), conditional: false });
458
+ out.set('inline', { surfaces: new Set(['inline']), conditional: false, defaultWhenOmitted: false });
447
459
  }
448
460
  return out.size === 0 ? null : out;
449
461
  }
@@ -637,8 +649,8 @@ export function checkAgentSsotIntegrity(files: ReadonlyMap<string, string>): Fla
637
649
  for (const block of tableBlocks) {
638
650
  const valuesFound = new Set<string>();
639
651
  for (const row of block) {
640
- const firstCell = row.replace(/^\|/, '').split('|')[0].replace(/`/g, '').trim();
641
- const token = firstCell.split(/\s/)[0].toLowerCase();
652
+ const firstCell = (row.replace(/^\|/, '').split('|')[0] ?? '').replace(/`/g, '').trim();
653
+ const token = (firstCell.split(/\s/)[0] ?? '').toLowerCase();
642
654
  if (token === 'inline' || token === 'auto' || token === '<name>') {
643
655
  valuesFound.add(token);
644
656
  }
@@ -684,7 +696,7 @@ export function headingAnchors(raw: string): Set<string> {
684
696
  const anchors = new Set<string>();
685
697
  for (const line of raw.split('\n')) {
686
698
  const m = line.match(/^#{1,6}\s+(.*)$/);
687
- if (m) anchors.add(headingSlug(m[1]));
699
+ if (m) anchors.add(headingSlug(m[1] ?? ''));
688
700
  }
689
701
  return anchors;
690
702
  }
@@ -704,7 +716,7 @@ export function checkSsotAnchorsResolve(crossCuttingRaw: string, files: Readonly
704
716
  for (const [filename, raw] of files) {
705
717
  const seen = new Set<string>();
706
718
  for (const m of raw.matchAll(/cross-cutting\.md#([\w-]+)/g)) {
707
- const anchor = m[1];
719
+ const anchor = m[1] ?? '';
708
720
  if (anchors.has(anchor) || seen.has(anchor)) continue;
709
721
  seen.add(anchor);
710
722
  violations.push({
@@ -42,7 +42,8 @@ function usage(): never {
42
42
  }
43
43
 
44
44
  function defaultSpurBin(): string {
45
- if (getEnvVar('SPUR_BIN')) return getEnvVar('SPUR_BIN');
45
+ const fromEnv = getEnvVar('SPUR_BIN');
46
+ if (fromEnv) return fromEnv;
46
47
  const local = fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url));
47
48
  if (existsSync(local)) return `bun ${local}`;
48
49
  return 'spur';
@@ -55,6 +56,7 @@ function parseArgs(argv: string[]): { wbs: string; answer: string; spurBin: stri
55
56
  let i = 0;
56
57
  while (i < argv.length) {
57
58
  const arg = argv[i];
59
+ if (arg === undefined) break;
58
60
  if (arg === '--spur-bin') {
59
61
  spurBin = argv[i + 1] ?? defaultSpurBin();
60
62
  i += 2;
@@ -176,8 +176,9 @@ function formatStepProfileHuman(profile) {
176
176
  `;
177
177
  }
178
178
  function defaultSpurBin() {
179
- if (getEnvVar("SPUR_BIN"))
180
- return getEnvVar("SPUR_BIN");
179
+ const fromEnv = getEnvVar("SPUR_BIN");
180
+ if (fromEnv)
181
+ return fromEnv;
181
182
  const local = fileURLToPath(new URL("../../../apps/cli/src/index.ts", import.meta.url));
182
183
  if (existsSync(local))
183
184
  return `bun ${local}`;
@@ -196,6 +197,8 @@ function parseStepProfileCliArgs(argv) {
196
197
  let help = false;
197
198
  for (let i = 0;i < argv.length; i++) {
198
199
  const a = argv[i];
200
+ if (a === undefined)
201
+ continue;
199
202
  if (a === "--help" || a === "-h")
200
203
  help = true;
201
204
  else if (a === "--json")
@@ -322,7 +322,8 @@ export interface StepProfileCliArgs {
322
322
  * --spur-bin > SPUR_BIN > monorepo-local CLI entry > PATH `spur`.
323
323
  */
324
324
  export function defaultSpurBin(): string {
325
- if (getEnvVar('SPUR_BIN')) return getEnvVar('SPUR_BIN');
325
+ const fromEnv = getEnvVar('SPUR_BIN');
326
+ if (fromEnv) return fromEnv;
326
327
  const local = fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url));
327
328
  if (existsSync(local)) return `bun ${local}`;
328
329
  return 'spur';
@@ -343,6 +344,7 @@ export function parseStepProfileCliArgs(argv: string[]): StepProfileCliArgs {
343
344
 
344
345
  for (let i = 0; i < argv.length; i++) {
345
346
  const a = argv[i];
347
+ if (a === undefined) continue;
346
348
  if (a === '--help' || a === '-h') help = true;
347
349
  else if (a === '--json') json = true;
348
350
  else if (a === '--last') last = positiveInt(argv[++i], last);
@@ -57,6 +57,8 @@ function changedPathsOf(section) {
57
57
  const paths = [];
58
58
  for (const match of section.matchAll(CHANGE_ENTRY)) {
59
59
  const path = match[1];
60
+ if (path === undefined)
61
+ continue;
60
62
  if (/\s/.test(path))
61
63
  continue;
62
64
  if (!path.includes("/") && !path.includes("."))
@@ -73,7 +75,8 @@ var SURFACE_MATCHERS = DOC_OWNED_SURFACES.map((glob) => ({ glob, regex: surfaceR
73
75
  function driftReasonForPath(path) {
74
76
  if (CORPUS_PREFIXES.some((prefix) => path.startsWith(prefix)))
75
77
  return null;
76
- if (path.includes("/") && !KNOWN_TOP_LEVEL.has(path.split("/")[0])) {
78
+ const top = path.split("/")[0];
79
+ if (path.includes("/") && top !== undefined && !KNOWN_TOP_LEVEL.has(top)) {
77
80
  return "new top-level workspace directory";
78
81
  }
79
82
  const match = SURFACE_MATCHERS.find((entry) => entry.regex.test(path));
@@ -82,7 +85,7 @@ function driftReasonForPath(path) {
82
85
  function defaultSpurRunner(env, cwd) {
83
86
  const parts = (env.spurBin ?? "spur").split(/\s+/).filter((part) => part.length > 0);
84
87
  return (args) => {
85
- const run = spawnSync(parts[0], [...parts.slice(1), ...args], {
88
+ const run = spawnSync(parts[0] ?? "spur", [...parts.slice(1), ...args], {
86
89
  cwd,
87
90
  encoding: "utf8",
88
91
  env: getEnvVars()
@@ -125,6 +125,7 @@ export function changedPathsOf(section: string | null): string[] {
125
125
  const paths: string[] = [];
126
126
  for (const match of section.matchAll(CHANGE_ENTRY)) {
127
127
  const path = match[1];
128
+ if (path === undefined) continue;
128
129
  // Path-like: no whitespace and at least one separator or dotted suffix, so prose
129
130
  // backticks never masquerade as change-map entries.
130
131
  if (/\s/.test(path)) continue;
@@ -148,7 +149,8 @@ const SURFACE_MATCHERS = DOC_OWNED_SURFACES.map((glob) => ({ glob, regex: surfac
148
149
  /** 0944 R2 surface match: doc-owned glob, corpus exclusion, new top-level directory. */
149
150
  export function driftReasonForPath(path: string): string | null {
150
151
  if (CORPUS_PREFIXES.some((prefix) => path.startsWith(prefix))) return null;
151
- if (path.includes('/') && !KNOWN_TOP_LEVEL.has(path.split('/')[0])) {
152
+ const top = path.split('/')[0];
153
+ if (path.includes('/') && top !== undefined && !KNOWN_TOP_LEVEL.has(top)) {
152
154
  return 'new top-level workspace directory';
153
155
  }
154
156
  const match = SURFACE_MATCHERS.find((entry) => entry.regex.test(path));
@@ -158,7 +160,7 @@ export function driftReasonForPath(path: string): string | null {
158
160
  function defaultSpurRunner(env: DriftProbeEnv, cwd?: string): SpurRunner {
159
161
  const parts = (env.spurBin ?? 'spur').split(/\s+/).filter((part) => part.length > 0);
160
162
  return (args: string[]): SpurShowResult => {
161
- const run = spawnSync(parts[0], [...parts.slice(1), ...args], {
163
+ const run = spawnSync(parts[0] ?? 'spur', [...parts.slice(1), ...args], {
162
164
  cwd,
163
165
  encoding: 'utf8',
164
166
  env: getEnvVars(),