@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/config/config.global.yaml +4 -2
- 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/templates/AGENTS.md +1 -1
- package/config/workflows/feature-lifecycle.yaml +46 -1
- package/config/workflows/feature-verification.yaml +11 -0
- package/config/workflows/wrapup-pipeline.yaml +1 -1
- package/package.json +10 -10
- package/plugins/sp/README.md +23 -18
- package/plugins/sp/agents/super-planner.md +5 -6
- package/plugins/sp/agents/super-reviewer.md +43 -0
- package/plugins/sp/bunfig.toml +8 -5
- package/plugins/sp/commands/dev-review-session.md +2 -1
- package/plugins/sp/commands/dev-runall.md +18 -9
- package/plugins/sp/hooks/careful-guard.ts +1 -1
- package/plugins/sp/hooks/context-post-tool.ts +7 -404
- package/plugins/sp/hooks/context-session-start.ts +6 -192
- package/plugins/sp/hooks/context-session-stop.ts +6 -91
- package/plugins/sp/hooks/pi/guard-extension.ts +26 -12
- 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-owner.ts +56 -0
- 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 +6 -4
- 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 +1361 -87
- package/plugins/sp/lib/plan-projection.generated.d.mts +169 -0
- package/plugins/sp/lib/plan-projection.generated.mjs +704 -1
- package/plugins/sp/lib/quality-gate.generated.d.mts +0 -12
- package/plugins/sp/lib/quality-gate.generated.mjs +62 -231
- 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 -200
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/references/roles.md +1 -1
- package/plugins/sp/scripts/batch-plan.mjs +740 -1
- package/plugins/sp/scripts/batch-plan.ts +48 -3
- 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 +158 -120
- package/plugins/sp/scripts/inline-run-setup.ts +1 -21
- package/plugins/sp/scripts/pr-reviewing.mjs +19 -5
- package/plugins/sp/scripts/pr-reviewing.ts +34 -9
- package/plugins/sp/scripts/quality-gate.mjs +38 -208
- 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 +160 -95
- package/plugins/sp/scripts/run-summary.ts +7 -5
- package/plugins/sp/scripts/session-timeline.mjs +57 -29
- package/plugins/sp/scripts/session-timeline.ts +4 -18
- package/plugins/sp/scripts/task-diffstat.mjs +43 -30
- package/plugins/sp/scripts/task-diffstat.ts +40 -71
- 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/indexed-context/SKILL.md +14 -7
- package/plugins/sp/skills/next-feature/SKILL.md +1 -1
- package/plugins/sp/skills/parallel-execution/SKILL.md +2 -2
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +2 -2
- package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +2 -2
- package/plugins/sp/skills/pr-reviewing/SKILL.md +7 -3
- package/plugins/sp/skills/session-review/SKILL.md +5 -3
- 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 +40 -8
- package/plugins/sp/skills/spur-cli/references/features.md +46 -5
- package/plugins/sp/skills/spur-cli/references/projects.md +7 -5
- package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +27 -0
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +2 -3
- package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +6 -1
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +56 -7
- package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +4 -1
- 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/flag-glossary.md +13 -11
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +37 -25
- package/spur.js +89792 -76992
- package/web/_astro/{BoardApp.D66k_s_T.js → BoardApp.5IIl2O-z.js} +1 -1
- package/web/_astro/BoardApp.Drnx-KeM.js +324 -0
- package/web/_astro/TaskDetail.Ci4LNn9S.js +1 -0
- package/web/_astro/{arc._BUadrdH.js → arc.j2EVkYVd.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.Dh7xS1Pp.js → architectureDiagram-3BPJPVTR.DZWSFqEG.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.DFDVpfXm.js → blockDiagram-GPEHLZMM.DE6GpXCN.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.V7_3_glh.js → c4Diagram-AAUBKEIU.DoVR1G6B.js} +1 -1
- package/web/_astro/channel.DmdDxGsq.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.De1yx7Jz.js → chunk-2J33WTMH.Do6-Gbyf.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.BDhHFuxx.js → chunk-4BX2VUAB.BYV1HGHx.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.DDLkWnmC.js → chunk-55IACEB6.CvI1zkrc.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.CwfiLnA7.js → chunk-727SXJPM.DNGSCRlX.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.CtGDmUnM.js → chunk-AQP2D5EJ.DXhhgT5z.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.BOLjpw73.js → chunk-FMBD7UC4.dzkRKP2u.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.BDhQls6O.js → chunk-ND2GUHAM.DujGLeEf.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.76qBynDF.js → chunk-QZHKN3VN.BWtIT3ZU.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.N0mpPHT0.js → classDiagram-4FO5ZUOK.DyApg0Nn.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.N0mpPHT0.js → classDiagram-v2-Q7XG4LA2.DyApg0Nn.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.CKknFfjB.js → cose-bilkent-S5V4N54A.DJQB-u_l.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.Cw3ZWBAh.js → cynefin-OW5HDTMX.bi3D_siV.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.D3yIKCRD.js → dagre-BM42HDAG.C-f7TrRg.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.D51dO83Z.js → diagram-2AECGRRQ.DrPfjHQB.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.Bb-FViDj.js → diagram-5GNKFQAL.C9S4WUHO.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.D3mN8S6x.js → diagram-KO2AKTUF.Dzzye8QH.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.dMMyPcbJ.js → diagram-LMA3HP47.DX-LRiof.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.AzbvzY4C.js → diagram-OG6HWLK6.Z-kAKwIz.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.BR5I4OZb.js → erDiagram-TEJ5UH35.D6dyClSv.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.DFqvhGkn.js → flowDiagram-I6XJVG4X.Cx3q2cxD.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.ByJbhH6J.js → ganttDiagram-6RSMTGT7.DU3gH6CT.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.CInHjQR3.js → gitGraphDiagram-PVQCEYII.ISmCnJ87.js} +1 -1
- package/web/_astro/index.Do2lLdgo.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.Cm0dGVjJ.js → infoDiagram-5YYISTIA.DR6_iNOK.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.C3nOP6Sj.js → ishikawaDiagram-YF4QCWOH.D7wY-ZGA.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.4IvLa2MW.js → journeyDiagram-JHISSGLW.DkP9-WK8.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.DS87Cl-k.js → kanban-definition-UN3LZRKU.wtgY98M2.js} +1 -1
- package/web/_astro/{linear.MzmRrNER.js → linear.CI6djNi7.js} +1 -1
- package/web/_astro/{mermaid.core.fK-WNswS.js → mermaid.core.CuQPxS8b.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.BqipOHhY.js → mindmap-definition-RKZ34NQL.5HNIEG7R.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.0nBiPqFG.js → pieDiagram-4H26LBE5.CchhDFjE.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.Szj7Zpx5.js → quadrantDiagram-W4KKPZXB.PYXXJPSJ.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.D4UR6TWo.js → requirementDiagram-4Y6WPE33.cUQuNAKU.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.DAE7BdOs.js → sankeyDiagram-5OEKKPKP.BO7MnRIA.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.BP9ZVq25.js → sequenceDiagram-3UESZ5HK.D9bElyD6.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.BQW7mIxk.js → stateDiagram-AJRCARHV.DhGdRz7I.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.PbK4nDih.js → stateDiagram-v2-BHNVJYJU.5GvLeAoL.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.Ci_GvUpo.js → timeline-definition-PNZ67QCA.LtAT9haM.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.DNw1Y5qx.js → vennDiagram-CIIHVFJN.DRth1yLA.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.CwlkaaNe.js → wardleyDiagram-YWT4CUSO.h9MlZfcr.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.MI6ZuiH_.js → xychartDiagram-2RQKCTM6.DHqy9DYA.js} +1 -1
- package/web/index.html +2 -2
- package/config/proportional-route-table.ts +0 -155
- package/plugins/sp/commands/dev-parallel.md +0 -37
- package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
- package/web/_astro/BoardApp.D6sjAJr_.js +0 -324
- package/web/_astro/TaskDetail.BynLnMNc.js +0 -1
- package/web/_astro/channel.DZVfto-r.js +0 -1
- package/web/_astro/index.COh_kBxN.css +0 -1
- /package/plugins/sp/{hooks → lib}/destructive-policy.ts +0 -0
- /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
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
* **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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
24
|
+
import { couldBeTaskFile } from '../lib/task-file-policy';
|
|
25
25
|
|
|
26
26
|
interface ToolPayload {
|
|
27
27
|
tool_name?: string;
|
|
@@ -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
|
+
}
|