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