@gobing-ai/spur 0.3.99 → 0.4.1
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/gates.yaml +36 -0
- package/config/decisions/history-anatomy.yaml +35 -0
- package/config/decisions/idea-pipeline.yaml +38 -0
- package/config/decisions/task-pipeline.yaml +55 -0
- package/config/plugin-scripts.json +25 -0
- package/config/rules/boundary/sp-runtime-path.yaml +2 -0
- package/config/rules/docs/sourced-api-claims.yaml +60 -0
- package/config/rules/quality/coverage-gate.yaml +5 -3
- package/config/rules/recommended-pre-check.yaml +8 -3
- package/config/rules/strict/runtime-boundaries.yaml +11 -0
- package/config/rules/structure/protected-files.yaml +3 -0
- package/config/rules/structure/scratch-evidence-pointer.yaml +35 -0
- package/config/script-placement-baseline.json +2 -2
- package/config/workflows/feature-lifecycle.yaml +9 -2
- package/config/workflows/history-anatomy.yaml +22 -0
- package/config/workflows/idea-pipeline.yaml +75 -0
- package/config/workflows/task-pipeline.yaml +368 -49
- package/config/workflows/wrapup-pipeline.yaml +106 -13
- package/package.json +9 -9
- package/plugins/sp/README.md +25 -2
- package/plugins/sp/agents/super-planner.md +11 -0
- package/plugins/sp/agents/super-reviewer.md +39 -11
- package/plugins/sp/bunfig.toml +8 -0
- package/plugins/sp/commands/dev-brainstorm.md +29 -16
- package/plugins/sp/commands/dev-changelog.md +5 -4
- package/plugins/sp/commands/dev-find-way.md +80 -0
- package/plugins/sp/commands/dev-idea.md +6 -0
- package/plugins/sp/commands/dev-parallel.md +8 -2
- package/plugins/sp/commands/dev-plan.md +6 -0
- package/plugins/sp/commands/dev-review-session.md +14 -7
- package/plugins/sp/commands/dev-run.md +17 -4
- package/plugins/sp/commands/dev-runall.md +21 -10
- package/plugins/sp/hooks/agent-lifecycle.ts +155 -0
- package/plugins/sp/hooks/context-session-start.ts +1 -1
- 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 +72 -11
- package/plugins/sp/lib/inline-run-app.ts +28 -0
- package/plugins/sp/lib/inline-run.generated.d.mts +6 -0
- package/plugins/sp/lib/inline-run.generated.mjs +364 -46
- package/plugins/sp/lib/plan-projection.generated.d.mts +28 -0
- package/plugins/sp/lib/plan-projection.generated.mjs +255 -0
- package/plugins/sp/lib/quality-gate.generated.d.mts +47 -1
- package/plugins/sp/lib/quality-gate.generated.mjs +30489 -82
- package/plugins/sp/lib/residual-scan.generated.d.mts +19 -0
- package/plugins/sp/lib/residual-scan.generated.mjs +66 -0
- package/plugins/sp/lib/run-summary-core.ts +561 -0
- package/plugins/sp/lib/transcript.ts +275 -0
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/references/roles.md +1 -1
- package/plugins/sp/scripts/batch-plan.mjs +335 -0
- package/plugins/sp/scripts/batch-plan.ts +100 -0
- package/plugins/sp/scripts/batch-preflight.mjs +51 -3
- package/plugins/sp/scripts/batch-preflight.ts +72 -1
- package/plugins/sp/scripts/commit-guard.mjs +216 -0
- package/plugins/sp/scripts/commit-guard.ts +249 -0
- package/plugins/sp/scripts/history-anatomy-cache.mjs +5 -0
- package/plugins/sp/scripts/inline-run-dispatch.mjs +130 -0
- package/plugins/sp/scripts/inline-run-dispatch.ts +135 -0
- package/plugins/sp/scripts/inline-run-setup.mjs +563 -91
- package/plugins/sp/scripts/inline-run-setup.ts +65 -97
- package/plugins/sp/scripts/persist-out-check.mjs +90 -41
- package/plugins/sp/scripts/persist-out-check.ts +112 -57
- package/plugins/sp/scripts/quality-gate.mjs +30407 -73
- package/plugins/sp/scripts/quality-gate.ts +21 -5
- package/plugins/sp/scripts/residual-scan.mjs +88 -38
- package/plugins/sp/scripts/residual-scan.ts +35 -38
- package/plugins/sp/scripts/run-summary.mjs +693 -0
- package/plugins/sp/scripts/run-summary.ts +105 -0
- package/plugins/sp/scripts/session-timeline.mjs +331 -0
- package/plugins/sp/scripts/session-timeline.ts +204 -0
- package/plugins/sp/scripts/wrapup-steps.mjs +150 -2
- package/plugins/sp/scripts/wrapup-steps.ts +203 -1
- package/plugins/sp/skills/brainstorm/SKILL.md +9 -47
- package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
- package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +1 -0
- package/plugins/sp/skills/code-implementation/SKILL.md +48 -1
- package/plugins/sp/skills/code-verification/SKILL.md +8 -4
- package/plugins/sp/skills/code-verification/references/secu-review.md +3 -0
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +68 -0
- package/plugins/sp/skills/fleet-join/SKILL.md +63 -0
- package/plugins/sp/skills/session-review/SKILL.md +77 -24
- package/plugins/sp/skills/source-driven-development/SKILL.md +5 -0
- package/plugins/sp/skills/spec-decomposition/references/decomposition.md +5 -3
- package/plugins/sp/skills/spur-cli/SKILL.md +6 -2
- package/plugins/sp/skills/spur-cli/references/agent.md +124 -17
- package/plugins/sp/skills/spur-cli/references/decision.md +98 -0
- package/plugins/sp/skills/spur-cli/references/message.md +7 -7
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +41 -3
- package/plugins/sp/skills/spur-cli/references/tasks.md +10 -7
- package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +7 -0
- package/plugins/sp/skills/spur-cli/references/workflows.md +7 -2
- package/plugins/sp/skills/spur-composer/SKILL.md +28 -2
- package/plugins/sp/skills/spur-dev/SKILL.md +18 -14
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +6 -3
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +49 -14
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +51 -18
- package/plugins/sp/skills/spur-dev/references/execution-batch-continuation.md +68 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +194 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +113 -963
- package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +130 -0
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +20 -0
- package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +471 -0
- package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +379 -0
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +51 -9
- package/plugins/sp/skills/spur-dev/references/glossary.md +2 -2
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +361 -156
- package/plugins/sp/skills/spur-dev/references/planning-workflow.md +6 -0
- package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +178 -0
- package/plugins/sp/skills/wayfinder/SKILL.md +10 -14
- package/schemas/decision-catalog.schema.json +226 -0
- package/schemas/spur-config.schema.json +25 -0
- package/schemas/state-machine-workflow.schema.json +23 -0
- package/spur.js +28165 -19051
- package/web/_astro/{BoardApp.i4eh_fHA.js → BoardApp.D66k_s_T.js} +1 -1
- package/web/_astro/BoardApp.D6sjAJr_.js +324 -0
- package/web/_astro/{TaskDetail.C5Cu8LdB.js → TaskDetail.BynLnMNc.js} +1 -1
- package/web/_astro/{arc.Bu5XpBEz.js → arc._BUadrdH.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.9vHQTvhF.js → architectureDiagram-3BPJPVTR.Dh7xS1Pp.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.Cn2Xl7D6.js → blockDiagram-GPEHLZMM.DFDVpfXm.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.7o0P_QDo.js → c4Diagram-AAUBKEIU.V7_3_glh.js} +1 -1
- package/web/_astro/channel.DZVfto-r.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.C3zZFsNt.js → chunk-2J33WTMH.De1yx7Jz.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.CNy_M7fO.js → chunk-4BX2VUAB.BDhHFuxx.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.2TiPnLBL.js → chunk-55IACEB6.DDLkWnmC.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.5CGSyKAp.js → chunk-727SXJPM.CwfiLnA7.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.w-ZIgfsK.js → chunk-AQP2D5EJ.CtGDmUnM.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.BkcEyhKa.js → chunk-FMBD7UC4.BOLjpw73.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.mxKKbBG4.js → chunk-ND2GUHAM.BDhQls6O.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.cyA0z9aT.js → chunk-QZHKN3VN.76qBynDF.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.CqtUzwhX.js → classDiagram-4FO5ZUOK.N0mpPHT0.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.CqtUzwhX.js → classDiagram-v2-Q7XG4LA2.N0mpPHT0.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.BExPTopI.js → cose-bilkent-S5V4N54A.CKknFfjB.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.Bq2i10Cn.js → cynefin-OW5HDTMX.Cw3ZWBAh.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.By_HsOuv.js → dagre-BM42HDAG.D3yIKCRD.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.BtU0b7hz.js → diagram-2AECGRRQ.D51dO83Z.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.D00ztMdT.js → diagram-5GNKFQAL.Bb-FViDj.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.DMVJWaUN.js → diagram-KO2AKTUF.D3mN8S6x.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.Cxqrjg5-.js → diagram-LMA3HP47.dMMyPcbJ.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.2lz_1B5h.js → diagram-OG6HWLK6.AzbvzY4C.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.BVQEuJ3T.js → erDiagram-TEJ5UH35.BR5I4OZb.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.Qlyo90ky.js → flowDiagram-I6XJVG4X.DFqvhGkn.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.DRwb8HHZ.js → ganttDiagram-6RSMTGT7.ByJbhH6J.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.YfoPthfS.js → gitGraphDiagram-PVQCEYII.CInHjQR3.js} +1 -1
- package/web/_astro/index.COh_kBxN.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.DvFREck1.js → infoDiagram-5YYISTIA.Cm0dGVjJ.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.BzCj9Q0-.js → ishikawaDiagram-YF4QCWOH.C3nOP6Sj.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.BDMT_AFb.js → journeyDiagram-JHISSGLW.4IvLa2MW.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.D6Fh3gj9.js → kanban-definition-UN3LZRKU.DS87Cl-k.js} +1 -1
- package/web/_astro/{linear.Dxw00fGQ.js → linear.MzmRrNER.js} +1 -1
- package/web/_astro/{mermaid.core.CsoxUWa0.js → mermaid.core.fK-WNswS.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.CN8oeHLu.js → mindmap-definition-RKZ34NQL.BqipOHhY.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.C7ADqQ_7.js → pieDiagram-4H26LBE5.0nBiPqFG.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.Cw9lKyHP.js → quadrantDiagram-W4KKPZXB.Szj7Zpx5.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.B1ni-z4q.js → requirementDiagram-4Y6WPE33.D4UR6TWo.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.BhexdjVr.js → sankeyDiagram-5OEKKPKP.DAE7BdOs.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.C3Rm7dpQ.js → sequenceDiagram-3UESZ5HK.BP9ZVq25.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.CkuMt27u.js → stateDiagram-AJRCARHV.BQW7mIxk.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.Dc_aazUA.js → stateDiagram-v2-BHNVJYJU.PbK4nDih.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.ja7sUFhH.js → timeline-definition-PNZ67QCA.Ci_GvUpo.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.BxIshX_K.js → vennDiagram-CIIHVFJN.DNw1Y5qx.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.DfHlC0sh.js → wardleyDiagram-YWT4CUSO.CwlkaaNe.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.DSnhHwCh.js → xychartDiagram-2RQKCTM6.MI6ZuiH_.js} +1 -1
- package/web/board-runtime.json +1 -1
- package/web/index.html +2 -2
- package/web/_astro/BoardApp.BVp-LYi6.js +0 -322
- package/web/_astro/channel.6IFIKBhu.js +0 -1
- package/web/_astro/index.CbNz17Sx.css +0 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* run-summary — the measured execution summary `/sp:dev-run` and `/sp:dev-runall` print by default
|
|
4
|
+
* (`--no-summary` skips it). Inputs: one or more `spur workflow progress <run-id> --json` files
|
|
5
|
+
* (`--progress [<label>=]<file>`) and the host transcript (resolved like session-timeline, or
|
|
6
|
+
* `--transcript`), OR the close-written per-run summary files (`--rollup [<label>=]<file>`, 1146 R3).
|
|
7
|
+
* Measurement and rendering live in `../lib/run-summary-core` (ADR-130 glue budget); this script is
|
|
8
|
+
* argv parsing and dispatch. Reporting only: no transcript or no timed attempts → an `n/a` result,
|
|
9
|
+
* exit 0. Node builtins only.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { getEnvVars } from '../lib/env';
|
|
13
|
+
import {
|
|
14
|
+
buildRunSummary,
|
|
15
|
+
gateFromReceipt,
|
|
16
|
+
type Progress,
|
|
17
|
+
type RollupInput,
|
|
18
|
+
renderRollupMarkdown,
|
|
19
|
+
renderSummaryMarkdown,
|
|
20
|
+
} from '../lib/run-summary-core';
|
|
21
|
+
import { resolveTranscript } from '../lib/transcript';
|
|
22
|
+
|
|
23
|
+
// Re-exported so the suite and any consumer keep one import path for the whole surface.
|
|
24
|
+
export * from '../lib/run-summary-core';
|
|
25
|
+
|
|
26
|
+
export const RUN_SUMMARY_USAGE =
|
|
27
|
+
'usage: run-summary (--progress [<label>=]<file> [--progress ...] | --rollup [<label>=]<summary.json> [--rollup ...]) [--transcript <path>] [--since <iso>] [--markdown]';
|
|
28
|
+
|
|
29
|
+
export function main(
|
|
30
|
+
argv: string[],
|
|
31
|
+
env: Record<string, string | undefined> = getEnvVars(),
|
|
32
|
+
write: (s: string) => void = (s) => process.stdout.write(s),
|
|
33
|
+
projectsRoot?: string,
|
|
34
|
+
): number {
|
|
35
|
+
let transcript: string | undefined;
|
|
36
|
+
let since: string | undefined;
|
|
37
|
+
let markdown = false;
|
|
38
|
+
const progressArgs: string[] = [];
|
|
39
|
+
const rollupArgs: string[] = [];
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
const arg = argv[i];
|
|
42
|
+
if (arg === '--transcript' && argv[i + 1]) transcript = argv[++i];
|
|
43
|
+
else if (arg === '--progress' && argv[i + 1]) progressArgs.push(argv[++i] as string);
|
|
44
|
+
else if (arg === '--rollup' && argv[i + 1]) rollupArgs.push(argv[++i] as string);
|
|
45
|
+
else if (arg === '--since' && argv[i + 1]) since = argv[++i];
|
|
46
|
+
else if (arg === '--markdown') markdown = true;
|
|
47
|
+
else if (arg === '--spur-bin' && argv[i + 1])
|
|
48
|
+
i++; // 0482 R2: accepted, unused (no spur calls)
|
|
49
|
+
else {
|
|
50
|
+
process.stderr.write(`${RUN_SUMMARY_USAGE}\n`);
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// 1146 AC5: --rollup is a different mode — it reads close-written summary files and never
|
|
55
|
+
// re-measures, so combining it with a measurement source is a usage error.
|
|
56
|
+
if (rollupArgs.length > 0 && (progressArgs.length > 0 || since !== undefined || transcript !== undefined)) {
|
|
57
|
+
process.stderr.write(`${RUN_SUMMARY_USAGE}\n`);
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
if (rollupArgs.length > 0) {
|
|
61
|
+
try {
|
|
62
|
+
const inputs: RollupInput[] = rollupArgs.map((arg) => {
|
|
63
|
+
const eq = arg.indexOf('=');
|
|
64
|
+
const [label, file] = eq > 0 ? [arg.slice(0, eq), arg.slice(eq + 1)] : [undefined, arg];
|
|
65
|
+
const summary = JSON.parse(readFileSync(file as string, 'utf8')) as RollupInput['summary'];
|
|
66
|
+
return { label: label ?? summary.runId ?? (file as string), summary };
|
|
67
|
+
});
|
|
68
|
+
write(`${renderRollupMarkdown(inputs)}\n`);
|
|
69
|
+
return 0;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
process.stderr.write(`run-summary: ${(error as Error).message}\n`);
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!progressArgs.length) {
|
|
76
|
+
process.stderr.write(`${RUN_SUMMARY_USAGE}\n`);
|
|
77
|
+
return 2;
|
|
78
|
+
}
|
|
79
|
+
const resolved = resolveTranscript(env, projectsRoot, transcript);
|
|
80
|
+
if (!resolved.ok) {
|
|
81
|
+
const out = markdown
|
|
82
|
+
? `Execution summary: n/a (${resolved.reason})`
|
|
83
|
+
: JSON.stringify({ available: false, reason: resolved.reason });
|
|
84
|
+
write(`${out}\n`);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const inputs = progressArgs.map((arg) => {
|
|
89
|
+
const eq = arg.indexOf('=');
|
|
90
|
+
const [label, file] = eq > 0 ? [arg.slice(0, eq), arg.slice(eq + 1)] : [undefined, arg];
|
|
91
|
+
return { label, progress: JSON.parse(readFileSync(file, 'utf8')) as Progress };
|
|
92
|
+
});
|
|
93
|
+
const summary = buildRunSummary(readFileSync(resolved.path, 'utf8').split('\n'), inputs, since);
|
|
94
|
+
summary.gate = gateFromReceipt(process.cwd(), inputs[0]?.progress.runId ?? '');
|
|
95
|
+
write(
|
|
96
|
+
`${markdown ? renderSummaryMarkdown(summary) : JSON.stringify({ ...summary, transcript: resolved.path })}\n`,
|
|
97
|
+
);
|
|
98
|
+
return 0;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
process.stderr.write(`run-summary: ${(error as Error).message}\n`);
|
|
101
|
+
return 2;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (import.meta.main) process.exit(main(process.argv.slice(2)));
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// plugins/sp/scripts/session-timeline.ts
|
|
5
|
+
import { readFileSync } from "fs";
|
|
6
|
+
|
|
7
|
+
// plugins/sp/lib/env.ts
|
|
8
|
+
function getEnvVars() {
|
|
9
|
+
return process.env;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// plugins/sp/lib/transcript.ts
|
|
13
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
var SESSION_ID = /^[A-Za-z0-9_-]+$/;
|
|
17
|
+
var OPERATOR_TOOLS = new Set(["AskUserQuestion", "ask_user_question"]);
|
|
18
|
+
var PI_ROLES = new Set(["user", "assistant", "toolResult"]);
|
|
19
|
+
var zeroTokens = () => ({ input: 0, cacheCreate: 0, cacheRead: 0, output: 0 });
|
|
20
|
+
function formatDuration(ms) {
|
|
21
|
+
const s = Math.round(ms / 1000);
|
|
22
|
+
const h = Math.floor(s / 3600);
|
|
23
|
+
const m = Math.floor(s % 3600 / 60);
|
|
24
|
+
const ss = String(s % 60).padStart(2, "0");
|
|
25
|
+
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
|
|
26
|
+
}
|
|
27
|
+
function formatTokens(n) {
|
|
28
|
+
if (n >= 999500)
|
|
29
|
+
return `${(n / 1e6).toFixed(1)}M`;
|
|
30
|
+
return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
31
|
+
}
|
|
32
|
+
function formatTokenSplit(t) {
|
|
33
|
+
if (!t)
|
|
34
|
+
return "n/a";
|
|
35
|
+
const nonCached = t.input + t.cacheCreate + t.output;
|
|
36
|
+
return `${formatTokens(nonCached + t.cacheRead)} / ${formatTokens(nonCached)}`;
|
|
37
|
+
}
|
|
38
|
+
function promptText(row) {
|
|
39
|
+
if (row.type === "message") {
|
|
40
|
+
if (row.message?.role !== "user")
|
|
41
|
+
return;
|
|
42
|
+
if (isInjectedPrompt(row))
|
|
43
|
+
return;
|
|
44
|
+
const content2 = row.message.content;
|
|
45
|
+
if (typeof content2 === "string")
|
|
46
|
+
return content2;
|
|
47
|
+
if (!Array.isArray(content2))
|
|
48
|
+
return;
|
|
49
|
+
return content2.find((b) => b.type === "text")?.text;
|
|
50
|
+
}
|
|
51
|
+
if (row.type !== "user" || row.isMeta || row.isCompactSummary)
|
|
52
|
+
return;
|
|
53
|
+
const content = row.message?.content;
|
|
54
|
+
if (typeof content === "string")
|
|
55
|
+
return content;
|
|
56
|
+
if (!Array.isArray(content) || content.some((b) => b.type === "tool_result"))
|
|
57
|
+
return;
|
|
58
|
+
return content.find((b) => b.type === "text")?.text;
|
|
59
|
+
}
|
|
60
|
+
function isInjectedPrompt(row) {
|
|
61
|
+
if (row.type !== "message" || row.message?.role !== "user")
|
|
62
|
+
return false;
|
|
63
|
+
const content = row.message.content;
|
|
64
|
+
const text = typeof content === "string" ? content : Array.isArray(content) ? content.find((b) => b.type === "text")?.text : undefined;
|
|
65
|
+
return typeof text === "string" && text.replace(/^\s+/, "").startsWith('<skill name="');
|
|
66
|
+
}
|
|
67
|
+
function sumTokens(all) {
|
|
68
|
+
const total = zeroTokens();
|
|
69
|
+
for (const t of all)
|
|
70
|
+
for (const k of Object.keys(total))
|
|
71
|
+
total[k] += t[k];
|
|
72
|
+
return total;
|
|
73
|
+
}
|
|
74
|
+
function sumSpans(spans) {
|
|
75
|
+
return {
|
|
76
|
+
workMs: spans.reduce((n, s) => n + s.workMs, 0),
|
|
77
|
+
waitMs: spans.reduce((n, s) => n + s.waitMs, 0),
|
|
78
|
+
toolCalls: spans.reduce((n, s) => n + s.toolCalls, 0),
|
|
79
|
+
tokens: sumTokens(spans.map((s) => s.tokens))
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
var newAcc = (ts) => ({
|
|
83
|
+
last: ts,
|
|
84
|
+
askMs: 0,
|
|
85
|
+
asks: new Map,
|
|
86
|
+
toolIds: new Set,
|
|
87
|
+
messages: new Map,
|
|
88
|
+
compactions: 0
|
|
89
|
+
});
|
|
90
|
+
function accumulate(acc, row, ts) {
|
|
91
|
+
acc.last = Math.max(acc.last, ts);
|
|
92
|
+
if (row.type === "message") {
|
|
93
|
+
const m = row.message;
|
|
94
|
+
if (m?.role === "assistant") {
|
|
95
|
+
const blocks2 = Array.isArray(m.content) ? m.content : [];
|
|
96
|
+
for (const b of blocks2) {
|
|
97
|
+
if (b.type === "toolCall" && b.id) {
|
|
98
|
+
acc.toolIds.add(b.id);
|
|
99
|
+
if (b.name && OPERATOR_TOOLS.has(b.name))
|
|
100
|
+
acc.asks.set(b.id, ts);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const u2 = m.usage;
|
|
104
|
+
if (u2 && row.id)
|
|
105
|
+
acc.messages.set(row.id, {
|
|
106
|
+
input: u2.input ?? 0,
|
|
107
|
+
cacheCreate: u2.cacheWrite ?? 0,
|
|
108
|
+
cacheRead: u2.cacheRead ?? 0,
|
|
109
|
+
output: u2.output ?? 0
|
|
110
|
+
});
|
|
111
|
+
} else if (m?.role === "toolResult" && m.toolCallId && acc.asks.has(m.toolCallId)) {
|
|
112
|
+
acc.askMs += ts - (acc.asks.get(m.toolCallId) ?? ts);
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (row.type === "compaction")
|
|
117
|
+
acc.compactions++;
|
|
118
|
+
const blocks = Array.isArray(row.message?.content) ? row.message.content : [];
|
|
119
|
+
for (const b of blocks) {
|
|
120
|
+
if (b.type === "tool_use" && b.id) {
|
|
121
|
+
acc.toolIds.add(b.id);
|
|
122
|
+
if (b.name && OPERATOR_TOOLS.has(b.name))
|
|
123
|
+
acc.asks.set(b.id, ts);
|
|
124
|
+
} else if (b.type === "tool_result" && b.tool_use_id && acc.asks.has(b.tool_use_id)) {
|
|
125
|
+
acc.askMs += ts - (acc.asks.get(b.tool_use_id) ?? ts);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const u = row.message?.usage;
|
|
129
|
+
if (row.type === "assistant" && u && row.message?.id) {
|
|
130
|
+
acc.messages.set(row.message.id, {
|
|
131
|
+
input: u.input_tokens ?? 0,
|
|
132
|
+
cacheCreate: u.cache_creation_input_tokens ?? 0,
|
|
133
|
+
cacheRead: u.cache_read_input_tokens ?? 0,
|
|
134
|
+
output: u.output_tokens ?? 0
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function sniffFormat(rows) {
|
|
139
|
+
for (const row of rows) {
|
|
140
|
+
if (row.type === "message" && PI_ROLES.has(row.message?.role ?? ""))
|
|
141
|
+
return "pi";
|
|
142
|
+
if (row.type === "user" || row.type === "assistant")
|
|
143
|
+
return "claude";
|
|
144
|
+
}
|
|
145
|
+
return "unknown";
|
|
146
|
+
}
|
|
147
|
+
function parseRows(lines) {
|
|
148
|
+
const rows = [];
|
|
149
|
+
let skippedLines = 0;
|
|
150
|
+
for (const line of lines) {
|
|
151
|
+
if (!line.trim())
|
|
152
|
+
continue;
|
|
153
|
+
let row;
|
|
154
|
+
try {
|
|
155
|
+
row = JSON.parse(line);
|
|
156
|
+
} catch {
|
|
157
|
+
skippedLines++;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const ts = row.timestamp ? Date.parse(row.timestamp) : Number.NaN;
|
|
161
|
+
if (!Number.isNaN(ts))
|
|
162
|
+
rows.push([row, ts]);
|
|
163
|
+
}
|
|
164
|
+
return { rows, skippedLines, format: sniffFormat(rows.map(([r]) => r)) };
|
|
165
|
+
}
|
|
166
|
+
function resolveTranscript(env, projectsRoot = join(homedir(), ".claude", "projects"), override) {
|
|
167
|
+
if (override)
|
|
168
|
+
return existsSync(override) ? { ok: true, path: override } : { ok: false, reason: "no transcript" };
|
|
169
|
+
const id = env.CLAUDE_CODE_SESSION_ID;
|
|
170
|
+
if (!id) {
|
|
171
|
+
const piFile = env.PI_SESSION_FILE;
|
|
172
|
+
if (piFile !== undefined && piFile !== "") {
|
|
173
|
+
return existsSync(piFile) ? { ok: true, path: piFile } : { ok: false, reason: `PI_SESSION_FILE ${piFile} does not exist` };
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
reason: "no host session id; pass --transcript <path> (pi: ~/.pi/agent/sessions/<cwd-slug>/<file>.jsonl, " + "or set PI_SESSION_FILE)"
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (!SESSION_ID.test(id))
|
|
181
|
+
return { ok: false, reason: "refusing a session id with path characters" };
|
|
182
|
+
if (!existsSync(projectsRoot))
|
|
183
|
+
return { ok: false, reason: `no transcript root ${projectsRoot}` };
|
|
184
|
+
for (const dir of readdirSync(projectsRoot)) {
|
|
185
|
+
const path = join(projectsRoot, dir, `${id}.jsonl`);
|
|
186
|
+
if (existsSync(path))
|
|
187
|
+
return { ok: true, path };
|
|
188
|
+
}
|
|
189
|
+
return { ok: false, reason: `no transcript for session ${id}` };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// plugins/sp/scripts/session-timeline.ts
|
|
193
|
+
var PROMPT_EXCERPT = 80;
|
|
194
|
+
var render = (span) => ({
|
|
195
|
+
...span,
|
|
196
|
+
work: formatDuration(span.workMs),
|
|
197
|
+
wait: formatDuration(span.waitMs),
|
|
198
|
+
token: formatTokenSplit(span.tokens)
|
|
199
|
+
});
|
|
200
|
+
function parseGroups(spec, count) {
|
|
201
|
+
let next = 1;
|
|
202
|
+
return spec.split(",").map((part) => {
|
|
203
|
+
const [a = Number.NaN, b = a] = part.trim().split("-").map(Number);
|
|
204
|
+
if (!Number.isInteger(a) || !Number.isInteger(b) || a < next || b < a || b > count) {
|
|
205
|
+
throw new Error(`invalid --group "${part}" (segments 1-${count}, ascending, no overlap)`);
|
|
206
|
+
}
|
|
207
|
+
next = b + 1;
|
|
208
|
+
return [a, b];
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function buildTimeline(lines, group) {
|
|
212
|
+
const open = [];
|
|
213
|
+
const { rows, skippedLines, format } = parseRows(lines);
|
|
214
|
+
const isPi = format === "pi";
|
|
215
|
+
let injectedPrompts = 0;
|
|
216
|
+
for (const [row, ts] of rows) {
|
|
217
|
+
if (isPi && isInjectedPrompt(row)) {
|
|
218
|
+
injectedPrompts++;
|
|
219
|
+
const injectedSeg = open.at(-1);
|
|
220
|
+
if (injectedSeg)
|
|
221
|
+
accumulate(injectedSeg, row, ts);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const prompt = promptText(row);
|
|
225
|
+
if (prompt !== undefined) {
|
|
226
|
+
open.push({ start: ts, prompt, ...newAcc(ts) });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const seg = open.at(-1);
|
|
230
|
+
if (seg)
|
|
231
|
+
accumulate(seg, row, ts);
|
|
232
|
+
}
|
|
233
|
+
const segments = open.map((seg, i) => {
|
|
234
|
+
const next = open[i + 1]?.start;
|
|
235
|
+
const idle = next === undefined ? 0 : Math.max(0, next - seg.last);
|
|
236
|
+
const oneLine = seg.prompt.replace(/\s+/g, " ").trim();
|
|
237
|
+
const span = {
|
|
238
|
+
index: i + 1,
|
|
239
|
+
start: new Date(seg.start).toISOString(),
|
|
240
|
+
prompt: oneLine.length > PROMPT_EXCERPT ? `${oneLine.slice(0, PROMPT_EXCERPT)}\u2026` : oneLine,
|
|
241
|
+
workMs: seg.last - seg.start - seg.askMs,
|
|
242
|
+
waitMs: idle + seg.askMs,
|
|
243
|
+
toolCalls: seg.toolIds.size,
|
|
244
|
+
tokens: sumTokens([...seg.messages.values()])
|
|
245
|
+
};
|
|
246
|
+
return render(isPi ? { ...span, compactions: seg.compactions } : span);
|
|
247
|
+
});
|
|
248
|
+
const first = open[0];
|
|
249
|
+
const last = open.at(-1);
|
|
250
|
+
const elapsedMs = first && last ? last.last - first.start : 0;
|
|
251
|
+
const totals = { ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) };
|
|
252
|
+
const timeline = {
|
|
253
|
+
available: true,
|
|
254
|
+
segments,
|
|
255
|
+
totals: render(isPi ? { ...totals, compactions: segments.reduce((n, s) => n + (s.compactions ?? 0), 0) } : totals),
|
|
256
|
+
skippedLines,
|
|
257
|
+
...isPi ? { injectedPrompts } : {}
|
|
258
|
+
};
|
|
259
|
+
if (group) {
|
|
260
|
+
timeline.stages = parseGroups(group, segments.length).map(([a, b]) => render({ ...sumSpans(segments.slice(a - 1, b)), segments: a === b ? `${a}` : `${a}-${b}` }));
|
|
261
|
+
}
|
|
262
|
+
return timeline;
|
|
263
|
+
}
|
|
264
|
+
var SESSION_TIMELINE_USAGE = 'usage: session-timeline [--transcript <path>] [--group "1-3,4,..."]';
|
|
265
|
+
function zeroSegmentReason(rows) {
|
|
266
|
+
const format = sniffFormat(rows.map(([r]) => r));
|
|
267
|
+
if (format === "unknown") {
|
|
268
|
+
const counts = new Map;
|
|
269
|
+
for (const [r] of rows) {
|
|
270
|
+
const kind = typeof r.type === "string" && r.type !== "" ? r.type : "(untyped)";
|
|
271
|
+
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
|
272
|
+
}
|
|
273
|
+
const census = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5).map(([kind, n]) => `${kind} ${n}`).join(", ");
|
|
274
|
+
return `unrecognized transcript format (row types: ${census})`;
|
|
275
|
+
}
|
|
276
|
+
const injected = rows.filter(([r]) => isInjectedPrompt(r)).length;
|
|
277
|
+
return `${format} transcript with no operator prompts (${rows.length} rows, ${injected} injected)`;
|
|
278
|
+
}
|
|
279
|
+
function main(argv, env = getEnvVars(), write = (s) => process.stdout.write(s), projectsRoot, writeErr = (s) => process.stderr.write(s)) {
|
|
280
|
+
let transcript;
|
|
281
|
+
let group;
|
|
282
|
+
for (let i = 0;i < argv.length; i++) {
|
|
283
|
+
const arg = argv[i];
|
|
284
|
+
if (arg === "--transcript" && argv[i + 1])
|
|
285
|
+
transcript = argv[++i];
|
|
286
|
+
else if (arg === "--group" && argv[i + 1])
|
|
287
|
+
group = argv[++i];
|
|
288
|
+
else if (arg === "--spur-bin" && argv[i + 1])
|
|
289
|
+
i++;
|
|
290
|
+
else {
|
|
291
|
+
writeErr(`${SESSION_TIMELINE_USAGE}
|
|
292
|
+
`);
|
|
293
|
+
return 2;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const resolved = resolveTranscript(env, projectsRoot, transcript);
|
|
297
|
+
if (!resolved.ok) {
|
|
298
|
+
write(`${JSON.stringify({ available: false, reason: resolved.reason })}
|
|
299
|
+
`);
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
const lines = readFileSync(resolved.path, "utf8").split(`
|
|
304
|
+
`);
|
|
305
|
+
const timeline = buildTimeline(lines, group);
|
|
306
|
+
if (timeline.segments.length === 0) {
|
|
307
|
+
write(`${JSON.stringify({ available: false, reason: zeroSegmentReason(parseRows(lines).rows) })}
|
|
308
|
+
`);
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
write(`${JSON.stringify({ ...timeline, transcript: resolved.path })}
|
|
312
|
+
`);
|
|
313
|
+
return 0;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
writeErr(`session-timeline: ${error.message}
|
|
316
|
+
`);
|
|
317
|
+
return 2;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
process.exit(main(process.argv.slice(2)));
|
|
321
|
+
export {
|
|
322
|
+
zeroSegmentReason,
|
|
323
|
+
resolveTranscript,
|
|
324
|
+
parseGroups,
|
|
325
|
+
main,
|
|
326
|
+
formatTokens,
|
|
327
|
+
formatTokenSplit,
|
|
328
|
+
formatDuration,
|
|
329
|
+
buildTimeline,
|
|
330
|
+
SESSION_TIMELINE_USAGE
|
|
331
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* session-timeline — measured time and token evidence for the sp:session-review Time breakdown.
|
|
4
|
+
* The visible conversation carries no timestamps or usage (so the table read `n/a`); the host
|
|
5
|
+
* transcript carries both. Reads the active Claude Code JSONL read-only
|
|
6
|
+
* (`~/.claude/projects/<slug>/<CLAUDE_CODE_SESSION_ID>.jsonl`, or `--transcript`), one segment per
|
|
7
|
+
* operator prompt: work = prompt → last activity; wait = idle until the next prompt plus time spent
|
|
8
|
+
* answering an AskUserQuestion; tokens counted once per message id and rendered `<total> /
|
|
9
|
+
* <non-cached>`. `--group "1-3,4"` sums segments into stages. No transcript → `{available:false}`,
|
|
10
|
+
* exit 0, and the review renders `n/a` instead of guessing. Node builtins only (standalone plugin).
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { getEnvVars } from '../lib/env';
|
|
14
|
+
import {
|
|
15
|
+
type Acc,
|
|
16
|
+
accumulate,
|
|
17
|
+
formatDuration,
|
|
18
|
+
formatTokenSplit,
|
|
19
|
+
isInjectedPrompt,
|
|
20
|
+
newAcc,
|
|
21
|
+
parseRows,
|
|
22
|
+
promptText,
|
|
23
|
+
type Row,
|
|
24
|
+
resolveTranscript,
|
|
25
|
+
type Span,
|
|
26
|
+
sniffFormat,
|
|
27
|
+
sumSpans,
|
|
28
|
+
sumTokens,
|
|
29
|
+
} from '../lib/transcript';
|
|
30
|
+
|
|
31
|
+
export { formatDuration, formatTokenSplit, formatTokens, resolveTranscript } from '../lib/transcript';
|
|
32
|
+
|
|
33
|
+
/** A span plus its `M:SS` / `H:MM:SS` and token renderings, so the review table needs no arithmetic. */
|
|
34
|
+
export type Rendered<T> = T & { work: string; wait: string; token: string };
|
|
35
|
+
|
|
36
|
+
export interface Timeline {
|
|
37
|
+
available: true;
|
|
38
|
+
transcript?: string;
|
|
39
|
+
segments: Rendered<Span & { index: number; start: string; prompt: string; compactions?: number }>[];
|
|
40
|
+
stages?: Rendered<Span & { segments: string }>[];
|
|
41
|
+
totals: Rendered<Span & { elapsedMs: number; elapsed: string; compactions?: number }>;
|
|
42
|
+
skippedLines: number;
|
|
43
|
+
/** pi only (1138 R2): skill-injected user bodies counted as activity, never as segments. */
|
|
44
|
+
injectedPrompts?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PROMPT_EXCERPT = 80;
|
|
48
|
+
|
|
49
|
+
const render = <T extends Span>(span: T): Rendered<T> => ({
|
|
50
|
+
...span,
|
|
51
|
+
work: formatDuration(span.workMs),
|
|
52
|
+
wait: formatDuration(span.waitMs),
|
|
53
|
+
token: formatTokenSplit(span.tokens),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** "1-3,4" → [[1,3],[4,4]]; 1-based, ascending, in range, non-overlapping. */
|
|
57
|
+
export function parseGroups(spec: string, count: number): [number, number][] {
|
|
58
|
+
let next = 1;
|
|
59
|
+
return spec.split(',').map((part) => {
|
|
60
|
+
const [a = Number.NaN, b = a] = part.trim().split('-').map(Number);
|
|
61
|
+
if (!Number.isInteger(a) || !Number.isInteger(b) || a < next || b < a || b > count) {
|
|
62
|
+
throw new Error(`invalid --group "${part}" (segments 1-${count}, ascending, no overlap)`);
|
|
63
|
+
}
|
|
64
|
+
next = b + 1;
|
|
65
|
+
return [a, b] as [number, number];
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface Open extends Acc {
|
|
70
|
+
start: number;
|
|
71
|
+
prompt: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildTimeline(lines: string[], group?: string): Timeline {
|
|
75
|
+
const open: Open[] = [];
|
|
76
|
+
const { rows, skippedLines, format } = parseRows(lines);
|
|
77
|
+
const isPi = format === 'pi';
|
|
78
|
+
let injectedPrompts = 0;
|
|
79
|
+
|
|
80
|
+
for (const [row, ts] of rows) {
|
|
81
|
+
// 1138 R2: an injected skill body is activity, not a prompt — accumulate it into the open
|
|
82
|
+
// segment (its tools and usage are real work) and count it, never open a segment.
|
|
83
|
+
if (isPi && isInjectedPrompt(row)) {
|
|
84
|
+
injectedPrompts++;
|
|
85
|
+
const injectedSeg = open.at(-1);
|
|
86
|
+
if (injectedSeg) accumulate(injectedSeg, row, ts);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const prompt = promptText(row);
|
|
90
|
+
if (prompt !== undefined) {
|
|
91
|
+
open.push({ start: ts, prompt, ...newAcc(ts) });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const seg = open.at(-1);
|
|
95
|
+
if (seg) accumulate(seg, row, ts); // rows before the first prompt are session preamble
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const segments = open.map(
|
|
99
|
+
(seg, i): Rendered<Span & { index: number; start: string; prompt: string; compactions?: number }> => {
|
|
100
|
+
const next = open[i + 1]?.start;
|
|
101
|
+
const idle = next === undefined ? 0 : Math.max(0, next - seg.last);
|
|
102
|
+
const oneLine = seg.prompt.replace(/\s+/g, ' ').trim();
|
|
103
|
+
const span = {
|
|
104
|
+
index: i + 1,
|
|
105
|
+
start: new Date(seg.start).toISOString(),
|
|
106
|
+
prompt: oneLine.length > PROMPT_EXCERPT ? `${oneLine.slice(0, PROMPT_EXCERPT)}…` : oneLine,
|
|
107
|
+
workMs: seg.last - seg.start - seg.askMs,
|
|
108
|
+
waitMs: idle + seg.askMs,
|
|
109
|
+
toolCalls: seg.toolIds.size,
|
|
110
|
+
tokens: sumTokens([...seg.messages.values()]),
|
|
111
|
+
};
|
|
112
|
+
return render(isPi ? { ...span, compactions: seg.compactions } : span);
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const first = open[0];
|
|
117
|
+
const last = open.at(-1);
|
|
118
|
+
const elapsedMs = first && last ? last.last - first.start : 0;
|
|
119
|
+
const totals = { ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) };
|
|
120
|
+
const timeline: Timeline = {
|
|
121
|
+
available: true,
|
|
122
|
+
segments,
|
|
123
|
+
totals: render(
|
|
124
|
+
isPi ? { ...totals, compactions: segments.reduce((n, s) => n + (s.compactions ?? 0), 0) } : totals,
|
|
125
|
+
),
|
|
126
|
+
skippedLines,
|
|
127
|
+
// Emitted only for pi, as `compactions` is, so Claude output is unchanged (1138 R2).
|
|
128
|
+
...(isPi ? { injectedPrompts } : {}),
|
|
129
|
+
};
|
|
130
|
+
if (group) {
|
|
131
|
+
timeline.stages = parseGroups(group, segments.length).map(([a, b]) =>
|
|
132
|
+
render({ ...sumSpans(segments.slice(a - 1, b)), segments: a === b ? `${a}` : `${a}-${b}` }),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return timeline;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const SESSION_TIMELINE_USAGE = 'usage: session-timeline [--transcript <path>] [--group "1-3,4,..."]';
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 1138 R3: a zero-segment result must say WHAT was detected, so an unparseable-but-recognised
|
|
142
|
+
* transcript can never read as an authoritative all-zero timeline. A known format names the
|
|
143
|
+
* row count and the injected count; an unknown one lists the top-5 row-type census.
|
|
144
|
+
*/
|
|
145
|
+
export function zeroSegmentReason(rows: [Row, number][]): string {
|
|
146
|
+
const format = sniffFormat(rows.map(([r]) => r));
|
|
147
|
+
if (format === 'unknown') {
|
|
148
|
+
const counts = new Map<string, number>();
|
|
149
|
+
for (const [r] of rows) {
|
|
150
|
+
const kind = typeof r.type === 'string' && r.type !== '' ? r.type : '(untyped)';
|
|
151
|
+
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
|
152
|
+
}
|
|
153
|
+
const census = [...counts.entries()]
|
|
154
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
155
|
+
.slice(0, 5)
|
|
156
|
+
.map(([kind, n]) => `${kind} ${n}`)
|
|
157
|
+
.join(', ');
|
|
158
|
+
return `unrecognized transcript format (row types: ${census})`;
|
|
159
|
+
}
|
|
160
|
+
const injected = rows.filter(([r]) => isInjectedPrompt(r)).length;
|
|
161
|
+
return `${format} transcript with no operator prompts (${rows.length} rows, ${injected} injected)`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function main(
|
|
165
|
+
argv: string[],
|
|
166
|
+
env: Record<string, string | undefined> = getEnvVars(),
|
|
167
|
+
write: (s: string) => void = (s) => process.stdout.write(s),
|
|
168
|
+
projectsRoot?: string,
|
|
169
|
+
writeErr: (s: string) => void = (s) => process.stderr.write(s),
|
|
170
|
+
): number {
|
|
171
|
+
let transcript: string | undefined;
|
|
172
|
+
let group: string | undefined;
|
|
173
|
+
for (let i = 0; i < argv.length; i++) {
|
|
174
|
+
const arg = argv[i];
|
|
175
|
+
if (arg === '--transcript' && argv[i + 1]) transcript = argv[++i];
|
|
176
|
+
else if (arg === '--group' && argv[i + 1]) group = argv[++i];
|
|
177
|
+
else if (arg === '--spur-bin' && argv[i + 1])
|
|
178
|
+
i++; // 0482 R2: accepted, unused (no spur calls)
|
|
179
|
+
else {
|
|
180
|
+
writeErr(`${SESSION_TIMELINE_USAGE}\n`);
|
|
181
|
+
return 2;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const resolved = resolveTranscript(env, projectsRoot, transcript);
|
|
185
|
+
if (!resolved.ok) {
|
|
186
|
+
write(`${JSON.stringify({ available: false, reason: resolved.reason })}\n`);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const lines = readFileSync(resolved.path, 'utf8').split('\n');
|
|
191
|
+
const timeline = buildTimeline(lines, group);
|
|
192
|
+
if (timeline.segments.length === 0) {
|
|
193
|
+
write(`${JSON.stringify({ available: false, reason: zeroSegmentReason(parseRows(lines).rows) })}\n`);
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
write(`${JSON.stringify({ ...timeline, transcript: resolved.path })}\n`);
|
|
197
|
+
return 0;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
writeErr(`session-timeline: ${(error as Error).message}\n`);
|
|
200
|
+
return 2;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (import.meta.main) process.exit(main(process.argv.slice(2)));
|