@compaction/cli 0.2.0 → 0.3.1
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/README.md +142 -64
- package/dist/cli/commands/activity.d.ts +8 -0
- package/dist/cli/commands/activity.js +35 -0
- package/dist/cli/commands/apply-context.js +68 -0
- package/dist/cli/commands/billing-delta.js +12 -1
- package/dist/cli/commands/capture-claude-code.d.ts +44 -0
- package/dist/cli/commands/capture-claude-code.js +206 -0
- package/dist/cli/commands/capture.js +283 -1
- package/dist/cli/commands/compact.js +9 -5
- package/dist/cli/commands/dev.d.ts +17 -0
- package/dist/cli/commands/dev.js +131 -0
- package/dist/cli/commands/gateway.d.ts +31 -0
- package/dist/cli/commands/gateway.js +312 -0
- package/dist/cli/commands/hooks.d.ts +5 -0
- package/dist/cli/commands/hooks.js +181 -0
- package/dist/cli/commands/init.js +422 -17
- package/dist/cli/commands/input-compaction-ab.d.ts +2 -0
- package/dist/cli/commands/input-compaction-ab.js +125 -0
- package/dist/cli/commands/optimize-hosted.d.ts +54 -0
- package/dist/cli/commands/optimize-hosted.js +123 -0
- package/dist/cli/commands/optimize.js +8 -0
- package/dist/cli/commands/output-shaping-ab.d.ts +2 -0
- package/dist/cli/commands/output-shaping-ab.js +132 -0
- package/dist/cli/commands/output-shaping.d.ts +7 -0
- package/dist/cli/commands/output-shaping.js +43 -0
- package/dist/cli/commands/policies.d.ts +2 -0
- package/dist/cli/commands/policies.js +80 -0
- package/dist/cli/commands/precall.d.ts +19 -0
- package/dist/cli/commands/precall.js +436 -0
- package/dist/cli/commands/recommend.js +1 -1
- package/dist/cli/commands/run.js +297 -12
- package/dist/cli/commands/upgrade-status.d.ts +42 -0
- package/dist/cli/commands/upgrade-status.js +152 -0
- package/dist/cli/cursor-export-read.d.ts +13 -0
- package/dist/cli/cursor-export-read.js +53 -0
- package/dist/cli/cursor-live-preflight.d.ts +19 -0
- package/dist/cli/cursor-live-preflight.js +46 -0
- package/dist/cli/index.js +36 -2
- package/dist/cli/onboarding/GatewayTui.d.ts +33 -0
- package/dist/cli/onboarding/GatewayTui.js +113 -0
- package/dist/cli/onboarding/model.d.ts +32 -1
- package/dist/cli/onboarding/model.js +27 -2
- package/dist/cli/terminal-logo.d.ts +27 -0
- package/dist/cli/terminal-logo.js +49 -0
- package/dist/core/activity-event.d.ts +107 -0
- package/dist/core/activity-event.js +182 -0
- package/dist/core/activity-store.d.ts +63 -0
- package/dist/core/activity-store.js +254 -0
- package/dist/core/activity-view.d.ts +76 -0
- package/dist/core/activity-view.js +120 -0
- package/dist/core/api-client/index.d.ts +3 -1
- package/dist/core/api-client/index.js +4 -0
- package/dist/core/api-client/payload.d.ts +5 -1
- package/dist/core/api-client/payload.js +8 -6
- package/dist/core/api-client/persisted-config.d.ts +57 -0
- package/dist/core/api-client/persisted-config.js +156 -0
- package/dist/core/api-client/tool.d.ts +29 -0
- package/dist/core/api-client/tool.js +50 -0
- package/dist/core/api-client/types.d.ts +15 -4
- package/dist/core/auto-apply-ask.d.ts +58 -0
- package/dist/core/auto-apply-ask.js +105 -0
- package/dist/core/auto-apply-gates.d.ts +76 -0
- package/dist/core/auto-apply-gates.js +113 -0
- package/dist/core/before-call-activity.d.ts +35 -0
- package/dist/core/before-call-activity.js +103 -0
- package/dist/core/before-call-recovery.d.ts +19 -0
- package/dist/core/before-call-recovery.js +46 -0
- package/dist/core/before-call-stdin.d.ts +59 -0
- package/dist/core/before-call-stdin.js +78 -0
- package/dist/core/before-call.d.ts +149 -0
- package/dist/core/before-call.js +358 -0
- package/dist/core/billing-delta/billing-delta-record.d.ts +28 -0
- package/dist/core/billing-delta/billing-delta-record.js +26 -0
- package/dist/core/capture-record.d.ts +40 -0
- package/dist/core/capture-record.js +55 -0
- package/dist/core/claude-code-before-call.d.ts +37 -0
- package/dist/core/claude-code-before-call.js +120 -0
- package/dist/core/claude-code-connect.d.ts +31 -0
- package/dist/core/claude-code-connect.js +87 -0
- package/dist/core/claude-code-hook-record.d.ts +71 -0
- package/dist/core/claude-code-hook-record.js +0 -0
- package/dist/core/claude-code-hooks.d.ts +77 -0
- package/dist/core/claude-code-hooks.js +141 -0
- package/dist/core/codex-capture.d.ts +45 -0
- package/dist/core/codex-capture.js +204 -0
- package/dist/core/command-runner.js +5 -1
- package/dist/core/compaction-artifacts.d.ts +51 -0
- package/dist/core/compaction-artifacts.js +242 -0
- package/dist/core/compactor.d.ts +9 -0
- package/dist/core/compactor.js +95 -0
- package/dist/core/cross-surface-event.d.ts +306 -0
- package/dist/core/cross-surface-event.js +330 -0
- package/dist/core/cursor-capture.d.ts +54 -0
- package/dist/core/cursor-capture.js +215 -0
- package/dist/core/cursor-preflight-probe.d.ts +8 -0
- package/dist/core/cursor-preflight-probe.js +88 -0
- package/dist/core/cursor-preflight.d.ts +86 -0
- package/dist/core/cursor-preflight.js +126 -0
- package/dist/core/gateway/apply-activation.d.ts +39 -0
- package/dist/core/gateway/apply-activation.js +84 -0
- package/dist/core/gateway/apply-policy.d.ts +64 -0
- package/dist/core/gateway/apply-policy.js +221 -0
- package/dist/core/gateway/apply-receipt.d.ts +36 -0
- package/dist/core/gateway/apply-receipt.js +75 -0
- package/dist/core/gateway/cache-proof.d.ts +45 -0
- package/dist/core/gateway/cache-proof.js +65 -0
- package/dist/core/gateway/configure.d.ts +50 -0
- package/dist/core/gateway/configure.js +169 -0
- package/dist/core/gateway/openai-usage.d.ts +56 -0
- package/dist/core/gateway/openai-usage.js +128 -0
- package/dist/core/gateway/receipt.d.ts +138 -0
- package/dist/core/gateway/receipt.js +120 -0
- package/dist/core/gateway/recovery.d.ts +23 -0
- package/dist/core/gateway/recovery.js +68 -0
- package/dist/core/gateway/server.d.ts +51 -0
- package/dist/core/gateway/server.js +276 -0
- package/dist/core/gateway/status.d.ts +45 -0
- package/dist/core/gateway/status.js +109 -0
- package/dist/core/hook-usage-aggregate.d.ts +47 -0
- package/dist/core/hook-usage-aggregate.js +161 -0
- package/dist/core/input-compaction-ab.d.ts +111 -0
- package/dist/core/input-compaction-ab.js +158 -0
- package/dist/core/local-run-record.d.ts +109 -0
- package/dist/core/local-run-record.js +223 -0
- package/dist/core/output-shaping-ab.d.ts +140 -0
- package/dist/core/output-shaping-ab.js +146 -0
- package/dist/core/output-shaping-attach.d.ts +31 -0
- package/dist/core/output-shaping-attach.js +57 -0
- package/dist/core/output-shaping.d.ts +56 -0
- package/dist/core/output-shaping.js +89 -0
- package/dist/core/policy-middleware.d.ts +121 -0
- package/dist/core/policy-middleware.js +919 -0
- package/dist/core/policy-preferences.d.ts +99 -0
- package/dist/core/policy-preferences.js +232 -0
- package/dist/core/run-aggregator.d.ts +11 -1
- package/dist/core/run-aggregator.js +29 -2
- package/dist/core/run-flow-report.d.ts +82 -0
- package/dist/core/run-flow-report.js +71 -0
- package/dist/core/safety-report.js +8 -1
- package/dist/core/shim-capture-bridge.d.ts +32 -0
- package/dist/core/shim-capture-bridge.js +88 -0
- package/dist/core/skill-injection-policy.d.ts +72 -0
- package/dist/core/skill-injection-policy.js +183 -0
- package/dist/core/spend-attribution.js +1 -1
- package/dist/core/token-accounting.d.ts +1 -1
- package/dist/core/tool-shim.d.ts +129 -0
- package/dist/core/tool-shim.js +447 -0
- package/dist/core/trace-parser.d.ts +13 -13
- package/dist/core/trace-parser.js +6 -6
- package/dist/core/types.d.ts +12 -2
- package/dist/core/waste-detector.d.ts +20 -0
- package/dist/core/waste-detector.js +160 -6
- package/package.json +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared content-free record step for the unified capture→compact→record→report flow (PUBLIC CLI/SDK).
|
|
3
|
+
* Design: `docs/design/unified-run-flow.md` (accepted 2026-06-28).
|
|
4
|
+
*
|
|
5
|
+
* After any tool's capture (Codex live wrapper, Claude Code capture/hook, …) this records a CONTENT-FREE
|
|
6
|
+
* usage event to the hosted control-plane: `provider_metadata {tool, provider, model}` + `token_usage`
|
|
7
|
+
* with **input and output counted SEPARATELY** and an honest **`token_source`**
|
|
8
|
+
* (provider-reported | local-estimate). It uploads NO trace content (metrics_only) and only runs when the
|
|
9
|
+
* user has configured the hosted path (URL + key) — never a default network call.
|
|
10
|
+
*
|
|
11
|
+
* Honesty rails (founder D2, 2026-06-28): the three axes stay distinct — provider-reported vs
|
|
12
|
+
* locally-observed/estimated vs SAVINGS. This records tokens (input + output) with their source; it does
|
|
13
|
+
* NOT compute or send any output-savings figure (output savings stay unavailable until the output-shaping
|
|
14
|
+
* policy family produces measured + eval-confirmed evidence).
|
|
15
|
+
*/
|
|
16
|
+
import { buildOptimizeRequest, sendRequest } from "./api-client/index.js";
|
|
17
|
+
/** Map a capture's UsageMetadata to the honest wire token source. */
|
|
18
|
+
export function captureTokenSource(usage) {
|
|
19
|
+
if (usage.provider_reported_tokens)
|
|
20
|
+
return "provider-reported";
|
|
21
|
+
if (usage.estimated_tokens)
|
|
22
|
+
return "local-estimate";
|
|
23
|
+
return "unknown";
|
|
24
|
+
}
|
|
25
|
+
/** Build the CONTENT-FREE (`metrics_only`) record request. Input/output sent separately; honest source. */
|
|
26
|
+
export function buildCaptureRecordRequest(opts) {
|
|
27
|
+
return buildOptimizeRequest({
|
|
28
|
+
trace: { redacted: true, reference: opts.reference },
|
|
29
|
+
payloadClass: "metrics_only",
|
|
30
|
+
tokenSource: captureTokenSource(opts.usage),
|
|
31
|
+
...(typeof opts.usage.input_tokens === "number" ? { inputTokens: opts.usage.input_tokens } : {}),
|
|
32
|
+
...(typeof opts.usage.output_tokens === "number" ? { outputTokens: opts.usage.output_tokens } : {}),
|
|
33
|
+
...(opts.usage.provider ? { provider: opts.usage.provider } : {}),
|
|
34
|
+
...(opts.usage.model ? { model: opts.usage.model } : {}),
|
|
35
|
+
tool: opts.tool,
|
|
36
|
+
optimizationMode: "recommend"
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Send the content-free record. Best-effort: a transport/HTTP failure returns `recorded: false` with a
|
|
41
|
+
* sanitized reason (never the key, headers, or body) — it must NEVER fail the user's capture.
|
|
42
|
+
*/
|
|
43
|
+
export async function recordCaptureContentFree(config, opts) {
|
|
44
|
+
const source = captureTokenSource(opts.usage);
|
|
45
|
+
try {
|
|
46
|
+
const response = await sendRequest(config, "/v0/optimize", buildCaptureRecordRequest(opts));
|
|
47
|
+
if (!response.ok)
|
|
48
|
+
return { recorded: false, reason: `hosted record rejected (HTTP ${response.status})` };
|
|
49
|
+
return { recorded: true, tool: opts.tool, source };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { recorded: false, reason: "could not reach the hosted API (transport error)" };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=capture-record.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ActivityEvent } from "./activity-event.js";
|
|
2
|
+
import { type AvoidableContextResult } from "./before-call.js";
|
|
3
|
+
/**
|
|
4
|
+
* The proven apply blocker for the Claude Code before-call surface (run-13). Recorded verbatim on every
|
|
5
|
+
* recommendation event's caveats so no reader upgrades a recommendation into an apply claim.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CLAUDE_CODE_BEFORE_CALL_APPLY_BLOCKER: string;
|
|
8
|
+
/** The parsed, content-free-by-caller fields of a UserPromptSubmit hook payload. */
|
|
9
|
+
export interface ClaudeCodePromptHookPayload {
|
|
10
|
+
/** The submitted user prompt text (analyzed IN-PROCESS; NEVER persisted). */
|
|
11
|
+
prompt: string;
|
|
12
|
+
/** The project working directory the hook ran in (activity is scoped under `<cwd>/.compaction`). */
|
|
13
|
+
cwd?: string;
|
|
14
|
+
/** The Claude Code session id (content-free identity), when present. */
|
|
15
|
+
sessionId?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parse the UserPromptSubmit hook JSON (from stdin). FAIL-CLOSED: returns `null` on any problem (not
|
|
19
|
+
* JSON, not an object, wrong `hook_event_name`, or no string `prompt`) so a malformed payload records
|
|
20
|
+
* nothing and can never break the fail-open hook. Only the fields we need are read; unknown keys ignored.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseUserPromptSubmitPayload(raw: string): ClaudeCodePromptHookPayload | null;
|
|
23
|
+
/**
|
|
24
|
+
* Build ONE content-free `claude_code` BEFORE-CALL RECOMMENDATION activity event from the tool-agnostic
|
|
25
|
+
* analysis `result`. RECOMMENDATION-ONLY in every field:
|
|
26
|
+
* - input is a LOCAL-ESTIMATE (chars/4) pre-call figure; output is unavailable-with-reason (no output
|
|
27
|
+
* exists before the call). Never provider-reported, never billing-confirmed, never a saving.
|
|
28
|
+
* - `approval_status: "not-required"` — nothing was applied, so nothing needed approval (and nothing
|
|
29
|
+
* COULD be approved: apply is a proven blocker on this surface).
|
|
30
|
+
* - `auto_apply.applied_automatically: false` (auto-apply is OFF, structurally).
|
|
31
|
+
* - `recovery.original_retained: true` — the original prompt ran UNCHANGED (never mutated).
|
|
32
|
+
* - `sync_status: "local-only"`.
|
|
33
|
+
* The apply blocker rides verbatim on the caveats. CONTENT-FREE: only counts/policy/labels/opaque ids.
|
|
34
|
+
*/
|
|
35
|
+
export declare function buildClaudeCodeBeforeCallEvent(result: AvoidableContextResult, opts?: {
|
|
36
|
+
sessionId?: string;
|
|
37
|
+
}): ActivityEvent;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code BEFORE-CALL recommendation core (PUBLIC CLI/SDK code — engine-free, ships in the npm
|
|
3
|
+
* package). Charter 2026-07-04-run-13 — the FIRST genuine before-call surface for Claude Code.
|
|
4
|
+
*
|
|
5
|
+
* Run-8 gave Claude Code always-on MEASUREMENT via the **Stop hook**, which fires at session END — it
|
|
6
|
+
* cannot observe a call before it happens. Run-13 uses a DIFFERENT, genuinely pre-call hook:
|
|
7
|
+
* **UserPromptSubmit**, which fires when the user submits a prompt, BEFORE Claude processes it
|
|
8
|
+
* (empirically verified against Claude Code 2.1.198: the payload carries `prompt`, `transcript_path`,
|
|
9
|
+
* `cwd`, `session_id`; exit 2 blocks the prompt before any model call).
|
|
10
|
+
*
|
|
11
|
+
* SCOPE = RECOMMENDATION-ONLY, and APPLY IS A PROVEN BLOCKER on this surface:
|
|
12
|
+
* - No Claude Code hook can make the model receive a REDUCED/compacted context. `UserPromptSubmit` can
|
|
13
|
+
* only ADD context (`additionalContext`) or BLOCK (exit 2); there is no `updatedPrompt`/context-reduce
|
|
14
|
+
* field on any hook (`PreToolUse` rewrites TOOL input only, not the conversation; `PreCompact` can only
|
|
15
|
+
* block). So Compaction cannot safely mutate what the model receives — apply is fail-closed.
|
|
16
|
+
* - Hooks are NON-INTERACTIVE (stdin carries the JSON payload; there is no TTY), so the ask-before-apply
|
|
17
|
+
* `[y/n/v]` UX that Codex's stdin path uses is impossible here. There is nothing to approve because
|
|
18
|
+
* nothing can be applied.
|
|
19
|
+
*
|
|
20
|
+
* This module therefore only OBSERVES the pending prompt (in-process, content-free), runs the SAME
|
|
21
|
+
* deterministic duplicate-context detector, and — when avoidable context is present — records ONE
|
|
22
|
+
* metrics-only `claude_code` activity event surfacing the recommendation. The original prompt ALWAYS
|
|
23
|
+
* runs UNCHANGED. This is NOT the Stop-hook path and makes NO before-call claim on the Stop hook.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { createUsageMetadata } from "./usage-metadata.js";
|
|
27
|
+
import { buildRunFlowTokenReport } from "./run-flow-report.js";
|
|
28
|
+
import { buildRunCrossSurfaceEvent } from "./cross-surface-event.js";
|
|
29
|
+
import { computeActivityEventId } from "./activity-event.js";
|
|
30
|
+
import { BEFORE_CALL_REDUCTION_LABEL } from "./before-call.js";
|
|
31
|
+
/**
|
|
32
|
+
* The proven apply blocker for the Claude Code before-call surface (run-13). Recorded verbatim on every
|
|
33
|
+
* recommendation event's caveats so no reader upgrades a recommendation into an apply claim.
|
|
34
|
+
*/
|
|
35
|
+
export const CLAUDE_CODE_BEFORE_CALL_APPLY_BLOCKER = "before-call APPLY is UNAVAILABLE on Claude Code hooks: no hook can make the model receive reduced " +
|
|
36
|
+
"context (UserPromptSubmit can only ADD context or BLOCK — there is no updatedPrompt/context-reduce " +
|
|
37
|
+
"field; PreToolUse rewrites TOOL input only; PreCompact can only block), and hooks are non-interactive " +
|
|
38
|
+
"so ask-before-apply is impossible. This is RECOMMENDATION-ONLY; the original prompt runs UNCHANGED.";
|
|
39
|
+
/** The honest reason the OUTPUT axis is unavailable at pre-call time on the Claude Code surface. */
|
|
40
|
+
const OUTPUT_UNAVAILABLE_REASON = "no output tokens exist before the call; Claude Code reports usage only after the model responds";
|
|
41
|
+
/**
|
|
42
|
+
* Parse the UserPromptSubmit hook JSON (from stdin). FAIL-CLOSED: returns `null` on any problem (not
|
|
43
|
+
* JSON, not an object, wrong `hook_event_name`, or no string `prompt`) so a malformed payload records
|
|
44
|
+
* nothing and can never break the fail-open hook. Only the fields we need are read; unknown keys ignored.
|
|
45
|
+
*/
|
|
46
|
+
export function parseUserPromptSubmitPayload(raw) {
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(raw);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (!parsed || typeof parsed !== "object")
|
|
55
|
+
return null;
|
|
56
|
+
const p = parsed;
|
|
57
|
+
// If a hook_event_name is present it MUST be UserPromptSubmit (defensive against a mis-wired hook).
|
|
58
|
+
if (p.hook_event_name !== undefined && p.hook_event_name !== "UserPromptSubmit")
|
|
59
|
+
return null;
|
|
60
|
+
if (typeof p.prompt !== "string")
|
|
61
|
+
return null;
|
|
62
|
+
return {
|
|
63
|
+
prompt: p.prompt,
|
|
64
|
+
...(typeof p.cwd === "string" ? { cwd: p.cwd } : {}),
|
|
65
|
+
...(typeof p.session_id === "string" ? { sessionId: p.session_id } : {})
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** A content-free, deterministic run id from the local-estimate counts (never any content). */
|
|
69
|
+
function contentFreeRunId(before, after) {
|
|
70
|
+
const digest = createHash("sha256").update(`before-call|claude_code|${before}|${after}`).digest("hex");
|
|
71
|
+
return `before-call-claude_code-${digest.slice(0, 16)}`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Build ONE content-free `claude_code` BEFORE-CALL RECOMMENDATION activity event from the tool-agnostic
|
|
75
|
+
* analysis `result`. RECOMMENDATION-ONLY in every field:
|
|
76
|
+
* - input is a LOCAL-ESTIMATE (chars/4) pre-call figure; output is unavailable-with-reason (no output
|
|
77
|
+
* exists before the call). Never provider-reported, never billing-confirmed, never a saving.
|
|
78
|
+
* - `approval_status: "not-required"` — nothing was applied, so nothing needed approval (and nothing
|
|
79
|
+
* COULD be approved: apply is a proven blocker on this surface).
|
|
80
|
+
* - `auto_apply.applied_automatically: false` (auto-apply is OFF, structurally).
|
|
81
|
+
* - `recovery.original_retained: true` — the original prompt ran UNCHANGED (never mutated).
|
|
82
|
+
* - `sync_status: "local-only"`.
|
|
83
|
+
* The apply blocker rides verbatim on the caveats. CONTENT-FREE: only counts/policy/labels/opaque ids.
|
|
84
|
+
*/
|
|
85
|
+
export function buildClaudeCodeBeforeCallEvent(result, opts = {}) {
|
|
86
|
+
const usage = createUsageMetadata({
|
|
87
|
+
inputTokens: result.input_tokens_before,
|
|
88
|
+
providerReportedTokens: false,
|
|
89
|
+
estimatedTokens: true,
|
|
90
|
+
limitations: [BEFORE_CALL_REDUCTION_LABEL]
|
|
91
|
+
});
|
|
92
|
+
const tokenReport = buildRunFlowTokenReport({ tool: "claude-code", usage, outputStatus: "unavailable" });
|
|
93
|
+
const base = buildRunCrossSurfaceEvent("claude_code", {
|
|
94
|
+
runId: contentFreeRunId(result.input_tokens_before, result.input_tokens_after_estimate),
|
|
95
|
+
tokenReport,
|
|
96
|
+
reasons: { output: OUTPUT_UNAVAILABLE_REASON }
|
|
97
|
+
});
|
|
98
|
+
const withSession = opts.sessionId ? { ...base, session_id: opts.sessionId } : base;
|
|
99
|
+
const activity = {
|
|
100
|
+
...withSession,
|
|
101
|
+
// The compacted local-estimate is the pre-call INPUT AFTER figure — a recommended (un-applied)
|
|
102
|
+
// local-estimate delta, never a saving, never provider-reported.
|
|
103
|
+
input_after: result.input_tokens_after_estimate,
|
|
104
|
+
policy_used: result.policy,
|
|
105
|
+
evidence_level: result.evidence_label,
|
|
106
|
+
caveats: [
|
|
107
|
+
...(base.caveats ?? []),
|
|
108
|
+
"before-call RECOMMENDATION-ONLY (Claude Code UserPromptSubmit hook, a genuine PRE-call event — " +
|
|
109
|
+
"NOT the post-session Stop hook): the original prompt runs UNCHANGED; nothing was applied",
|
|
110
|
+
CLAUDE_CODE_BEFORE_CALL_APPLY_BLOCKER,
|
|
111
|
+
"local-estimate pre-call delta (chars/4), NOT a saving / NOT provider-reported / NOT billing-confirmed"
|
|
112
|
+
],
|
|
113
|
+
approval_status: "not-required",
|
|
114
|
+
auto_apply: { eligible: result.has_avoidable_context, preference: "ask-each-time", applied_automatically: false },
|
|
115
|
+
recovery: { original_retained: true },
|
|
116
|
+
sync_status: "local-only"
|
|
117
|
+
};
|
|
118
|
+
return { ...activity, activity_event_id: computeActivityEventId(activity) };
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=claude-code-before-call.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ClaudeSettings } from "./claude-code-hooks.js";
|
|
2
|
+
/** The outcome of a connect attempt. `verified` is the ONLY thing a "connected" claim may rest on. */
|
|
3
|
+
export type ConnectClaudeCodeStatus = "installed" | "already-present" | "dry-run" | "verify-failed" | "error";
|
|
4
|
+
export interface ConnectClaudeCodeResult {
|
|
5
|
+
status: ConnectClaudeCodeStatus;
|
|
6
|
+
/** The settings file targeted (project `.claude/settings.json` by default, or `~/.claude/...`). */
|
|
7
|
+
settingsPath: string;
|
|
8
|
+
/** Whether the settings file already existed before this attempt. */
|
|
9
|
+
existed: boolean;
|
|
10
|
+
/** TRUE only when a re-read of the file confirms Compaction's Stop hook is present. */
|
|
11
|
+
verified: boolean;
|
|
12
|
+
/** The exact hook command that was (or would be) installed. */
|
|
13
|
+
hookCommand: string;
|
|
14
|
+
/** Present on `error`: the honest reason the install was refused (nothing was written). */
|
|
15
|
+
error?: string;
|
|
16
|
+
/** Present on `dry-run`: the settings that WOULD be written (nothing was written). */
|
|
17
|
+
wouldWrite?: ClaudeSettings;
|
|
18
|
+
}
|
|
19
|
+
export interface ConnectClaudeCodeOptions {
|
|
20
|
+
/** The settings file to install into. */
|
|
21
|
+
settingsPath: string;
|
|
22
|
+
/** Preview only: compute + report the merge, write NOTHING. */
|
|
23
|
+
dryRun?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Install (or confirm) Compaction's Claude Code Stop hook in `settingsPath`, then VERIFY by
|
|
27
|
+
* re-reading the file. Returns a structured result; NEVER throws for the malformed-settings case
|
|
28
|
+
* (that is reported as `status: "error"` with nothing written) so the connect UX stays a clean
|
|
29
|
+
* one-liner. The only status the caller may present as "connected" is one with `verified: true`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function connectClaudeCodeHook(options: ConnectClaudeCodeOptions): Promise<ConnectClaudeCodeResult>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connect-once installer for the Claude Code Stop hook (PUBLIC CLI/SDK code, engine-free).
|
|
3
|
+
*
|
|
4
|
+
* `compaction init --connect 1` uses this to ACTUALLY install the consented Stop hook (reusing the
|
|
5
|
+
* same pure `installStopHook` merge that `compaction hooks install` uses) and then VERIFY the write
|
|
6
|
+
* landed by RE-READING the settings file and confirming Compaction's hook entry is present. The
|
|
7
|
+
* connect flow may only claim "connected" when `verified` is true — a failed or unverified install
|
|
8
|
+
* is reported honestly with the one exact command to run manually, never as success.
|
|
9
|
+
*
|
|
10
|
+
* Consent + safety (identical posture to `hooks install`): running the connect command IS the
|
|
11
|
+
* consent; the merge NEVER replaces existing settings and is idempotent; `--dry-run` writes nothing;
|
|
12
|
+
* a malformed settings file is refused (left untouched), not clobbered. Local file I/O only — no
|
|
13
|
+
* network, no new dependency.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { CLAUDE_CODE_HOOK_COMMAND, hasCompactionStopHook, installStopHook } from "./claude-code-hooks.js";
|
|
18
|
+
async function readSettings(file) {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await readFile(file, "utf8");
|
|
21
|
+
const parsed = JSON.parse(raw);
|
|
22
|
+
return { settings: parsed && typeof parsed === "object" ? parsed : {}, existed: true };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { settings: {}, existed: false };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function writeSettings(file, settings) {
|
|
29
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
30
|
+
await writeFile(file, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Install (or confirm) Compaction's Claude Code Stop hook in `settingsPath`, then VERIFY by
|
|
34
|
+
* re-reading the file. Returns a structured result; NEVER throws for the malformed-settings case
|
|
35
|
+
* (that is reported as `status: "error"` with nothing written) so the connect UX stays a clean
|
|
36
|
+
* one-liner. The only status the caller may present as "connected" is one with `verified: true`.
|
|
37
|
+
*/
|
|
38
|
+
export async function connectClaudeCodeHook(options) {
|
|
39
|
+
const { settingsPath, dryRun = false } = options;
|
|
40
|
+
const { settings, existed } = await readSettings(settingsPath);
|
|
41
|
+
let merged;
|
|
42
|
+
try {
|
|
43
|
+
merged = installStopHook(settings);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return {
|
|
47
|
+
status: "error",
|
|
48
|
+
settingsPath,
|
|
49
|
+
existed,
|
|
50
|
+
verified: false,
|
|
51
|
+
hookCommand: CLAUDE_CODE_HOOK_COMMAND,
|
|
52
|
+
error: error instanceof Error ? error.message : String(error)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (merged.alreadyPresent) {
|
|
56
|
+
return {
|
|
57
|
+
status: "already-present",
|
|
58
|
+
settingsPath,
|
|
59
|
+
existed,
|
|
60
|
+
verified: true, // the hook is present in the file we just read
|
|
61
|
+
hookCommand: CLAUDE_CODE_HOOK_COMMAND
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (dryRun) {
|
|
65
|
+
return {
|
|
66
|
+
status: "dry-run",
|
|
67
|
+
settingsPath,
|
|
68
|
+
existed,
|
|
69
|
+
verified: false,
|
|
70
|
+
hookCommand: CLAUDE_CODE_HOOK_COMMAND,
|
|
71
|
+
wouldWrite: merged.settings
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
await writeSettings(settingsPath, merged.settings);
|
|
75
|
+
// VERIFY: re-read from disk and confirm the entry actually landed. A write that did not take
|
|
76
|
+
// (odd filesystem, permissions, a racing writer) must NOT be reported as connected.
|
|
77
|
+
const reread = await readSettings(settingsPath);
|
|
78
|
+
const verified = reread.existed && hasCompactionStopHook(reread.settings);
|
|
79
|
+
return {
|
|
80
|
+
status: verified ? "installed" : "verify-failed",
|
|
81
|
+
settingsPath,
|
|
82
|
+
existed,
|
|
83
|
+
verified,
|
|
84
|
+
hookCommand: CLAUDE_CODE_HOOK_COMMAND
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=claude-code-connect.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { TokenSource } from "./api-client/index.js";
|
|
2
|
+
import type { UsageMetadata } from "./usage-metadata.js";
|
|
3
|
+
export declare const CLAUDE_CODE_HOOK_RECORD_SCHEMA: "compaction.claude-code-hook-record.v1";
|
|
4
|
+
export declare const CLAUDE_CODE_HOOK_LEDGER_SCHEMA: "compaction.claude-code-hook-ledger.v1";
|
|
5
|
+
/** The Stop hook payload shape (validated 2026-06-29). Only the fields we use are typed. */
|
|
6
|
+
export interface StopHookPayload {
|
|
7
|
+
session_id?: string;
|
|
8
|
+
transcript_path?: string;
|
|
9
|
+
hook_event_name?: string;
|
|
10
|
+
stop_hook_active?: boolean;
|
|
11
|
+
/** NEVER read or stored — listed only to document that it exists and is deliberately ignored. */
|
|
12
|
+
last_assistant_message?: unknown;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
/** Fail-safe parse of the Stop payload JSON from stdin. Returns null on any parse error (fail-open). */
|
|
16
|
+
export declare function parseStopPayload(raw: string): StopHookPayload | null;
|
|
17
|
+
/** The transcript path to parse, or null when the payload does not carry one (then the hook is a no-op). */
|
|
18
|
+
export declare function resolveTranscriptPath(payload: StopHookPayload | null): string | null;
|
|
19
|
+
export interface DedupKeyInput {
|
|
20
|
+
sessionId: string | undefined;
|
|
21
|
+
/** Content-free one-way digest of the normalized trace (preferred — stable per session-state). */
|
|
22
|
+
fingerprint: string | undefined;
|
|
23
|
+
messageCount: number;
|
|
24
|
+
inputTokens: number | null;
|
|
25
|
+
outputTokens: number | null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Deterministic, content-free dedup key. Prefers the trace fingerprint (a SHA-256 over normalized content
|
|
29
|
+
* that re-captures reproduce, so the SAME Stop/session state yields the SAME key). Falls back to a signature
|
|
30
|
+
* over message count + token totals when no fingerprint is available. Same state ⇒ same key ⇒ no double-record.
|
|
31
|
+
*/
|
|
32
|
+
export declare function computeDedupKey(input: DedupKeyInput): string;
|
|
33
|
+
export interface ClaudeCodeHookLedger {
|
|
34
|
+
schema: typeof CLAUDE_CODE_HOOK_LEDGER_SCHEMA;
|
|
35
|
+
entries: Array<{
|
|
36
|
+
dedupKey: string;
|
|
37
|
+
sessionId?: string;
|
|
38
|
+
recordedAt: string;
|
|
39
|
+
}>;
|
|
40
|
+
}
|
|
41
|
+
export declare function emptyLedger(): ClaudeCodeHookLedger;
|
|
42
|
+
export declare function isAlreadyRecorded(ledger: ClaudeCodeHookLedger, dedupKey: string): boolean;
|
|
43
|
+
/** Honest wire token source from usage (mirrors capture-record; kept local to stay a pure leaf). */
|
|
44
|
+
export declare function hookTokenSource(usage: UsageMetadata): TokenSource;
|
|
45
|
+
/** CONTENT-FREE record. Token counts + honest source + ids only; no message/prompt/completion content. */
|
|
46
|
+
export interface ClaudeCodeHookRecord {
|
|
47
|
+
schema: typeof CLAUDE_CODE_HOOK_RECORD_SCHEMA;
|
|
48
|
+
tool: "claude-code";
|
|
49
|
+
sessionId?: string;
|
|
50
|
+
dedupKey: string;
|
|
51
|
+
inputTokens: number | null;
|
|
52
|
+
outputTokens: number | null;
|
|
53
|
+
cacheReadInputTokens: number | null;
|
|
54
|
+
cacheCreationInputTokens: number | null;
|
|
55
|
+
totalTokens: number | null;
|
|
56
|
+
providerReported: boolean;
|
|
57
|
+
tokenSource: TokenSource;
|
|
58
|
+
provider?: string;
|
|
59
|
+
model?: string;
|
|
60
|
+
messageCount: number;
|
|
61
|
+
recordedAt: string;
|
|
62
|
+
}
|
|
63
|
+
export interface BuildHookRecordInput {
|
|
64
|
+
usage: UsageMetadata;
|
|
65
|
+
sessionId?: string;
|
|
66
|
+
messageCount: number;
|
|
67
|
+
dedupKey: string;
|
|
68
|
+
recordedAt: string;
|
|
69
|
+
}
|
|
70
|
+
/** Build the content-free record. Missing token counts stay null (never invented as 0). */
|
|
71
|
+
export declare function buildClaudeCodeHookRecord(input: BuildHookRecordInput): ClaudeCodeHookRecord;
|
|
Binary file
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code hook install/uninstall — PURE settings merge (PUBLIC CLI/SDK code, engine-free).
|
|
3
|
+
* Used by `compaction hooks install|uninstall` and the connect flow. All IO (read/write the settings
|
|
4
|
+
* file, stdin, dry-run printing) lives in the CLI; this module only transforms a parsed settings object
|
|
5
|
+
* so it is trivially testable: merge-not-replace, idempotent, and removal that touches ONLY Compaction's
|
|
6
|
+
* own hook entries.
|
|
7
|
+
*
|
|
8
|
+
* Two hooks, two events, two purposes:
|
|
9
|
+
* - **Stop** (`--from-hook`, run-8): MEASUREMENT. Fires at session END; parses the session JSONL via the
|
|
10
|
+
* Stop payload's `transcript_path` and records CONTENT-FREE usage.
|
|
11
|
+
* - **UserPromptSubmit** (`--from-prompt-hook`, run-13): the first genuine BEFORE-CALL surface. Fires
|
|
12
|
+
* before Claude processes the prompt; records a CONTENT-FREE before-call RECOMMENDATION (apply is a
|
|
13
|
+
* proven blocker on this surface — hooks cannot reduce the model's context and are non-interactive).
|
|
14
|
+
*
|
|
15
|
+
* Both hooks are fail-open by contract (they must never break Claude Code if Compaction fails).
|
|
16
|
+
*/
|
|
17
|
+
/** The command the **Stop** (measurement) hook runs. Session JSONL found via the Stop payload's transcript_path. */
|
|
18
|
+
export declare const CLAUDE_CODE_HOOK_COMMAND = "compaction capture claude-code --from-hook";
|
|
19
|
+
/**
|
|
20
|
+
* The command the **before-call** hook runs (charter run-13). Installed on the **UserPromptSubmit** event
|
|
21
|
+
* — a genuine PRE-call hook (fires before Claude processes the prompt), NOT the post-session Stop hook.
|
|
22
|
+
* NOTE: `--from-prompt-hook` does NOT substring-match `--from-hook`, so the two hooks are never confused.
|
|
23
|
+
*/
|
|
24
|
+
export declare const CLAUDE_CODE_BEFORE_CALL_HOOK_COMMAND = "compaction capture claude-code --from-prompt-hook";
|
|
25
|
+
/** The Claude Code hook event a Compaction hook installs on. */
|
|
26
|
+
export type ClaudeHookEvent = "Stop" | "UserPromptSubmit";
|
|
27
|
+
type HookGroup = {
|
|
28
|
+
matcher?: string;
|
|
29
|
+
hooks?: Array<{
|
|
30
|
+
type?: string;
|
|
31
|
+
command?: string;
|
|
32
|
+
}>;
|
|
33
|
+
};
|
|
34
|
+
/** A Claude Code settings object (only the shape we touch is typed; unknown keys are preserved as-is). */
|
|
35
|
+
export interface ClaudeSettings {
|
|
36
|
+
hooks?: {
|
|
37
|
+
Stop?: HookGroup[];
|
|
38
|
+
UserPromptSubmit?: HookGroup[];
|
|
39
|
+
[event: string]: unknown;
|
|
40
|
+
};
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
/** True when a hook command is Compaction's own Claude Code **Stop** (measurement) hook. */
|
|
44
|
+
export declare function isCompactionHookCommand(command: string | undefined): boolean;
|
|
45
|
+
/** True when a hook command is Compaction's own Claude Code **before-call** (UserPromptSubmit) hook. */
|
|
46
|
+
export declare function isCompactionBeforeCallHookCommand(command: string | undefined): boolean;
|
|
47
|
+
/** True when the settings object already contains Compaction's own **Stop** hook. */
|
|
48
|
+
export declare function hasCompactionStopHook(settings: ClaudeSettings): boolean;
|
|
49
|
+
/** True when the settings object already contains Compaction's own **before-call** hook. */
|
|
50
|
+
export declare function hasCompactionBeforeCallHook(settings: ClaudeSettings): boolean;
|
|
51
|
+
export interface InstallResult {
|
|
52
|
+
settings: ClaudeSettings;
|
|
53
|
+
/** True when the settings changed (the hook was newly added). */
|
|
54
|
+
changed: boolean;
|
|
55
|
+
/** True when Compaction's hook was already present (idempotent no-op). */
|
|
56
|
+
alreadyPresent: boolean;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* MERGE Compaction's **Stop** (measurement) hook into the settings. Preserves everything; idempotent.
|
|
60
|
+
*/
|
|
61
|
+
export declare function installStopHook(input: ClaudeSettings, command?: string): InstallResult;
|
|
62
|
+
/**
|
|
63
|
+
* MERGE Compaction's **before-call** hook onto the UserPromptSubmit event. Preserves everything;
|
|
64
|
+
* idempotent. This is a genuine PRE-call hook — it never touches the Stop (measurement) hook.
|
|
65
|
+
*/
|
|
66
|
+
export declare function installBeforeCallHook(input: ClaudeSettings, command?: string): InstallResult;
|
|
67
|
+
export interface UninstallResult {
|
|
68
|
+
settings: ClaudeSettings;
|
|
69
|
+
changed: boolean;
|
|
70
|
+
/** Number of Compaction hook entries removed. */
|
|
71
|
+
removedCount: number;
|
|
72
|
+
}
|
|
73
|
+
/** Remove ONLY Compaction's **Stop** hook entries; preserve all other hooks and settings. */
|
|
74
|
+
export declare function uninstallStopHook(input: ClaudeSettings): UninstallResult;
|
|
75
|
+
/** Remove ONLY Compaction's **before-call** (UserPromptSubmit) hook entries; preserve everything else. */
|
|
76
|
+
export declare function uninstallBeforeCallHook(input: ClaudeSettings): UninstallResult;
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code hook install/uninstall — PURE settings merge (PUBLIC CLI/SDK code, engine-free).
|
|
3
|
+
* Used by `compaction hooks install|uninstall` and the connect flow. All IO (read/write the settings
|
|
4
|
+
* file, stdin, dry-run printing) lives in the CLI; this module only transforms a parsed settings object
|
|
5
|
+
* so it is trivially testable: merge-not-replace, idempotent, and removal that touches ONLY Compaction's
|
|
6
|
+
* own hook entries.
|
|
7
|
+
*
|
|
8
|
+
* Two hooks, two events, two purposes:
|
|
9
|
+
* - **Stop** (`--from-hook`, run-8): MEASUREMENT. Fires at session END; parses the session JSONL via the
|
|
10
|
+
* Stop payload's `transcript_path` and records CONTENT-FREE usage.
|
|
11
|
+
* - **UserPromptSubmit** (`--from-prompt-hook`, run-13): the first genuine BEFORE-CALL surface. Fires
|
|
12
|
+
* before Claude processes the prompt; records a CONTENT-FREE before-call RECOMMENDATION (apply is a
|
|
13
|
+
* proven blocker on this surface — hooks cannot reduce the model's context and are non-interactive).
|
|
14
|
+
*
|
|
15
|
+
* Both hooks are fail-open by contract (they must never break Claude Code if Compaction fails).
|
|
16
|
+
*/
|
|
17
|
+
/** The command the **Stop** (measurement) hook runs. Session JSONL found via the Stop payload's transcript_path. */
|
|
18
|
+
export const CLAUDE_CODE_HOOK_COMMAND = "compaction capture claude-code --from-hook";
|
|
19
|
+
/**
|
|
20
|
+
* The command the **before-call** hook runs (charter run-13). Installed on the **UserPromptSubmit** event
|
|
21
|
+
* — a genuine PRE-call hook (fires before Claude processes the prompt), NOT the post-session Stop hook.
|
|
22
|
+
* NOTE: `--from-prompt-hook` does NOT substring-match `--from-hook`, so the two hooks are never confused.
|
|
23
|
+
*/
|
|
24
|
+
export const CLAUDE_CODE_BEFORE_CALL_HOOK_COMMAND = "compaction capture claude-code --from-prompt-hook";
|
|
25
|
+
/** True when a hook command is Compaction's own Claude Code **Stop** (measurement) hook. */
|
|
26
|
+
export function isCompactionHookCommand(command) {
|
|
27
|
+
if (typeof command !== "string")
|
|
28
|
+
return false;
|
|
29
|
+
// Require all three so a third-party command that merely mentions "capture claude-code --from-hook" is not
|
|
30
|
+
// mistaken for ours (uninstall removes ONLY our own hook). `--from-hook` is NOT a substring of
|
|
31
|
+
// `--from-prompt-hook`, so this never matches the before-call hook.
|
|
32
|
+
return command.includes("compaction") && command.includes("capture claude-code") && command.includes("--from-hook");
|
|
33
|
+
}
|
|
34
|
+
/** True when a hook command is Compaction's own Claude Code **before-call** (UserPromptSubmit) hook. */
|
|
35
|
+
export function isCompactionBeforeCallHookCommand(command) {
|
|
36
|
+
if (typeof command !== "string")
|
|
37
|
+
return false;
|
|
38
|
+
return command.includes("compaction") && command.includes("capture claude-code") && command.includes("--from-prompt-hook");
|
|
39
|
+
}
|
|
40
|
+
/** True when `settings` already contains a Compaction hook (matched by `isOurs`) on `event`. Pure read. */
|
|
41
|
+
function hasCompactionHookOnEvent(settings, event, isOurs) {
|
|
42
|
+
const groups = settings.hooks?.[event];
|
|
43
|
+
if (!Array.isArray(groups))
|
|
44
|
+
return false;
|
|
45
|
+
return groups.some((group) => (group.hooks ?? []).some((h) => isOurs(h.command)));
|
|
46
|
+
}
|
|
47
|
+
/** True when the settings object already contains Compaction's own **Stop** hook. */
|
|
48
|
+
export function hasCompactionStopHook(settings) {
|
|
49
|
+
return hasCompactionHookOnEvent(settings, "Stop", isCompactionHookCommand);
|
|
50
|
+
}
|
|
51
|
+
/** True when the settings object already contains Compaction's own **before-call** hook. */
|
|
52
|
+
export function hasCompactionBeforeCallHook(settings) {
|
|
53
|
+
return hasCompactionHookOnEvent(settings, "UserPromptSubmit", isCompactionBeforeCallHookCommand);
|
|
54
|
+
}
|
|
55
|
+
function clone(value) {
|
|
56
|
+
return JSON.parse(JSON.stringify(value));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* MERGE (never replace) a Compaction hook (identified by `isOurs`) onto `event` with `command`. Preserves
|
|
60
|
+
* all existing keys, hooks, and other entries. Idempotent: if our hook is already present, returns the
|
|
61
|
+
* settings unchanged. Refuses (throws) rather than clobber a malformed `hooks` / `hooks[event]`.
|
|
62
|
+
*/
|
|
63
|
+
function installHookOnEvent(input, event, command, isOurs) {
|
|
64
|
+
const settings = clone(input);
|
|
65
|
+
if (settings.hooks !== undefined && (typeof settings.hooks !== "object" || settings.hooks === null || Array.isArray(settings.hooks))) {
|
|
66
|
+
throw new Error("existing settings.hooks is malformed (not an object) — refusing to modify; fix the settings file manually.");
|
|
67
|
+
}
|
|
68
|
+
const hooks = (settings.hooks ??= {});
|
|
69
|
+
if (hooks[event] !== undefined && !Array.isArray(hooks[event])) {
|
|
70
|
+
throw new Error(`existing settings.hooks.${event} is malformed (not an array) — refusing to modify; fix the settings file manually.`);
|
|
71
|
+
}
|
|
72
|
+
const groups = (hooks[event] ??= []);
|
|
73
|
+
const alreadyPresent = groups.some((group) => (group.hooks ?? []).some((h) => isOurs(h.command)));
|
|
74
|
+
if (alreadyPresent) {
|
|
75
|
+
return { settings: input, changed: false, alreadyPresent: true };
|
|
76
|
+
}
|
|
77
|
+
groups.push({ hooks: [{ type: "command", command }] });
|
|
78
|
+
return { settings, changed: true, alreadyPresent: false };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* MERGE Compaction's **Stop** (measurement) hook into the settings. Preserves everything; idempotent.
|
|
82
|
+
*/
|
|
83
|
+
export function installStopHook(input, command = CLAUDE_CODE_HOOK_COMMAND) {
|
|
84
|
+
return installHookOnEvent(input, "Stop", command, isCompactionHookCommand);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* MERGE Compaction's **before-call** hook onto the UserPromptSubmit event. Preserves everything;
|
|
88
|
+
* idempotent. This is a genuine PRE-call hook — it never touches the Stop (measurement) hook.
|
|
89
|
+
*/
|
|
90
|
+
export function installBeforeCallHook(input, command = CLAUDE_CODE_BEFORE_CALL_HOOK_COMMAND) {
|
|
91
|
+
return installHookOnEvent(input, "UserPromptSubmit", command, isCompactionBeforeCallHookCommand);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Remove ONLY Compaction's own hook entries (matched by `isOurs`) from `event`; preserve all other hooks
|
|
95
|
+
* and settings. Cleans up empty containers (an emptied group, an empty event array, an empty hooks
|
|
96
|
+
* object) so nothing dangling is left, but never touches another tool's hooks.
|
|
97
|
+
*/
|
|
98
|
+
function uninstallHookFromEvent(input, event, isOurs) {
|
|
99
|
+
const settings = clone(input);
|
|
100
|
+
const groups = settings.hooks?.[event];
|
|
101
|
+
if (!Array.isArray(groups)) {
|
|
102
|
+
return { settings: input, changed: false, removedCount: 0 };
|
|
103
|
+
}
|
|
104
|
+
let removedCount = 0;
|
|
105
|
+
const next = groups
|
|
106
|
+
.map((group) => {
|
|
107
|
+
const inner = group.hooks ?? [];
|
|
108
|
+
const kept = inner.filter((h) => {
|
|
109
|
+
const drop = isOurs(h.command);
|
|
110
|
+
if (drop)
|
|
111
|
+
removedCount += 1;
|
|
112
|
+
return !drop;
|
|
113
|
+
});
|
|
114
|
+
return { group, kept };
|
|
115
|
+
})
|
|
116
|
+
// drop groups that became empty ONLY because we removed our hook (preserve groups that were already empty/other)
|
|
117
|
+
.filter(({ group, kept }) => kept.length > 0 || (group.hooks ?? []).length === 0)
|
|
118
|
+
.map(({ group, kept }) => (group.hooks ? { ...group, hooks: kept } : group));
|
|
119
|
+
if (removedCount === 0) {
|
|
120
|
+
return { settings: input, changed: false, removedCount: 0 };
|
|
121
|
+
}
|
|
122
|
+
if (next.length === 0) {
|
|
123
|
+
delete settings.hooks[event];
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
settings.hooks[event] = next;
|
|
127
|
+
}
|
|
128
|
+
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
|
129
|
+
delete settings.hooks;
|
|
130
|
+
}
|
|
131
|
+
return { settings, changed: true, removedCount };
|
|
132
|
+
}
|
|
133
|
+
/** Remove ONLY Compaction's **Stop** hook entries; preserve all other hooks and settings. */
|
|
134
|
+
export function uninstallStopHook(input) {
|
|
135
|
+
return uninstallHookFromEvent(input, "Stop", isCompactionHookCommand);
|
|
136
|
+
}
|
|
137
|
+
/** Remove ONLY Compaction's **before-call** (UserPromptSubmit) hook entries; preserve everything else. */
|
|
138
|
+
export function uninstallBeforeCallHook(input) {
|
|
139
|
+
return uninstallHookFromEvent(input, "UserPromptSubmit", isCompactionBeforeCallHookCommand);
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=claude-code-hooks.js.map
|