@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
@@ -1,21 +1,12 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * persist-out-check — WT-4 pre-removal assertion for E71 persist-out (task 1067 R1).
3
+ * persist-out-check — WT-4 pre-removal assertion for E71 persist-out (task 1067 R1, 1148 R1).
4
4
  *
5
- * Before `git worktree remove`, verify the worktree's durable evidence would not be
6
- * abandoned: every file the worktree owns under `.spur/run/` (`<wbs>-*` per forwarded
7
- * task file, `<runId>-*` per forwarded run id) and the whole `.spur/memory/evidence/`
8
- * tree must exist in the invoking tree with identical bytes. Missing files BLOCK the
9
- * removal (persist-out was skipped or stale); divergent files block too (persist-out
10
- * never overwrites — reconcile by hand per execution-batch.md). No new public CLI verb:
11
- * this is a driver-side assertion step, not a corpus tool.
12
- *
13
- * ponytail: file-level comparison only — DB run rows and nested-run deep walks stay
14
- * persist-out's job (`inline-run-setup --persist-out`); this checks the planes E71
15
- * names as files. Listing caps (64/prefix, 256 evidence) refuse runaways by name.
16
- *
17
- * Node-builtin imports only; pure helpers are exported for tests. Exit 0 ok, 1
18
- * blocked/divergent, 2 usage error. Normal output is the finding lines themselves.
5
+ * Before `git worktree remove`, verify the worktree's OWNED durable evidence would not be abandoned:
6
+ * every `.spur/run/` and `.spur/memory/evidence/` file starting with a forwarded `<wbs>-`/`<runId>-`
7
+ * prefix must exist in the invoking tree with identical bytes. Unowned evidence is counted
8
+ * (`unowned: N`) and does not block (1148 R1). Node builtins only; helpers are exported for tests.
9
+ * Exit 0 ok, 1 blocked/divergent, 2 usage error. Normal output is the finding lines themselves.
19
10
  */
20
11
 
21
12
  import { spawnSync } from 'node:child_process';
@@ -26,7 +17,6 @@ const EVIDENCE_DIR = join('.spur', 'memory', 'evidence');
26
17
  const RUN_DIR = join('.spur', 'run');
27
18
  const NAMED_CAP = 32;
28
19
  const PREFIX_CAP = 64;
29
- const EVIDENCE_CAP = 256;
30
20
 
31
21
  /** Task files own their `<wbs>-*` evidence: leading digits before `_` in the basename. */
32
22
  export function wbsOfTaskFile(path: string): string | null {
@@ -34,49 +24,64 @@ export function wbsOfTaskFile(path: string): string | null {
34
24
  return m?.[1] ?? null;
35
25
  }
36
26
 
37
- function listFilesUnder(root: string, rel: string, cap: number): string[] | null {
38
- const dir = join(root, rel);
27
+ export interface ListObligationsResult {
28
+ files: string[];
29
+ unowned: number;
30
+ }
31
+
32
+ /** Recursively list files under `dir`, relative to `root`. */
33
+ function walkFiles(dir: string, root: string): string[] {
39
34
  if (!existsSync(dir)) return [];
40
35
  const out: string[] = [];
41
- const walk = (cur: string): boolean => {
42
- for (const entry of readdirSync(cur, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
43
- const p = join(cur, entry.name);
44
- if (entry.isDirectory()) {
45
- if (!walk(p)) return false;
46
- } else {
47
- out.push(relative(root, p));
48
- if (out.length > cap) return false;
49
- }
36
+ const walk = (cur: string): void => {
37
+ for (const e of readdirSync(cur, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
38
+ const p = join(cur, e.name);
39
+ if (e.isDirectory()) walk(p);
40
+ else if (e.isFile()) out.push(relative(root, p));
50
41
  }
51
- return true;
52
42
  };
53
- return walk(dir) ? out : null;
43
+ walk(dir);
44
+ return out;
45
+ }
46
+
47
+ /** Owned evidence (prefix-matched) under one plane; null once a prefix exceeds PREFIX_CAP. */
48
+ function collectOwned(rels: string[], prefixes: string[], counts: Map<string, number>, out: string[]): boolean {
49
+ for (const rel of rels) {
50
+ const prefix = prefixes.find((p) => basename(rel).startsWith(`${p}-`));
51
+ if (prefix === undefined) continue;
52
+ const count = (counts.get(prefix) ?? 0) + 1;
53
+ if (count > PREFIX_CAP) return false;
54
+ counts.set(prefix, count);
55
+ out.push(rel);
56
+ }
57
+ return true;
54
58
  }
55
59
 
56
60
  /**
57
- * Evidence files the worktree owns: `.spur/memory/evidence/**` plus `.spur/run/<prefix>-*`
58
- * direct children per task-file WBS / run id. Returns null on a cap overrun or a listing
59
- * failure (not a directory, permission denied) — the caller refuses either way.
61
+ * Evidence the worktree owns: `.spur/memory/evidence/` (recursive) and `.spur/run/` (flat) files
62
+ * whose basename starts with a forwarded `<wbs>-`/`<runId>-` prefix. Evidence matching no prefix is
63
+ * counted as `unowned` (1148 R1). Null when no prefix is forwarded or a prefix cap is exceeded.
60
64
  */
61
- export function listObligations(wtRoot: string, wbsList: string[], runIds: string[]): string[] | null {
62
- const evidence = listFilesUnder(wtRoot, EVIDENCE_DIR, EVIDENCE_CAP);
63
- if (evidence === null) return null;
64
- const out = [...evidence];
65
- const prefixes = [...wbsList, ...runIds];
66
- for (const prefix of prefixes) {
67
- const dir = join(wtRoot, RUN_DIR);
68
- if (!existsSync(dir)) continue;
69
- let entries: string[];
70
- try {
71
- entries = readdirSync(dir).sort();
72
- } catch {
73
- return null;
74
- }
75
- const owned = entries.filter((n) => n.startsWith(`${prefix}-`));
76
- if (owned.length > PREFIX_CAP) return null;
77
- out.push(...owned.map((n) => join(RUN_DIR, n)));
78
- }
79
- return [...new Set(out)].sort();
65
+ export function listObligations(wtRoot: string, wbsList: string[], runIds: string[]): ListObligationsResult | null {
66
+ const prefixes = [...new Set([...wbsList, ...runIds].filter(Boolean))];
67
+ if (prefixes.length === 0) return null;
68
+
69
+ const evDir = join(wtRoot, EVIDENCE_DIR);
70
+ const runDir = join(wtRoot, RUN_DIR);
71
+ const evFiles = walkFiles(evDir, wtRoot);
72
+ const runFiles = existsSync(runDir)
73
+ ? readdirSync(runDir, { withFileTypes: true })
74
+ .filter((e) => e.isFile())
75
+ .map((e) => join(RUN_DIR, e.name))
76
+ .sort()
77
+ : [];
78
+
79
+ const files: string[] = [];
80
+ const counts = new Map<string, number>();
81
+ if (!collectOwned(evFiles, prefixes, counts, files)) return null;
82
+ if (!collectOwned(runFiles, prefixes, counts, files)) return null;
83
+ const unowned = evFiles.length - files.filter((f) => f.startsWith(EVIDENCE_DIR)).length;
84
+ return { files: [...new Set(files)].sort(), unowned };
80
85
  }
81
86
 
82
87
  /** Classify obligations against the invoking tree: missing, divergent (byte-differs), or ok. */
@@ -84,11 +89,15 @@ export function compareTrees(
84
89
  wtRoot: string,
85
90
  invokeRoot: string,
86
91
  files: string[],
92
+ foreignDivergent = new Set<string>(),
87
93
  ): { missing: string[]; divergent: string[]; ok: number } {
88
94
  const missing: string[] = [];
89
95
  const divergent: string[] = [];
90
96
  let ok = 0;
91
97
  for (const rel of files) {
98
+ if (foreignDivergent.has(rel) || foreignDivergent.has(basename(rel))) {
99
+ continue;
100
+ }
92
101
  const wtPath = join(wtRoot, rel);
93
102
  const invPath = join(invokeRoot, rel);
94
103
  if (!existsSync(invPath)) {
@@ -107,7 +116,7 @@ export function compareTrees(
107
116
  }
108
117
 
109
118
  export function persistOutCheckUsage(): string {
110
- return 'usage: persist-out-check --from <worktree> [--task-file <path>]… [--run-id <id>]… [--root <invoke-tree>]';
119
+ return 'usage: persist-out-check --from <worktree> [--task-file <path>]… [--run-id <id>]… [--root <invoke-tree>] [--success-json <path>]';
111
120
  }
112
121
 
113
122
  /**
@@ -129,6 +138,7 @@ export function defaultInvokeRoot(cwd: string): string {
129
138
 
130
139
  export function main(argv: string[], invokeRoot = defaultInvokeRoot(process.cwd())): number {
131
140
  let wtRoot = '';
141
+ let successJsonPath = '';
132
142
  const wbsList: string[] = [];
133
143
  const runIds: string[] = [];
134
144
  for (let i = 0; i < argv.length; i++) {
@@ -145,25 +155,64 @@ export function main(argv: string[], invokeRoot = defaultInvokeRoot(process.cwd(
145
155
  } else if (arg === '--run-id') {
146
156
  const id = argv[++i] ?? '';
147
157
  if (id) runIds.push(id);
158
+ } else if (arg === '--success-json') {
159
+ successJsonPath = argv[++i] ?? '';
148
160
  } else {
149
161
  process.stderr.write(`${persistOutCheckUsage()}\n`);
150
162
  return 2;
151
163
  }
152
164
  }
165
+ if (wbsList.length === 0 && runIds.length === 0) {
166
+ process.stderr.write(
167
+ `${persistOutCheckUsage()}\npersist-out-check: at least one --task-file or --run-id prefix required\n`,
168
+ );
169
+ return 2;
170
+ }
153
171
  if (!wtRoot || !existsSync(wtRoot) || !statSync(wtRoot).isDirectory()) {
154
172
  process.stderr.write(
155
173
  `${persistOutCheckUsage()}\npersist-out-check: --from must be an existing worktree directory\n`,
156
174
  );
157
175
  return 2;
158
176
  }
159
- const files = listObligations(wtRoot, wbsList, runIds);
160
- if (files === null) {
177
+ const foreignDivergent = new Set<string>();
178
+ const skips: Array<{ id: string; reason: string }> = [];
179
+ const candidates = [
180
+ successJsonPath,
181
+ join(invokeRoot, '.spur', 'run', 'persist-out.json'),
182
+ join(wtRoot, '.spur', 'run', 'persist-out.json'),
183
+ ].filter(Boolean);
184
+ for (const cand of candidates) {
185
+ if (existsSync(cand)) {
186
+ try {
187
+ const parsed = JSON.parse(readFileSync(cand, 'utf8'));
188
+ if (Array.isArray(parsed.evidenceSkipped)) {
189
+ for (const item of parsed.evidenceSkipped) {
190
+ if (item.reason === 'foreign-divergent' && typeof item.name === 'string') {
191
+ foreignDivergent.add(item.name);
192
+ foreignDivergent.add(join(EVIDENCE_DIR, item.name));
193
+ }
194
+ }
195
+ }
196
+ if (Array.isArray(parsed.skipped)) {
197
+ for (const item of parsed.skipped) {
198
+ if (typeof item.id === 'string' && typeof item.reason === 'string') {
199
+ skips.push({ id: item.id, reason: item.reason });
200
+ }
201
+ }
202
+ }
203
+ break;
204
+ } catch {}
205
+ }
206
+ }
207
+ const obligations = listObligations(wtRoot, wbsList, runIds);
208
+ if (obligations === null) {
161
209
  process.stderr.write(
162
- `persist-out-check: BLOCKED — worktree evidence listing failed or exceeded caps (64/prefix, ${EVIDENCE_CAP} evidence files) — inspect by hand\n`,
210
+ `persist-out-check: BLOCKED — worktree evidence listing failed or exceeded caps (${PREFIX_CAP}/prefix) — inspect by hand\n`,
163
211
  );
164
212
  return 1;
165
213
  }
166
- const { missing, divergent, ok } = compareTrees(wtRoot, invokeRoot, files);
214
+ const { files, unowned } = obligations;
215
+ const { missing, divergent, ok } = compareTrees(wtRoot, invokeRoot, files, foreignDivergent);
167
216
  const findings = [
168
217
  ...missing.map((f) => `MISSING ${f}`),
169
218
  ...divergent.map((f) => `DIVERGENT ${f} — reconcile by hand (persist-out never overwrites)`),
@@ -171,7 +220,13 @@ export function main(argv: string[], invokeRoot = defaultInvokeRoot(process.cwd(
171
220
  for (const line of findings.slice(0, NAMED_CAP)) process.stdout.write(`${line}\n`);
172
221
  if (findings.length > NAMED_CAP) process.stdout.write(`… +${findings.length - NAMED_CAP} more\n`);
173
222
  if (findings.length === 0) {
174
- process.stdout.write(`persist-out-check: ok — ${ok} evidence file(s) persisted, nothing abandoned\n`);
223
+ for (const skip of skips) {
224
+ process.stdout.write(`SKIP ${skip.id}: ${skip.reason}\n`);
225
+ }
226
+ const abandonedMsg = skips.length > 0 ? `${skips.length} skipped` : 'nothing abandoned';
227
+ process.stdout.write(
228
+ `persist-out-check: ok — ${ok} evidence file(s) persisted, unowned: ${unowned}, ${abandonedMsg}\n`,
229
+ );
175
230
  return 0;
176
231
  }
177
232
  process.stderr.write(
@@ -480,6 +480,15 @@ ${requestMarker(pr.headRefOid)}`;
480
480
  writeStatus(args, "REQUESTED");
481
481
  emit(args, { ok: true, requested: true, pr: pr.number, url: pr.url, head: pr.headRefOid, requestedAt, body }, `Requested GitHub Codex review on PR #${pr.number} (${pr.url}) at HEAD ${pr.headRefOid.slice(0, 7)}.`);
482
482
  }
483
+ function revalidateWaitTarget(args, number) {
484
+ const fresh = viewPr();
485
+ if (!fresh || fresh.number !== number) {
486
+ writeStatus(args, "FAIL");
487
+ fail(args, `PR #${number} is no longer the branch's PR (view reports ${fresh ? `#${fresh.number}` : "none"}) \u2014 request a fresh review`, 2);
488
+ }
489
+ requireExpectedHead(args, fresh);
490
+ return fresh;
491
+ }
483
492
  function cmdWait(args) {
484
493
  const since = args.flags.get("--since") ?? new Date().toISOString();
485
494
  const timeoutSec = Number(args.flags.get("--timeout") ?? "600");
@@ -494,24 +503,29 @@ function cmdWait(args) {
494
503
  writeStatus(args, "FAIL");
495
504
  fail(args, "no PR for the current branch", 2);
496
505
  }
506
+ const number = pr.number;
497
507
  requireExpectedHead(args, pr);
498
508
  const deadline = Date.now() + timeoutSec * 1000;
499
509
  for (;; ) {
500
- const reviews = fetchReviews(ctx, pr.number);
501
- const findings = normalizeFindings(reviews, fetchInlineComments(ctx, pr.number), fetchIssueComments(ctx, pr.number), since, pr.headRefOid);
510
+ const observed = revalidateWaitTarget(args, number);
511
+ const reviews = fetchReviews(ctx, number);
512
+ const findings = normalizeFindings(reviews, fetchInlineComments(ctx, number), fetchIssueComments(ctx, number), since, observed.headRefOid);
502
513
  if (findings.length > 0) {
514
+ revalidateWaitTarget(args, number);
503
515
  writeStatus(args, "FOUND");
504
516
  emit(args, { ok: true, verdict: "FOUND", findings }, renderFindings(findings));
505
517
  return;
506
518
  }
507
- if (hasCurrentCleanReview(reviews, since, pr.headRefOid)) {
519
+ if (hasCurrentCleanReview(reviews, since, observed.headRefOid)) {
520
+ const fresh = revalidateWaitTarget(args, number);
508
521
  writeStatus(args, "CLEAN");
509
- emit(args, { ok: true, verdict: "CLEAN", pr: pr.number, url: pr.url, head: pr.headRefOid, findings: [] }, `Codex review completed cleanly for HEAD ${pr.headRefOid.slice(0, 7)}.`);
522
+ emit(args, { ok: true, verdict: "CLEAN", pr: fresh.number, url: fresh.url, head: fresh.headRefOid, findings: [] }, `Codex review completed cleanly for HEAD ${fresh.headRefOid.slice(0, 7)}.`);
510
523
  return;
511
524
  }
512
525
  if (Date.now() >= deadline) {
526
+ revalidateWaitTarget(args, number);
513
527
  writeStatus(args, "TIMEOUT");
514
- emit(args, { ok: true, verdict: "TIMEOUT", pr: pr.number, url: pr.url }, `No Codex review within ${timeoutSec}s \u2014 still pending. Collect later with /sp:dev-pr-review collect.
528
+ emit(args, { ok: true, verdict: "TIMEOUT", pr: number, url: pr.url }, `No Codex review within ${timeoutSec}s \u2014 still pending. Collect later with /sp:dev-pr-review collect.
515
529
  PR: ${pr.url}`);
516
530
  throw new ScriptExit("", 3, args.booleans.has("--json"));
517
531
  }
@@ -703,6 +703,26 @@ function cmdRequest(args: ParsedArgs): void {
703
703
  );
704
704
  }
705
705
 
706
+ /**
707
+ * 1161 R1: re-read the PR and re-assert the pinned identity + expected head. Called at the start of
708
+ * every poll and again immediately before any FOUND/CLEAN/TIMEOUT status or output, because a head
709
+ * captured once at wait start lets a later push return CLEAN for an old commit. A vanished PR, a
710
+ * different PR on the branch, or a moved head is FAIL/exit2 through the existing error channel.
711
+ */
712
+ function revalidateWaitTarget(args: ParsedArgs, number: number): GhPr {
713
+ const fresh = viewPr();
714
+ if (!fresh || fresh.number !== number) {
715
+ writeStatus(args, 'FAIL');
716
+ fail(
717
+ args,
718
+ `PR #${number} is no longer the branch's PR (view reports ${fresh ? `#${fresh.number}` : 'none'}) — request a fresh review`,
719
+ 2,
720
+ );
721
+ }
722
+ requireExpectedHead(args, fresh);
723
+ return fresh;
724
+ }
725
+
706
726
  function cmdWait(args: ParsedArgs): void {
707
727
  const since = args.flags.get('--since') ?? new Date().toISOString();
708
728
  const timeoutSec = Number(args.flags.get('--timeout') ?? '600');
@@ -715,36 +735,41 @@ function cmdWait(args: ParsedArgs): void {
715
735
  writeStatus(args, 'FAIL');
716
736
  fail(args, 'no PR for the current branch', 2);
717
737
  }
738
+ const number = pr.number; // 1161 R1: pinned — a switched PR number is a failure, not a retarget.
718
739
  requireExpectedHead(args, pr);
719
- const deadline = Date.now() + timeoutSec * 1000;
740
+ const deadline = Date.now() + timeoutSec * 1000; // fixed once; validation never resets it
720
741
  for (;;) {
721
- const reviews = fetchReviews(ctx, pr.number);
742
+ const observed = revalidateWaitTarget(args, number);
743
+ const reviews = fetchReviews(ctx, number);
722
744
  const findings = normalizeFindings(
723
745
  reviews,
724
- fetchInlineComments(ctx, pr.number),
725
- fetchIssueComments(ctx, pr.number),
746
+ fetchInlineComments(ctx, number),
747
+ fetchIssueComments(ctx, number),
726
748
  since,
727
- pr.headRefOid,
749
+ observed.headRefOid,
728
750
  );
729
751
  if (findings.length > 0) {
752
+ revalidateWaitTarget(args, number); // pre-return recheck: never FOUND for a moved head
730
753
  writeStatus(args, 'FOUND');
731
754
  emit(args, { ok: true, verdict: 'FOUND', findings }, renderFindings(findings));
732
755
  return;
733
756
  }
734
- if (hasCurrentCleanReview(reviews, since, pr.headRefOid)) {
757
+ if (hasCurrentCleanReview(reviews, since, observed.headRefOid)) {
758
+ const fresh = revalidateWaitTarget(args, number); // pre-return recheck: never CLEAN for a moved head
735
759
  writeStatus(args, 'CLEAN');
736
760
  emit(
737
761
  args,
738
- { ok: true, verdict: 'CLEAN', pr: pr.number, url: pr.url, head: pr.headRefOid, findings: [] },
739
- `Codex review completed cleanly for HEAD ${pr.headRefOid.slice(0, 7)}.`,
762
+ { ok: true, verdict: 'CLEAN', pr: fresh.number, url: fresh.url, head: fresh.headRefOid, findings: [] },
763
+ `Codex review completed cleanly for HEAD ${fresh.headRefOid.slice(0, 7)}.`,
740
764
  );
741
765
  return;
742
766
  }
743
767
  if (Date.now() >= deadline) {
768
+ revalidateWaitTarget(args, number); // a head moved while pending is a failure, not a timeout
744
769
  writeStatus(args, 'TIMEOUT');
745
770
  emit(
746
771
  args,
747
- { ok: true, verdict: 'TIMEOUT', pr: pr.number, url: pr.url },
772
+ { ok: true, verdict: 'TIMEOUT', pr: number, url: pr.url },
748
773
  `No Codex review within ${timeoutSec}s — still pending. Collect later with /sp:dev-pr-review collect.\nPR: ${pr.url}`,
749
774
  );
750
775
  throw new ScriptExit('', 3, args.booleans.has('--json'));