@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.
- package/.claude-plugin/marketplace.json +15 -0
- package/README.md +15 -1
- package/config/templates/AGENTS.md +5 -3
- package/config/workflows/task-pipeline.yaml +113 -12
- package/package.json +15 -12
- package/plugins/README.md +655 -0
- package/plugins/sp/README.md +621 -0
- package/plugins/sp/agents/expert-spur.md +109 -0
- package/plugins/sp/agents/super-coder.md +126 -0
- package/plugins/sp/agents/super-planner.md +283 -0
- package/plugins/sp/agents/super-reviewer.md +154 -0
- package/plugins/sp/commands/dev-arch.md +30 -0
- package/plugins/sp/commands/dev-brainstorm.md +40 -0
- package/plugins/sp/commands/dev-changelog.md +28 -0
- package/plugins/sp/commands/dev-daily.md +29 -0
- package/plugins/sp/commands/dev-debug.md +29 -0
- package/plugins/sp/commands/dev-dogfood.md +33 -0
- package/plugins/sp/commands/dev-featurechange.md +130 -0
- package/plugins/sp/commands/dev-find-conflict.md +51 -0
- package/plugins/sp/commands/dev-find-issue.md +76 -0
- package/plugins/sp/commands/dev-find-next.md +60 -0
- package/plugins/sp/commands/dev-fixall.md +33 -0
- package/plugins/sp/commands/dev-gitmsg.md +28 -0
- package/plugins/sp/commands/dev-gtd.md +97 -0
- package/plugins/sp/commands/dev-handover.md +26 -0
- package/plugins/sp/commands/dev-idea.md +50 -0
- package/plugins/sp/commands/dev-next.md +31 -0
- package/plugins/sp/commands/dev-parallel.md +30 -0
- package/plugins/sp/commands/dev-plan.md +46 -0
- package/plugins/sp/commands/dev-refine.md +48 -0
- package/plugins/sp/commands/dev-refineall.md +65 -0
- package/plugins/sp/commands/dev-refresh.md +32 -0
- package/plugins/sp/commands/dev-reverse.md +32 -0
- package/plugins/sp/commands/dev-review.md +31 -0
- package/plugins/sp/commands/dev-run.md +63 -0
- package/plugins/sp/commands/dev-runall.md +83 -0
- package/plugins/sp/commands/dev-simplify.md +39 -0
- package/plugins/sp/commands/dev-unit.md +29 -0
- package/plugins/sp/commands/dev-verify.md +48 -0
- package/plugins/sp/commands/dev-verifyall.md +72 -0
- package/plugins/sp/commands/dev-wrap.md +48 -0
- package/plugins/sp/commands/dev-wrapall.md +51 -0
- package/plugins/sp/commands/rule-add.md +18 -0
- package/plugins/sp/commands/rule-refine.md +18 -0
- package/plugins/sp/commands/rule-scan.md +18 -0
- package/plugins/sp/commands/spur-init.md +22 -0
- package/plugins/sp/commands/workflow-add.md +18 -0
- package/plugins/sp/commands/workflow-refine.md +18 -0
- package/plugins/sp/hooks/agent-hint.ts +60 -0
- package/plugins/sp/hooks/careful-guard.ts +147 -0
- package/plugins/sp/hooks/context-post-tool.ts +325 -0
- package/plugins/sp/hooks/context-session-start.ts +182 -0
- package/plugins/sp/hooks/context-session-stop.ts +95 -0
- package/plugins/sp/hooks/hooks.json +51 -0
- package/plugins/sp/hooks/pi/guard-extension.ts +341 -0
- package/plugins/sp/hooks/task-write-guard.ts +84 -0
- package/plugins/sp/plugin.json +8 -0
- package/plugins/sp/scripts/batch-preflight.ts +204 -0
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +606 -0
- package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +363 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +168 -0
- package/plugins/sp/scripts/feature-sync-bounded.ts +451 -0
- package/plugins/sp/scripts/stage-registry-adapter.ts +1341 -0
- package/plugins/sp/scripts/task-size-precheck.ts +202 -0
- package/plugins/sp/scripts/validate-commands.ts +651 -0
- package/plugins/sp/scripts/validate-flag-contracts.ts +875 -0
- package/plugins/sp/skills/brainstorm/SKILL.md +361 -0
- package/plugins/sp/skills/brainstorm/agents/openai.yaml +14 -0
- package/plugins/sp/skills/brainstorm/examples/ideation-example.md +209 -0
- package/plugins/sp/skills/brainstorm/metadata.openclaw +7 -0
- package/plugins/sp/skills/brainstorm/references/grilling-interview.md +68 -0
- package/plugins/sp/skills/brainstorm/references/workflows.md +255 -0
- package/plugins/sp/skills/branch-workflow/SKILL.md +109 -0
- package/plugins/sp/skills/branch-workflow/references/branch-lifecycle.md +69 -0
- package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +100 -0
- package/plugins/sp/skills/code-implementation/SKILL.md +203 -0
- package/plugins/sp/skills/code-implementation/references/debugging.md +64 -0
- package/plugins/sp/skills/code-implementation/references/implementation-patterns.md +113 -0
- package/plugins/sp/skills/code-improvement/SKILL.md +231 -0
- package/plugins/sp/skills/code-improvement/references/deepening-signals.md +205 -0
- package/plugins/sp/skills/code-review/SKILL.md +112 -0
- package/plugins/sp/skills/code-review/references/receiving-code-review.md +70 -0
- package/plugins/sp/skills/code-review/references/review-lenses.md +117 -0
- package/plugins/sp/skills/code-review/references/self-review-checklist.md +53 -0
- package/plugins/sp/skills/code-review/references/verification-before-completion.md +68 -0
- package/plugins/sp/skills/code-simplification/SKILL.md +225 -0
- package/plugins/sp/skills/code-testing/SKILL.md +108 -0
- package/plugins/sp/skills/code-testing/references/stacks/bun-ts.md +165 -0
- package/plugins/sp/skills/code-testing/references/stacks/go.md +88 -0
- package/plugins/sp/skills/code-testing/references/stacks/python.md +117 -0
- package/plugins/sp/skills/code-testing/references/test-loop-breaker.md +37 -0
- package/plugins/sp/skills/code-testing/references/test-output-discipline.md +52 -0
- package/plugins/sp/skills/code-testing/references/unit-testing.md +212 -0
- package/plugins/sp/skills/code-verification/SKILL.md +535 -0
- package/plugins/sp/skills/code-verification/references/code-improvement.md +39 -0
- package/plugins/sp/skills/code-verification/references/secu-review.md +94 -0
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +141 -0
- package/plugins/sp/skills/conflict-finding/SKILL.md +254 -0
- package/plugins/sp/skills/conflict-finding/references/authority-resolution.md +258 -0
- package/plugins/sp/skills/conflict-finding/references/comparison-protocol.md +277 -0
- package/plugins/sp/skills/conflict-finding/references/finding-contract.md +346 -0
- package/plugins/sp/skills/conflict-finding/references/remediation-routing.md +276 -0
- package/plugins/sp/skills/daily-summary/SKILL.md +171 -0
- package/plugins/sp/skills/daily-summary/agents/openai.yaml +13 -0
- package/plugins/sp/skills/doc-evolve/SKILL.md +175 -0
- package/plugins/sp/skills/doc-evolve/references/operations.md +93 -0
- package/plugins/sp/skills/dogfood-testing/SKILL.md +608 -0
- package/plugins/sp/skills/dogfood-testing/references/monitor-ledger.md +211 -0
- package/plugins/sp/skills/dogfood-testing/references/report-template.md +351 -0
- package/plugins/sp/skills/doubt-driven-development/SKILL.md +136 -0
- package/plugins/sp/skills/functional-review/SKILL.md +364 -0
- package/plugins/sp/skills/functional-review/references/verdict-schema.md +133 -0
- package/plugins/sp/skills/indexed-context/SKILL.md +164 -0
- package/plugins/sp/skills/issue-finding/SKILL.md +442 -0
- package/plugins/sp/skills/issue-finding/agents/openai.yaml +15 -0
- package/plugins/sp/skills/issue-finding/examples/expected-findings.json +27 -0
- package/plugins/sp/skills/issue-finding/examples/session-test-loop.jsonl +23 -0
- package/plugins/sp/skills/issue-finding/references/session-formats.md +151 -0
- package/plugins/sp/skills/next-feature/SKILL.md +125 -0
- package/plugins/sp/skills/next-feature/references/handoff-routing.md +92 -0
- package/plugins/sp/skills/next-feature/references/proposal-contract.md +49 -0
- package/plugins/sp/skills/next-feature/references/ranking-rubric.md +52 -0
- package/plugins/sp/skills/next-feature/references/signal-derivation.md +67 -0
- package/plugins/sp/skills/next-router/SKILL.md +181 -0
- package/plugins/sp/skills/next-router/references/messages.md +90 -0
- package/plugins/sp/skills/next-router/references/routing-table.md +216 -0
- package/plugins/sp/skills/parallel-execution/SKILL.md +148 -0
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +109 -0
- package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +101 -0
- package/plugins/sp/skills/parallel-execution/references/result-synthesis.md +94 -0
- package/plugins/sp/skills/reverse-engineering/SKILL.md +375 -0
- package/plugins/sp/skills/reverse-engineering/agents/openai.yaml +18 -0
- package/plugins/sp/skills/source-driven-development/SKILL.md +124 -0
- package/plugins/sp/skills/spec-decomposition/SKILL.md +117 -0
- package/plugins/sp/skills/spec-decomposition/references/decomposition.md +539 -0
- package/plugins/sp/skills/spur-cli/SKILL.md +133 -0
- package/plugins/sp/skills/spur-cli/references/agent.md +179 -0
- package/plugins/sp/skills/spur-cli/references/features/acceptance-criteria.md +82 -0
- package/plugins/sp/skills/spur-cli/references/features/hierarchy-mece.md +177 -0
- package/plugins/sp/skills/spur-cli/references/features/roadmap-priority.md +84 -0
- package/plugins/sp/skills/spur-cli/references/features/verbs.md +122 -0
- package/plugins/sp/skills/spur-cli/references/features.md +234 -0
- package/plugins/sp/skills/spur-cli/references/init.md +119 -0
- package/plugins/sp/skills/spur-cli/references/message.md +98 -0
- package/plugins/sp/skills/spur-cli/references/rules/authoring-rules.md +212 -0
- package/plugins/sp/skills/spur-cli/references/rules/fine-tuning.md +148 -0
- package/plugins/sp/skills/spur-cli/references/rules/operations.md +228 -0
- package/plugins/sp/skills/spur-cli/references/rules/validation-and-extension.md +129 -0
- package/plugins/sp/skills/spur-cli/references/rules.md +209 -0
- package/plugins/sp/skills/spur-cli/references/serve.md +58 -0
- package/plugins/sp/skills/spur-cli/references/tasks/l3-guard-cheatsheet.md +98 -0
- package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +95 -0
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +361 -0
- package/plugins/sp/skills/spur-cli/references/tasks.md +280 -0
- package/plugins/sp/skills/spur-cli/references/team.md +133 -0
- package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +226 -0
- package/plugins/sp/skills/spur-cli/references/workflows/operations.md +211 -0
- package/plugins/sp/skills/spur-cli/references/workflows/validation-and-extension.md +129 -0
- package/plugins/sp/skills/spur-cli/references/workflows.md +355 -0
- package/plugins/sp/skills/spur-dev/SKILL.md +224 -0
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +196 -0
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +680 -0
- package/plugins/sp/skills/spur-dev/references/decision-brief.md +80 -0
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +546 -0
- package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +164 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +790 -0
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +352 -0
- package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +191 -0
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +417 -0
- package/plugins/sp/skills/spur-dev/references/gate-checklists.md +171 -0
- package/plugins/sp/skills/spur-dev/references/glossary.md +95 -0
- package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +80 -0
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +120 -0
- package/plugins/sp/skills/spur-dev/references/planning-workflow.md +302 -0
- package/plugins/sp/skills/spur-dev/references/product-planning.md +206 -0
- package/plugins/sp/skills/spur-dev/references/section-batching.md +24 -0
- package/plugins/sp/skills/sys-architecture/SKILL.md +155 -0
- package/plugins/sp/skills/sys-architecture/references/decision-method.md +137 -0
- package/plugins/sp/skills/sys-architecture/references/upkeep-survey.md +75 -0
- package/plugins/sp/skills/sys-debugging/SKILL.md +228 -0
- package/plugins/sp/skills/sys-debugging/references/debugging-protocol.md +62 -0
- package/plugins/sp/skills/test-driven-development/SKILL.md +248 -0
- package/plugins/sp/skills/wayfinder/SKILL.md +292 -0
- 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
|
+
}
|