@compaction/cli 0.2.0 → 0.3.0

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 (153) hide show
  1. package/README.md +41 -12
  2. package/dist/cli/commands/activity.d.ts +8 -0
  3. package/dist/cli/commands/activity.js +35 -0
  4. package/dist/cli/commands/apply-context.js +68 -0
  5. package/dist/cli/commands/billing-delta.js +12 -1
  6. package/dist/cli/commands/capture-claude-code.d.ts +44 -0
  7. package/dist/cli/commands/capture-claude-code.js +206 -0
  8. package/dist/cli/commands/capture.js +283 -1
  9. package/dist/cli/commands/compact.js +9 -5
  10. package/dist/cli/commands/dev.d.ts +17 -0
  11. package/dist/cli/commands/dev.js +131 -0
  12. package/dist/cli/commands/gateway.d.ts +31 -0
  13. package/dist/cli/commands/gateway.js +312 -0
  14. package/dist/cli/commands/hooks.d.ts +5 -0
  15. package/dist/cli/commands/hooks.js +181 -0
  16. package/dist/cli/commands/init.js +422 -17
  17. package/dist/cli/commands/input-compaction-ab.d.ts +2 -0
  18. package/dist/cli/commands/input-compaction-ab.js +125 -0
  19. package/dist/cli/commands/optimize-hosted.d.ts +54 -0
  20. package/dist/cli/commands/optimize-hosted.js +123 -0
  21. package/dist/cli/commands/optimize.js +8 -0
  22. package/dist/cli/commands/output-shaping-ab.d.ts +2 -0
  23. package/dist/cli/commands/output-shaping-ab.js +132 -0
  24. package/dist/cli/commands/output-shaping.d.ts +7 -0
  25. package/dist/cli/commands/output-shaping.js +43 -0
  26. package/dist/cli/commands/policies.d.ts +2 -0
  27. package/dist/cli/commands/policies.js +80 -0
  28. package/dist/cli/commands/precall.d.ts +19 -0
  29. package/dist/cli/commands/precall.js +436 -0
  30. package/dist/cli/commands/recommend.js +1 -1
  31. package/dist/cli/commands/run.js +297 -12
  32. package/dist/cli/commands/upgrade-status.d.ts +42 -0
  33. package/dist/cli/commands/upgrade-status.js +152 -0
  34. package/dist/cli/cursor-export-read.d.ts +13 -0
  35. package/dist/cli/cursor-export-read.js +53 -0
  36. package/dist/cli/cursor-live-preflight.d.ts +19 -0
  37. package/dist/cli/cursor-live-preflight.js +46 -0
  38. package/dist/cli/index.js +36 -2
  39. package/dist/cli/onboarding/GatewayTui.d.ts +33 -0
  40. package/dist/cli/onboarding/GatewayTui.js +113 -0
  41. package/dist/cli/onboarding/model.d.ts +32 -1
  42. package/dist/cli/onboarding/model.js +27 -2
  43. package/dist/cli/terminal-logo.d.ts +27 -0
  44. package/dist/cli/terminal-logo.js +49 -0
  45. package/dist/core/activity-event.d.ts +107 -0
  46. package/dist/core/activity-event.js +182 -0
  47. package/dist/core/activity-store.d.ts +63 -0
  48. package/dist/core/activity-store.js +254 -0
  49. package/dist/core/activity-view.d.ts +76 -0
  50. package/dist/core/activity-view.js +120 -0
  51. package/dist/core/api-client/index.d.ts +3 -1
  52. package/dist/core/api-client/index.js +4 -0
  53. package/dist/core/api-client/payload.d.ts +5 -1
  54. package/dist/core/api-client/payload.js +8 -6
  55. package/dist/core/api-client/persisted-config.d.ts +57 -0
  56. package/dist/core/api-client/persisted-config.js +156 -0
  57. package/dist/core/api-client/tool.d.ts +29 -0
  58. package/dist/core/api-client/tool.js +50 -0
  59. package/dist/core/api-client/types.d.ts +15 -4
  60. package/dist/core/auto-apply-ask.d.ts +58 -0
  61. package/dist/core/auto-apply-ask.js +105 -0
  62. package/dist/core/auto-apply-gates.d.ts +76 -0
  63. package/dist/core/auto-apply-gates.js +113 -0
  64. package/dist/core/before-call-activity.d.ts +35 -0
  65. package/dist/core/before-call-activity.js +103 -0
  66. package/dist/core/before-call-recovery.d.ts +19 -0
  67. package/dist/core/before-call-recovery.js +46 -0
  68. package/dist/core/before-call-stdin.d.ts +59 -0
  69. package/dist/core/before-call-stdin.js +78 -0
  70. package/dist/core/before-call.d.ts +149 -0
  71. package/dist/core/before-call.js +358 -0
  72. package/dist/core/billing-delta/billing-delta-record.d.ts +28 -0
  73. package/dist/core/billing-delta/billing-delta-record.js +26 -0
  74. package/dist/core/capture-record.d.ts +40 -0
  75. package/dist/core/capture-record.js +55 -0
  76. package/dist/core/claude-code-before-call.d.ts +37 -0
  77. package/dist/core/claude-code-before-call.js +120 -0
  78. package/dist/core/claude-code-connect.d.ts +31 -0
  79. package/dist/core/claude-code-connect.js +87 -0
  80. package/dist/core/claude-code-hook-record.d.ts +71 -0
  81. package/dist/core/claude-code-hook-record.js +0 -0
  82. package/dist/core/claude-code-hooks.d.ts +77 -0
  83. package/dist/core/claude-code-hooks.js +141 -0
  84. package/dist/core/codex-capture.d.ts +45 -0
  85. package/dist/core/codex-capture.js +204 -0
  86. package/dist/core/command-runner.js +5 -1
  87. package/dist/core/compaction-artifacts.d.ts +51 -0
  88. package/dist/core/compaction-artifacts.js +242 -0
  89. package/dist/core/compactor.d.ts +9 -0
  90. package/dist/core/compactor.js +95 -0
  91. package/dist/core/cross-surface-event.d.ts +306 -0
  92. package/dist/core/cross-surface-event.js +330 -0
  93. package/dist/core/cursor-capture.d.ts +54 -0
  94. package/dist/core/cursor-capture.js +215 -0
  95. package/dist/core/cursor-preflight-probe.d.ts +8 -0
  96. package/dist/core/cursor-preflight-probe.js +88 -0
  97. package/dist/core/cursor-preflight.d.ts +86 -0
  98. package/dist/core/cursor-preflight.js +126 -0
  99. package/dist/core/gateway/apply-activation.d.ts +39 -0
  100. package/dist/core/gateway/apply-activation.js +84 -0
  101. package/dist/core/gateway/apply-policy.d.ts +64 -0
  102. package/dist/core/gateway/apply-policy.js +221 -0
  103. package/dist/core/gateway/apply-receipt.d.ts +36 -0
  104. package/dist/core/gateway/apply-receipt.js +75 -0
  105. package/dist/core/gateway/cache-proof.d.ts +45 -0
  106. package/dist/core/gateway/cache-proof.js +65 -0
  107. package/dist/core/gateway/configure.d.ts +50 -0
  108. package/dist/core/gateway/configure.js +169 -0
  109. package/dist/core/gateway/openai-usage.d.ts +56 -0
  110. package/dist/core/gateway/openai-usage.js +128 -0
  111. package/dist/core/gateway/receipt.d.ts +138 -0
  112. package/dist/core/gateway/receipt.js +120 -0
  113. package/dist/core/gateway/recovery.d.ts +23 -0
  114. package/dist/core/gateway/recovery.js +68 -0
  115. package/dist/core/gateway/server.d.ts +51 -0
  116. package/dist/core/gateway/server.js +276 -0
  117. package/dist/core/gateway/status.d.ts +45 -0
  118. package/dist/core/gateway/status.js +109 -0
  119. package/dist/core/hook-usage-aggregate.d.ts +47 -0
  120. package/dist/core/hook-usage-aggregate.js +161 -0
  121. package/dist/core/input-compaction-ab.d.ts +111 -0
  122. package/dist/core/input-compaction-ab.js +158 -0
  123. package/dist/core/local-run-record.d.ts +109 -0
  124. package/dist/core/local-run-record.js +223 -0
  125. package/dist/core/output-shaping-ab.d.ts +140 -0
  126. package/dist/core/output-shaping-ab.js +146 -0
  127. package/dist/core/output-shaping-attach.d.ts +31 -0
  128. package/dist/core/output-shaping-attach.js +57 -0
  129. package/dist/core/output-shaping.d.ts +56 -0
  130. package/dist/core/output-shaping.js +89 -0
  131. package/dist/core/policy-middleware.d.ts +121 -0
  132. package/dist/core/policy-middleware.js +919 -0
  133. package/dist/core/policy-preferences.d.ts +99 -0
  134. package/dist/core/policy-preferences.js +232 -0
  135. package/dist/core/run-aggregator.d.ts +11 -1
  136. package/dist/core/run-aggregator.js +29 -2
  137. package/dist/core/run-flow-report.d.ts +82 -0
  138. package/dist/core/run-flow-report.js +71 -0
  139. package/dist/core/safety-report.js +8 -1
  140. package/dist/core/shim-capture-bridge.d.ts +32 -0
  141. package/dist/core/shim-capture-bridge.js +88 -0
  142. package/dist/core/skill-injection-policy.d.ts +72 -0
  143. package/dist/core/skill-injection-policy.js +183 -0
  144. package/dist/core/spend-attribution.js +1 -1
  145. package/dist/core/token-accounting.d.ts +1 -1
  146. package/dist/core/tool-shim.d.ts +129 -0
  147. package/dist/core/tool-shim.js +447 -0
  148. package/dist/core/trace-parser.d.ts +13 -13
  149. package/dist/core/trace-parser.js +6 -6
  150. package/dist/core/types.d.ts +12 -2
  151. package/dist/core/waste-detector.d.ts +20 -0
  152. package/dist/core/waste-detector.js +160 -6
  153. package/package.json +1 -1
package/README.md CHANGED
@@ -5,9 +5,11 @@ supported agent sessions on your own machine, shows input/output tokens and esti
5
5
  with honest labels, and writes local reports and redacted share bundles. Everything runs
6
6
  locally and writes local files only — **no account, no telemetry, no upload by default.**
7
7
 
8
- > **The free CLI captures, measures, inspects, reports, and redacts.** Optimization, advanced
9
- > verification, apply guidance, policies, and team reporting are **not** in the free CLI — they
10
- > are the **opt-in Compaction API**, which is **not a live hosted service yet**.
8
+ > **The free CLI captures, measures, inspects, reports, redacts — and performs deterministic
9
+ > local compaction with the basic policies (`compact`), writing honest before/after artifacts.**
10
+ > Advanced verification, recommendation and apply guidance, advanced policies, and team
11
+ > reporting are **not** in the free CLI — they are the **opt-in Compaction API**, which is
12
+ > **not a live hosted service yet**.
11
13
 
12
14
  ## What the CLI does
13
15
 
@@ -22,6 +24,11 @@ Everything below runs on your machine and writes local files only:
22
24
  run carries provider usage metadata; otherwise they are a **local estimate (chars/4)**. Cost is
23
25
  a **price-table estimate** labeled `local estimate` / `token-estimated cost` — **never
24
26
  billing-confirmed**.
27
+ - **Compact deterministically, locally.** `compact` applies the basic deterministic policies
28
+ (e.g. `stale_tool_output_to_state_capsule`) to a captured/imported trace and writes honest
29
+ before/after artifacts locally (report, safety/recoverability report, state capsules). The
30
+ original trace is retained, token figures stay **local estimates** unless provider-reported,
31
+ and **nothing is applied to your workflow automatically**.
25
32
  - **Inspect spend and roll up runs.** `spend`, `summary`, and `aggregate` summarize where context
26
33
  spend came from and roll up local runs — local files only, no hosted dashboard.
27
34
  - **Build a local context store and retrieve from it.** `context add` accumulates source-pointed
@@ -48,10 +55,11 @@ and roughly what does it cost?"* — entirely locally, with honest labels.
48
55
 
49
56
  These are **not** part of the free local CLI:
50
57
 
51
- - **Optimization** — producing a compacted context from a captured trace.
52
- - **Advanced verification** — stronger checks on what a compacted context retains.
58
+ - **Recommendation** — a recommended-policy verdict (`recommend`) for a captured trace.
59
+ - **Advanced verification** — stronger checks on what a compacted context retains (incl. `compact --eval`).
53
60
  - **Apply guidance** — a readiness verdict and a review/approval path before any context is applied.
54
- - **Policies & team reporting** — configurable behavior controls and cross-run / team reporting.
61
+ - **Advanced policies & team reporting** — configurable behavior controls beyond the basic
62
+ deterministic policies, and cross-run / team reporting.
55
63
 
56
64
  > **The Compaction API is not broadly hosted yet.** There is **no live hosted service today**.
57
65
  > Until the API is available, these capabilities are **on the roadmap / opt-in**, not present in
@@ -74,7 +82,7 @@ npx @compaction/cli --help # no install at all
74
82
  ```
75
83
 
76
84
  The installed binary is `compaction`. After a global install, sanity-check with
77
- `compaction --version` (→ `0.2.0`) and `compaction --help`.
85
+ `compaction --version` (→ `0.3.0`) and `compaction --help`.
78
86
 
79
87
  ### curl one-liner
80
88
 
@@ -109,8 +117,27 @@ compaction spend ./my-trace/captured-trace.json
109
117
  compaction feedback --redact --yes
110
118
  ```
111
119
 
112
- Optimization, verification, and apply guidance are the opt-in Compaction API not steps of the
113
- free local CLI.
120
+ Deterministic basic compaction (`compaction compact <trace> --out <dir>`) also runs locally in
121
+ the free CLI. Advanced verification, recommendation, and apply guidance are the opt-in
122
+ Compaction API — not steps of the free local CLI.
123
+
124
+ ## Gateway (local, byte-safe)
125
+
126
+ The Compaction **Gateway** is a local, OpenAI-compatible reverse proxy. Point your OpenAI client at it
127
+ (or launch your workflow through it) and it forwards traffic to the provider and records **content-free**
128
+ token/cache receipts — no prompt/response content, and your provider key stays on the client. Set it up
129
+ from onboarding (`compaction` → **Gateway setup**), or directly:
130
+
131
+ ```bash
132
+ compaction gateway start # start the local gateway (RECORD mode; byte-safe, no mutation)
133
+ compaction gateway run -- npm run dev # run a command through the gateway (no baseURL copy-paste)
134
+ compaction gateway status # requests observed + a content-free receipts rollup
135
+ ```
136
+
137
+ Record mode never changes your request or response. An **experimental**, explicit-opt-in deterministic
138
+ `apply` mode (`--mode apply --policy deterministic-dedupe`) can remove exact-duplicate large blocks from
139
+ known-safe requests, retaining the original locally (`compaction gateway recover <id>`). Nothing is called
140
+ a saving — the gateway reports provider-reported token/cache counts (or "unavailable"), never a cost figure.
114
141
 
115
142
  ## Supported local workflows
116
143
 
@@ -141,6 +168,7 @@ compaction capture provider-usage --endpoint <url> [--credential-env-var <NAME>]
141
168
  compaction import <trace-file> --source <source> --out <dir>
142
169
  compaction import --list-sources
143
170
  compaction analyze <trace-file>
171
+ compaction compact <trace-file> --out <dir> # deterministic local compaction (basic policies) + honest artifacts
144
172
  compaction context add <trace-file> # build a local context store from a captured/imported trace
145
173
  compaction context get "<query>" # retrieve + assemble the active context (with source pointers)
146
174
  compaction spend <trace-or-optimization-path> # context-spend attribution
@@ -180,9 +208,10 @@ Every figure this tool produces carries one of these labels — and nothing stro
180
208
 
181
209
  ## Known limitations
182
210
 
183
- - **The free CLI captures and measures; it does not optimize or verify.** Optimization, advanced
184
- verification, apply guidance, policies, and team reporting are the **opt-in Compaction API** —
185
- not the free local CLI. The API is **not broadly hosted yet**.
211
+ - **The free CLI captures, measures, and compacts deterministically; it does not perform
212
+ advanced verification.** Recommendation, advanced verification (incl. `compact --eval`), apply
213
+ guidance, advanced policies, and team reporting are the **opt-in Compaction API** — not the
214
+ free local CLI. The API is **not broadly hosted yet**.
186
215
  - **Cost figures are estimates or operator-entered records, not billing-confirmed.** Locally
187
216
  computed cost figures are price-table estimates; provider-usage records carry operator-entered/
188
217
  captured console figures (not provider-verified by this tool). **No figure is billing-confirmed.**
@@ -0,0 +1,8 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `compaction activity` — READ-ONLY, metrics-only view of recent runs from the local activity
4
+ * store (`.compaction/activity/activity.jsonl`). Content-free: it renders only the counts/labels
5
+ * the events already carry; it never reads or prints prompt/response content, and it writes
6
+ * NOTHING. Empty store → a friendly message, exit 0.
7
+ */
8
+ export declare function registerActivityCommand(program: Command): void;
@@ -0,0 +1,35 @@
1
+ import chalk from "chalk";
2
+ import { readActivityEvents } from "../../core/activity-store.js";
3
+ import { DEFAULT_ACTIVITY_LIMIT, buildActivityJson, buildActivityRows, formatActivityTable } from "../../core/activity-view.js";
4
+ /**
5
+ * `compaction activity` — READ-ONLY, metrics-only view of recent runs from the local activity
6
+ * store (`.compaction/activity/activity.jsonl`). Content-free: it renders only the counts/labels
7
+ * the events already carry; it never reads or prints prompt/response content, and it writes
8
+ * NOTHING. Empty store → a friendly message, exit 0.
9
+ */
10
+ export function registerActivityCommand(program) {
11
+ program
12
+ .command("activity")
13
+ .description("Show recent runs recorded in the local metrics-only activity store (read-only, content-free).")
14
+ .option("--limit <n>", `Max runs to show, most recent first (default ${DEFAULT_ACTIVITY_LIMIT})`)
15
+ .option("--surface <surface>", "Only show runs from this surface (e.g. cursor, codex, cli, claude_code)")
16
+ .option("--json", "Machine-readable JSON output")
17
+ .action(async (options) => {
18
+ const limit = options.limit !== undefined ? Number.parseInt(options.limit, 10) : DEFAULT_ACTIVITY_LIMIT;
19
+ if (Number.isNaN(limit) || limit < 0) {
20
+ console.error(`Invalid --limit "${options.limit}": expected a non-negative integer.`);
21
+ process.exitCode = 1;
22
+ return;
23
+ }
24
+ const { events, skipped } = await readActivityEvents();
25
+ const rows = buildActivityRows(events, { limit, surface: options.surface });
26
+ const meta = { totalEvents: events.length, limit, surface: options.surface, skippedCount: skipped.length };
27
+ if (options.json) {
28
+ console.log(JSON.stringify(buildActivityJson(rows, meta), null, 2));
29
+ return;
30
+ }
31
+ const table = formatActivityTable(rows, meta);
32
+ console.log(rows.length === 0 ? chalk.yellow(table) : chalk.cyan(table));
33
+ });
34
+ }
35
+ //# sourceMappingURL=activity.js.map
@@ -1,11 +1,27 @@
1
1
  import path from "node:path";
2
2
  import chalk from "chalk";
3
3
  import { Option } from "commander";
4
+ import { AUTO_APPLY_QUESTION_LINES, formatSavedPreferenceConfirmation, resolveAutoApplyOffer } from "../../core/auto-apply-ask.js";
5
+ import { savePolicyPreference } from "../../core/policy-preferences.js";
4
6
  import { parseTraceFile } from "../../core/trace-parser.js";
5
7
  import { runEngineCommand } from "../engine-degrade.js";
6
8
  function defaultOutputDir(artifactRoot, traceId) {
7
9
  return path.join(artifactRoot, traceId);
8
10
  }
11
+ /**
12
+ * Infer the SAFEST workflow tool for the auto-apply preference scope. Prefers the explicit
13
+ * `--workflow-tool` flag; otherwise maps an unambiguous trace source to its tool. Returns undefined
14
+ * when it cannot infer safely (then the offer fails closed and saves nothing — never guesses global).
15
+ */
16
+ function inferWorkflowTool(explicit, traceSource) {
17
+ if (explicit && explicit.trim() !== "")
18
+ return explicit.trim();
19
+ if (traceSource === "codex_import")
20
+ return "codex";
21
+ if (traceSource === "cursor_import")
22
+ return "cursor";
23
+ return undefined;
24
+ }
9
25
  /**
10
26
  * `compaction apply-context <trace-file> --out <dir>` — LEVEL-5 IN-WORKFLOW APPLY, Form A.
11
27
  *
@@ -26,6 +42,12 @@ export function registerApplyContextCommand(program) {
26
42
  .addOption(new Option("--approve-skill-injection-policy", "EXPLICITLY also authorize the approval-required repeated_skill_injection_to_state_capsule policy. Default off; no auto-apply."))
27
43
  .addOption(new Option("--attest-used", "OPTIONAL operator attestation: AFTER an approval, the operator records that they used the approved context in a live call. Stored as operator-ATTESTED provenance (the operator's own claim), never observed by compaction.dev."))
28
44
  .option("--attest-note <text>", "Optional free-text note recorded with the operator attestation")
45
+ .addOption(new Option("--offer-auto-apply", "OPT-IN. AFTER an explicit approval, offer the ONE binary question 'apply this automatically next time when safety gates pass?' (default no). Saving a preference records intent only — it NEVER applies anything (no auto-apply engine exists)."))
46
+ .addOption(new Option("--remember-auto-apply", "Non-interactive answer YES to the auto-apply offer (requires --offer-auto-apply and a prior approval). Saves a preference for the inferred safest scope; applies nothing now."))
47
+ .addOption(new Option("--no-remember", "Non-interactive answer NO to the auto-apply offer (the default): save nothing, keep asking each time."))
48
+ .option("--workflow-tool <tool>", "Tool/workflow to scope a saved auto-apply preference to (never global/cross-tool). Inferred from the trace source when omitted.")
49
+ .option("--workflow-repo <repo>", "Optional content-free repo identifier to narrow a saved auto-apply preference's scope.")
50
+ .option("--policy-type <type>", "Policy type a saved auto-apply preference concerns (defaults to the approved policy name).")
29
51
  .action(async (traceFile, options) => {
30
52
  await runEngineCommand(async () => {
31
53
  // Lazy-load the proprietary engine: present in-repo/API, excluded from the public package.
@@ -70,6 +92,52 @@ export function registerApplyContextCommand(program) {
70
92
  }
71
93
  }
72
94
  console.log(chalk.green(`Output directory: ${result.outputDirectory}`));
95
+ // OPT-IN post-approval auto-apply OFFER (§11a block 4/5). This offer is made ONLY after an
96
+ // explicit in-workflow approval already emitted an approved context. It APPLIES NOTHING: on
97
+ // yes it records a PREFERENCE (intent for the future gated slice) via the #598 store; on
98
+ // no/default it saves nothing. It never touches apply execution semantics.
99
+ if (options.offerAutoApply === true && result.approvedContextEmitted) {
100
+ console.log("");
101
+ for (const line of AUTO_APPLY_QUESTION_LINES)
102
+ console.log(chalk.cyan(line));
103
+ const answer = options.rememberAutoApply === true ? "yes" : "no";
104
+ const decision = resolveAutoApplyOffer({
105
+ approvedInWorkflow: true,
106
+ answer,
107
+ scope: {
108
+ tool: inferWorkflowTool(options.workflowTool, trace.source),
109
+ repo: options.workflowRepo,
110
+ policyType: options.policyType ?? result.review.policies_applied[0]
111
+ }
112
+ });
113
+ if (decision.action === "save") {
114
+ const saved = await savePolicyPreference({
115
+ scope: decision.scope,
116
+ preference: decision.preference,
117
+ gates_required: decision.gatesRequired
118
+ });
119
+ if (saved.saved) {
120
+ console.log("");
121
+ console.log(chalk.green(formatSavedPreferenceConfirmation(saved.preference)));
122
+ }
123
+ else {
124
+ console.log("");
125
+ console.log(chalk.yellow("Preference NOT saved (fail-closed) — nothing was applied:"));
126
+ for (const problem of saved.problems)
127
+ console.log(chalk.yellow(` - ${problem}`));
128
+ }
129
+ }
130
+ else if (decision.action === "blocked") {
131
+ console.log("");
132
+ console.log(chalk.yellow("Preference NOT saved — could not infer a safe (non-global, non-cross-tool) scope. Nothing applied:"));
133
+ for (const problem of decision.problems)
134
+ console.log(chalk.yellow(` - ${problem}`));
135
+ console.log(chalk.dim(" Pass --workflow-tool <tool> (and optionally --policy-type / --workflow-repo) to record a preference."));
136
+ }
137
+ else {
138
+ console.log(chalk.dim("No auto-apply preference saved — Compaction will keep asking each time (the default)."));
139
+ }
140
+ }
73
141
  // Exit non-zero when no approved context was emitted (refusal / review-only), mirroring apply/approve.
74
142
  if (!result.approvedContextEmitted) {
75
143
  process.exitCode = 1;
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import chalk from "chalk";
4
4
  import { writeJsonArtifact, writeTextArtifact } from "../../core/artifact-writer.js";
5
- import { addRun, computeReport, initMeasurementRecord, BILLING_DELTA_CRITERION, BILLING_DELTA_RECORD_ROOT } from "../../core/billing-delta/billing-delta-record.js";
5
+ import { addRun, computeReport, initMeasurementRecord, API_METERED_BILLING_CONFIRMED_CRITERIA, BILLING_DELTA_CRITERION, BILLING_DELTA_FIXED_PLAN_NOTE, BILLING_DELTA_RECORD_ROOT } from "../../core/billing-delta/billing-delta-record.js";
6
6
  const PRIVACY_NOTE = "Operator-run, local-first. compaction.dev makes NO provider call, reads NO credential, and has NO network access. " +
7
7
  "All billing figures are operator-entered (read from YOUR provider console/billing export) and are NOT provider-verified by this tool. " +
8
8
  "Aggregate cost only — no prompt/completion content is stored.";
@@ -81,6 +81,17 @@ function buildReportMarkdown(report) {
81
81
  "",
82
82
  `- ${report.criterion}`,
83
83
  "",
84
+ "## API-metered billing-confirmed criteria (cross-surface contract §5, D6 — checklist only)",
85
+ "",
86
+ "> A billing-confirmed figure is possible ONLY on api_metered workflows, ONLY through this",
87
+ "> billing-delta flow, and ONLY when ALL of the criteria below hold. This report does NOT verify",
88
+ "> them and NEVER carries billing_confirmed: true. Even when all criteria hold, the claim is",
89
+ "> workflow-scoped only — generalized billing-confirmed = future only.",
90
+ ">",
91
+ `> ${BILLING_DELTA_FIXED_PLAN_NOTE}`,
92
+ "",
93
+ ...API_METERED_BILLING_CONFIRMED_CRITERIA.map((c, i) => `${i + 1}. ${c}`),
94
+ "",
84
95
  "## Verdict notes",
85
96
  "",
86
97
  ...report.verdict_notes.map((n) => `- ${n}`),
@@ -1,3 +1,4 @@
1
+ import type { UsageMetadata } from "../../core/usage-metadata.js";
1
2
  interface DiscoverClaudeCodeOptions {
2
3
  projectsDir?: string;
3
4
  }
@@ -8,6 +9,49 @@ interface CaptureClaudeCodeOptions {
8
9
  includeSubagents?: boolean;
9
10
  }
10
11
  export declare function captureClaudeCodeCommand(options: CaptureClaudeCodeOptions): Promise<void>;
12
+ /** A normalized usage summary for the hook path — usage + a content-free fingerprint + message count. */
13
+ interface HookNormalizeResult {
14
+ usage: UsageMetadata;
15
+ messageCount: number;
16
+ fingerprint?: string;
17
+ }
18
+ export interface FromHookDeps {
19
+ readStdin?: () => Promise<string>;
20
+ normalize?: (transcriptPath: string) => Promise<HookNormalizeResult>;
21
+ cwd?: string;
22
+ now?: () => string;
23
+ hostedConfigured?: () => boolean;
24
+ }
25
+ export interface FromHookOptions {
26
+ dryRun?: boolean;
27
+ }
28
+ /**
29
+ * `compaction capture claude-code --from-hook`. Reads the Stop payload from stdin, resolves the session
30
+ * JSONL via `transcript_path`, and records CONTENT-FREE usage only — never message content, never
31
+ * `last_assistant_message`. Idempotent via a content-free dedup ledger. FAIL-OPEN by contract: any error is
32
+ * swallowed and the process exits 0 so the hook can never break Claude Code.
33
+ */
34
+ export declare function captureClaudeCodeFromHook(options?: FromHookOptions, deps?: FromHookDeps): Promise<void>;
35
+ export interface FromPromptHookDeps {
36
+ readStdin?: () => Promise<string>;
37
+ cwd?: string;
38
+ }
39
+ /**
40
+ * `compaction capture claude-code --from-prompt-hook` — the BEFORE-CALL recommendation path (charter
41
+ * 2026-07-04-run-13). The Claude Code **UserPromptSubmit** hook runs this BEFORE the model processes the
42
+ * prompt (a genuine pre-call event — NOT the post-session Stop hook). It reads the UserPromptSubmit
43
+ * payload from stdin, analyzes the pending PROMPT for avoidable duplicated context IN-PROCESS
44
+ * (content-free), and — when present — records ONE metrics-only `claude_code` before-call RECOMMENDATION
45
+ * activity event so it appears in `compaction activity`. Apply is a PROVEN BLOCKER on this surface (no
46
+ * hook can reduce the model's context; hooks are non-interactive) → recommendation-only; the original
47
+ * prompt ALWAYS runs UNCHANGED.
48
+ *
49
+ * CRITICAL: this path writes NOTHING to stdout — on UserPromptSubmit, a hook's stdout is ADDED to the
50
+ * model's context, so injecting anything would be both intrusive and self-defeating for a compaction
51
+ * tool. It is silent and transparent; the recommendation lives in `compaction activity`. FAIL-OPEN +
52
+ * CONTENT-FREE: any error is swallowed and the process exits 0; the prompt text is never persisted.
53
+ */
54
+ export declare function captureClaudeCodeFromPromptHook(deps?: FromPromptHookDeps): Promise<void>;
11
55
  /**
12
56
  * Discover local Claude Code sessions (metadata only) and print, for each, the
13
57
  * ready-to-run `capture claude-code --session <path>` command (guided capture).
@@ -1,6 +1,18 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import { writeJsonArtifact, writeTextArtifact } from "../../core/artifact-writer.js";
2
4
  import { ClaudeCodeAdapter, PRIVACY_WARNING } from "../../core/adapters/claude-code-adapter.js";
3
5
  import { discoverClaudeCodeSessions, DISCOVERY_PRIVACY_NOTE } from "../../core/adapters/claude-code-discovery.js";
6
+ import { recordCaptureContentFree } from "../../core/capture-record.js";
7
+ import { buildRunFlowTokenReport, formatRunFlowTokenReport } from "../../core/run-flow-report.js";
8
+ import { buildRunCrossSurfaceEvent } from "../../core/cross-surface-event.js";
9
+ import { buildMeasureOnlyActivityEvent } from "../../core/activity-event.js";
10
+ import { appendActivityEvent } from "../../core/activity-store.js";
11
+ import { detectAvoidableContext } from "../../core/before-call.js";
12
+ import { parseUserPromptSubmitPayload, buildClaudeCodeBeforeCallEvent } from "../../core/claude-code-before-call.js";
13
+ import { resolveApiConfig } from "../../core/api-client/index.js";
14
+ import { hostedConfigured } from "./optimize-hosted.js";
15
+ import { buildClaudeCodeHookRecord, computeDedupKey, emptyLedger, isAlreadyRecorded, parseStopPayload, resolveTranscriptPath } from "../../core/claude-code-hook-record.js";
4
16
  function buildOutputDir(sessionPath, out) {
5
17
  if (out)
6
18
  return out;
@@ -120,6 +132,16 @@ export async function captureClaudeCodeCommand(options) {
120
132
  console.log(" figures shown by 'spend' and 'compact' are deterministic LOCAL ESTIMATES (chars/4) — a");
121
133
  console.log(" different measurement, labeled there as local estimates; neither figure is billing-confirmed.");
122
134
  }
135
+ // 6b-iii. Shared UNIFIED-FLOW honest token report (docs/design/unified-run-flow.md, D1/D3): the SAME
136
+ // block that `run codex` / `run cursor` print, so all three tools present ONE honest `token_source`
137
+ // summary. Claude Code usage is PROVIDER-REPORTED and whole (no per-field unavailable split, like
138
+ // Codex), so outputStatus is "present"; when a session lacks provider usage the shared builder honestly
139
+ // downgrades to local-estimate / unavailable — it never labels anything stronger than the evidence. This
140
+ // is a reporting surface only: input/output are shown SEPARATELY with an explicit source, and output is
141
+ // shown as TOKENS ONLY (never a saving). No logic, adapter, or record behavior changes here.
142
+ const runFlowReport = buildRunFlowTokenReport({ tool: "claude-code", usage, outputStatus: "present" });
143
+ for (const line of formatRunFlowTokenReport(runFlowReport))
144
+ console.log(line);
123
145
  // 6c. Per-run distinctness fingerprint (a one-way digest over canonical normalized
124
146
  // content — NOT raw content). Lets a re-capture of the same session be told apart
125
147
  // from a genuinely new one; when unavailable, distinctness is `not_verified`.
@@ -138,6 +160,20 @@ export async function captureClaudeCodeCommand(options) {
138
160
  await writeTextArtifact(outDir, "capture-report.md", markdownReport);
139
161
  // 7. Print artifacts written
140
162
  console.log(`Artifacts written to: ${outDir}/`);
163
+ // Unified flow (docs/design/unified-run-flow.md): content-free record (input/output separate +
164
+ // honest source) when hosted-configured. No default network call — only when COMPACTION_API_URL +
165
+ // COMPACTION_API_KEY are set. Claude Code session usage is provider-reported.
166
+ if (hostedConfigured()) {
167
+ const rec = await recordCaptureContentFree(resolveApiConfig(), {
168
+ usage,
169
+ tool: "claude-code",
170
+ reference: `${outDir}/captured-trace.json`
171
+ });
172
+ console.log(rec.recorded ? `Recorded content-free usage (tool=claude-code, source=${rec.source}).` : `Not recorded: ${rec.reason}.`);
173
+ }
174
+ else {
175
+ console.log("Not recorded (set COMPACTION_API_URL + COMPACTION_API_KEY to record content-free usage).");
176
+ }
141
177
  // 8. Print next step — chain into the verified strong-MVP Claude Code flow:
142
178
  // spend (where the spend goes) → compact --eval (compaction + strong apply-readiness:
143
179
  // deterministic recoverability + commitment-preservation + fixture task-check + token/cost).
@@ -151,6 +187,176 @@ export async function captureClaudeCodeCommand(options) {
151
187
  console.log(" # fixture task-check, and token/cost accounting — nothing is applied automatically.");
152
188
  console.log(` (or: compaction analyze ${outDir}/captured-trace.json for a quick read-only estimate)`);
153
189
  }
190
+ async function readAllStdin() {
191
+ if (process.stdin.isTTY)
192
+ return "";
193
+ const chunks = [];
194
+ for await (const chunk of process.stdin)
195
+ chunks.push(Buffer.from(chunk));
196
+ return Buffer.concat(chunks).toString("utf8");
197
+ }
198
+ async function defaultHookNormalize(transcriptPath) {
199
+ const adapter = new ClaudeCodeAdapter();
200
+ const { trace, usage, provenance } = await adapter.normalize({ sourcePath: transcriptPath });
201
+ return {
202
+ usage,
203
+ messageCount: trace.messages.length,
204
+ ...(provenance.traceFingerprint ? { fingerprint: provenance.traceFingerprint.content_sha256 } : {})
205
+ };
206
+ }
207
+ function hookDir(cwd) {
208
+ return path.join(cwd, ".compaction", "hooks", "claude-code");
209
+ }
210
+ async function loadLedger(cwd) {
211
+ try {
212
+ const raw = await readFile(path.join(hookDir(cwd), "ledger.json"), "utf8");
213
+ const parsed = JSON.parse(raw);
214
+ return Array.isArray(parsed.entries) ? parsed : emptyLedger();
215
+ }
216
+ catch {
217
+ return emptyLedger();
218
+ }
219
+ }
220
+ /**
221
+ * Append ONE metrics-only activity event for a Stop-hook capture (the always-on bridge). Builds the
222
+ * `claude_code` cross-surface event from the SAME honest token report the record uses, then the
223
+ * measure-only activity extension, and appends it to `.compaction/activity/` under the hook's cwd
224
+ * (so tmp-cwd tests and real projects both scope correctly). Content-free and best-effort: any
225
+ * local failure is swallowed here so it can NEVER break the fail-open hook.
226
+ */
227
+ async function appendClaudeCodeHookActivity(input) {
228
+ try {
229
+ // outputStatus "present": buildRunFlowTokenReport honestly downgrades a missing output axis to
230
+ // unavailable — a genuinely-absent field is never labeled provider-reported (no silent zero).
231
+ const tokenReport = buildRunFlowTokenReport({ tool: "claude-code", usage: input.usage, outputStatus: "present" });
232
+ const crossSurfaceEvent = buildRunCrossSurfaceEvent("claude_code", {
233
+ // run_id is the deterministic content-free dedup key → deterministic activity_event_id → the
234
+ // store dedupes a re-captured session (same state = one event).
235
+ runId: `claude-code-${input.dedupKey}`,
236
+ tokenReport,
237
+ reasons: {
238
+ input: "the Claude Code session usage did not report input tokens for this session state",
239
+ output: "the Claude Code session usage did not report output tokens for this session state"
240
+ },
241
+ ...(input.usage.model ? { modelLabel: input.usage.model } : {})
242
+ });
243
+ // session_id rides on the event too (content-free identity), when the Stop payload carried one.
244
+ const eventWithSession = input.sessionId
245
+ ? { ...crossSurfaceEvent, session_id: input.sessionId }
246
+ : crossSurfaceEvent;
247
+ // Measurement only: nothing was applied or retained by Compaction, so original_retained=false.
248
+ const activityEvent = buildMeasureOnlyActivityEvent(eventWithSession, { original_retained: false });
249
+ const result = await appendActivityEvent(activityEvent, path.join(input.cwd, ".compaction", "activity"));
250
+ console.log(result.appended
251
+ ? `compaction hook: recorded metrics-only activity (surface=claude_code, id ${result.activity_event_id.slice(0, 12)}…) — see 'compaction activity'.`
252
+ : `compaction hook: activity not appended (${result.reason}).`);
253
+ }
254
+ catch {
255
+ // Best-effort local append — never fail the fail-open hook on a filesystem/store error.
256
+ }
257
+ }
258
+ /**
259
+ * `compaction capture claude-code --from-hook`. Reads the Stop payload from stdin, resolves the session
260
+ * JSONL via `transcript_path`, and records CONTENT-FREE usage only — never message content, never
261
+ * `last_assistant_message`. Idempotent via a content-free dedup ledger. FAIL-OPEN by contract: any error is
262
+ * swallowed and the process exits 0 so the hook can never break Claude Code.
263
+ */
264
+ export async function captureClaudeCodeFromHook(options = {}, deps = {}) {
265
+ const cwd = deps.cwd ?? process.cwd();
266
+ const now = deps.now ?? (() => new Date().toISOString());
267
+ const readStdin = deps.readStdin ?? readAllStdin;
268
+ const normalize = deps.normalize ?? defaultHookNormalize;
269
+ const hostedIsConfigured = deps.hostedConfigured ?? hostedConfigured;
270
+ try {
271
+ const payload = parseStopPayload(await readStdin());
272
+ const transcriptPath = resolveTranscriptPath(payload);
273
+ if (!transcriptPath) {
274
+ console.log("compaction hook: no transcript_path in the Stop payload — nothing recorded (content-free).");
275
+ return;
276
+ }
277
+ const sessionId = typeof payload?.session_id === "string" ? payload.session_id : undefined;
278
+ const { usage, messageCount, fingerprint } = await normalize(transcriptPath);
279
+ const dedupKey = computeDedupKey({
280
+ sessionId,
281
+ fingerprint,
282
+ messageCount,
283
+ inputTokens: typeof usage.input_tokens === "number" ? usage.input_tokens : null,
284
+ outputTokens: typeof usage.output_tokens === "number" ? usage.output_tokens : null
285
+ });
286
+ const ledger = await loadLedger(cwd);
287
+ if (isAlreadyRecorded(ledger, dedupKey)) {
288
+ console.log(`compaction hook: already recorded this session state (dedup ${dedupKey.slice(0, 8)}…) — skipped.`);
289
+ return;
290
+ }
291
+ const record = buildClaudeCodeHookRecord({ usage, ...(sessionId ? { sessionId } : {}), messageCount, dedupKey, recordedAt: now() });
292
+ if (options.dryRun) {
293
+ console.log("compaction hook --dry-run: would record (content-free, nothing written):");
294
+ console.log(JSON.stringify(record, null, 2));
295
+ return;
296
+ }
297
+ const dir = hookDir(cwd);
298
+ await mkdir(path.join(dir, "records"), { recursive: true });
299
+ await writeFile(path.join(dir, "records", `${dedupKey}.json`), `${JSON.stringify(record, null, 2)}\n`, "utf8");
300
+ ledger.entries.push({ dedupKey, ...(sessionId ? { sessionId } : {}), recordedAt: record.recordedAt });
301
+ await writeFile(path.join(dir, "ledger.json"), `${JSON.stringify(ledger, null, 2)}\n`, "utf8");
302
+ console.log(`compaction hook: recorded content-free usage (source=${record.tokenSource}, dedup ${dedupKey.slice(0, 8)}…).`);
303
+ // ALWAYS-ON BRIDGE (charter 2026-07-04-run-8): the Stop hook runs this command at session end,
304
+ // so the SAME content-free usage ALSO appends ONE metrics-only activity event to the local
305
+ // activity store (.compaction/activity/). THIS is what makes a subsequent Claude Code session
306
+ // appear in `compaction activity` with NO manual import. Honesty: surface "claude_code",
307
+ // provider "anthropic", token axes copied VERBATIM from the SAME token report (provider-reported
308
+ // where the session usage carried them, else unavailable-with-reason — never a silent zero);
309
+ // measure-only, so approval "not-required", auto_apply OFF (not eligible, "ask-each-time",
310
+ // applied_automatically false — no auto-apply logic exists), sync "local-only", recovery
311
+ // original_retained=false (measurement only — Compaction modified/retained nothing). The event
312
+ // id is DETERMINISTIC from session state (run_id = the content-free dedup key), so a re-captured
313
+ // session yields the SAME id and the store dedupes it. Best-effort + content-free; a local
314
+ // filesystem failure here never breaks the hook.
315
+ await appendClaudeCodeHookActivity({ cwd, usage, dedupKey, sessionId });
316
+ if (hostedIsConfigured()) {
317
+ const rec = await recordCaptureContentFree(resolveApiConfig(), { usage, tool: "claude-code", reference: `hook:${dedupKey}` });
318
+ console.log(rec.recorded ? `compaction hook: also recorded to hosted (source=${rec.source}).` : `compaction hook: hosted not recorded: ${rec.reason}.`);
319
+ }
320
+ }
321
+ catch (error) {
322
+ // FAIL-OPEN: never propagate — the hook must not break Claude Code.
323
+ const message = error instanceof Error ? error.message : String(error);
324
+ console.log(`compaction hook: skipped (non-fatal: ${message}).`);
325
+ }
326
+ }
327
+ /**
328
+ * `compaction capture claude-code --from-prompt-hook` — the BEFORE-CALL recommendation path (charter
329
+ * 2026-07-04-run-13). The Claude Code **UserPromptSubmit** hook runs this BEFORE the model processes the
330
+ * prompt (a genuine pre-call event — NOT the post-session Stop hook). It reads the UserPromptSubmit
331
+ * payload from stdin, analyzes the pending PROMPT for avoidable duplicated context IN-PROCESS
332
+ * (content-free), and — when present — records ONE metrics-only `claude_code` before-call RECOMMENDATION
333
+ * activity event so it appears in `compaction activity`. Apply is a PROVEN BLOCKER on this surface (no
334
+ * hook can reduce the model's context; hooks are non-interactive) → recommendation-only; the original
335
+ * prompt ALWAYS runs UNCHANGED.
336
+ *
337
+ * CRITICAL: this path writes NOTHING to stdout — on UserPromptSubmit, a hook's stdout is ADDED to the
338
+ * model's context, so injecting anything would be both intrusive and self-defeating for a compaction
339
+ * tool. It is silent and transparent; the recommendation lives in `compaction activity`. FAIL-OPEN +
340
+ * CONTENT-FREE: any error is swallowed and the process exits 0; the prompt text is never persisted.
341
+ */
342
+ export async function captureClaudeCodeFromPromptHook(deps = {}) {
343
+ const readStdin = deps.readStdin ?? readAllStdin;
344
+ try {
345
+ const payload = parseUserPromptSubmitPayload(await readStdin());
346
+ if (!payload)
347
+ return; // malformed/absent payload → nothing recorded (fail-open)
348
+ const result = detectAvoidableContext(payload.prompt);
349
+ if (!result.has_avoidable_context)
350
+ return; // honest no-op → no activity noise, no stdout
351
+ const cwd = payload.cwd ?? deps.cwd ?? process.cwd();
352
+ const event = buildClaudeCodeBeforeCallEvent(result, payload.sessionId ? { sessionId: payload.sessionId } : {});
353
+ await appendActivityEvent(event, path.join(cwd, ".compaction", "activity"));
354
+ // No stdout (would inject into the model context); the recommendation is in `compaction activity`.
355
+ }
356
+ catch {
357
+ // FAIL-OPEN: never propagate — the before-call hook must not break Claude Code.
358
+ }
359
+ }
154
360
  /**
155
361
  * Discover local Claude Code sessions (metadata only) and print, for each, the
156
362
  * ready-to-run `capture claude-code --session <path>` command (guided capture).