@gobing-ai/spur 0.3.98 → 0.4.0

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 (120) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.global.yaml +17 -0
  3. package/config/decisions/task-pipeline.yaml +55 -0
  4. package/config/plugin-scripts.json +15 -0
  5. package/config/rules/boundary/sp-runtime-path.yaml +2 -0
  6. package/config/rules/quality/coverage-gate.yaml +5 -3
  7. package/config/rules/strict/runtime-boundaries.yaml +11 -0
  8. package/config/rules/structure/protected-files.yaml +3 -0
  9. package/config/templates/task/brainstorm.md +1 -1
  10. package/config/templates/task/feature-impl.md +1 -1
  11. package/config/templates/task/issue.md +1 -1
  12. package/config/templates/task/meta.md +1 -1
  13. package/config/templates/task/review.md +1 -1
  14. package/config/templates/task/standard.md +1 -1
  15. package/config/workflows/task-pipeline.yaml +178 -18
  16. package/package.json +9 -9
  17. package/plugins/sp/README.md +14 -1
  18. package/plugins/sp/agents/super-reviewer.md +25 -11
  19. package/plugins/sp/commands/dev-changelog.md +5 -4
  20. package/plugins/sp/commands/dev-review-session.md +12 -7
  21. package/plugins/sp/commands/dev-run.md +5 -2
  22. package/plugins/sp/commands/dev-runall.md +5 -2
  23. package/plugins/sp/hooks/agent-lifecycle.ts +155 -0
  24. package/plugins/sp/hooks/fleet-guest-stop.ts +180 -0
  25. package/plugins/sp/hooks/hooks.json +45 -1
  26. package/plugins/sp/hooks/pi/guard-extension.ts +21 -0
  27. package/plugins/sp/lib/history-anatomy.generated.mjs +5 -0
  28. package/plugins/sp/lib/idea-handoff.generated.mjs +68 -10
  29. package/plugins/sp/lib/inline-run.generated.d.mts +1 -0
  30. package/plugins/sp/lib/inline-run.generated.mjs +83 -20
  31. package/plugins/sp/lib/residual-scan.generated.d.mts +5 -0
  32. package/plugins/sp/lib/residual-scan.generated.mjs +45 -5
  33. package/plugins/sp/lib/transcript.ts +160 -0
  34. package/plugins/sp/plugin.json +1 -1
  35. package/plugins/sp/scripts/history-anatomy-cache.mjs +5 -0
  36. package/plugins/sp/scripts/inline-run-setup.mjs +21 -20
  37. package/plugins/sp/scripts/inline-run-setup.ts +25 -31
  38. package/plugins/sp/scripts/persist-out-check.mjs +164 -0
  39. package/plugins/sp/scripts/persist-out-check.ts +185 -0
  40. package/plugins/sp/scripts/residual-scan.mjs +52 -15
  41. package/plugins/sp/scripts/residual-scan.ts +11 -11
  42. package/plugins/sp/scripts/run-summary.mjs +286 -0
  43. package/plugins/sp/scripts/run-summary.ts +241 -0
  44. package/plugins/sp/scripts/session-timeline.mjs +234 -0
  45. package/plugins/sp/scripts/session-timeline.ts +152 -0
  46. package/plugins/sp/scripts/task-diffstat.mjs +17 -5
  47. package/plugins/sp/scripts/task-diffstat.ts +25 -4
  48. package/plugins/sp/skills/code-verification/SKILL.md +8 -0
  49. package/plugins/sp/skills/code-verification/references/verdict-schema.md +17 -1
  50. package/plugins/sp/skills/fleet-join/SKILL.md +63 -0
  51. package/plugins/sp/skills/session-review/SKILL.md +46 -24
  52. package/plugins/sp/skills/spur-cli/SKILL.md +1 -0
  53. package/plugins/sp/skills/spur-cli/references/agent.md +108 -6
  54. package/plugins/sp/skills/spur-cli/references/decision.md +97 -0
  55. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +22 -0
  56. package/plugins/sp/skills/spur-composer/SKILL.md +27 -1
  57. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +40 -7
  58. package/plugins/sp/skills/spur-dev/references/dev-operations.md +28 -16
  59. package/plugins/sp/skills/spur-dev/references/execution-batch.md +278 -42
  60. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +17 -1
  61. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +16 -4
  62. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +139 -10
  63. package/plugins/sp/skills/spur-dev/references/planning-workflow.md +6 -0
  64. package/schemas/decision-catalog.schema.json +226 -0
  65. package/schemas/spur-config.schema.json +25 -0
  66. package/spur.js +8856 -2564
  67. package/web/_astro/BoardApp.Bzr4OXrW.js +324 -0
  68. package/web/_astro/{BoardApp.Dflq5a8x.js → BoardApp.VeoxxLBy.js} +1 -1
  69. package/web/_astro/{TaskDetail.CK7oXddP.js → TaskDetail.LAyR_Pvn.js} +1 -1
  70. package/web/_astro/{arc.BIqkDATc.js → arc.f4e0OYLg.js} +1 -1
  71. package/web/_astro/{architectureDiagram-3BPJPVTR.D0B3SvWv.js → architectureDiagram-3BPJPVTR.SSIvxi-Z.js} +1 -1
  72. package/web/_astro/{blockDiagram-GPEHLZMM.C2YyKQmL.js → blockDiagram-GPEHLZMM.DSXIHOFf.js} +1 -1
  73. package/web/_astro/{c4Diagram-AAUBKEIU.DmtTYrt_.js → c4Diagram-AAUBKEIU.DG9Ubu85.js} +1 -1
  74. package/web/_astro/channel.CUFdtWhm.js +1 -0
  75. package/web/_astro/{chunk-2J33WTMH.HsRXbh7l.js → chunk-2J33WTMH.C7QU88Qh.js} +1 -1
  76. package/web/_astro/{chunk-4BX2VUAB.obE_P-R3.js → chunk-4BX2VUAB.26Z94xBj.js} +1 -1
  77. package/web/_astro/{chunk-55IACEB6.CjWTF5mz.js → chunk-55IACEB6.DYRiBkqw.js} +1 -1
  78. package/web/_astro/{chunk-727SXJPM.DMo799Ax.js → chunk-727SXJPM.D5Vfv5pe.js} +1 -1
  79. package/web/_astro/{chunk-AQP2D5EJ.-cE6JOKZ.js → chunk-AQP2D5EJ.DXHV47bN.js} +1 -1
  80. package/web/_astro/{chunk-FMBD7UC4.D6Rw6HIk.js → chunk-FMBD7UC4.D1suAfOs.js} +1 -1
  81. package/web/_astro/{chunk-ND2GUHAM.d5rBD73Z.js → chunk-ND2GUHAM.D6BmdHEY.js} +1 -1
  82. package/web/_astro/{chunk-QZHKN3VN.r3ox40Q3.js → chunk-QZHKN3VN.rnPU-Ak-.js} +1 -1
  83. package/web/_astro/{classDiagram-4FO5ZUOK.CGGbvjjJ.js → classDiagram-4FO5ZUOK.CvEfXIo1.js} +1 -1
  84. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CGGbvjjJ.js → classDiagram-v2-Q7XG4LA2.CvEfXIo1.js} +1 -1
  85. package/web/_astro/{cose-bilkent-S5V4N54A.CmKtPd4l.js → cose-bilkent-S5V4N54A.DkMisLSm.js} +1 -1
  86. package/web/_astro/{cynefin-OW5HDTMX.QNg5GXs8.js → cynefin-OW5HDTMX.C77Q-pF6.js} +1 -1
  87. package/web/_astro/{dagre-BM42HDAG.B1SabsjC.js → dagre-BM42HDAG.YaV8ce27.js} +1 -1
  88. package/web/_astro/{diagram-2AECGRRQ.RtDbNj89.js → diagram-2AECGRRQ.CUyGEKge.js} +1 -1
  89. package/web/_astro/{diagram-5GNKFQAL.IzZpuM-Y.js → diagram-5GNKFQAL.CRgd8dgj.js} +1 -1
  90. package/web/_astro/{diagram-KO2AKTUF.DPTF_fgv.js → diagram-KO2AKTUF.Bmzys9hq.js} +1 -1
  91. package/web/_astro/{diagram-LMA3HP47.CCyza39P.js → diagram-LMA3HP47.BvmJY4Lw.js} +1 -1
  92. package/web/_astro/{diagram-OG6HWLK6.BXAi8zcZ.js → diagram-OG6HWLK6.0hEhD30d.js} +1 -1
  93. package/web/_astro/{erDiagram-TEJ5UH35.DUDX7_MR.js → erDiagram-TEJ5UH35.Bjt8lrnm.js} +1 -1
  94. package/web/_astro/{flowDiagram-I6XJVG4X.C63QTh3N.js → flowDiagram-I6XJVG4X.CJZTjRDV.js} +1 -1
  95. package/web/_astro/{ganttDiagram-6RSMTGT7.CnlZJSwG.js → ganttDiagram-6RSMTGT7._VqoVafc.js} +1 -1
  96. package/web/_astro/{gitGraphDiagram-PVQCEYII.CTqIrOPC.js → gitGraphDiagram-PVQCEYII.CEDb7tVo.js} +1 -1
  97. package/web/_astro/index.Dg_3bOaJ.css +1 -0
  98. package/web/_astro/{infoDiagram-5YYISTIA.BYMiDFh9.js → infoDiagram-5YYISTIA.CgwifPpK.js} +1 -1
  99. package/web/_astro/{ishikawaDiagram-YF4QCWOH.CRiQmI-z.js → ishikawaDiagram-YF4QCWOH.B99wx2J1.js} +1 -1
  100. package/web/_astro/{journeyDiagram-JHISSGLW.YQgWBsEU.js → journeyDiagram-JHISSGLW.2hRwJbEo.js} +1 -1
  101. package/web/_astro/{kanban-definition-UN3LZRKU.1ECnrrK1.js → kanban-definition-UN3LZRKU.BahJkKNC.js} +1 -1
  102. package/web/_astro/{linear.DzVA71yT.js → linear.DguUUe6V.js} +1 -1
  103. package/web/_astro/{mermaid.core.BK7P2Sr_.js → mermaid.core.CvgfmqVr.js} +4 -4
  104. package/web/_astro/{mindmap-definition-RKZ34NQL.BvXyWZK7.js → mindmap-definition-RKZ34NQL.B3qEA3x9.js} +1 -1
  105. package/web/_astro/{pieDiagram-4H26LBE5.BiXk1TW3.js → pieDiagram-4H26LBE5.FlWA-_ru.js} +1 -1
  106. package/web/_astro/{quadrantDiagram-W4KKPZXB.krvBH8lh.js → quadrantDiagram-W4KKPZXB.9bVn35X7.js} +1 -1
  107. package/web/_astro/{requirementDiagram-4Y6WPE33.ih1gL25c.js → requirementDiagram-4Y6WPE33.ChykIhct.js} +1 -1
  108. package/web/_astro/{sankeyDiagram-5OEKKPKP.CpUxkv4u.js → sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js} +1 -1
  109. package/web/_astro/{sequenceDiagram-3UESZ5HK.clAfeN7y.js → sequenceDiagram-3UESZ5HK.J7JsHWDt.js} +1 -1
  110. package/web/_astro/{stateDiagram-AJRCARHV.DpXWb8-m.js → stateDiagram-AJRCARHV.DAPbiCSS.js} +1 -1
  111. package/web/_astro/{stateDiagram-v2-BHNVJYJU.ClZb9GIH.js → stateDiagram-v2-BHNVJYJU.D2jvSi0q.js} +1 -1
  112. package/web/_astro/{timeline-definition-PNZ67QCA.BLcX5dh7.js → timeline-definition-PNZ67QCA.psW2ci29.js} +1 -1
  113. package/web/_astro/{vennDiagram-CIIHVFJN.oGgpKMpx.js → vennDiagram-CIIHVFJN.DlILYVmg.js} +1 -1
  114. package/web/_astro/{wardleyDiagram-YWT4CUSO.BPHtMtt-.js → wardleyDiagram-YWT4CUSO.xNWwLJnn.js} +1 -1
  115. package/web/_astro/{xychartDiagram-2RQKCTM6.BNZOPYSk.js → xychartDiagram-2RQKCTM6.EU_BuH7V.js} +1 -1
  116. package/web/board-runtime.json +1 -1
  117. package/web/index.html +2 -2
  118. package/web/_astro/BoardApp.CYbe-OTb.js +0 -322
  119. package/web/_astro/channel.JuKVyuhZ.js +0 -1
  120. package/web/_astro/index.CbNz17Sx.css +0 -1
@@ -61,3 +61,8 @@ export declare function foldVerdict(
61
61
  maxFindings?: number,
62
62
  ): FoldResult;
63
63
  export declare function renderReport(wbs: string, items: ResidualItem[], attemptCount: number): string;
64
+ export declare function verdictDisagreementNote(
65
+ runDir: string,
66
+ wbs: string,
67
+ fs: Pick<typeof import('node:fs'), 'existsSync' | 'readFileSync' | 'statSync'>,
68
+ ): string | null;
@@ -3,7 +3,8 @@ import { join } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  var MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
5
5
  var PRIORITY_PATTERN = /^P[1-4]/;
6
- var NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|—)\s*(\(.*\))?\.?$/i;
6
+ var RANGE_PRIORITY = /^P[1-4]\s*[\u2013\u2014-]\s*P?[1-4]/;
7
+ var NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|\u2014)\s*(\(.*\))?\.?$/i;
7
8
  var DISPOSITION_HEADER = /^(Disposition|Action|Status|Resolution|Fixed)$/i;
8
9
  var RESOLVED_DISPOSITION = /^(FIXED|RESOLVED|DONE)\b/i;
9
10
  var DEFERRED_DISPOSITION = /^DEFER(RED)?\b/i;
@@ -60,7 +61,7 @@ function parseReviewFindings(taskContent) {
60
61
  const priority = (cells[priorityCol] ?? "").trim();
61
62
  const finding = (cells[findingCol] ?? "").trim();
62
63
  const disposition = dispositionCol === -1 ? "" : (cells[dispositionCol] ?? "").trim();
63
- if (PRIORITY_PATTERN.test(priority) && !NONE_FINDING.test(finding) && finding.length > 0 && !RESOLVED_DISPOSITION.test(disposition)) {
64
+ if (PRIORITY_PATTERN.test(priority) && !RANGE_PRIORITY.test(priority) && !NONE_FINDING.test(finding) && finding.length > 0 && !RESOLVED_DISPOSITION.test(disposition)) {
64
65
  const location = locationOf(cells[locationCol] ?? "", finding);
65
66
  out.push(DEFERRED_DISPOSITION.test(disposition) ? { priority, location, text: finding, deferral: disposition } : { priority, location, text: finding });
66
67
  }
@@ -70,7 +71,7 @@ function parseReviewFindings(taskContent) {
70
71
  return out;
71
72
  }
72
73
  function splitRow(line) {
73
- return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
74
+ return line.trim().replace(/^\|/, "").replace(/\|$/, "").split(/(?<!\\)\|/).map((c) => c.trim());
74
75
  }
75
76
  function parseDiffMarkers(addedLines) {
76
77
  return addedLines.filter((l) => !EXCLUDED_PATHS.some((p) => l.file.startsWith(p))).filter((l) => !l.text.includes(ALLOW_PRAGMA)).filter((l) => MARKER_PATTERN.test(l.text)).map((l) => ({ location: `${l.file}:${l.line}`, text: l.text.trim() }));
@@ -198,7 +199,8 @@ function renderReport(wbs, items, attemptCount) {
198
199
  function recordedVerdictPath(runDir, wbs, fs) {
199
200
  const evidence = join(runDir, "..", "memory", "evidence");
200
201
  const durable = join(evidence, `${wbs}-verdict.json`);
201
- for (const path of [join(runDir, ".."), join(evidence, ".."), evidence, durable]) {
202
+ const run = join(runDir, `${wbs}-verdict.json`);
203
+ for (const path of [join(runDir, ".."), join(evidence, ".."), evidence, durable, run]) {
202
204
  try {
203
205
  if (fs.lstatSync(path).isSymbolicLink())
204
206
  throw new Error(`residual-scan: symlink evidence path: ${path}`);
@@ -207,9 +209,47 @@ function recordedVerdictPath(runDir, wbs, fs) {
207
209
  throw error;
208
210
  }
209
211
  }
210
- return fs.existsSync(durable) ? durable : join(runDir, `${wbs}-verdict.json`);
212
+ if (!fs.existsSync(durable))
213
+ return run;
214
+ if (!fs.existsSync(run))
215
+ return durable;
216
+ const newer = (a, b) => mtimeOf(fs, b) > mtimeOf(fs, a) ? b : a;
217
+ return newer(run, durable);
218
+ }
219
+ function mtimeOf(fs, path) {
220
+ try {
221
+ return fs.statSync(path).mtimeMs;
222
+ } catch {
223
+ return 0;
224
+ }
225
+ }
226
+ function verdictDisagreementNote(runDir, wbs, fs) {
227
+ const run = join(runDir, `${wbs}-verdict.json`);
228
+ const durable = join(runDir, "..", "memory", "evidence", `${wbs}-verdict.json`);
229
+ if (!fs.existsSync(run) || !fs.existsSync(durable))
230
+ return null;
231
+ const bytes = (path) => {
232
+ try {
233
+ return fs.statSync(path).isFile() ? fs.readFileSync(path, "utf8") : null;
234
+ } catch {
235
+ return null;
236
+ }
237
+ };
238
+ const runBytes = bytes(run);
239
+ if (runBytes === null || runBytes === bytes(durable))
240
+ return null;
241
+ const value = (path) => {
242
+ try {
243
+ return JSON.parse(bytes(path) ?? "{}").verdict ?? "?";
244
+ } catch {
245
+ return "?";
246
+ }
247
+ };
248
+ const winner = mtimeOf(fs, durable) > mtimeOf(fs, run) ? "durable" : "run";
249
+ return `residual-fold: ${wbs} verdict copies disagree — run=${run} (${value(run)}) durable=${durable} (${value(durable)})` + ` → chose ${winner} (newer mtime)`;
211
250
  }
212
251
  export {
252
+ verdictDisagreementNote,
213
253
  scanResiduals,
214
254
  renderReport,
215
255
  recordedVerdictPath,
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Host-transcript measurement primitives shared by session-timeline (session review) and
3
+ * run-summary (dev-run / dev-runall execution summary). Reads Claude Code JSONL rows read-only;
4
+ * tokens are counted once per message id (a response spans several records). Node builtins only.
5
+ */
6
+ import { existsSync, readdirSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ export type Tokens = Record<'input' | 'cacheCreate' | 'cacheRead' | 'output', number>;
11
+
12
+ export interface Span {
13
+ workMs: number;
14
+ waitMs: number;
15
+ toolCalls: number;
16
+ tokens: Tokens;
17
+ }
18
+
19
+ type Block = Partial<Record<'type' | 'id' | 'name' | 'text' | 'tool_use_id', string>>;
20
+
21
+ export interface Row {
22
+ type?: string;
23
+ timestamp?: string;
24
+ isMeta?: boolean;
25
+ isCompactSummary?: boolean;
26
+ message?: { id?: string; content?: unknown; usage?: Record<string, number | undefined> };
27
+ }
28
+
29
+ /** Running measurement of one segment or window. */
30
+ export interface Acc {
31
+ last: number;
32
+ askMs: number;
33
+ asks: Map<string, number>;
34
+ toolIds: Set<string>;
35
+ messages: Map<string, Tokens>;
36
+ }
37
+
38
+ const SESSION_ID = /^[A-Za-z0-9_-]+$/;
39
+ /** Host tools whose call→result gap is the operator answering, not the agent working. */
40
+ const OPERATOR_TOOLS = new Set(['AskUserQuestion']);
41
+
42
+ export const zeroTokens = (): Tokens => ({ input: 0, cacheCreate: 0, cacheRead: 0, output: 0 });
43
+
44
+ export function formatDuration(ms: number): string {
45
+ const s = Math.round(ms / 1000);
46
+ const h = Math.floor(s / 3600);
47
+ const m = Math.floor((s % 3600) / 60);
48
+ const ss = String(s % 60).padStart(2, '0');
49
+ return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
50
+ }
51
+
52
+ /** Compact token units: `950`, `41k`, `28.4M`. */
53
+ export function formatTokens(n: number): string {
54
+ if (n >= 999_500) return `${(n / 1e6).toFixed(1)}M`;
55
+ return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
56
+ }
57
+
58
+ /**
59
+ * `<total> / <non-cached>`: total = every input class + output; non-cached drops cache reads, the
60
+ * share re-served from the prompt cache, leaving the fresh input, cache writes and output.
61
+ */
62
+ export function formatTokenSplit(t: Tokens | null): string {
63
+ if (!t) return 'n/a';
64
+ const nonCached = t.input + t.cacheCreate + t.output;
65
+ return `${formatTokens(nonCached + t.cacheRead)} / ${formatTokens(nonCached)}`;
66
+ }
67
+
68
+ /** Operator prompt text, or undefined for tool results, meta rows and compaction summaries. */
69
+ export function promptText(row: Row): string | undefined {
70
+ if (row.type !== 'user' || row.isMeta || row.isCompactSummary) return undefined;
71
+ const content = row.message?.content;
72
+ if (typeof content === 'string') return content;
73
+ if (!Array.isArray(content) || (content as Block[]).some((b) => b.type === 'tool_result')) return undefined;
74
+ return (content as Block[]).find((b) => b.type === 'text')?.text;
75
+ }
76
+
77
+ export function sumTokens(all: Tokens[]): Tokens {
78
+ const total = zeroTokens();
79
+ for (const t of all) for (const k of Object.keys(total) as (keyof Tokens)[]) total[k] += t[k];
80
+ return total;
81
+ }
82
+
83
+ export function sumSpans(spans: Span[]): Span {
84
+ return {
85
+ workMs: spans.reduce((n, s) => n + s.workMs, 0),
86
+ waitMs: spans.reduce((n, s) => n + s.waitMs, 0),
87
+ toolCalls: spans.reduce((n, s) => n + s.toolCalls, 0),
88
+ tokens: sumTokens(spans.map((s) => s.tokens)),
89
+ };
90
+ }
91
+
92
+ export const newAcc = (ts: number): Acc => ({
93
+ last: ts,
94
+ askMs: 0,
95
+ asks: new Map(),
96
+ toolIds: new Set(),
97
+ messages: new Map(),
98
+ });
99
+
100
+ /** Fold one timestamped non-prompt record into a segment or window. */
101
+ export function accumulate(acc: Acc, row: Row, ts: number): void {
102
+ acc.last = Math.max(acc.last, ts);
103
+ const blocks = Array.isArray(row.message?.content) ? (row.message.content as Block[]) : [];
104
+ for (const b of blocks) {
105
+ if (b.type === 'tool_use' && b.id) {
106
+ acc.toolIds.add(b.id);
107
+ if (b.name && OPERATOR_TOOLS.has(b.name)) acc.asks.set(b.id, ts);
108
+ } else if (b.type === 'tool_result' && b.tool_use_id && acc.asks.has(b.tool_use_id)) {
109
+ acc.askMs += ts - (acc.asks.get(b.tool_use_id) ?? ts);
110
+ }
111
+ }
112
+ const u = row.message?.usage;
113
+ // Split records of one response repeat its usage: keyed by message id, overwrite, never add.
114
+ if (row.type === 'assistant' && u && row.message?.id) {
115
+ acc.messages.set(row.message.id, {
116
+ input: u.input_tokens ?? 0,
117
+ cacheCreate: u.cache_creation_input_tokens ?? 0,
118
+ cacheRead: u.cache_read_input_tokens ?? 0,
119
+ output: u.output_tokens ?? 0,
120
+ });
121
+ }
122
+ }
123
+
124
+ /** Timestamped rows in file order; malformed lines counted, timestamp-less rows dropped. */
125
+ export function parseRows(lines: string[]): { rows: [Row, number][]; skippedLines: number } {
126
+ const rows: [Row, number][] = [];
127
+ let skippedLines = 0;
128
+ for (const line of lines) {
129
+ if (!line.trim()) continue;
130
+ let row: Row;
131
+ try {
132
+ row = JSON.parse(line) as Row;
133
+ } catch {
134
+ skippedLines++;
135
+ continue;
136
+ }
137
+ const ts = row.timestamp ? Date.parse(row.timestamp) : Number.NaN;
138
+ if (!Number.isNaN(ts)) rows.push([row, ts]);
139
+ }
140
+ return { rows, skippedLines };
141
+ }
142
+
143
+ export type Resolved = { ok: true; path: string } | { ok: false; reason: string };
144
+
145
+ export function resolveTranscript(
146
+ env: Record<string, string | undefined>,
147
+ projectsRoot = join(homedir(), '.claude', 'projects'),
148
+ override?: string,
149
+ ): Resolved {
150
+ if (override) return existsSync(override) ? { ok: true, path: override } : { ok: false, reason: 'no transcript' };
151
+ const id = env.CLAUDE_CODE_SESSION_ID;
152
+ if (!id) return { ok: false, reason: 'no host session id (CLAUDE_CODE_SESSION_ID); pass --transcript <path>' };
153
+ if (!SESSION_ID.test(id)) return { ok: false, reason: 'refusing a session id with path characters' };
154
+ if (!existsSync(projectsRoot)) return { ok: false, reason: `no transcript root ${projectsRoot}` };
155
+ for (const dir of readdirSync(projectsRoot)) {
156
+ const path = join(projectsRoot, dir, `${id}.jsonl`);
157
+ if (existsSync(path)) return { ok: true, path };
158
+ }
159
+ return { ok: false, reason: `no transcript for session ${id}` };
160
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.3.98",
3
+ "version": "0.4.0",
4
4
  "description": "Spur — a local-first harness engineering toolkit that wraps mainstream coding agents with constraint checking, workflow orchestration, and history analytics.",
5
5
  "extensions": {
6
6
  "pi": ["./hooks/pi/guard-extension.ts"]
@@ -264,6 +264,7 @@ var FINDING_CATEGORIES = [
264
264
  "telemetry",
265
265
  "positive"
266
266
  ];
267
+ var FINDING_CONFIDENCES = ["high", "medium", "low"];
267
268
  function checkReportStructure(reportMarkdown) {
268
269
  const problems = [];
269
270
  if (/TODO|PLACEHOLDER|FIXME|^\|\s*\|/im.test(reportMarkdown))
@@ -308,6 +309,10 @@ function checkReportStructure(reportMarkdown) {
308
309
  if (!/(^|[\s`])P[123]([\s`.]|$)/.test(block) && !block.includes("symbolic-severity")) {
309
310
  problems.push("finding-invalid-severity");
310
311
  }
312
+ const confValue = block.match(/`confidence`\s*[:=]\s*`?([^`\n]*?)`?\s*(?:\n|$)/)?.[1]?.trim();
313
+ if (confValue !== undefined && confValue !== "" && !FINDING_CONFIDENCES.includes(confValue.toLowerCase())) {
314
+ problems.push(`finding-invalid-confidence:${confValue}`);
315
+ }
311
316
  const catValue = block.match(/`category`\s*[:=]\s*`?([^`\n]+?)`?\s*(?:\n|$)/)?.[1];
312
317
  if (catValue && !FINDING_CATEGORIES.includes(catValue.trim())) {
313
318
  problems.push(`finding-invalid-category:${catValue.trim()}`);
@@ -18,7 +18,7 @@ function usage() {
18
18
  const text = [
19
19
  "Usage: bun plugins/sp/scripts/inline-run-setup.ts --run-id <id> --file <definition> [--spur-bin <path>]",
20
20
  " bun plugins/sp/scripts/inline-run-setup.ts --fingerprint --task-file <path> [--feature-file <path>] [--spur-bin <path>]",
21
- " bun plugins/sp/scripts/inline-run-setup.ts --action --run-id <id> --node <state> --kind <kind> --status <done|failed> --ok <true|false> --duration-ms <n> [--spur-bin <path>]",
21
+ " bun plugins/sp/scripts/inline-run-setup.ts --action --run-id <id> --node <state> --kind <kind> --status <done|failed> --ok <true|false> --duration-ms <n> [--estimated] [--spur-bin <path>]",
22
22
  " bun plugins/sp/scripts/inline-run-setup.ts --actions-file <json-file> --run-id <id> [--spur-bin <path>] (1007 R5 batch trace emission)",
23
23
  " bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--reason <terminal-reason>] [--spur-bin <path>]",
24
24
  " bun plugins/sp/scripts/inline-run-setup.ts --persist-out --from <worktree-path> [--task-file <path>]... [--spur-bin <path>]",
@@ -55,22 +55,6 @@ function resolveAppEntry(spurBin) {
55
55
  }
56
56
  return { entry, portable: true };
57
57
  }
58
- async function readInstalledInventory(file, spurBin) {
59
- const localCli = fileURLToPath(new URL("../../../apps/cli/src/index.ts", import.meta.url));
60
- const bundle = fileURLToPath(new URL("../lib/inline-run.generated.mjs", import.meta.url));
61
- const { splitLaunchCommand } = await import(bundle);
62
- const launch = spurBin ? splitLaunchCommand(spurBin, 'inline-run-setup "spurBin"') : existsSync(localCli) ? { command: "bun", leadingArgs: [localCli] } : { command: "spur", leadingArgs: [] };
63
- if ("error" in launch)
64
- throw new Error(launch.error);
65
- const result = spawnSync(launch.command, [...launch.leadingArgs, "workflow", "show", file, "--format", "todo", "--json"], { cwd: process.cwd(), encoding: "utf8", timeout: 30000, maxBuffer: 4194304 });
66
- if (result.status !== 0) {
67
- throw new Error(`could not resolve the workflow definition with the installed CLI: ${result.error?.message ?? result.stderr}`);
68
- }
69
- const value = JSON.parse(result.stdout);
70
- if (value && typeof value === "object" && "ok" in value && "data" in value && value.ok === true)
71
- return value.data;
72
- return value;
73
- }
74
58
  var TERMINAL_REASONS = new Set([
75
59
  "done",
76
60
  "paused-operator",
@@ -92,7 +76,7 @@ async function main() {
92
76
  process.exit(child.status ?? 1);
93
77
  }
94
78
  const flags = new Map;
95
- let fingerprint = false, action = false, close = false, decide = false, persistOut = false;
79
+ let fingerprint = false, action = false, close = false, decide = false, persistOut = false, estimated = false;
96
80
  const taskFiles = [];
97
81
  let spurBin = getEnvVar("SPUR_BIN") ?? "";
98
82
  const argv = process.argv.slice(2);
@@ -108,6 +92,8 @@ async function main() {
108
92
  decide = true;
109
93
  else if (flag === "--persist-out")
110
94
  persistOut = true;
95
+ else if (flag === "--estimated")
96
+ estimated = true;
111
97
  else if (flag === "--task-file")
112
98
  taskFiles.push(argv[++i] ?? "");
113
99
  else if (flag === "--spur-bin")
@@ -127,6 +113,8 @@ async function main() {
127
113
  const okRaw = flags.get("--ok") ?? "";
128
114
  const durationRaw = flags.get("--duration-ms") ?? "";
129
115
  const actionsFile = flags.get("--actions-file") ?? "";
116
+ if (estimated && !action)
117
+ usage();
130
118
  if (fingerprint) {
131
119
  if (runId !== "" || file !== "" || taskFiles.length !== 1 || (taskFiles[0] ?? "").trim() === "")
132
120
  usage();
@@ -207,7 +195,16 @@ async function main() {
207
195
  const durationMs = Number(durationRaw);
208
196
  if (durationRaw.trim() === "" || !Number.isFinite(durationMs) || durationMs < 0)
209
197
  usage();
210
- process.exit(await app2.runInlineRunTrace({ runId, close: false, node, kind, status, ok: okRaw === "true", durationMs }));
198
+ process.exit(await app2.runInlineRunTrace({
199
+ runId,
200
+ close: false,
201
+ node,
202
+ kind,
203
+ status,
204
+ ok: okRaw === "true",
205
+ durationMs,
206
+ ...estimated ? { estimated: true } : {}
207
+ }));
211
208
  }
212
209
  if (runId.trim() === "" || file.trim() === "")
213
210
  usage();
@@ -217,7 +214,11 @@ async function main() {
217
214
  const app = await import(entry);
218
215
  let inventory;
219
216
  try {
220
- inventory = await readInstalledInventory(file, spurBin);
217
+ inventory = await app.readInstalledInventory({
218
+ file,
219
+ spurBin,
220
+ localCli: fileURLToPath(new URL("../../../apps/cli/src/index.ts", import.meta.url))
221
+ });
221
222
  } catch (error) {
222
223
  const message = `could not resolve the workflow definition: ${error instanceof Error ? error.message : String(error)}`;
223
224
  app.writeInlineRunOutcome(runId, { ok: false, runId, error: message });
@@ -18,7 +18,7 @@ function usage(): never {
18
18
  const text = [
19
19
  'Usage: bun plugins/sp/scripts/inline-run-setup.ts --run-id <id> --file <definition> [--spur-bin <path>]',
20
20
  ' bun plugins/sp/scripts/inline-run-setup.ts --fingerprint --task-file <path> [--feature-file <path>] [--spur-bin <path>]',
21
- ' bun plugins/sp/scripts/inline-run-setup.ts --action --run-id <id> --node <state> --kind <kind> --status <done|failed> --ok <true|false> --duration-ms <n> [--spur-bin <path>]',
21
+ ' bun plugins/sp/scripts/inline-run-setup.ts --action --run-id <id> --node <state> --kind <kind> --status <done|failed> --ok <true|false> --duration-ms <n> [--estimated] [--spur-bin <path>]',
22
22
  ' bun plugins/sp/scripts/inline-run-setup.ts --actions-file <json-file> --run-id <id> [--spur-bin <path>] (1007 R5 batch trace emission)',
23
23
  ' bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--reason <terminal-reason>] [--spur-bin <path>]',
24
24
  ' bun plugins/sp/scripts/inline-run-setup.ts --persist-out --from <worktree-path> [--task-file <path>]... [--spur-bin <path>]',
@@ -61,33 +61,6 @@ function resolveAppEntry(spurBin: string): { entry: string; portable: boolean }
61
61
  return { entry, portable: true };
62
62
  }
63
63
 
64
- /** Let the selected CLI own config and layer resolution, then revalidate its snapshot in the app. */
65
- async function readInstalledInventory(file: string, spurBin: string): Promise<unknown> {
66
- const localCli = fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url));
67
- const bundle = fileURLToPath(new URL('../lib/inline-run.generated.mjs', import.meta.url));
68
- const { splitLaunchCommand } = (await import(bundle)) as typeof import('../lib/inline-run.generated.mjs');
69
- const launch = spurBin
70
- ? splitLaunchCommand(spurBin, 'inline-run-setup "spurBin"')
71
- : existsSync(localCli)
72
- ? { command: 'bun', leadingArgs: [localCli] }
73
- : { command: 'spur', leadingArgs: [] };
74
- if ('error' in launch) throw new Error(launch.error);
75
- const result = spawnSync(
76
- launch.command,
77
- [...launch.leadingArgs, 'workflow', 'show', file, '--format', 'todo', '--json'],
78
- { cwd: process.cwd(), encoding: 'utf8', timeout: 30_000, maxBuffer: 4 * 1024 * 1024 },
79
- );
80
- if (result.status !== 0) {
81
- throw new Error(
82
- `could not resolve the workflow definition with the installed CLI: ${result.error?.message ?? result.stderr}`,
83
- );
84
- }
85
- const value: unknown = JSON.parse(result.stdout);
86
- // Honor the existing optional JSON envelope without inventing another projection format.
87
- if (value && typeof value === 'object' && 'ok' in value && 'data' in value && value.ok === true) return value.data;
88
- return value;
89
- }
90
-
91
64
  // 0937 R2 closed terminal-reason vocabulary for `--close`. COPIED from
92
65
  // packages/app/src/workflow/terminal-reason.ts (no value import); parity test asserts equality.
93
66
  const TERMINAL_REASONS = new Set([
@@ -116,7 +89,8 @@ async function main(): Promise<void> {
116
89
  action = false,
117
90
  close = false,
118
91
  decide = false,
119
- persistOut = false;
92
+ persistOut = false,
93
+ estimated = false;
120
94
  const taskFiles: string[] = [];
121
95
  let spurBin = getEnvVar('SPUR_BIN') ?? '';
122
96
  const argv = process.argv.slice(2);
@@ -127,6 +101,7 @@ async function main(): Promise<void> {
127
101
  else if (flag === '--close') close = true;
128
102
  else if (flag === '--decide') decide = true;
129
103
  else if (flag === '--persist-out') persistOut = true;
104
+ else if (flag === '--estimated') estimated = true;
130
105
  else if (flag === '--task-file') taskFiles.push(argv[++i] ?? '');
131
106
  else if (flag === '--spur-bin') spurBin = argv[++i] ?? spurBin;
132
107
  else if (flag !== undefined) flags.set(flag, argv[++i] ?? '');
@@ -145,6 +120,10 @@ async function main(): Promise<void> {
145
120
  const durationRaw = flags.get('--duration-ms') ?? '';
146
121
  const actionsFile = flags.get('--actions-file') ?? '';
147
122
 
123
+ // `--estimated` labels a host-reported duration the driver did NOT time around the action
124
+ // (1070 R2). Only `--action` carries one: every other mode is refused here, before any write.
125
+ if (estimated && !action) usage();
126
+
148
127
  if (fingerprint) {
149
128
  if (runId !== '' || file !== '' || taskFiles.length !== 1 || (taskFiles[0] ?? '').trim() === '') usage();
150
129
  const app = (await import(resolveAppEntry(spurBin).entry)) as InlineApp;
@@ -218,7 +197,16 @@ async function main(): Promise<void> {
218
197
  const durationMs = Number(durationRaw);
219
198
  if (durationRaw.trim() === '' || !Number.isFinite(durationMs) || durationMs < 0) usage();
220
199
  process.exit(
221
- await app.runInlineRunTrace({ runId, close: false, node, kind, status, ok: okRaw === 'true', durationMs }),
200
+ await app.runInlineRunTrace({
201
+ runId,
202
+ close: false,
203
+ node,
204
+ kind,
205
+ status,
206
+ ok: okRaw === 'true',
207
+ durationMs,
208
+ ...(estimated ? { estimated: true } : {}),
209
+ }),
222
210
  );
223
211
  }
224
212
 
@@ -228,7 +216,13 @@ async function main(): Promise<void> {
228
216
  const app = (await import(entry)) as InlineApp;
229
217
  let inventory: unknown;
230
218
  try {
231
- inventory = await readInstalledInventory(file, spurBin);
219
+ // The CLI walk lives in the app service (ADR-130 glue budget): the script passes only the
220
+ // paths it derives from its own module URL.
221
+ inventory = await app.readInstalledInventory({
222
+ file,
223
+ spurBin,
224
+ localCli: fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url)),
225
+ });
232
226
  } catch (error) {
233
227
  const message = `could not resolve the workflow definition: ${error instanceof Error ? error.message : String(error)}`;
234
228
  app.writeInlineRunOutcome(runId, { ok: false, runId, error: message });
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+
4
+ // plugins/sp/scripts/persist-out-check.ts
5
+ import { spawnSync } from "child_process";
6
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
7
+ import { basename, dirname, isAbsolute, join, relative } from "path";
8
+ var EVIDENCE_DIR = join(".spur", "memory", "evidence");
9
+ var RUN_DIR = join(".spur", "run");
10
+ var NAMED_CAP = 32;
11
+ var PREFIX_CAP = 64;
12
+ var EVIDENCE_CAP = 256;
13
+ function wbsOfTaskFile(path) {
14
+ const m = /^(\d+)_/.exec(basename(path));
15
+ return m?.[1] ?? null;
16
+ }
17
+ function listFilesUnder(root, rel, cap) {
18
+ const dir = join(root, rel);
19
+ if (!existsSync(dir))
20
+ return [];
21
+ const out = [];
22
+ const walk = (cur) => {
23
+ for (const entry of readdirSync(cur, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
24
+ const p = join(cur, entry.name);
25
+ if (entry.isDirectory()) {
26
+ if (!walk(p))
27
+ return false;
28
+ } else {
29
+ out.push(relative(root, p));
30
+ if (out.length > cap)
31
+ return false;
32
+ }
33
+ }
34
+ return true;
35
+ };
36
+ return walk(dir) ? out : null;
37
+ }
38
+ function listObligations(wtRoot, wbsList, runIds) {
39
+ const evidence = listFilesUnder(wtRoot, EVIDENCE_DIR, EVIDENCE_CAP);
40
+ if (evidence === null)
41
+ return null;
42
+ const out = [...evidence];
43
+ const prefixes = [...wbsList, ...runIds];
44
+ for (const prefix of prefixes) {
45
+ const dir = join(wtRoot, RUN_DIR);
46
+ if (!existsSync(dir))
47
+ continue;
48
+ let entries;
49
+ try {
50
+ entries = readdirSync(dir).sort();
51
+ } catch {
52
+ return null;
53
+ }
54
+ const owned = entries.filter((n) => n.startsWith(`${prefix}-`));
55
+ if (owned.length > PREFIX_CAP)
56
+ return null;
57
+ out.push(...owned.map((n) => join(RUN_DIR, n)));
58
+ }
59
+ return [...new Set(out)].sort();
60
+ }
61
+ function compareTrees(wtRoot, invokeRoot, files) {
62
+ const missing = [];
63
+ const divergent = [];
64
+ let ok = 0;
65
+ for (const rel of files) {
66
+ const wtPath = join(wtRoot, rel);
67
+ const invPath = join(invokeRoot, rel);
68
+ if (!existsSync(invPath)) {
69
+ missing.push(rel);
70
+ continue;
71
+ }
72
+ try {
73
+ const same = readFileSync(wtPath).equals(readFileSync(invPath));
74
+ if (same)
75
+ ok++;
76
+ else
77
+ divergent.push(rel);
78
+ } catch {
79
+ divergent.push(rel);
80
+ }
81
+ }
82
+ return { missing, divergent, ok };
83
+ }
84
+ function persistOutCheckUsage() {
85
+ return "usage: persist-out-check --from <worktree> [--task-file <path>]\u2026 [--run-id <id>]\u2026 [--root <invoke-tree>]";
86
+ }
87
+ function defaultInvokeRoot(cwd) {
88
+ try {
89
+ const run = spawnSync("git", ["-C", cwd, "rev-parse", "--git-common-dir"], { encoding: "utf8" });
90
+ const common = run.stdout.trim();
91
+ if (run.status === 0 && common)
92
+ return dirname(isAbsolute(common) ? common : join(cwd, common));
93
+ } catch {}
94
+ return cwd;
95
+ }
96
+ function main(argv, invokeRoot = defaultInvokeRoot(process.cwd())) {
97
+ let wtRoot = "";
98
+ const wbsList = [];
99
+ const runIds = [];
100
+ for (let i = 0;i < argv.length; i++) {
101
+ const arg = argv[i];
102
+ if (arg === "--spur-bin") {
103
+ i++;
104
+ } else if (arg === "--from") {
105
+ wtRoot = argv[++i] ?? "";
106
+ } else if (arg === "--root") {
107
+ invokeRoot = argv[++i] ?? invokeRoot;
108
+ } else if (arg === "--task-file") {
109
+ const wbs = wbsOfTaskFile(argv[++i] ?? "");
110
+ if (wbs)
111
+ wbsList.push(wbs);
112
+ } else if (arg === "--run-id") {
113
+ const id = argv[++i] ?? "";
114
+ if (id)
115
+ runIds.push(id);
116
+ } else {
117
+ process.stderr.write(`${persistOutCheckUsage()}
118
+ `);
119
+ return 2;
120
+ }
121
+ }
122
+ if (!wtRoot || !existsSync(wtRoot) || !statSync(wtRoot).isDirectory()) {
123
+ process.stderr.write(`${persistOutCheckUsage()}
124
+ persist-out-check: --from must be an existing worktree directory
125
+ `);
126
+ return 2;
127
+ }
128
+ const files = listObligations(wtRoot, wbsList, runIds);
129
+ if (files === null) {
130
+ process.stderr.write(`persist-out-check: BLOCKED \u2014 worktree evidence listing failed or exceeded caps (64/prefix, ${EVIDENCE_CAP} evidence files) \u2014 inspect by hand
131
+ `);
132
+ return 1;
133
+ }
134
+ const { missing, divergent, ok } = compareTrees(wtRoot, invokeRoot, files);
135
+ const findings = [
136
+ ...missing.map((f) => `MISSING ${f}`),
137
+ ...divergent.map((f) => `DIVERGENT ${f} \u2014 reconcile by hand (persist-out never overwrites)`)
138
+ ];
139
+ for (const line of findings.slice(0, NAMED_CAP))
140
+ process.stdout.write(`${line}
141
+ `);
142
+ if (findings.length > NAMED_CAP)
143
+ process.stdout.write(`\u2026 +${findings.length - NAMED_CAP} more
144
+ `);
145
+ if (findings.length === 0) {
146
+ process.stdout.write(`persist-out-check: ok \u2014 ${ok} evidence file(s) persisted, nothing abandoned
147
+ `);
148
+ return 0;
149
+ }
150
+ process.stderr.write(`persist-out-check: BLOCKED ${missing.length} missing, ${divergent.length} divergent \u2014 run inline-run-setup --persist-out --from <worktree> before worktree removal
151
+ `);
152
+ return 1;
153
+ }
154
+ {
155
+ process.exit(main(process.argv.slice(2)));
156
+ }
157
+ export {
158
+ wbsOfTaskFile,
159
+ persistOutCheckUsage,
160
+ main,
161
+ listObligations,
162
+ defaultInvokeRoot,
163
+ compareTrees
164
+ };