@gobing-ai/spur 0.3.96 → 0.3.98

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 (95) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/rules/strict/runtime-boundaries.yaml +1 -0
  3. package/config/templates/AGENTS.md +3 -3
  4. package/config/workflows/wrapup-pipeline.yaml +2 -1
  5. package/package.json +2 -2
  6. package/plugins/sp/README.md +15 -9
  7. package/plugins/sp/commands/dev-job-dump.md +29 -0
  8. package/plugins/sp/commands/dev-job-resume.md +29 -0
  9. package/plugins/sp/lib/inline-run.generated.mjs +34 -18
  10. package/plugins/sp/lib/quality-gate.generated.mjs +12 -5
  11. package/plugins/sp/lib/residual-scan.generated.d.mts +1 -0
  12. package/plugins/sp/lib/residual-scan.generated.mjs +16 -0
  13. package/plugins/sp/plugin.json +1 -1
  14. package/plugins/sp/references/roles.md +5 -5
  15. package/plugins/sp/scripts/feature-verification-steps.mjs +14 -1
  16. package/plugins/sp/scripts/feature-verification-steps.ts +14 -1
  17. package/plugins/sp/scripts/inline-run-setup.mjs +4 -2
  18. package/plugins/sp/scripts/inline-run-setup.ts +12 -12
  19. package/plugins/sp/scripts/quality-gate.mjs +13 -6
  20. package/plugins/sp/scripts/quality-gate.ts +1 -1
  21. package/plugins/sp/scripts/residual-scan.mjs +64 -38
  22. package/plugins/sp/scripts/residual-scan.ts +39 -34
  23. package/plugins/sp/scripts/wrapup-steps.mjs +64 -3
  24. package/plugins/sp/scripts/wrapup-steps.ts +122 -3
  25. package/plugins/sp/skills/code-implementation/SKILL.md +1 -1
  26. package/plugins/sp/skills/conflict-finding/SKILL.md +1 -1
  27. package/plugins/sp/skills/conflict-finding/references/authority-resolution.md +5 -3
  28. package/plugins/sp/skills/conflict-finding/references/remediation-routing.md +9 -8
  29. package/plugins/sp/skills/doc-evolve/SKILL.md +4 -3
  30. package/plugins/sp/skills/source-driven-development/SKILL.md +11 -0
  31. package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +16 -9
  32. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +7 -4
  33. package/plugins/sp/skills/spur-cli/references/workflows.md +17 -14
  34. package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
  35. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +3 -1
  36. package/plugins/sp/skills/spur-dev/references/dev-operations.md +94 -17
  37. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +4 -2
  38. package/plugins/sp/skills/spur-dev/references/execution-batch.md +8 -6
  39. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +3 -3
  40. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +16 -8
  41. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +46 -12
  42. package/spur.js +5241 -3762
  43. package/web/_astro/BoardApp.CYbe-OTb.js +322 -0
  44. package/web/_astro/{BoardApp.BIjMatT1.js → BoardApp.Dflq5a8x.js} +1 -1
  45. package/web/_astro/{TaskDetail.BZM3EAFx.js → TaskDetail.CK7oXddP.js} +1 -1
  46. package/web/_astro/{arc.HkRiZnoI.js → arc.BIqkDATc.js} +1 -1
  47. package/web/_astro/{architectureDiagram-3BPJPVTR.BDdK-tgZ.js → architectureDiagram-3BPJPVTR.D0B3SvWv.js} +1 -1
  48. package/web/_astro/{blockDiagram-GPEHLZMM.CZ9kOBxx.js → blockDiagram-GPEHLZMM.C2YyKQmL.js} +1 -1
  49. package/web/_astro/{c4Diagram-AAUBKEIU.DXK9qeRD.js → c4Diagram-AAUBKEIU.DmtTYrt_.js} +1 -1
  50. package/web/_astro/channel.JuKVyuhZ.js +1 -0
  51. package/web/_astro/{chunk-2J33WTMH.BTD2WeX5.js → chunk-2J33WTMH.HsRXbh7l.js} +1 -1
  52. package/web/_astro/{chunk-4BX2VUAB.Ci6Qbcvk.js → chunk-4BX2VUAB.obE_P-R3.js} +1 -1
  53. package/web/_astro/{chunk-55IACEB6.yl3zsj7p.js → chunk-55IACEB6.CjWTF5mz.js} +1 -1
  54. package/web/_astro/{chunk-727SXJPM.ClXZsfyR.js → chunk-727SXJPM.DMo799Ax.js} +1 -1
  55. package/web/_astro/{chunk-AQP2D5EJ.BmKWZBcP.js → chunk-AQP2D5EJ.-cE6JOKZ.js} +1 -1
  56. package/web/_astro/{chunk-FMBD7UC4.By30fcb7.js → chunk-FMBD7UC4.D6Rw6HIk.js} +1 -1
  57. package/web/_astro/{chunk-ND2GUHAM.24NmrD-k.js → chunk-ND2GUHAM.d5rBD73Z.js} +1 -1
  58. package/web/_astro/{chunk-QZHKN3VN.BN4sKdcS.js → chunk-QZHKN3VN.r3ox40Q3.js} +1 -1
  59. package/web/_astro/{classDiagram-4FO5ZUOK.CJpoMPb5.js → classDiagram-4FO5ZUOK.CGGbvjjJ.js} +1 -1
  60. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CJpoMPb5.js → classDiagram-v2-Q7XG4LA2.CGGbvjjJ.js} +1 -1
  61. package/web/_astro/{cose-bilkent-S5V4N54A.BraxQ2Nt.js → cose-bilkent-S5V4N54A.CmKtPd4l.js} +1 -1
  62. package/web/_astro/{cynefin-OW5HDTMX.gzoU73oL.js → cynefin-OW5HDTMX.QNg5GXs8.js} +1 -1
  63. package/web/_astro/{dagre-BM42HDAG.wyDfWySU.js → dagre-BM42HDAG.B1SabsjC.js} +1 -1
  64. package/web/_astro/{diagram-2AECGRRQ.h2_EbMxV.js → diagram-2AECGRRQ.RtDbNj89.js} +1 -1
  65. package/web/_astro/{diagram-5GNKFQAL.Db6dcs89.js → diagram-5GNKFQAL.IzZpuM-Y.js} +1 -1
  66. package/web/_astro/{diagram-KO2AKTUF.NVwzxu_U.js → diagram-KO2AKTUF.DPTF_fgv.js} +1 -1
  67. package/web/_astro/{diagram-LMA3HP47.B-eNgDQk.js → diagram-LMA3HP47.CCyza39P.js} +1 -1
  68. package/web/_astro/{diagram-OG6HWLK6.Bsa8ZRbJ.js → diagram-OG6HWLK6.BXAi8zcZ.js} +1 -1
  69. package/web/_astro/{erDiagram-TEJ5UH35.BospRE5Q.js → erDiagram-TEJ5UH35.DUDX7_MR.js} +1 -1
  70. package/web/_astro/{flowDiagram-I6XJVG4X.DMzvHwAA.js → flowDiagram-I6XJVG4X.C63QTh3N.js} +1 -1
  71. package/web/_astro/{ganttDiagram-6RSMTGT7.Dmdxgd-M.js → ganttDiagram-6RSMTGT7.CnlZJSwG.js} +1 -1
  72. package/web/_astro/{gitGraphDiagram-PVQCEYII.CaqdO0rL.js → gitGraphDiagram-PVQCEYII.CTqIrOPC.js} +1 -1
  73. package/web/_astro/index.CbNz17Sx.css +1 -0
  74. package/web/_astro/{infoDiagram-5YYISTIA.DDVD9Wn7.js → infoDiagram-5YYISTIA.BYMiDFh9.js} +1 -1
  75. package/web/_astro/{ishikawaDiagram-YF4QCWOH.EKyCUFsL.js → ishikawaDiagram-YF4QCWOH.CRiQmI-z.js} +1 -1
  76. package/web/_astro/{journeyDiagram-JHISSGLW.By2-RkYB.js → journeyDiagram-JHISSGLW.YQgWBsEU.js} +1 -1
  77. package/web/_astro/{kanban-definition-UN3LZRKU.ar-WmlyR.js → kanban-definition-UN3LZRKU.1ECnrrK1.js} +1 -1
  78. package/web/_astro/{linear.BnzMBgo_.js → linear.DzVA71yT.js} +1 -1
  79. package/web/_astro/{mermaid.core.DSqeFu2Y.js → mermaid.core.BK7P2Sr_.js} +4 -4
  80. package/web/_astro/{mindmap-definition-RKZ34NQL.1XcF8Wh2.js → mindmap-definition-RKZ34NQL.BvXyWZK7.js} +1 -1
  81. package/web/_astro/{pieDiagram-4H26LBE5.DnItHOdK.js → pieDiagram-4H26LBE5.BiXk1TW3.js} +1 -1
  82. package/web/_astro/{quadrantDiagram-W4KKPZXB.BQQyrnBt.js → quadrantDiagram-W4KKPZXB.krvBH8lh.js} +1 -1
  83. package/web/_astro/{requirementDiagram-4Y6WPE33.Dmzxun82.js → requirementDiagram-4Y6WPE33.ih1gL25c.js} +1 -1
  84. package/web/_astro/{sankeyDiagram-5OEKKPKP.BBIeRKYB.js → sankeyDiagram-5OEKKPKP.CpUxkv4u.js} +1 -1
  85. package/web/_astro/{sequenceDiagram-3UESZ5HK.CUNVdBBf.js → sequenceDiagram-3UESZ5HK.clAfeN7y.js} +1 -1
  86. package/web/_astro/{stateDiagram-AJRCARHV.DlFf2sJm.js → stateDiagram-AJRCARHV.DpXWb8-m.js} +1 -1
  87. package/web/_astro/{stateDiagram-v2-BHNVJYJU.DXR7I1H5.js → stateDiagram-v2-BHNVJYJU.ClZb9GIH.js} +1 -1
  88. package/web/_astro/{timeline-definition-PNZ67QCA.BcO-GyX2.js → timeline-definition-PNZ67QCA.BLcX5dh7.js} +1 -1
  89. package/web/_astro/{vennDiagram-CIIHVFJN.ChCECmmW.js → vennDiagram-CIIHVFJN.oGgpKMpx.js} +1 -1
  90. package/web/_astro/{wardleyDiagram-YWT4CUSO.CRaT12mD.js → wardleyDiagram-YWT4CUSO.BPHtMtt-.js} +1 -1
  91. package/web/_astro/{xychartDiagram-2RQKCTM6.B3lsHVpj.js → xychartDiagram-2RQKCTM6.BNZOPYSk.js} +1 -1
  92. package/web/index.html +2 -2
  93. package/web/_astro/BoardApp.GvjIe9Z6.js +0 -191
  94. package/web/_astro/channel.DlhX2MEt.js +0 -1
  95. package/web/_astro/index.EoZlzLK-.css +0 -1
@@ -1,17 +1,7 @@
1
1
  #!/usr/bin/env bun
2
- /**
3
- * residual-scan — deterministic task-leftover scanner behind feature F96 (ADR-071).
4
- *
5
- * Contract owner: docs/design/task-residual-sweep.md. IO glue only (task 1003 R5): argv, git/spur spawning, file
6
- * IO, and the four modes; parsing/classification/folding/reporting live in the generated standalone bundle
7
- * `plugins/sp/lib/residual-scan.generated.mjs` (source: packages/app/src/services/residual-scan.ts), re-exported via
8
- * `export *`; the local `scanResiduals` wrapper (old 5-arg IO signature) shadows the core of the same name.
9
- * Modes: scan <wbs> (write `.spur/run/<wbs>-residuals.json`); fold <wbs> (fold blocking residuals into
10
- * `<wbs>-verdict.json` with PASS→PARTIAL downgrade + `<wbs>-test-gate.findings`); settle <wbs> (file one follow-up
11
- * task for deferrables, delete `/tmp/<wbs>-*` residue); report <wbs> (write `<wbs>-residual-report.md` when the residual-sweep check failed).
12
- */
2
+ /** F96 residual IO glue. Pure logic: residual-scan.generated.mjs; contract: task-residual-sweep.md. */
13
3
  import { spawnSync } from 'node:child_process';
14
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import * as fs from 'node:fs';
15
5
  import { tmpdir } from 'node:os';
16
6
  import { join } from 'node:path';
17
7
  import { getEnvVars } from '../lib/env';
@@ -40,7 +30,7 @@ function spur(env: ScanEnv, spurBinFlag: string | undefined, args: string[], cwd
40
30
  }
41
31
  function isRegularFile(path: string): boolean {
42
32
  try {
43
- return statSync(path).isFile();
33
+ return fs.statSync(path).isFile();
44
34
  } catch {
45
35
  return false;
46
36
  }
@@ -59,7 +49,7 @@ export function collectAddedLines(root: string, base: string): Array<{ file: str
59
49
  const untracked = run('git', ['ls-files', '--others', '--exclude-standard'], root);
60
50
  for (const f of untracked.stdout.split('\n')) {
61
51
  if (f.length === 0 || !isRegularFile(join(root, f))) continue;
62
- for (const [idx, text] of readFileSync(join(root, f), 'utf8').split('\n').entries())
52
+ for (const [idx, text] of fs.readFileSync(join(root, f), 'utf8').split('\n').entries())
63
53
  out.push({ file: f, line: idx + 1, text });
64
54
  }
65
55
  return out;
@@ -67,7 +57,7 @@ export function collectAddedLines(root: string, base: string): Array<{ file: str
67
57
  export function listStagingResidue(tmpDir: string, wbs: string): string[] {
68
58
  let names: string[];
69
59
  try {
70
- names = readdirSync(tmpDir);
60
+ names = fs.readdirSync(tmpDir);
71
61
  } catch {
72
62
  return [];
73
63
  }
@@ -75,7 +65,7 @@ export function listStagingResidue(tmpDir: string, wbs: string): string[] {
75
65
  }
76
66
  function readDeferrals(runDir: string, wbs: string): core.Deferral[] {
77
67
  const path = join(runDir, `${wbs}-residual-deferrals.json`);
78
- if (!existsSync(path)) return [];
68
+ if (!fs.existsSync(path)) return [];
79
69
  const isDeferral = (e: unknown): e is core.Deferral =>
80
70
  typeof e === 'object' &&
81
71
  e !== null &&
@@ -83,7 +73,7 @@ function readDeferrals(runDir: string, wbs: string): core.Deferral[] {
83
73
  typeof (e as core.Deferral).reason === 'string' &&
84
74
  (e as core.Deferral).reason.trim().length > 0;
85
75
  try {
86
- const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
76
+ const parsed: unknown = JSON.parse(fs.readFileSync(path, 'utf8'));
87
77
  return (Array.isArray(parsed) ? parsed : []).filter(isDeferral);
88
78
  } catch {
89
79
  return [];
@@ -92,7 +82,7 @@ function readDeferrals(runDir: string, wbs: string): core.Deferral[] {
92
82
  export function scanResiduals(root: string, wbs: string, tmpDir: string, taskContent: string, _env: ScanEnv) {
93
83
  const runDir = join(root, '.spur', 'run');
94
84
  const basePath = join(runDir, `${wbs}-base.sha`);
95
- const base = existsSync(basePath) ? readFileSync(basePath, 'utf8').trim() : null;
85
+ const base = fs.existsSync(basePath) ? fs.readFileSync(basePath, 'utf8').trim() : null;
96
86
  const stagingResidue = listStagingResidue(tmpDir, wbs);
97
87
  const deferrals = readDeferrals(runDir, wbs);
98
88
  const addedLines = base === null ? [] : collectAddedLines(root, base);
@@ -126,14 +116,14 @@ function loadTask(env: ScanEnv, spurBinFlag: string | undefined, wbs: string, ro
126
116
  return { content: typeof parsed.content === 'string' ? parsed.content : '', featureId };
127
117
  }
128
118
  function loadVerdict(runDir: string, wbs: string): VerdictFile {
129
- return JSON.parse(readFileSync(join(runDir, `${wbs}-verdict.json`), 'utf8'));
119
+ return JSON.parse(fs.readFileSync(core.recordedVerdictPath(runDir, wbs, fs), 'utf8'));
130
120
  }
131
121
  function scanMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
132
122
  const runDir = join(opts.root, '.spur', 'run');
133
- mkdirSync(runDir, { recursive: true });
123
+ fs.mkdirSync(runDir, { recursive: true });
134
124
  const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
135
125
  const artifact = scanResiduals(opts.root, opts.wbs, opts.tmpDir, content, env);
136
- writeFileSync(join(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}\n`);
126
+ fs.writeFileSync(join(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}\n`);
137
127
  io.out(
138
128
  `residual-scan: ${opts.wbs} blocking=${artifact.counts.blocking} deferrable=${artifact.counts.deferrable} advisory=${artifact.counts.advisory} housekeeping=${artifact.counts.housekeeping}\n`,
139
129
  );
@@ -141,16 +131,29 @@ function scanMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
141
131
  }
142
132
  function foldMode(opts: ParsedArgs, _env: ScanEnv, io: ScanIo): number {
143
133
  const runDir = join(opts.root, '.spur', 'run');
144
- const scan = JSON.parse(readFileSync(join(runDir, `${opts.wbs}-residuals.json`), 'utf8')) as core.ResidualArtifact;
145
- const verdictPath = join(runDir, `${opts.wbs}-verdict.json`);
134
+ const scan = JSON.parse(
135
+ fs.readFileSync(join(runDir, `${opts.wbs}-residuals.json`), 'utf8'),
136
+ ) as core.ResidualArtifact;
137
+ const target = core.recordedVerdictPath(runDir, opts.wbs, fs);
146
138
  const verdict = loadVerdict(runDir, opts.wbs);
147
139
  const findingsPath = join(runDir, `${opts.wbs}-test-gate.findings`);
148
- const fold = core.foldVerdict(verdict, scan, existsSync(findingsPath) ? readFileSync(findingsPath, 'utf8') : '');
149
- writeFileSync(
150
- verdictPath,
151
- `${JSON.stringify({ ...verdict, verdict: fold.verdict, checks: fold.checks }, null, 2)}\n`,
140
+ const fold = core.foldVerdict(
141
+ verdict,
142
+ scan,
143
+ fs.existsSync(findingsPath) ? fs.readFileSync(findingsPath, 'utf8') : '',
152
144
  );
153
- writeFileSync(findingsPath, fold.findings);
145
+ const bytes = `${JSON.stringify({ ...verdict, verdict: fold.verdict, checks: fold.checks }, null, 2)}\n`;
146
+ const temporary = `${target}.${process.pid}.tmp`;
147
+ try {
148
+ fs.writeFileSync(temporary, bytes, { flag: 'wx' });
149
+ fs.renameSync(temporary, target);
150
+ } finally {
151
+ fs.rmSync(temporary, { force: true });
152
+ }
153
+ // The pipeline still consumes the attempt copy after folding; the recorded copy is authoritative.
154
+ const scratch = join(runDir, `${opts.wbs}-verdict.json`);
155
+ if (target !== scratch) fs.writeFileSync(scratch, bytes);
156
+ fs.writeFileSync(findingsPath, fold.findings);
154
157
  io.out(
155
158
  `residual-fold: ${opts.wbs} verdict=${fold.verdict} residual-sweep=${fold.checks.find((c) => c.name === 'residual-sweep')?.status}\n`,
156
159
  );
@@ -163,7 +166,7 @@ function settleMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
163
166
  const scan = scanResiduals(opts.root, wbs, opts.tmpDir, task.content, env);
164
167
  const residualsPath = join(runDir, `${wbs}-residuals.json`);
165
168
  let prior: Partial<core.ResidualArtifact & { followUp?: string }> = {};
166
- if (existsSync(residualsPath)) prior = JSON.parse(readFileSync(residualsPath, 'utf8'));
169
+ if (fs.existsSync(residualsPath)) prior = JSON.parse(fs.readFileSync(residualsPath, 'utf8'));
167
170
  const deferred = scan.items.filter((i) => i.class === 'deferrable');
168
171
  if (deferred.length > 0 && prior.followUp === undefined) {
169
172
  if (task.featureId === '') {
@@ -189,7 +192,7 @@ function settleMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
189
192
  const rows = deferred.map((i) => `- ${i.id} — ${i.location}: ${i.text}`);
190
193
  const head = `Source task: ${wbs} (feature ${task.featureId}) — deferred residuals filed by residual-scan settle.`;
191
194
  const bgFile = join(runDir, `${wbs}-residual-background.md`);
192
- writeFileSync(bgFile, `${[head, '', ...rows].join('\n')}\n`);
195
+ fs.writeFileSync(bgFile, `${[head, '', ...rows].join('\n')}\n`);
193
196
  const updArgs = ['task', 'update', wbsNew, '--section', 'Background', '--from-file', bgFile];
194
197
  if (spur(env, opts.spurBin, updArgs, opts.root).status !== 0) {
195
198
  io.err(`residual-settle: background write failed; re-run: residual-scan settle ${wbs}\n`);
@@ -200,13 +203,13 @@ function settleMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
200
203
  }
201
204
  for (const path of listStagingResidue(opts.tmpDir, wbs)) {
202
205
  try {
203
- rmSync(path, { force: true });
206
+ fs.rmSync(path, { force: true });
204
207
  } catch {
205
208
  io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${wbs}\n`);
206
209
  return 0;
207
210
  }
208
211
  }
209
- writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}\n`);
212
+ fs.writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}\n`);
210
213
  return 0;
211
214
  }
212
215
  function reportMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
@@ -217,8 +220,10 @@ function reportMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
217
220
  const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir, content, env);
218
221
  const blocking = scan.items.filter((i) => i.class === 'blocking');
219
222
  const attemptFile = join(runDir, `${opts.wbs}-test-fix-attempt`);
220
- const attempts = existsSync(attemptFile) ? Number.parseInt(readFileSync(attemptFile, 'utf8').trim() || '0', 10) : 0;
221
- writeFileSync(
223
+ const attempts = fs.existsSync(attemptFile)
224
+ ? Number.parseInt(fs.readFileSync(attemptFile, 'utf8').trim() || '0', 10)
225
+ : 0;
226
+ fs.writeFileSync(
222
227
  join(runDir, `${opts.wbs}-residual-report.md`),
223
228
  core.renderReport(opts.wbs, blocking, Number.isNaN(attempts) ? 0 : attempts),
224
229
  );
@@ -3,7 +3,7 @@
3
3
 
4
4
  // plugins/sp/scripts/wrapup-steps.ts
5
5
  import { spawnSync } from "child_process";
6
- import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
7
7
  import { join } from "path";
8
8
 
9
9
  // plugins/sp/lib/env.ts
@@ -114,10 +114,68 @@ function resolveTasks(env, options = {}) {
114
114
  if (unresolved) {
115
115
  return writeFail(`failed:unresolved or non-completed task (see ${relTasksFile})`);
116
116
  }
117
+ if ((env.feature ?? "") !== "") {
118
+ const pf = preflightFeature(env, cwd);
119
+ if (pf)
120
+ return writeFail(pf);
121
+ }
117
122
  writeFileSync(abs(relStatusFile), `PASS
118
123
  `);
119
124
  return { status: "PASS", statusFile: relStatusFile, tasksFile: relTasksFile, exitCode: 0 };
120
125
  }
126
+ function isFindingList(value) {
127
+ return Array.isArray(value) && value.every((f) => f !== null && typeof f === "object" && typeof f.severity === "string" && typeof f.code === "string" && f.code.length > 0);
128
+ }
129
+ function sortedUniqueCodes(findings) {
130
+ const codes = findings.filter((f) => f.severity === "error").map((f) => String(f.code ?? "")).filter((c) => c.length > 0);
131
+ return [...new Set(codes)].sort();
132
+ }
133
+ function preflightFeature(env, cwd) {
134
+ const feature = env.feature ?? "";
135
+ const abs = (p) => cwd ? join(cwd, p) : p;
136
+ const syncOut = spur(env, ["feature", "sync", feature, "--dry-run", "--json"], { cwd });
137
+ let sync;
138
+ if (syncOut.status === 0) {
139
+ try {
140
+ const parsed = JSON.parse(syncOut.stdout);
141
+ if (parsed !== null && typeof parsed === "object")
142
+ sync = parsed;
143
+ } catch {}
144
+ }
145
+ if (sync === undefined)
146
+ return "failed:preflight:sync-unreadable";
147
+ const proposal = sync.proposal;
148
+ if (proposal === null || typeof proposal !== "object" || Array.isArray(proposal) || proposal.gateBlocked !== undefined && typeof proposal.gateBlocked !== "boolean" || proposal.gateFindings !== undefined && !isFindingList(proposal.gateFindings) || proposal.hops !== undefined && (!Array.isArray(proposal.hops) || !proposal.hops.every((hop) => typeof hop === "string")) || proposal.gateBlocked !== true && (typeof proposal.from !== "string" || typeof proposal.to !== "string")) {
149
+ return "failed:preflight:sync-unreadable";
150
+ }
151
+ if (proposal.gateBlocked === true) {
152
+ return `failed:preflight:gate-blocked ${sortedUniqueCodes(proposal.gateFindings ?? []).join(",")}`;
153
+ }
154
+ const reachesDone = proposal.to === "done" || (proposal.hops ?? []).includes("done");
155
+ if (!reachesDone)
156
+ return null;
157
+ const checkOut = spur(env, ["feature", "check", feature, "--strict", "--as", "done", "--json"], { cwd });
158
+ let check;
159
+ try {
160
+ check = JSON.parse(checkOut.stdout);
161
+ } catch {
162
+ return "failed:preflight:check-unreadable";
163
+ }
164
+ const payload = Array.isArray(check) && check.length === 1 ? check[0] : check;
165
+ const findings = payload !== null && typeof payload === "object" ? payload.findings : undefined;
166
+ if (!isFindingList(findings) || checkOut.status !== 0 && (checkOut.status !== 1 || !findings.some((f) => f.severity === "error"))) {
167
+ return "failed:preflight:check-unreadable";
168
+ }
169
+ const eligible = proposal.from === "verifying" ? findings : findings.filter((f) => !String(f.code ?? "").startsWith("L4.feature-receipt-"));
170
+ const runId = env.__runId ?? "";
171
+ if (runId.length > 0) {
172
+ mkdirSync(abs(join(".spur", "run")), { recursive: true });
173
+ writeFileSync(abs(join(".spur", "run", `${runId}-wrapup-preflight.json`)), `${JSON.stringify(check)}
174
+ `);
175
+ }
176
+ const codes = sortedUniqueCodes(eligible);
177
+ return codes.length > 0 ? `failed:preflight:done-gate ${codes.join(",")}` : null;
178
+ }
121
179
  var ROUTE_REASON_TABLE = {
122
180
  fast: "fast:evidence complete+consistent",
123
181
  "": "safety:missing evidence (mode empty)",
@@ -239,8 +297,10 @@ function runMetrics(env, options = {}) {
239
297
  const frontmatter = parsed.frontmatter !== null && typeof parsed.frontmatter === "object" ? parsed.frontmatter : {};
240
298
  const featureId = String(jqPick(frontmatter.feature_id, parsed.feature_id, ""));
241
299
  const status2 = String(jqPick(frontmatter.status, parsed.status, "unknown"));
242
- const verdictPath = join(".spur", "run", `${wbs}-verdict.json`);
243
- const artifactVerdict = verdictOfArtifact(abs(verdictPath));
300
+ const durable = join(".spur", "memory", "evidence", `${wbs}-verdict.json`);
301
+ const hasDurable = existsSync(abs(durable));
302
+ const verdictPath = hasDurable ? durable : join(".spur", "run", `${wbs}-verdict.json`);
303
+ const artifactVerdict = verdictOfArtifact(abs(verdictPath)) ?? (hasDurable ? "UNKNOWN" : null);
244
304
  const trackedVerdict = verdictFromTestingSection(typeof parsed.content === "string" ? parsed.content : "");
245
305
  const verdict = artifactVerdict ?? trackedVerdict ?? "UNKNOWN";
246
306
  if (verdict === "UNKNOWN") {
@@ -410,6 +470,7 @@ export {
410
470
  runMetrics,
411
471
  runFeatureTransition,
412
472
  resolveTasks,
473
+ preflightFeature,
413
474
  main,
414
475
  jqPick,
415
476
  classifySync,
@@ -9,6 +9,7 @@
9
9
  * the shell-program caps while writing the same `.spur/run` artifacts:
10
10
  * - `<runId>-wrapup-tasks.json` normalized, deduplicated WBS capture (resolve)
11
11
  * - `<runId>-wrapup-resolve.status` `PASS`/`FAIL` (resolve)
12
+ * - `<runId>-wrapup-preflight.json` full feature-check JSON (resolve pre-flight, when `feature` is set)
12
13
  * - `<runId>-route-reason.txt` route reason (route-reason subcommand)
13
14
  * - `.spur/memory/wrapup-metrics.jsonl` one row per task (metrics)
14
15
  * - `<runId>-wrapup-metrics.status` `PASS`/`FAIL` (metrics)
@@ -30,7 +31,7 @@
30
31
  */
31
32
 
32
33
  import { spawnSync } from 'node:child_process';
33
- import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
34
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
34
35
  import { join } from 'node:path';
35
36
  import { getEnvVars } from '../lib/env';
36
37
  import { spurCommand } from '../lib/spur-bin';
@@ -174,10 +175,126 @@ export function resolveTasks(env: WrapupStepsEnv, options: WrapupStepsOptions =
174
175
  if (unresolved) {
175
176
  return writeFail(`failed:unresolved or non-completed task (see ${relTasksFile})`);
176
177
  }
178
+ if ((env.feature ?? '') !== '') {
179
+ const pf = preflightFeature(env, cwd);
180
+ if (pf) return writeFail(pf);
181
+ }
177
182
  writeFileSync(abs(relStatusFile), 'PASS\n');
178
183
  return { status: 'PASS', statusFile: relStatusFile, tasksFile: relTasksFile, exitCode: 0 };
179
184
  }
180
185
 
186
+ interface PreflightFinding {
187
+ severity: string;
188
+ code: string;
189
+ }
190
+
191
+ function isFindingList(value: unknown): value is PreflightFinding[] {
192
+ return (
193
+ Array.isArray(value) &&
194
+ value.every(
195
+ (f) =>
196
+ f !== null &&
197
+ typeof f === 'object' &&
198
+ typeof f.severity === 'string' &&
199
+ typeof f.code === 'string' &&
200
+ f.code.length > 0,
201
+ )
202
+ );
203
+ }
204
+
205
+ interface PreflightSyncPayload {
206
+ proposal?: {
207
+ gateBlocked?: boolean;
208
+ from?: string;
209
+ to?: string;
210
+ hops?: string[];
211
+ gateFindings?: PreflightFinding[];
212
+ };
213
+ }
214
+
215
+ function sortedUniqueCodes(findings: PreflightFinding[]): string[] {
216
+ const codes = findings
217
+ .filter((f) => f.severity === 'error')
218
+ .map((f) => String(f.code ?? ''))
219
+ .filter((c) => c.length > 0);
220
+ return [...new Set(codes)].sort();
221
+ }
222
+
223
+ /**
224
+ * 1033 R2 feature pre-flight for wrapup resolve — runs after the task list resolves and
225
+ * before any corpus-mutating wrapup state. Returns a `failed:preflight:*` reason on the
226
+ * first blocking condition, or null when wrapup may proceed. Never writes PASS, never
227
+ * relaxes the later feature-transition / feature-verify gates, and uses the same `spurBin`
228
+ * env as `runFeatureTransition`.
229
+ */
230
+ export function preflightFeature(env: WrapupStepsEnv, cwd?: string): string | null {
231
+ const feature = env.feature ?? '';
232
+ const abs = (p: string): string => (cwd ? join(cwd, p) : p);
233
+
234
+ const syncOut = spur(env, ['feature', 'sync', feature, '--dry-run', '--json'], { cwd });
235
+ let sync: PreflightSyncPayload | undefined;
236
+ if (syncOut.status === 0) {
237
+ try {
238
+ const parsed: unknown = JSON.parse(syncOut.stdout);
239
+ if (parsed !== null && typeof parsed === 'object') sync = parsed as PreflightSyncPayload;
240
+ } catch {
241
+ // fall through to the unreadable branch below
242
+ }
243
+ }
244
+ if (sync === undefined) return 'failed:preflight:sync-unreadable';
245
+ const proposal = sync.proposal;
246
+ if (
247
+ proposal === null ||
248
+ typeof proposal !== 'object' ||
249
+ Array.isArray(proposal) ||
250
+ (proposal.gateBlocked !== undefined && typeof proposal.gateBlocked !== 'boolean') ||
251
+ (proposal.gateFindings !== undefined && !isFindingList(proposal.gateFindings)) ||
252
+ (proposal.hops !== undefined &&
253
+ (!Array.isArray(proposal.hops) || !proposal.hops.every((hop) => typeof hop === 'string'))) ||
254
+ (proposal.gateBlocked !== true && (typeof proposal.from !== 'string' || typeof proposal.to !== 'string'))
255
+ ) {
256
+ return 'failed:preflight:sync-unreadable';
257
+ }
258
+
259
+ if (proposal.gateBlocked === true) {
260
+ return `failed:preflight:gate-blocked ${sortedUniqueCodes(proposal.gateFindings ?? []).join(',')}`;
261
+ }
262
+
263
+ const reachesDone = proposal.to === 'done' || (proposal.hops ?? []).includes('done');
264
+ if (!reachesDone) return null;
265
+
266
+ const checkOut = spur(env, ['feature', 'check', feature, '--strict', '--as', 'done', '--json'], { cwd });
267
+ let check: unknown;
268
+ try {
269
+ check = JSON.parse(checkOut.stdout);
270
+ } catch {
271
+ return 'failed:preflight:check-unreadable';
272
+ }
273
+ const payload = Array.isArray(check) && check.length === 1 ? check[0] : check;
274
+ const findings =
275
+ payload !== null && typeof payload === 'object' ? (payload as { findings?: unknown }).findings : undefined;
276
+ if (
277
+ !isFindingList(findings) ||
278
+ (checkOut.status !== 0 && (checkOut.status !== 1 || !findings.some((f) => f.severity === 'error')))
279
+ ) {
280
+ return 'failed:preflight:check-unreadable';
281
+ }
282
+ // The verifying onEnter will produce the receipt, so receipt-only errors on a feature
283
+ // that is not yet verifying would fail every wrapup forever.
284
+ const eligible =
285
+ proposal.from === 'verifying'
286
+ ? findings
287
+ : findings.filter((f) => !String(f.code ?? '').startsWith('L4.feature-receipt-'));
288
+
289
+ const runId = env.__runId ?? '';
290
+ if (runId.length > 0) {
291
+ mkdirSync(abs(join('.spur', 'run')), { recursive: true });
292
+ writeFileSync(abs(join('.spur', 'run', `${runId}-wrapup-preflight.json`)), `${JSON.stringify(check)}\n`);
293
+ }
294
+ const codes = sortedUniqueCodes(eligible);
295
+ return codes.length > 0 ? `failed:preflight:done-gate ${codes.join(',')}` : null;
296
+ }
297
+
181
298
  /**
182
299
  * 0944 R3 route-reason map — mirrors the former inline jq object one-for-one, plus the
183
300
  * `safety` entry (operator-forced doc-sync). `fast:drift-probe-clean` is NOT in the map:
@@ -362,8 +479,10 @@ export function runMetrics(env: WrapupStepsEnv, options: WrapupStepsOptions = {}
362
479
  // fast-forwards and removes the tree, taking the gitignored artifact with it — the tracked
363
480
  // `Testing` line `task record` already wrote into the task file is the durable copy. Honest
364
481
  // UNKNOWN is the last resort, and it is telemetry, never proof of completion.
365
- const verdictPath = join('.spur', 'run', `${wbs}-verdict.json`);
366
- const artifactVerdict = verdictOfArtifact(abs(verdictPath));
482
+ const durable = join('.spur', 'memory', 'evidence', `${wbs}-verdict.json`);
483
+ const hasDurable = existsSync(abs(durable));
484
+ const verdictPath = hasDurable ? durable : join('.spur', 'run', `${wbs}-verdict.json`);
485
+ const artifactVerdict = verdictOfArtifact(abs(verdictPath)) ?? (hasDurable ? 'UNKNOWN' : null);
367
486
  const trackedVerdict = verdictFromTestingSection(typeof parsed.content === 'string' ? parsed.content : '');
368
487
  const verdict = artifactVerdict ?? trackedVerdict ?? 'UNKNOWN';
369
488
  if (verdict === 'UNKNOWN') {
@@ -184,7 +184,7 @@ reproduce → isolate → minimal fix → regression guard.
184
184
 
185
185
  - Writing code without having read the immediate callers of what you're changing.
186
186
  - A `## Solution` section that lists files but not what changed or why.
187
- - A `## Solution` section with file references that are not in backtick `` `path:line` `` form (L3 requirement: `` `packages/app/src/foo.ts:123` `` or `` `packages/app/src/bar.ts:10-20` ``; paths from repo root).
187
+ - A `## Solution` section with file references that are not in backtick `` `path:line` `` form (L3 requirement: `` `packages/app/src/foo.ts:123` `` or `` `packages/app/src/bar.ts:10-20` ``; paths from repo root). Non-repo paths (external packages, `@scope/...`) can never resolve repo-relative — cite them in the frozen external-evidence form instead: `Evidence: @gobing-ai/ts-x \`src/persistence.ts\` line 108` (origin and line number OUTSIDE the backticks). Wrong: `` `@gobing-ai/ts-x/src/persistence.ts:108` `` — the L4 gate fails it as an unresolvable anchor (task 1055).
188
188
  - A new abstraction with exactly one caller and no second use in sight.
189
189
  - The diff touches files unrelated to the task's scope.
190
190
  - The diff touches another WBS's surfaces, or the pass reads sibling `todo` / `wip` task files.
@@ -249,7 +249,7 @@ Thin wrapper: `Skill(skill="sp:conflict-finding", args="$ARGUMENTS")`.
249
249
  - Silently omitting a selected pillar or a tool failure (no coverage accounting).
250
250
  - Claiming "comprehensive" when `coverage.complete` is false.
251
251
  - Any mutation in audit mode, or automatic mutation merely because `--resolve` is present.
252
- - Editing a derived projection before its authority.
252
+ - Editing a derived projection before correcting a defective authority, or changing a correct authority merely to repair its stale projection.
253
253
  - Adding a production analyzer, index/cache/database, dependency, or dedicated subagent in v1.
254
254
 
255
255
  ## Reference files
@@ -206,13 +206,15 @@ authority it governs:
206
206
  | Owner | Repair route | Governs (claim types) |
207
207
  |-------|--------------|----------------------|
208
208
  | Task/feature corpus | `sp:spur-cli` / `spur task` / `spur feature`; never direct file writes | Task obligation/status, feature goal and AC |
209
- | Numbered docs and AGENTS projections | `sp:doc-evolve`; authority first, derived projections second | Process rules, structural decision, product scope, architecture, command surface |
209
+ | Numbered docs and AGENTS projections | `sp:doc-evolve`; inspect authority first, correct it only if defective, then repair affected projections | Process rules, structural decision, product scope, architecture, command surface |
210
210
  | Source/tests | create/use a Spur task and route through `sp:spur-dev` / build competencies | Implementation behavior, architecture mechanism |
211
211
  | Command/skill capability source | Superskill command/skill lifecycle in the owning plugin source | Command/API/schema surface |
212
212
  | Ambiguous authority | **stop** for an operator decision; do not mutate either side | (all, when unresolved) |
213
213
 
214
- Never edit a derived projection before its authority. The authority column of §2 is always the
215
- owner surface's source of truth; a projection is brought into line with it, not the reverse.
214
+ Inspect the governing authority before repairing a derived projection. Correct the authority first
215
+ only when it is defective and the correction is authorized; otherwise leave it unchanged and repair
216
+ the projection. The authority column of §2 remains the owner surface's source of truth; a projection
217
+ is brought into line with it, not the reverse.
216
218
 
217
219
  ## 7. Worked examples
218
220
 
@@ -113,13 +113,13 @@ between "the audit said X" and "the file now says Y."
113
113
  Each approved repair is dispatched through the artifact's **owner surface** — the harness path that
114
114
  owns writes to that artifact — not a direct file write. Authority for a repair is the same
115
115
  authority model as the audit ([./authority-resolution.md](./authority-resolution.md)): for a given
116
- subject + claim type, one artifact is normative; derived projections are updated only after their
117
- authority.
116
+ subject + claim type, one artifact is normative. Inspect it first; correct it only if defective and
117
+ authorized, then update affected projections. A correct authority needs no edit for a stale projection.
118
118
 
119
119
  | Owner | Repair route | Never |
120
120
  |-------|--------------|-------|
121
121
  | Task/feature corpus | `sp:spur-cli` — `spur task` / `spur feature` (with `--section`, `--from-file`, `--json` as needed) | Direct file writes to task/feature files. |
122
- | Numbered docs and AGENTS projections | `sp:doc-evolve` — **authority first, derived projections second** (see below) | Editing `docs/05_FEATURES.md` or an `AGENTS.md` projection before its authoritative source is fixed. |
122
+ | Numbered docs and AGENTS projections | `sp:doc-evolve` — inspect authority first; correct it only if defective, then repair affected projections (see below) | Changing correct authority to repair a stale projection, or projecting a defective authority before its authorized correction. |
123
123
  | Source / tests | Create or use a **Spur task** and route through `sp:spur-dev` / build competencies (`sp:code-implementation`, `sp:code-testing`), **unless** the active session already has explicit implementation authority for the change. | Bare in-place edits to source/tests without a task or explicit authority. |
124
124
  | Command/skill capability source | Superskill command/skill **lifecycle** (`superskill command …` / `superskill skill …` scaffold/validate/evaluate/refine) in the owning plugin source. | Hand-editing capability source outside the Superskill lifecycle. |
125
125
  | Ambiguous authority | **Stop for an operator decision.** Do not mutate either side. | Choosing a winner, editing both sides, or forcing a global ranking. |
@@ -148,12 +148,13 @@ item; never route around it with a direct write.
148
148
 
149
149
  ### 3.2 Numbered docs and AGENTS projections
150
150
 
151
- Documentation repairs follow **authority first, derived projections second**. If a derived
151
+ Documentation repairs **inspect authority first**. If a derived
152
152
  projection (`docs/04_DESIGN.md`, `docs/05_FEATURES.md`, `AGENTS.md`, templates) has drifted from its
153
153
  authority (`docs/00_ADR.md`, `docs/01_PRD.md`, `docs/03_ARCHITECTURE.md`,
154
- `docs/99_PROJECT_CONSTITUTION.md`), the repair fixes the **authority** first, then re-derives the
155
- projection via `sp:doc-evolve`. Editing a projection without its authority is a prohibited
156
- anti-pattern (see §6).
154
+ `docs/99_PROJECT_CONSTITUTION.md`), leave a correct authority unchanged and repair the stale
155
+ projection via `sp:doc-evolve`. If the authority itself is defective, correct it first within the
156
+ approved scope, then update affected projections. Incomparable authority still requires an operator
157
+ decision; projection drift alone never authorizes changing the governing contract (see §6).
157
158
 
158
159
  `sp:doc-evolve` owns the numbered-doc/projection lifecycle: sync checks, contract verification,
159
160
  frontmatter checks, and derived-doc refresh. A doc repair is dispatched to it; it is not hand-applied.
@@ -244,7 +245,7 @@ These are the explicit boundaries for remediation. Each is a hard violation, not
244
245
  | Anti-pattern | Why it is prohibited |
245
246
  |--------------|----------------------|
246
247
  | **Automatic mutation merely because `--resolve` is present** | `--resolve` only opens the workflow; it never authorizes automatic repair. Confirmation + freshness + owner surface are all required. |
247
- | **Editing a derived projection before its authority** | A projection (`docs/04`/`docs/05`, `AGENTS.md`, templates) is not the source of truth; fixing it without its authority (`ADR`/`PRD`/constitution) leaves the real conflict in place. Authority first. |
248
+ | **Changing correct authority for projection drift** | A stale projection is repaired against its governing contract. Correct authority only when defective and authorized; then update affected projections. |
248
249
  | **Broad cleanup outside confirmed findings** | Only the artifacts named by confirmed, freshness-checked findings are touched. No opportunistic refactors or doc "improvements" while repairing. |
249
250
  | **Ambiguity silently resolved** | Ambiguous/missing authority stops for an operator decision; it is never forced through a global ranking or a guessed owner. |
250
251
  | **Partial failure silently continued** | A failed repair is reported, not papered over; siblings are not marked successful and the run does not roll forward silently. |
@@ -100,9 +100,10 @@ rg --files apps packages # real modules
100
100
  git log -1 --format='%ci' -- docs/04_DESIGN.md # last touch vs. recent surface changes
101
101
  ```
102
102
 
103
- **Repair protocol (§7):** fix the authoritative statement first, then affected detail/index/entry
104
- files. Preserve ADR numbers, original titles/dates and decision history; editorial condensation
105
- follows §6.1, while actual reversals need a superseding decision. Record findings in a task/report.
103
+ **Repair protocol (§7):** inspect the governing statement first. Correct it only if defective and
104
+ authorized, then update affected detail/index/entry files; otherwise leave the authority unchanged
105
+ and repair its stale projections. Preserve ADR numbers, original titles/dates and decision history;
106
+ editorial condensation follows §6.1, while actual reversals need a superseding decision. Record findings in a task/report.
106
107
  Routine lessons go to existing learning/context storage (§8); no automatic constitution edits.
107
108
 
108
109
  Output a **drift report**: per finding, `{ doc, what code says, what the doc says, authority, repair
@@ -91,6 +91,16 @@ Generate the code and **cite the source** (doc URL + date, or `pkg@version` + th
91
91
  point of use or in the task's notes. A claim about external behavior without a citation is
92
92
  unverified — flag it as such rather than presenting it as fact.
93
93
 
94
+ ### Step 5 — Close with a verification verdict
95
+
96
+ Before finishing, verify the citations for the API/library claims you emitted and close with one
97
+ line: `Verification: HIGH|MEDIUM|LOW — <first failing check and deviation, or 'all checks passed'>`.
98
+
99
+ - **Citation coverage** — every API/library claim in the output carries a source citation (doc URL + date, or `pkg@version` + file/symbol); no uncited claim is asserted as fact.
100
+ - **Source authority** — each citation points at the pinned version's primary source (installed signature/source, or official docs for the lockfile version), not the latest docs or memory.
101
+ - **Honest confidence** — the stated HIGH/MEDIUM/LOW matches the evidence: HIGH requires a check against the pinned source this session; MEDIUM/LOW items are labeled, and LOW is never presented as fact.
102
+ - **HIGH** — all three checks pass. **MEDIUM** — coverage intact but some citations are off-version (latest docs for a pinned dependency) or secondary-source only. **LOW** — an API/library claim lacks any citation, or a LOW-confidence claim was asserted as fact.
103
+
94
104
  ## Common Rationalizations
95
105
 
96
106
  | Rationalization | Reality |
@@ -116,6 +126,7 @@ unverified — flag it as such rather than presenting it as fact.
116
126
  - [ ] Both questions answered — the API exists **and** is called within its contract.
117
127
  - [ ] Claims about external behavior carry a citation (source + date, or `pkg@version` + symbol); unverifiable ones are flagged, not asserted.
118
128
  - [ ] Confidence is stated honestly (HIGH/MEDIUM/LOW); nothing at LOW was presented as fact.
129
+ - [ ] Run closes with the Step 5 verdict line: `Verification: HIGH|MEDIUM|LOW — <first failing check and deviation, or 'all checks passed'>`.
119
130
 
120
131
  ## See also
121
132
 
@@ -42,13 +42,16 @@ for `Plan`, `Acceptance Criteria`, hand-authored `Solution`, and any narrative s
42
42
  spur task update 0040 --section Review --from-file /tmp/review.md
43
43
  ```
44
44
 
45
- 3. The whole `### Review` body is now that file's contents. To amend rather than overwrite, read
46
- the current body (`spur task show 0040`), edit the temp file to the full desired state, and
47
- replace again — there is no append mode for ordinary sections.
45
+ 3. The whole `### Review` body is now that file's contents. To amend rather than overwrite, either
46
+ read the current body (`spur task show 0040`), edit the temp file to the full desired state, and
47
+ replace again — or pass `--append` (task 1057) to write the file's body after the existing
48
+ section content (existing content + one blank line + new body; surrounding whitespace trimmed; an empty/missing section is
49
+ created). `--append` requires `--section` and never replaces on its own.
48
50
 
49
51
  **`Q&A` is the exception.** `--section "Q&A"` APPENDS a timestamped `#### Q&A entry — <ISO>`
50
52
  block rather than replacing the section. Start the body with `<!-- qa:replace -->` to replace it
51
- wholesale.
53
+ wholesale. Explicit `--append` on `Q&A` uses the generic append (no timestamped wrapper); the
54
+ `<!-- qa:replace -->` marker still wins and forces wholesale replace.
52
55
 
53
56
  `--section` **requires** `--from-file` (exit `2` otherwise). Section names match the DD-08 headings
54
57
  exactly: `Background`, `Requirements`, `Acceptance Criteria`, `Q&A`, `Design`, `Plan`, `Solution`, `Root Cause`, `Testing`, `Review`, `References`, `History`, `Notes` (universal sections are `History`, `References`, `Notes`; `Root Cause` is carried by the `issue` template variant).
@@ -58,16 +61,20 @@ exactly: `Background`, `Requirements`, `Acceptance Criteria`, `Q&A`, `Design`, `
58
61
  `spur task record 0040 --transition testing` reads `.spur/run/0040-verdict.json`, writes
59
62
  `Testing` (per-requirement table), and backfills `Review` (P1–P4 findings table) **only when the
60
63
  section is bare** — a standalone compatibility fallback, never an overwrite of the review
61
- coordinator's authored Review — optionally backfilling a bare `Solution` from `git diff -U0`. It
62
- never transitions to `done`.
64
+ coordinator's authored Review — optionally backfilling a bare `Solution` from `git diff -U0`.
65
+ Record can also refresh its own earlier Review backfill. The `--transition testing` example stops
66
+ at testing; `--transition done` with a PASS verdict walks the forward lifecycle edges through their
67
+ guards and creates the required pipeline run-link immediately before the done hop.
63
68
 
64
69
  - **Use `record`** when a verify step produced a verdict artifact — it is the pipeline's record step
65
70
  and the deterministic `Testing` writer.
66
71
  - **Use `update --section`** when you are authoring a section by hand (planning, design, narrative
67
72
  solution) or amending one `record` already wrote.
68
73
 
69
- `record` writes `Testing` unconditionally and `Review` only when bare; a later
70
- `update --section` overwrites either (file-wins). Both go through the same file-wins atomic write.
74
+ `record` re-reads the verdict on each invocation. PASS/PARTIAL/FAIL artifacts refresh Testing;
75
+ UNKNOWN preserves authored Testing and writes a stub only when Testing is bare. Review remains
76
+ fallback-only: bare content or record's own earlier backfill can be replaced, authored Review cannot.
77
+ A later `update --section` overwrites either section (file-wins). Both use the same atomic write.
71
78
 
72
79
  ## Which section, when
73
80
 
@@ -81,7 +88,7 @@ LLM's job (orchestrated by `sp:spur-dev`); this skill only owns the *mechanism*:
81
88
  | `Plan` | before `wip` | the step list |
82
89
  | `Solution` | during impl (implement step) | the approach actually taken; L3 `file:line` rule fires once it has real content |
83
90
  | `Testing` | testing phase (`record` — deterministic writer) | what was verified and how — gated at `wip→testing` by `check` |
84
- | `Review` | review phase (`/sp:dev-review` coordinator) | merged SECU findings + verdict — gated at `testing→done` by `check --strict-core` |
91
+ | `Review` | review phase (`/sp:dev-review` coordinator) | merged SECU findings + verdict — gated at `testing→done` by `check --as done` |
85
92
 
86
93
  Section presence per status is a **runtime contract**, not a table: query
87
94
  `spur task sections <wbs> list --json` (permitted present sections) and `spur task check <wbs> --json`