@gobing-ai/spur 0.4.2 → 0.4.3
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/decisions/maker-liveness.yaml +39 -0
- package/config/plugin-scripts.json +0 -4
- package/config/rules/README.md +1 -0
- package/config/rules/docs/sourced-api-claims.yaml +56 -12
- package/config/rules/strict/runtime-boundaries.yaml +7 -0
- package/config/script-placement-baseline.json +1 -19
- package/config/workflows/feature-lifecycle.yaml +46 -1
- package/config/workflows/feature-verification.yaml +11 -0
- package/package.json +9 -9
- package/plugins/sp/README.md +22 -16
- package/plugins/sp/agents/super-reviewer.md +43 -0
- package/plugins/sp/bunfig.toml +8 -5
- package/plugins/sp/hooks/careful-guard.ts +1 -1
- package/plugins/sp/hooks/context-post-tool.ts +7 -427
- package/plugins/sp/hooks/context-session-start.ts +5 -213
- package/plugins/sp/hooks/context-session-stop.ts +5 -191
- package/plugins/sp/hooks/pi/guard-extension.ts +6 -5
- package/plugins/sp/hooks/task-write-guard.ts +1 -1
- package/plugins/sp/{hooks → lib}/agent-hint.ts +1 -1
- package/plugins/sp/lib/context-post-tool.ts +432 -0
- package/plugins/sp/lib/context-session-start.ts +214 -0
- package/plugins/sp/lib/context-session-stop.ts +196 -0
- package/plugins/sp/lib/dogfood-pipeline.ts +265 -0
- package/plugins/sp/lib/dogfood-report.ts +278 -0
- package/plugins/sp/lib/idea-handoff.generated.mjs +3 -3
- package/plugins/sp/lib/inline-run-app.ts +40 -0
- package/plugins/sp/lib/inline-run.generated.d.mts +3 -3
- package/plugins/sp/lib/inline-run.generated.mjs +1347 -90
- package/plugins/sp/lib/plan-projection.generated.d.mts +150 -0
- package/plugins/sp/lib/plan-projection.generated.mjs +591 -1
- package/plugins/sp/lib/quality-gate.generated.mjs +38 -25
- package/plugins/sp/lib/residual-scan.generated.d.mts +7 -1
- package/plugins/sp/lib/residual-scan.generated.mjs +71 -6
- package/plugins/sp/lib/run-summary-core.ts +34 -370
- package/plugins/sp/lib/transcript.ts +37 -228
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/batch-plan.mjs +586 -0
- package/plugins/sp/scripts/batch-preflight.mjs +23 -1
- package/plugins/sp/scripts/batch-preflight.ts +35 -295
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +108 -87
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +111 -104
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -1
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +7 -265
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +3 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +6 -272
- package/plugins/sp/scripts/feature-verification-steps.mjs +7 -123
- package/plugins/sp/scripts/feature-verification-steps.ts +29 -181
- package/plugins/sp/scripts/inline-run-dispatch.mjs +27 -26
- package/plugins/sp/scripts/inline-run-dispatch.ts +1 -21
- package/plugins/sp/scripts/inline-run-setup.mjs +149 -122
- package/plugins/sp/scripts/inline-run-setup.ts +1 -21
- package/plugins/sp/scripts/quality-gate.mjs +14 -2
- package/plugins/sp/scripts/residual-scan.mjs +73 -6
- package/plugins/sp/scripts/residual-scan.ts +8 -5
- package/plugins/sp/scripts/run-summary.mjs +151 -97
- package/plugins/sp/scripts/run-summary.ts +7 -5
- package/plugins/sp/scripts/session-timeline.mjs +46 -19
- package/plugins/sp/scripts/task-diffstat.mjs +11 -1
- package/plugins/sp/scripts/task-diffstat.ts +8 -39
- package/plugins/sp/skills/code-implementation/SKILL.md +7 -1
- package/plugins/sp/skills/code-verification/references/secu-review.md +2 -1
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +4 -0
- package/plugins/sp/skills/dogfood-testing/SKILL.md +2 -2
- package/plugins/sp/skills/next-feature/SKILL.md +1 -1
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
- package/plugins/sp/skills/source-driven-development/SKILL.md +7 -4
- package/plugins/sp/skills/spur-cli/references/builder.md +3 -2
- package/plugins/sp/skills/spur-cli/references/decision.md +32 -7
- package/plugins/sp/skills/spur-cli/references/features.md +46 -5
- package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +1 -1
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +13 -3
- package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +22 -0
- package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +14 -0
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +29 -12
- package/spur.js +80681 -79296
- package/web/_astro/{BoardApp.B3GQZvj8.js → BoardApp.5IIl2O-z.js} +1 -1
- package/web/_astro/{BoardApp.BT7L2kSb.js → BoardApp.Drnx-KeM.js} +150 -150
- package/web/_astro/{TaskDetail.Cxov1_N_.js → TaskDetail.Ci4LNn9S.js} +1 -1
- package/web/_astro/{arc.vaT7tPOB.js → arc.j2EVkYVd.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.DRMmTjkN.js → architectureDiagram-3BPJPVTR.DZWSFqEG.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.cla0LO_q.js → blockDiagram-GPEHLZMM.DE6GpXCN.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.D4YAIBKn.js → c4Diagram-AAUBKEIU.DoVR1G6B.js} +1 -1
- package/web/_astro/channel.DmdDxGsq.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.BrSJbMRZ.js → chunk-2J33WTMH.Do6-Gbyf.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.CuLMgKF5.js → chunk-4BX2VUAB.BYV1HGHx.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.zkvgu54F.js → chunk-55IACEB6.CvI1zkrc.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.DxWkACh8.js → chunk-727SXJPM.DNGSCRlX.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.42XrmIlH.js → chunk-AQP2D5EJ.DXhhgT5z.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.Bao2wMK9.js → chunk-FMBD7UC4.dzkRKP2u.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.BhydojD1.js → chunk-ND2GUHAM.DujGLeEf.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.CmZ82g-H.js → chunk-QZHKN3VN.BWtIT3ZU.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.Blr83Gt_.js → classDiagram-4FO5ZUOK.DyApg0Nn.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.Blr83Gt_.js → classDiagram-v2-Q7XG4LA2.DyApg0Nn.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.qmfv0SPn.js → cose-bilkent-S5V4N54A.DJQB-u_l.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.BtELhXPR.js → cynefin-OW5HDTMX.bi3D_siV.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.AWmaAvXo.js → dagre-BM42HDAG.C-f7TrRg.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.DdH34KDi.js → diagram-2AECGRRQ.DrPfjHQB.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.BmNPix1k.js → diagram-5GNKFQAL.C9S4WUHO.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.BNQd_Ppo.js → diagram-KO2AKTUF.Dzzye8QH.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.BAPgDi0e.js → diagram-LMA3HP47.DX-LRiof.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.DZ4zyLdC.js → diagram-OG6HWLK6.Z-kAKwIz.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.Q1TySMmw.js → erDiagram-TEJ5UH35.D6dyClSv.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.BXhFBicG.js → flowDiagram-I6XJVG4X.Cx3q2cxD.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.vHSgcuo2.js → ganttDiagram-6RSMTGT7.DU3gH6CT.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.Bi1f73C7.js → gitGraphDiagram-PVQCEYII.ISmCnJ87.js} +1 -1
- package/web/_astro/index.Do2lLdgo.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.DPvXZEWm.js → infoDiagram-5YYISTIA.DR6_iNOK.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.Cz829fsV.js → ishikawaDiagram-YF4QCWOH.D7wY-ZGA.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.enRnB9Me.js → journeyDiagram-JHISSGLW.DkP9-WK8.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.DnTNk6d2.js → kanban-definition-UN3LZRKU.wtgY98M2.js} +1 -1
- package/web/_astro/{linear.BzGnBwNL.js → linear.CI6djNi7.js} +1 -1
- package/web/_astro/{mermaid.core.CSbTpIVz.js → mermaid.core.CuQPxS8b.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.DGJDy8zT.js → mindmap-definition-RKZ34NQL.5HNIEG7R.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.HSPdi5_8.js → pieDiagram-4H26LBE5.CchhDFjE.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.6Gv8mz2Y.js → quadrantDiagram-W4KKPZXB.PYXXJPSJ.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.CnBCtFwa.js → requirementDiagram-4Y6WPE33.cUQuNAKU.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.5M8VWAu8.js → sankeyDiagram-5OEKKPKP.BO7MnRIA.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.DN41XN-U.js → sequenceDiagram-3UESZ5HK.D9bElyD6.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.ErOO3W8j.js → stateDiagram-AJRCARHV.DhGdRz7I.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.B_ZHICM-.js → stateDiagram-v2-BHNVJYJU.5GvLeAoL.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.CWdosr07.js → timeline-definition-PNZ67QCA.LtAT9haM.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.BHzYjNV5.js → vennDiagram-CIIHVFJN.DRth1yLA.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.h8x4IwPX.js → wardleyDiagram-YWT4CUSO.h9MlZfcr.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.RTapW2X8.js → xychartDiagram-2RQKCTM6.DHqy9DYA.js} +1 -1
- package/web/index.html +2 -2
- package/config/proportional-route-table.ts +0 -155
- package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
- package/web/_astro/channel.D4nG9YLf.js +0 -1
- package/web/_astro/index.BhSwN8yY.css +0 -1
- /package/plugins/sp/{hooks → lib}/context-owner.ts +0 -0
- /package/plugins/sp/{hooks → lib}/destructive-policy.ts +0 -0
- /package/plugins/sp/{hooks → lib}/task-file-policy.ts +0 -0
|
@@ -1,200 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* context-session-stop — SessionEnd
|
|
3
|
+
* context-session-stop — installed SessionEnd entry for indexed-context (session teardown).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* per-turn and tore down after turn 1), and on Pi/omp it maps to `session_shutdown` — off the
|
|
9
|
-
* `agent_end`/Stop event where blocking gates (e.g. cc/anti-hallucination) live and would collide.
|
|
10
|
-
*
|
|
11
|
-
* **Owner-gated finalization (task 1159).** A nested `agent.run` reuses its parent's pointer but
|
|
12
|
-
* used to finalize and delete it on its own shutdown, so every later parent tool event landed in a
|
|
13
|
-
* dead ledger. Shutdown now proceeds only when the caller's host-native identity equals the owner
|
|
14
|
-
* recorded on the pointer, so a child's shutdown is a no-op.
|
|
15
|
-
*
|
|
16
|
-
* **Fail-open contract:** every error path — missing `.spur/context/`, missing/unparseable
|
|
17
|
-
* `.session.json`, write failure — exits 0 with no output.
|
|
18
|
-
*
|
|
19
|
-
* Self-contained by design (task 0232). Installed hook configs use the portable
|
|
20
|
-
* `superskill hook run sp context-session-stop` entrypoint.
|
|
5
|
+
* Task 1174: the owner-gated finalization logic lives in `plugins/sp/lib/context-session-stop.ts`;
|
|
6
|
+
* this file is the argv/stdin adapter. Every error path exits 0 with no output, and a nested
|
|
7
|
+
* `agent.run`'s shutdown stays a no-op because the owner gate lives in the core, not here.
|
|
21
8
|
*/
|
|
22
9
|
|
|
23
|
-
import {
|
|
24
|
-
import { join } from 'node:path';
|
|
25
|
-
import { getEnvVar, getEnvVars } from '../lib/env';
|
|
26
|
-
import { claudeHookOwnerFromPayloadText } from './context-owner';
|
|
27
|
-
// Type-only import: erased at bundle time, so this hook keeps its minimal
|
|
28
|
-
// node:fs/node:path/env dependency surface (sp-plugin-standalone contract).
|
|
29
|
-
import type { SessionOwner } from './context-session-start';
|
|
30
|
-
|
|
31
|
-
interface LedgerEvent {
|
|
32
|
-
session: string;
|
|
33
|
-
type: string;
|
|
34
|
-
tokens?: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface SessionPointer {
|
|
38
|
-
session?: unknown;
|
|
39
|
-
owner?: unknown;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Parse and validate an owner read from the pointer or supplied by a caller. */
|
|
43
|
-
function validSessionOwner(raw: unknown): SessionOwner | null {
|
|
44
|
-
if (!raw || typeof raw !== 'object') return null;
|
|
45
|
-
const host = (raw as { host?: unknown }).host;
|
|
46
|
-
const sessionId = (raw as { sessionId?: unknown }).sessionId;
|
|
47
|
-
if (host !== 'claude' && host !== 'pi') return null;
|
|
48
|
-
if (typeof sessionId !== 'string') return null;
|
|
49
|
-
const trimmed = sessionId.trim();
|
|
50
|
-
return trimmed.length > 0 ? { host, sessionId: trimmed } : null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function sameOwner(a: SessionOwner, b: SessionOwner): boolean {
|
|
54
|
-
return a.host === b.host && a.sessionId === b.sessionId;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Read and validate the private session pointer, or null when absent/corrupt. */
|
|
58
|
-
function readPointer(sessionFile: string): SessionPointer | null {
|
|
59
|
-
if (!existsSync(sessionFile)) return null;
|
|
60
|
-
try {
|
|
61
|
-
return JSON.parse(readFileSync(sessionFile, 'utf-8')) as SessionPointer;
|
|
62
|
-
} catch {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Scan the token-ledger JSONL for this session's events and compute rollup totals. */
|
|
68
|
-
function computeSessionTotals(
|
|
69
|
-
ledgerPath: string,
|
|
70
|
-
sessionId: string,
|
|
71
|
-
): { reads: number; writes: number; tokens: number } {
|
|
72
|
-
let reads = 0;
|
|
73
|
-
let writes = 0;
|
|
74
|
-
let tokens = 0;
|
|
75
|
-
let lines: string[];
|
|
76
|
-
try {
|
|
77
|
-
lines = readFileSync(ledgerPath, 'utf-8').split('\n');
|
|
78
|
-
} catch {
|
|
79
|
-
return { reads, writes, tokens }; // missing/unreadable ledger → zero totals
|
|
80
|
-
}
|
|
81
|
-
for (const line of lines) {
|
|
82
|
-
if (!line.trim()) continue;
|
|
83
|
-
try {
|
|
84
|
-
const evt = JSON.parse(line) as LedgerEvent;
|
|
85
|
-
if (evt.session !== sessionId) continue;
|
|
86
|
-
if (evt.type === 'read') reads++;
|
|
87
|
-
else if (evt.type === 'write') writes++;
|
|
88
|
-
if (evt.tokens) tokens += evt.tokens;
|
|
89
|
-
} catch {
|
|
90
|
-
// skip unparseable lines
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return { reads, writes, tokens };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Return the existing `session_end` row for `sessionId`, or null when the ledger has none. */
|
|
97
|
-
function findSessionEnd(ledgerPath: string, sessionId: string): Record<string, unknown> | null {
|
|
98
|
-
let lines: string[];
|
|
99
|
-
try {
|
|
100
|
-
lines = readFileSync(ledgerPath, 'utf-8').split('\n');
|
|
101
|
-
} catch {
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
for (const line of lines) {
|
|
105
|
-
if (!line.trim()) continue;
|
|
106
|
-
try {
|
|
107
|
-
const evt = JSON.parse(line) as Record<string, unknown>;
|
|
108
|
-
if (evt.type === 'session_end' && evt.session === sessionId) return evt;
|
|
109
|
-
} catch {
|
|
110
|
-
// skip unparseable lines
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return null;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Finalize one session (task 0969 R3; owner-gated task 1159): read the `.session.json` pointer,
|
|
118
|
-
* compute the session's rollup totals, append the `session_end` row, remove the pointer
|
|
119
|
-
* (best-effort) and return the event. Shared by this hook's entrypoint and Pi's `session_shutdown`
|
|
120
|
-
* so both hosts roll up identically. Returns null on every fail-open path.
|
|
121
|
-
*
|
|
122
|
-
* `caller` is the identity of the process firing shutdown. Finalization requires it to be a valid
|
|
123
|
-
* identity that **equals** the pointer's recorded owner; a missing/mismatched/malformed caller, a
|
|
124
|
-
* legacy ownerless pointer or a corrupt pointer all return null with no writes and no removal.
|
|
125
|
-
* Ownerless pointers stay readable and reusable for tool recording — the bounded legacy policy is
|
|
126
|
-
* that an unattributable session is retired by the existing idle-start expiry, never claimed here.
|
|
127
|
-
*
|
|
128
|
-
* Repeat safety: before appending, an existing `session_end` row for the ledger suppresses a
|
|
129
|
-
* second one, so a replay after a failed pointer cleanup neither duplicates the row nor strands the
|
|
130
|
-
* pointer. Removal re-reads the pointer and requires both the ledger id and the owner to be
|
|
131
|
-
* unchanged, so a pointer replaced since the first read is left untouched.
|
|
132
|
-
*/
|
|
133
|
-
export function recordSessionEnd(
|
|
134
|
-
dir: string,
|
|
135
|
-
now: () => Date = () => new Date(),
|
|
136
|
-
caller?: SessionOwner,
|
|
137
|
-
): Record<string, unknown> | null {
|
|
138
|
-
const sessionFile = join(dir, '.session.json');
|
|
139
|
-
const pointer = readPointer(sessionFile);
|
|
140
|
-
if (!pointer) return null;
|
|
141
|
-
|
|
142
|
-
const sessionId = typeof pointer.session === 'string' ? pointer.session : '';
|
|
143
|
-
if (!sessionId) return null;
|
|
144
|
-
|
|
145
|
-
// Ownership gate: only the identity recorded at creation may finalize or remove.
|
|
146
|
-
const pointerOwner = validSessionOwner(pointer.owner);
|
|
147
|
-
const callerOwner = validSessionOwner(caller);
|
|
148
|
-
if (!pointerOwner || !callerOwner || !sameOwner(pointerOwner, callerOwner)) return null;
|
|
149
|
-
|
|
150
|
-
const ledgerPath = join(dir, 'token-ledger.jsonl');
|
|
151
|
-
|
|
152
|
-
let event: Record<string, unknown>;
|
|
153
|
-
const existing = findSessionEnd(ledgerPath, sessionId);
|
|
154
|
-
if (existing) {
|
|
155
|
-
// Replay: the end row survived an earlier failed pointer cleanup. Retry the removal below
|
|
156
|
-
// without appending a duplicate.
|
|
157
|
-
event = existing;
|
|
158
|
-
} else {
|
|
159
|
-
const totals = computeSessionTotals(ledgerPath, sessionId);
|
|
160
|
-
event = { ts: now().toISOString(), session: sessionId, type: 'session_end', totals };
|
|
161
|
-
try {
|
|
162
|
-
appendFileSync(ledgerPath, `${JSON.stringify(event)}\n`);
|
|
163
|
-
} catch {
|
|
164
|
-
return null; // retain the pointer so a later owning shutdown can retry
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Remove only the exact pointer finalized above: re-read and require the ledger id and owner
|
|
169
|
-
// to be unchanged, so a pointer replaced during recording survives.
|
|
170
|
-
try {
|
|
171
|
-
const current = readPointer(sessionFile);
|
|
172
|
-
const currentOwner = current ? validSessionOwner(current.owner) : null;
|
|
173
|
-
if (current?.session === sessionId && currentOwner && sameOwner(currentOwner, pointerOwner)) {
|
|
174
|
-
rmSync(sessionFile, { force: true });
|
|
175
|
-
}
|
|
176
|
-
} catch {
|
|
177
|
-
// cleanup is best-effort
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return event;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Finalize the session for a raw Claude stdin payload (task 1159, testable half).
|
|
185
|
-
*
|
|
186
|
-
* Malformed/absent text carries no identity, so shutdown fails closed; the dir resolution lives here
|
|
187
|
-
* rather than in the entrypoint so the untestable subprocess body stays a few lines.
|
|
188
|
-
*/
|
|
189
|
-
export function recordSessionEndFromPayloadText(
|
|
190
|
-
text: string,
|
|
191
|
-
env: NodeJS.ProcessEnv = getEnvVars(),
|
|
192
|
-
): Record<string, unknown> | null {
|
|
193
|
-
const dir = join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context');
|
|
194
|
-
return recordSessionEnd(dir, undefined, claudeHookOwnerFromPayloadText(text, env) ?? undefined);
|
|
195
|
-
}
|
|
10
|
+
import { recordSessionEndFromPayloadText } from '../lib/context-session-stop';
|
|
196
11
|
|
|
197
|
-
// Entrypoint — thin stdin adapter (fail-open); logic lives in the exported core above.
|
|
198
12
|
if (import.meta.main) {
|
|
199
13
|
try {
|
|
200
14
|
recordSessionEndFromPayloadText(await Bun.stdin.text());
|
|
@@ -27,13 +27,14 @@ import { existsSync, realpathSync } from 'node:fs';
|
|
|
27
27
|
import { homedir } from 'node:os';
|
|
28
28
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
29
29
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
30
|
+
import type { SessionOwner } from '../../lib/context-owner';
|
|
31
|
+
import { recordToolUseEvent, type ToolPayload } from '../../lib/context-post-tool';
|
|
32
|
+
import { recordSessionStart } from '../../lib/context-session-start';
|
|
33
|
+
import { recordSessionEnd } from '../../lib/context-session-stop';
|
|
34
|
+
import { classifyCommand } from '../../lib/destructive-policy';
|
|
30
35
|
import { getEnvVar, getEnvVars } from '../../lib/env';
|
|
36
|
+
import { couldBeTaskFile } from '../../lib/task-file-policy';
|
|
31
37
|
import { reportLifecycleHook } from '../agent-lifecycle';
|
|
32
|
-
import { recordToolUseEvent, type ToolPayload } from '../context-post-tool';
|
|
33
|
-
import { recordSessionStart, type SessionOwner } from '../context-session-start';
|
|
34
|
-
import { recordSessionEnd } from '../context-session-stop';
|
|
35
|
-
import { classifyCommand } from '../destructive-policy';
|
|
36
|
-
import { couldBeTaskFile } from '../task-file-policy';
|
|
37
38
|
|
|
38
39
|
// ─── Constants ───────────────────────────────────────────────────────────
|
|
39
40
|
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { spawnSync } from 'node:child_process';
|
|
23
23
|
import { getEnvVar } from '../lib/env';
|
|
24
|
-
import { couldBeTaskFile } from '
|
|
24
|
+
import { couldBeTaskFile } from '../lib/task-file-policy';
|
|
25
25
|
|
|
26
26
|
interface ToolPayload {
|
|
27
27
|
tool_name?: string;
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-post-tool — PostToolUse hook for indexed-context
|
|
3
|
+
* (matcher: Bash|Grep|Glob|Read|Write|Edit — task 0248).
|
|
4
|
+
*
|
|
5
|
+
* Appends one event line to `token-ledger.jsonl` per tool call: path and/or short
|
|
6
|
+
* summary, action, and token estimate. Reads session ID (+ optional agent/model)
|
|
7
|
+
* from `.spur/context/.session.json`.
|
|
8
|
+
*
|
|
9
|
+
* Token cascade (task 0246): tool_response.content → Write tool_input.content →
|
|
10
|
+
* Edit old/new strings → Read file stat → omit tokens (never store 0 for unknown).
|
|
11
|
+
*
|
|
12
|
+
* Redaction (task 0248): store summary only (never full stdout); cap text ~4 KiB;
|
|
13
|
+
* strip obvious secret patterns. Bash tokens estimate from capped stdout/stderr length.
|
|
14
|
+
*
|
|
15
|
+
* **Fail-open contract:** every error path exits 0 with no output.
|
|
16
|
+
*
|
|
17
|
+
* Self-contained by design (task 0232/0246/0248). Installed hook configs use
|
|
18
|
+
* `superskill hook run sp context-post-tool`.
|
|
19
|
+
*
|
|
20
|
+
* Task 1174: this core lives in `plugins/sp/lib/`; `plugins/sp/hooks/context-post-tool.ts` keeps the argv/stdin adapter the installed entry resolves to.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { execSync } from 'node:child_process';
|
|
24
|
+
import { appendFileSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { join, resolve, sep } from 'node:path';
|
|
26
|
+
|
|
27
|
+
/** Tools recorded by this hook (must match hooks.json PostToolUse matcher). */
|
|
28
|
+
export const ALLOWED_TOOLS = new Set(['Bash', 'Grep', 'Glob', 'Read', 'Write', 'Edit']);
|
|
29
|
+
|
|
30
|
+
/** Max stored summary / token-estimate input size (bytes). Task 0248 ~2–4 KiB. */
|
|
31
|
+
export const REDACTION_CAP_BYTES = 4096;
|
|
32
|
+
|
|
33
|
+
/** Max command/pattern summary length before ellipsis (chars). */
|
|
34
|
+
export const SUMMARY_MAX_CHARS = 200;
|
|
35
|
+
|
|
36
|
+
export interface ToolPayload {
|
|
37
|
+
session_id?: string;
|
|
38
|
+
tool_name?: string;
|
|
39
|
+
tool_input?: {
|
|
40
|
+
file_path?: string;
|
|
41
|
+
content?: string;
|
|
42
|
+
old_string?: string;
|
|
43
|
+
new_string?: string;
|
|
44
|
+
/** Bash */
|
|
45
|
+
command?: string;
|
|
46
|
+
/** Grep / Glob (Claude Code shapes vary) */
|
|
47
|
+
pattern?: string;
|
|
48
|
+
glob_pattern?: string;
|
|
49
|
+
glob?: string;
|
|
50
|
+
path?: string;
|
|
51
|
+
};
|
|
52
|
+
tool_response?: {
|
|
53
|
+
content?: string | unknown;
|
|
54
|
+
filePath?: string;
|
|
55
|
+
stdout?: string;
|
|
56
|
+
stderr?: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface SessionFile {
|
|
61
|
+
session?: string;
|
|
62
|
+
agent?: string;
|
|
63
|
+
model?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Estimate tokens from raw byte count: Math.ceil(bytes / 4). */
|
|
67
|
+
export function estimateTokens(text: string): number {
|
|
68
|
+
return Math.ceil(new TextEncoder().encode(text).length / 4);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Scrub secret-like patterns without size capping. */
|
|
72
|
+
export function scrubSecrets(text: string): string {
|
|
73
|
+
let s = text;
|
|
74
|
+
// Quoted JSON fields first (task 1158): in `{"password": "…"}` the key is itself
|
|
75
|
+
// quoted, so the bare `key=value` pass below never sees a separator directly after
|
|
76
|
+
// the key and would persist the value verbatim. Match the quoted sensitive key, keep
|
|
77
|
+
// it, and replace the entire value. The key may be spelled three ways in real Bash:
|
|
78
|
+
// `"key"`, `'key'`, and the shell-escaped `\"key\"` inside a double-quoted argument
|
|
79
|
+
// (`curl -d "{\"password\":\"…\"}"`); the value may be quoted the same way or left
|
|
80
|
+
// unquoted (`{"password": 12345}`). Escape-aware: a `\"` or `\\` inside a value is
|
|
81
|
+
// content, never a terminator, and an unterminated value (including a trailing lone
|
|
82
|
+
// backslash) is redacted through end-of-input so a truncated command cannot leak a
|
|
83
|
+
// suffix. In the shell-escaped opener form the JSON quote shows up in three raw
|
|
84
|
+
// spellings: a terminator `\"`, a JSON-level escaped quote `\\\"` (escaped
|
|
85
|
+
// backslash + escaped quote) that is value content, and a bare `"` that is neither.
|
|
86
|
+
// That form gets a dedicated alternative whose body consumes `\\\"` as content and
|
|
87
|
+
// stops only at a clean `\"` terminator; when no clean terminator follows it fails
|
|
88
|
+
// closed and redacts through end-of-input, so a bare `"` cannot end the value early
|
|
89
|
+
// and leak its suffix. Runs before
|
|
90
|
+
// Bearer/`key=` passes so the whole value is replaced, not prefixed. The key must end
|
|
91
|
+
// on a sensitive word, so `token_count`, `tokens`, `tokenizer` and
|
|
92
|
+
// `authorization_header` stay readable.
|
|
93
|
+
s = s.replace(
|
|
94
|
+
/((\\?(["']))(?:[A-Za-z0-9]+[_-])*(?:api[_-]?key|password|passwd|secret|token|access[_-]?key|private[_-]?key|authorization)\2\s*:\s*)(?:\\\3(?:[^"\\]|\\\\(?:\\\3)|\\[^"])*(?:\\\3|[\s\S]*$)|"(?:[^"\\]|\\.)*(?:"|\\?$)|'(?:[^'\\]|\\.)*(?:'|\\?$)|[^\s,}\][{"']+)/gi,
|
|
95
|
+
'$1"***"',
|
|
96
|
+
);
|
|
97
|
+
// Bearer / API key prefixes first (before generic key=value)
|
|
98
|
+
s = s.replace(/\bBearer\s+[A-Za-z0-9._\-+=/]+/gi, 'Bearer ***');
|
|
99
|
+
// `sk-` keys. Segments may be hyphen-separated (`sk-ant-api03-…`), so match the
|
|
100
|
+
// whole run rather than a single alphanumeric block — anchoring on `[A-Za-z0-9]{8,}`
|
|
101
|
+
// alone stopped at the first hyphen and let every Anthropic key through, which is
|
|
102
|
+
// the most likely secret to appear in a Claude-agent harness.
|
|
103
|
+
s = s.replace(/\bsk-[A-Za-z0-9_-]{8,}/g, 'sk-***');
|
|
104
|
+
s = s.replace(/\bAKIA[0-9A-Z]{16}\b/g, 'AKIA***');
|
|
105
|
+
// Provider token prefixes with a fixed, unambiguous shape.
|
|
106
|
+
s = s.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}/g, 'github_pat_***');
|
|
107
|
+
s = s.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, (m) => `${m.slice(0, 4)}***`);
|
|
108
|
+
// Key=value style secrets (value = rest of non-space token). The key may carry a
|
|
109
|
+
// prefix (`ANTHROPIC_API_KEY`, `GITHUB_TOKEN`): `_` is a word character, so a
|
|
110
|
+
// leading `\b` would not match there and every namespaced env var leaked. Allow an
|
|
111
|
+
// optional `WORD_`-style prefix instead of requiring a boundary.
|
|
112
|
+
// The whole key (prefix included) is captured so the ledger still says *which*
|
|
113
|
+
// variable was scrubbed — `ANTHROPIC_API_KEY=***`, not a bare `API_KEY=***`.
|
|
114
|
+
s = s.replace(
|
|
115
|
+
/\b((?:[A-Za-z0-9]+[_-])*(?:api[_-]?key|password|passwd|secret|token|access[_-]?key|private[_-]?key))\s*[:=]\s*\S+/gi,
|
|
116
|
+
'$1=***',
|
|
117
|
+
);
|
|
118
|
+
// Authorization: <scheme> <credentials...>
|
|
119
|
+
s = s.replace(/\bAuthorization\s*:\s*\S+(?:\s+\S+)*/gi, 'Authorization: ***');
|
|
120
|
+
// PEM private key blocks — redact the payload, keep the header as a marker.
|
|
121
|
+
s = s.replace(
|
|
122
|
+
/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z ]+ )?PRIVATE KEY-----|$)/g,
|
|
123
|
+
'[private-key-redacted]',
|
|
124
|
+
);
|
|
125
|
+
// Collapse long base64 only when + / or = present (avoid pure a-z body blobs)
|
|
126
|
+
s = s.replace(/\b[A-Za-z0-9+/]{80,}={0,2}\b/g, (m) => (/[+/=]/.test(m) ? '[base64-redacted]' : m));
|
|
127
|
+
return s;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Cap text to {@link REDACTION_CAP_BYTES} and scrub obvious secret patterns.
|
|
132
|
+
* Used for any stored summary fragments — never full env dumps.
|
|
133
|
+
*/
|
|
134
|
+
export function redactText(text: string, capBytes: number = REDACTION_CAP_BYTES): string {
|
|
135
|
+
const s = scrubSecrets(text);
|
|
136
|
+
const bytes = new TextEncoder().encode(s);
|
|
137
|
+
if (bytes.length <= capBytes) return s;
|
|
138
|
+
// Truncate on byte boundary then append marker
|
|
139
|
+
let end = capBytes;
|
|
140
|
+
while (end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) end--;
|
|
141
|
+
return `${new TextDecoder().decode(bytes.subarray(0, end))}…[truncated]`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Byte length after scrub + hard cap (no truncation marker) — for token estimates. */
|
|
145
|
+
export function cappedByteLength(text: string, capBytes: number = REDACTION_CAP_BYTES): number {
|
|
146
|
+
const scrubbed = scrubSecrets(text);
|
|
147
|
+
return Math.min(new TextEncoder().encode(scrubbed).length, capBytes);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Truncate a one-line summary for the ledger (after light redaction). */
|
|
151
|
+
export function truncateSummary(text: string, maxChars: number = SUMMARY_MAX_CHARS): string {
|
|
152
|
+
const cleaned = redactText(text.replace(/\s+/g, ' ').trim(), REDACTION_CAP_BYTES);
|
|
153
|
+
if (cleaned.length <= maxChars) return cleaned;
|
|
154
|
+
return `${cleaned.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Map platform tool name → ledger `type` (task 0248 R2).
|
|
159
|
+
* Edit stays `write` with action=edit (0245/0246 convention).
|
|
160
|
+
*/
|
|
161
|
+
export function mapToolType(toolName: string): string {
|
|
162
|
+
switch (toolName) {
|
|
163
|
+
case 'Read':
|
|
164
|
+
return 'read';
|
|
165
|
+
case 'Write':
|
|
166
|
+
case 'Edit':
|
|
167
|
+
return 'write';
|
|
168
|
+
case 'Bash':
|
|
169
|
+
return 'bash';
|
|
170
|
+
case 'Grep':
|
|
171
|
+
return 'grep';
|
|
172
|
+
case 'Glob':
|
|
173
|
+
return 'glob';
|
|
174
|
+
default:
|
|
175
|
+
return toolName.toLowerCase();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Short summary for Bash/Grep/Glob (command truncated, pattern, path glob).
|
|
181
|
+
* Not full stdout. Returns undefined when nothing useful to store.
|
|
182
|
+
*/
|
|
183
|
+
export function buildToolSummary(toolName: string, toolInput: ToolPayload['tool_input']): string | undefined {
|
|
184
|
+
if (!toolInput) return undefined;
|
|
185
|
+
if (toolName === 'Bash') {
|
|
186
|
+
const cmd = typeof toolInput.command === 'string' ? toolInput.command : '';
|
|
187
|
+
if (!cmd.trim()) return undefined;
|
|
188
|
+
return truncateSummary(cmd);
|
|
189
|
+
}
|
|
190
|
+
if (toolName === 'Grep') {
|
|
191
|
+
const pattern = typeof toolInput.pattern === 'string' ? toolInput.pattern : '';
|
|
192
|
+
const path = typeof toolInput.path === 'string' ? toolInput.path : '';
|
|
193
|
+
const glob =
|
|
194
|
+
(typeof toolInput.glob === 'string' && toolInput.glob) ||
|
|
195
|
+
(typeof toolInput.glob_pattern === 'string' && toolInput.glob_pattern) ||
|
|
196
|
+
'';
|
|
197
|
+
const parts = [pattern && `/${pattern}/`, path, glob].filter(Boolean);
|
|
198
|
+
if (parts.length === 0) return undefined;
|
|
199
|
+
return truncateSummary(parts.join(' '));
|
|
200
|
+
}
|
|
201
|
+
if (toolName === 'Glob') {
|
|
202
|
+
const pattern =
|
|
203
|
+
(typeof toolInput.pattern === 'string' && toolInput.pattern) ||
|
|
204
|
+
(typeof toolInput.glob_pattern === 'string' && toolInput.glob_pattern) ||
|
|
205
|
+
(typeof toolInput.glob === 'string' && toolInput.glob) ||
|
|
206
|
+
'';
|
|
207
|
+
const path = typeof toolInput.path === 'string' ? toolInput.path : '';
|
|
208
|
+
const parts = [pattern, path].filter(Boolean);
|
|
209
|
+
if (parts.length === 0) return undefined;
|
|
210
|
+
return truncateSummary(parts.join(' in '));
|
|
211
|
+
}
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Response text used only for size/token estimate — never written to the ledger. */
|
|
216
|
+
function responseTextForEstimate(toolResponse: ToolPayload['tool_response']): string {
|
|
217
|
+
if (!toolResponse) return '';
|
|
218
|
+
const parts: string[] = [];
|
|
219
|
+
if (typeof toolResponse.content === 'string') parts.push(toolResponse.content);
|
|
220
|
+
if (typeof toolResponse.stdout === 'string') parts.push(toolResponse.stdout);
|
|
221
|
+
if (typeof toolResponse.stderr === 'string') parts.push(toolResponse.stderr);
|
|
222
|
+
return parts.join('\n');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Token cascade (tasks 0246 / 0248). Returns undefined when unknown — callers omit
|
|
227
|
+
* the field rather than writing tokens: 0.
|
|
228
|
+
*
|
|
229
|
+
* Bash: capped stdout/stderr length. Grep/Glob: capped result size or undefined.
|
|
230
|
+
*/
|
|
231
|
+
export function resolveTokenEstimate(
|
|
232
|
+
toolName: string,
|
|
233
|
+
toolInput: ToolPayload['tool_input'],
|
|
234
|
+
toolResponse: ToolPayload['tool_response'],
|
|
235
|
+
): number | undefined {
|
|
236
|
+
// Bash: estimate from capped response only (never uncapped multi-MB stdout).
|
|
237
|
+
if (toolName === 'Bash') {
|
|
238
|
+
const raw = responseTextForEstimate(toolResponse);
|
|
239
|
+
if (raw.length === 0) return undefined;
|
|
240
|
+
return Math.ceil(cappedByteLength(raw) / 4);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Grep / Glob: result size after cap, or omit when empty.
|
|
244
|
+
if (toolName === 'Grep' || toolName === 'Glob') {
|
|
245
|
+
const raw = responseTextForEstimate(toolResponse);
|
|
246
|
+
if (raw.length === 0) return undefined;
|
|
247
|
+
return Math.ceil(cappedByteLength(raw) / 4);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const responseContent = toolResponse?.content;
|
|
251
|
+
if (typeof responseContent === 'string' && responseContent.length > 0) {
|
|
252
|
+
return estimateTokens(responseContent);
|
|
253
|
+
}
|
|
254
|
+
if (toolName === 'Write' && typeof toolInput?.content === 'string' && toolInput.content.length > 0) {
|
|
255
|
+
return estimateTokens(toolInput.content);
|
|
256
|
+
}
|
|
257
|
+
if (toolName === 'Edit') {
|
|
258
|
+
const parts = [toolInput?.old_string ?? '', toolInput?.new_string ?? ''].join('');
|
|
259
|
+
if (parts.length > 0) return estimateTokens(parts);
|
|
260
|
+
}
|
|
261
|
+
if (toolName === 'Read' && typeof toolInput?.file_path === 'string' && toolInput.file_path) {
|
|
262
|
+
try {
|
|
263
|
+
if (existsSync(toolInput.file_path)) {
|
|
264
|
+
const size = statSync(toolInput.file_path).size;
|
|
265
|
+
if (size > 0) return Math.ceil(size / 4);
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
/* fail-open: skip stat */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function readSessionFile(dir: string): SessionFile {
|
|
275
|
+
const sessionFile = join(dir, '.session.json');
|
|
276
|
+
if (!existsSync(sessionFile)) return {};
|
|
277
|
+
try {
|
|
278
|
+
return JSON.parse(readFileSync(sessionFile, 'utf-8')) as SessionFile;
|
|
279
|
+
} catch {
|
|
280
|
+
return {};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Core record path (testable). Returns the written event, or null when nothing was logged
|
|
286
|
+
* (unknown tool, missing session, missing path/summary, I/O failure).
|
|
287
|
+
*/
|
|
288
|
+
export function recordToolUseEvent(
|
|
289
|
+
contextDir: string,
|
|
290
|
+
payload: ToolPayload,
|
|
291
|
+
now: () => Date = () => new Date(),
|
|
292
|
+
): Record<string, unknown> | null {
|
|
293
|
+
const toolName = payload.tool_name ?? '';
|
|
294
|
+
if (!ALLOWED_TOOLS.has(toolName)) return null;
|
|
295
|
+
|
|
296
|
+
const filePath = payload.tool_input?.file_path ?? '';
|
|
297
|
+
const summary = buildToolSummary(toolName, payload.tool_input);
|
|
298
|
+
|
|
299
|
+
// Read/Write/Edit still require a path; Bash/Grep/Glob require a short summary.
|
|
300
|
+
if (toolName === 'Read' || toolName === 'Write' || toolName === 'Edit') {
|
|
301
|
+
if (!filePath) return null;
|
|
302
|
+
} else if (!summary) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const sessionMeta = readSessionFile(contextDir);
|
|
307
|
+
const session = sessionMeta.session ?? '';
|
|
308
|
+
if (!session) return null;
|
|
309
|
+
|
|
310
|
+
// Freshness stamp (task 0711 R4): the indexed-context producer is the agent
|
|
311
|
+
// following the skill's update guidance; the observable producer moment in
|
|
312
|
+
// code is a Write/Edit landing on a `.spur/context/*.md` file. Each such
|
|
313
|
+
// write refreshes `.freshness.json` so staleness checks can compare the
|
|
314
|
+
// sidecar's source commit against HEAD until the next regeneration.
|
|
315
|
+
if ((toolName === 'Write' || toolName === 'Edit') && isContextIndexFile(contextDir, filePath)) {
|
|
316
|
+
stampContextFreshness(contextDir, currentHeadCommit(), now());
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const tokens = resolveTokenEstimate(toolName, payload.tool_input, payload.tool_response);
|
|
320
|
+
const ts = now().toISOString();
|
|
321
|
+
const type = mapToolType(toolName);
|
|
322
|
+
const action = toolName === 'Write' ? 'create' : toolName === 'Edit' ? 'edit' : undefined;
|
|
323
|
+
|
|
324
|
+
const event: Record<string, unknown> = { ts, session, type };
|
|
325
|
+
if (filePath) event.file = filePath;
|
|
326
|
+
if (summary) event.summary = summary;
|
|
327
|
+
if (tokens !== undefined) event.tokens = tokens;
|
|
328
|
+
if (action) event.action = action;
|
|
329
|
+
|
|
330
|
+
// Best-effort identity fields — never block logging.
|
|
331
|
+
if (typeof payload.session_id === 'string' && payload.session_id) {
|
|
332
|
+
event.sessionId = payload.session_id;
|
|
333
|
+
}
|
|
334
|
+
if (typeof sessionMeta.agent === 'string' && sessionMeta.agent) event.agent = sessionMeta.agent;
|
|
335
|
+
if (typeof sessionMeta.model === 'string' && sessionMeta.model) event.model = sessionMeta.model;
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
appendFileSync(join(contextDir, 'token-ledger.jsonl'), `${JSON.stringify(event)}\n`);
|
|
339
|
+
} catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return event;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ─── Context freshness sidecar (task 0711 R4) ───────────────────────────
|
|
347
|
+
|
|
348
|
+
export interface ContextFreshness {
|
|
349
|
+
schema_version: number;
|
|
350
|
+
source_commit: string;
|
|
351
|
+
generated_at: string;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export const CONTEXT_FRESHNESS_SCHEMA_VERSION = 1;
|
|
355
|
+
|
|
356
|
+
/** True when `filePath` is a regenerated context index inside `contextDir` (not the sidecars). */
|
|
357
|
+
export function isContextIndexFile(contextDir: string, filePath: string): boolean {
|
|
358
|
+
const resolvedDir = resolve(contextDir);
|
|
359
|
+
const resolvedFile = resolve(filePath);
|
|
360
|
+
if (!resolvedFile.startsWith(resolvedDir + sep)) return false;
|
|
361
|
+
const name = resolvedFile.slice(resolvedDir.length + 1);
|
|
362
|
+
return name.endsWith('.md');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Current HEAD, best-effort — null when git is unavailable or the dir is not a work tree. */
|
|
366
|
+
export function currentHeadCommit(): string | null {
|
|
367
|
+
try {
|
|
368
|
+
const out = execSync('git rev-parse HEAD', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
369
|
+
const commit = out.trim();
|
|
370
|
+
return commit.length > 0 ? commit : null;
|
|
371
|
+
} catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Write/refresh `.spur/context/.freshness.json`. Best-effort: any I/O failure
|
|
378
|
+
* leaves the previous sidecar (or none) in place — the hook contract is
|
|
379
|
+
* fail-open, never blocking.
|
|
380
|
+
*/
|
|
381
|
+
export function stampContextFreshness(contextDir: string, sourceCommit: string | null, at: Date): void {
|
|
382
|
+
if (!sourceCommit) return;
|
|
383
|
+
const body: ContextFreshness = {
|
|
384
|
+
schema_version: CONTEXT_FRESHNESS_SCHEMA_VERSION,
|
|
385
|
+
source_commit: sourceCommit,
|
|
386
|
+
generated_at: at.toISOString(),
|
|
387
|
+
};
|
|
388
|
+
try {
|
|
389
|
+
writeFileSync(join(contextDir, '.freshness.json'), JSON.stringify(body));
|
|
390
|
+
} catch {
|
|
391
|
+
/* fail-open */
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export interface ContextFreshnessCheck {
|
|
396
|
+
stale: boolean;
|
|
397
|
+
reason?: string;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Compare a freshness sidecar against the current HEAD (task 0711 R4). A
|
|
402
|
+
* missing/unparsable sidecar is stale (`never stamped`); a commit mismatch is
|
|
403
|
+
* stale until the producer regenerates the index.
|
|
404
|
+
*/
|
|
405
|
+
export function checkContextFreshness(raw: string | null, headCommit: string | null): ContextFreshnessCheck {
|
|
406
|
+
if (raw === null) return { stale: true, reason: 'never stamped' };
|
|
407
|
+
let body: ContextFreshness;
|
|
408
|
+
try {
|
|
409
|
+
body = JSON.parse(raw) as ContextFreshness;
|
|
410
|
+
} catch {
|
|
411
|
+
return { stale: true, reason: 'malformed sidecar' };
|
|
412
|
+
}
|
|
413
|
+
if (body.schema_version !== CONTEXT_FRESHNESS_SCHEMA_VERSION) {
|
|
414
|
+
return { stale: true, reason: `schema_version ${String(body.schema_version)}` };
|
|
415
|
+
}
|
|
416
|
+
if (typeof body.source_commit !== 'string' || body.source_commit === '') {
|
|
417
|
+
return { stale: true, reason: 'missing source_commit' };
|
|
418
|
+
}
|
|
419
|
+
if (headCommit !== null && body.source_commit !== headCommit) {
|
|
420
|
+
return { stale: true, reason: 'source commit changed since generation' };
|
|
421
|
+
}
|
|
422
|
+
return { stale: false };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Read the freshness sidecar, or null when absent/unreadable. */
|
|
426
|
+
export function readContextFreshness(contextDir: string): string | null {
|
|
427
|
+
try {
|
|
428
|
+
return readFileSync(join(contextDir, '.freshness.json'), 'utf-8');
|
|
429
|
+
} catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|