@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/config/config.global.yaml +17 -0
- package/config/decisions/task-pipeline.yaml +55 -0
- package/config/plugin-scripts.json +15 -0
- package/config/rules/boundary/sp-runtime-path.yaml +2 -0
- package/config/rules/quality/coverage-gate.yaml +5 -3
- package/config/rules/strict/runtime-boundaries.yaml +11 -0
- package/config/rules/structure/protected-files.yaml +3 -0
- package/config/templates/task/brainstorm.md +1 -1
- package/config/templates/task/feature-impl.md +1 -1
- package/config/templates/task/issue.md +1 -1
- package/config/templates/task/meta.md +1 -1
- package/config/templates/task/review.md +1 -1
- package/config/templates/task/standard.md +1 -1
- package/config/workflows/task-pipeline.yaml +178 -18
- package/package.json +9 -9
- package/plugins/sp/README.md +14 -1
- package/plugins/sp/agents/super-reviewer.md +25 -11
- package/plugins/sp/commands/dev-changelog.md +5 -4
- package/plugins/sp/commands/dev-review-session.md +12 -7
- package/plugins/sp/commands/dev-run.md +5 -2
- package/plugins/sp/commands/dev-runall.md +5 -2
- package/plugins/sp/hooks/agent-lifecycle.ts +155 -0
- package/plugins/sp/hooks/fleet-guest-stop.ts +180 -0
- package/plugins/sp/hooks/hooks.json +45 -1
- package/plugins/sp/hooks/pi/guard-extension.ts +21 -0
- package/plugins/sp/lib/history-anatomy.generated.mjs +5 -0
- package/plugins/sp/lib/idea-handoff.generated.mjs +68 -10
- package/plugins/sp/lib/inline-run.generated.d.mts +1 -0
- package/plugins/sp/lib/inline-run.generated.mjs +83 -20
- package/plugins/sp/lib/residual-scan.generated.d.mts +5 -0
- package/plugins/sp/lib/residual-scan.generated.mjs +45 -5
- package/plugins/sp/lib/transcript.ts +160 -0
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/history-anatomy-cache.mjs +5 -0
- package/plugins/sp/scripts/inline-run-setup.mjs +21 -20
- package/plugins/sp/scripts/inline-run-setup.ts +25 -31
- package/plugins/sp/scripts/persist-out-check.mjs +164 -0
- package/plugins/sp/scripts/persist-out-check.ts +185 -0
- package/plugins/sp/scripts/residual-scan.mjs +52 -15
- package/plugins/sp/scripts/residual-scan.ts +11 -11
- package/plugins/sp/scripts/run-summary.mjs +286 -0
- package/plugins/sp/scripts/run-summary.ts +241 -0
- package/plugins/sp/scripts/session-timeline.mjs +234 -0
- package/plugins/sp/scripts/session-timeline.ts +152 -0
- package/plugins/sp/scripts/task-diffstat.mjs +17 -5
- package/plugins/sp/scripts/task-diffstat.ts +25 -4
- package/plugins/sp/skills/code-verification/SKILL.md +8 -0
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +17 -1
- package/plugins/sp/skills/fleet-join/SKILL.md +63 -0
- package/plugins/sp/skills/session-review/SKILL.md +46 -24
- package/plugins/sp/skills/spur-cli/SKILL.md +1 -0
- package/plugins/sp/skills/spur-cli/references/agent.md +108 -6
- package/plugins/sp/skills/spur-cli/references/decision.md +97 -0
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +22 -0
- package/plugins/sp/skills/spur-composer/SKILL.md +27 -1
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +40 -7
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +28 -16
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +278 -42
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +17 -1
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +16 -4
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +139 -10
- package/plugins/sp/skills/spur-dev/references/planning-workflow.md +6 -0
- package/schemas/decision-catalog.schema.json +226 -0
- package/schemas/spur-config.schema.json +25 -0
- package/spur.js +8856 -2564
- package/web/_astro/BoardApp.Bzr4OXrW.js +324 -0
- package/web/_astro/{BoardApp.Dflq5a8x.js → BoardApp.VeoxxLBy.js} +1 -1
- package/web/_astro/{TaskDetail.CK7oXddP.js → TaskDetail.LAyR_Pvn.js} +1 -1
- package/web/_astro/{arc.BIqkDATc.js → arc.f4e0OYLg.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.D0B3SvWv.js → architectureDiagram-3BPJPVTR.SSIvxi-Z.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.C2YyKQmL.js → blockDiagram-GPEHLZMM.DSXIHOFf.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.DmtTYrt_.js → c4Diagram-AAUBKEIU.DG9Ubu85.js} +1 -1
- package/web/_astro/channel.CUFdtWhm.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.HsRXbh7l.js → chunk-2J33WTMH.C7QU88Qh.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.obE_P-R3.js → chunk-4BX2VUAB.26Z94xBj.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.CjWTF5mz.js → chunk-55IACEB6.DYRiBkqw.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.DMo799Ax.js → chunk-727SXJPM.D5Vfv5pe.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.-cE6JOKZ.js → chunk-AQP2D5EJ.DXHV47bN.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.D6Rw6HIk.js → chunk-FMBD7UC4.D1suAfOs.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.d5rBD73Z.js → chunk-ND2GUHAM.D6BmdHEY.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.r3ox40Q3.js → chunk-QZHKN3VN.rnPU-Ak-.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.CGGbvjjJ.js → classDiagram-4FO5ZUOK.CvEfXIo1.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.CGGbvjjJ.js → classDiagram-v2-Q7XG4LA2.CvEfXIo1.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.CmKtPd4l.js → cose-bilkent-S5V4N54A.DkMisLSm.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.QNg5GXs8.js → cynefin-OW5HDTMX.C77Q-pF6.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.B1SabsjC.js → dagre-BM42HDAG.YaV8ce27.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.RtDbNj89.js → diagram-2AECGRRQ.CUyGEKge.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.IzZpuM-Y.js → diagram-5GNKFQAL.CRgd8dgj.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.DPTF_fgv.js → diagram-KO2AKTUF.Bmzys9hq.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.CCyza39P.js → diagram-LMA3HP47.BvmJY4Lw.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.BXAi8zcZ.js → diagram-OG6HWLK6.0hEhD30d.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.DUDX7_MR.js → erDiagram-TEJ5UH35.Bjt8lrnm.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.C63QTh3N.js → flowDiagram-I6XJVG4X.CJZTjRDV.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.CnlZJSwG.js → ganttDiagram-6RSMTGT7._VqoVafc.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.CTqIrOPC.js → gitGraphDiagram-PVQCEYII.CEDb7tVo.js} +1 -1
- package/web/_astro/index.Dg_3bOaJ.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.BYMiDFh9.js → infoDiagram-5YYISTIA.CgwifPpK.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.CRiQmI-z.js → ishikawaDiagram-YF4QCWOH.B99wx2J1.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.YQgWBsEU.js → journeyDiagram-JHISSGLW.2hRwJbEo.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.1ECnrrK1.js → kanban-definition-UN3LZRKU.BahJkKNC.js} +1 -1
- package/web/_astro/{linear.DzVA71yT.js → linear.DguUUe6V.js} +1 -1
- package/web/_astro/{mermaid.core.BK7P2Sr_.js → mermaid.core.CvgfmqVr.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.BvXyWZK7.js → mindmap-definition-RKZ34NQL.B3qEA3x9.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.BiXk1TW3.js → pieDiagram-4H26LBE5.FlWA-_ru.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.krvBH8lh.js → quadrantDiagram-W4KKPZXB.9bVn35X7.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.ih1gL25c.js → requirementDiagram-4Y6WPE33.ChykIhct.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.CpUxkv4u.js → sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.clAfeN7y.js → sequenceDiagram-3UESZ5HK.J7JsHWDt.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.DpXWb8-m.js → stateDiagram-AJRCARHV.DAPbiCSS.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.ClZb9GIH.js → stateDiagram-v2-BHNVJYJU.D2jvSi0q.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.BLcX5dh7.js → timeline-definition-PNZ67QCA.psW2ci29.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.oGgpKMpx.js → vennDiagram-CIIHVFJN.DlILYVmg.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.BPHtMtt-.js → wardleyDiagram-YWT4CUSO.xNWwLJnn.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.BNZOPYSk.js → xychartDiagram-2RQKCTM6.EU_BuH7V.js} +1 -1
- package/web/board-runtime.json +1 -1
- package/web/index.html +2 -2
- package/web/_astro/BoardApp.CYbe-OTb.js +0 -322
- package/web/_astro/channel.JuKVyuhZ.js +0 -1
- 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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/plugins/sp/plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sp",
|
|
3
|
-
"version": "0.
|
|
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({
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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
|
+
};
|