@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.
Files changed (133) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/decisions/maker-liveness.yaml +39 -0
  3. package/config/plugin-scripts.json +0 -4
  4. package/config/rules/README.md +1 -0
  5. package/config/rules/docs/sourced-api-claims.yaml +56 -12
  6. package/config/rules/strict/runtime-boundaries.yaml +7 -0
  7. package/config/script-placement-baseline.json +1 -19
  8. package/config/workflows/feature-lifecycle.yaml +46 -1
  9. package/config/workflows/feature-verification.yaml +11 -0
  10. package/package.json +9 -9
  11. package/plugins/sp/README.md +22 -16
  12. package/plugins/sp/agents/super-reviewer.md +43 -0
  13. package/plugins/sp/bunfig.toml +8 -5
  14. package/plugins/sp/hooks/careful-guard.ts +1 -1
  15. package/plugins/sp/hooks/context-post-tool.ts +7 -427
  16. package/plugins/sp/hooks/context-session-start.ts +5 -213
  17. package/plugins/sp/hooks/context-session-stop.ts +5 -191
  18. package/plugins/sp/hooks/pi/guard-extension.ts +6 -5
  19. package/plugins/sp/hooks/task-write-guard.ts +1 -1
  20. package/plugins/sp/{hooks → lib}/agent-hint.ts +1 -1
  21. package/plugins/sp/lib/context-post-tool.ts +432 -0
  22. package/plugins/sp/lib/context-session-start.ts +214 -0
  23. package/plugins/sp/lib/context-session-stop.ts +196 -0
  24. package/plugins/sp/lib/dogfood-pipeline.ts +265 -0
  25. package/plugins/sp/lib/dogfood-report.ts +278 -0
  26. package/plugins/sp/lib/idea-handoff.generated.mjs +3 -3
  27. package/plugins/sp/lib/inline-run-app.ts +40 -0
  28. package/plugins/sp/lib/inline-run.generated.d.mts +3 -3
  29. package/plugins/sp/lib/inline-run.generated.mjs +1347 -90
  30. package/plugins/sp/lib/plan-projection.generated.d.mts +150 -0
  31. package/plugins/sp/lib/plan-projection.generated.mjs +591 -1
  32. package/plugins/sp/lib/quality-gate.generated.mjs +38 -25
  33. package/plugins/sp/lib/residual-scan.generated.d.mts +7 -1
  34. package/plugins/sp/lib/residual-scan.generated.mjs +71 -6
  35. package/plugins/sp/lib/run-summary-core.ts +34 -370
  36. package/plugins/sp/lib/transcript.ts +37 -228
  37. package/plugins/sp/plugin.json +1 -1
  38. package/plugins/sp/scripts/batch-plan.mjs +586 -0
  39. package/plugins/sp/scripts/batch-preflight.mjs +23 -1
  40. package/plugins/sp/scripts/batch-preflight.ts +35 -295
  41. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +108 -87
  42. package/plugins/sp/scripts/daily-summary/daily-summary.ts +111 -104
  43. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -1
  44. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +7 -265
  45. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +3 -0
  46. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +6 -272
  47. package/plugins/sp/scripts/feature-verification-steps.mjs +7 -123
  48. package/plugins/sp/scripts/feature-verification-steps.ts +29 -181
  49. package/plugins/sp/scripts/inline-run-dispatch.mjs +27 -26
  50. package/plugins/sp/scripts/inline-run-dispatch.ts +1 -21
  51. package/plugins/sp/scripts/inline-run-setup.mjs +149 -122
  52. package/plugins/sp/scripts/inline-run-setup.ts +1 -21
  53. package/plugins/sp/scripts/quality-gate.mjs +14 -2
  54. package/plugins/sp/scripts/residual-scan.mjs +73 -6
  55. package/plugins/sp/scripts/residual-scan.ts +8 -5
  56. package/plugins/sp/scripts/run-summary.mjs +151 -97
  57. package/plugins/sp/scripts/run-summary.ts +7 -5
  58. package/plugins/sp/scripts/session-timeline.mjs +46 -19
  59. package/plugins/sp/scripts/task-diffstat.mjs +11 -1
  60. package/plugins/sp/scripts/task-diffstat.ts +8 -39
  61. package/plugins/sp/skills/code-implementation/SKILL.md +7 -1
  62. package/plugins/sp/skills/code-verification/references/secu-review.md +2 -1
  63. package/plugins/sp/skills/code-verification/references/verdict-schema.md +4 -0
  64. package/plugins/sp/skills/dogfood-testing/SKILL.md +2 -2
  65. package/plugins/sp/skills/next-feature/SKILL.md +1 -1
  66. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
  67. package/plugins/sp/skills/source-driven-development/SKILL.md +7 -4
  68. package/plugins/sp/skills/spur-cli/references/builder.md +3 -2
  69. package/plugins/sp/skills/spur-cli/references/decision.md +32 -7
  70. package/plugins/sp/skills/spur-cli/references/features.md +46 -5
  71. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +1 -1
  72. package/plugins/sp/skills/spur-dev/references/execution-batch.md +13 -3
  73. package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +22 -0
  74. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +14 -0
  75. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +29 -12
  76. package/spur.js +80681 -79296
  77. package/web/_astro/{BoardApp.B3GQZvj8.js → BoardApp.5IIl2O-z.js} +1 -1
  78. package/web/_astro/{BoardApp.BT7L2kSb.js → BoardApp.Drnx-KeM.js} +150 -150
  79. package/web/_astro/{TaskDetail.Cxov1_N_.js → TaskDetail.Ci4LNn9S.js} +1 -1
  80. package/web/_astro/{arc.vaT7tPOB.js → arc.j2EVkYVd.js} +1 -1
  81. package/web/_astro/{architectureDiagram-3BPJPVTR.DRMmTjkN.js → architectureDiagram-3BPJPVTR.DZWSFqEG.js} +1 -1
  82. package/web/_astro/{blockDiagram-GPEHLZMM.cla0LO_q.js → blockDiagram-GPEHLZMM.DE6GpXCN.js} +1 -1
  83. package/web/_astro/{c4Diagram-AAUBKEIU.D4YAIBKn.js → c4Diagram-AAUBKEIU.DoVR1G6B.js} +1 -1
  84. package/web/_astro/channel.DmdDxGsq.js +1 -0
  85. package/web/_astro/{chunk-2J33WTMH.BrSJbMRZ.js → chunk-2J33WTMH.Do6-Gbyf.js} +1 -1
  86. package/web/_astro/{chunk-4BX2VUAB.CuLMgKF5.js → chunk-4BX2VUAB.BYV1HGHx.js} +1 -1
  87. package/web/_astro/{chunk-55IACEB6.zkvgu54F.js → chunk-55IACEB6.CvI1zkrc.js} +1 -1
  88. package/web/_astro/{chunk-727SXJPM.DxWkACh8.js → chunk-727SXJPM.DNGSCRlX.js} +1 -1
  89. package/web/_astro/{chunk-AQP2D5EJ.42XrmIlH.js → chunk-AQP2D5EJ.DXhhgT5z.js} +1 -1
  90. package/web/_astro/{chunk-FMBD7UC4.Bao2wMK9.js → chunk-FMBD7UC4.dzkRKP2u.js} +1 -1
  91. package/web/_astro/{chunk-ND2GUHAM.BhydojD1.js → chunk-ND2GUHAM.DujGLeEf.js} +1 -1
  92. package/web/_astro/{chunk-QZHKN3VN.CmZ82g-H.js → chunk-QZHKN3VN.BWtIT3ZU.js} +1 -1
  93. package/web/_astro/{classDiagram-4FO5ZUOK.Blr83Gt_.js → classDiagram-4FO5ZUOK.DyApg0Nn.js} +1 -1
  94. package/web/_astro/{classDiagram-v2-Q7XG4LA2.Blr83Gt_.js → classDiagram-v2-Q7XG4LA2.DyApg0Nn.js} +1 -1
  95. package/web/_astro/{cose-bilkent-S5V4N54A.qmfv0SPn.js → cose-bilkent-S5V4N54A.DJQB-u_l.js} +1 -1
  96. package/web/_astro/{cynefin-OW5HDTMX.BtELhXPR.js → cynefin-OW5HDTMX.bi3D_siV.js} +1 -1
  97. package/web/_astro/{dagre-BM42HDAG.AWmaAvXo.js → dagre-BM42HDAG.C-f7TrRg.js} +1 -1
  98. package/web/_astro/{diagram-2AECGRRQ.DdH34KDi.js → diagram-2AECGRRQ.DrPfjHQB.js} +1 -1
  99. package/web/_astro/{diagram-5GNKFQAL.BmNPix1k.js → diagram-5GNKFQAL.C9S4WUHO.js} +1 -1
  100. package/web/_astro/{diagram-KO2AKTUF.BNQd_Ppo.js → diagram-KO2AKTUF.Dzzye8QH.js} +1 -1
  101. package/web/_astro/{diagram-LMA3HP47.BAPgDi0e.js → diagram-LMA3HP47.DX-LRiof.js} +1 -1
  102. package/web/_astro/{diagram-OG6HWLK6.DZ4zyLdC.js → diagram-OG6HWLK6.Z-kAKwIz.js} +1 -1
  103. package/web/_astro/{erDiagram-TEJ5UH35.Q1TySMmw.js → erDiagram-TEJ5UH35.D6dyClSv.js} +1 -1
  104. package/web/_astro/{flowDiagram-I6XJVG4X.BXhFBicG.js → flowDiagram-I6XJVG4X.Cx3q2cxD.js} +1 -1
  105. package/web/_astro/{ganttDiagram-6RSMTGT7.vHSgcuo2.js → ganttDiagram-6RSMTGT7.DU3gH6CT.js} +1 -1
  106. package/web/_astro/{gitGraphDiagram-PVQCEYII.Bi1f73C7.js → gitGraphDiagram-PVQCEYII.ISmCnJ87.js} +1 -1
  107. package/web/_astro/index.Do2lLdgo.css +1 -0
  108. package/web/_astro/{infoDiagram-5YYISTIA.DPvXZEWm.js → infoDiagram-5YYISTIA.DR6_iNOK.js} +1 -1
  109. package/web/_astro/{ishikawaDiagram-YF4QCWOH.Cz829fsV.js → ishikawaDiagram-YF4QCWOH.D7wY-ZGA.js} +1 -1
  110. package/web/_astro/{journeyDiagram-JHISSGLW.enRnB9Me.js → journeyDiagram-JHISSGLW.DkP9-WK8.js} +1 -1
  111. package/web/_astro/{kanban-definition-UN3LZRKU.DnTNk6d2.js → kanban-definition-UN3LZRKU.wtgY98M2.js} +1 -1
  112. package/web/_astro/{linear.BzGnBwNL.js → linear.CI6djNi7.js} +1 -1
  113. package/web/_astro/{mermaid.core.CSbTpIVz.js → mermaid.core.CuQPxS8b.js} +4 -4
  114. package/web/_astro/{mindmap-definition-RKZ34NQL.DGJDy8zT.js → mindmap-definition-RKZ34NQL.5HNIEG7R.js} +1 -1
  115. package/web/_astro/{pieDiagram-4H26LBE5.HSPdi5_8.js → pieDiagram-4H26LBE5.CchhDFjE.js} +1 -1
  116. package/web/_astro/{quadrantDiagram-W4KKPZXB.6Gv8mz2Y.js → quadrantDiagram-W4KKPZXB.PYXXJPSJ.js} +1 -1
  117. package/web/_astro/{requirementDiagram-4Y6WPE33.CnBCtFwa.js → requirementDiagram-4Y6WPE33.cUQuNAKU.js} +1 -1
  118. package/web/_astro/{sankeyDiagram-5OEKKPKP.5M8VWAu8.js → sankeyDiagram-5OEKKPKP.BO7MnRIA.js} +1 -1
  119. package/web/_astro/{sequenceDiagram-3UESZ5HK.DN41XN-U.js → sequenceDiagram-3UESZ5HK.D9bElyD6.js} +1 -1
  120. package/web/_astro/{stateDiagram-AJRCARHV.ErOO3W8j.js → stateDiagram-AJRCARHV.DhGdRz7I.js} +1 -1
  121. package/web/_astro/{stateDiagram-v2-BHNVJYJU.B_ZHICM-.js → stateDiagram-v2-BHNVJYJU.5GvLeAoL.js} +1 -1
  122. package/web/_astro/{timeline-definition-PNZ67QCA.CWdosr07.js → timeline-definition-PNZ67QCA.LtAT9haM.js} +1 -1
  123. package/web/_astro/{vennDiagram-CIIHVFJN.BHzYjNV5.js → vennDiagram-CIIHVFJN.DRth1yLA.js} +1 -1
  124. package/web/_astro/{wardleyDiagram-YWT4CUSO.h8x4IwPX.js → wardleyDiagram-YWT4CUSO.h9MlZfcr.js} +1 -1
  125. package/web/_astro/{xychartDiagram-2RQKCTM6.RTapW2X8.js → xychartDiagram-2RQKCTM6.DHqy9DYA.js} +1 -1
  126. package/web/index.html +2 -2
  127. package/config/proportional-route-table.ts +0 -155
  128. package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
  129. package/web/_astro/channel.D4nG9YLf.js +0 -1
  130. package/web/_astro/index.BhSwN8yY.css +0 -1
  131. /package/plugins/sp/{hooks → lib}/context-owner.ts +0 -0
  132. /package/plugins/sp/{hooks → lib}/destructive-policy.ts +0 -0
  133. /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 hook for indexed-context (session teardown).
3
+ * context-session-stop — installed SessionEnd entry for indexed-context (session teardown).
4
4
  *
5
- * Scans `token-ledger.jsonl` for the current session's events, computes rollup totals, appends
6
- * a `session_end` event, and cleans up `.session.json`. Registered on SessionEnd (not Stop) so it
7
- * runs once at true session teardown: on Claude Code the rollup counts the whole session (Stop fired
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 { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
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 './task-file-policy';
24
+ import { couldBeTaskFile } from '../lib/task-file-policy';
25
25
 
26
26
  interface ToolPayload {
27
27
  tool_name?: string;
@@ -1,4 +1,4 @@
1
- import { getEnvVars } from '../lib/env';
1
+ import { getEnvVars } from './env';
2
2
  /**
3
3
  * Shared host-agent identification helpers — the **only** host-agent detection path
4
4
  * in the sp plugin hooks.
@@ -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
+ }