@gobing-ai/spur 0.3.96 → 0.3.97

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 (87) 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 +6 -0
  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 +25 -16
  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 +2 -2
  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/quality-gate.mjs +13 -6
  18. package/plugins/sp/scripts/quality-gate.ts +1 -1
  19. package/plugins/sp/scripts/residual-scan.mjs +64 -38
  20. package/plugins/sp/scripts/residual-scan.ts +39 -34
  21. package/plugins/sp/scripts/wrapup-steps.mjs +64 -3
  22. package/plugins/sp/scripts/wrapup-steps.ts +122 -3
  23. package/plugins/sp/skills/source-driven-development/SKILL.md +11 -0
  24. package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +9 -5
  25. package/plugins/sp/skills/spur-cli/references/workflows.md +17 -14
  26. package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
  27. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +3 -1
  28. package/plugins/sp/skills/spur-dev/references/dev-operations.md +79 -5
  29. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +4 -2
  30. package/plugins/sp/skills/spur-dev/references/execution-batch.md +8 -6
  31. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +3 -3
  32. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +9 -0
  33. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +43 -12
  34. package/spur.js +4246 -2996
  35. package/web/_astro/{BoardApp.BIjMatT1.js → BoardApp.Bmv5WkJ9.js} +1 -1
  36. package/web/_astro/BoardApp.vveLOdDq.js +322 -0
  37. package/web/_astro/{TaskDetail.BZM3EAFx.js → TaskDetail.IKc3uDYV.js} +1 -1
  38. package/web/_astro/{arc.HkRiZnoI.js → arc.C63Ufg35.js} +1 -1
  39. package/web/_astro/{architectureDiagram-3BPJPVTR.BDdK-tgZ.js → architectureDiagram-3BPJPVTR.CP8Hyldk.js} +1 -1
  40. package/web/_astro/{blockDiagram-GPEHLZMM.CZ9kOBxx.js → blockDiagram-GPEHLZMM.Bo_48MRA.js} +1 -1
  41. package/web/_astro/{c4Diagram-AAUBKEIU.DXK9qeRD.js → c4Diagram-AAUBKEIU.Ch61sw-O.js} +1 -1
  42. package/web/_astro/channel.BKCmJpWM.js +1 -0
  43. package/web/_astro/{chunk-2J33WTMH.BTD2WeX5.js → chunk-2J33WTMH.CHpzS7xf.js} +1 -1
  44. package/web/_astro/{chunk-4BX2VUAB.Ci6Qbcvk.js → chunk-4BX2VUAB.DzMNNWIw.js} +1 -1
  45. package/web/_astro/{chunk-55IACEB6.yl3zsj7p.js → chunk-55IACEB6.BqPrDw_Q.js} +1 -1
  46. package/web/_astro/{chunk-727SXJPM.ClXZsfyR.js → chunk-727SXJPM.ZzBHhNw-.js} +1 -1
  47. package/web/_astro/{chunk-AQP2D5EJ.BmKWZBcP.js → chunk-AQP2D5EJ.1_O_LLkx.js} +1 -1
  48. package/web/_astro/{chunk-FMBD7UC4.By30fcb7.js → chunk-FMBD7UC4.GS4UHg7_.js} +1 -1
  49. package/web/_astro/{chunk-ND2GUHAM.24NmrD-k.js → chunk-ND2GUHAM.BnqotN4g.js} +1 -1
  50. package/web/_astro/{chunk-QZHKN3VN.BN4sKdcS.js → chunk-QZHKN3VN.Ghuh8zoJ.js} +1 -1
  51. package/web/_astro/{classDiagram-4FO5ZUOK.CJpoMPb5.js → classDiagram-4FO5ZUOK.BW9vC3zg.js} +1 -1
  52. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CJpoMPb5.js → classDiagram-v2-Q7XG4LA2.BW9vC3zg.js} +1 -1
  53. package/web/_astro/{cose-bilkent-S5V4N54A.BraxQ2Nt.js → cose-bilkent-S5V4N54A.D6jNC0ti.js} +1 -1
  54. package/web/_astro/{cynefin-OW5HDTMX.gzoU73oL.js → cynefin-OW5HDTMX.BOGoCs4L.js} +1 -1
  55. package/web/_astro/{dagre-BM42HDAG.wyDfWySU.js → dagre-BM42HDAG.8iU5VcWi.js} +1 -1
  56. package/web/_astro/{diagram-2AECGRRQ.h2_EbMxV.js → diagram-2AECGRRQ.CVt88HwH.js} +1 -1
  57. package/web/_astro/{diagram-5GNKFQAL.Db6dcs89.js → diagram-5GNKFQAL.C9HxUUMl.js} +1 -1
  58. package/web/_astro/{diagram-KO2AKTUF.NVwzxu_U.js → diagram-KO2AKTUF.4ywkQN3t.js} +1 -1
  59. package/web/_astro/{diagram-LMA3HP47.B-eNgDQk.js → diagram-LMA3HP47.C48A6gpz.js} +1 -1
  60. package/web/_astro/{diagram-OG6HWLK6.Bsa8ZRbJ.js → diagram-OG6HWLK6.DxFieQW4.js} +1 -1
  61. package/web/_astro/{erDiagram-TEJ5UH35.BospRE5Q.js → erDiagram-TEJ5UH35.CtjtQqYs.js} +1 -1
  62. package/web/_astro/{flowDiagram-I6XJVG4X.DMzvHwAA.js → flowDiagram-I6XJVG4X.C5zAiahU.js} +1 -1
  63. package/web/_astro/{ganttDiagram-6RSMTGT7.Dmdxgd-M.js → ganttDiagram-6RSMTGT7.D_L6zsny.js} +1 -1
  64. package/web/_astro/{gitGraphDiagram-PVQCEYII.CaqdO0rL.js → gitGraphDiagram-PVQCEYII.Ignnrk3V.js} +1 -1
  65. package/web/_astro/index.CbNz17Sx.css +1 -0
  66. package/web/_astro/{infoDiagram-5YYISTIA.DDVD9Wn7.js → infoDiagram-5YYISTIA.DLv8-P5u.js} +1 -1
  67. package/web/_astro/{ishikawaDiagram-YF4QCWOH.EKyCUFsL.js → ishikawaDiagram-YF4QCWOH.p0OTpwFO.js} +1 -1
  68. package/web/_astro/{journeyDiagram-JHISSGLW.By2-RkYB.js → journeyDiagram-JHISSGLW.CIaBt-DD.js} +1 -1
  69. package/web/_astro/{kanban-definition-UN3LZRKU.ar-WmlyR.js → kanban-definition-UN3LZRKU.DGySIntq.js} +1 -1
  70. package/web/_astro/{linear.BnzMBgo_.js → linear.BATV4RXu.js} +1 -1
  71. package/web/_astro/{mermaid.core.DSqeFu2Y.js → mermaid.core.DzkwM3VX.js} +4 -4
  72. package/web/_astro/{mindmap-definition-RKZ34NQL.1XcF8Wh2.js → mindmap-definition-RKZ34NQL.3piBRRzA.js} +1 -1
  73. package/web/_astro/{pieDiagram-4H26LBE5.DnItHOdK.js → pieDiagram-4H26LBE5.DUNueLub.js} +1 -1
  74. package/web/_astro/{quadrantDiagram-W4KKPZXB.BQQyrnBt.js → quadrantDiagram-W4KKPZXB.9bihVbrt.js} +1 -1
  75. package/web/_astro/{requirementDiagram-4Y6WPE33.Dmzxun82.js → requirementDiagram-4Y6WPE33.CkjsC-YT.js} +1 -1
  76. package/web/_astro/{sankeyDiagram-5OEKKPKP.BBIeRKYB.js → sankeyDiagram-5OEKKPKP.Dbq4vaoQ.js} +1 -1
  77. package/web/_astro/{sequenceDiagram-3UESZ5HK.CUNVdBBf.js → sequenceDiagram-3UESZ5HK.BivrH99P.js} +1 -1
  78. package/web/_astro/{stateDiagram-AJRCARHV.DlFf2sJm.js → stateDiagram-AJRCARHV.CqLQ1Vzz.js} +1 -1
  79. package/web/_astro/{stateDiagram-v2-BHNVJYJU.DXR7I1H5.js → stateDiagram-v2-BHNVJYJU.DvvoLD3b.js} +1 -1
  80. package/web/_astro/{timeline-definition-PNZ67QCA.BcO-GyX2.js → timeline-definition-PNZ67QCA.B-KR78gv.js} +1 -1
  81. package/web/_astro/{vennDiagram-CIIHVFJN.ChCECmmW.js → vennDiagram-CIIHVFJN.Ck0Eieb5.js} +1 -1
  82. package/web/_astro/{wardleyDiagram-YWT4CUSO.CRaT12mD.js → wardleyDiagram-YWT4CUSO.CRfdd_do.js} +1 -1
  83. package/web/_astro/{xychartDiagram-2RQKCTM6.B3lsHVpj.js → xychartDiagram-2RQKCTM6.Ck_Jxk9C.js} +1 -1
  84. package/web/index.html +2 -2
  85. package/web/_astro/BoardApp.GvjIe9Z6.js +0 -191
  86. package/web/_astro/channel.DlhX2MEt.js +0 -1
  87. 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') {
@@ -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
 
@@ -58,16 +58,20 @@ exactly: `Background`, `Requirements`, `Acceptance Criteria`, `Q&A`, `Design`, `
58
58
  `spur task record 0040 --transition testing` reads `.spur/run/0040-verdict.json`, writes
59
59
  `Testing` (per-requirement table), and backfills `Review` (P1–P4 findings table) **only when the
60
60
  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`.
61
+ coordinator's authored Review — optionally backfilling a bare `Solution` from `git diff -U0`.
62
+ Record can also refresh its own earlier Review backfill. The `--transition testing` example stops
63
+ at testing; `--transition done` with a PASS verdict walks the forward lifecycle edges through their
64
+ guards and creates the required pipeline run-link immediately before the done hop.
63
65
 
64
66
  - **Use `record`** when a verify step produced a verdict artifact — it is the pipeline's record step
65
67
  and the deterministic `Testing` writer.
66
68
  - **Use `update --section`** when you are authoring a section by hand (planning, design, narrative
67
69
  solution) or amending one `record` already wrote.
68
70
 
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.
71
+ `record` re-reads the verdict on each invocation. PASS/PARTIAL/FAIL artifacts refresh Testing;
72
+ UNKNOWN preserves authored Testing and writes a stub only when Testing is bare. Review remains
73
+ fallback-only: bare content or record's own earlier backfill can be replaced, authored Review cannot.
74
+ A later `update --section` overwrites either section (file-wins). Both use the same atomic write.
71
75
 
72
76
  ## Which section, when
73
77
 
@@ -81,7 +85,7 @@ LLM's job (orchestrated by `sp:spur-dev`); this skill only owns the *mechanism*:
81
85
  | `Plan` | before `wip` | the step list |
82
86
  | `Solution` | during impl (implement step) | the approach actually taken; L3 `file:line` rule fires once it has real content |
83
87
  | `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` |
88
+ | `Review` | review phase (`/sp:dev-review` coordinator) | merged SECU findings + verdict — gated at `testing→done` by `check --as done` |
85
89
 
86
90
  Section presence per status is a **runtime contract**, not a table: query
87
91
  `spur task sections <wbs> list --json` (permitted present sections) and `spur task check <wbs> --json`
@@ -204,7 +204,7 @@ spur workflow run ./workflows/approval.yaml --silent # errors only
204
204
  spur workflow run ./workflows/approval.yaml --verbose # transitions + correlation diagnostics
205
205
  spur workflow run ./workflows/approval.yaml --detail minimal # tersest human output
206
206
  spur workflow run ./workflows/approval.yaml --trace-file # persist redacted JSONL trace
207
- spur workflow run ./workflows/approval.yaml --no-log # opt out of the run record .spur/run/<RUNID>.md + .state.json
207
+ spur workflow run ./workflows/approval.yaml --no-log # opt out of the run record .spur/memory/runs/<RUNID>.md + .state.json
208
208
  spur workflow run ./workflows/approval.yaml --steer # interactive steering on stdin
209
209
  ```
210
210
 
@@ -214,7 +214,7 @@ spur workflow run ./workflows/approval.yaml --steer # interactive
214
214
  per-step headers), `full` (transitions + correlation). `--verbose` is shorthand for `--detail full`.
215
215
  - **`--trace-file`** appends a redacted, schema-versioned JSONL trace under `.spur/workflow/`
216
216
  for post-run analysis - independent of human/JSON output.
217
- - **`--no-log`** opts out of writing the two-file run record (`.spur/run/<RUNID>.md` + `.state.json`).
217
+ - **`--no-log`** opts out of writing the two-file run record (`.spur/memory/runs/<RUNID>.md` + `.state.json`).
218
218
  By default the record is written **and retained** after the run ends; this flag skips it entirely
219
219
  (propagates to the `--async` detached worker). No `--keep-log` / delete-by-default exists.
220
220
  - **`--steer`** is synchronous and in-process: it cannot combine with `--json` or `--async` (exit `2`).
@@ -285,7 +285,7 @@ not advertise `--json-envelope` because its JSON projection is a kept-raw docume
285
285
  | `--verbose` | Include transitions and correlation diagnostics in human progress (implies `--detail full`). |
286
286
  | `--detail <level>` | Human detail level: `minimal`, `invocation` (default), or `full`. |
287
287
  | `--trace-file` | Append a redacted schema-versioned JSONL trace under `.spur/workflow/`. |
288
- | `--no-log` | Opt out of writing the two-file run record `.spur/run/<RUNID>.md` + `.state.json` (retained by default; propagates to `--async` workers). |
288
+ | `--no-log` | Opt out of writing the two-file run record `.spur/memory/runs/<RUNID>.md` + `.state.json` (retained by default; propagates to `--async` workers). |
289
289
  | `--steer` | Accept in-process steering commands on stdin at declared action boundaries (sync only; incompatible with `--json`/`--async`). |
290
290
 
291
291
  `validate` and `run` exit non-zero on failure (`run` exits non-zero when the final status is not
@@ -302,7 +302,7 @@ Follow a live run to terminal (human streaming mode):
302
302
  ```bash
303
303
  spur workflow trace <run-id> --follow # stream until terminal (default 1000ms poll)
304
304
  spur workflow trace <run-id> --follow --poll 500 # poll every 500ms
305
- spur workflow trace <run-id> --follow --output # stream .spur/run/<RUNID>.md instead of the DB timeline
305
+ spur workflow trace <run-id> --follow --output # stream .spur/memory/runs/<RUNID>.md instead of the DB timeline
306
306
  spur workflow trace <run-id> --follow --timeout 600000 # bound the watch; timeout → one checkpoint, run continues, exit 1
307
307
  ```
308
308
 
@@ -316,7 +316,7 @@ spur workflow trace <run-id> --follow --timeout 600000 # bound the watch; timeou
316
316
  status and exits `1`. A watch timeout never cancels or relaunches the run; resume by re-running the same
317
317
  follow command.
318
318
  - **`--output`** swaps the follow source from the structured DB timeline to the human run record
319
- (`.spur/run/<RUNID>.md`, tail -f equivalent), streaming new lines as they land and exiting at
319
+ (`.spur/memory/runs/<RUNID>.md`, tail -f equivalent), streaming new lines as they land and exiting at
320
320
  terminal status. A pre-0925 run with only a legacy `<RUNID>.log` is followed in place (read-only
321
321
  fallback). It requires `--follow` and a `run-id`, is a human stream (rejects `--json`), and is
322
322
  a **distinct source** — it never interleaves with the DB timeline. If no record file appears (e.g. the
@@ -360,15 +360,18 @@ redirecting `agent.run` stages (ADR-047).
360
360
 
361
361
  - **Stale-run finalization** (existing): bulk-finalizes orphaned `running`/`pending` runs as `failed`.
362
362
  `--older-than <minutes>` (default 30) and `--force` (all non-terminal regardless of age) apply here only.
363
- - **Run-log reclamation** (0429): removes retained `.spur/run/<RUNID>.log` files whose mtime is older
364
- than `workflow.logRetentionDays` in `.spur/config.yaml` (default 30 days; integer days, not minutes).
365
- Age is the only gate; best-effort deletes never abort the rest. Never touches
366
- `.spur/workflow/<RUNID>.jsonl` or `*-partial.md`. Scope stays legacy `.log` names only (0925 R4):
367
- the two-file record (`.md` + `.state.json`) is not reclaimed until a pair retention policy exists.
368
- - **`--logs`** scopes to log reclamation only (skips stale-run finalization). `--dry-run` applies to
369
- both scopes (lists what would be removed, writes nothing). `--json` returns
370
- `{ olderThanMinutes, dryRun, cleaned, logs: { retentionDays, dryRun, reclaimed, failures } }` (with
371
- `--logs`, the reclamation object alone).
363
+ - **Run-log reclamation** (0429): removes expired `.log` files from `.spur/memory/runs/`
364
+ and legacy `.spur/run/`, using `workflow.logRetentionDays` (default 30 days). Non-terminal
365
+ runs, including paused/interrupted runs, are protected; ownership lookup failures preserve logs.
366
+ The two-file record (`.md` + `.state.json`) and partial handoffs are retained.
367
+ - **Checkpoint reclamation**: removes expired terminal checkpoints only when confinement,
368
+ metadata and run-ownership guards pass. Recoverable runs remain protected.
369
+ - **Migration** (E71): verified lasting scratch data is preserved and references redirected before
370
+ reclamation. Unsettled imports, live dependencies, malformed files and conflicts retain their sources.
371
+ - **`--logs`** scopes to log reclamation and its migration (skips stale-run finalization and
372
+ checkpoint reclamation). `--dry-run` reports without copying or deleting. `--json` returns
373
+ `{ olderThanMinutes, dryRun, cleaned, logs, checkpoints, migration }`; with `--logs`, it returns
374
+ `{ retentionDays, dryRun, reclaimed, failures, migration }`.
372
375
 
373
376
  ## Behavior
374
377
 
@@ -206,6 +206,8 @@ section adds what that table has no room for: per-file content summaries and ite
206
206
  step (glossary, config companions).** Read Step routing to find "which file for step X"; read below
207
207
  for "what's actually in file Y" or for resources that sit outside the step sequence.
208
208
 
209
+ - Job transfer: [dump/resume](references/dev-operations.md#11a-job-dump).
210
+
209
211
  - [references/glossary.md](references/glossary.md) — sp's own vocabulary: spine, competency, facade,
210
212
  corpus, gate, verdict, noun/verb, half, HITL, WBS, section-write contract — canonical term +
211
213
  Avoid list. Owns term definitions only; `cross-cutting.md` owns the process rules that use them.
@@ -135,7 +135,9 @@ workflow run` and never redirect silently to `agent.default`. Interactive **omit
135
135
  **host-controlled and non-subprocess**, but no longer guarantees host-context execution for every
136
136
  model stage (task 0508): an eligible `agent.run` stage — pure-slash input, non-interactive state,
137
137
  native subagent with shared-worktree read/write/shell capability, and a task above the
138
- `estimate_hours` dispatch floor (2026-09-15 subagent-dispatch evaluation; the floor and the
138
+ `estimate_hours` dispatch floor — a small, non-sensitive triage diffstat
139
+ (`.spur/run/<wbs>-diffstat.json`, verify only) keeps that stage host-inline (2026-09-15
140
+ subagent-dispatch evaluation; the floor and the
139
141
  resume-over-re-dispatch rule for worker-role continuation stages are owned by
140
142
  [inline-pipeline-driver.md](inline-pipeline-driver.md)) — dispatches **once** to that
141
143
  native subagent and joins before the driver continues; any pre-dispatch eligibility failure falls