@gobing-ai/spur 0.4.0 → 0.4.2

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 (172) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.global.yaml +4 -2
  3. package/config/decisions/gates.yaml +36 -0
  4. package/config/decisions/history-anatomy.yaml +35 -0
  5. package/config/decisions/idea-pipeline.yaml +38 -0
  6. package/config/plugin-scripts.json +15 -0
  7. package/config/rules/docs/sourced-api-claims.yaml +60 -0
  8. package/config/rules/recommended-pre-check.yaml +8 -3
  9. package/config/rules/structure/scratch-evidence-pointer.yaml +35 -0
  10. package/config/script-placement-baseline.json +2 -2
  11. package/config/templates/AGENTS.md +1 -1
  12. package/config/workflows/feature-lifecycle.yaml +9 -2
  13. package/config/workflows/history-anatomy.yaml +22 -0
  14. package/config/workflows/idea-pipeline.yaml +75 -0
  15. package/config/workflows/task-pipeline.yaml +216 -57
  16. package/config/workflows/wrapup-pipeline.yaml +107 -14
  17. package/package.json +10 -10
  18. package/plugins/sp/README.md +12 -3
  19. package/plugins/sp/agents/super-planner.md +16 -6
  20. package/plugins/sp/agents/super-reviewer.md +14 -0
  21. package/plugins/sp/bunfig.toml +8 -0
  22. package/plugins/sp/commands/dev-brainstorm.md +29 -16
  23. package/plugins/sp/commands/dev-find-way.md +80 -0
  24. package/plugins/sp/commands/dev-idea.md +6 -0
  25. package/plugins/sp/commands/dev-plan.md +6 -0
  26. package/plugins/sp/commands/dev-review-session.md +4 -1
  27. package/plugins/sp/commands/dev-run.md +12 -2
  28. package/plugins/sp/commands/dev-runall.md +32 -15
  29. package/plugins/sp/hooks/context-owner.ts +56 -0
  30. package/plugins/sp/hooks/context-post-tool.ts +23 -0
  31. package/plugins/sp/hooks/context-session-start.ts +49 -27
  32. package/plugins/sp/hooks/context-session-stop.ts +131 -30
  33. package/plugins/sp/hooks/pi/guard-extension.ts +21 -8
  34. package/plugins/sp/lib/idea-handoff.generated.mjs +43 -5
  35. package/plugins/sp/lib/inline-run-app.ts +28 -0
  36. package/plugins/sp/lib/inline-run.generated.d.mts +5 -0
  37. package/plugins/sp/lib/inline-run.generated.mjs +351 -46
  38. package/plugins/sp/lib/plan-projection.generated.d.mts +47 -0
  39. package/plugins/sp/lib/plan-projection.generated.mjs +368 -0
  40. package/plugins/sp/lib/quality-gate.generated.d.mts +35 -1
  41. package/plugins/sp/lib/quality-gate.generated.mjs +30296 -71
  42. package/plugins/sp/lib/residual-scan.generated.d.mts +19 -0
  43. package/plugins/sp/lib/residual-scan.generated.mjs +66 -0
  44. package/plugins/sp/lib/run-summary-core.ts +561 -0
  45. package/plugins/sp/lib/transcript.ts +150 -7
  46. package/plugins/sp/plugin.json +1 -1
  47. package/plugins/sp/references/roles.md +1 -1
  48. package/plugins/sp/scripts/batch-plan.mjs +488 -0
  49. package/plugins/sp/scripts/batch-plan.ts +145 -0
  50. package/plugins/sp/scripts/batch-preflight.mjs +51 -3
  51. package/plugins/sp/scripts/batch-preflight.ts +72 -1
  52. package/plugins/sp/scripts/commit-guard.mjs +216 -0
  53. package/plugins/sp/scripts/commit-guard.ts +249 -0
  54. package/plugins/sp/scripts/inline-run-dispatch.mjs +130 -0
  55. package/plugins/sp/scripts/inline-run-dispatch.ts +135 -0
  56. package/plugins/sp/scripts/inline-run-setup.mjs +562 -80
  57. package/plugins/sp/scripts/inline-run-setup.ts +52 -78
  58. package/plugins/sp/scripts/persist-out-check.mjs +90 -41
  59. package/plugins/sp/scripts/persist-out-check.ts +112 -57
  60. package/plugins/sp/scripts/pr-reviewing.mjs +19 -5
  61. package/plugins/sp/scripts/pr-reviewing.ts +34 -9
  62. package/plugins/sp/scripts/quality-gate.mjs +30213 -61
  63. package/plugins/sp/scripts/quality-gate.ts +21 -5
  64. package/plugins/sp/scripts/residual-scan.mjs +86 -31
  65. package/plugins/sp/scripts/residual-scan.ts +28 -30
  66. package/plugins/sp/scripts/run-summary.mjs +432 -14
  67. package/plugins/sp/scripts/run-summary.ts +41 -177
  68. package/plugins/sp/scripts/session-timeline.mjs +109 -11
  69. package/plugins/sp/scripts/session-timeline.ts +57 -19
  70. package/plugins/sp/scripts/task-diffstat.mjs +32 -29
  71. package/plugins/sp/scripts/task-diffstat.ts +34 -34
  72. package/plugins/sp/scripts/wrapup-steps.mjs +150 -2
  73. package/plugins/sp/scripts/wrapup-steps.ts +203 -1
  74. package/plugins/sp/skills/brainstorm/SKILL.md +9 -47
  75. package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
  76. package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +1 -0
  77. package/plugins/sp/skills/code-implementation/SKILL.md +48 -1
  78. package/plugins/sp/skills/code-verification/SKILL.md +3 -4
  79. package/plugins/sp/skills/code-verification/references/secu-review.md +3 -0
  80. package/plugins/sp/skills/code-verification/references/verdict-schema.md +68 -0
  81. package/plugins/sp/skills/fleet-join/SKILL.md +1 -1
  82. package/plugins/sp/skills/indexed-context/SKILL.md +14 -7
  83. package/plugins/sp/skills/parallel-execution/SKILL.md +2 -2
  84. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
  85. package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +2 -2
  86. package/plugins/sp/skills/pr-reviewing/SKILL.md +7 -3
  87. package/plugins/sp/skills/session-review/SKILL.md +40 -7
  88. package/plugins/sp/skills/source-driven-development/SKILL.md +5 -0
  89. package/plugins/sp/skills/spec-decomposition/references/decomposition.md +5 -3
  90. package/plugins/sp/skills/spur-cli/SKILL.md +5 -2
  91. package/plugins/sp/skills/spur-cli/references/agent.md +20 -15
  92. package/plugins/sp/skills/spur-cli/references/decision.md +16 -8
  93. package/plugins/sp/skills/spur-cli/references/message.md +7 -7
  94. package/plugins/sp/skills/spur-cli/references/projects.md +7 -5
  95. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +19 -3
  96. package/plugins/sp/skills/spur-cli/references/tasks.md +10 -7
  97. package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +7 -0
  98. package/plugins/sp/skills/spur-cli/references/workflows.md +7 -2
  99. package/plugins/sp/skills/spur-composer/SKILL.md +1 -1
  100. package/plugins/sp/skills/spur-dev/SKILL.md +20 -14
  101. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +33 -3
  102. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +49 -14
  103. package/plugins/sp/skills/spur-dev/references/dev-operations.md +37 -17
  104. package/plugins/sp/skills/spur-dev/references/execution-batch-continuation.md +68 -0
  105. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +199 -0
  106. package/plugins/sp/skills/spur-dev/references/execution-batch.md +156 -1024
  107. package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +133 -0
  108. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +6 -0
  109. package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +471 -0
  110. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +379 -0
  111. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +44 -12
  112. package/plugins/sp/skills/spur-dev/references/glossary.md +2 -2
  113. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +340 -165
  114. package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +178 -0
  115. package/plugins/sp/skills/wayfinder/SKILL.md +10 -14
  116. package/schemas/state-machine-workflow.schema.json +23 -0
  117. package/spur.js +42402 -26889
  118. package/web/_astro/{BoardApp.VeoxxLBy.js → BoardApp.B3GQZvj8.js} +1 -1
  119. package/web/_astro/BoardApp.BT7L2kSb.js +324 -0
  120. package/web/_astro/TaskDetail.Cxov1_N_.js +1 -0
  121. package/web/_astro/{arc.f4e0OYLg.js → arc.vaT7tPOB.js} +1 -1
  122. package/web/_astro/{architectureDiagram-3BPJPVTR.SSIvxi-Z.js → architectureDiagram-3BPJPVTR.DRMmTjkN.js} +1 -1
  123. package/web/_astro/{blockDiagram-GPEHLZMM.DSXIHOFf.js → blockDiagram-GPEHLZMM.cla0LO_q.js} +1 -1
  124. package/web/_astro/{c4Diagram-AAUBKEIU.DG9Ubu85.js → c4Diagram-AAUBKEIU.D4YAIBKn.js} +1 -1
  125. package/web/_astro/channel.D4nG9YLf.js +1 -0
  126. package/web/_astro/{chunk-2J33WTMH.C7QU88Qh.js → chunk-2J33WTMH.BrSJbMRZ.js} +1 -1
  127. package/web/_astro/{chunk-4BX2VUAB.26Z94xBj.js → chunk-4BX2VUAB.CuLMgKF5.js} +1 -1
  128. package/web/_astro/{chunk-55IACEB6.DYRiBkqw.js → chunk-55IACEB6.zkvgu54F.js} +1 -1
  129. package/web/_astro/{chunk-727SXJPM.D5Vfv5pe.js → chunk-727SXJPM.DxWkACh8.js} +1 -1
  130. package/web/_astro/{chunk-AQP2D5EJ.DXHV47bN.js → chunk-AQP2D5EJ.42XrmIlH.js} +1 -1
  131. package/web/_astro/{chunk-FMBD7UC4.D1suAfOs.js → chunk-FMBD7UC4.Bao2wMK9.js} +1 -1
  132. package/web/_astro/{chunk-ND2GUHAM.D6BmdHEY.js → chunk-ND2GUHAM.BhydojD1.js} +1 -1
  133. package/web/_astro/{chunk-QZHKN3VN.rnPU-Ak-.js → chunk-QZHKN3VN.CmZ82g-H.js} +1 -1
  134. package/web/_astro/{classDiagram-4FO5ZUOK.CvEfXIo1.js → classDiagram-4FO5ZUOK.Blr83Gt_.js} +1 -1
  135. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CvEfXIo1.js → classDiagram-v2-Q7XG4LA2.Blr83Gt_.js} +1 -1
  136. package/web/_astro/{cose-bilkent-S5V4N54A.DkMisLSm.js → cose-bilkent-S5V4N54A.qmfv0SPn.js} +1 -1
  137. package/web/_astro/{cynefin-OW5HDTMX.C77Q-pF6.js → cynefin-OW5HDTMX.BtELhXPR.js} +1 -1
  138. package/web/_astro/{dagre-BM42HDAG.YaV8ce27.js → dagre-BM42HDAG.AWmaAvXo.js} +1 -1
  139. package/web/_astro/{diagram-2AECGRRQ.CUyGEKge.js → diagram-2AECGRRQ.DdH34KDi.js} +1 -1
  140. package/web/_astro/{diagram-5GNKFQAL.CRgd8dgj.js → diagram-5GNKFQAL.BmNPix1k.js} +1 -1
  141. package/web/_astro/{diagram-KO2AKTUF.Bmzys9hq.js → diagram-KO2AKTUF.BNQd_Ppo.js} +1 -1
  142. package/web/_astro/{diagram-LMA3HP47.BvmJY4Lw.js → diagram-LMA3HP47.BAPgDi0e.js} +1 -1
  143. package/web/_astro/{diagram-OG6HWLK6.0hEhD30d.js → diagram-OG6HWLK6.DZ4zyLdC.js} +1 -1
  144. package/web/_astro/{erDiagram-TEJ5UH35.Bjt8lrnm.js → erDiagram-TEJ5UH35.Q1TySMmw.js} +1 -1
  145. package/web/_astro/{flowDiagram-I6XJVG4X.CJZTjRDV.js → flowDiagram-I6XJVG4X.BXhFBicG.js} +1 -1
  146. package/web/_astro/{ganttDiagram-6RSMTGT7._VqoVafc.js → ganttDiagram-6RSMTGT7.vHSgcuo2.js} +1 -1
  147. package/web/_astro/{gitGraphDiagram-PVQCEYII.CEDb7tVo.js → gitGraphDiagram-PVQCEYII.Bi1f73C7.js} +1 -1
  148. package/web/_astro/index.BhSwN8yY.css +1 -0
  149. package/web/_astro/{infoDiagram-5YYISTIA.CgwifPpK.js → infoDiagram-5YYISTIA.DPvXZEWm.js} +1 -1
  150. package/web/_astro/{ishikawaDiagram-YF4QCWOH.B99wx2J1.js → ishikawaDiagram-YF4QCWOH.Cz829fsV.js} +1 -1
  151. package/web/_astro/{journeyDiagram-JHISSGLW.2hRwJbEo.js → journeyDiagram-JHISSGLW.enRnB9Me.js} +1 -1
  152. package/web/_astro/{kanban-definition-UN3LZRKU.BahJkKNC.js → kanban-definition-UN3LZRKU.DnTNk6d2.js} +1 -1
  153. package/web/_astro/{linear.DguUUe6V.js → linear.BzGnBwNL.js} +1 -1
  154. package/web/_astro/{mermaid.core.CvgfmqVr.js → mermaid.core.CSbTpIVz.js} +4 -4
  155. package/web/_astro/{mindmap-definition-RKZ34NQL.B3qEA3x9.js → mindmap-definition-RKZ34NQL.DGJDy8zT.js} +1 -1
  156. package/web/_astro/{pieDiagram-4H26LBE5.FlWA-_ru.js → pieDiagram-4H26LBE5.HSPdi5_8.js} +1 -1
  157. package/web/_astro/{quadrantDiagram-W4KKPZXB.9bVn35X7.js → quadrantDiagram-W4KKPZXB.6Gv8mz2Y.js} +1 -1
  158. package/web/_astro/{requirementDiagram-4Y6WPE33.ChykIhct.js → requirementDiagram-4Y6WPE33.CnBCtFwa.js} +1 -1
  159. package/web/_astro/{sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js → sankeyDiagram-5OEKKPKP.5M8VWAu8.js} +1 -1
  160. package/web/_astro/{sequenceDiagram-3UESZ5HK.J7JsHWDt.js → sequenceDiagram-3UESZ5HK.DN41XN-U.js} +1 -1
  161. package/web/_astro/{stateDiagram-AJRCARHV.DAPbiCSS.js → stateDiagram-AJRCARHV.ErOO3W8j.js} +1 -1
  162. package/web/_astro/{stateDiagram-v2-BHNVJYJU.D2jvSi0q.js → stateDiagram-v2-BHNVJYJU.B_ZHICM-.js} +1 -1
  163. package/web/_astro/{timeline-definition-PNZ67QCA.psW2ci29.js → timeline-definition-PNZ67QCA.CWdosr07.js} +1 -1
  164. package/web/_astro/{vennDiagram-CIIHVFJN.DlILYVmg.js → vennDiagram-CIIHVFJN.BHzYjNV5.js} +1 -1
  165. package/web/_astro/{wardleyDiagram-YWT4CUSO.xNWwLJnn.js → wardleyDiagram-YWT4CUSO.h8x4IwPX.js} +1 -1
  166. package/web/_astro/{xychartDiagram-2RQKCTM6.EU_BuH7V.js → xychartDiagram-2RQKCTM6.RTapW2X8.js} +1 -1
  167. package/web/index.html +2 -2
  168. package/plugins/sp/commands/dev-parallel.md +0 -31
  169. package/web/_astro/BoardApp.Bzr4OXrW.js +0 -324
  170. package/web/_astro/TaskDetail.LAyR_Pvn.js +0 -1
  171. package/web/_astro/channel.CUFdtWhm.js +0 -1
  172. package/web/_astro/index.Dg_3bOaJ.css +0 -1
@@ -16,7 +16,7 @@ var SENSITIVE_PREFIXES = ["drizzle/", "packages/config/", ".github/", "plugins/s
16
16
  var SENSITIVE_GLOBS = ["apps/server/src/**/auth*", "**/*secret*", "**/*.sql"];
17
17
  function globToRegex(glob) {
18
18
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**/", "\x00").replaceAll("**", "\x01").replaceAll("*", "[^/]*").replaceAll("\x00", "(?:.*/)?").replaceAll("\x01", ".*");
19
- return new RegExp(`^${escaped}$`);
19
+ return new RegExp(`^${escaped}$`, "s");
20
20
  }
21
21
  var SENSITIVE_MATCHERS = [
22
22
  ...SENSITIVE_PREFIXES.map((prefix) => ({
@@ -52,22 +52,12 @@ function countNewlines(path, cwd) {
52
52
  lines += 1;
53
53
  return lines;
54
54
  }
55
- function expandDiffPath(raw) {
56
- const arrow = raw.indexOf(" => ");
57
- if (arrow === -1)
58
- return [raw];
59
- const open = raw.indexOf("{");
60
- const close = raw.lastIndexOf("}");
61
- if (open !== -1 && close > open && open < arrow && arrow < close) {
62
- const inner = raw.slice(open + 1, close);
63
- const innerArrow = inner.indexOf(" => ");
64
- if (innerArrow !== -1) {
65
- const head = raw.slice(0, open);
66
- const tail = raw.slice(close + 1);
67
- return [head + inner.slice(0, innerArrow) + tail, head + inner.slice(innerArrow + 4) + tail];
68
- }
69
- }
70
- return [raw.slice(0, arrow), raw.slice(arrow + 4)];
55
+ function addCounts(add, del) {
56
+ for (const count of [add, del])
57
+ if (count !== "-" && !/^\d+$/.test(count))
58
+ return null;
59
+ const [a, d] = [add === "-" ? 0 : Number(add), del === "-" ? 0 : Number(del)];
60
+ return Number.isSafeInteger(a) && Number.isSafeInteger(d) ? [a, d] : null;
71
61
  }
72
62
  function runDiffstat(env, options = {}, git = defaultGitRunner(options.cwd)) {
73
63
  const cwd = options.cwd;
@@ -99,23 +89,37 @@ function runDiffstat(env, options = {}, git = defaultGitRunner(options.cwd)) {
99
89
  } catch {}
100
90
  if (!/^[0-9a-f]{7,40}$/i.test(base))
101
91
  return failSafe("run base .spur/run/<wbs>-base.sha missing or malformed");
102
- const numstat = git(["diff", "--numstat", base]);
92
+ const numstat = git(["diff", "--numstat", "-z", base]);
103
93
  if (numstat.status !== 0)
104
94
  return failSafe(`git diff --numstat failed (status=${numstat.status})`);
95
+ if (numstat.stdout.length > 0 && !numstat.stdout.endsWith("\x00"))
96
+ return failSafe("git diff --numstat -z output is not NUL-terminated (malformed framing)");
105
97
  const paths = new Set;
106
98
  let insertions = 0;
107
99
  let deletions = 0;
108
- for (const line of numstat.stdout.split(`
109
- `)) {
110
- const row2 = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
111
- if (!row2)
100
+ const records = numstat.stdout.split("\x00").slice(0, -1);
101
+ for (let i = 0;i < records.length; i++) {
102
+ const header = records[i] ?? "";
103
+ const first = header.indexOf("\t");
104
+ const second = header.indexOf("\t", first + 1);
105
+ if (first < 1 || second < first + 2)
106
+ return failSafe("git diff --numstat -z record is missing counts or path");
107
+ const counts = addCounts(header.slice(0, first), header.slice(first + 1, second));
108
+ if (counts === null)
109
+ return failSafe("git diff --numstat -z record has a malformed count");
110
+ insertions += counts[0];
111
+ deletions += counts[1];
112
+ const path = header.slice(second + 1);
113
+ if (path !== "") {
114
+ paths.add(path);
112
115
  continue;
113
- for (const p of expandDiffPath(row2[3] ?? ""))
114
- paths.add(p);
115
- if (row2[1] !== "-")
116
- insertions += Number(row2[1]);
117
- if (row2[2] !== "-")
118
- deletions += Number(row2[2]);
116
+ }
117
+ const oldPath = records[++i];
118
+ const newPath = records[++i];
119
+ if (!oldPath || !newPath)
120
+ return failSafe("git diff --numstat -z rename record has incomplete paths");
121
+ paths.add(oldPath);
122
+ paths.add(newPath);
119
123
  }
120
124
  const others = git(["ls-files", "--others", "--exclude-standard", "-z"]);
121
125
  if (others.status !== 0)
@@ -161,7 +165,6 @@ export {
161
165
  sensitiveReasonForPath,
162
166
  runDiffstat,
163
167
  main,
164
- expandDiffPath,
165
168
  TASK_DIFFSTAT_USAGE,
166
169
  SENSITIVE_PREFIXES,
167
170
  SENSITIVE_GLOBS
@@ -7,11 +7,11 @@
7
7
  * `precheck`, F96 R1) and the current working tree, then writes:
8
8
  * `.spur/run/<wbs>-diffstat.json` `{ files, insertions, deletions, paths[], sensitive }`
9
9
  *
10
- * Counts come from `git diff --numstat <base>` (tracked changes; binary rows count the
11
- * path but no lines) plus untracked files (`git ls-files --others --exclude-standard`,
12
- * newline-counted — # ponytail: untracked line counts are a newline scan, numstat-grade
13
- * binary detection is unnecessary for a lane heuristic; switch to `--numstat --no-index`
14
- * if a miscount ever flips a lane).
10
+ * Counts come from `git diff --numstat -z <base>` (tracked changes; binary rows count the path
11
+ * but no lines; NUL records carry RAW paths, so a non-ASCII or newline-bearing path reaches the
12
+ * sensitive matchers intact) plus untracked files (`git ls-files --others --exclude-standard -z`,
13
+ * newline-counted — # ponytail: untracked line counts are a newline scan, numstat-grade binary
14
+ * detection is unnecessary here; switch to `--numstat --no-index` if a miscount flips a lane).
15
15
  *
16
16
  * `sensitive` is true when ANY changed path matches the exported SENSITIVE list (0943 R2c);
17
17
  * the workflow's pre-decide guard maps sensitive (or >400 changed lines) to the safety lane
@@ -85,7 +85,7 @@ function globToRegex(glob: string): RegExp {
85
85
  .replaceAll('*', '[^/]*')
86
86
  .replaceAll('\u0000', '(?:.*/)?')
87
87
  .replaceAll('\u0001', '.*');
88
- return new RegExp(`^${escaped}$`);
88
+ return new RegExp(`^${escaped}$`, 's');
89
89
  }
90
90
 
91
91
  const SENSITIVE_MATCHERS: Array<{ label: string; test: (path: string) => boolean }> = [
@@ -125,26 +125,10 @@ function countNewlines(path: string, cwd?: string): number {
125
125
  return lines;
126
126
  }
127
127
 
128
- /**
129
- * git renders renames in --numstat paths as `old => new` (full) or `head{old => new}tail`
130
- * (partial). The sensitive matchers anchor on single paths, so both rename halves must be
131
- * surfaced — otherwise a rename INTO a sensitive path (review 0943 P3#1) classified clean.
132
- */
133
- export function expandDiffPath(raw: string): string[] {
134
- const arrow = raw.indexOf(' => ');
135
- if (arrow === -1) return [raw];
136
- const open = raw.indexOf('{');
137
- const close = raw.lastIndexOf('}');
138
- if (open !== -1 && close > open && open < arrow && arrow < close) {
139
- const inner = raw.slice(open + 1, close);
140
- const innerArrow = inner.indexOf(' => ');
141
- if (innerArrow !== -1) {
142
- const head = raw.slice(0, open);
143
- const tail = raw.slice(close + 1);
144
- return [head + inner.slice(0, innerArrow) + tail, head + inner.slice(innerArrow + 4) + tail];
145
- }
146
- }
147
- return [raw.slice(0, arrow), raw.slice(arrow + 4)];
128
+ function addCounts(add: string, del: string): [number, number] | null {
129
+ for (const count of [add, del]) if (count !== '-' && !/^\d+$/.test(count)) return null;
130
+ const [a, d] = [add === '-' ? 0 : Number(add), del === '-' ? 0 : Number(del)];
131
+ return Number.isSafeInteger(a) && Number.isSafeInteger(d) ? [a, d] : null;
148
132
  }
149
133
 
150
134
  /**
@@ -190,18 +174,34 @@ export function runDiffstat(
190
174
  }
191
175
  if (!/^[0-9a-f]{7,40}$/i.test(base)) return failSafe('run base .spur/run/<wbs>-base.sha missing or malformed');
192
176
 
193
- const numstat = git(['diff', '--numstat', base]);
177
+ const numstat = git(['diff', '--numstat', '-z', base]);
194
178
  if (numstat.status !== 0) return failSafe(`git diff --numstat failed (status=${numstat.status})`);
179
+ if (numstat.stdout.length > 0 && !numstat.stdout.endsWith('\u0000'))
180
+ return failSafe('git diff --numstat -z output is not NUL-terminated (malformed framing)');
195
181
  const paths = new Set<string>();
196
182
  let insertions = 0;
197
183
  let deletions = 0;
198
- for (const line of numstat.stdout.split('\n')) {
199
- // Binary rows report `-` for both counts; the path still counts as changed.
200
- const row = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
201
- if (!row) continue;
202
- for (const p of expandDiffPath(row[3] ?? '')) paths.add(p);
203
- if (row[1] !== '-') insertions += Number(row[1]);
204
- if (row[2] !== '-') deletions += Number(row[2]);
184
+ const records = numstat.stdout.split('\u0000').slice(0, -1); // drop the terminating NUL tail
185
+ for (let i = 0; i < records.length; i++) {
186
+ const header = records[i] ?? '';
187
+ const first = header.indexOf('\t'); // first two tabs only
188
+ const second = header.indexOf('\t', first + 1); // a raw path may itself hold more tabs
189
+ if (first < 1 || second < first + 2) return failSafe('git diff --numstat -z record is missing counts or path');
190
+ const counts = addCounts(header.slice(0, first), header.slice(first + 1, second));
191
+ if (counts === null) return failSafe('git diff --numstat -z record has a malformed count');
192
+ insertions += counts[0];
193
+ deletions += counts[1]; // once per count header, never once per rename half
194
+ const path = header.slice(second + 1);
195
+ if (path !== '') {
196
+ paths.add(path);
197
+ continue;
198
+ }
199
+ // An empty path field is Git's rename/copy form: the next two records are old and new.
200
+ const oldPath = records[++i];
201
+ const newPath = records[++i];
202
+ if (!oldPath || !newPath) return failSafe('git diff --numstat -z rename record has incomplete paths');
203
+ paths.add(oldPath);
204
+ paths.add(newPath);
205
205
  }
206
206
  const others = git(['ls-files', '--others', '--exclude-standard', '-z']);
207
207
  if (others.status !== 0) return failSafe(`git ls-files failed (status=${others.status})`);
@@ -443,7 +443,137 @@ function runFeatureTransition(env, options = {}) {
443
443
  `);
444
444
  return { status: syncStatus, statusFile: relStatusFile, exitCode: 0 };
445
445
  }
446
- var WRAPUP_STEPS_USAGE = "usage: wrapup-steps.ts <resolve|route-reason|metrics|feature-transition> (env: __runId, tasks, mode, feature, featureGateCmd, spurBin)";
446
+ var DOC_SYNC_SNAPSHOT_SUFFIX = "wrapup-doc-sync-snapshot.txt";
447
+ var DOC_SYNC_SCOPE_STATUS_SUFFIX = "wrapup-doc-sync-scope.status";
448
+ var DOC_SYNC_SUPERSESSION_STATUS_SUFFIX = "wrapup-doc-supersession.status";
449
+ var SUPERSESSION_PIN = "repo-wide-tests/adr-supersession.test.ts";
450
+ var GIT_UNAVAILABLE_MARKER = "# git-unavailable:";
451
+ var SNAPSHOT_MISSING = "snapshot-missing";
452
+ function porcelainPath(line) {
453
+ const body = line.length >= 3 ? line.slice(3) : line;
454
+ const arrow = body.indexOf(" -> ");
455
+ const target = arrow >= 0 ? body.slice(arrow + 4) : body;
456
+ const trimmed = target.trim();
457
+ return trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2 ? trimmed.slice(1, -1) : trimmed;
458
+ }
459
+ function isDocSyncAllowedPath(path, allowedExtra) {
460
+ if (allowedExtra.includes(path))
461
+ return true;
462
+ if (!path.startsWith("docs/"))
463
+ return false;
464
+ return !path.startsWith("docs/tasks") && !path.startsWith("docs/features");
465
+ }
466
+ function docSyncScopeViolations(snapshot, current, allowedExtra) {
467
+ const before = new Set(snapshot.split(`
468
+ `).filter((line) => line.trim() !== ""));
469
+ const paths = new Set;
470
+ for (const line of current.split(`
471
+ `)) {
472
+ if (line.trim() === "" || before.has(line))
473
+ continue;
474
+ const path = porcelainPath(line);
475
+ if (!isDocSyncAllowedPath(path, allowedExtra))
476
+ paths.add(path);
477
+ }
478
+ return [...paths].sort();
479
+ }
480
+ function gitPorcelain(cwd) {
481
+ const result = spawnSync("git", ["status", "--porcelain", "-uall"], {
482
+ cwd,
483
+ encoding: "utf8"
484
+ });
485
+ if (result.error !== undefined)
486
+ return { ok: false, listing: "", detail: String(result.error) };
487
+ if (result.status !== 0)
488
+ return { ok: false, listing: "", detail: (result.stderr ?? "").trim() };
489
+ return { ok: true, listing: result.stdout ?? "", detail: "" };
490
+ }
491
+ function wrapupRunPaths(env, cwd) {
492
+ const runId = env.__runId ?? "";
493
+ const rel = (suffix) => join(".spur", "run", `${runId}-${suffix}`);
494
+ return { rel, abs: (suffix) => cwd ? join(cwd, rel(suffix)) : rel(suffix) };
495
+ }
496
+ function docSyncAllowedExtra(env) {
497
+ const runId = env.__runId ?? "";
498
+ return [
499
+ join(".spur", "run", `${runId}-wrapup-learnings.md`),
500
+ join(".spur", "run", `${runId}-${DOC_SYNC_SNAPSHOT_SUFFIX}`),
501
+ join(".spur", "run", `${runId}-${DOC_SYNC_SCOPE_STATUS_SUFFIX}`)
502
+ ];
503
+ }
504
+ function runDocSyncSnapshot(env, options = {}) {
505
+ const cwd = options.cwd;
506
+ const paths = wrapupRunPaths(env, cwd);
507
+ mkdirSync(cwd ? join(cwd, ".spur", "run") : join(".spur", "run"), { recursive: true });
508
+ writeFileSync(paths.abs(DOC_SYNC_SNAPSHOT_SUFFIX), "");
509
+ const listing = gitPorcelain(cwd);
510
+ if (!listing.ok) {
511
+ writeFileSync(paths.abs(DOC_SYNC_SNAPSHOT_SUFFIX), `${GIT_UNAVAILABLE_MARKER} ${listing.detail}
512
+ `);
513
+ process.stderr.write(`doc-sync-snapshot: git status unavailable \u2014 scope check skips (${listing.detail})
514
+ `);
515
+ return { statusFile: paths.rel(DOC_SYNC_SNAPSHOT_SUFFIX), captured: false, exitCode: 0 };
516
+ }
517
+ writeFileSync(paths.abs(DOC_SYNC_SNAPSHOT_SUFFIX), listing.listing);
518
+ return { statusFile: paths.rel(DOC_SYNC_SNAPSHOT_SUFFIX), captured: true, exitCode: 0 };
519
+ }
520
+ function runDocSyncScope(env, options = {}) {
521
+ const cwd = options.cwd;
522
+ const paths = wrapupRunPaths(env, cwd);
523
+ mkdirSync(cwd ? join(cwd, ".spur", "run") : join(".spur", "run"), { recursive: true });
524
+ const statusFile = paths.rel(DOC_SYNC_SCOPE_STATUS_SUFFIX);
525
+ const write = (status, detail) => {
526
+ writeFileSync(paths.abs(DOC_SYNC_SCOPE_STATUS_SUFFIX), `${detail}
527
+ `);
528
+ if (status !== "PASS")
529
+ process.stderr.write(`doc-sync-scope: ${detail}
530
+ `);
531
+ return { status, statusFile, violations: status === "PASS" ? [] : [detail], exitCode: 0 };
532
+ };
533
+ const snapshot = readFileSyncSafe(paths.abs(DOC_SYNC_SNAPSHOT_SUFFIX));
534
+ if (snapshot === null) {
535
+ return write("scope-violation", `scope-violation: ${SNAPSHOT_MISSING}`);
536
+ }
537
+ if (snapshot.startsWith(GIT_UNAVAILABLE_MARKER)) {
538
+ process.stderr.write(`doc-sync-scope: entry snapshot recorded no git listing \u2014 scope check skipped
539
+ `);
540
+ return write("PASS", "PASS");
541
+ }
542
+ const current = gitPorcelain(cwd);
543
+ if (!current.ok) {
544
+ return write("scope-violation", `scope-violation: git-unavailable (${current.detail})`);
545
+ }
546
+ const violations = docSyncScopeViolations(snapshot, current.listing, docSyncAllowedExtra(env));
547
+ if (violations.length === 0)
548
+ return write("PASS", "PASS");
549
+ return write("scope-violation", `scope-violation: ${violations.join(" ")}`);
550
+ }
551
+ function runDocSupersessionCheck(env, options = {}) {
552
+ const cwd = options.cwd;
553
+ const paths = wrapupRunPaths(env, cwd);
554
+ mkdirSync(cwd ? join(cwd, ".spur", "run") : join(".spur", "run"), { recursive: true });
555
+ const statusFile = paths.rel(DOC_SYNC_SUPERSESSION_STATUS_SUFFIX);
556
+ const pinPath = cwd ? join(cwd, SUPERSESSION_PIN) : SUPERSESSION_PIN;
557
+ if (!existsSync(pinPath)) {
558
+ writeFileSync(paths.abs(DOC_SYNC_SUPERSESSION_STATUS_SUFFIX), `PASS
559
+ `);
560
+ return { status: "PASS", statusFile, exitCode: 0 };
561
+ }
562
+ const pin = spawnSync("bun", ["test", SUPERSESSION_PIN], { cwd, encoding: "utf8" });
563
+ if (pin.status === 0) {
564
+ writeFileSync(paths.abs(DOC_SYNC_SUPERSESSION_STATUS_SUFFIX), `PASS
565
+ `);
566
+ return { status: "PASS", statusFile, exitCode: 0 };
567
+ }
568
+ writeFileSync(paths.abs(DOC_SYNC_SUPERSESSION_STATUS_SUFFIX), `supersession-pin-failed: ${SUPERSESSION_PIN}
569
+ `);
570
+ const detail = `${pin.stdout ?? ""}${pin.stderr ?? ""}`.trim();
571
+ process.stderr.write(`doc-supersession: ${SUPERSESSION_PIN} failed (exit ${String(pin.status ?? 1)}) \u2014 the doc-sync step introduced or kept superseded index content:
572
+ ${detail.slice(-2000)}
573
+ `);
574
+ return { status: "supersession-pin-failed", statusFile, exitCode: 0 };
575
+ }
576
+ var WRAPUP_STEPS_USAGE = "usage: wrapup-steps.ts <resolve|route-reason|metrics|feature-transition|doc-sync-snapshot|doc-sync-scope|doc-supersession> (env: __runId, tasks, mode, feature, featureGateCmd, spurBin)";
447
577
  function main(argv, env = getEnvVars(), options = {}) {
448
578
  const sub = argv[0];
449
579
  if (sub === "resolve")
@@ -456,6 +586,12 @@ function main(argv, env = getEnvVars(), options = {}) {
456
586
  }
457
587
  if (sub === "feature-transition")
458
588
  return runFeatureTransition(env, options).exitCode;
589
+ if (sub === "doc-sync-snapshot")
590
+ return runDocSyncSnapshot(env, options).exitCode;
591
+ if (sub === "doc-sync-scope")
592
+ return runDocSyncScope(env, options).exitCode;
593
+ if (sub === "doc-supersession")
594
+ return runDocSupersessionCheck(env, options).exitCode;
459
595
  process.stderr.write(`${WRAPUP_STEPS_USAGE}
460
596
  `);
461
597
  return 2;
@@ -469,11 +605,23 @@ export {
469
605
  taskStatusOf,
470
606
  runMetrics,
471
607
  runFeatureTransition,
608
+ runDocSyncSnapshot,
609
+ runDocSyncScope,
610
+ runDocSupersessionCheck,
472
611
  resolveTasks,
473
612
  preflightFeature,
613
+ porcelainPath,
474
614
  main,
475
615
  jqPick,
616
+ isDocSyncAllowedPath,
617
+ docSyncScopeViolations,
476
618
  classifySync,
477
619
  WRAPUP_STEPS_USAGE,
478
- WBS_PATTERN
620
+ WBS_PATTERN,
621
+ SUPERSESSION_PIN,
622
+ SNAPSHOT_MISSING,
623
+ GIT_UNAVAILABLE_MARKER,
624
+ DOC_SYNC_SUPERSESSION_STATUS_SUFFIX,
625
+ DOC_SYNC_SNAPSHOT_SUFFIX,
626
+ DOC_SYNC_SCOPE_STATUS_SUFFIX
479
627
  };
@@ -14,6 +14,9 @@
14
14
  * - `.spur/memory/wrapup-metrics.jsonl` one row per task (metrics)
15
15
  * - `<runId>-wrapup-metrics.status` `PASS`/`FAIL` (metrics)
16
16
  * - `<runId>-wrapup-sync.status` `PASS`/`FAIL` (feature-transition)
17
+ * - `<runId>-wrapup-doc-sync-snapshot.txt` working listing at doc-sync entry (doc-sync-snapshot)
18
+ * - `<runId>-wrapup-doc-sync-scope.status` `PASS`/`scope-violation: <paths>` (doc-sync-scope)
19
+ * - `<runId>-wrapup-doc-supersession.status` `PASS`/`supersession-pin-failed: <pin>` (doc-supersession)
17
20
  *
18
21
  * Environment comes from the workflow vars: `__runId`, `tasks`, `feature`, `featureGateCmd`
19
22
  * and `spurBin` (split on whitespace into a command plus prefix args, so
@@ -668,8 +671,204 @@ export function runFeatureTransition(env: WrapupStepsEnv, options: WrapupStepsOp
668
671
  return { status: syncStatus, statusFile: relStatusFile, exitCode: 0 };
669
672
  }
670
673
 
674
+ /** Run-scoped snapshot of the working listing taken at doc-sync entry (1147 R1). */
675
+ export const DOC_SYNC_SNAPSHOT_SUFFIX = 'wrapup-doc-sync-snapshot.txt';
676
+ /** Run-scoped verdict of the post-doc-sync write-scope check (1147 R1). */
677
+ export const DOC_SYNC_SCOPE_STATUS_SUFFIX = 'wrapup-doc-sync-scope.status';
678
+ /** Run-scoped verdict of the post-doc-sync supersession-pin re-run (1147 R2). */
679
+ export const DOC_SYNC_SUPERSESSION_STATUS_SUFFIX = 'wrapup-doc-supersession.status';
680
+ /**
681
+ * The supersession pins doc-sync must not resurrect (1147 R2). Re-run right after doc-sync so a
682
+ * re-added delinked index row is attributed to doc-sync instead of to the later `doc-tripwire`
683
+ * backstop, which stays unchanged.
684
+ */
685
+ export const SUPERSESSION_PIN = 'repo-wide-tests/adr-supersession.test.ts';
686
+ /** Snapshot marker for "git could not list the tree" — the check then skips instead of guessing. */
687
+ export const GIT_UNAVAILABLE_MARKER = '# git-unavailable:';
688
+
689
+ /** Snapshot marker the scope check reports when doc-sync's entry snapshot is absent (fail closed). */
690
+ export const SNAPSHOT_MISSING = 'snapshot-missing';
691
+
692
+ /**
693
+ * One `git status --porcelain` line → the repo-relative path it names. A rename/copy line
694
+ * (`R old -> new`) reports its target, and a quoted path is unquoted (1147 R1).
695
+ */
696
+ export function porcelainPath(line: string): string {
697
+ const body = line.length >= 3 ? line.slice(3) : line;
698
+ const arrow = body.indexOf(' -> ');
699
+ const target = arrow >= 0 ? body.slice(arrow + 4) : body;
700
+ const trimmed = target.trim();
701
+ return trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2 ? trimmed.slice(1, -1) : trimmed;
702
+ }
703
+
704
+ /**
705
+ * doc-sync's declared write scope (1147 R1): `docs/**` except the task/feature corpus, the
706
+ * learnings capture the step declares, and the guard's own snapshot/status bookkeeping (never
707
+ * doc-sync edits, and present in the listing on a repeated check).
708
+ */
709
+ export function isDocSyncAllowedPath(path: string, allowedExtra: readonly string[]): boolean {
710
+ if (allowedExtra.includes(path)) return true;
711
+ if (!path.startsWith('docs/')) return false;
712
+ return !path.startsWith('docs/tasks') && !path.startsWith('docs/features');
713
+ }
714
+
715
+ /**
716
+ * Paths doc-sync newly dirtied: the current porcelain listing minus the entry snapshot's lines.
717
+ * A line present in both listings is pre-existing dirt (a sibling task's uncommitted work) and is
718
+ * never attributed to this pass (1147 R1).
719
+ */
720
+ export function docSyncScopeViolations(snapshot: string, current: string, allowedExtra: readonly string[]): string[] {
721
+ const before = new Set(snapshot.split('\n').filter((line) => line.trim() !== ''));
722
+ const paths = new Set<string>();
723
+ for (const line of current.split('\n')) {
724
+ if (line.trim() === '' || before.has(line)) continue;
725
+ const path = porcelainPath(line);
726
+ if (!isDocSyncAllowedPath(path, allowedExtra)) paths.add(path);
727
+ }
728
+ return [...paths].sort();
729
+ }
730
+
731
+ /** `git status --porcelain -uall` — the listing both the snapshot and the scope check compare. */
732
+ function gitPorcelain(cwd?: string): { ok: boolean; listing: string; detail: string } {
733
+ const result = spawnSync('git', ['status', '--porcelain', '-uall'], {
734
+ cwd,
735
+ encoding: 'utf8',
736
+ });
737
+ if (result.error !== undefined) return { ok: false, listing: '', detail: String(result.error) };
738
+ if (result.status !== 0) return { ok: false, listing: '', detail: (result.stderr ?? '').trim() };
739
+ return { ok: true, listing: result.stdout ?? '', detail: '' };
740
+ }
741
+
742
+ interface WrapupRunPaths {
743
+ rel: (suffix: string) => string;
744
+ abs: (suffix: string) => string;
745
+ }
746
+
747
+ function wrapupRunPaths(env: WrapupStepsEnv, cwd?: string): WrapupRunPaths {
748
+ const runId = env.__runId ?? '';
749
+ const rel = (suffix: string): string => join('.spur', 'run', `${runId}-${suffix}`);
750
+ return { rel, abs: (suffix: string) => (cwd ? join(cwd, rel(suffix)) : rel(suffix)) };
751
+ }
752
+
753
+ /** The four path shapes this check knows are not doc-sync edits (1147 R1). */
754
+ function docSyncAllowedExtra(env: WrapupStepsEnv): string[] {
755
+ const runId = env.__runId ?? '';
756
+ return [
757
+ join('.spur', 'run', `${runId}-wrapup-learnings.md`),
758
+ join('.spur', 'run', `${runId}-${DOC_SYNC_SNAPSHOT_SUFFIX}`),
759
+ join('.spur', 'run', `${runId}-${DOC_SYNC_SCOPE_STATUS_SUFFIX}`),
760
+ ];
761
+ }
762
+
763
+ /** Outcome of `doc-sync-snapshot`: the recorded entry listing (or a git-unavailable marker). */
764
+ export interface DocSyncSnapshotResult {
765
+ statusFile: string;
766
+ /** True when the listing was captured; false when git could not list the tree. */
767
+ captured: boolean;
768
+ exitCode: number;
769
+ }
770
+
771
+ /**
772
+ * `doc-sync-snapshot` — record the working listing at doc-sync entry (1147 R1), before the model
773
+ * query runs, so the post-doc-sync check can attribute only this step's writes. Never aborts: a
774
+ * git failure is recorded in the snapshot (the check then skips rather than guessing).
775
+ */
776
+ export function runDocSyncSnapshot(env: WrapupStepsEnv, options: WrapupStepsOptions = {}): DocSyncSnapshotResult {
777
+ const cwd = options.cwd;
778
+ const paths = wrapupRunPaths(env, cwd);
779
+ mkdirSync(cwd ? join(cwd, '.spur', 'run') : join('.spur', 'run'), { recursive: true });
780
+ // Create the file BEFORE listing so it is present in both listings (never a false violation).
781
+ writeFileSync(paths.abs(DOC_SYNC_SNAPSHOT_SUFFIX), '');
782
+ const listing = gitPorcelain(cwd);
783
+ if (!listing.ok) {
784
+ writeFileSync(paths.abs(DOC_SYNC_SNAPSHOT_SUFFIX), `${GIT_UNAVAILABLE_MARKER} ${listing.detail}\n`);
785
+ process.stderr.write(`doc-sync-snapshot: git status unavailable — scope check skips (${listing.detail})\n`);
786
+ return { statusFile: paths.rel(DOC_SYNC_SNAPSHOT_SUFFIX), captured: false, exitCode: 0 };
787
+ }
788
+ writeFileSync(paths.abs(DOC_SYNC_SNAPSHOT_SUFFIX), listing.listing);
789
+ return { statusFile: paths.rel(DOC_SYNC_SNAPSHOT_SUFFIX), captured: true, exitCode: 0 };
790
+ }
791
+
792
+ /** Outcome of `doc-sync-scope`: the recorded verdict plus the offending paths it named. */
793
+ export interface DocSyncScopeResult {
794
+ status: 'PASS' | 'scope-violation';
795
+ statusFile: string;
796
+ violations: string[];
797
+ exitCode: number;
798
+ }
799
+
800
+ /**
801
+ * `doc-sync-scope` — the deterministic write-scope guard (1147 R1): diff the working listing
802
+ * against the entry snapshot and fail the step on any path outside the declared scope. The
803
+ * violating edits are reported, never reverted (the operator decides); the verdict lives in the
804
+ * status file so the workflow guard routes on it.
805
+ */
806
+ export function runDocSyncScope(env: WrapupStepsEnv, options: WrapupStepsOptions = {}): DocSyncScopeResult {
807
+ const cwd = options.cwd;
808
+ const paths = wrapupRunPaths(env, cwd);
809
+ mkdirSync(cwd ? join(cwd, '.spur', 'run') : join('.spur', 'run'), { recursive: true });
810
+ const statusFile = paths.rel(DOC_SYNC_SCOPE_STATUS_SUFFIX);
811
+ const write = (status: DocSyncScopeResult['status'], detail: string): DocSyncScopeResult => {
812
+ writeFileSync(paths.abs(DOC_SYNC_SCOPE_STATUS_SUFFIX), `${detail}\n`);
813
+ if (status !== 'PASS') process.stderr.write(`doc-sync-scope: ${detail}\n`);
814
+ return { status, statusFile, violations: status === 'PASS' ? [] : [detail], exitCode: 0 };
815
+ };
816
+
817
+ const snapshot = readFileSyncSafe(paths.abs(DOC_SYNC_SNAPSHOT_SUFFIX));
818
+ if (snapshot === null) {
819
+ return write('scope-violation', `scope-violation: ${SNAPSHOT_MISSING}`);
820
+ }
821
+ if (snapshot.startsWith(GIT_UNAVAILABLE_MARKER)) {
822
+ process.stderr.write('doc-sync-scope: entry snapshot recorded no git listing — scope check skipped\n');
823
+ return write('PASS', 'PASS');
824
+ }
825
+ const current = gitPorcelain(cwd);
826
+ if (!current.ok) {
827
+ return write('scope-violation', `scope-violation: git-unavailable (${current.detail})`);
828
+ }
829
+ const violations = docSyncScopeViolations(snapshot, current.listing, docSyncAllowedExtra(env));
830
+ if (violations.length === 0) return write('PASS', 'PASS');
831
+ return write('scope-violation', `scope-violation: ${violations.join(' ')}`);
832
+ }
833
+
834
+ /** Outcome of `doc-supersession`: the recorded verdict of the supersession-pin re-run. */
835
+ export interface DocSupersessionResult {
836
+ status: 'PASS' | 'supersession-pin-failed';
837
+ statusFile: string;
838
+ exitCode: number;
839
+ }
840
+
841
+ /**
842
+ * `doc-supersession` — re-run the supersession pins right after doc-sync (1147 R2). A doc-sync
843
+ * that resurrects a deliberately delinked index row fails HERE, named with the pin, instead of at
844
+ * the later repo-wide `doc-tripwire` backstop (which is never weakened). A project without the pin
845
+ * skips: this check must stay inert outside the spur source repo.
846
+ */
847
+ export function runDocSupersessionCheck(env: WrapupStepsEnv, options: WrapupStepsOptions = {}): DocSupersessionResult {
848
+ const cwd = options.cwd;
849
+ const paths = wrapupRunPaths(env, cwd);
850
+ mkdirSync(cwd ? join(cwd, '.spur', 'run') : join('.spur', 'run'), { recursive: true });
851
+ const statusFile = paths.rel(DOC_SYNC_SUPERSESSION_STATUS_SUFFIX);
852
+ const pinPath = cwd ? join(cwd, SUPERSESSION_PIN) : SUPERSESSION_PIN;
853
+ if (!existsSync(pinPath)) {
854
+ writeFileSync(paths.abs(DOC_SYNC_SUPERSESSION_STATUS_SUFFIX), 'PASS\n');
855
+ return { status: 'PASS', statusFile, exitCode: 0 };
856
+ }
857
+ const pin = spawnSync('bun', ['test', SUPERSESSION_PIN], { cwd, encoding: 'utf8' });
858
+ if (pin.status === 0) {
859
+ writeFileSync(paths.abs(DOC_SYNC_SUPERSESSION_STATUS_SUFFIX), 'PASS\n');
860
+ return { status: 'PASS', statusFile, exitCode: 0 };
861
+ }
862
+ writeFileSync(paths.abs(DOC_SYNC_SUPERSESSION_STATUS_SUFFIX), `supersession-pin-failed: ${SUPERSESSION_PIN}\n`);
863
+ const detail = `${pin.stdout ?? ''}${pin.stderr ?? ''}`.trim();
864
+ process.stderr.write(
865
+ `doc-supersession: ${SUPERSESSION_PIN} failed (exit ${String(pin.status ?? 1)}) — the doc-sync step introduced or kept superseded index content:\n${detail.slice(-2000)}\n`,
866
+ );
867
+ return { status: 'supersession-pin-failed', statusFile, exitCode: 0 };
868
+ }
869
+
671
870
  export const WRAPUP_STEPS_USAGE =
672
- 'usage: wrapup-steps.ts <resolve|route-reason|metrics|feature-transition> (env: __runId, tasks, mode, feature, featureGateCmd, spurBin)';
871
+ 'usage: wrapup-steps.ts <resolve|route-reason|metrics|feature-transition|doc-sync-snapshot|doc-sync-scope|doc-supersession> (env: __runId, tasks, mode, feature, featureGateCmd, spurBin)';
673
872
 
674
873
  export function main(argv: string[], env: WrapupStepsEnv = getEnvVars(), options: WrapupStepsOptions = {}): number {
675
874
  const sub = argv[0];
@@ -680,6 +879,9 @@ export function main(argv: string[], env: WrapupStepsEnv = getEnvVars(), options
680
879
  return 0;
681
880
  }
682
881
  if (sub === 'feature-transition') return runFeatureTransition(env, options).exitCode;
882
+ if (sub === 'doc-sync-snapshot') return runDocSyncSnapshot(env, options).exitCode;
883
+ if (sub === 'doc-sync-scope') return runDocSyncScope(env, options).exitCode;
884
+ if (sub === 'doc-supersession') return runDocSupersessionCheck(env, options).exitCode;
683
885
  process.stderr.write(`${WRAPUP_STEPS_USAGE}\n`);
684
886
  return 2;
685
887
  }