@gobing-ai/spur 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/decisions/maker-liveness.yaml +39 -0
  3. package/config/plugin-scripts.json +0 -4
  4. package/config/rules/README.md +1 -0
  5. package/config/rules/docs/sourced-api-claims.yaml +56 -12
  6. package/config/rules/strict/runtime-boundaries.yaml +7 -0
  7. package/config/script-placement-baseline.json +1 -19
  8. package/config/workflows/feature-lifecycle.yaml +46 -1
  9. package/config/workflows/feature-verification.yaml +11 -0
  10. package/package.json +9 -9
  11. package/plugins/sp/README.md +22 -16
  12. package/plugins/sp/agents/super-reviewer.md +43 -0
  13. package/plugins/sp/bunfig.toml +8 -5
  14. package/plugins/sp/hooks/careful-guard.ts +1 -1
  15. package/plugins/sp/hooks/context-post-tool.ts +7 -427
  16. package/plugins/sp/hooks/context-session-start.ts +5 -213
  17. package/plugins/sp/hooks/context-session-stop.ts +5 -191
  18. package/plugins/sp/hooks/pi/guard-extension.ts +6 -5
  19. package/plugins/sp/hooks/task-write-guard.ts +1 -1
  20. package/plugins/sp/{hooks → lib}/agent-hint.ts +1 -1
  21. package/plugins/sp/lib/context-post-tool.ts +432 -0
  22. package/plugins/sp/lib/context-session-start.ts +214 -0
  23. package/plugins/sp/lib/context-session-stop.ts +196 -0
  24. package/plugins/sp/lib/dogfood-pipeline.ts +265 -0
  25. package/plugins/sp/lib/dogfood-report.ts +278 -0
  26. package/plugins/sp/lib/idea-handoff.generated.mjs +3 -3
  27. package/plugins/sp/lib/inline-run-app.ts +40 -0
  28. package/plugins/sp/lib/inline-run.generated.d.mts +3 -3
  29. package/plugins/sp/lib/inline-run.generated.mjs +1347 -90
  30. package/plugins/sp/lib/plan-projection.generated.d.mts +150 -0
  31. package/plugins/sp/lib/plan-projection.generated.mjs +591 -1
  32. package/plugins/sp/lib/quality-gate.generated.mjs +38 -25
  33. package/plugins/sp/lib/residual-scan.generated.d.mts +7 -1
  34. package/plugins/sp/lib/residual-scan.generated.mjs +71 -6
  35. package/plugins/sp/lib/run-summary-core.ts +34 -370
  36. package/plugins/sp/lib/transcript.ts +37 -228
  37. package/plugins/sp/plugin.json +1 -1
  38. package/plugins/sp/scripts/batch-plan.mjs +586 -0
  39. package/plugins/sp/scripts/batch-preflight.mjs +23 -1
  40. package/plugins/sp/scripts/batch-preflight.ts +35 -295
  41. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +108 -87
  42. package/plugins/sp/scripts/daily-summary/daily-summary.ts +111 -104
  43. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -1
  44. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +7 -265
  45. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +3 -0
  46. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +6 -272
  47. package/plugins/sp/scripts/feature-verification-steps.mjs +7 -123
  48. package/plugins/sp/scripts/feature-verification-steps.ts +29 -181
  49. package/plugins/sp/scripts/inline-run-dispatch.mjs +27 -26
  50. package/plugins/sp/scripts/inline-run-dispatch.ts +1 -21
  51. package/plugins/sp/scripts/inline-run-setup.mjs +149 -122
  52. package/plugins/sp/scripts/inline-run-setup.ts +1 -21
  53. package/plugins/sp/scripts/quality-gate.mjs +14 -2
  54. package/plugins/sp/scripts/residual-scan.mjs +73 -6
  55. package/plugins/sp/scripts/residual-scan.ts +8 -5
  56. package/plugins/sp/scripts/run-summary.mjs +151 -97
  57. package/plugins/sp/scripts/run-summary.ts +7 -5
  58. package/plugins/sp/scripts/session-timeline.mjs +46 -19
  59. package/plugins/sp/scripts/task-diffstat.mjs +11 -1
  60. package/plugins/sp/scripts/task-diffstat.ts +8 -39
  61. package/plugins/sp/skills/code-implementation/SKILL.md +7 -1
  62. package/plugins/sp/skills/code-verification/references/secu-review.md +2 -1
  63. package/plugins/sp/skills/code-verification/references/verdict-schema.md +4 -0
  64. package/plugins/sp/skills/dogfood-testing/SKILL.md +2 -2
  65. package/plugins/sp/skills/next-feature/SKILL.md +1 -1
  66. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
  67. package/plugins/sp/skills/source-driven-development/SKILL.md +7 -4
  68. package/plugins/sp/skills/spur-cli/references/builder.md +3 -2
  69. package/plugins/sp/skills/spur-cli/references/decision.md +32 -7
  70. package/plugins/sp/skills/spur-cli/references/features.md +46 -5
  71. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +1 -1
  72. package/plugins/sp/skills/spur-dev/references/execution-batch.md +13 -3
  73. package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +22 -0
  74. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +14 -0
  75. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +29 -12
  76. package/spur.js +80681 -79296
  77. package/web/_astro/{BoardApp.B3GQZvj8.js → BoardApp.5IIl2O-z.js} +1 -1
  78. package/web/_astro/{BoardApp.BT7L2kSb.js → BoardApp.Drnx-KeM.js} +150 -150
  79. package/web/_astro/{TaskDetail.Cxov1_N_.js → TaskDetail.Ci4LNn9S.js} +1 -1
  80. package/web/_astro/{arc.vaT7tPOB.js → arc.j2EVkYVd.js} +1 -1
  81. package/web/_astro/{architectureDiagram-3BPJPVTR.DRMmTjkN.js → architectureDiagram-3BPJPVTR.DZWSFqEG.js} +1 -1
  82. package/web/_astro/{blockDiagram-GPEHLZMM.cla0LO_q.js → blockDiagram-GPEHLZMM.DE6GpXCN.js} +1 -1
  83. package/web/_astro/{c4Diagram-AAUBKEIU.D4YAIBKn.js → c4Diagram-AAUBKEIU.DoVR1G6B.js} +1 -1
  84. package/web/_astro/channel.DmdDxGsq.js +1 -0
  85. package/web/_astro/{chunk-2J33WTMH.BrSJbMRZ.js → chunk-2J33WTMH.Do6-Gbyf.js} +1 -1
  86. package/web/_astro/{chunk-4BX2VUAB.CuLMgKF5.js → chunk-4BX2VUAB.BYV1HGHx.js} +1 -1
  87. package/web/_astro/{chunk-55IACEB6.zkvgu54F.js → chunk-55IACEB6.CvI1zkrc.js} +1 -1
  88. package/web/_astro/{chunk-727SXJPM.DxWkACh8.js → chunk-727SXJPM.DNGSCRlX.js} +1 -1
  89. package/web/_astro/{chunk-AQP2D5EJ.42XrmIlH.js → chunk-AQP2D5EJ.DXhhgT5z.js} +1 -1
  90. package/web/_astro/{chunk-FMBD7UC4.Bao2wMK9.js → chunk-FMBD7UC4.dzkRKP2u.js} +1 -1
  91. package/web/_astro/{chunk-ND2GUHAM.BhydojD1.js → chunk-ND2GUHAM.DujGLeEf.js} +1 -1
  92. package/web/_astro/{chunk-QZHKN3VN.CmZ82g-H.js → chunk-QZHKN3VN.BWtIT3ZU.js} +1 -1
  93. package/web/_astro/{classDiagram-4FO5ZUOK.Blr83Gt_.js → classDiagram-4FO5ZUOK.DyApg0Nn.js} +1 -1
  94. package/web/_astro/{classDiagram-v2-Q7XG4LA2.Blr83Gt_.js → classDiagram-v2-Q7XG4LA2.DyApg0Nn.js} +1 -1
  95. package/web/_astro/{cose-bilkent-S5V4N54A.qmfv0SPn.js → cose-bilkent-S5V4N54A.DJQB-u_l.js} +1 -1
  96. package/web/_astro/{cynefin-OW5HDTMX.BtELhXPR.js → cynefin-OW5HDTMX.bi3D_siV.js} +1 -1
  97. package/web/_astro/{dagre-BM42HDAG.AWmaAvXo.js → dagre-BM42HDAG.C-f7TrRg.js} +1 -1
  98. package/web/_astro/{diagram-2AECGRRQ.DdH34KDi.js → diagram-2AECGRRQ.DrPfjHQB.js} +1 -1
  99. package/web/_astro/{diagram-5GNKFQAL.BmNPix1k.js → diagram-5GNKFQAL.C9S4WUHO.js} +1 -1
  100. package/web/_astro/{diagram-KO2AKTUF.BNQd_Ppo.js → diagram-KO2AKTUF.Dzzye8QH.js} +1 -1
  101. package/web/_astro/{diagram-LMA3HP47.BAPgDi0e.js → diagram-LMA3HP47.DX-LRiof.js} +1 -1
  102. package/web/_astro/{diagram-OG6HWLK6.DZ4zyLdC.js → diagram-OG6HWLK6.Z-kAKwIz.js} +1 -1
  103. package/web/_astro/{erDiagram-TEJ5UH35.Q1TySMmw.js → erDiagram-TEJ5UH35.D6dyClSv.js} +1 -1
  104. package/web/_astro/{flowDiagram-I6XJVG4X.BXhFBicG.js → flowDiagram-I6XJVG4X.Cx3q2cxD.js} +1 -1
  105. package/web/_astro/{ganttDiagram-6RSMTGT7.vHSgcuo2.js → ganttDiagram-6RSMTGT7.DU3gH6CT.js} +1 -1
  106. package/web/_astro/{gitGraphDiagram-PVQCEYII.Bi1f73C7.js → gitGraphDiagram-PVQCEYII.ISmCnJ87.js} +1 -1
  107. package/web/_astro/index.Do2lLdgo.css +1 -0
  108. package/web/_astro/{infoDiagram-5YYISTIA.DPvXZEWm.js → infoDiagram-5YYISTIA.DR6_iNOK.js} +1 -1
  109. package/web/_astro/{ishikawaDiagram-YF4QCWOH.Cz829fsV.js → ishikawaDiagram-YF4QCWOH.D7wY-ZGA.js} +1 -1
  110. package/web/_astro/{journeyDiagram-JHISSGLW.enRnB9Me.js → journeyDiagram-JHISSGLW.DkP9-WK8.js} +1 -1
  111. package/web/_astro/{kanban-definition-UN3LZRKU.DnTNk6d2.js → kanban-definition-UN3LZRKU.wtgY98M2.js} +1 -1
  112. package/web/_astro/{linear.BzGnBwNL.js → linear.CI6djNi7.js} +1 -1
  113. package/web/_astro/{mermaid.core.CSbTpIVz.js → mermaid.core.CuQPxS8b.js} +4 -4
  114. package/web/_astro/{mindmap-definition-RKZ34NQL.DGJDy8zT.js → mindmap-definition-RKZ34NQL.5HNIEG7R.js} +1 -1
  115. package/web/_astro/{pieDiagram-4H26LBE5.HSPdi5_8.js → pieDiagram-4H26LBE5.CchhDFjE.js} +1 -1
  116. package/web/_astro/{quadrantDiagram-W4KKPZXB.6Gv8mz2Y.js → quadrantDiagram-W4KKPZXB.PYXXJPSJ.js} +1 -1
  117. package/web/_astro/{requirementDiagram-4Y6WPE33.CnBCtFwa.js → requirementDiagram-4Y6WPE33.cUQuNAKU.js} +1 -1
  118. package/web/_astro/{sankeyDiagram-5OEKKPKP.5M8VWAu8.js → sankeyDiagram-5OEKKPKP.BO7MnRIA.js} +1 -1
  119. package/web/_astro/{sequenceDiagram-3UESZ5HK.DN41XN-U.js → sequenceDiagram-3UESZ5HK.D9bElyD6.js} +1 -1
  120. package/web/_astro/{stateDiagram-AJRCARHV.ErOO3W8j.js → stateDiagram-AJRCARHV.DhGdRz7I.js} +1 -1
  121. package/web/_astro/{stateDiagram-v2-BHNVJYJU.B_ZHICM-.js → stateDiagram-v2-BHNVJYJU.5GvLeAoL.js} +1 -1
  122. package/web/_astro/{timeline-definition-PNZ67QCA.CWdosr07.js → timeline-definition-PNZ67QCA.LtAT9haM.js} +1 -1
  123. package/web/_astro/{vennDiagram-CIIHVFJN.BHzYjNV5.js → vennDiagram-CIIHVFJN.DRth1yLA.js} +1 -1
  124. package/web/_astro/{wardleyDiagram-YWT4CUSO.h8x4IwPX.js → wardleyDiagram-YWT4CUSO.h9MlZfcr.js} +1 -1
  125. package/web/_astro/{xychartDiagram-2RQKCTM6.RTapW2X8.js → xychartDiagram-2RQKCTM6.DHqy9DYA.js} +1 -1
  126. package/web/index.html +2 -2
  127. package/config/proportional-route-table.ts +0 -155
  128. package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
  129. package/web/_astro/channel.D4nG9YLf.js +0 -1
  130. package/web/_astro/index.BhSwN8yY.css +0 -1
  131. /package/plugins/sp/{hooks → lib}/context-owner.ts +0 -0
  132. /package/plugins/sp/{hooks → lib}/destructive-policy.ts +0 -0
  133. /package/plugins/sp/{hooks → lib}/task-file-policy.ts +0 -0
@@ -1,438 +1,18 @@
1
1
  #!/usr/bin/env bun
2
-
3
2
  /**
4
- * context-post-tool — PostToolUse hook for indexed-context
3
+ * context-post-tool — installed PostToolUse entry for indexed-context
5
4
  * (matcher: Bash|Grep|Glob|Read|Write|Edit — task 0248).
6
5
  *
7
- * Appends one event line to `token-ledger.jsonl` per tool call: path and/or short
8
- * summary, action, and token estimate. Reads session ID (+ optional agent/model)
9
- * from `.spur/context/.session.json`.
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 { execSync } from 'node:child_process';
24
- import { appendFileSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
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 hook for indexed-context.
5
- *
6
- * Generates a session ID, writes `.spur/context/.session.json` (tracking the current session
7
- * for PostToolUse and Stop hooks), and appends a `session_start` event to `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
- * Self-contained by design (task 0232/0246).
5
+ * Task 1174: the session open + ownership logic lives in `plugins/sp/lib/context-session-start.ts`;
6
+ * this file is the argv/stdin adapter. Malformed/absent stdin still opens the session ownerless
7
+ * (legacy policy) and every error path exits 0 with no output.
15
8
  */
16
9
 
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 { 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());