@gobing-ai/spur 0.3.41 → 0.3.43

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 (185) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/README.md +15 -1
  3. package/config/templates/AGENTS.md +5 -3
  4. package/config/workflows/task-pipeline.yaml +113 -12
  5. package/package.json +15 -12
  6. package/plugins/README.md +655 -0
  7. package/plugins/sp/README.md +621 -0
  8. package/plugins/sp/agents/expert-spur.md +109 -0
  9. package/plugins/sp/agents/super-coder.md +126 -0
  10. package/plugins/sp/agents/super-planner.md +283 -0
  11. package/plugins/sp/agents/super-reviewer.md +154 -0
  12. package/plugins/sp/commands/dev-arch.md +30 -0
  13. package/plugins/sp/commands/dev-brainstorm.md +40 -0
  14. package/plugins/sp/commands/dev-changelog.md +28 -0
  15. package/plugins/sp/commands/dev-daily.md +29 -0
  16. package/plugins/sp/commands/dev-debug.md +29 -0
  17. package/plugins/sp/commands/dev-dogfood.md +33 -0
  18. package/plugins/sp/commands/dev-featurechange.md +130 -0
  19. package/plugins/sp/commands/dev-find-conflict.md +51 -0
  20. package/plugins/sp/commands/dev-find-issue.md +76 -0
  21. package/plugins/sp/commands/dev-find-next.md +60 -0
  22. package/plugins/sp/commands/dev-fixall.md +33 -0
  23. package/plugins/sp/commands/dev-gitmsg.md +28 -0
  24. package/plugins/sp/commands/dev-gtd.md +97 -0
  25. package/plugins/sp/commands/dev-handover.md +26 -0
  26. package/plugins/sp/commands/dev-idea.md +50 -0
  27. package/plugins/sp/commands/dev-next.md +31 -0
  28. package/plugins/sp/commands/dev-parallel.md +30 -0
  29. package/plugins/sp/commands/dev-plan.md +46 -0
  30. package/plugins/sp/commands/dev-refine.md +48 -0
  31. package/plugins/sp/commands/dev-refineall.md +65 -0
  32. package/plugins/sp/commands/dev-refresh.md +32 -0
  33. package/plugins/sp/commands/dev-reverse.md +32 -0
  34. package/plugins/sp/commands/dev-review.md +31 -0
  35. package/plugins/sp/commands/dev-run.md +63 -0
  36. package/plugins/sp/commands/dev-runall.md +83 -0
  37. package/plugins/sp/commands/dev-simplify.md +39 -0
  38. package/plugins/sp/commands/dev-unit.md +29 -0
  39. package/plugins/sp/commands/dev-verify.md +48 -0
  40. package/plugins/sp/commands/dev-verifyall.md +72 -0
  41. package/plugins/sp/commands/dev-wrap.md +48 -0
  42. package/plugins/sp/commands/dev-wrapall.md +51 -0
  43. package/plugins/sp/commands/rule-add.md +18 -0
  44. package/plugins/sp/commands/rule-refine.md +18 -0
  45. package/plugins/sp/commands/rule-scan.md +18 -0
  46. package/plugins/sp/commands/spur-init.md +22 -0
  47. package/plugins/sp/commands/workflow-add.md +18 -0
  48. package/plugins/sp/commands/workflow-refine.md +18 -0
  49. package/plugins/sp/hooks/agent-hint.ts +60 -0
  50. package/plugins/sp/hooks/careful-guard.ts +147 -0
  51. package/plugins/sp/hooks/context-post-tool.ts +325 -0
  52. package/plugins/sp/hooks/context-session-start.ts +182 -0
  53. package/plugins/sp/hooks/context-session-stop.ts +95 -0
  54. package/plugins/sp/hooks/hooks.json +51 -0
  55. package/plugins/sp/hooks/pi/guard-extension.ts +341 -0
  56. package/plugins/sp/hooks/task-write-guard.ts +84 -0
  57. package/plugins/sp/plugin.json +8 -0
  58. package/plugins/sp/scripts/batch-preflight.ts +204 -0
  59. package/plugins/sp/scripts/daily-summary/daily-summary.ts +606 -0
  60. package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
  61. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +363 -0
  62. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +168 -0
  63. package/plugins/sp/scripts/feature-sync-bounded.ts +451 -0
  64. package/plugins/sp/scripts/stage-registry-adapter.ts +1341 -0
  65. package/plugins/sp/scripts/task-size-precheck.ts +202 -0
  66. package/plugins/sp/scripts/validate-commands.ts +651 -0
  67. package/plugins/sp/scripts/validate-flag-contracts.ts +875 -0
  68. package/plugins/sp/skills/brainstorm/SKILL.md +361 -0
  69. package/plugins/sp/skills/brainstorm/agents/openai.yaml +14 -0
  70. package/plugins/sp/skills/brainstorm/examples/ideation-example.md +209 -0
  71. package/plugins/sp/skills/brainstorm/metadata.openclaw +7 -0
  72. package/plugins/sp/skills/brainstorm/references/grilling-interview.md +68 -0
  73. package/plugins/sp/skills/brainstorm/references/workflows.md +255 -0
  74. package/plugins/sp/skills/branch-workflow/SKILL.md +109 -0
  75. package/plugins/sp/skills/branch-workflow/references/branch-lifecycle.md +69 -0
  76. package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +100 -0
  77. package/plugins/sp/skills/code-implementation/SKILL.md +203 -0
  78. package/plugins/sp/skills/code-implementation/references/debugging.md +64 -0
  79. package/plugins/sp/skills/code-implementation/references/implementation-patterns.md +113 -0
  80. package/plugins/sp/skills/code-improvement/SKILL.md +231 -0
  81. package/plugins/sp/skills/code-improvement/references/deepening-signals.md +205 -0
  82. package/plugins/sp/skills/code-review/SKILL.md +112 -0
  83. package/plugins/sp/skills/code-review/references/receiving-code-review.md +70 -0
  84. package/plugins/sp/skills/code-review/references/review-lenses.md +117 -0
  85. package/plugins/sp/skills/code-review/references/self-review-checklist.md +53 -0
  86. package/plugins/sp/skills/code-review/references/verification-before-completion.md +68 -0
  87. package/plugins/sp/skills/code-simplification/SKILL.md +225 -0
  88. package/plugins/sp/skills/code-testing/SKILL.md +108 -0
  89. package/plugins/sp/skills/code-testing/references/stacks/bun-ts.md +165 -0
  90. package/plugins/sp/skills/code-testing/references/stacks/go.md +88 -0
  91. package/plugins/sp/skills/code-testing/references/stacks/python.md +117 -0
  92. package/plugins/sp/skills/code-testing/references/test-loop-breaker.md +37 -0
  93. package/plugins/sp/skills/code-testing/references/test-output-discipline.md +52 -0
  94. package/plugins/sp/skills/code-testing/references/unit-testing.md +212 -0
  95. package/plugins/sp/skills/code-verification/SKILL.md +535 -0
  96. package/plugins/sp/skills/code-verification/references/code-improvement.md +39 -0
  97. package/plugins/sp/skills/code-verification/references/secu-review.md +94 -0
  98. package/plugins/sp/skills/code-verification/references/verdict-schema.md +141 -0
  99. package/plugins/sp/skills/conflict-finding/SKILL.md +254 -0
  100. package/plugins/sp/skills/conflict-finding/references/authority-resolution.md +258 -0
  101. package/plugins/sp/skills/conflict-finding/references/comparison-protocol.md +277 -0
  102. package/plugins/sp/skills/conflict-finding/references/finding-contract.md +346 -0
  103. package/plugins/sp/skills/conflict-finding/references/remediation-routing.md +276 -0
  104. package/plugins/sp/skills/daily-summary/SKILL.md +171 -0
  105. package/plugins/sp/skills/daily-summary/agents/openai.yaml +13 -0
  106. package/plugins/sp/skills/doc-evolve/SKILL.md +175 -0
  107. package/plugins/sp/skills/doc-evolve/references/operations.md +93 -0
  108. package/plugins/sp/skills/dogfood-testing/SKILL.md +608 -0
  109. package/plugins/sp/skills/dogfood-testing/references/monitor-ledger.md +211 -0
  110. package/plugins/sp/skills/dogfood-testing/references/report-template.md +351 -0
  111. package/plugins/sp/skills/doubt-driven-development/SKILL.md +136 -0
  112. package/plugins/sp/skills/functional-review/SKILL.md +364 -0
  113. package/plugins/sp/skills/functional-review/references/verdict-schema.md +133 -0
  114. package/plugins/sp/skills/indexed-context/SKILL.md +164 -0
  115. package/plugins/sp/skills/issue-finding/SKILL.md +442 -0
  116. package/plugins/sp/skills/issue-finding/agents/openai.yaml +15 -0
  117. package/plugins/sp/skills/issue-finding/examples/expected-findings.json +27 -0
  118. package/plugins/sp/skills/issue-finding/examples/session-test-loop.jsonl +23 -0
  119. package/plugins/sp/skills/issue-finding/references/session-formats.md +151 -0
  120. package/plugins/sp/skills/next-feature/SKILL.md +125 -0
  121. package/plugins/sp/skills/next-feature/references/handoff-routing.md +92 -0
  122. package/plugins/sp/skills/next-feature/references/proposal-contract.md +49 -0
  123. package/plugins/sp/skills/next-feature/references/ranking-rubric.md +52 -0
  124. package/plugins/sp/skills/next-feature/references/signal-derivation.md +67 -0
  125. package/plugins/sp/skills/next-router/SKILL.md +181 -0
  126. package/plugins/sp/skills/next-router/references/messages.md +90 -0
  127. package/plugins/sp/skills/next-router/references/routing-table.md +216 -0
  128. package/plugins/sp/skills/parallel-execution/SKILL.md +148 -0
  129. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +109 -0
  130. package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +101 -0
  131. package/plugins/sp/skills/parallel-execution/references/result-synthesis.md +94 -0
  132. package/plugins/sp/skills/reverse-engineering/SKILL.md +375 -0
  133. package/plugins/sp/skills/reverse-engineering/agents/openai.yaml +18 -0
  134. package/plugins/sp/skills/source-driven-development/SKILL.md +124 -0
  135. package/plugins/sp/skills/spec-decomposition/SKILL.md +117 -0
  136. package/plugins/sp/skills/spec-decomposition/references/decomposition.md +539 -0
  137. package/plugins/sp/skills/spur-cli/SKILL.md +133 -0
  138. package/plugins/sp/skills/spur-cli/references/agent.md +179 -0
  139. package/plugins/sp/skills/spur-cli/references/features/acceptance-criteria.md +82 -0
  140. package/plugins/sp/skills/spur-cli/references/features/hierarchy-mece.md +177 -0
  141. package/plugins/sp/skills/spur-cli/references/features/roadmap-priority.md +84 -0
  142. package/plugins/sp/skills/spur-cli/references/features/verbs.md +122 -0
  143. package/plugins/sp/skills/spur-cli/references/features.md +234 -0
  144. package/plugins/sp/skills/spur-cli/references/init.md +119 -0
  145. package/plugins/sp/skills/spur-cli/references/message.md +98 -0
  146. package/plugins/sp/skills/spur-cli/references/rules/authoring-rules.md +212 -0
  147. package/plugins/sp/skills/spur-cli/references/rules/fine-tuning.md +148 -0
  148. package/plugins/sp/skills/spur-cli/references/rules/operations.md +228 -0
  149. package/plugins/sp/skills/spur-cli/references/rules/validation-and-extension.md +129 -0
  150. package/plugins/sp/skills/spur-cli/references/rules.md +209 -0
  151. package/plugins/sp/skills/spur-cli/references/serve.md +58 -0
  152. package/plugins/sp/skills/spur-cli/references/tasks/l3-guard-cheatsheet.md +98 -0
  153. package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +95 -0
  154. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +361 -0
  155. package/plugins/sp/skills/spur-cli/references/tasks.md +280 -0
  156. package/plugins/sp/skills/spur-cli/references/team.md +133 -0
  157. package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +226 -0
  158. package/plugins/sp/skills/spur-cli/references/workflows/operations.md +211 -0
  159. package/plugins/sp/skills/spur-cli/references/workflows/validation-and-extension.md +129 -0
  160. package/plugins/sp/skills/spur-cli/references/workflows.md +355 -0
  161. package/plugins/sp/skills/spur-dev/SKILL.md +224 -0
  162. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +196 -0
  163. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +680 -0
  164. package/plugins/sp/skills/spur-dev/references/decision-brief.md +80 -0
  165. package/plugins/sp/skills/spur-dev/references/dev-operations.md +546 -0
  166. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +164 -0
  167. package/plugins/sp/skills/spur-dev/references/execution-batch.md +790 -0
  168. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +352 -0
  169. package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +191 -0
  170. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +417 -0
  171. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +171 -0
  172. package/plugins/sp/skills/spur-dev/references/glossary.md +95 -0
  173. package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +80 -0
  174. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +120 -0
  175. package/plugins/sp/skills/spur-dev/references/planning-workflow.md +302 -0
  176. package/plugins/sp/skills/spur-dev/references/product-planning.md +206 -0
  177. package/plugins/sp/skills/spur-dev/references/section-batching.md +24 -0
  178. package/plugins/sp/skills/sys-architecture/SKILL.md +155 -0
  179. package/plugins/sp/skills/sys-architecture/references/decision-method.md +137 -0
  180. package/plugins/sp/skills/sys-architecture/references/upkeep-survey.md +75 -0
  181. package/plugins/sp/skills/sys-debugging/SKILL.md +228 -0
  182. package/plugins/sp/skills/sys-debugging/references/debugging-protocol.md +62 -0
  183. package/plugins/sp/skills/test-driven-development/SKILL.md +248 -0
  184. package/plugins/sp/skills/wayfinder/SKILL.md +292 -0
  185. package/spur.js +6035 -4963
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * careful-guard — PreToolUse guard for destructive shell commands (task 0215, R3).
4
+ *
5
+ * Warn (permission `ask`) before a `Bash` tool call runs a destructive command — `rm -rf`
6
+ * (any flag spelling, including the POSIX uppercase `-R`), `DROP TABLE`/`DROP DATABASE`/
7
+ * `TRUNCATE`, `git push --force`/`-f`/`+refspec`, `git reset --hard`, `git checkout .`/
8
+ * `git restore .`, `kubectl delete`, `docker system prune`. The operator can confirm to
9
+ * proceed. Pure pattern-match + decision, no domain logic (mirrors `task-write-guard`).
10
+ *
11
+ * **Safe exceptions:** `rm -rf` of well-known build/dependency caches (`node_modules`, `dist`,
12
+ * `.next`, `coverage`, `build`, `.turbo`, `.cache`) passes without a warning — deleting a rebuildable
13
+ * cache is routine, not dangerous.
14
+ *
15
+ * **Fail-open contract:** every error path — unparseable payload, non-Bash tool, empty command —
16
+ * emits an `allow` decision. A broken guard must never wedge an agent tool call.
17
+ *
18
+ * **Escape hatch:** `SPUR_CAREFUL=off` short-circuits to allow.
19
+ */
20
+
21
+ interface ToolPayload {
22
+ tool_name?: string;
23
+ tool_input?: { command?: string };
24
+ }
25
+
26
+ type Decision = 'allow' | 'ask';
27
+
28
+ function preToolUseDecision(decision: Decision, reason?: string): never {
29
+ const hookSpecificOutput: Record<string, unknown> = {
30
+ hookEventName: 'PreToolUse',
31
+ permissionDecision: decision,
32
+ };
33
+ if (reason !== undefined) hookSpecificOutput.permissionDecisionReason = reason;
34
+ const out: Record<string, unknown> = { hookSpecificOutput };
35
+ if (reason !== undefined) out.systemMessage = reason;
36
+ process.stdout.write(JSON.stringify(out));
37
+ process.exit(0);
38
+ }
39
+
40
+ /** Well-known rebuildable caches a `rm -rf` may target without a warning. */
41
+ const SAFE_RM_TARGET =
42
+ /^(?:\.?\/)?(?:[\w.@-]+\/)*(?:node_modules|dist|\.next|coverage|build|\.turbo|\.cache|\.parcel-cache|out)\/?\*?$/;
43
+
44
+ /**
45
+ * Expand an argument string into the set of flags it sets, splitting short-flag
46
+ * clusters into their individual letters: `-Rf --force` → `{R, f, --force}`.
47
+ *
48
+ * Matching flags with ad-hoc regexes per call site is what let `rm -R` through —
49
+ * `-\w*r` only ever matched the lowercase spelling, even though `man rm` defines
50
+ * `-r` as "Equivalent to -R". Parsing once, case-preserved, makes that class of
51
+ * miss unrepresentable: a caller names every spelling it cares about explicitly.
52
+ */
53
+ function parseFlags(args: string): Set<string> {
54
+ const flags = new Set<string>();
55
+ for (const token of args.trim().split(/\s+/)) {
56
+ if (token.length < 2 || !token.startsWith('-') || token === '--') continue;
57
+ if (token.startsWith('--')) {
58
+ flags.add(token.split('=')[0] as string); // `--force=x` → `--force`
59
+ continue;
60
+ }
61
+ for (const ch of token.slice(1)) flags.add(ch);
62
+ }
63
+ return flags;
64
+ }
65
+
66
+ /** True when a `rm` invocation is both recursive and forced (any flag spelling). */
67
+ function isRecursiveForceRm(args: string): boolean {
68
+ const flags = parseFlags(args);
69
+ // POSIX rm accepts -r and -R interchangeably; --recursive is the GNU long form.
70
+ const recursive = flags.has('r') || flags.has('R') || flags.has('--recursive');
71
+ const force = flags.has('f') || flags.has('--force');
72
+ return recursive && force;
73
+ }
74
+
75
+ /** True when every non-flag target of a `rm` invocation is a known-safe cache path. */
76
+ function rmTargetsAllSafe(args: string): boolean {
77
+ const targets = args
78
+ .trim()
79
+ .split(/\s+/)
80
+ .filter((t) => t.length > 0 && !t.startsWith('-'));
81
+ if (targets.length === 0) return false;
82
+ return targets.every((t) => SAFE_RM_TARGET.test(t));
83
+ }
84
+
85
+ /** Always-warn destructive patterns (no safe exception). */
86
+ const DESTRUCTIVE: Array<{ label: string; re: RegExp }> = [
87
+ {
88
+ label: 'a SQL DROP/TRUNCATE (DROP TABLE/DATABASE, TRUNCATE)',
89
+ re: /\b(?:DROP\s+(?:TABLE|DATABASE)|TRUNCATE(?:\s+TABLE)?)\b/i,
90
+ },
91
+ { label: 'a force push (git push --force / -f)', re: /\bgit\s+push\b[^\n]*(?:--force(?!-with-lease)|\s-f\b)/i },
92
+ {
93
+ // `git push origin +main` forces that ref without any --force flag.
94
+ label: 'a force push via a + refspec (git push … +ref)',
95
+ re: /\bgit\s+push\b[^\n]*\s\+[\w./-]+/i,
96
+ },
97
+ { label: 'a hard reset (git reset --hard)', re: /\bgit\s+reset\b[^\n]*--hard\b/i },
98
+ {
99
+ label: 'a working-tree discard (git checkout . / git restore .)',
100
+ re: /\bgit\s+(?:checkout|restore)\s+(?:--\s+)?\.(?:\s|$)/i,
101
+ },
102
+ { label: 'a cluster delete (kubectl delete)', re: /\bkubectl\s+delete\b/i },
103
+ { label: 'a docker prune (docker system prune)', re: /\bdocker\s+system\s+prune\b/i },
104
+ ];
105
+
106
+ /** Return a human label for the destructive command, or null when the command is safe. */
107
+ function classifyCommand(command: string): string | null {
108
+ for (const rmMatch of command.matchAll(/\brm\b([^\n&|;]*)/g)) {
109
+ const args = rmMatch[1] ?? '';
110
+ if (isRecursiveForceRm(args) && !rmTargetsAllSafe(args)) {
111
+ return 'a recursive force remove (rm -rf)';
112
+ }
113
+ }
114
+ for (const { label, re } of DESTRUCTIVE) {
115
+ if (re.test(command)) return label;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ async function main(): Promise<void> {
121
+ if (process.env.SPUR_CAREFUL === 'off') preToolUseDecision('allow');
122
+
123
+ const stdinText = await Bun.stdin.text();
124
+ let payload: ToolPayload;
125
+ try {
126
+ payload = JSON.parse(stdinText) as ToolPayload;
127
+ } catch {
128
+ preToolUseDecision('allow'); // unparseable payload — fail open
129
+ }
130
+
131
+ if (payload.tool_name !== 'Bash') preToolUseDecision('allow');
132
+
133
+ const command = payload.tool_input?.command ?? '';
134
+ if (command.trim() === '') preToolUseDecision('allow');
135
+
136
+ const hit = classifyCommand(command);
137
+ if (hit !== null) {
138
+ preToolUseDecision(
139
+ 'ask',
140
+ `This command looks like ${hit} — a destructive operation. Confirm you intend to run it. ` +
141
+ 'Set SPUR_CAREFUL=off to disable this guard.',
142
+ );
143
+ }
144
+ preToolUseDecision('allow');
145
+ }
146
+
147
+ if (import.meta.main) void main();
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * context-post-tool — PostToolUse hook for indexed-context
4
+ * (matcher: Bash|Grep|Glob|Read|Write|Edit — task 0248).
5
+ *
6
+ * Appends one event line to `token-ledger.jsonl` per tool call: path and/or short
7
+ * summary, action, and token estimate. Reads session ID (+ optional agent/model)
8
+ * from `.spur/context/.session.json`.
9
+ *
10
+ * Token cascade (task 0246): tool_response.content → Write tool_input.content →
11
+ * Edit old/new strings → Read file stat → omit tokens (never store 0 for unknown).
12
+ *
13
+ * Redaction (task 0248): store summary only (never full stdout); cap text ~4 KiB;
14
+ * strip obvious secret patterns. Bash tokens estimate from capped stdout/stderr length.
15
+ *
16
+ * **Fail-open contract:** every error path exits 0 with no output.
17
+ *
18
+ * Self-contained by design (task 0232/0246/0248). Installed hook configs use
19
+ * `superskill hook run sp context-post-tool`.
20
+ */
21
+
22
+ import { appendFileSync, existsSync, readFileSync, statSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+
25
+ /** Tools recorded by this hook (must match hooks.json PostToolUse matcher). */
26
+ export const ALLOWED_TOOLS = new Set(['Bash', 'Grep', 'Glob', 'Read', 'Write', 'Edit']);
27
+
28
+ /** Max stored summary / token-estimate input size (bytes). Task 0248 ~2–4 KiB. */
29
+ export const REDACTION_CAP_BYTES = 4096;
30
+
31
+ /** Max command/pattern summary length before ellipsis (chars). */
32
+ export const SUMMARY_MAX_CHARS = 200;
33
+
34
+ interface ToolPayload {
35
+ session_id?: string;
36
+ tool_name?: string;
37
+ tool_input?: {
38
+ file_path?: string;
39
+ content?: string;
40
+ old_string?: string;
41
+ new_string?: string;
42
+ /** Bash */
43
+ command?: string;
44
+ /** Grep / Glob (Claude Code shapes vary) */
45
+ pattern?: string;
46
+ glob_pattern?: string;
47
+ glob?: string;
48
+ path?: string;
49
+ };
50
+ tool_response?: {
51
+ content?: string | unknown;
52
+ filePath?: string;
53
+ stdout?: string;
54
+ stderr?: string;
55
+ };
56
+ }
57
+
58
+ interface SessionFile {
59
+ session?: string;
60
+ agent?: string;
61
+ model?: string;
62
+ }
63
+
64
+ /** Estimate tokens from raw byte count: Math.ceil(bytes / 4). */
65
+ export function estimateTokens(text: string): number {
66
+ return Math.ceil(new TextEncoder().encode(text).length / 4);
67
+ }
68
+
69
+ /** Scrub secret-like patterns without size capping. */
70
+ export function scrubSecrets(text: string): string {
71
+ let s = text;
72
+ // Bearer / API key prefixes first (before generic key=value)
73
+ s = s.replace(/\bBearer\s+[A-Za-z0-9._\-+=/]+/gi, 'Bearer ***');
74
+ // `sk-` keys. Segments may be hyphen-separated (`sk-ant-api03-…`), so match the
75
+ // whole run rather than a single alphanumeric block — anchoring on `[A-Za-z0-9]{8,}`
76
+ // alone stopped at the first hyphen and let every Anthropic key through, which is
77
+ // the most likely secret to appear in a Claude-agent harness.
78
+ s = s.replace(/\bsk-[A-Za-z0-9_-]{8,}/g, 'sk-***');
79
+ s = s.replace(/\bAKIA[0-9A-Z]{16}\b/g, 'AKIA***');
80
+ // Provider token prefixes with a fixed, unambiguous shape.
81
+ s = s.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}/g, 'github_pat_***');
82
+ s = s.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, (m) => `${m.slice(0, 4)}***`);
83
+ // Key=value style secrets (value = rest of non-space token). The key may carry a
84
+ // prefix (`ANTHROPIC_API_KEY`, `GITHUB_TOKEN`): `_` is a word character, so a
85
+ // leading `\b` would not match there and every namespaced env var leaked. Allow an
86
+ // optional `WORD_`-style prefix instead of requiring a boundary.
87
+ // The whole key (prefix included) is captured so the ledger still says *which*
88
+ // variable was scrubbed — `ANTHROPIC_API_KEY=***`, not a bare `API_KEY=***`.
89
+ s = s.replace(
90
+ /\b((?:[A-Za-z0-9]+[_-])*(?:api[_-]?key|password|passwd|secret|token|access[_-]?key|private[_-]?key))\s*[:=]\s*\S+/gi,
91
+ '$1=***',
92
+ );
93
+ // Authorization: <scheme> <credentials...>
94
+ s = s.replace(/\bAuthorization\s*:\s*\S+(?:\s+\S+)*/gi, 'Authorization: ***');
95
+ // PEM private key blocks — redact the payload, keep the header as a marker.
96
+ s = s.replace(
97
+ /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z ]+ )?PRIVATE KEY-----|$)/g,
98
+ '[private-key-redacted]',
99
+ );
100
+ // Collapse long base64 only when + / or = present (avoid pure a-z body blobs)
101
+ s = s.replace(/\b[A-Za-z0-9+/]{80,}={0,2}\b/g, (m) => (/[+/=]/.test(m) ? '[base64-redacted]' : m));
102
+ return s;
103
+ }
104
+
105
+ /**
106
+ * Cap text to {@link REDACTION_CAP_BYTES} and scrub obvious secret patterns.
107
+ * Used for any stored summary fragments — never full env dumps.
108
+ */
109
+ export function redactText(text: string, capBytes: number = REDACTION_CAP_BYTES): string {
110
+ const s = scrubSecrets(text);
111
+ const bytes = new TextEncoder().encode(s);
112
+ if (bytes.length <= capBytes) return s;
113
+ // Truncate on byte boundary then append marker
114
+ let end = capBytes;
115
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--;
116
+ return `${new TextDecoder().decode(bytes.subarray(0, end))}…[truncated]`;
117
+ }
118
+
119
+ /** Byte length after scrub + hard cap (no truncation marker) — for token estimates. */
120
+ export function cappedByteLength(text: string, capBytes: number = REDACTION_CAP_BYTES): number {
121
+ const scrubbed = scrubSecrets(text);
122
+ return Math.min(new TextEncoder().encode(scrubbed).length, capBytes);
123
+ }
124
+
125
+ /** Truncate a one-line summary for the ledger (after light redaction). */
126
+ export function truncateSummary(text: string, maxChars: number = SUMMARY_MAX_CHARS): string {
127
+ const cleaned = redactText(text.replace(/\s+/g, ' ').trim(), REDACTION_CAP_BYTES);
128
+ if (cleaned.length <= maxChars) return cleaned;
129
+ return `${cleaned.slice(0, Math.max(0, maxChars - 1))}…`;
130
+ }
131
+
132
+ /**
133
+ * Map platform tool name → ledger `type` (task 0248 R2).
134
+ * Edit stays `write` with action=edit (0245/0246 convention).
135
+ */
136
+ export function mapToolType(toolName: string): string {
137
+ switch (toolName) {
138
+ case 'Read':
139
+ return 'read';
140
+ case 'Write':
141
+ case 'Edit':
142
+ return 'write';
143
+ case 'Bash':
144
+ return 'bash';
145
+ case 'Grep':
146
+ return 'grep';
147
+ case 'Glob':
148
+ return 'glob';
149
+ default:
150
+ return toolName.toLowerCase();
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Short summary for Bash/Grep/Glob (command truncated, pattern, path glob).
156
+ * Not full stdout. Returns undefined when nothing useful to store.
157
+ */
158
+ export function buildToolSummary(toolName: string, toolInput: ToolPayload['tool_input']): string | undefined {
159
+ if (!toolInput) return undefined;
160
+ if (toolName === 'Bash') {
161
+ const cmd = typeof toolInput.command === 'string' ? toolInput.command : '';
162
+ if (!cmd.trim()) return undefined;
163
+ return truncateSummary(cmd);
164
+ }
165
+ if (toolName === 'Grep') {
166
+ const pattern = typeof toolInput.pattern === 'string' ? toolInput.pattern : '';
167
+ const path = typeof toolInput.path === 'string' ? toolInput.path : '';
168
+ const glob =
169
+ (typeof toolInput.glob === 'string' && toolInput.glob) ||
170
+ (typeof toolInput.glob_pattern === 'string' && toolInput.glob_pattern) ||
171
+ '';
172
+ const parts = [pattern && `/${pattern}/`, path, glob].filter(Boolean);
173
+ if (parts.length === 0) return undefined;
174
+ return truncateSummary(parts.join(' '));
175
+ }
176
+ if (toolName === 'Glob') {
177
+ const pattern =
178
+ (typeof toolInput.pattern === 'string' && toolInput.pattern) ||
179
+ (typeof toolInput.glob_pattern === 'string' && toolInput.glob_pattern) ||
180
+ (typeof toolInput.glob === 'string' && toolInput.glob) ||
181
+ '';
182
+ const path = typeof toolInput.path === 'string' ? toolInput.path : '';
183
+ const parts = [pattern, path].filter(Boolean);
184
+ if (parts.length === 0) return undefined;
185
+ return truncateSummary(parts.join(' in '));
186
+ }
187
+ return undefined;
188
+ }
189
+
190
+ /** Response text used only for size/token estimate — never written to the ledger. */
191
+ function responseTextForEstimate(toolResponse: ToolPayload['tool_response']): string {
192
+ if (!toolResponse) return '';
193
+ const parts: string[] = [];
194
+ if (typeof toolResponse.content === 'string') parts.push(toolResponse.content);
195
+ if (typeof toolResponse.stdout === 'string') parts.push(toolResponse.stdout);
196
+ if (typeof toolResponse.stderr === 'string') parts.push(toolResponse.stderr);
197
+ return parts.join('\n');
198
+ }
199
+
200
+ /**
201
+ * Token cascade (tasks 0246 / 0248). Returns undefined when unknown — callers omit
202
+ * the field rather than writing tokens: 0.
203
+ *
204
+ * Bash: capped stdout/stderr length. Grep/Glob: capped result size or undefined.
205
+ */
206
+ export function resolveTokenEstimate(
207
+ toolName: string,
208
+ toolInput: ToolPayload['tool_input'],
209
+ toolResponse: ToolPayload['tool_response'],
210
+ ): number | undefined {
211
+ // Bash: estimate from capped response only (never uncapped multi-MB stdout).
212
+ if (toolName === 'Bash') {
213
+ const raw = responseTextForEstimate(toolResponse);
214
+ if (raw.length === 0) return undefined;
215
+ return Math.ceil(cappedByteLength(raw) / 4);
216
+ }
217
+
218
+ // Grep / Glob: result size after cap, or omit when empty.
219
+ if (toolName === 'Grep' || toolName === 'Glob') {
220
+ const raw = responseTextForEstimate(toolResponse);
221
+ if (raw.length === 0) return undefined;
222
+ return Math.ceil(cappedByteLength(raw) / 4);
223
+ }
224
+
225
+ const responseContent = toolResponse?.content;
226
+ if (typeof responseContent === 'string' && responseContent.length > 0) {
227
+ return estimateTokens(responseContent);
228
+ }
229
+ if (toolName === 'Write' && typeof toolInput?.content === 'string' && toolInput.content.length > 0) {
230
+ return estimateTokens(toolInput.content);
231
+ }
232
+ if (toolName === 'Edit') {
233
+ const parts = [toolInput?.old_string ?? '', toolInput?.new_string ?? ''].join('');
234
+ if (parts.length > 0) return estimateTokens(parts);
235
+ }
236
+ if (toolName === 'Read' && typeof toolInput?.file_path === 'string' && toolInput.file_path) {
237
+ try {
238
+ if (existsSync(toolInput.file_path)) {
239
+ const size = statSync(toolInput.file_path).size;
240
+ if (size > 0) return Math.ceil(size / 4);
241
+ }
242
+ } catch {
243
+ /* fail-open: skip stat */
244
+ }
245
+ }
246
+ return undefined;
247
+ }
248
+
249
+ function readSessionFile(dir: string): SessionFile {
250
+ const sessionFile = join(dir, '.session.json');
251
+ if (!existsSync(sessionFile)) return {};
252
+ try {
253
+ return JSON.parse(readFileSync(sessionFile, 'utf-8')) as SessionFile;
254
+ } catch {
255
+ return {};
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Core record path (testable). Returns the written event, or null when nothing was logged
261
+ * (unknown tool, missing session, missing path/summary, I/O failure).
262
+ */
263
+ export function recordToolUseEvent(
264
+ contextDir: string,
265
+ payload: ToolPayload,
266
+ now: () => Date = () => new Date(),
267
+ ): Record<string, unknown> | null {
268
+ const toolName = payload.tool_name ?? '';
269
+ if (!ALLOWED_TOOLS.has(toolName)) return null;
270
+
271
+ const filePath = payload.tool_input?.file_path ?? '';
272
+ const summary = buildToolSummary(toolName, payload.tool_input);
273
+
274
+ // Read/Write/Edit still require a path; Bash/Grep/Glob require a short summary.
275
+ if (toolName === 'Read' || toolName === 'Write' || toolName === 'Edit') {
276
+ if (!filePath) return null;
277
+ } else if (!summary) {
278
+ return null;
279
+ }
280
+
281
+ const sessionMeta = readSessionFile(contextDir);
282
+ const session = sessionMeta.session ?? '';
283
+ if (!session) return null;
284
+
285
+ const tokens = resolveTokenEstimate(toolName, payload.tool_input, payload.tool_response);
286
+ const ts = now().toISOString();
287
+ const type = mapToolType(toolName);
288
+ const action = toolName === 'Write' ? 'create' : toolName === 'Edit' ? 'edit' : undefined;
289
+
290
+ const event: Record<string, unknown> = { ts, session, type };
291
+ if (filePath) event.file = filePath;
292
+ if (summary) event.summary = summary;
293
+ if (tokens !== undefined) event.tokens = tokens;
294
+ if (action) event.action = action;
295
+
296
+ // Best-effort identity fields — never block logging.
297
+ if (typeof payload.session_id === 'string' && payload.session_id) {
298
+ event.sessionId = payload.session_id;
299
+ }
300
+ if (typeof sessionMeta.agent === 'string' && sessionMeta.agent) event.agent = sessionMeta.agent;
301
+ if (typeof sessionMeta.model === 'string' && sessionMeta.model) event.model = sessionMeta.model;
302
+
303
+ try {
304
+ appendFileSync(join(contextDir, 'token-ledger.jsonl'), `${JSON.stringify(event)}\n`);
305
+ } catch {
306
+ return null;
307
+ }
308
+
309
+ return event;
310
+ }
311
+
312
+ // Entrypoint — thin wrapper; logic lives in {@link recordToolUseEvent} for unit coverage.
313
+ if (import.meta.main) {
314
+ void (async () => {
315
+ const dir = join(process.env.CLAUDE_PROJECT_DIR ?? process.cwd(), '.spur', 'context');
316
+ try {
317
+ const stdinText = await Bun.stdin.text();
318
+ const payload = JSON.parse(stdinText) as ToolPayload;
319
+ recordToolUseEvent(dir, payload);
320
+ } catch {
321
+ /* fail-open: malformed stdin / I/O */
322
+ }
323
+ process.exit(0);
324
+ })();
325
+ }
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * context-session-start — SessionStart hook for indexed-context.
4
+ *
5
+ * Generates a session ID, writes `.spur/context/.session.json` (tracking the current session
6
+ * for PostToolUse and Stop hooks), and appends a `session_start` event to
7
+ * `token-ledger.jsonl`. Task 0246: best-effort agent/model hints on session file + event.
8
+ *
9
+ * **Fail-open contract:** every error path exits 0 with no output.
10
+ *
11
+ * Self-contained by design (task 0232/0246).
12
+ */
13
+
14
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ import { resolveAgentHint, resolveModelHint } from './agent-hint';
18
+
19
+ export { resolveAgentHint, resolveModelHint } from './agent-hint';
20
+
21
+ /**
22
+ * Ancestor-run marker exported by `@gobing-ai/ts-ai-runner` ≥ 0.4.15 (`AGENT_RUN_ID_ENV`).
23
+ *
24
+ * `AiRunner` sets this in the agent subprocess environment whenever the caller supplies an
25
+ * `AgentRunCorrelation`, which Spur's pipeline always does
26
+ * (`packages/app/src/workflow/actions/agent-run.ts:148-152`) as does `spur agent run`
27
+ * (`packages/app/src/services/agent-service.ts:661`). execa's `extendEnv` default propagates it
28
+ * transitively, so every descendant of an agent run — including the hook subprocesses the host
29
+ * fires inside it — inherits the same value. Its mere presence answers "am I nested?" exactly.
30
+ *
31
+ * Deliberately a string literal rather than an import: this hook is self-contained by design
32
+ * (tasks 0232/0246) and runs both as a standalone script and from Superskill's bundled runner, so
33
+ * it must not depend on the workspace's module graph. The name is a published contract on the
34
+ * ts-ai-runner side, so a literal here is a stable coupling, not a guess.
35
+ */
36
+ export const AGENT_RUN_ID_ENV = 'SPUR_RUN_ID';
37
+
38
+ /**
39
+ * Fallback idle window for hosts and code paths that do **not** propagate a run correlation
40
+ * (task 0398 R3).
41
+ *
42
+ * Only consulted when {@link AGENT_RUN_ID_ENV} is absent. With ts-ai-runner ≥ 0.4.15 the
43
+ * agent-run path is detected exactly, so this no longer covers the case that motivated it — it
44
+ * remains as a backstop for nested `SessionStart` fires that arrive outside a correlated run
45
+ * (a host that spawns its own helper processes, or an `agent.run` invoked without a correlation).
46
+ *
47
+ * ponytail: still a wall-clock heuristic on that residual path — two genuinely distinct
48
+ * uncorrelated sessions started inside the window merge into one ledger session. Kept because
49
+ * deleting it would regress every non-correlated nesting path back to the original bug, and the
50
+ * set of hosts is not enumerable from here. Delete it once every nesting path is known to carry a
51
+ * correlation.
52
+ */
53
+ export const SESSION_REUSE_IDLE_MS = 4 * 60 * 60 * 1000;
54
+
55
+ interface SessionFileBody {
56
+ session?: unknown;
57
+ started?: unknown;
58
+ }
59
+
60
+ /**
61
+ * Return the id of the session already in flight, or null when a new one should be minted.
62
+ *
63
+ * **Why this exists (0398 R3).** `SessionStart` fires in every nested `agent.run` subprocess, not
64
+ * once per host session. Each firing used to mint a fresh `session-<date>-<HHMM>` id, append a
65
+ * `session_start` row, and overwrite the `.session.json` pointer that `context-post-tool` reads —
66
+ * so a single pipeline run registered as dozens of sessions and orphaned the parent's event
67
+ * stream. The H6 corpus shows the signature: 332 `session_start` against 157 `session_end` over
68
+ * 18 days, 298 distinct ids, 39 starts in a 2-day window that held a handful of real sessions.
69
+ *
70
+ * **Two signals, in precedence order.**
71
+ *
72
+ * 1. {@link AGENT_RUN_ID_ENV} present → this process is a descendant of an agent run, definitively.
73
+ * Reuse the recorded session with no time bound: a pipeline step legitimately runs for the full
74
+ * `implementTimeoutMs` (30 min) and longer batches run for hours, so any wall-clock window would
75
+ * eventually split a run that is demonstrably still in flight.
76
+ * 2. No marker → fall back to the {@link SESSION_REUSE_IDLE_MS} window on the `started` stamp, for
77
+ * hosts that nest without propagating a correlation.
78
+ *
79
+ * A crashed session leaves a stale `.session.json`; on path 2 the idle window retires it. On path 1
80
+ * a stale file is only reachable from inside a live agent run, where reusing it is the correct
81
+ * answer anyway.
82
+ */
83
+ export function resolveActiveSession(dir: string, now: Date, env: NodeJS.ProcessEnv = process.env): string | null {
84
+ let raw: string;
85
+ try {
86
+ raw = readFileSync(join(dir, '.session.json'), 'utf-8');
87
+ } catch {
88
+ return null; // no in-flight session (or unreadable) → mint a new one
89
+ }
90
+
91
+ let body: SessionFileBody;
92
+ try {
93
+ body = JSON.parse(raw) as SessionFileBody;
94
+ } catch {
95
+ return null; // corrupt pointer → mint a new one
96
+ }
97
+
98
+ if (typeof body.session !== 'string' || body.session.length === 0) return null;
99
+
100
+ // Signal 1 — exact ancestry. No `started` parse, no time bound.
101
+ const runId = env[AGENT_RUN_ID_ENV];
102
+ if (typeof runId === 'string' && runId.trim().length > 0) return body.session;
103
+
104
+ // Signal 2 — residual heuristic for uncorrelated nesting.
105
+ if (typeof body.started !== 'string') return null;
106
+
107
+ const started = Date.parse(body.started);
108
+ if (Number.isNaN(started)) return null;
109
+
110
+ const idleMs = now.getTime() - started;
111
+ if (idleMs < 0 || idleMs > SESSION_REUSE_IDLE_MS) return null; // stale or clock-skewed → new
112
+
113
+ return body.session;
114
+ }
115
+
116
+ /**
117
+ * Core session-start path (testable). Returns session id on success, null on I/O failure.
118
+ *
119
+ * Idempotent per in-flight session (0398 R3): when `resolveActiveSession` finds one, this returns
120
+ * that id and writes nothing — no ledger row, no pointer rewrite.
121
+ */
122
+ export function recordSessionStart(
123
+ dir: string,
124
+ env: NodeJS.ProcessEnv = process.env,
125
+ now: () => Date = () => new Date(),
126
+ ): string | null {
127
+ try {
128
+ mkdirSync(dir, { recursive: true });
129
+ } catch {
130
+ return null;
131
+ }
132
+
133
+ const at = now();
134
+
135
+ const active = resolveActiveSession(dir, at, env);
136
+ if (active !== null) return active;
137
+ const pad = (n: number) => String(n).padStart(2, '0');
138
+ const sessionId = `session-${at.toISOString().slice(0, 10)}-${pad(at.getHours())}${pad(at.getMinutes())}`;
139
+ const ts = at.toISOString();
140
+ const agent = resolveAgentHint(env);
141
+ const model = resolveModelHint(env);
142
+
143
+ const sessionBody: Record<string, unknown> = {
144
+ session: sessionId,
145
+ started: ts,
146
+ reads: 0,
147
+ writes: 0,
148
+ tokens: 0,
149
+ };
150
+ if (agent) sessionBody.agent = agent;
151
+ if (model) sessionBody.model = model;
152
+
153
+ const sessionFile = join(dir, '.session.json');
154
+ try {
155
+ writeFileSync(sessionFile, JSON.stringify(sessionBody));
156
+ } catch {
157
+ return null;
158
+ }
159
+
160
+ const startEvent: Record<string, unknown> = { ts, session: sessionId, type: 'session_start' };
161
+ if (agent) startEvent.agent = agent;
162
+ if (model) startEvent.model = model;
163
+
164
+ const ledgerPath = join(dir, 'token-ledger.jsonl');
165
+ try {
166
+ appendFileSync(ledgerPath, `${JSON.stringify(startEvent)}\n`);
167
+ } catch {
168
+ return null;
169
+ }
170
+
171
+ return sessionId;
172
+ }
173
+
174
+ // Entrypoint — kept minimal so unit coverage focuses on pure helpers above.
175
+ if (import.meta.main) {
176
+ try {
177
+ recordSessionStart(join(process.env.CLAUDE_PROJECT_DIR ?? process.cwd(), '.spur', 'context'));
178
+ } catch {
179
+ /* fail-open */
180
+ }
181
+ process.exit(0);
182
+ }