@gobing-ai/spur 0.4.8 → 0.4.10

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 (211) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/pipeline-budgets.json +2 -2
  3. package/config/rules/README.md +1 -1
  4. package/config/rules/boundary/config-loading-ownership.yaml +1 -0
  5. package/config/rules/boundary/env-var-hygiene.yaml +0 -1
  6. package/config/rules/boundary/sp-plugin-standalone.yaml +1 -1
  7. package/config/rules/boundary/sp-script-placement.yaml +3 -3
  8. package/config/rules/docs/sourced-api-claims.yaml +29 -64
  9. package/config/rules/strict/runtime-boundaries.yaml +40 -1
  10. package/config/rules/structure/protected-files.yaml +1 -1
  11. package/config/rules/surface/check-cli-surface.yaml +33 -0
  12. package/config/rules/typescript/no-debugger.yaml +3 -0
  13. package/config/workflow-candidates.json +2 -2
  14. package/config/workflows/feature-verification.yaml +6 -19
  15. package/config/workflows/history-anatomy.yaml +14 -28
  16. package/config/workflows/idea-pipeline.yaml +6 -19
  17. package/config/workflows/pr-review.yaml +9 -16
  18. package/config/workflows/task-pipeline.yaml +18 -25
  19. package/config/workflows/wrapup-pipeline.yaml +21 -63
  20. package/package.json +3 -3
  21. package/plugins/sp/README.md +70 -37
  22. package/plugins/sp/agents/expert-spur.md +0 -1
  23. package/plugins/sp/agents/super-planner.md +5 -5
  24. package/plugins/sp/agents/super-reviewer.md +2 -2
  25. package/plugins/sp/commands/dev-changelog.md +1 -1
  26. package/plugins/sp/commands/dev-daily.md +1 -1
  27. package/plugins/sp/commands/dev-pr-review.md +1 -1
  28. package/plugins/sp/commands/dev-review-session.md +1 -1
  29. package/plugins/sp/commands/dev-runall.md +4 -4
  30. package/plugins/sp/commands/dev-verify.md +4 -6
  31. package/plugins/sp/commands/dev-verifyall.md +4 -5
  32. package/plugins/sp/hooks/hooks.json +11 -1
  33. package/plugins/sp/lib/context-post-tool.ts +1 -1
  34. package/plugins/sp/lib/context-session-start.ts +1 -1
  35. package/plugins/sp/lib/context-session-stop.ts +1 -1
  36. package/plugins/sp/lib/destructive-policy.ts +65 -1
  37. package/plugins/sp/plugin.json +2 -5
  38. package/plugins/sp/{hooks → scripts/hooks}/agent-lifecycle.ts +1 -1
  39. package/plugins/sp/scripts/hooks/careful-guard.ts +94 -0
  40. package/plugins/sp/{hooks → scripts/hooks}/context-post-tool.ts +2 -2
  41. package/plugins/sp/{hooks → scripts/hooks}/context-session-start.ts +1 -1
  42. package/plugins/sp/{hooks → scripts/hooks}/context-session-stop.ts +1 -1
  43. package/plugins/sp/{hooks → scripts/hooks}/fleet-guest-stop.ts +6 -4
  44. package/plugins/sp/{hooks → scripts/hooks}/task-write-guard.ts +38 -21
  45. package/plugins/sp/skills/brainstorm/SKILL.md +12 -11
  46. package/plugins/sp/skills/brainstorm/agents/openai.yaml +1 -1
  47. package/plugins/sp/skills/brainstorm/examples/ideation-example.md +0 -4
  48. package/plugins/sp/skills/brainstorm/metadata.openclaw +1 -1
  49. package/plugins/sp/skills/brainstorm/references/workflows.md +4 -14
  50. package/plugins/sp/skills/code-implementation/SKILL.md +1 -1
  51. package/plugins/sp/skills/code-verification/SKILL.md +8 -8
  52. package/plugins/sp/skills/code-verification/references/secu-review.md +4 -4
  53. package/plugins/sp/skills/code-verification/references/verdict-schema.md +9 -1
  54. package/plugins/sp/skills/daily-summary/SKILL.md +4 -5
  55. package/plugins/sp/skills/dogfood-testing/SKILL.md +9 -9
  56. package/plugins/sp/skills/dogfood-testing/references/report-template.md +1 -1
  57. package/plugins/sp/skills/history-anatomy/references/modes.md +2 -2
  58. package/plugins/sp/skills/history-anatomy/references/operations.md +3 -4
  59. package/plugins/sp/skills/history-anatomy/references/report-contract.md +2 -3
  60. package/plugins/sp/skills/issue-finding/SKILL.md +1 -1
  61. package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
  62. package/plugins/sp/skills/parallel-execution/SKILL.md +2 -2
  63. package/plugins/sp/skills/parallel-execution/references/result-synthesis.md +9 -10
  64. package/plugins/sp/skills/pr-reviewing/SKILL.md +14 -15
  65. package/plugins/sp/skills/reverse-engineering/SKILL.md +4 -6
  66. package/plugins/sp/skills/session-review/SKILL.md +13 -29
  67. package/plugins/sp/skills/source-driven-development/SKILL.md +26 -27
  68. package/plugins/sp/skills/spur-check/SKILL.md +14 -13
  69. package/plugins/sp/skills/spur-cli/SKILL.md +1 -1
  70. package/plugins/sp/skills/spur-cli/references/agent.md +17 -2
  71. package/plugins/sp/skills/spur-cli/references/builder.md +27 -6
  72. package/plugins/sp/skills/spur-cli/references/features.md +6 -1
  73. package/plugins/sp/skills/spur-cli/references/history.md +3 -0
  74. package/plugins/sp/skills/spur-cli/references/self.md +5 -3
  75. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +3 -0
  76. package/plugins/sp/skills/spur-cli/references/tasks.md +13 -3
  77. package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +1 -1
  78. package/plugins/sp/skills/spur-cli/references/workflows.md +36 -1
  79. package/plugins/sp/skills/spur-dev/SKILL.md +1 -1
  80. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +1 -1
  81. package/plugins/sp/skills/spur-dev/references/dev-operations.md +12 -13
  82. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +10 -11
  83. package/plugins/sp/skills/spur-dev/references/execution-batch.md +16 -17
  84. package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +1 -1
  85. package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +9 -12
  86. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +6 -8
  87. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +1 -1
  88. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +33 -40
  89. package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +24 -24
  90. package/plugins/sp/skills/spur-doctor/SKILL.md +5 -4
  91. package/spur.js +12726 -1456
  92. package/web/_astro/{BoardApp.CaNvQBD_.js → BoardApp.B5rTVNd_.js} +1 -1
  93. package/web/_astro/{BoardApp.C5jnkkKP.js → BoardApp.BMprmPRc.js} +141 -141
  94. package/web/_astro/{TaskDetail.DGKndhI8.js → TaskDetail.Ddsur8qd.js} +1 -1
  95. package/web/_astro/{arc.DHrwdg3G.js → arc.DLqVVBxP.js} +1 -1
  96. package/web/_astro/{architectureDiagram-3BPJPVTR.pvKpK1s5.js → architectureDiagram-3BPJPVTR.C-Pk3uAi.js} +1 -1
  97. package/web/_astro/{blockDiagram-GPEHLZMM.BQmRcZsS.js → blockDiagram-GPEHLZMM.BRO1i_Nm.js} +1 -1
  98. package/web/_astro/{c4Diagram-AAUBKEIU.CCLjHeH1.js → c4Diagram-AAUBKEIU.BU_cWu6e.js} +1 -1
  99. package/web/_astro/channel.Bs3Illhy.js +1 -0
  100. package/web/_astro/{chunk-2J33WTMH.DL6Yuoma.js → chunk-2J33WTMH.D2VXiQ2t.js} +1 -1
  101. package/web/_astro/{chunk-4BX2VUAB.CzlyEI7l.js → chunk-4BX2VUAB.BcHT772J.js} +1 -1
  102. package/web/_astro/{chunk-55IACEB6.B60EWxp1.js → chunk-55IACEB6.DhDtICmo.js} +1 -1
  103. package/web/_astro/{chunk-727SXJPM.c7v_fRLo.js → chunk-727SXJPM.BoKHp7gV.js} +1 -1
  104. package/web/_astro/{chunk-AQP2D5EJ.8BxLWdDc.js → chunk-AQP2D5EJ.Chu4JRpE.js} +1 -1
  105. package/web/_astro/{chunk-FMBD7UC4.DmKuxGFT.js → chunk-FMBD7UC4.wHdwKPlq.js} +1 -1
  106. package/web/_astro/{chunk-ND2GUHAM.1Zwd4mqV.js → chunk-ND2GUHAM.ffN-M1JB.js} +1 -1
  107. package/web/_astro/{chunk-QZHKN3VN.BvYLu-A1.js → chunk-QZHKN3VN.FRubwg4S.js} +1 -1
  108. package/web/_astro/{classDiagram-4FO5ZUOK.CEqRt0WU.js → classDiagram-4FO5ZUOK.c22tQsc_.js} +1 -1
  109. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CEqRt0WU.js → classDiagram-v2-Q7XG4LA2.c22tQsc_.js} +1 -1
  110. package/web/_astro/{cose-bilkent-S5V4N54A.C7JiUdj2.js → cose-bilkent-S5V4N54A.B9c6fiSj.js} +1 -1
  111. package/web/_astro/{cynefin-OW5HDTMX.B6mZ-1be.js → cynefin-OW5HDTMX.DrZsrUHl.js} +1 -1
  112. package/web/_astro/{dagre-BM42HDAG.BuryfDy6.js → dagre-BM42HDAG.DG1U4b3y.js} +1 -1
  113. package/web/_astro/{diagram-2AECGRRQ.dao39YQM.js → diagram-2AECGRRQ.CH15b-K3.js} +1 -1
  114. package/web/_astro/{diagram-5GNKFQAL.lnGZe4At.js → diagram-5GNKFQAL.BH1j7twR.js} +1 -1
  115. package/web/_astro/{diagram-KO2AKTUF.uWfhzEfT.js → diagram-KO2AKTUF.q2zCkbAj.js} +1 -1
  116. package/web/_astro/{diagram-LMA3HP47.ppq8-198.js → diagram-LMA3HP47.BLaaZnHV.js} +1 -1
  117. package/web/_astro/{diagram-OG6HWLK6.CKSCqDt5.js → diagram-OG6HWLK6.BcpRdQwI.js} +1 -1
  118. package/web/_astro/{erDiagram-TEJ5UH35.sFSf2yBN.js → erDiagram-TEJ5UH35.D7KBlqfM.js} +1 -1
  119. package/web/_astro/{flowDiagram-I6XJVG4X.BJQjKLbz.js → flowDiagram-I6XJVG4X.D_gmuRqg.js} +1 -1
  120. package/web/_astro/{ganttDiagram-6RSMTGT7.DAHKNj2N.js → ganttDiagram-6RSMTGT7.DzgWYniD.js} +1 -1
  121. package/web/_astro/{gitGraphDiagram-PVQCEYII.BlmzcW_O.js → gitGraphDiagram-PVQCEYII.576i3hm3.js} +1 -1
  122. package/web/_astro/index.DiC1a6lh.css +1 -0
  123. package/web/_astro/{infoDiagram-5YYISTIA.DGhjaqsi.js → infoDiagram-5YYISTIA.DMCclBiz.js} +1 -1
  124. package/web/_astro/{ishikawaDiagram-YF4QCWOH.gKIpgt6F.js → ishikawaDiagram-YF4QCWOH.9Kyca4SH.js} +1 -1
  125. package/web/_astro/{journeyDiagram-JHISSGLW.DG3tmP0R.js → journeyDiagram-JHISSGLW.chbdG-8q.js} +1 -1
  126. package/web/_astro/{kanban-definition-UN3LZRKU.CgHDSxkR.js → kanban-definition-UN3LZRKU.Bds54-e0.js} +1 -1
  127. package/web/_astro/{linear.BuGEpcuM.js → linear.D5ND9GN_.js} +1 -1
  128. package/web/_astro/{mermaid.core.DBVqkWBp.js → mermaid.core.D9N_PGbj.js} +4 -4
  129. package/web/_astro/{mindmap-definition-RKZ34NQL.D-kOcji9.js → mindmap-definition-RKZ34NQL.t0xop5vp.js} +1 -1
  130. package/web/_astro/{pieDiagram-4H26LBE5.BvGPwtZt.js → pieDiagram-4H26LBE5.DqgKh6hM.js} +1 -1
  131. package/web/_astro/{quadrantDiagram-W4KKPZXB.BKb_9FV6.js → quadrantDiagram-W4KKPZXB.DeuHVi2t.js} +1 -1
  132. package/web/_astro/{requirementDiagram-4Y6WPE33.Cx0fvD8v.js → requirementDiagram-4Y6WPE33.BdYFSG4W.js} +1 -1
  133. package/web/_astro/{sankeyDiagram-5OEKKPKP.fatULi0W.js → sankeyDiagram-5OEKKPKP.k1LPPETJ.js} +1 -1
  134. package/web/_astro/{sequenceDiagram-3UESZ5HK.ChHyKqco.js → sequenceDiagram-3UESZ5HK.DdyfpzoG.js} +1 -1
  135. package/web/_astro/{stateDiagram-AJRCARHV.BdUpKWyj.js → stateDiagram-AJRCARHV.DSEI8il0.js} +1 -1
  136. package/web/_astro/{stateDiagram-v2-BHNVJYJU.fmJxqIm5.js → stateDiagram-v2-BHNVJYJU.CoMnQRKo.js} +1 -1
  137. package/web/_astro/{timeline-definition-PNZ67QCA.BayDn5-V.js → timeline-definition-PNZ67QCA.DM1AHNm-.js} +1 -1
  138. package/web/_astro/{vennDiagram-CIIHVFJN.Bm2Aggwa.js → vennDiagram-CIIHVFJN.C-cJmVtl.js} +1 -1
  139. package/web/_astro/{wardleyDiagram-YWT4CUSO.DI75O-uG.js → wardleyDiagram-YWT4CUSO.D0xMzKPb.js} +1 -1
  140. package/web/_astro/{xychartDiagram-2RQKCTM6.DL90E7Q7.js → xychartDiagram-2RQKCTM6.BH5xJcEq.js} +1 -1
  141. package/web/index.html +2 -2
  142. package/config/plugin-scripts.json +0 -116
  143. package/config/script-placement-baseline.json +0 -33
  144. package/plugins/sp/hooks/careful-guard.ts +0 -74
  145. package/plugins/sp/hooks/pi/guard-extension.ts +0 -304
  146. package/plugins/sp/lib/dogfood-pipeline.ts +0 -265
  147. package/plugins/sp/lib/dogfood-report.ts +0 -278
  148. package/plugins/sp/lib/history-anatomy.generated.d.mts +0 -112
  149. package/plugins/sp/lib/history-anatomy.generated.mjs +0 -691
  150. package/plugins/sp/lib/idea-handoff.generated.d.mts +0 -17
  151. package/plugins/sp/lib/idea-handoff.generated.mjs +0 -1446
  152. package/plugins/sp/lib/inline-run-app.ts +0 -68
  153. package/plugins/sp/lib/inline-run.generated.d.mts +0 -37
  154. package/plugins/sp/lib/inline-run.generated.mjs +0 -3103
  155. package/plugins/sp/lib/plan-projection.generated.d.mts +0 -197
  156. package/plugins/sp/lib/plan-projection.generated.mjs +0 -982
  157. package/plugins/sp/lib/quality-gate.generated.d.mts +0 -139
  158. package/plugins/sp/lib/quality-gate.generated.mjs +0 -30510
  159. package/plugins/sp/lib/residual-scan.generated.d.mts +0 -93
  160. package/plugins/sp/lib/residual-scan.generated.mjs +0 -383
  161. package/plugins/sp/lib/run-summary-core.ts +0 -225
  162. package/plugins/sp/lib/spur-bin.ts +0 -36
  163. package/plugins/sp/lib/step-profile.generated.d.mts +0 -71
  164. package/plugins/sp/lib/step-profile.generated.mjs +0 -174
  165. package/plugins/sp/lib/transcript.ts +0 -112
  166. package/plugins/sp/scripts/batch-plan.mjs +0 -1096
  167. package/plugins/sp/scripts/batch-plan.ts +0 -145
  168. package/plugins/sp/scripts/batch-preflight.mjs +0 -423
  169. package/plugins/sp/scripts/batch-preflight.ts +0 -270
  170. package/plugins/sp/scripts/commit-guard.mjs +0 -226
  171. package/plugins/sp/scripts/commit-guard.ts +0 -248
  172. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -626
  173. package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -855
  174. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -211
  175. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -110
  176. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -282
  177. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -98
  178. package/plugins/sp/scripts/feature-verification-steps.mjs +0 -82
  179. package/plugins/sp/scripts/feature-verification-steps.ts +0 -164
  180. package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -931
  181. package/plugins/sp/scripts/history-anatomy-cache.ts +0 -247
  182. package/plugins/sp/scripts/idea-handoff.mjs +0 -32
  183. package/plugins/sp/scripts/idea-handoff.ts +0 -47
  184. package/plugins/sp/scripts/inline-run-dispatch.mjs +0 -131
  185. package/plugins/sp/scripts/inline-run-dispatch.ts +0 -115
  186. package/plugins/sp/scripts/inline-run-setup.mjs +0 -746
  187. package/plugins/sp/scripts/inline-run-setup.ts +0 -198
  188. package/plugins/sp/scripts/persist-out-check.mjs +0 -217
  189. package/plugins/sp/scripts/persist-out-check.ts +0 -249
  190. package/plugins/sp/scripts/pr-reviewing.mjs +0 -712
  191. package/plugins/sp/scripts/pr-reviewing.ts +0 -952
  192. package/plugins/sp/scripts/quality-gate.mjs +0 -30491
  193. package/plugins/sp/scripts/quality-gate.ts +0 -80
  194. package/plugins/sp/scripts/residual-scan.mjs +0 -634
  195. package/plugins/sp/scripts/residual-scan.ts +0 -248
  196. package/plugins/sp/scripts/run-summary.mjs +0 -758
  197. package/plugins/sp/scripts/run-summary.ts +0 -107
  198. package/plugins/sp/scripts/script-root.mjs +0 -129
  199. package/plugins/sp/scripts/script-root.ts +0 -199
  200. package/plugins/sp/scripts/session-timeline.mjs +0 -359
  201. package/plugins/sp/scripts/session-timeline.ts +0 -190
  202. package/plugins/sp/scripts/task-diffstat.mjs +0 -181
  203. package/plugins/sp/scripts/task-diffstat.ts +0 -219
  204. package/plugins/sp/scripts/workflow-step-profile.mjs +0 -338
  205. package/plugins/sp/scripts/workflow-step-profile.ts +0 -165
  206. package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
  207. package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -261
  208. package/plugins/sp/scripts/wrapup-steps.mjs +0 -627
  209. package/plugins/sp/scripts/wrapup-steps.ts +0 -891
  210. package/web/_astro/channel.C7wG9RxA.js +0 -1
  211. package/web/_astro/index.BCY1JJHd.css +0 -1
@@ -1,891 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * wrapup-steps — deterministic wrap-up capture, metrics and feature sync behind the
4
- * wrapup-pipeline wrappers (task 0824, feature I21, governance §1.2 composition budgets).
5
- *
6
- * Reproduces the former wrapup-pipeline `task-resolve:onEnter:0`, `task-resolve:onEnter:1`
7
- * (route-reason writer, moved here in 0944), `metrics-record:onEnter:0` and
8
- * `feature-transition:onEnter:0` shell programs one-for-one so the workflow stays inside
9
- * the shell-program caps while writing the same `.spur/run` artifacts:
10
- * - `<runId>-wrapup-tasks.json` normalized, deduplicated WBS capture (resolve)
11
- * - `<runId>-wrapup-resolve.status` `PASS`/`FAIL` (resolve)
12
- * - `<runId>-wrapup-preflight.json` full feature-check JSON (resolve pre-flight, when `feature` is set)
13
- * - `<runId>-route-reason.txt` route reason (route-reason subcommand)
14
- * - `.spur/memory/wrapup-metrics.jsonl` one row per task (metrics)
15
- * - `<runId>-wrapup-metrics.status` `PASS`/`FAIL` (metrics)
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)
20
- *
21
- * Environment comes from the workflow vars: `__runId`, `tasks`, `feature`, `featureGateCmd`
22
- * and `spurBin` (split on whitespace into a command plus prefix args, so
23
- * `bun apps/cli/src/index.ts` works).
24
- *
25
- * Truthfulness contract (0770 + 0783): wrap-up never mutates task status; a lookup failure is
26
- * recorded as FAIL, never silently omitted as success; metrics rows are serialized as JSON
27
- * (never interpolated printf); and the required sync succeeds only for a valid matching
28
- * unblocked proposal whose target status is freshly observed — a gate PASS can never convert
29
- * a failed sync into success. The process always exits 0 after resolve/metrics/
30
- * feature-transition; the verdict lives in the status file (an empty `feature` is a
31
- * mis-invocation and exits 1).
32
- *
33
- * Node-builtin imports only; pure helpers are exported for unit testing (ADR-065).
34
- */
35
-
36
- import { spawnSync } from 'node:child_process';
37
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
38
- import { join } from 'node:path';
39
- import { getEnvVars } from '../lib/env';
40
- import { spurCommand } from '../lib/spur-bin';
41
-
42
- export interface WrapupStepsEnv {
43
- __runId?: string;
44
- tasks?: string;
45
- feature?: string;
46
- featureGateCmd?: string;
47
- spurBin?: string;
48
- [key: string]: string | undefined;
49
- }
50
-
51
- export interface WrapupStepsOptions {
52
- /** Base directory for `.spur/run` / `.spur/memory`; defaults to the process cwd. */
53
- cwd?: string;
54
- }
55
-
56
- /** jq `//` chain: first value that is neither null, undefined nor false; otherwise the fallback. */
57
- export function jqPick(...values: unknown[]): unknown {
58
- for (const value of values) {
59
- if (value !== null && value !== undefined && value !== false) return value;
60
- }
61
- return values[values.length - 1];
62
- }
63
-
64
- /** `jq -r` text rendering of a JSON value (strings raw, everything else compact JSON). */
65
- function jqText(value: unknown): string {
66
- return typeof value === 'string' ? value : JSON.stringify(value);
67
- }
68
-
69
- /** Canonical four-digit WBS string: whitespace is rejected, not trimmed (0783 R1). */
70
- export const WBS_PATTERN = /^[0-9]{4}$/;
71
-
72
- function spur(
73
- env: WrapupStepsEnv,
74
- args: string[],
75
- options: { cwd?: string; stderr?: 'inherit' } = {},
76
- ): { status: number; stdout: string } {
77
- const { cmd, prefix } = spurCommand(env.spurBin);
78
- const result = spawnSync(cmd, [...prefix, ...args], {
79
- cwd: options.cwd,
80
- encoding: 'utf8',
81
- ...(options.stderr === 'inherit' ? { stdio: ['ignore', 'pipe', 'inherit'] as const } : {}),
82
- });
83
- if (result.error !== undefined) return { status: result.status ?? 1, stdout: '' };
84
- return { status: result.status ?? 1, stdout: result.stdout ?? '' };
85
- }
86
-
87
- /** jq status-chain semantics for `task show` output; null/missing status means the lookup failed. */
88
- export function taskStatusOf(taskJson: string): { resolved: unknown; present: boolean } {
89
- let parsed: unknown;
90
- try {
91
- parsed = JSON.parse(taskJson);
92
- } catch {
93
- return { resolved: null, present: false };
94
- }
95
- if (parsed === null || typeof parsed !== 'object') return { resolved: null, present: false };
96
- const frontmatter = (parsed as Record<string, unknown>).frontmatter;
97
- const fmStatus =
98
- frontmatter !== null && typeof frontmatter === 'object'
99
- ? (frontmatter as Record<string, unknown>).status
100
- : undefined;
101
- const status = (parsed as Record<string, unknown>).status;
102
- // jq: `.frontmatter.status // .status` is null only when frontmatter.status is null/absent
103
- // AND .status is null; a trailing false is kept (false != null in jq).
104
- if (fmStatus === null || fmStatus === undefined) {
105
- return { resolved: status ?? null, present: status !== undefined && status !== null };
106
- }
107
- return { resolved: fmStatus, present: true };
108
- }
109
-
110
- /** One truthy status display: `unresolved` when the lookup left nothing to show. */
111
- function statusDisplay(taskJson: string): string {
112
- const { resolved } = taskStatusOf(taskJson);
113
- if (resolved === null || resolved === undefined || resolved === false) return 'unresolved';
114
- return jqText(resolved);
115
- }
116
-
117
- export interface ResolveResult {
118
- status: 'PASS' | 'FAIL';
119
- statusFile: string;
120
- tasksFile: string;
121
- /** Exit code the workflow wrapper observes (only an empty __runId is a hard failure). */
122
- exitCode: number;
123
- }
124
-
125
- /** `resolve` — parse and validate vars.tasks exactly once, then resolve every member. */
126
- export function resolveTasks(env: WrapupStepsEnv, options: WrapupStepsOptions = {}): ResolveResult {
127
- const cwd = options.cwd;
128
- const runId = env.__runId ?? '';
129
- if (runId.length === 0) {
130
- process.stderr.write('task-resolve: __runId is empty — refusing the legacy fixed-path fallback\n');
131
- return { status: 'FAIL', statusFile: '', tasksFile: '', exitCode: 1 };
132
- }
133
- mkdirSync(cwd ? join(cwd, '.spur', 'run') : join('.spur', 'run'), { recursive: true });
134
- const relTasksFile = join('.spur', 'run', `${runId}-wrapup-tasks.json`);
135
- const relReasonFile = join('.spur', 'run', `${runId}-route-reason.txt`);
136
- const relStatusFile = join('.spur', 'run', `${runId}-wrapup-resolve.status`);
137
- const abs = (p: string): string => (cwd ? join(cwd, p) : p);
138
-
139
- const writeFail = (reason: string): ResolveResult => {
140
- writeFileSync(abs(relReasonFile), reason);
141
- writeFileSync(abs(relStatusFile), 'FAIL\n');
142
- return { status: 'FAIL', statusFile: relStatusFile, tasksFile: relTasksFile, exitCode: 0 };
143
- };
144
-
145
- let parsedTasks: unknown;
146
- try {
147
- parsedTasks = JSON.parse(env.tasks ?? '');
148
- } catch {
149
- parsedTasks = undefined;
150
- }
151
- const validArray =
152
- Array.isArray(parsedTasks) && parsedTasks.every((w) => typeof w === 'string' && WBS_PATTERN.test(w));
153
- if (!validArray) {
154
- process.stderr.write(
155
- 'task-resolve: tasks must be a JSON array of canonical four-digit WBS strings (whitespace is rejected, not trimmed)\n',
156
- );
157
- return writeFail('failed:tasks is not a JSON array of canonical four-digit WBS strings');
158
- }
159
-
160
- // Dedupe in first-seen order (never sorted).
161
- const deduped: string[] = [];
162
- for (const wbs of parsedTasks as string[]) {
163
- if (!deduped.includes(wbs)) deduped.push(wbs);
164
- }
165
- writeFileSync(abs(relTasksFile), `${JSON.stringify(deduped)}\n`);
166
-
167
- let unresolved = false;
168
- for (const wbs of deduped) {
169
- const shown = spur(env, ['task', 'show', wbs, '--json'], { cwd });
170
- const status = shown.status === 0 ? statusDisplay(shown.stdout) : 'unresolved';
171
- if (status !== 'done' && status !== 'cancelled') {
172
- process.stderr.write(
173
- `task-resolve: task ${wbs} did not resolve to a completed status (status=${status})\n`,
174
- );
175
- unresolved = true;
176
- }
177
- }
178
- if (unresolved) {
179
- return writeFail(`failed:unresolved or non-completed task (see ${relTasksFile})`);
180
- }
181
- if ((env.feature ?? '') !== '') {
182
- const pf = preflightFeature(env, cwd);
183
- if (pf) return writeFail(pf);
184
- }
185
- writeFileSync(abs(relStatusFile), 'PASS\n');
186
- return { status: 'PASS', statusFile: relStatusFile, tasksFile: relTasksFile, exitCode: 0 };
187
- }
188
-
189
- interface PreflightFinding {
190
- severity: string;
191
- code: string;
192
- }
193
-
194
- function isFindingList(value: unknown): value is PreflightFinding[] {
195
- return (
196
- Array.isArray(value) &&
197
- value.every(
198
- (f) =>
199
- f !== null &&
200
- typeof f === 'object' &&
201
- typeof f.severity === 'string' &&
202
- typeof f.code === 'string' &&
203
- f.code.length > 0,
204
- )
205
- );
206
- }
207
-
208
- interface PreflightSyncPayload {
209
- proposal?: {
210
- gateBlocked?: boolean;
211
- from?: string;
212
- to?: string;
213
- hops?: string[];
214
- gateFindings?: PreflightFinding[];
215
- };
216
- }
217
-
218
- function sortedUniqueCodes(findings: PreflightFinding[]): string[] {
219
- const codes = findings
220
- .filter((f) => f.severity === 'error')
221
- .map((f) => String(f.code ?? ''))
222
- .filter((c) => c.length > 0);
223
- return [...new Set(codes)].sort();
224
- }
225
-
226
- /**
227
- * 1033 R2 feature pre-flight for wrapup resolve — runs after the task list resolves and
228
- * before any corpus-mutating wrapup state. Returns a `failed:preflight:*` reason on the
229
- * first blocking condition, or null when wrapup may proceed. Never writes PASS, never
230
- * relaxes the later feature-transition / feature-verify gates, and uses the same `spurBin`
231
- * env as `runFeatureTransition`.
232
- */
233
- export function preflightFeature(env: WrapupStepsEnv, cwd?: string): string | null {
234
- const feature = env.feature ?? '';
235
- const abs = (p: string): string => (cwd ? join(cwd, p) : p);
236
-
237
- const syncOut = spur(env, ['feature', 'sync', feature, '--dry-run', '--json'], { cwd });
238
- let sync: PreflightSyncPayload | undefined;
239
- if (syncOut.status === 0) {
240
- try {
241
- const parsed: unknown = JSON.parse(syncOut.stdout);
242
- if (parsed !== null && typeof parsed === 'object') sync = parsed as PreflightSyncPayload;
243
- } catch {
244
- // fall through to the unreadable branch below
245
- }
246
- }
247
- if (sync === undefined) return 'failed:preflight:sync-unreadable';
248
- const proposal = sync.proposal;
249
- if (
250
- proposal === null ||
251
- typeof proposal !== 'object' ||
252
- Array.isArray(proposal) ||
253
- (proposal.gateBlocked !== undefined && typeof proposal.gateBlocked !== 'boolean') ||
254
- (proposal.gateFindings !== undefined && !isFindingList(proposal.gateFindings)) ||
255
- (proposal.hops !== undefined &&
256
- (!Array.isArray(proposal.hops) || !proposal.hops.every((hop) => typeof hop === 'string'))) ||
257
- (proposal.gateBlocked !== true && (typeof proposal.from !== 'string' || typeof proposal.to !== 'string'))
258
- ) {
259
- return 'failed:preflight:sync-unreadable';
260
- }
261
-
262
- if (proposal.gateBlocked === true) {
263
- return `failed:preflight:gate-blocked ${sortedUniqueCodes(proposal.gateFindings ?? []).join(',')}`;
264
- }
265
-
266
- const reachesDone = proposal.to === 'done' || (proposal.hops ?? []).includes('done');
267
- if (!reachesDone) return null;
268
-
269
- const checkOut = spur(env, ['feature', 'check', feature, '--strict', '--as', 'done', '--json'], { cwd });
270
- let check: unknown;
271
- try {
272
- check = JSON.parse(checkOut.stdout);
273
- } catch {
274
- return 'failed:preflight:check-unreadable';
275
- }
276
- const payload = Array.isArray(check) && check.length === 1 ? check[0] : check;
277
- const findings =
278
- payload !== null && typeof payload === 'object' ? (payload as { findings?: unknown }).findings : undefined;
279
- if (
280
- !isFindingList(findings) ||
281
- (checkOut.status !== 0 && (checkOut.status !== 1 || !findings.some((f) => f.severity === 'error')))
282
- ) {
283
- return 'failed:preflight:check-unreadable';
284
- }
285
- // The verifying onEnter will produce the receipt, so receipt-only errors on a feature
286
- // that is not yet verifying would fail every wrapup forever.
287
- const eligible =
288
- proposal.from === 'verifying'
289
- ? findings
290
- : findings.filter((f) => !String(f.code ?? '').startsWith('L4.feature-receipt-'));
291
-
292
- const runId = env.__runId ?? '';
293
- if (runId.length > 0) {
294
- mkdirSync(abs(join('.spur', 'run')), { recursive: true });
295
- writeFileSync(abs(join('.spur', 'run', `${runId}-wrapup-preflight.json`)), `${JSON.stringify(check)}\n`);
296
- }
297
- const codes = sortedUniqueCodes(eligible);
298
- return codes.length > 0 ? `failed:preflight:done-gate ${codes.join(',')}` : null;
299
- }
300
-
301
- /**
302
- * 0944 R3 route-reason map — mirrors the former inline jq object one-for-one, plus the
303
- * `safety` entry (operator-forced doc-sync). `fast:drift-probe-clean` is NOT in the map:
304
- * it is only claimable when the drift probe itself classified the wrapup clean.
305
- */
306
- const ROUTE_REASON_TABLE: Record<string, string> = {
307
- fast: 'fast:evidence complete+consistent',
308
- '': 'safety:missing evidence (mode empty)',
309
- unknown: 'safety:unknown evidence quality',
310
- conflict: 'safety:conflicting evidence',
311
- safety: 'safety:operator-forced doc-sync',
312
- };
313
-
314
- export interface RouteReasonResult {
315
- reason: string;
316
- reasonFile: string;
317
- exitCode: number;
318
- }
319
-
320
- /**
321
- * `route-reason` — derive the task-resolve route reason from the validated capture, the
322
- * projected mode and the drift probe verdict (0944). A resolve FAIL keeps its own reason
323
- * (nothing is written, exit 0). A missing or corrupted capture never yields a `skipped`
324
- * or `fast:drift-probe-clean` claim. The log line is run-attributed (0770).
325
- */
326
- export function writeRouteReason(env: WrapupStepsEnv, options: WrapupStepsOptions = {}): RouteReasonResult {
327
- const cwd = options.cwd;
328
- const runId = env.__runId ?? '';
329
- if (runId.length === 0) {
330
- process.stderr.write('task-resolve: __runId is empty — refusing to write a route reason\n');
331
- return { reason: '', reasonFile: '', exitCode: 1 };
332
- }
333
- mkdirSync(cwd ? join(cwd, '.spur', 'run') : join('.spur', 'run'), { recursive: true });
334
- mkdirSync(cwd ? join(cwd, '.spur', 'memory') : join('.spur', 'memory'), { recursive: true });
335
- const relReasonFile = join('.spur', 'run', `${runId}-route-reason.txt`);
336
- const abs = (p: string): string => (cwd ? join(cwd, p) : p);
337
-
338
- const statusFile = join('.spur', 'run', `${runId}-wrapup-resolve.status`);
339
- if (readFileSyncSafe(abs(statusFile))?.trim() === 'FAIL') {
340
- // The failed reason stands; the failed edge already owns the run.
341
- return { reason: '', reasonFile: relReasonFile, exitCode: 0 };
342
- }
343
-
344
- // A missing/corrupted capture stays at -1: never 0, so no skipped claim can be invented.
345
- let taskCount = -1;
346
- try {
347
- const parsed: unknown = JSON.parse(
348
- readFileSync(abs(join('.spur', 'run', `${runId}-wrapup-tasks.json`)), 'utf8'),
349
- );
350
- if (Array.isArray(parsed)) taskCount = parsed.length;
351
- } catch {
352
- // treated as uncountable below
353
- }
354
- let probeClean = false;
355
- try {
356
- const probe: unknown = JSON.parse(readFileSync(abs(join('.spur', 'run', `${runId}-drift-probe.json`)), 'utf8'));
357
- probeClean = (probe as { clean?: unknown } | null)?.clean === true;
358
- } catch {
359
- // no probe verdict — the map decides
360
- }
361
-
362
- const mode = env.mode ?? '';
363
- let reason: string;
364
- if (taskCount === 0) {
365
- reason = 'skipped:empty task list';
366
- } else if (mode === 'fast' && probeClean) {
367
- reason = 'fast:drift-probe-clean';
368
- } else {
369
- reason = ROUTE_REASON_TABLE[mode] ?? `safety:unrecognized evidence (mode=${mode})`;
370
- }
371
- writeFileSync(abs(relReasonFile), `${reason}\n`);
372
- appendFileSync(abs(join('.spur', 'memory', 'wrapup-routes.log')), `${runId} ${reason}\n`);
373
- return { reason, reasonFile: relReasonFile, exitCode: 0 };
374
- }
375
-
376
- function readFileSyncSafe(path: string): string | null {
377
- try {
378
- return readFileSync(path, 'utf8');
379
- } catch {
380
- return null;
381
- }
382
- }
383
-
384
- /**
385
- * Verdict from a task record's tracked `Testing` section — the durable copy that outlives a
386
- * worktree teardown or clone (F93). Local copy of `parseVerdictLine` and of the `Testing` slice
387
- * in `extractTestingSection` (`packages/app/src/services/task-record.ts`) because ADR-065 keeps
388
- * plugin scripts on builtin and relative imports only; keep the three literals in step by hand
389
- * (the parity guard in `plugins/sp/tests/wrapup-steps.test.ts` reads the canonical literals).
390
- *
391
- * The slice rule is the canonical one verbatim — `#{1,6}` heading, end at the next
392
- * same-or-higher heading — so an `# Testing` or `#### Testing` heading, and an h4 subheading
393
- * inside a `## Testing` section, read the same here and in the app. One deliberate difference:
394
- * with no `Testing` heading at all this returns null instead of falling back to the whole
395
- * document, so a `Verdict:` token in another section cannot be misread as this task's verdict.
396
- */
397
- export function verdictFromTestingSection(content: string): string | null {
398
- const heading = /^#{1,6}\s+Testing\s*$/m.exec(content);
399
- if (!heading) return null;
400
- const level = heading[0].match(/^#+/)?.[0]?.length ?? 2;
401
- const rest = content.slice(heading.index + heading[0].length);
402
- const next = new RegExp(`^#{1,${level}}\\s+\\S`, 'm').exec(rest);
403
- const section = next ? rest.slice(0, next.index) : rest;
404
- for (const line of section.split('\n')) {
405
- // Line-anchored (optionally after `- ` bullet or `**` bold) so evidence text
406
- // containing a mid-line "Verdict:" token cannot be misread as the section verdict.
407
- const m = /^(?:-\s*|\*\*)?Verdict:\s*(PASS|PARTIAL|FAIL|UNKNOWN)\b/i.exec(line.trim());
408
- if (m?.[1] !== undefined) return m[1].toUpperCase();
409
- }
410
- return null;
411
- }
412
-
413
- /**
414
- * `verdict` field of a verdict artifact; null when the file is absent, unreadable, malformed or
415
- * carries no usable verdict (jq `//` semantics: null, undefined and false all count as missing).
416
- */
417
- function verdictOfArtifact(verdictPath: string): string | null {
418
- try {
419
- const raw = jqPick((JSON.parse(readFileSync(verdictPath, 'utf8')) as { verdict?: unknown }).verdict, '');
420
- const text = jqText(raw);
421
- return text.length > 0 ? text : null;
422
- } catch {
423
- return null;
424
- }
425
- }
426
-
427
- export interface MetricsResult {
428
- status: 'PASS' | 'FAIL';
429
- statusFile: string;
430
- }
431
-
432
- /** `metrics` — append one JSONL row per captured task; a missing row is never silently absorbed. */
433
- export function runMetrics(env: WrapupStepsEnv, options: WrapupStepsOptions = {}): MetricsResult {
434
- const cwd = options.cwd;
435
- const runId = env.__runId ?? '';
436
- mkdirSync(cwd ? join(cwd, '.spur', 'run') : join('.spur', 'run'), { recursive: true });
437
- mkdirSync(cwd ? join(cwd, '.spur', 'memory') : join('.spur', 'memory'), { recursive: true });
438
- const relStatusFile = join('.spur', 'run', `${runId}-wrapup-metrics.status`);
439
- const relTasksFile = join('.spur', 'run', `${runId}-wrapup-tasks.json`);
440
- const relMetricsFile = join('.spur', 'memory', 'wrapup-metrics.jsonl');
441
- const abs = (p: string): string => (cwd ? join(cwd, p) : p);
442
-
443
- const fail = (): MetricsResult => {
444
- writeFileSync(abs(relStatusFile), 'FAIL\n');
445
- return { status: 'FAIL', statusFile: relStatusFile };
446
- };
447
-
448
- let captured: unknown;
449
- try {
450
- captured = JSON.parse(readFileSync(abs(relTasksFile), 'utf8'));
451
- } catch {
452
- captured = undefined;
453
- }
454
- const validCapture = Array.isArray(captured) && captured.every((w) => typeof w === 'string' && WBS_PATTERN.test(w));
455
- if (!validCapture) {
456
- process.stderr.write(
457
- 'metrics-record: run-scoped task capture missing, corrupted or non-canonical — refusing to record metrics\n',
458
- );
459
- return fail();
460
- }
461
-
462
- let metricsRc = 0;
463
- for (const wbs of captured as string[]) {
464
- const shown = spur(env, ['task', 'show', wbs, '--json'], { cwd });
465
- const lookup = shown.status === 0 ? taskStatusOf(shown.stdout) : { resolved: null, present: false };
466
- if (!lookup.present) {
467
- process.stderr.write(
468
- `metrics-record: task ${wbs} lookup failed or was malformed — recording FAIL instead of silently omitting its metrics row\n`,
469
- );
470
- metricsRc = 1;
471
- continue;
472
- }
473
- const parsed = JSON.parse(shown.stdout) as Record<string, unknown>;
474
- const frontmatter =
475
- parsed.frontmatter !== null && typeof parsed.frontmatter === 'object'
476
- ? (parsed.frontmatter as Record<string, unknown>)
477
- : {};
478
- const featureId = String(jqPick(frontmatter.feature_id, parsed.feature_id, ''));
479
- const status = String(jqPick(frontmatter.status, parsed.status, 'unknown'));
480
-
481
- // The verdict artifact stays the first source (F93 R2); when it is gone — a worktree run
482
- // fast-forwards and removes the tree, taking the gitignored artifact with it — the tracked
483
- // `Testing` line `task record` already wrote into the task file is the durable copy. Honest
484
- // UNKNOWN is the last resort, and it is telemetry, never proof of completion.
485
- const durable = join('.spur', 'memory', 'evidence', `${wbs}-verdict.json`);
486
- const hasDurable = existsSync(abs(durable));
487
- const verdictPath = hasDurable ? durable : join('.spur', 'run', `${wbs}-verdict.json`);
488
- const artifactVerdict = verdictOfArtifact(abs(verdictPath)) ?? (hasDurable ? 'UNKNOWN' : null);
489
- const trackedVerdict = verdictFromTestingSection(typeof parsed.content === 'string' ? parsed.content : '');
490
- const verdict = artifactVerdict ?? trackedVerdict ?? 'UNKNOWN';
491
- if (verdict === 'UNKNOWN') {
492
- // R3: name the task, the artifact path and the tracked state. An explicit UNKNOWN on
493
- // either source is still an uncertified row, so it reports the same way as a miss.
494
- process.stderr.write(
495
- `metrics-record: task ${wbs} has no certifying verdict — ${verdictPath}: ${artifactVerdict ?? 'missing or carries none'}, tracked Testing: ${trackedVerdict ?? 'no Verdict: line'} — recording UNKNOWN telemetry\n`,
496
- );
497
- }
498
-
499
- const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
500
- const row = { wbs, feature_id: featureId, status, verdict, timestamp };
501
- try {
502
- appendFileSync(abs(relMetricsFile), `${JSON.stringify(row)}\n`);
503
- } catch {
504
- process.stderr.write(
505
- `metrics-record: metrics append failed for task ${wbs} — recording FAIL instead of claiming the row landed\n`,
506
- );
507
- metricsRc = 1;
508
- }
509
- }
510
- const status: 'PASS' | 'FAIL' = metricsRc === 0 ? 'PASS' : 'FAIL';
511
- writeFileSync(abs(relStatusFile), `${status}\n`);
512
- return { status, statusFile: relStatusFile };
513
- }
514
-
515
- export interface FeatureTransitionResult {
516
- status: 'PASS' | 'FAIL';
517
- statusFile: string;
518
- /** Only an empty vars.feature is a hard failure (mis-invocation, not a blocked sync). */
519
- exitCode: number;
520
- }
521
-
522
- /** Classify one sync result per 0783 R4; returns the blocking reason or '' for a verified sync. */
523
- export function classifySync(
524
- syncOutput: string,
525
- syncRc: number,
526
- feature: string,
527
- observed: string,
528
- ): { reason: string; applied: string; syncOk: boolean } {
529
- if (syncRc !== 0) {
530
- return { reason: `sync exited nonzero (rc=${syncRc})`, applied: 'unreadable', syncOk: false };
531
- }
532
- let parsed: unknown;
533
- try {
534
- parsed = JSON.parse(syncOutput);
535
- } catch {
536
- parsed = undefined;
537
- }
538
- const obj = parsed !== null && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : undefined;
539
- const proposal =
540
- obj?.proposal !== null && typeof obj?.proposal === 'object'
541
- ? (obj.proposal as Record<string, unknown>)
542
- : undefined;
543
- const shapeOk =
544
- obj !== undefined &&
545
- proposal !== undefined &&
546
- typeof proposal.featureId === 'string' &&
547
- typeof proposal.from === 'string' &&
548
- typeof proposal.to === 'string' &&
549
- typeof obj.applied === 'boolean';
550
- if (!shapeOk) {
551
- return { reason: 'malformed or unreadable sync result', applied: 'unreadable', syncOk: false };
552
- }
553
- const applied = obj.applied === true ? 'true' : 'false';
554
- const pFrom = String(jqPick(proposal.from, ''));
555
- const pTo = String(jqPick(proposal.to, ''));
556
- if (String(jqPick(proposal.featureId, '')) !== feature) {
557
- return { reason: `sync proposal does not match feature ${feature}`, applied, syncOk: false };
558
- }
559
- if (proposal.gateBlocked === true) {
560
- return {
561
- reason: 'sync proposal is gate-blocked — a blocked sync is not a no-change success',
562
- applied,
563
- syncOk: false,
564
- };
565
- }
566
- if (proposal.requiresConfirm === true) {
567
- return { reason: 'sync proposal requires operator confirmation', applied, syncOk: false };
568
- }
569
- if (applied === 'true' && observed !== pTo) {
570
- return {
571
- reason: `applied sync did not land on the proposal target (observed=${observed}, to=${pTo})`,
572
- applied,
573
- syncOk: false,
574
- };
575
- }
576
- if (applied === 'false' && (pFrom !== pTo || observed !== pTo)) {
577
- return {
578
- reason: `sync applied nothing without a from==to observed no-op (from=${pFrom}, to=${pTo}, observed=${observed})`,
579
- applied,
580
- syncOk: false,
581
- };
582
- }
583
- return { reason: '', applied, syncOk: true };
584
- }
585
-
586
- /** `feature-transition` — required bounded sync, observation and the affected-feature gate. */
587
- export function runFeatureTransition(env: WrapupStepsEnv, options: WrapupStepsOptions = {}): FeatureTransitionResult {
588
- const cwd = options.cwd;
589
- const runId = env.__runId ?? '';
590
- const feature = env.feature ?? '';
591
- mkdirSync(cwd ? join(cwd, '.spur', 'run') : join('.spur', 'run'), { recursive: true });
592
- const relStatusFile = join('.spur', 'run', `${runId}-wrapup-sync.status`);
593
- const abs = (p: string): string => (cwd ? join(cwd, p) : p);
594
- if (feature.length === 0) {
595
- process.stderr.write(
596
- 'feature-transition: vars.feature is empty — refusing no-op feature sync (mis-invocation, not a blocked sync)\n',
597
- );
598
- return { status: 'FAIL', statusFile: relStatusFile, exitCode: 1 };
599
- }
600
-
601
- // 1004 R5: one direct service sync (stderr streams through, stdout is the JSON payload).
602
- // Repeated-BLOCKED suppression (0411) lives in the feature sync service itself (1004 R3),
603
- // so the bounded-wrapper resolution branches are gone.
604
- let syncOutput = '';
605
- let syncRc = 1;
606
- const sync = spur(env, ['feature', 'sync', feature, '--json'], { cwd, stderr: 'inherit' });
607
- syncOutput = sync.stdout;
608
- syncRc = sync.status;
609
- process.stdout.write(`${syncOutput}\n`);
610
-
611
- const shown = spur(env, ['feature', 'show', feature, '--json'], { cwd });
612
- let observed = '';
613
- if (shown.status === 0) {
614
- try {
615
- const parsed = JSON.parse(shown.stdout) as Record<string, unknown>;
616
- const frontmatter =
617
- parsed.frontmatter !== null && typeof parsed.frontmatter === 'object'
618
- ? (parsed.frontmatter as Record<string, unknown>)
619
- : {};
620
- const picked = jqPick(parsed.status, frontmatter.status, '');
621
- observed = picked === '' ? '' : jqText(picked);
622
- } catch {
623
- observed = '';
624
- }
625
- }
626
- if (observed.length === 0) observed = 'unreadable';
627
-
628
- const classified = classifySync(syncOutput, syncRc, feature, observed);
629
- const { reason, applied } = classified;
630
- const syncOk = classified.syncOk;
631
-
632
- let gate = 'skipped';
633
- if (applied === 'true' || syncRc !== 0) {
634
- process.stdout.write(
635
- `feature-transition: sync applied or failed after a possible partial transition for ${feature} — running feature gate: ${env.featureGateCmd ?? ''}\n`,
636
- );
637
- const gateResult = spawnSync('sh', ['-c', env.featureGateCmd ?? ''], { cwd, stdio: 'inherit' });
638
- if ((gateResult.status ?? 1) === 0) {
639
- gate = 'PASS';
640
- process.stdout.write(`feature-transition: feature gate PASS for feature ${feature}\n`);
641
- } else {
642
- gate = 'FAIL';
643
- process.stderr.write(
644
- `feature-transition: feature gate FAIL for feature ${feature} — inspect findings before reporting the transition complete\n`,
645
- );
646
- }
647
- } else {
648
- process.stdout.write(
649
- `feature-transition: sync did not apply a transition (rc=${syncRc}, applied=${applied}) — feature gate skipped\n`,
650
- );
651
- }
652
-
653
- let syncStatus: 'PASS' | 'FAIL';
654
- if (!syncOk || gate === 'FAIL') {
655
- syncStatus = 'FAIL';
656
- process.stderr.write(
657
- `feature-transition: required synchronization failed for ${feature} — ${reason}; gate=${gate}\n`,
658
- );
659
- } else if (applied === 'false') {
660
- syncStatus = 'PASS';
661
- process.stdout.write(
662
- `feature-transition: feature sync verified for ${feature} (from==to observed at ${observed}, gate=${gate}) — explicit no-change\n`,
663
- );
664
- } else {
665
- syncStatus = 'PASS';
666
- process.stdout.write(
667
- `feature-transition: feature sync verified for ${feature} (applied, observed=${observed}, gate=${gate})\n`,
668
- );
669
- }
670
- writeFileSync(abs(relStatusFile), `${syncStatus}\n`);
671
- return { status: syncStatus, statusFile: relStatusFile, exitCode: 0 };
672
- }
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
-
870
- export const WRAPUP_STEPS_USAGE =
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)';
872
-
873
- export function main(argv: string[], env: WrapupStepsEnv = getEnvVars(), options: WrapupStepsOptions = {}): number {
874
- const sub = argv[0];
875
- if (sub === 'resolve') return resolveTasks(env, options).exitCode;
876
- if (sub === 'route-reason') return writeRouteReason(env, options).exitCode;
877
- if (sub === 'metrics') {
878
- runMetrics(env, options);
879
- return 0;
880
- }
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;
885
- process.stderr.write(`${WRAPUP_STEPS_USAGE}\n`);
886
- return 2;
887
- }
888
-
889
- if (import.meta.main) {
890
- process.exit(main(process.argv.slice(2)));
891
- }