@gobing-ai/spur 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/config/decisions/maker-liveness.yaml +39 -0
- package/config/plugin-scripts.json +0 -4
- package/config/rules/README.md +1 -0
- package/config/rules/docs/sourced-api-claims.yaml +56 -12
- package/config/rules/strict/runtime-boundaries.yaml +7 -0
- package/config/script-placement-baseline.json +1 -19
- package/config/workflows/feature-lifecycle.yaml +46 -1
- package/config/workflows/feature-verification.yaml +11 -0
- package/package.json +9 -9
- package/plugins/sp/README.md +22 -16
- package/plugins/sp/agents/super-reviewer.md +43 -0
- package/plugins/sp/bunfig.toml +8 -5
- package/plugins/sp/hooks/careful-guard.ts +1 -1
- package/plugins/sp/hooks/context-post-tool.ts +7 -427
- package/plugins/sp/hooks/context-session-start.ts +5 -213
- package/plugins/sp/hooks/context-session-stop.ts +5 -191
- package/plugins/sp/hooks/pi/guard-extension.ts +6 -5
- package/plugins/sp/hooks/task-write-guard.ts +1 -1
- package/plugins/sp/{hooks → lib}/agent-hint.ts +1 -1
- package/plugins/sp/lib/context-post-tool.ts +432 -0
- package/plugins/sp/lib/context-session-start.ts +214 -0
- package/plugins/sp/lib/context-session-stop.ts +196 -0
- package/plugins/sp/lib/dogfood-pipeline.ts +265 -0
- package/plugins/sp/lib/dogfood-report.ts +278 -0
- package/plugins/sp/lib/idea-handoff.generated.mjs +3 -3
- package/plugins/sp/lib/inline-run-app.ts +40 -0
- package/plugins/sp/lib/inline-run.generated.d.mts +3 -3
- package/plugins/sp/lib/inline-run.generated.mjs +1347 -90
- package/plugins/sp/lib/plan-projection.generated.d.mts +150 -0
- package/plugins/sp/lib/plan-projection.generated.mjs +591 -1
- package/plugins/sp/lib/quality-gate.generated.mjs +38 -25
- package/plugins/sp/lib/residual-scan.generated.d.mts +7 -1
- package/plugins/sp/lib/residual-scan.generated.mjs +71 -6
- package/plugins/sp/lib/run-summary-core.ts +34 -370
- package/plugins/sp/lib/transcript.ts +37 -228
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/batch-plan.mjs +586 -0
- package/plugins/sp/scripts/batch-preflight.mjs +23 -1
- package/plugins/sp/scripts/batch-preflight.ts +35 -295
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +108 -87
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +111 -104
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -1
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +7 -265
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +3 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +6 -272
- package/plugins/sp/scripts/feature-verification-steps.mjs +7 -123
- package/plugins/sp/scripts/feature-verification-steps.ts +29 -181
- package/plugins/sp/scripts/inline-run-dispatch.mjs +27 -26
- package/plugins/sp/scripts/inline-run-dispatch.ts +1 -21
- package/plugins/sp/scripts/inline-run-setup.mjs +149 -122
- package/plugins/sp/scripts/inline-run-setup.ts +1 -21
- package/plugins/sp/scripts/quality-gate.mjs +14 -2
- package/plugins/sp/scripts/residual-scan.mjs +73 -6
- package/plugins/sp/scripts/residual-scan.ts +8 -5
- package/plugins/sp/scripts/run-summary.mjs +151 -97
- package/plugins/sp/scripts/run-summary.ts +7 -5
- package/plugins/sp/scripts/session-timeline.mjs +46 -19
- package/plugins/sp/scripts/task-diffstat.mjs +11 -1
- package/plugins/sp/scripts/task-diffstat.ts +8 -39
- package/plugins/sp/skills/code-implementation/SKILL.md +7 -1
- package/plugins/sp/skills/code-verification/references/secu-review.md +2 -1
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +4 -0
- package/plugins/sp/skills/dogfood-testing/SKILL.md +2 -2
- package/plugins/sp/skills/next-feature/SKILL.md +1 -1
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
- package/plugins/sp/skills/source-driven-development/SKILL.md +7 -4
- package/plugins/sp/skills/spur-cli/references/builder.md +3 -2
- package/plugins/sp/skills/spur-cli/references/decision.md +32 -7
- package/plugins/sp/skills/spur-cli/references/features.md +46 -5
- package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +1 -1
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +13 -3
- package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +22 -0
- package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +14 -0
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +29 -12
- package/spur.js +80681 -79296
- package/web/_astro/{BoardApp.B3GQZvj8.js → BoardApp.5IIl2O-z.js} +1 -1
- package/web/_astro/{BoardApp.BT7L2kSb.js → BoardApp.Drnx-KeM.js} +150 -150
- package/web/_astro/{TaskDetail.Cxov1_N_.js → TaskDetail.Ci4LNn9S.js} +1 -1
- package/web/_astro/{arc.vaT7tPOB.js → arc.j2EVkYVd.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.DRMmTjkN.js → architectureDiagram-3BPJPVTR.DZWSFqEG.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.cla0LO_q.js → blockDiagram-GPEHLZMM.DE6GpXCN.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.D4YAIBKn.js → c4Diagram-AAUBKEIU.DoVR1G6B.js} +1 -1
- package/web/_astro/channel.DmdDxGsq.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.BrSJbMRZ.js → chunk-2J33WTMH.Do6-Gbyf.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.CuLMgKF5.js → chunk-4BX2VUAB.BYV1HGHx.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.zkvgu54F.js → chunk-55IACEB6.CvI1zkrc.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.DxWkACh8.js → chunk-727SXJPM.DNGSCRlX.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.42XrmIlH.js → chunk-AQP2D5EJ.DXhhgT5z.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.Bao2wMK9.js → chunk-FMBD7UC4.dzkRKP2u.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.BhydojD1.js → chunk-ND2GUHAM.DujGLeEf.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.CmZ82g-H.js → chunk-QZHKN3VN.BWtIT3ZU.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.Blr83Gt_.js → classDiagram-4FO5ZUOK.DyApg0Nn.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.Blr83Gt_.js → classDiagram-v2-Q7XG4LA2.DyApg0Nn.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.qmfv0SPn.js → cose-bilkent-S5V4N54A.DJQB-u_l.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.BtELhXPR.js → cynefin-OW5HDTMX.bi3D_siV.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.AWmaAvXo.js → dagre-BM42HDAG.C-f7TrRg.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.DdH34KDi.js → diagram-2AECGRRQ.DrPfjHQB.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.BmNPix1k.js → diagram-5GNKFQAL.C9S4WUHO.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.BNQd_Ppo.js → diagram-KO2AKTUF.Dzzye8QH.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.BAPgDi0e.js → diagram-LMA3HP47.DX-LRiof.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.DZ4zyLdC.js → diagram-OG6HWLK6.Z-kAKwIz.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.Q1TySMmw.js → erDiagram-TEJ5UH35.D6dyClSv.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.BXhFBicG.js → flowDiagram-I6XJVG4X.Cx3q2cxD.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.vHSgcuo2.js → ganttDiagram-6RSMTGT7.DU3gH6CT.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.Bi1f73C7.js → gitGraphDiagram-PVQCEYII.ISmCnJ87.js} +1 -1
- package/web/_astro/index.Do2lLdgo.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.DPvXZEWm.js → infoDiagram-5YYISTIA.DR6_iNOK.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.Cz829fsV.js → ishikawaDiagram-YF4QCWOH.D7wY-ZGA.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.enRnB9Me.js → journeyDiagram-JHISSGLW.DkP9-WK8.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.DnTNk6d2.js → kanban-definition-UN3LZRKU.wtgY98M2.js} +1 -1
- package/web/_astro/{linear.BzGnBwNL.js → linear.CI6djNi7.js} +1 -1
- package/web/_astro/{mermaid.core.CSbTpIVz.js → mermaid.core.CuQPxS8b.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.DGJDy8zT.js → mindmap-definition-RKZ34NQL.5HNIEG7R.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.HSPdi5_8.js → pieDiagram-4H26LBE5.CchhDFjE.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.6Gv8mz2Y.js → quadrantDiagram-W4KKPZXB.PYXXJPSJ.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.CnBCtFwa.js → requirementDiagram-4Y6WPE33.cUQuNAKU.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.5M8VWAu8.js → sankeyDiagram-5OEKKPKP.BO7MnRIA.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.DN41XN-U.js → sequenceDiagram-3UESZ5HK.D9bElyD6.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.ErOO3W8j.js → stateDiagram-AJRCARHV.DhGdRz7I.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.B_ZHICM-.js → stateDiagram-v2-BHNVJYJU.5GvLeAoL.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.CWdosr07.js → timeline-definition-PNZ67QCA.LtAT9haM.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.BHzYjNV5.js → vennDiagram-CIIHVFJN.DRth1yLA.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.h8x4IwPX.js → wardleyDiagram-YWT4CUSO.h9MlZfcr.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.RTapW2X8.js → xychartDiagram-2RQKCTM6.DHqy9DYA.js} +1 -1
- package/web/index.html +2 -2
- package/config/proportional-route-table.ts +0 -155
- package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
- package/web/_astro/channel.D4nG9YLf.js +0 -1
- package/web/_astro/index.BhSwN8yY.css +0 -1
- /package/plugins/sp/{hooks → lib}/context-owner.ts +0 -0
- /package/plugins/sp/{hooks → lib}/destructive-policy.ts +0 -0
- /package/plugins/sp/{hooks → lib}/task-file-policy.ts +0 -0
|
@@ -1,438 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
|
|
3
2
|
/**
|
|
4
|
-
* context-post-tool — PostToolUse
|
|
3
|
+
* context-post-tool — installed PostToolUse entry for indexed-context
|
|
5
4
|
* (matcher: Bash|Grep|Glob|Read|Write|Edit — task 0248).
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Token cascade (task 0246): tool_response.content → Write tool_input.content →
|
|
12
|
-
* Edit old/new strings → Read file stat → omit tokens (never store 0 for unknown).
|
|
13
|
-
*
|
|
14
|
-
* Redaction (task 0248): store summary only (never full stdout); cap text ~4 KiB;
|
|
15
|
-
* strip obvious secret patterns. Bash tokens estimate from capped stdout/stderr length.
|
|
16
|
-
*
|
|
17
|
-
* **Fail-open contract:** every error path exits 0 with no output.
|
|
18
|
-
*
|
|
19
|
-
* Self-contained by design (task 0232/0246/0248). Installed hook configs use
|
|
20
|
-
* `superskill hook run sp context-post-tool`.
|
|
6
|
+
* Task 1174: the capture logic lives in `plugins/sp/lib/context-post-tool.ts`; this file is the
|
|
7
|
+
* argv/stdin adapter the installed `superskill hook run sp context-post-tool` entry resolves to.
|
|
8
|
+
* The redaction, byte-cap and fail-open contract (every error path exits 0 with no output) is
|
|
9
|
+
* unchanged — the adapter adds no policy of its own.
|
|
21
10
|
*/
|
|
22
11
|
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import { join, resolve, sep } from 'node:path';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { recordToolUseEvent, type ToolPayload } from '../lib/context-post-tool';
|
|
26
14
|
import { getEnvVar } from '../lib/env';
|
|
27
15
|
|
|
28
|
-
/** Tools recorded by this hook (must match hooks.json PostToolUse matcher). */
|
|
29
|
-
export const ALLOWED_TOOLS = new Set(['Bash', 'Grep', 'Glob', 'Read', 'Write', 'Edit']);
|
|
30
|
-
|
|
31
|
-
/** Max stored summary / token-estimate input size (bytes). Task 0248 ~2–4 KiB. */
|
|
32
|
-
export const REDACTION_CAP_BYTES = 4096;
|
|
33
|
-
|
|
34
|
-
/** Max command/pattern summary length before ellipsis (chars). */
|
|
35
|
-
export const SUMMARY_MAX_CHARS = 200;
|
|
36
|
-
|
|
37
|
-
export interface ToolPayload {
|
|
38
|
-
session_id?: string;
|
|
39
|
-
tool_name?: string;
|
|
40
|
-
tool_input?: {
|
|
41
|
-
file_path?: string;
|
|
42
|
-
content?: string;
|
|
43
|
-
old_string?: string;
|
|
44
|
-
new_string?: string;
|
|
45
|
-
/** Bash */
|
|
46
|
-
command?: string;
|
|
47
|
-
/** Grep / Glob (Claude Code shapes vary) */
|
|
48
|
-
pattern?: string;
|
|
49
|
-
glob_pattern?: string;
|
|
50
|
-
glob?: string;
|
|
51
|
-
path?: string;
|
|
52
|
-
};
|
|
53
|
-
tool_response?: {
|
|
54
|
-
content?: string | unknown;
|
|
55
|
-
filePath?: string;
|
|
56
|
-
stdout?: string;
|
|
57
|
-
stderr?: string;
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface SessionFile {
|
|
62
|
-
session?: string;
|
|
63
|
-
agent?: string;
|
|
64
|
-
model?: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Estimate tokens from raw byte count: Math.ceil(bytes / 4). */
|
|
68
|
-
export function estimateTokens(text: string): number {
|
|
69
|
-
return Math.ceil(new TextEncoder().encode(text).length / 4);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Scrub secret-like patterns without size capping. */
|
|
73
|
-
export function scrubSecrets(text: string): string {
|
|
74
|
-
let s = text;
|
|
75
|
-
// Quoted JSON fields first (task 1158): in `{"password": "…"}` the key is itself
|
|
76
|
-
// quoted, so the bare `key=value` pass below never sees a separator directly after
|
|
77
|
-
// the key and would persist the value verbatim. Match the quoted sensitive key, keep
|
|
78
|
-
// it, and replace the entire value. The key may be spelled three ways in real Bash:
|
|
79
|
-
// `"key"`, `'key'`, and the shell-escaped `\"key\"` inside a double-quoted argument
|
|
80
|
-
// (`curl -d "{\"password\":\"…\"}"`); the value may be quoted the same way or left
|
|
81
|
-
// unquoted (`{"password": 12345}`). Escape-aware: a `\"` or `\\` inside a value is
|
|
82
|
-
// content, never a terminator, and an unterminated value (including a trailing lone
|
|
83
|
-
// backslash) is redacted through end-of-input so a truncated command cannot leak a
|
|
84
|
-
// suffix. In the shell-escaped opener form the JSON quote shows up in three raw
|
|
85
|
-
// spellings: a terminator `\"`, a JSON-level escaped quote `\\\"` (escaped
|
|
86
|
-
// backslash + escaped quote) that is value content, and a bare `"` that is neither.
|
|
87
|
-
// That form gets a dedicated alternative whose body consumes `\\\"` as content and
|
|
88
|
-
// stops only at a clean `\"` terminator; when no clean terminator follows it fails
|
|
89
|
-
// closed and redacts through end-of-input, so a bare `"` cannot end the value early
|
|
90
|
-
// and leak its suffix. Runs before
|
|
91
|
-
// Bearer/`key=` passes so the whole value is replaced, not prefixed. The key must end
|
|
92
|
-
// on a sensitive word, so `token_count`, `tokens`, `tokenizer` and
|
|
93
|
-
// `authorization_header` stay readable.
|
|
94
|
-
s = s.replace(
|
|
95
|
-
/((\\?(["']))(?:[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,
|
|
96
|
-
'$1"***"',
|
|
97
|
-
);
|
|
98
|
-
// Bearer / API key prefixes first (before generic key=value)
|
|
99
|
-
s = s.replace(/\bBearer\s+[A-Za-z0-9._\-+=/]+/gi, 'Bearer ***');
|
|
100
|
-
// `sk-` keys. Segments may be hyphen-separated (`sk-ant-api03-…`), so match the
|
|
101
|
-
// whole run rather than a single alphanumeric block — anchoring on `[A-Za-z0-9]{8,}`
|
|
102
|
-
// alone stopped at the first hyphen and let every Anthropic key through, which is
|
|
103
|
-
// the most likely secret to appear in a Claude-agent harness.
|
|
104
|
-
s = s.replace(/\bsk-[A-Za-z0-9_-]{8,}/g, 'sk-***');
|
|
105
|
-
s = s.replace(/\bAKIA[0-9A-Z]{16}\b/g, 'AKIA***');
|
|
106
|
-
// Provider token prefixes with a fixed, unambiguous shape.
|
|
107
|
-
s = s.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}/g, 'github_pat_***');
|
|
108
|
-
s = s.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, (m) => `${m.slice(0, 4)}***`);
|
|
109
|
-
// Key=value style secrets (value = rest of non-space token). The key may carry a
|
|
110
|
-
// prefix (`ANTHROPIC_API_KEY`, `GITHUB_TOKEN`): `_` is a word character, so a
|
|
111
|
-
// leading `\b` would not match there and every namespaced env var leaked. Allow an
|
|
112
|
-
// optional `WORD_`-style prefix instead of requiring a boundary.
|
|
113
|
-
// The whole key (prefix included) is captured so the ledger still says *which*
|
|
114
|
-
// variable was scrubbed — `ANTHROPIC_API_KEY=***`, not a bare `API_KEY=***`.
|
|
115
|
-
s = s.replace(
|
|
116
|
-
/\b((?:[A-Za-z0-9]+[_-])*(?:api[_-]?key|password|passwd|secret|token|access[_-]?key|private[_-]?key))\s*[:=]\s*\S+/gi,
|
|
117
|
-
'$1=***',
|
|
118
|
-
);
|
|
119
|
-
// Authorization: <scheme> <credentials...>
|
|
120
|
-
s = s.replace(/\bAuthorization\s*:\s*\S+(?:\s+\S+)*/gi, 'Authorization: ***');
|
|
121
|
-
// PEM private key blocks — redact the payload, keep the header as a marker.
|
|
122
|
-
s = s.replace(
|
|
123
|
-
/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z ]+ )?PRIVATE KEY-----|$)/g,
|
|
124
|
-
'[private-key-redacted]',
|
|
125
|
-
);
|
|
126
|
-
// Collapse long base64 only when + / or = present (avoid pure a-z body blobs)
|
|
127
|
-
s = s.replace(/\b[A-Za-z0-9+/]{80,}={0,2}\b/g, (m) => (/[+/=]/.test(m) ? '[base64-redacted]' : m));
|
|
128
|
-
return s;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Cap text to {@link REDACTION_CAP_BYTES} and scrub obvious secret patterns.
|
|
133
|
-
* Used for any stored summary fragments — never full env dumps.
|
|
134
|
-
*/
|
|
135
|
-
export function redactText(text: string, capBytes: number = REDACTION_CAP_BYTES): string {
|
|
136
|
-
const s = scrubSecrets(text);
|
|
137
|
-
const bytes = new TextEncoder().encode(s);
|
|
138
|
-
if (bytes.length <= capBytes) return s;
|
|
139
|
-
// Truncate on byte boundary then append marker
|
|
140
|
-
let end = capBytes;
|
|
141
|
-
while (end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) end--;
|
|
142
|
-
return `${new TextDecoder().decode(bytes.subarray(0, end))}…[truncated]`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Byte length after scrub + hard cap (no truncation marker) — for token estimates. */
|
|
146
|
-
export function cappedByteLength(text: string, capBytes: number = REDACTION_CAP_BYTES): number {
|
|
147
|
-
const scrubbed = scrubSecrets(text);
|
|
148
|
-
return Math.min(new TextEncoder().encode(scrubbed).length, capBytes);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Truncate a one-line summary for the ledger (after light redaction). */
|
|
152
|
-
export function truncateSummary(text: string, maxChars: number = SUMMARY_MAX_CHARS): string {
|
|
153
|
-
const cleaned = redactText(text.replace(/\s+/g, ' ').trim(), REDACTION_CAP_BYTES);
|
|
154
|
-
if (cleaned.length <= maxChars) return cleaned;
|
|
155
|
-
return `${cleaned.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Map platform tool name → ledger `type` (task 0248 R2).
|
|
160
|
-
* Edit stays `write` with action=edit (0245/0246 convention).
|
|
161
|
-
*/
|
|
162
|
-
export function mapToolType(toolName: string): string {
|
|
163
|
-
switch (toolName) {
|
|
164
|
-
case 'Read':
|
|
165
|
-
return 'read';
|
|
166
|
-
case 'Write':
|
|
167
|
-
case 'Edit':
|
|
168
|
-
return 'write';
|
|
169
|
-
case 'Bash':
|
|
170
|
-
return 'bash';
|
|
171
|
-
case 'Grep':
|
|
172
|
-
return 'grep';
|
|
173
|
-
case 'Glob':
|
|
174
|
-
return 'glob';
|
|
175
|
-
default:
|
|
176
|
-
return toolName.toLowerCase();
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Short summary for Bash/Grep/Glob (command truncated, pattern, path glob).
|
|
182
|
-
* Not full stdout. Returns undefined when nothing useful to store.
|
|
183
|
-
*/
|
|
184
|
-
export function buildToolSummary(toolName: string, toolInput: ToolPayload['tool_input']): string | undefined {
|
|
185
|
-
if (!toolInput) return undefined;
|
|
186
|
-
if (toolName === 'Bash') {
|
|
187
|
-
const cmd = typeof toolInput.command === 'string' ? toolInput.command : '';
|
|
188
|
-
if (!cmd.trim()) return undefined;
|
|
189
|
-
return truncateSummary(cmd);
|
|
190
|
-
}
|
|
191
|
-
if (toolName === 'Grep') {
|
|
192
|
-
const pattern = typeof toolInput.pattern === 'string' ? toolInput.pattern : '';
|
|
193
|
-
const path = typeof toolInput.path === 'string' ? toolInput.path : '';
|
|
194
|
-
const glob =
|
|
195
|
-
(typeof toolInput.glob === 'string' && toolInput.glob) ||
|
|
196
|
-
(typeof toolInput.glob_pattern === 'string' && toolInput.glob_pattern) ||
|
|
197
|
-
'';
|
|
198
|
-
const parts = [pattern && `/${pattern}/`, path, glob].filter(Boolean);
|
|
199
|
-
if (parts.length === 0) return undefined;
|
|
200
|
-
return truncateSummary(parts.join(' '));
|
|
201
|
-
}
|
|
202
|
-
if (toolName === 'Glob') {
|
|
203
|
-
const pattern =
|
|
204
|
-
(typeof toolInput.pattern === 'string' && toolInput.pattern) ||
|
|
205
|
-
(typeof toolInput.glob_pattern === 'string' && toolInput.glob_pattern) ||
|
|
206
|
-
(typeof toolInput.glob === 'string' && toolInput.glob) ||
|
|
207
|
-
'';
|
|
208
|
-
const path = typeof toolInput.path === 'string' ? toolInput.path : '';
|
|
209
|
-
const parts = [pattern, path].filter(Boolean);
|
|
210
|
-
if (parts.length === 0) return undefined;
|
|
211
|
-
return truncateSummary(parts.join(' in '));
|
|
212
|
-
}
|
|
213
|
-
return undefined;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** Response text used only for size/token estimate — never written to the ledger. */
|
|
217
|
-
function responseTextForEstimate(toolResponse: ToolPayload['tool_response']): string {
|
|
218
|
-
if (!toolResponse) return '';
|
|
219
|
-
const parts: string[] = [];
|
|
220
|
-
if (typeof toolResponse.content === 'string') parts.push(toolResponse.content);
|
|
221
|
-
if (typeof toolResponse.stdout === 'string') parts.push(toolResponse.stdout);
|
|
222
|
-
if (typeof toolResponse.stderr === 'string') parts.push(toolResponse.stderr);
|
|
223
|
-
return parts.join('\n');
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Token cascade (tasks 0246 / 0248). Returns undefined when unknown — callers omit
|
|
228
|
-
* the field rather than writing tokens: 0.
|
|
229
|
-
*
|
|
230
|
-
* Bash: capped stdout/stderr length. Grep/Glob: capped result size or undefined.
|
|
231
|
-
*/
|
|
232
|
-
export function resolveTokenEstimate(
|
|
233
|
-
toolName: string,
|
|
234
|
-
toolInput: ToolPayload['tool_input'],
|
|
235
|
-
toolResponse: ToolPayload['tool_response'],
|
|
236
|
-
): number | undefined {
|
|
237
|
-
// Bash: estimate from capped response only (never uncapped multi-MB stdout).
|
|
238
|
-
if (toolName === 'Bash') {
|
|
239
|
-
const raw = responseTextForEstimate(toolResponse);
|
|
240
|
-
if (raw.length === 0) return undefined;
|
|
241
|
-
return Math.ceil(cappedByteLength(raw) / 4);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Grep / Glob: result size after cap, or omit when empty.
|
|
245
|
-
if (toolName === 'Grep' || toolName === 'Glob') {
|
|
246
|
-
const raw = responseTextForEstimate(toolResponse);
|
|
247
|
-
if (raw.length === 0) return undefined;
|
|
248
|
-
return Math.ceil(cappedByteLength(raw) / 4);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const responseContent = toolResponse?.content;
|
|
252
|
-
if (typeof responseContent === 'string' && responseContent.length > 0) {
|
|
253
|
-
return estimateTokens(responseContent);
|
|
254
|
-
}
|
|
255
|
-
if (toolName === 'Write' && typeof toolInput?.content === 'string' && toolInput.content.length > 0) {
|
|
256
|
-
return estimateTokens(toolInput.content);
|
|
257
|
-
}
|
|
258
|
-
if (toolName === 'Edit') {
|
|
259
|
-
const parts = [toolInput?.old_string ?? '', toolInput?.new_string ?? ''].join('');
|
|
260
|
-
if (parts.length > 0) return estimateTokens(parts);
|
|
261
|
-
}
|
|
262
|
-
if (toolName === 'Read' && typeof toolInput?.file_path === 'string' && toolInput.file_path) {
|
|
263
|
-
try {
|
|
264
|
-
if (existsSync(toolInput.file_path)) {
|
|
265
|
-
const size = statSync(toolInput.file_path).size;
|
|
266
|
-
if (size > 0) return Math.ceil(size / 4);
|
|
267
|
-
}
|
|
268
|
-
} catch {
|
|
269
|
-
/* fail-open: skip stat */
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
return undefined;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function readSessionFile(dir: string): SessionFile {
|
|
276
|
-
const sessionFile = join(dir, '.session.json');
|
|
277
|
-
if (!existsSync(sessionFile)) return {};
|
|
278
|
-
try {
|
|
279
|
-
return JSON.parse(readFileSync(sessionFile, 'utf-8')) as SessionFile;
|
|
280
|
-
} catch {
|
|
281
|
-
return {};
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Core record path (testable). Returns the written event, or null when nothing was logged
|
|
287
|
-
* (unknown tool, missing session, missing path/summary, I/O failure).
|
|
288
|
-
*/
|
|
289
|
-
export function recordToolUseEvent(
|
|
290
|
-
contextDir: string,
|
|
291
|
-
payload: ToolPayload,
|
|
292
|
-
now: () => Date = () => new Date(),
|
|
293
|
-
): Record<string, unknown> | null {
|
|
294
|
-
const toolName = payload.tool_name ?? '';
|
|
295
|
-
if (!ALLOWED_TOOLS.has(toolName)) return null;
|
|
296
|
-
|
|
297
|
-
const filePath = payload.tool_input?.file_path ?? '';
|
|
298
|
-
const summary = buildToolSummary(toolName, payload.tool_input);
|
|
299
|
-
|
|
300
|
-
// Read/Write/Edit still require a path; Bash/Grep/Glob require a short summary.
|
|
301
|
-
if (toolName === 'Read' || toolName === 'Write' || toolName === 'Edit') {
|
|
302
|
-
if (!filePath) return null;
|
|
303
|
-
} else if (!summary) {
|
|
304
|
-
return null;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const sessionMeta = readSessionFile(contextDir);
|
|
308
|
-
const session = sessionMeta.session ?? '';
|
|
309
|
-
if (!session) return null;
|
|
310
|
-
|
|
311
|
-
// Freshness stamp (task 0711 R4): the indexed-context producer is the agent
|
|
312
|
-
// following the skill's update guidance; the observable producer moment in
|
|
313
|
-
// code is a Write/Edit landing on a `.spur/context/*.md` file. Each such
|
|
314
|
-
// write refreshes `.freshness.json` so staleness checks can compare the
|
|
315
|
-
// sidecar's source commit against HEAD until the next regeneration.
|
|
316
|
-
if ((toolName === 'Write' || toolName === 'Edit') && isContextIndexFile(contextDir, filePath)) {
|
|
317
|
-
stampContextFreshness(contextDir, currentHeadCommit(), now());
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const tokens = resolveTokenEstimate(toolName, payload.tool_input, payload.tool_response);
|
|
321
|
-
const ts = now().toISOString();
|
|
322
|
-
const type = mapToolType(toolName);
|
|
323
|
-
const action = toolName === 'Write' ? 'create' : toolName === 'Edit' ? 'edit' : undefined;
|
|
324
|
-
|
|
325
|
-
const event: Record<string, unknown> = { ts, session, type };
|
|
326
|
-
if (filePath) event.file = filePath;
|
|
327
|
-
if (summary) event.summary = summary;
|
|
328
|
-
if (tokens !== undefined) event.tokens = tokens;
|
|
329
|
-
if (action) event.action = action;
|
|
330
|
-
|
|
331
|
-
// Best-effort identity fields — never block logging.
|
|
332
|
-
if (typeof payload.session_id === 'string' && payload.session_id) {
|
|
333
|
-
event.sessionId = payload.session_id;
|
|
334
|
-
}
|
|
335
|
-
if (typeof sessionMeta.agent === 'string' && sessionMeta.agent) event.agent = sessionMeta.agent;
|
|
336
|
-
if (typeof sessionMeta.model === 'string' && sessionMeta.model) event.model = sessionMeta.model;
|
|
337
|
-
|
|
338
|
-
try {
|
|
339
|
-
appendFileSync(join(contextDir, 'token-ledger.jsonl'), `${JSON.stringify(event)}\n`);
|
|
340
|
-
} catch {
|
|
341
|
-
return null;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
return event;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// ─── Context freshness sidecar (task 0711 R4) ───────────────────────────
|
|
348
|
-
|
|
349
|
-
export interface ContextFreshness {
|
|
350
|
-
schema_version: number;
|
|
351
|
-
source_commit: string;
|
|
352
|
-
generated_at: string;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
export const CONTEXT_FRESHNESS_SCHEMA_VERSION = 1;
|
|
356
|
-
|
|
357
|
-
/** True when `filePath` is a regenerated context index inside `contextDir` (not the sidecars). */
|
|
358
|
-
export function isContextIndexFile(contextDir: string, filePath: string): boolean {
|
|
359
|
-
const resolvedDir = resolve(contextDir);
|
|
360
|
-
const resolvedFile = resolve(filePath);
|
|
361
|
-
if (!resolvedFile.startsWith(resolvedDir + sep)) return false;
|
|
362
|
-
const name = resolvedFile.slice(resolvedDir.length + 1);
|
|
363
|
-
return name.endsWith('.md');
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/** Current HEAD, best-effort — null when git is unavailable or the dir is not a work tree. */
|
|
367
|
-
export function currentHeadCommit(): string | null {
|
|
368
|
-
try {
|
|
369
|
-
const out = execSync('git rev-parse HEAD', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
370
|
-
const commit = out.trim();
|
|
371
|
-
return commit.length > 0 ? commit : null;
|
|
372
|
-
} catch {
|
|
373
|
-
return null;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Write/refresh `.spur/context/.freshness.json`. Best-effort: any I/O failure
|
|
379
|
-
* leaves the previous sidecar (or none) in place — the hook contract is
|
|
380
|
-
* fail-open, never blocking.
|
|
381
|
-
*/
|
|
382
|
-
export function stampContextFreshness(contextDir: string, sourceCommit: string | null, at: Date): void {
|
|
383
|
-
if (!sourceCommit) return;
|
|
384
|
-
const body: ContextFreshness = {
|
|
385
|
-
schema_version: CONTEXT_FRESHNESS_SCHEMA_VERSION,
|
|
386
|
-
source_commit: sourceCommit,
|
|
387
|
-
generated_at: at.toISOString(),
|
|
388
|
-
};
|
|
389
|
-
try {
|
|
390
|
-
writeFileSync(join(contextDir, '.freshness.json'), JSON.stringify(body));
|
|
391
|
-
} catch {
|
|
392
|
-
/* fail-open */
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
export interface ContextFreshnessCheck {
|
|
397
|
-
stale: boolean;
|
|
398
|
-
reason?: string;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Compare a freshness sidecar against the current HEAD (task 0711 R4). A
|
|
403
|
-
* missing/unparsable sidecar is stale (`never stamped`); a commit mismatch is
|
|
404
|
-
* stale until the producer regenerates the index.
|
|
405
|
-
*/
|
|
406
|
-
export function checkContextFreshness(raw: string | null, headCommit: string | null): ContextFreshnessCheck {
|
|
407
|
-
if (raw === null) return { stale: true, reason: 'never stamped' };
|
|
408
|
-
let body: ContextFreshness;
|
|
409
|
-
try {
|
|
410
|
-
body = JSON.parse(raw) as ContextFreshness;
|
|
411
|
-
} catch {
|
|
412
|
-
return { stale: true, reason: 'malformed sidecar' };
|
|
413
|
-
}
|
|
414
|
-
if (body.schema_version !== CONTEXT_FRESHNESS_SCHEMA_VERSION) {
|
|
415
|
-
return { stale: true, reason: `schema_version ${String(body.schema_version)}` };
|
|
416
|
-
}
|
|
417
|
-
if (typeof body.source_commit !== 'string' || body.source_commit === '') {
|
|
418
|
-
return { stale: true, reason: 'missing source_commit' };
|
|
419
|
-
}
|
|
420
|
-
if (headCommit !== null && body.source_commit !== headCommit) {
|
|
421
|
-
return { stale: true, reason: 'source commit changed since generation' };
|
|
422
|
-
}
|
|
423
|
-
return { stale: false };
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/** Read the freshness sidecar, or null when absent/unreadable. */
|
|
427
|
-
export function readContextFreshness(contextDir: string): string | null {
|
|
428
|
-
try {
|
|
429
|
-
return readFileSync(join(contextDir, '.freshness.json'), 'utf-8');
|
|
430
|
-
} catch {
|
|
431
|
-
return null;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Entrypoint — thin wrapper; logic lives in {@link recordToolUseEvent} for unit coverage.
|
|
436
16
|
if (import.meta.main) {
|
|
437
17
|
void (async () => {
|
|
438
18
|
const dir = join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context');
|
|
@@ -1,222 +1,14 @@
|
|
|
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 `token-ledger.jsonl`.
|
|
8
|
-
* Task 0246: best-effort agent/model hints on session file + event. Task 1159: the pointer records
|
|
9
|
-
* the host-native {@link SessionOwner} that created it, so a nested child can reuse the ledger for
|
|
10
|
-
* tool recording without being able to tear it down.
|
|
11
|
-
*
|
|
12
|
-
* **Fail-open contract:** every error path exits 0 with no output.
|
|
3
|
+
* context-session-start — installed SessionStart entry for indexed-context.
|
|
13
4
|
*
|
|
14
|
-
*
|
|
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.
|
|
15
8
|
*/
|
|
16
9
|
|
|
17
|
-
import {
|
|
18
|
-
import { randomUUID } from 'node:crypto';
|
|
19
|
-
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
-
import { join } from 'node:path';
|
|
21
|
-
import { getEnvVar, getEnvVars } from '../lib/env';
|
|
22
|
-
import { resolveAgentHint, resolveModelHint } from './agent-hint';
|
|
23
|
-
import { claudeHookOwnerFromPayloadText, type SessionOwner } from './context-owner';
|
|
24
|
-
import { checkContextFreshness } from './context-post-tool';
|
|
25
|
-
|
|
26
|
-
export { resolveAgentHint, resolveModelHint } from './agent-hint';
|
|
27
|
-
export type { SessionOwner } from './context-owner';
|
|
28
|
-
// Owner identity + Claude payload derivation live in ./context-owner (pure, unit-testable);
|
|
29
|
-
// re-exported here because this module stays the hook surface consumers reach for.
|
|
30
|
-
export { claudeHookOwnerFromPayloadText, claudeSessionOwner } from './context-owner';
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Ancestor-run marker exported by `@gobing-ai/ts-ai-runner` ≥ 0.4.15 (`AGENT_RUN_ID_ENV`).
|
|
34
|
-
*
|
|
35
|
-
* `AiRunner` sets this in the agent subprocess environment whenever the caller supplies an
|
|
36
|
-
* `AgentRunCorrelation`, which Spur's pipeline always does
|
|
37
|
-
* (`packages/app/src/workflow/actions/agent-run.ts:148-152`) as does `spur agent run`
|
|
38
|
-
* (`packages/app/src/services/agent-service.ts:661`). execa's `extendEnv` default propagates it
|
|
39
|
-
* transitively, so every descendant of an agent run — including the hook subprocesses the host
|
|
40
|
-
* fires inside it — inherits the same value. Its mere presence answers "am I nested?" exactly.
|
|
41
|
-
*
|
|
42
|
-
* Deliberately a string literal rather than an import: this hook is self-contained by design
|
|
43
|
-
* (tasks 0232/0246) and runs both as a standalone script and from Superskill's bundled runner, so
|
|
44
|
-
* it must not depend on the workspace's module graph. The name is a published contract on the
|
|
45
|
-
* ts-ai-runner side, so a literal here is a stable coupling, not a guess.
|
|
46
|
-
*/
|
|
47
|
-
export const AGENT_RUN_ID_ENV = 'SPUR_RUN_ID';
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Fallback idle window for hosts and code paths that do **not** propagate a run correlation
|
|
51
|
-
* (task 0398 R3).
|
|
52
|
-
*
|
|
53
|
-
* Only consulted when {@link AGENT_RUN_ID_ENV} is absent. With ts-ai-runner ≥ 0.4.15 the
|
|
54
|
-
* agent-run path is detected exactly, so this no longer covers the case that motivated it — it
|
|
55
|
-
* remains as a backstop for nested `SessionStart` fires that arrive outside a correlated run
|
|
56
|
-
* (a host that spawns its own helper processes, or an `agent.run` invoked without a correlation).
|
|
57
|
-
*
|
|
58
|
-
* ponytail: still a wall-clock heuristic on that residual path — two genuinely distinct
|
|
59
|
-
* uncorrelated sessions started inside the window merge into one ledger session. Kept because
|
|
60
|
-
* deleting it would regress every non-correlated nesting path back to the original bug, and the
|
|
61
|
-
* set of hosts is not enumerable from here. Delete it once every nesting path is known to carry a
|
|
62
|
-
* correlation.
|
|
63
|
-
*/
|
|
64
|
-
export const SESSION_REUSE_IDLE_MS = 4 * 60 * 60 * 1000;
|
|
65
|
-
|
|
66
|
-
interface SessionFileBody {
|
|
67
|
-
session?: unknown;
|
|
68
|
-
started?: unknown;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Return the id of the session already in flight, or null when a new one should be minted.
|
|
73
|
-
*
|
|
74
|
-
* **Why this exists (0398 R3).** `SessionStart` fires in every nested `agent.run` subprocess, not
|
|
75
|
-
* once per host session. Each firing used to mint a fresh `session-<date>-<HHMM>` id (now
|
|
76
|
-
* seconds-plus-random, 1166), append a
|
|
77
|
-
* `session_start` row, and overwrite the `.session.json` pointer that `context-post-tool` reads —
|
|
78
|
-
* so a single pipeline run registered as dozens of sessions and orphaned the parent's event
|
|
79
|
-
* stream. The H6 corpus shows the signature: 332 `session_start` against 157 `session_end` over
|
|
80
|
-
* 18 days, 298 distinct ids, 39 starts in a 2-day window that held a handful of real sessions.
|
|
81
|
-
*
|
|
82
|
-
* **Two signals, in precedence order.** (1) {@link AGENT_RUN_ID_ENV} present → definitively a
|
|
83
|
-
* descendant of an agent run: reuse the recorded session with no time bound, because a pipeline step
|
|
84
|
-
* legitimately runs for the full `implementTimeoutMs` (45 min) and longer batches for hours, so any
|
|
85
|
-
* wall-clock window would eventually split a run that is still in flight. (2) No marker → fall back
|
|
86
|
-
* to the {@link SESSION_REUSE_IDLE_MS} window on the `started` stamp, for hosts that nest without
|
|
87
|
-
* propagating a correlation. A crashed session leaves a stale `.session.json`: on (2) the idle window
|
|
88
|
-
* retires it, on (1) a stale file is only reachable from inside a live agent run, where reusing it is
|
|
89
|
-
* the correct answer anyway.
|
|
90
|
-
*/
|
|
91
|
-
export function resolveActiveSession(dir: string, now: Date, env: NodeJS.ProcessEnv = getEnvVars()): string | null {
|
|
92
|
-
let body: SessionFileBody;
|
|
93
|
-
try {
|
|
94
|
-
// No in-flight session, unreadable file or corrupt pointer → mint a new one.
|
|
95
|
-
body = JSON.parse(readFileSync(join(dir, '.session.json'), 'utf-8')) as SessionFileBody;
|
|
96
|
-
} catch {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (typeof body.session !== 'string' || body.session.length === 0) return null;
|
|
101
|
-
|
|
102
|
-
// Signal 1 — exact ancestry. No `started` parse, no time bound.
|
|
103
|
-
const runId = env[AGENT_RUN_ID_ENV];
|
|
104
|
-
if (typeof runId === 'string' && runId.trim().length > 0) return body.session;
|
|
105
|
-
|
|
106
|
-
// Signal 2 — residual heuristic for uncorrelated nesting.
|
|
107
|
-
if (typeof body.started !== 'string') return null;
|
|
108
|
-
|
|
109
|
-
const started = Date.parse(body.started);
|
|
110
|
-
if (Number.isNaN(started)) return null;
|
|
111
|
-
|
|
112
|
-
const idleMs = now.getTime() - started;
|
|
113
|
-
if (idleMs < 0 || idleMs > SESSION_REUSE_IDLE_MS) return null; // stale or clock-skewed → new
|
|
114
|
-
|
|
115
|
-
return body.session;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Core session-start path (testable). Returns session id on success, null on I/O failure.
|
|
120
|
-
*
|
|
121
|
-
* Idempotent per in-flight session (0398 R3): when `resolveActiveSession` finds one, this returns
|
|
122
|
-
* that id and writes nothing — no ledger row, no pointer rewrite.
|
|
123
|
-
*
|
|
124
|
-
* Ownership (task 1159): a **new** pointer records a valid `owner` only; a reused start never
|
|
125
|
-
* rewrites the pointer, so a nested child cannot take ownership from the parent that opened it,
|
|
126
|
-
* and only the recorded owner's shutdown can finalize the ledger.
|
|
127
|
-
*/
|
|
128
|
-
export function recordSessionStart(
|
|
129
|
-
dir: string,
|
|
130
|
-
env: NodeJS.ProcessEnv = getEnvVars(),
|
|
131
|
-
now: () => Date = () => new Date(),
|
|
132
|
-
agentFallback?: string,
|
|
133
|
-
owner?: SessionOwner,
|
|
134
|
-
): string | null {
|
|
135
|
-
try {
|
|
136
|
-
mkdirSync(dir, { recursive: true });
|
|
137
|
-
} catch {
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const at = now();
|
|
142
|
-
|
|
143
|
-
const active = resolveActiveSession(dir, at, env);
|
|
144
|
-
if (active !== null) return active;
|
|
145
|
-
const pad = (n: number) => String(n).padStart(2, '0');
|
|
146
|
-
// Uniqueness suffix (1166): minute-granular ids collided when a session started in the same
|
|
147
|
-
// minute an earlier one ended, and the frozen end algorithm then suppressed the new session's
|
|
148
|
-
// `session_end` row as a duplicate. Seconds + a random suffix make collisions practically
|
|
149
|
-
// impossible; the id stays an opaque ledger key everywhere else.
|
|
150
|
-
const sessionId = `session-${at.toISOString().slice(0, 10)}-${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}-${randomUUID().slice(0, 8)}`;
|
|
151
|
-
const ts = at.toISOString();
|
|
152
|
-
const agent = resolveAgentHint(env, agentFallback);
|
|
153
|
-
const model = resolveModelHint(env);
|
|
154
|
-
|
|
155
|
-
const sessionBody: Record<string, unknown> = {
|
|
156
|
-
session: sessionId,
|
|
157
|
-
started: ts,
|
|
158
|
-
reads: 0,
|
|
159
|
-
writes: 0,
|
|
160
|
-
tokens: 0,
|
|
161
|
-
};
|
|
162
|
-
if (agent) sessionBody.agent = agent;
|
|
163
|
-
if (model) sessionBody.model = model;
|
|
164
|
-
const ownerId = typeof owner?.sessionId === 'string' ? owner.sessionId.trim() : '';
|
|
165
|
-
const host = owner?.host;
|
|
166
|
-
if (ownerId !== '' && (host === 'claude' || host === 'pi')) sessionBody.owner = { host, sessionId: ownerId };
|
|
167
|
-
|
|
168
|
-
const sessionFile = join(dir, '.session.json');
|
|
169
|
-
try {
|
|
170
|
-
writeFileSync(sessionFile, JSON.stringify(sessionBody));
|
|
171
|
-
} catch {
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const startEvent: Record<string, unknown> = { ts, session: sessionId, type: 'session_start' };
|
|
176
|
-
if (agent) startEvent.agent = agent;
|
|
177
|
-
if (model) startEvent.model = model;
|
|
178
|
-
|
|
179
|
-
// Freshness check (task 0711 R4): report whether the context indexes were
|
|
180
|
-
// regenerated at the current HEAD. Best-effort, fail-open — git failures
|
|
181
|
-
// yield head=null, which marks stale only via the sidecar's own defects.
|
|
182
|
-
let headCommit: string | null = null;
|
|
183
|
-
try {
|
|
184
|
-
const out = execSync('git rev-parse HEAD', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
185
|
-
const trimmed = out.trim();
|
|
186
|
-
headCommit = trimmed.length > 0 ? trimmed : null;
|
|
187
|
-
} catch {
|
|
188
|
-
headCommit = null;
|
|
189
|
-
}
|
|
190
|
-
let freshnessRaw: string | null = null;
|
|
191
|
-
try {
|
|
192
|
-
freshnessRaw = readFileSync(join(dir, '.freshness.json'), 'utf-8');
|
|
193
|
-
} catch {
|
|
194
|
-
freshnessRaw = null;
|
|
195
|
-
}
|
|
196
|
-
startEvent.contextFreshness = checkContextFreshness(freshnessRaw, headCommit);
|
|
197
|
-
|
|
198
|
-
const ledgerPath = join(dir, 'token-ledger.jsonl');
|
|
199
|
-
try {
|
|
200
|
-
appendFileSync(ledgerPath, `${JSON.stringify(startEvent)}\n`);
|
|
201
|
-
} catch {
|
|
202
|
-
return null;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return sessionId;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Open (or reuse) the session for a raw Claude stdin payload (task 1159, testable half).
|
|
210
|
-
*
|
|
211
|
-
* Malformed/absent text still opens the session ownerless (legacy policy); the dir resolution lives
|
|
212
|
-
* here rather than in the entrypoint so the untestable subprocess body stays a few lines.
|
|
213
|
-
*/
|
|
214
|
-
export function recordSessionStartFromPayloadText(text: string, env: NodeJS.ProcessEnv = getEnvVars()): string | null {
|
|
215
|
-
const dir = join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context');
|
|
216
|
-
return recordSessionStart(dir, env, undefined, undefined, claudeHookOwnerFromPayloadText(text, env) ?? undefined);
|
|
217
|
-
}
|
|
10
|
+
import { recordSessionStartFromPayloadText } from '../lib/context-session-start';
|
|
218
11
|
|
|
219
|
-
// Entrypoint — thin stdin adapter; logic lives in the exported helpers above (task 1159 coverage).
|
|
220
12
|
if (import.meta.main) {
|
|
221
13
|
try {
|
|
222
14
|
recordSessionStartFromPayloadText(await Bun.stdin.text());
|