@gobing-ai/spur 0.3.99 → 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 (106) 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 +10 -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/workflows/task-pipeline.yaml +178 -18
  10. package/package.json +9 -9
  11. package/plugins/sp/README.md +14 -1
  12. package/plugins/sp/agents/super-reviewer.md +25 -11
  13. package/plugins/sp/commands/dev-changelog.md +5 -4
  14. package/plugins/sp/commands/dev-review-session.md +12 -7
  15. package/plugins/sp/commands/dev-run.md +5 -2
  16. package/plugins/sp/commands/dev-runall.md +5 -2
  17. package/plugins/sp/hooks/agent-lifecycle.ts +155 -0
  18. package/plugins/sp/hooks/fleet-guest-stop.ts +180 -0
  19. package/plugins/sp/hooks/hooks.json +45 -1
  20. package/plugins/sp/hooks/pi/guard-extension.ts +21 -0
  21. package/plugins/sp/lib/history-anatomy.generated.mjs +5 -0
  22. package/plugins/sp/lib/idea-handoff.generated.mjs +34 -9
  23. package/plugins/sp/lib/inline-run.generated.d.mts +1 -0
  24. package/plugins/sp/lib/inline-run.generated.mjs +49 -19
  25. package/plugins/sp/lib/transcript.ts +160 -0
  26. package/plugins/sp/plugin.json +1 -1
  27. package/plugins/sp/scripts/history-anatomy-cache.mjs +5 -0
  28. package/plugins/sp/scripts/inline-run-setup.mjs +21 -20
  29. package/plugins/sp/scripts/inline-run-setup.ts +25 -31
  30. package/plugins/sp/scripts/residual-scan.mjs +2 -7
  31. package/plugins/sp/scripts/residual-scan.ts +7 -8
  32. package/plugins/sp/scripts/run-summary.mjs +286 -0
  33. package/plugins/sp/scripts/run-summary.ts +241 -0
  34. package/plugins/sp/scripts/session-timeline.mjs +234 -0
  35. package/plugins/sp/scripts/session-timeline.ts +152 -0
  36. package/plugins/sp/skills/code-verification/SKILL.md +5 -0
  37. package/plugins/sp/skills/fleet-join/SKILL.md +63 -0
  38. package/plugins/sp/skills/session-review/SKILL.md +46 -24
  39. package/plugins/sp/skills/spur-cli/SKILL.md +1 -0
  40. package/plugins/sp/skills/spur-cli/references/agent.md +108 -6
  41. package/plugins/sp/skills/spur-cli/references/decision.md +97 -0
  42. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +22 -0
  43. package/plugins/sp/skills/spur-composer/SKILL.md +27 -1
  44. package/plugins/sp/skills/spur-dev/references/dev-operations.md +28 -16
  45. package/plugins/sp/skills/spur-dev/references/execution-batch.md +64 -7
  46. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +14 -0
  47. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +16 -4
  48. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +32 -7
  49. package/plugins/sp/skills/spur-dev/references/planning-workflow.md +6 -0
  50. package/schemas/decision-catalog.schema.json +226 -0
  51. package/schemas/spur-config.schema.json +25 -0
  52. package/spur.js +7440 -2424
  53. package/web/_astro/BoardApp.Bzr4OXrW.js +324 -0
  54. package/web/_astro/{BoardApp.i4eh_fHA.js → BoardApp.VeoxxLBy.js} +1 -1
  55. package/web/_astro/{TaskDetail.C5Cu8LdB.js → TaskDetail.LAyR_Pvn.js} +1 -1
  56. package/web/_astro/{arc.Bu5XpBEz.js → arc.f4e0OYLg.js} +1 -1
  57. package/web/_astro/{architectureDiagram-3BPJPVTR.9vHQTvhF.js → architectureDiagram-3BPJPVTR.SSIvxi-Z.js} +1 -1
  58. package/web/_astro/{blockDiagram-GPEHLZMM.Cn2Xl7D6.js → blockDiagram-GPEHLZMM.DSXIHOFf.js} +1 -1
  59. package/web/_astro/{c4Diagram-AAUBKEIU.7o0P_QDo.js → c4Diagram-AAUBKEIU.DG9Ubu85.js} +1 -1
  60. package/web/_astro/channel.CUFdtWhm.js +1 -0
  61. package/web/_astro/{chunk-2J33WTMH.C3zZFsNt.js → chunk-2J33WTMH.C7QU88Qh.js} +1 -1
  62. package/web/_astro/{chunk-4BX2VUAB.CNy_M7fO.js → chunk-4BX2VUAB.26Z94xBj.js} +1 -1
  63. package/web/_astro/{chunk-55IACEB6.2TiPnLBL.js → chunk-55IACEB6.DYRiBkqw.js} +1 -1
  64. package/web/_astro/{chunk-727SXJPM.5CGSyKAp.js → chunk-727SXJPM.D5Vfv5pe.js} +1 -1
  65. package/web/_astro/{chunk-AQP2D5EJ.w-ZIgfsK.js → chunk-AQP2D5EJ.DXHV47bN.js} +1 -1
  66. package/web/_astro/{chunk-FMBD7UC4.BkcEyhKa.js → chunk-FMBD7UC4.D1suAfOs.js} +1 -1
  67. package/web/_astro/{chunk-ND2GUHAM.mxKKbBG4.js → chunk-ND2GUHAM.D6BmdHEY.js} +1 -1
  68. package/web/_astro/{chunk-QZHKN3VN.cyA0z9aT.js → chunk-QZHKN3VN.rnPU-Ak-.js} +1 -1
  69. package/web/_astro/{classDiagram-4FO5ZUOK.CqtUzwhX.js → classDiagram-4FO5ZUOK.CvEfXIo1.js} +1 -1
  70. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CqtUzwhX.js → classDiagram-v2-Q7XG4LA2.CvEfXIo1.js} +1 -1
  71. package/web/_astro/{cose-bilkent-S5V4N54A.BExPTopI.js → cose-bilkent-S5V4N54A.DkMisLSm.js} +1 -1
  72. package/web/_astro/{cynefin-OW5HDTMX.Bq2i10Cn.js → cynefin-OW5HDTMX.C77Q-pF6.js} +1 -1
  73. package/web/_astro/{dagre-BM42HDAG.By_HsOuv.js → dagre-BM42HDAG.YaV8ce27.js} +1 -1
  74. package/web/_astro/{diagram-2AECGRRQ.BtU0b7hz.js → diagram-2AECGRRQ.CUyGEKge.js} +1 -1
  75. package/web/_astro/{diagram-5GNKFQAL.D00ztMdT.js → diagram-5GNKFQAL.CRgd8dgj.js} +1 -1
  76. package/web/_astro/{diagram-KO2AKTUF.DMVJWaUN.js → diagram-KO2AKTUF.Bmzys9hq.js} +1 -1
  77. package/web/_astro/{diagram-LMA3HP47.Cxqrjg5-.js → diagram-LMA3HP47.BvmJY4Lw.js} +1 -1
  78. package/web/_astro/{diagram-OG6HWLK6.2lz_1B5h.js → diagram-OG6HWLK6.0hEhD30d.js} +1 -1
  79. package/web/_astro/{erDiagram-TEJ5UH35.BVQEuJ3T.js → erDiagram-TEJ5UH35.Bjt8lrnm.js} +1 -1
  80. package/web/_astro/{flowDiagram-I6XJVG4X.Qlyo90ky.js → flowDiagram-I6XJVG4X.CJZTjRDV.js} +1 -1
  81. package/web/_astro/{ganttDiagram-6RSMTGT7.DRwb8HHZ.js → ganttDiagram-6RSMTGT7._VqoVafc.js} +1 -1
  82. package/web/_astro/{gitGraphDiagram-PVQCEYII.YfoPthfS.js → gitGraphDiagram-PVQCEYII.CEDb7tVo.js} +1 -1
  83. package/web/_astro/index.Dg_3bOaJ.css +1 -0
  84. package/web/_astro/{infoDiagram-5YYISTIA.DvFREck1.js → infoDiagram-5YYISTIA.CgwifPpK.js} +1 -1
  85. package/web/_astro/{ishikawaDiagram-YF4QCWOH.BzCj9Q0-.js → ishikawaDiagram-YF4QCWOH.B99wx2J1.js} +1 -1
  86. package/web/_astro/{journeyDiagram-JHISSGLW.BDMT_AFb.js → journeyDiagram-JHISSGLW.2hRwJbEo.js} +1 -1
  87. package/web/_astro/{kanban-definition-UN3LZRKU.D6Fh3gj9.js → kanban-definition-UN3LZRKU.BahJkKNC.js} +1 -1
  88. package/web/_astro/{linear.Dxw00fGQ.js → linear.DguUUe6V.js} +1 -1
  89. package/web/_astro/{mermaid.core.CsoxUWa0.js → mermaid.core.CvgfmqVr.js} +4 -4
  90. package/web/_astro/{mindmap-definition-RKZ34NQL.CN8oeHLu.js → mindmap-definition-RKZ34NQL.B3qEA3x9.js} +1 -1
  91. package/web/_astro/{pieDiagram-4H26LBE5.C7ADqQ_7.js → pieDiagram-4H26LBE5.FlWA-_ru.js} +1 -1
  92. package/web/_astro/{quadrantDiagram-W4KKPZXB.Cw9lKyHP.js → quadrantDiagram-W4KKPZXB.9bVn35X7.js} +1 -1
  93. package/web/_astro/{requirementDiagram-4Y6WPE33.B1ni-z4q.js → requirementDiagram-4Y6WPE33.ChykIhct.js} +1 -1
  94. package/web/_astro/{sankeyDiagram-5OEKKPKP.BhexdjVr.js → sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js} +1 -1
  95. package/web/_astro/{sequenceDiagram-3UESZ5HK.C3Rm7dpQ.js → sequenceDiagram-3UESZ5HK.J7JsHWDt.js} +1 -1
  96. package/web/_astro/{stateDiagram-AJRCARHV.CkuMt27u.js → stateDiagram-AJRCARHV.DAPbiCSS.js} +1 -1
  97. package/web/_astro/{stateDiagram-v2-BHNVJYJU.Dc_aazUA.js → stateDiagram-v2-BHNVJYJU.D2jvSi0q.js} +1 -1
  98. package/web/_astro/{timeline-definition-PNZ67QCA.ja7sUFhH.js → timeline-definition-PNZ67QCA.psW2ci29.js} +1 -1
  99. package/web/_astro/{vennDiagram-CIIHVFJN.BxIshX_K.js → vennDiagram-CIIHVFJN.DlILYVmg.js} +1 -1
  100. package/web/_astro/{wardleyDiagram-YWT4CUSO.DfHlC0sh.js → wardleyDiagram-YWT4CUSO.xNWwLJnn.js} +1 -1
  101. package/web/_astro/{xychartDiagram-2RQKCTM6.DSnhHwCh.js → xychartDiagram-2RQKCTM6.EU_BuH7V.js} +1 -1
  102. package/web/board-runtime.json +1 -1
  103. package/web/index.html +2 -2
  104. package/web/_astro/BoardApp.BVp-LYi6.js +0 -322
  105. package/web/_astro/channel.6IFIKBhu.js +0 -1
  106. package/web/_astro/index.CbNz17Sx.css +0 -1
@@ -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.99",
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 });
@@ -427,12 +427,7 @@ function settleMode(opts, env, io) {
427
427
  prior = JSON.parse(fs.readFileSync(residualsPath, "utf8"));
428
428
  const deferred = scan.items.filter((i) => i.class === "deferrable");
429
429
  if (deferred.length > 0 && prior.followUp === undefined) {
430
- if (task.featureId === "") {
431
- io.err(`residual-settle: ${wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${wbs}
432
- `);
433
- return 0;
434
- }
435
- const args = ["task", "create", `Residuals from ${wbs}`, "--feature", task.featureId, "--skip-ready", "--json"];
430
+ const args = ["task", "create", `Residuals from ${wbs}`, "--skip-ready", "--json"];
436
431
  const created = spur(env, opts.spurBin, args, opts.root);
437
432
  let wbsNew = "";
438
433
  try {
@@ -448,7 +443,7 @@ function settleMode(opts, env, io) {
448
443
  return 0;
449
444
  }
450
445
  const rows = deferred.map((i) => `- ${i.id} \u2014 ${i.location}: ${i.text}`);
451
- const head = `Source task: ${wbs} (feature ${task.featureId}) \u2014 deferred residuals filed by residual-scan settle.`;
446
+ const head = `Source task: ${wbs}${task.featureId === "" ? "" : ` (feature ${task.featureId})`} \u2014 deferred residuals filed by residual-scan settle (unlinked: a deferral must not hold the completing feature open).`;
452
447
  const bgFile = join2(runDir, `${wbs}-residual-background.md`);
453
448
  fs.writeFileSync(bgFile, `${[head, "", ...rows].join(`
454
449
  `)}
@@ -170,13 +170,12 @@ function settleMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
170
170
  if (fs.existsSync(residualsPath)) prior = JSON.parse(fs.readFileSync(residualsPath, 'utf8'));
171
171
  const deferred = scan.items.filter((i) => i.class === 'deferrable');
172
172
  if (deferred.length > 0 && prior.followUp === undefined) {
173
- if (task.featureId === '') {
174
- io.err(
175
- `residual-settle: ${wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${wbs}\n`,
176
- );
177
- return 0;
178
- }
179
- const args = ['task', 'create', `Residuals from ${wbs}`, '--feature', task.featureId, '--skip-ready', '--json'];
173
+ // The follow-up is filed UNLINKED: carrying a feature edge to the feature whose run produced
174
+ // it made that feature's done-gate fail with `L4.verifying-incomplete-tasks` the moment a
175
+ // residual was deferred (2026-10-04 session, E72/1086→1087), so a deferral must not be able
176
+ // to block the completing feature. The source task and its feature stay named in the
177
+ // Background, which keeps the traceability without the lifecycle coupling.
178
+ const args = ['task', 'create', `Residuals from ${wbs}`, '--skip-ready', '--json'];
180
179
  const created = spur(env, opts.spurBin, args, opts.root);
181
180
  let wbsNew = '';
182
181
  try {
@@ -191,7 +190,7 @@ function settleMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
191
190
  return 0;
192
191
  }
193
192
  const rows = deferred.map((i) => `- ${i.id} — ${i.location}: ${i.text}`);
194
- const head = `Source task: ${wbs} (feature ${task.featureId}) — deferred residuals filed by residual-scan settle.`;
193
+ const head = `Source task: ${wbs}${task.featureId === '' ? '' : ` (feature ${task.featureId})`} — deferred residuals filed by residual-scan settle (unlinked: a deferral must not hold the completing feature open).`;
195
194
  const bgFile = join(runDir, `${wbs}-residual-background.md`);
196
195
  fs.writeFileSync(bgFile, `${[head, '', ...rows].join('\n')}\n`);
197
196
  const updArgs = ['task', 'update', wbsNew, '--section', 'Background', '--from-file', bgFile];