@gobing-ai/spur 0.4.1 → 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 (159) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.global.yaml +4 -2
  3. package/config/decisions/maker-liveness.yaml +39 -0
  4. package/config/plugin-scripts.json +0 -4
  5. package/config/rules/README.md +1 -0
  6. package/config/rules/docs/sourced-api-claims.yaml +56 -12
  7. package/config/rules/strict/runtime-boundaries.yaml +7 -0
  8. package/config/script-placement-baseline.json +1 -19
  9. package/config/templates/AGENTS.md +1 -1
  10. package/config/workflows/feature-lifecycle.yaml +46 -1
  11. package/config/workflows/feature-verification.yaml +11 -0
  12. package/config/workflows/wrapup-pipeline.yaml +1 -1
  13. package/package.json +10 -10
  14. package/plugins/sp/README.md +23 -18
  15. package/plugins/sp/agents/super-planner.md +5 -6
  16. package/plugins/sp/agents/super-reviewer.md +43 -0
  17. package/plugins/sp/bunfig.toml +8 -5
  18. package/plugins/sp/commands/dev-review-session.md +2 -1
  19. package/plugins/sp/commands/dev-runall.md +18 -9
  20. package/plugins/sp/hooks/careful-guard.ts +1 -1
  21. package/plugins/sp/hooks/context-post-tool.ts +7 -404
  22. package/plugins/sp/hooks/context-session-start.ts +6 -192
  23. package/plugins/sp/hooks/context-session-stop.ts +6 -91
  24. package/plugins/sp/hooks/pi/guard-extension.ts +26 -12
  25. package/plugins/sp/hooks/task-write-guard.ts +1 -1
  26. package/plugins/sp/{hooks → lib}/agent-hint.ts +1 -1
  27. package/plugins/sp/lib/context-owner.ts +56 -0
  28. package/plugins/sp/lib/context-post-tool.ts +432 -0
  29. package/plugins/sp/lib/context-session-start.ts +214 -0
  30. package/plugins/sp/lib/context-session-stop.ts +196 -0
  31. package/plugins/sp/lib/dogfood-pipeline.ts +265 -0
  32. package/plugins/sp/lib/dogfood-report.ts +278 -0
  33. package/plugins/sp/lib/idea-handoff.generated.mjs +6 -4
  34. package/plugins/sp/lib/inline-run-app.ts +40 -0
  35. package/plugins/sp/lib/inline-run.generated.d.mts +3 -3
  36. package/plugins/sp/lib/inline-run.generated.mjs +1361 -87
  37. package/plugins/sp/lib/plan-projection.generated.d.mts +169 -0
  38. package/plugins/sp/lib/plan-projection.generated.mjs +704 -1
  39. package/plugins/sp/lib/quality-gate.generated.d.mts +0 -12
  40. package/plugins/sp/lib/quality-gate.generated.mjs +62 -231
  41. package/plugins/sp/lib/residual-scan.generated.d.mts +7 -1
  42. package/plugins/sp/lib/residual-scan.generated.mjs +71 -6
  43. package/plugins/sp/lib/run-summary-core.ts +34 -370
  44. package/plugins/sp/lib/transcript.ts +37 -200
  45. package/plugins/sp/plugin.json +1 -1
  46. package/plugins/sp/references/roles.md +1 -1
  47. package/plugins/sp/scripts/batch-plan.mjs +740 -1
  48. package/plugins/sp/scripts/batch-plan.ts +48 -3
  49. package/plugins/sp/scripts/batch-preflight.mjs +23 -1
  50. package/plugins/sp/scripts/batch-preflight.ts +35 -295
  51. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +108 -87
  52. package/plugins/sp/scripts/daily-summary/daily-summary.ts +111 -104
  53. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -1
  54. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +7 -265
  55. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +3 -0
  56. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +6 -272
  57. package/plugins/sp/scripts/feature-verification-steps.mjs +7 -123
  58. package/plugins/sp/scripts/feature-verification-steps.ts +29 -181
  59. package/plugins/sp/scripts/inline-run-dispatch.mjs +27 -26
  60. package/plugins/sp/scripts/inline-run-dispatch.ts +1 -21
  61. package/plugins/sp/scripts/inline-run-setup.mjs +158 -120
  62. package/plugins/sp/scripts/inline-run-setup.ts +1 -21
  63. package/plugins/sp/scripts/pr-reviewing.mjs +19 -5
  64. package/plugins/sp/scripts/pr-reviewing.ts +34 -9
  65. package/plugins/sp/scripts/quality-gate.mjs +38 -208
  66. package/plugins/sp/scripts/residual-scan.mjs +73 -6
  67. package/plugins/sp/scripts/residual-scan.ts +8 -5
  68. package/plugins/sp/scripts/run-summary.mjs +160 -95
  69. package/plugins/sp/scripts/run-summary.ts +7 -5
  70. package/plugins/sp/scripts/session-timeline.mjs +57 -29
  71. package/plugins/sp/scripts/session-timeline.ts +4 -18
  72. package/plugins/sp/scripts/task-diffstat.mjs +43 -30
  73. package/plugins/sp/scripts/task-diffstat.ts +40 -71
  74. package/plugins/sp/skills/code-implementation/SKILL.md +7 -1
  75. package/plugins/sp/skills/code-verification/references/secu-review.md +2 -1
  76. package/plugins/sp/skills/code-verification/references/verdict-schema.md +4 -0
  77. package/plugins/sp/skills/dogfood-testing/SKILL.md +2 -2
  78. package/plugins/sp/skills/indexed-context/SKILL.md +14 -7
  79. package/plugins/sp/skills/next-feature/SKILL.md +1 -1
  80. package/plugins/sp/skills/parallel-execution/SKILL.md +2 -2
  81. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +2 -2
  82. package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +2 -2
  83. package/plugins/sp/skills/pr-reviewing/SKILL.md +7 -3
  84. package/plugins/sp/skills/session-review/SKILL.md +5 -3
  85. package/plugins/sp/skills/source-driven-development/SKILL.md +7 -4
  86. package/plugins/sp/skills/spur-cli/references/builder.md +3 -2
  87. package/plugins/sp/skills/spur-cli/references/decision.md +40 -8
  88. package/plugins/sp/skills/spur-cli/references/features.md +46 -5
  89. package/plugins/sp/skills/spur-cli/references/projects.md +7 -5
  90. package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
  91. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +27 -0
  92. package/plugins/sp/skills/spur-dev/references/dev-operations.md +2 -3
  93. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +6 -1
  94. package/plugins/sp/skills/spur-dev/references/execution-batch.md +56 -7
  95. package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +4 -1
  96. package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +22 -0
  97. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +14 -0
  98. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +13 -11
  99. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +37 -25
  100. package/spur.js +89792 -76992
  101. package/web/_astro/{BoardApp.D66k_s_T.js → BoardApp.5IIl2O-z.js} +1 -1
  102. package/web/_astro/BoardApp.Drnx-KeM.js +324 -0
  103. package/web/_astro/TaskDetail.Ci4LNn9S.js +1 -0
  104. package/web/_astro/{arc._BUadrdH.js → arc.j2EVkYVd.js} +1 -1
  105. package/web/_astro/{architectureDiagram-3BPJPVTR.Dh7xS1Pp.js → architectureDiagram-3BPJPVTR.DZWSFqEG.js} +1 -1
  106. package/web/_astro/{blockDiagram-GPEHLZMM.DFDVpfXm.js → blockDiagram-GPEHLZMM.DE6GpXCN.js} +1 -1
  107. package/web/_astro/{c4Diagram-AAUBKEIU.V7_3_glh.js → c4Diagram-AAUBKEIU.DoVR1G6B.js} +1 -1
  108. package/web/_astro/channel.DmdDxGsq.js +1 -0
  109. package/web/_astro/{chunk-2J33WTMH.De1yx7Jz.js → chunk-2J33WTMH.Do6-Gbyf.js} +1 -1
  110. package/web/_astro/{chunk-4BX2VUAB.BDhHFuxx.js → chunk-4BX2VUAB.BYV1HGHx.js} +1 -1
  111. package/web/_astro/{chunk-55IACEB6.DDLkWnmC.js → chunk-55IACEB6.CvI1zkrc.js} +1 -1
  112. package/web/_astro/{chunk-727SXJPM.CwfiLnA7.js → chunk-727SXJPM.DNGSCRlX.js} +1 -1
  113. package/web/_astro/{chunk-AQP2D5EJ.CtGDmUnM.js → chunk-AQP2D5EJ.DXhhgT5z.js} +1 -1
  114. package/web/_astro/{chunk-FMBD7UC4.BOLjpw73.js → chunk-FMBD7UC4.dzkRKP2u.js} +1 -1
  115. package/web/_astro/{chunk-ND2GUHAM.BDhQls6O.js → chunk-ND2GUHAM.DujGLeEf.js} +1 -1
  116. package/web/_astro/{chunk-QZHKN3VN.76qBynDF.js → chunk-QZHKN3VN.BWtIT3ZU.js} +1 -1
  117. package/web/_astro/{classDiagram-4FO5ZUOK.N0mpPHT0.js → classDiagram-4FO5ZUOK.DyApg0Nn.js} +1 -1
  118. package/web/_astro/{classDiagram-v2-Q7XG4LA2.N0mpPHT0.js → classDiagram-v2-Q7XG4LA2.DyApg0Nn.js} +1 -1
  119. package/web/_astro/{cose-bilkent-S5V4N54A.CKknFfjB.js → cose-bilkent-S5V4N54A.DJQB-u_l.js} +1 -1
  120. package/web/_astro/{cynefin-OW5HDTMX.Cw3ZWBAh.js → cynefin-OW5HDTMX.bi3D_siV.js} +1 -1
  121. package/web/_astro/{dagre-BM42HDAG.D3yIKCRD.js → dagre-BM42HDAG.C-f7TrRg.js} +1 -1
  122. package/web/_astro/{diagram-2AECGRRQ.D51dO83Z.js → diagram-2AECGRRQ.DrPfjHQB.js} +1 -1
  123. package/web/_astro/{diagram-5GNKFQAL.Bb-FViDj.js → diagram-5GNKFQAL.C9S4WUHO.js} +1 -1
  124. package/web/_astro/{diagram-KO2AKTUF.D3mN8S6x.js → diagram-KO2AKTUF.Dzzye8QH.js} +1 -1
  125. package/web/_astro/{diagram-LMA3HP47.dMMyPcbJ.js → diagram-LMA3HP47.DX-LRiof.js} +1 -1
  126. package/web/_astro/{diagram-OG6HWLK6.AzbvzY4C.js → diagram-OG6HWLK6.Z-kAKwIz.js} +1 -1
  127. package/web/_astro/{erDiagram-TEJ5UH35.BR5I4OZb.js → erDiagram-TEJ5UH35.D6dyClSv.js} +1 -1
  128. package/web/_astro/{flowDiagram-I6XJVG4X.DFqvhGkn.js → flowDiagram-I6XJVG4X.Cx3q2cxD.js} +1 -1
  129. package/web/_astro/{ganttDiagram-6RSMTGT7.ByJbhH6J.js → ganttDiagram-6RSMTGT7.DU3gH6CT.js} +1 -1
  130. package/web/_astro/{gitGraphDiagram-PVQCEYII.CInHjQR3.js → gitGraphDiagram-PVQCEYII.ISmCnJ87.js} +1 -1
  131. package/web/_astro/index.Do2lLdgo.css +1 -0
  132. package/web/_astro/{infoDiagram-5YYISTIA.Cm0dGVjJ.js → infoDiagram-5YYISTIA.DR6_iNOK.js} +1 -1
  133. package/web/_astro/{ishikawaDiagram-YF4QCWOH.C3nOP6Sj.js → ishikawaDiagram-YF4QCWOH.D7wY-ZGA.js} +1 -1
  134. package/web/_astro/{journeyDiagram-JHISSGLW.4IvLa2MW.js → journeyDiagram-JHISSGLW.DkP9-WK8.js} +1 -1
  135. package/web/_astro/{kanban-definition-UN3LZRKU.DS87Cl-k.js → kanban-definition-UN3LZRKU.wtgY98M2.js} +1 -1
  136. package/web/_astro/{linear.MzmRrNER.js → linear.CI6djNi7.js} +1 -1
  137. package/web/_astro/{mermaid.core.fK-WNswS.js → mermaid.core.CuQPxS8b.js} +4 -4
  138. package/web/_astro/{mindmap-definition-RKZ34NQL.BqipOHhY.js → mindmap-definition-RKZ34NQL.5HNIEG7R.js} +1 -1
  139. package/web/_astro/{pieDiagram-4H26LBE5.0nBiPqFG.js → pieDiagram-4H26LBE5.CchhDFjE.js} +1 -1
  140. package/web/_astro/{quadrantDiagram-W4KKPZXB.Szj7Zpx5.js → quadrantDiagram-W4KKPZXB.PYXXJPSJ.js} +1 -1
  141. package/web/_astro/{requirementDiagram-4Y6WPE33.D4UR6TWo.js → requirementDiagram-4Y6WPE33.cUQuNAKU.js} +1 -1
  142. package/web/_astro/{sankeyDiagram-5OEKKPKP.DAE7BdOs.js → sankeyDiagram-5OEKKPKP.BO7MnRIA.js} +1 -1
  143. package/web/_astro/{sequenceDiagram-3UESZ5HK.BP9ZVq25.js → sequenceDiagram-3UESZ5HK.D9bElyD6.js} +1 -1
  144. package/web/_astro/{stateDiagram-AJRCARHV.BQW7mIxk.js → stateDiagram-AJRCARHV.DhGdRz7I.js} +1 -1
  145. package/web/_astro/{stateDiagram-v2-BHNVJYJU.PbK4nDih.js → stateDiagram-v2-BHNVJYJU.5GvLeAoL.js} +1 -1
  146. package/web/_astro/{timeline-definition-PNZ67QCA.Ci_GvUpo.js → timeline-definition-PNZ67QCA.LtAT9haM.js} +1 -1
  147. package/web/_astro/{vennDiagram-CIIHVFJN.DNw1Y5qx.js → vennDiagram-CIIHVFJN.DRth1yLA.js} +1 -1
  148. package/web/_astro/{wardleyDiagram-YWT4CUSO.CwlkaaNe.js → wardleyDiagram-YWT4CUSO.h9MlZfcr.js} +1 -1
  149. package/web/_astro/{xychartDiagram-2RQKCTM6.MI6ZuiH_.js → xychartDiagram-2RQKCTM6.DHqy9DYA.js} +1 -1
  150. package/web/index.html +2 -2
  151. package/config/proportional-route-table.ts +0 -155
  152. package/plugins/sp/commands/dev-parallel.md +0 -37
  153. package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
  154. package/web/_astro/BoardApp.D6sjAJr_.js +0 -324
  155. package/web/_astro/TaskDetail.BynLnMNc.js +0 -1
  156. package/web/_astro/channel.DZVfto-r.js +0 -1
  157. package/web/_astro/index.COh_kBxN.css +0 -1
  158. /package/plugins/sp/{hooks → lib}/destructive-policy.ts +0 -0
  159. /package/plugins/sp/{hooks → lib}/task-file-policy.ts +0 -0
@@ -1,203 +1,17 @@
1
1
  #!/usr/bin/env bun
2
-
3
2
  /**
4
- * context-session-start — SessionStart hook for indexed-context.
5
- *
6
- * Generates a session ID, writes `.spur/context/.session.json` (tracking the current session
7
- * for PostToolUse and Stop hooks), and appends a `session_start` event to
8
- * `token-ledger.jsonl`. Task 0246: best-effort agent/model hints on session file + event.
9
- *
10
- * **Fail-open contract:** every error path exits 0 with no output.
3
+ * context-session-start — installed SessionStart entry for indexed-context.
11
4
  *
12
- * Self-contained by design (task 0232/0246).
5
+ * Task 1174: the session open + ownership logic lives in `plugins/sp/lib/context-session-start.ts`;
6
+ * this file is the argv/stdin adapter. Malformed/absent stdin still opens the session ownerless
7
+ * (legacy policy) and every error path exits 0 with no output.
13
8
  */
14
9
 
15
- import { execSync } from 'node:child_process';
16
- import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
- import { join } from 'node:path';
18
- import { getEnvVar, getEnvVars } from '../lib/env';
19
- import { resolveAgentHint, resolveModelHint } from './agent-hint';
20
- import { checkContextFreshness } from './context-post-tool';
21
-
22
- export { resolveAgentHint, resolveModelHint } from './agent-hint';
23
-
24
- /**
25
- * Ancestor-run marker exported by `@gobing-ai/ts-ai-runner` ≥ 0.4.15 (`AGENT_RUN_ID_ENV`).
26
- *
27
- * `AiRunner` sets this in the agent subprocess environment whenever the caller supplies an
28
- * `AgentRunCorrelation`, which Spur's pipeline always does
29
- * (`packages/app/src/workflow/actions/agent-run.ts:148-152`) as does `spur agent run`
30
- * (`packages/app/src/services/agent-service.ts:661`). execa's `extendEnv` default propagates it
31
- * transitively, so every descendant of an agent run — including the hook subprocesses the host
32
- * fires inside it — inherits the same value. Its mere presence answers "am I nested?" exactly.
33
- *
34
- * Deliberately a string literal rather than an import: this hook is self-contained by design
35
- * (tasks 0232/0246) and runs both as a standalone script and from Superskill's bundled runner, so
36
- * it must not depend on the workspace's module graph. The name is a published contract on the
37
- * ts-ai-runner side, so a literal here is a stable coupling, not a guess.
38
- */
39
- export const AGENT_RUN_ID_ENV = 'SPUR_RUN_ID';
40
-
41
- /**
42
- * Fallback idle window for hosts and code paths that do **not** propagate a run correlation
43
- * (task 0398 R3).
44
- *
45
- * Only consulted when {@link AGENT_RUN_ID_ENV} is absent. With ts-ai-runner ≥ 0.4.15 the
46
- * agent-run path is detected exactly, so this no longer covers the case that motivated it — it
47
- * remains as a backstop for nested `SessionStart` fires that arrive outside a correlated run
48
- * (a host that spawns its own helper processes, or an `agent.run` invoked without a correlation).
49
- *
50
- * ponytail: still a wall-clock heuristic on that residual path — two genuinely distinct
51
- * uncorrelated sessions started inside the window merge into one ledger session. Kept because
52
- * deleting it would regress every non-correlated nesting path back to the original bug, and the
53
- * set of hosts is not enumerable from here. Delete it once every nesting path is known to carry a
54
- * correlation.
55
- */
56
- export const SESSION_REUSE_IDLE_MS = 4 * 60 * 60 * 1000;
57
-
58
- interface SessionFileBody {
59
- session?: unknown;
60
- started?: unknown;
61
- }
62
-
63
- /**
64
- * Return the id of the session already in flight, or null when a new one should be minted.
65
- *
66
- * **Why this exists (0398 R3).** `SessionStart` fires in every nested `agent.run` subprocess, not
67
- * once per host session. Each firing used to mint a fresh `session-<date>-<HHMM>` id, append a
68
- * `session_start` row, and overwrite the `.session.json` pointer that `context-post-tool` reads —
69
- * so a single pipeline run registered as dozens of sessions and orphaned the parent's event
70
- * stream. The H6 corpus shows the signature: 332 `session_start` against 157 `session_end` over
71
- * 18 days, 298 distinct ids, 39 starts in a 2-day window that held a handful of real sessions.
72
- *
73
- * **Two signals, in precedence order.**
74
- *
75
- * 1. {@link AGENT_RUN_ID_ENV} present → this process is a descendant of an agent run, definitively.
76
- * Reuse the recorded session with no time bound: a pipeline step legitimately runs for the full
77
- * `implementTimeoutMs` (45 min) and longer batches run for hours, so any wall-clock window would
78
- * eventually split a run that is demonstrably still in flight.
79
- * 2. No marker → fall back to the {@link SESSION_REUSE_IDLE_MS} window on the `started` stamp, for
80
- * hosts that nest without propagating a correlation.
81
- *
82
- * A crashed session leaves a stale `.session.json`; on path 2 the idle window retires it. On path 1
83
- * a stale file is only reachable from inside a live agent run, where reusing it is the correct
84
- * answer anyway.
85
- */
86
- export function resolveActiveSession(dir: string, now: Date, env: NodeJS.ProcessEnv = getEnvVars()): string | null {
87
- let raw: string;
88
- try {
89
- raw = readFileSync(join(dir, '.session.json'), 'utf-8');
90
- } catch {
91
- return null; // no in-flight session (or unreadable) → mint a new one
92
- }
93
-
94
- let body: SessionFileBody;
95
- try {
96
- body = JSON.parse(raw) as SessionFileBody;
97
- } catch {
98
- return null; // corrupt pointer → mint a new one
99
- }
100
-
101
- if (typeof body.session !== 'string' || body.session.length === 0) return null;
102
-
103
- // Signal 1 — exact ancestry. No `started` parse, no time bound.
104
- const runId = env[AGENT_RUN_ID_ENV];
105
- if (typeof runId === 'string' && runId.trim().length > 0) return body.session;
106
-
107
- // Signal 2 — residual heuristic for uncorrelated nesting.
108
- if (typeof body.started !== 'string') return null;
109
-
110
- const started = Date.parse(body.started);
111
- if (Number.isNaN(started)) return null;
112
-
113
- const idleMs = now.getTime() - started;
114
- if (idleMs < 0 || idleMs > SESSION_REUSE_IDLE_MS) return null; // stale or clock-skewed → new
115
-
116
- return body.session;
117
- }
118
-
119
- /**
120
- * Core session-start path (testable). Returns session id on success, null on I/O failure.
121
- *
122
- * Idempotent per in-flight session (0398 R3): when `resolveActiveSession` finds one, this returns
123
- * that id and writes nothing — no ledger row, no pointer rewrite.
124
- */
125
- export function recordSessionStart(
126
- dir: string,
127
- env: NodeJS.ProcessEnv = getEnvVars(),
128
- now: () => Date = () => new Date(),
129
- agentFallback?: string,
130
- ): string | null {
131
- try {
132
- mkdirSync(dir, { recursive: true });
133
- } catch {
134
- return null;
135
- }
136
-
137
- const at = now();
138
-
139
- const active = resolveActiveSession(dir, at, env);
140
- if (active !== null) return active;
141
- const pad = (n: number) => String(n).padStart(2, '0');
142
- const sessionId = `session-${at.toISOString().slice(0, 10)}-${pad(at.getHours())}${pad(at.getMinutes())}`;
143
- const ts = at.toISOString();
144
- const agent = resolveAgentHint(env, agentFallback);
145
- const model = resolveModelHint(env);
146
-
147
- const sessionBody: Record<string, unknown> = {
148
- session: sessionId,
149
- started: ts,
150
- reads: 0,
151
- writes: 0,
152
- tokens: 0,
153
- };
154
- if (agent) sessionBody.agent = agent;
155
- if (model) sessionBody.model = model;
156
-
157
- const sessionFile = join(dir, '.session.json');
158
- try {
159
- writeFileSync(sessionFile, JSON.stringify(sessionBody));
160
- } catch {
161
- return null;
162
- }
163
-
164
- const startEvent: Record<string, unknown> = { ts, session: sessionId, type: 'session_start' };
165
- if (agent) startEvent.agent = agent;
166
- if (model) startEvent.model = model;
167
-
168
- // Freshness check (task 0711 R4): report whether the context indexes were
169
- // regenerated at the current HEAD. Best-effort, fail-open — git failures
170
- // yield head=null, which marks stale only via the sidecar's own defects.
171
- let headCommit: string | null = null;
172
- try {
173
- const out = execSync('git rev-parse HEAD', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
174
- const trimmed = out.trim();
175
- headCommit = trimmed.length > 0 ? trimmed : null;
176
- } catch {
177
- headCommit = null;
178
- }
179
- let freshnessRaw: string | null = null;
180
- try {
181
- freshnessRaw = readFileSync(join(dir, '.freshness.json'), 'utf-8');
182
- } catch {
183
- freshnessRaw = null;
184
- }
185
- startEvent.contextFreshness = checkContextFreshness(freshnessRaw, headCommit);
186
-
187
- const ledgerPath = join(dir, 'token-ledger.jsonl');
188
- try {
189
- appendFileSync(ledgerPath, `${JSON.stringify(startEvent)}\n`);
190
- } catch {
191
- return null;
192
- }
193
-
194
- return sessionId;
195
- }
10
+ import { recordSessionStartFromPayloadText } from '../lib/context-session-start';
196
11
 
197
- // Entrypoint — kept minimal so unit coverage focuses on pure helpers above.
198
12
  if (import.meta.main) {
199
13
  try {
200
- recordSessionStart(join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context'));
14
+ recordSessionStartFromPayloadText(await Bun.stdin.text());
201
15
  } catch {
202
16
  /* fail-open */
203
17
  }
@@ -1,102 +1,17 @@
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
- * **Fail-open contract:** every error path — missing `.spur/context/`, missing/unparseable
12
- * `.session.json`, write failure — exits 0 with no output.
13
- *
14
- * Self-contained by design (task 0232). Installed hook configs use the portable
15
- * `superskill hook run sp context-session-stop` entrypoint.
16
- */
17
-
18
- import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
19
- import { join } from 'node:path';
20
- import { getEnvVar } from '../lib/env';
21
-
22
- interface LedgerEvent {
23
- session: string;
24
- type: string;
25
- tokens?: number;
26
- }
27
-
28
- /** Scan the token-ledger JSONL for this session's events and compute rollup totals. */
29
- function computeSessionTotals(
30
- ledgerPath: string,
31
- sessionId: string,
32
- ): { reads: number; writes: number; tokens: number } {
33
- if (!existsSync(ledgerPath)) return { reads: 0, writes: 0, tokens: 0 };
34
- let reads = 0;
35
- let writes = 0;
36
- let tokens = 0;
37
- for (const line of readFileSync(ledgerPath, 'utf-8').split('\n')) {
38
- if (!line.trim()) continue;
39
- try {
40
- const evt = JSON.parse(line) as LedgerEvent;
41
- if (evt.session !== sessionId) continue;
42
- if (evt.type === 'read') reads++;
43
- else if (evt.type === 'write') writes++;
44
- if (evt.tokens) tokens += evt.tokens;
45
- } catch {
46
- // skip unparseable lines
47
- }
48
- }
49
- return { reads, writes, tokens };
50
- }
51
-
52
- /**
53
- * Finalize one session (task 0969 R3): read the `.session.json` pointer, compute the session's
54
- * rollup totals, append the `session_end` row, remove the pointer (best-effort) and return the
55
- * event. Shared by this hook's entrypoint and Pi's `session_shutdown` so both hosts roll up
56
- * identically. Returns null on every fail-open path.
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.
57
8
  */
58
- export function recordSessionEnd(dir: string, now: () => Date = () => new Date()): Record<string, unknown> | null {
59
- const sessionFile = join(dir, '.session.json');
60
- if (!existsSync(sessionFile)) return null;
61
9
 
62
- let sessionId = '';
63
- try {
64
- const session = JSON.parse(readFileSync(sessionFile, 'utf-8')) as { session?: string };
65
- sessionId = session.session ?? '';
66
- } catch {
67
- return null;
68
- }
69
-
70
- if (!sessionId) return null;
71
-
72
- const totals = computeSessionTotals(join(dir, 'token-ledger.jsonl'), sessionId);
73
-
74
- const event = {
75
- ts: now().toISOString(),
76
- session: sessionId,
77
- type: 'session_end' as const,
78
- totals,
79
- };
80
-
81
- try {
82
- appendFileSync(join(dir, 'token-ledger.jsonl'), `${JSON.stringify(event)}\n`);
83
- } catch {
84
- return null;
85
- }
86
-
87
- try {
88
- rmSync(sessionFile, { force: true });
89
- } catch {
90
- // cleanup is best-effort
91
- }
92
-
93
- return event;
94
- }
10
+ import { recordSessionEndFromPayloadText } from '../lib/context-session-stop';
95
11
 
96
- // Entrypoint: run the shared core, then exit 0 (fail-open) as this hook always has.
97
12
  if (import.meta.main) {
98
13
  try {
99
- recordSessionEnd(join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context'));
14
+ recordSessionEndFromPayloadText(await Bun.stdin.text());
100
15
  } catch {
101
16
  /* fail-open: every error path exits 0 */
102
17
  }
@@ -26,14 +26,15 @@ import { spawnSync } from 'node:child_process';
26
26
  import { existsSync, realpathSync } from 'node:fs';
27
27
  import { homedir } from 'node:os';
28
28
  import { isAbsolute, join, resolve } from 'node:path';
29
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
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 } 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
 
@@ -44,6 +45,16 @@ const spurContextDir = (): string => join(process.cwd(), '.spur', 'context');
44
45
 
45
46
  // ─── Helpers ─────────────────────────────────────────────────────────────
46
47
 
48
+ /**
49
+ * Pi session owner identity (task 1159): the extension's own session-manager id, stable for the
50
+ * life of the Pi session and distinct from any nested child session. Read here — not from a PID or
51
+ * `SPUR_RUN_ID` — so a nested child's `session_shutdown` cannot claim the parent's pointer.
52
+ */
53
+ function piSessionOwner(ctx: ExtensionContext): SessionOwner | null {
54
+ const sessionId = ctx.sessionManager.getSessionId().trim();
55
+ return sessionId.length > 0 ? { host: 'pi', sessionId } : null;
56
+ }
57
+
47
58
  type TaskOwnership = 'owned' | 'unowned' | 'unknown';
48
59
 
49
60
  /**
@@ -238,32 +249,35 @@ export default function (pi: ExtensionAPI): void {
238
249
  });
239
250
 
240
251
  // ── context-post-tool: shared recordToolUseEvent (task 0969 R1) ──
241
- pi.on('tool_result', async (event) => {
252
+ pi.on('tool_result', async (event, ctx) => {
242
253
  try {
243
254
  const payload = normalizePiToolEvent(
244
255
  event.toolName,
245
256
  event.input as Record<string, unknown> | undefined,
246
257
  (event as { content?: unknown }).content,
247
258
  );
248
- if (payload) recordToolUseEvent(spurContextDir(), payload);
259
+ // Host-native session id, as Claude's payload carries (H21 parity).
260
+ const sessionId = piSessionOwner(ctx)?.sessionId;
261
+ if (payload)
262
+ recordToolUseEvent(spurContextDir(), sessionId ? { ...payload, session_id: sessionId } : payload);
249
263
  } catch {
250
264
  // fail-open
251
265
  }
252
266
  });
253
267
 
254
268
  // ── context-session-start: shared recordSessionStart (R2) ────────
255
- pi.on('session_start', async () => {
269
+ pi.on('session_start', async (_event, ctx) => {
256
270
  try {
257
- recordSessionStart(spurContextDir(), getEnvVars(), undefined, 'pi');
271
+ recordSessionStart(spurContextDir(), getEnvVars(), undefined, 'pi', piSessionOwner(ctx) ?? undefined);
258
272
  } catch {
259
273
  // fail-open
260
274
  }
261
275
  });
262
276
 
263
277
  // ── context-session-stop: shared recordSessionEnd (R3) ──────────
264
- pi.on('session_shutdown', async () => {
278
+ pi.on('session_shutdown', async (_event, ctx) => {
265
279
  try {
266
- recordSessionEnd(spurContextDir());
280
+ recordSessionEnd(spurContextDir(), undefined, piSessionOwner(ctx) ?? undefined);
267
281
  } catch {
268
282
  // fail-open
269
283
  }
@@ -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,56 @@
1
+ /**
2
+ * context-owner — host-native session ownership identity shared by the indexed-context hooks
3
+ * (task 1159).
4
+ *
5
+ * Extracted from `context-session-start`/`context-session-stop` so the Claude stdin-payload →
6
+ * owner derivation is a pure, importable function. A hook's `if (import.meta.main)` entrypoint runs
7
+ * in a subprocess whose body Bun's coverage never attributes, so any real logic left there is
8
+ * untestable; this module keeps that logic where a unit test can reach it.
9
+ *
10
+ * Standalone-safe (sp-plugin-standalone): no imports, so a hook bundles it as a relative path and
11
+ * `context-session-stop` stays free of the session-start module's heavier dependency surface.
12
+ */
13
+
14
+ /**
15
+ * Who owns a session ledger pointer (task 1159): the host that opened the session plus that host's
16
+ * own native session id — Claude's hook payload `session_id`, Pi's `ctx.sessionManager.getSessionId()`.
17
+ * Deliberately **not** the ledger id (shared by every nested run), `process.pid` (a fresh hook process
18
+ * per event) or `SPUR_RUN_ID` (present on nested children too): none of those separate owner from
19
+ * child, so none may gate teardown; the host-native id can.
20
+ */
21
+ export interface SessionOwner {
22
+ host: 'claude' | 'pi';
23
+ sessionId: string;
24
+ }
25
+
26
+ /**
27
+ * Claude hook identity (task 1159): the payload's `session_id`, falling back to
28
+ * `CLAUDE_CODE_SESSION_ID` only when the payload id is absent or empty (some Claude hook payloads
29
+ * omit `session_id`). Returns null when neither yields a nonempty id — the pointer is then written
30
+ * ownerless, so a later shutdown cannot prove ownership of it (bounded legacy policy in
31
+ * context-session-stop).
32
+ */
33
+ export function claudeSessionOwner(payload: unknown, env: NodeJS.ProcessEnv): SessionOwner | null {
34
+ const raw = payload && typeof payload === 'object' ? (payload as { session_id?: unknown }).session_id : undefined;
35
+ const fromPayload = typeof raw === 'string' ? raw.trim() : '';
36
+ const fromEnv = (env.CLAUDE_CODE_SESSION_ID ?? '').trim();
37
+ const sessionId = fromPayload.length > 0 ? fromPayload : fromEnv;
38
+ return sessionId.length > 0 ? { host: 'claude', sessionId } : null;
39
+ }
40
+
41
+ /**
42
+ * Derive the Claude owner from a raw hook stdin payload (task 1159).
43
+ *
44
+ * Malformed or empty stdin carries no payload identity, so it degrades to the env-only fallback
45
+ * rather than throwing — both hooks keep their fail-open contract. Behaviour is identical to the
46
+ * former inline `JSON.parse(await Bun.stdin.text())` try/catch in each entrypoint.
47
+ */
48
+ export function claudeHookOwnerFromPayloadText(text: string, env: NodeJS.ProcessEnv): SessionOwner | null {
49
+ let payload: unknown;
50
+ try {
51
+ payload = JSON.parse(text) as unknown;
52
+ } catch {
53
+ payload = undefined;
54
+ }
55
+ return claudeSessionOwner(payload, env);
56
+ }