@compaction/cli 0.1.4 → 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 (170) hide show
  1. package/README.md +51 -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 +24 -11
  10. package/dist/cli/commands/context.d.ts +2 -0
  11. package/dist/cli/commands/context.js +130 -0
  12. package/dist/cli/commands/dev.d.ts +17 -0
  13. package/dist/cli/commands/dev.js +131 -0
  14. package/dist/cli/commands/gateway.d.ts +31 -0
  15. package/dist/cli/commands/gateway.js +312 -0
  16. package/dist/cli/commands/hooks.d.ts +5 -0
  17. package/dist/cli/commands/hooks.js +181 -0
  18. package/dist/cli/commands/import.js +3 -1
  19. package/dist/cli/commands/init.js +422 -17
  20. package/dist/cli/commands/input-compaction-ab.d.ts +2 -0
  21. package/dist/cli/commands/input-compaction-ab.js +125 -0
  22. package/dist/cli/commands/optimize-hosted.d.ts +54 -0
  23. package/dist/cli/commands/optimize-hosted.js +123 -0
  24. package/dist/cli/commands/optimize.js +8 -0
  25. package/dist/cli/commands/output-shaping-ab.d.ts +2 -0
  26. package/dist/cli/commands/output-shaping-ab.js +132 -0
  27. package/dist/cli/commands/output-shaping.d.ts +7 -0
  28. package/dist/cli/commands/output-shaping.js +43 -0
  29. package/dist/cli/commands/policies.d.ts +2 -0
  30. package/dist/cli/commands/policies.js +80 -0
  31. package/dist/cli/commands/precall.d.ts +19 -0
  32. package/dist/cli/commands/precall.js +436 -0
  33. package/dist/cli/commands/recommend.js +1 -1
  34. package/dist/cli/commands/run.js +310 -18
  35. package/dist/cli/commands/upgrade-status.d.ts +42 -0
  36. package/dist/cli/commands/upgrade-status.js +152 -0
  37. package/dist/cli/cursor-export-read.d.ts +13 -0
  38. package/dist/cli/cursor-export-read.js +53 -0
  39. package/dist/cli/cursor-live-preflight.d.ts +19 -0
  40. package/dist/cli/cursor-live-preflight.js +46 -0
  41. package/dist/cli/index.js +39 -2
  42. package/dist/cli/onboarding/GatewayTui.d.ts +33 -0
  43. package/dist/cli/onboarding/GatewayTui.js +113 -0
  44. package/dist/cli/onboarding/model.d.ts +32 -1
  45. package/dist/cli/onboarding/model.js +27 -2
  46. package/dist/cli/terminal-logo.d.ts +27 -0
  47. package/dist/cli/terminal-logo.js +49 -0
  48. package/dist/core/activity-event.d.ts +107 -0
  49. package/dist/core/activity-event.js +182 -0
  50. package/dist/core/activity-store.d.ts +63 -0
  51. package/dist/core/activity-store.js +254 -0
  52. package/dist/core/activity-view.d.ts +76 -0
  53. package/dist/core/activity-view.js +120 -0
  54. package/dist/core/api-client/index.d.ts +3 -1
  55. package/dist/core/api-client/index.js +4 -0
  56. package/dist/core/api-client/payload.d.ts +5 -1
  57. package/dist/core/api-client/payload.js +8 -6
  58. package/dist/core/api-client/persisted-config.d.ts +57 -0
  59. package/dist/core/api-client/persisted-config.js +156 -0
  60. package/dist/core/api-client/tool.d.ts +29 -0
  61. package/dist/core/api-client/tool.js +50 -0
  62. package/dist/core/api-client/types.d.ts +15 -4
  63. package/dist/core/auto-apply-ask.d.ts +58 -0
  64. package/dist/core/auto-apply-ask.js +105 -0
  65. package/dist/core/auto-apply-gates.d.ts +76 -0
  66. package/dist/core/auto-apply-gates.js +113 -0
  67. package/dist/core/before-call-activity.d.ts +35 -0
  68. package/dist/core/before-call-activity.js +103 -0
  69. package/dist/core/before-call-recovery.d.ts +19 -0
  70. package/dist/core/before-call-recovery.js +46 -0
  71. package/dist/core/before-call-stdin.d.ts +59 -0
  72. package/dist/core/before-call-stdin.js +78 -0
  73. package/dist/core/before-call.d.ts +149 -0
  74. package/dist/core/before-call.js +358 -0
  75. package/dist/core/billing-delta/billing-delta-record.d.ts +28 -0
  76. package/dist/core/billing-delta/billing-delta-record.js +26 -0
  77. package/dist/core/capture-record.d.ts +40 -0
  78. package/dist/core/capture-record.js +55 -0
  79. package/dist/core/claude-code-before-call.d.ts +37 -0
  80. package/dist/core/claude-code-before-call.js +120 -0
  81. package/dist/core/claude-code-connect.d.ts +31 -0
  82. package/dist/core/claude-code-connect.js +87 -0
  83. package/dist/core/claude-code-hook-record.d.ts +71 -0
  84. package/dist/core/claude-code-hook-record.js +0 -0
  85. package/dist/core/claude-code-hooks.d.ts +77 -0
  86. package/dist/core/claude-code-hooks.js +141 -0
  87. package/dist/core/codex-capture.d.ts +45 -0
  88. package/dist/core/codex-capture.js +204 -0
  89. package/dist/core/command-runner.js +5 -1
  90. package/dist/core/compaction-artifacts.d.ts +51 -0
  91. package/dist/core/compaction-artifacts.js +242 -0
  92. package/dist/core/compactor.d.ts +9 -0
  93. package/dist/core/compactor.js +95 -0
  94. package/dist/core/context-store-eval-cases.d.ts +6 -0
  95. package/dist/core/context-store-eval-cases.js +331 -0
  96. package/dist/core/context-store-eval.d.ts +140 -0
  97. package/dist/core/context-store-eval.js +138 -0
  98. package/dist/core/context-store-fs.d.ts +73 -0
  99. package/dist/core/context-store-fs.js +157 -0
  100. package/dist/core/context-store-sufficiency.d.ts +98 -0
  101. package/dist/core/context-store-sufficiency.js +135 -0
  102. package/dist/core/context-store.d.ts +155 -0
  103. package/dist/core/context-store.js +228 -0
  104. package/dist/core/cross-surface-event.d.ts +306 -0
  105. package/dist/core/cross-surface-event.js +330 -0
  106. package/dist/core/cursor-capture.d.ts +54 -0
  107. package/dist/core/cursor-capture.js +215 -0
  108. package/dist/core/cursor-preflight-probe.d.ts +8 -0
  109. package/dist/core/cursor-preflight-probe.js +88 -0
  110. package/dist/core/cursor-preflight.d.ts +86 -0
  111. package/dist/core/cursor-preflight.js +126 -0
  112. package/dist/core/gateway/apply-activation.d.ts +39 -0
  113. package/dist/core/gateway/apply-activation.js +84 -0
  114. package/dist/core/gateway/apply-policy.d.ts +64 -0
  115. package/dist/core/gateway/apply-policy.js +221 -0
  116. package/dist/core/gateway/apply-receipt.d.ts +36 -0
  117. package/dist/core/gateway/apply-receipt.js +75 -0
  118. package/dist/core/gateway/cache-proof.d.ts +45 -0
  119. package/dist/core/gateway/cache-proof.js +65 -0
  120. package/dist/core/gateway/configure.d.ts +50 -0
  121. package/dist/core/gateway/configure.js +169 -0
  122. package/dist/core/gateway/openai-usage.d.ts +56 -0
  123. package/dist/core/gateway/openai-usage.js +128 -0
  124. package/dist/core/gateway/receipt.d.ts +138 -0
  125. package/dist/core/gateway/receipt.js +120 -0
  126. package/dist/core/gateway/recovery.d.ts +23 -0
  127. package/dist/core/gateway/recovery.js +68 -0
  128. package/dist/core/gateway/server.d.ts +51 -0
  129. package/dist/core/gateway/server.js +276 -0
  130. package/dist/core/gateway/status.d.ts +45 -0
  131. package/dist/core/gateway/status.js +109 -0
  132. package/dist/core/hook-usage-aggregate.d.ts +47 -0
  133. package/dist/core/hook-usage-aggregate.js +161 -0
  134. package/dist/core/input-compaction-ab.d.ts +111 -0
  135. package/dist/core/input-compaction-ab.js +158 -0
  136. package/dist/core/local-run-record.d.ts +109 -0
  137. package/dist/core/local-run-record.js +223 -0
  138. package/dist/core/output-shaping-ab.d.ts +140 -0
  139. package/dist/core/output-shaping-ab.js +146 -0
  140. package/dist/core/output-shaping-attach.d.ts +31 -0
  141. package/dist/core/output-shaping-attach.js +57 -0
  142. package/dist/core/output-shaping.d.ts +56 -0
  143. package/dist/core/output-shaping.js +89 -0
  144. package/dist/core/policy-middleware.d.ts +121 -0
  145. package/dist/core/policy-middleware.js +919 -0
  146. package/dist/core/policy-preferences.d.ts +99 -0
  147. package/dist/core/policy-preferences.js +232 -0
  148. package/dist/core/report-generator.d.ts +19 -1
  149. package/dist/core/report-generator.js +51 -0
  150. package/dist/core/run-aggregator.d.ts +56 -1
  151. package/dist/core/run-aggregator.js +93 -2
  152. package/dist/core/run-flow-report.d.ts +82 -0
  153. package/dist/core/run-flow-report.js +71 -0
  154. package/dist/core/safety-report.js +8 -1
  155. package/dist/core/shim-capture-bridge.d.ts +32 -0
  156. package/dist/core/shim-capture-bridge.js +88 -0
  157. package/dist/core/skill-injection-policy.d.ts +72 -0
  158. package/dist/core/skill-injection-policy.js +183 -0
  159. package/dist/core/spend-attribution.js +1 -1
  160. package/dist/core/token-accounting.d.ts +1 -1
  161. package/dist/core/tool-shim.d.ts +129 -0
  162. package/dist/core/tool-shim.js +447 -0
  163. package/dist/core/trace-adapters.d.ts +8 -0
  164. package/dist/core/trace-adapters.js +4 -0
  165. package/dist/core/trace-parser.d.ts +13 -13
  166. package/dist/core/trace-parser.js +6 -6
  167. package/dist/core/types.d.ts +45 -2
  168. package/dist/core/waste-detector.d.ts +20 -0
  169. package/dist/core/waste-detector.js +160 -6
  170. package/package.json +1 -1
@@ -1,11 +1,16 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import path from "node:path";
5
6
  import chalk from "chalk";
6
7
  import { defaultProjectsDir, discoverClaudeCodeSessions } from "../../core/adapters/claude-code-discovery.js";
8
+ import { connectClaudeCodeHook } from "../../core/claude-code-connect.js";
9
+ import { SHIM_TOOLS, installToolShim, uninstallToolShim, verifyShimActive, resolveExecutableOnPath, writeShellConfigPathLine, removeShellConfigPathLine } from "../../core/tool-shim.js";
7
10
  import { BRAND_HEX, VALUE_PROMISE, STATUS_PILLS, PRIMARY, SECONDARY, FREE_FLOW, HONESTY_LINES, FOOTER_LINES, ALL_WORKFLOW_KEYS } from "../onboarding/model.js";
8
11
  import { decideInteractiveTuiFromProcess } from "../onboarding/should-use-tui.js";
12
+ import { runGatewayStart, parseListen } from "./gateway.js";
13
+ import { planGatewayConfigure, formatConfigurePlan } from "../../core/gateway/configure.js";
9
14
  /**
10
15
  * `compaction init` — first-run, value-led terminal onboarding.
11
16
  *
@@ -110,7 +115,10 @@ function afterCaptureBlock() {
110
115
  chalk.dim(` ${HONESTY_LINES[2]}`),
111
116
  "",
112
117
  chalk.dim(` ${HONESTY_LINES[3]}`),
113
- chalk.dim(` ${HONESTY_LINES[4]}`)
118
+ chalk.dim(` ${HONESTY_LINES[4]}`),
119
+ "",
120
+ chalk.dim(" Optional — auto-capture content-free usage on each Claude Code Stop:"),
121
+ ` ${ACCENT("compaction hooks install")} ${chalk.dim("(consented, merge-not-replace, --dry-run; nothing is written until you run it)")}`
114
122
  ];
115
123
  }
116
124
  function footerBlock() {
@@ -182,13 +190,306 @@ function printSelection(card) {
182
190
  ];
183
191
  console.log(out.join("\n"));
184
192
  }
193
+ /** Human labels + the honest per-tool token-source line shown in the shim connect block. */
194
+ const SHIM_TOOL_LABELS = {
195
+ codex: {
196
+ title: "Codex",
197
+ connectNum: "2",
198
+ measurableForm: "codex exec --json",
199
+ tokenNote: "provider-reported tokens (codex exec --json turn.completed.usage) — NOT billing-confirmed"
200
+ },
201
+ cursor: {
202
+ title: "Cursor",
203
+ connectNum: "3",
204
+ measurableForm: "cursor-agent … --output-format json",
205
+ tokenNote: "LOCAL-ESTIMATE tokens only (Cursor emits no provider usage) — never provider-reported; output unavailable when the result is not separable"
206
+ }
207
+ };
208
+ const CONNECT_ALIASES = {
209
+ "1": "claude-code",
210
+ "2": "codex",
211
+ "3": "cursor",
212
+ "4": "all",
213
+ "5": "skip",
214
+ "claude-code": "claude-code",
215
+ codex: "codex",
216
+ cursor: "cursor",
217
+ all: "all",
218
+ skip: "skip"
219
+ };
220
+ /** Honest per-tool line: whether the real binary is on PATH and whether OUR shim is verified active. */
221
+ function shimDetectLine(tool, label) {
222
+ const shimName = SHIM_TOOLS[tool].shimName;
223
+ const realBin = resolveExecutableOnPath(shimName, process.env, [verifyShimActive(tool).shimDir]);
224
+ const v = verifyShimActive(tool);
225
+ if (v.active)
226
+ return chalk.green(` [x] ${label.padEnd(17)} connected (shim active → ${shimName})`);
227
+ if (v.installed)
228
+ return chalk.yellow(` [~] ${label.padEnd(17)} shim installed, NOT yet on PATH (run --connect ${SHIM_TOOL_LABELS[tool].connectNum} to finish)`);
229
+ if (realBin)
230
+ return ` [ ] ${label.padEnd(17)} ${shimName} found — not connected (run --connect ${SHIM_TOOL_LABELS[tool].connectNum})`;
231
+ return ` [ ] ${label.padEnd(17)} not found on PATH`;
232
+ }
233
+ /**
234
+ * Detection block. Claude Code is scanned (read-only, session discovery). Codex/Cursor are detected
235
+ * by the presence of the real binary on PATH AND whether Compaction's PATH shim is verified active
236
+ * (resolve-check, never assumed). The browser is "not connected / not checked" — NEVER "not
237
+ * installed": Compaction does not scan Chrome profiles.
238
+ */
239
+ function connectDetectionBlock(det) {
240
+ const claude = det.claudeDetected
241
+ ? chalk.green(` [x] Claude Code detected (${det.sessionCount} session(s))`)
242
+ : ` [ ] Claude Code not detected here`;
243
+ return [
244
+ header("Detected AI tools on this machine"),
245
+ "",
246
+ claude,
247
+ shimDetectLine("codex", "Codex"),
248
+ shimDetectLine("cursor", "Cursor"),
249
+ " [ ] Browser extension not connected / not checked",
250
+ "",
251
+ chalk.dim(" Codex and Cursor connect via a Compaction-owned PATH shim (reversible; verified by"),
252
+ chalk.dim(" resolving the tool name). The browser extension is 'not connected / not checked' —"),
253
+ chalk.dim(" Compaction never scans your Chrome profiles to guess whether it is installed.")
254
+ ];
255
+ }
256
+ function connectMenuBlock() {
257
+ return [
258
+ header("Enable Compaction for"),
259
+ "",
260
+ " [1] Claude Code",
261
+ " [2] Codex",
262
+ " [3] Cursor",
263
+ " [4] All supported CLI tools",
264
+ " [5] Skip"
265
+ ];
266
+ }
267
+ /**
268
+ * The Codex/Cursor PATH-shim install-result block. HONEST by status: it claims "connected" ONLY when
269
+ * the shim is verified ACTIVE (the tool name resolves to our shim on PATH). When the shim is installed
270
+ * but the shim dir is not yet on PATH, it prints the ONE exact export line and says "not yet active" —
271
+ * never a false success. It scopes "measured automatically" to the MEASURABLE batch form and states
272
+ * plainly that interactive/other invocations are NOT measured. The real binary is never replaced.
273
+ */
274
+ function shimConnectBlock(result, wroteShellConfig) {
275
+ const meta = SHIM_TOOL_LABELS[result.tool];
276
+ const title = meta.title;
277
+ if (result.status === "installed-active" || result.status === "already-active") {
278
+ const lines = [
279
+ ` ${chalk.bold("▸ " + title)} ${chalk.green("— connected")}`,
280
+ "",
281
+ chalk.green(` Compaction is now active for ${title} (PATH shim: ${result.shimPath} → real ${result.shimName}: ${result.realBin ?? "recorded"}).`),
282
+ ` From now on, ${chalk.bold(meta.measurableForm)} runs are measured automatically — no manual import needed.`,
283
+ chalk.dim(` Interactive / other ${result.shimName} invocations pass through untouched and are NOT measured (never faked).`),
284
+ chalk.dim(` Token source: ${meta.tokenNote}.`),
285
+ chalk.dim(" Metrics-only; no prompt or response content is stored or uploaded."),
286
+ "",
287
+ chalk.dim(" See your runs anytime: compaction activity"),
288
+ chalk.dim(` Fallback/debug (per-invocation, not needed once connected): compaction run ${result.tool} -- <cmd>`),
289
+ chalk.dim(` Disconnect (reversible): compaction init --disconnect ${meta.connectNum}`)
290
+ ];
291
+ return lines;
292
+ }
293
+ if (result.status === "installed-not-on-path") {
294
+ const resolved = result.verification.resolvedPath;
295
+ const out = [
296
+ ` ${chalk.bold("▸ " + title)} ${chalk.yellow("— installed, NOT yet active")}`,
297
+ "",
298
+ chalk.yellow(` The shim is installed at ${result.shimPath}, but its directory is not on your PATH yet,`),
299
+ chalk.yellow(` so \`${result.shimName}\` still resolves to ${resolved ?? "the real binary"} — NOT the shim.`),
300
+ " Add this ONE line to activate it (prepends the shim dir so it wins), then re-run to verify:",
301
+ ` ${ACCENT(result.exportLine)}`,
302
+ ` ${ACCENT(`compaction init --connect ${meta.connectNum}`)}`
303
+ ];
304
+ if (wroteShellConfig) {
305
+ out.push("", chalk.green(` --write-shell-config: ${wroteShellConfig.status} the line in ${wroteShellConfig.rcPath}` + (wroteShellConfig.backupPath ? ` (backup: ${wroteShellConfig.backupPath}).` : ".")), chalk.dim(" Open a new shell (or `source` that file), then re-run `compaction init --connect " + meta.connectNum + "` to verify."), chalk.dim(` Undo: compaction init --disconnect ${meta.connectNum} (removes the shim and restores the backup).`));
306
+ }
307
+ else {
308
+ out.push("", chalk.dim(" Prefer Compaction append it for you? Re-run with --write-shell-config (writes a reversible .bak backup first)."));
309
+ }
310
+ return out;
311
+ }
312
+ if (result.status === "no-real-binary") {
313
+ return [
314
+ ` ${chalk.bold("▸ " + title)} ${chalk.dim("— not found")}`,
315
+ "",
316
+ chalk.dim(` No \`${result.shimName}\` binary was found on your PATH, so there is nothing to measure yet.`),
317
+ chalk.dim(` Install ${title} first, then re-run \`compaction init --connect ${meta.connectNum}\`. Nothing was written.`)
318
+ ];
319
+ }
320
+ // verify-failed — NEVER claim connected.
321
+ return [
322
+ ` ${chalk.bold("▸ " + title)} ${chalk.red("— NOT connected")}`,
323
+ "",
324
+ chalk.red(` A shim write to ${result.shimPath} was attempted but re-reading it did not confirm the shim.`),
325
+ " Nothing is claimed connected. Check permissions on the Compaction shim directory and re-run."
326
+ ];
327
+ }
328
+ /** The reversible disconnect block for a Codex/Cursor shim. */
329
+ function shimDisconnectBlock(tool, removed, shimPath, rc) {
330
+ const meta = SHIM_TOOL_LABELS[tool];
331
+ const lines = [
332
+ ` ${chalk.bold("▸ " + meta.title)} ${chalk.green("— disconnected")}`,
333
+ "",
334
+ removed
335
+ ? chalk.green(` Removed the ${meta.title} shim (${shimPath}). The real ${SHIM_TOOLS[tool].shimName} binary was never touched.`)
336
+ : chalk.dim(` No ${meta.title} shim was installed (${shimPath}); nothing to remove.`)
337
+ ];
338
+ if (rc && rc.status === "removed") {
339
+ lines.push(chalk.green(` Reverted the shell-rc PATH line in ${rc.rcPath}` + (rc.backupRestored ? ` (restored from ${rc.backupRestored}).` : ".")));
340
+ }
341
+ lines.push(chalk.dim(" Open a new shell so the PATH change takes effect."));
342
+ return lines;
343
+ }
344
+ function skipBlock() {
345
+ return [
346
+ header("Skipped"),
347
+ "",
348
+ chalk.dim(" Nothing was installed or changed. Re-run `compaction init --connect 1` anytime to"),
349
+ chalk.dim(" connect Claude Code, or `compaction hooks install` to install the Stop hook directly.")
350
+ ];
351
+ }
352
+ /**
353
+ * The Claude Code install-result block. HONEST TENSE by status: it says "now active" ONLY when a
354
+ * re-read verified the hook landed (`installed` / `already-present`), and NEVER claims connected on
355
+ * a failed/unverified install — it prints the one exact manual command instead. Scope is stated
356
+ * explicitly (this project vs all your projects), with the required reload caveat.
357
+ */
358
+ function claudeInstallBlock(result, user) {
359
+ const scopeParenthetical = user ? "all your projects" : "this project";
360
+ const scopeSuffix = user ? "in any project" : "in this project";
361
+ const manualCmd = `compaction hooks install${user ? " --user" : ""}`;
362
+ if (result.status === "installed" || result.status === "already-present") {
363
+ const headline = result.status === "already-present"
364
+ ? ` Compaction is already active for Claude Code (${scopeParenthetical}: ${result.settingsPath}).`
365
+ : ` Compaction is now active for Claude Code (${scopeParenthetical}: ${result.settingsPath}).`;
366
+ return [
367
+ ` ${chalk.bold("▸ Claude Code")} ${chalk.green("— connected")}`,
368
+ "",
369
+ chalk.green(headline),
370
+ ` From now on, supported Claude Code sessions ${scopeSuffix} are measured automatically —`,
371
+ " no manual import needed. Reload Claude Code once (open /hooks or restart) to load the hook.",
372
+ chalk.dim(" Metrics-only; no prompt or response content is stored or uploaded."),
373
+ "",
374
+ chalk.dim(" See your runs anytime: compaction activity")
375
+ ];
376
+ }
377
+ if (result.status === "dry-run") {
378
+ return [
379
+ ` ${chalk.bold("▸ Claude Code")} ${chalk.yellow("— dry run (nothing written)")}`,
380
+ "",
381
+ chalk.yellow(` --dry-run: the hook was NOT installed. It would be merged into ${result.settingsPath}:`),
382
+ chalk.dim(JSON.stringify(result.wouldWrite ?? {}, null, 2)),
383
+ "",
384
+ chalk.dim(" Re-run without --dry-run to connect.")
385
+ ];
386
+ }
387
+ // verify-failed | error — NEVER claim connected. Print the one exact manual command.
388
+ const why = result.status === "error"
389
+ ? `Could not install safely: ${result.error ?? "unknown error"}. Nothing was written.`
390
+ : `A write to ${result.settingsPath} was attempted but re-reading it did not find the hook.`;
391
+ return [
392
+ ` ${chalk.bold("▸ Claude Code")} ${chalk.red("— NOT connected")}`,
393
+ "",
394
+ chalk.red(` ${why}`),
395
+ " Nothing is claimed connected. Install it yourself, then re-run to verify:",
396
+ ` ${ACCENT(manualCmd)}`
397
+ ];
398
+ }
399
+ /**
400
+ * Activation copy — the capability summary. `before-call` compaction is honestly hedged "where
401
+ * supported" (the Claude Code Stop hook is a post-session event, so before-call apply is NOT on
402
+ * this path — see docs/ops/always-on-followups.md). Auto-apply is OFF by default.
403
+ */
404
+ function activationCopyBlock() {
405
+ return [
406
+ header("What Compaction does once connected"),
407
+ "",
408
+ " - measure input/output tokens (metrics-only)",
409
+ " - detect avoidable context",
410
+ " - recommend safe compaction before the call where supported",
411
+ " - ask before applying",
412
+ " - remember auto-apply preferences only after explicit approval",
413
+ " - record metrics-only activity for history (compaction activity)",
414
+ "",
415
+ chalk.dim(" Metrics only. No prompt or response content is stored or uploaded."),
416
+ chalk.dim(" Nothing is ever changed without asking you first. Auto-apply is off by default.")
417
+ ];
418
+ }
419
+ /** The static header (wordmark + detection + menu) shown above the per-choice action block. */
420
+ function connectHeaderLines(det) {
421
+ return [
422
+ "",
423
+ ` ${WORDMARK}`,
424
+ "",
425
+ ` ${VALUE_PROMISE}`,
426
+ "",
427
+ RULE,
428
+ "",
429
+ ...connectDetectionBlock(det),
430
+ "",
431
+ ...connectMenuBlock(),
432
+ "",
433
+ RULE,
434
+ ""
435
+ ];
436
+ }
437
+ /**
438
+ * "Run a command through the Gateway" (preferred routing) — copy-ready guidance. The TUI takes no text
439
+ * input, so we print the exact `compaction gateway run -- <command>` form; running it starts/reuses the
440
+ * gateway, injects the base URL into the child (never a key), and reports whether traffic was observed.
441
+ */
442
+ function printGatewayDevGuidance(listen) {
443
+ const out = [
444
+ "",
445
+ ` ${chalk.bold("▸ Run a command through the Gateway")}`,
446
+ chalk.dim(" The no-copy path: Compaction starts (or reuses) the local gateway, points your command's"),
447
+ chalk.dim(" OpenAI base URL at it, runs your command unchanged, and reports whether traffic was observed."),
448
+ "",
449
+ chalk.dim(" Run this next (replace with your dev/run command):"),
450
+ ` ${ACCENT("compaction gateway run -- npm run dev")}`,
451
+ ` ${ACCENT("compaction gateway run -- node app.js")}`,
452
+ "",
453
+ chalk.dim(" Your provider API key stays on the child process — the gateway never reads or stores it."),
454
+ chalk.dim(" Already running? Compaction cannot attach to a live process unless it already points at the"),
455
+ chalk.dim(" gateway — restart it through `compaction gateway run --` to route future calls."),
456
+ "",
457
+ chalk.dim(` See traffic anytime: ${ACCENT("compaction gateway status")} · listen: ${listen}`),
458
+ ""
459
+ ];
460
+ console.log(out.join("\n"));
461
+ }
462
+ /**
463
+ * "Configure this project" (approval-gated) — plan-only preview. Detects the project's OpenAI-config
464
+ * surface and prints the exact diff that WOULD be written; it writes nothing. The user applies it with
465
+ * `compaction gateway configure --apply` (which backs up first).
466
+ */
467
+ function printGatewayConfigurePreview(listen) {
468
+ const { host, port } = parseListen(listen);
469
+ const baseUrl = `http://${host}:${port}/v1`;
470
+ const plan = planGatewayConfigure(process.cwd(), baseUrl);
471
+ const out = ["", ` ${chalk.bold("▸ Configure this project")}`, ""];
472
+ for (const line of formatConfigurePlan(plan))
473
+ out.push(` ${chalk.dim(line)}`);
474
+ out.push("", chalk.dim(" Nothing was written. This is a preview — apply it yourself when ready."), "");
475
+ console.log(out.join("\n"));
476
+ }
185
477
  export function registerInitCommand(program) {
186
478
  program
187
- .command("init")
479
+ // isDefault: bare `compaction` (no subcommand) opens onboarding — the interactive TUI in a real
480
+ // terminal (workflow chooser + Gateway setup), or the static screen elsewhere. `--help` still shows help.
481
+ .command("init", { isDefault: true })
188
482
  .description("First-run onboarding: an interactive workflow chooser in a real terminal (TUI), or the " +
189
- "value promise + exact next commands as a static screen elsewhere. Guidance + one read-only " +
190
- "local detection only writes nothing, no network, no credentials.")
483
+ "value promise + exact next commands as a static screen elsewhere. The default screen writes " +
484
+ "nothing (read-only local detection, no network, no credentials). `--connect 1` installs the " +
485
+ "consented Claude Code Stop hook (merge-not-replace) and verifies it; `--connect 2`/`3` install " +
486
+ "+ verify a reversible Codex/Cursor PATH shim — connect-once always-on.")
191
487
  .option("--path <path>", "Focus one input path: claude-code | openai-agents | codex | import")
488
+ .option("--connect <choice>", "Connect-once menu: 1|claude-code, 2|codex, 3|cursor, 4|all, 5|skip. `1`/`all` installs + verifies the consented Claude Code Stop hook; `2`/`3` install + VERIFY a reversible Codex/Cursor PATH shim (never claims active without resolving the tool name).")
489
+ .option("--disconnect <choice>", "Reversibly remove a Codex/Cursor PATH shim: 2|codex, 3|cursor (restores any shell-rc backup). The real binary is never touched.")
490
+ .option("--write-shell-config", "With --connect 2/3: append the shim PATH line to your shell rc (zsh/bash), writing a reversible .bak backup first. Off by default — the default only PRINTS the line.")
491
+ .option("--user", "With --connect: install the Claude Code hook into ~/.claude/settings.json (all your projects) instead of the project's .claude/settings.json.")
492
+ .option("--dry-run", "With --connect: show what the Claude Code hook install would write, but write nothing.")
192
493
  .option("--projects-dir <dir>", "Claude Code projects directory to detect against (default: the standard local location)")
193
494
  .option("--interactive", "prefer the interactive TUI; if no TTY is available, show the static screen and why")
194
495
  .option("--static", "always show the plain static screen (no TUI)")
@@ -204,6 +505,72 @@ export function registerInitCommand(program) {
204
505
  // Read-only local detection (no writes, no network).
205
506
  const projectsDir = options.projectsDir ?? defaultProjectsDir();
206
507
  const det = await detectClaudeCode(projectsDir);
508
+ // --disconnect: reversibly remove a Codex/Cursor shim (2|codex, 3|cursor). Restores any rc backup.
509
+ if (options.disconnect !== undefined) {
510
+ const choice = CONNECT_ALIASES[options.disconnect.trim().toLowerCase()];
511
+ const shimTool = choice === "codex" ? "codex" : choice === "cursor" ? "cursor" : undefined;
512
+ if (!shimTool) {
513
+ console.error(`unknown --disconnect '${options.disconnect}'. Valid: 2|codex, 3|cursor`);
514
+ process.exitCode = 1;
515
+ return;
516
+ }
517
+ const uninstalled = uninstallToolShim(shimTool);
518
+ const rc = removeShellConfigPathLine();
519
+ const lines = shimDisconnectBlock(shimTool, uninstalled.status === "removed", uninstalled.shimPath, rc);
520
+ console.log([...connectHeaderLines(det), ...lines, "", RULE, ""].join("\n"));
521
+ return;
522
+ }
523
+ // --connect: the connect-once menu. `1`/`all` (Claude Code) installs + verifies the consented
524
+ // Stop hook; `2`/`3` install + VERIFY a reversible Codex/Cursor PATH shim; skip changes nothing.
525
+ if (options.connect !== undefined) {
526
+ const resolved = CONNECT_ALIASES[options.connect.trim().toLowerCase()];
527
+ if (!resolved) {
528
+ console.error(`unknown --connect '${options.connect}'. Valid: 1|claude-code, 2|codex, 3|cursor, 4|all, 5|skip`);
529
+ process.exitCode = 1;
530
+ return;
531
+ }
532
+ const actionLines = [];
533
+ if (resolved === "skip") {
534
+ actionLines.push(...skipBlock());
535
+ }
536
+ else {
537
+ // Claude Code (or "all") is the ONE install-capable choice: real merge-not-replace install
538
+ // into project .claude/settings.json (or ~/.claude/settings.json with --user), then a
539
+ // re-read VERIFY. This is the only init path that writes — running it IS the consent.
540
+ if (resolved === "claude-code" || resolved === "all") {
541
+ const settingsPath = options.user
542
+ ? path.join(homedir(), ".claude", "settings.json")
543
+ : path.join(process.cwd(), ".claude", "settings.json");
544
+ const result = await connectClaudeCodeHook({
545
+ settingsPath,
546
+ ...(options.dryRun ? { dryRun: true } : {})
547
+ });
548
+ actionLines.push(...claudeInstallBlock(result, Boolean(options.user)));
549
+ // Honesty: a failed/unverified install must not exit 0 as if connected.
550
+ if (result.status === "verify-failed" || result.status === "error")
551
+ process.exitCode = 1;
552
+ actionLines.push("");
553
+ }
554
+ // Codex/Cursor: install + VERIFY a reversible PATH shim (the run-8 pattern applied to a shim).
555
+ // "connected" is claimed ONLY when the shim resolves active; otherwise the exact export line
556
+ // is printed and it is honestly reported "not yet active". Auto-apply stays OFF (no apply path).
557
+ const shimTools = resolved === "all" ? ["codex", "cursor"] : resolved === "codex" ? ["codex"] : resolved === "cursor" ? ["cursor"] : [];
558
+ for (const shimTool of shimTools) {
559
+ const install = installToolShim(shimTool);
560
+ let wroteShellConfig;
561
+ if (options.writeShellConfig && install.status === "installed-not-on-path") {
562
+ const rc = writeShellConfigPathLine();
563
+ wroteShellConfig = { rcPath: rc.rcPath, status: rc.status, ...(rc.backupPath ? { backupPath: rc.backupPath } : {}) };
564
+ }
565
+ actionLines.push(...shimConnectBlock(install, wroteShellConfig), "");
566
+ // Honesty: an unverified/failed shim write must not exit 0 as if connected.
567
+ if (install.status === "verify-failed")
568
+ process.exitCode = 1;
569
+ }
570
+ }
571
+ console.log([...connectHeaderLines(det), ...actionLines, RULE, "", ...activationCopyBlock(), ""].join("\n"));
572
+ return;
573
+ }
207
574
  // Decide static vs interactive. A focused path (--path) or --static always
208
575
  // serves static; --interactive forces the TUI iff a TTY is present;
209
576
  // otherwise auto-detect.
@@ -216,21 +583,59 @@ export function registerInitCommand(program) {
216
583
  if (wantTui) {
217
584
  // Dynamic import keeps Ink/React off every non-TTY code path.
218
585
  const { runInitTui } = await import("../onboarding/InitTui.js");
219
- const result = await runInitTui({
220
- version,
221
- detection: {
222
- detected: det.claudeDetected,
223
- sessionCount: det.sessionCount,
224
- projectsDir: det.projectsDir
586
+ // Loop so "Back" from the Gateway setup flow returns to the workflow chooser.
587
+ for (;;) {
588
+ const result = await runInitTui({
589
+ version,
590
+ detection: {
591
+ detected: det.claudeDetected,
592
+ sessionCount: det.sessionCount,
593
+ projectsDir: det.projectsDir
594
+ }
595
+ });
596
+ if (result.action !== "select") {
597
+ console.log(chalk.dim("\nNo workflow selected. Run `compaction` anytime.\n"));
598
+ return;
225
599
  }
226
- });
227
- if (result.action === "select") {
228
- printSelection(result.card);
229
- }
230
- else {
231
- console.log(chalk.dim("\nNo workflow selected. Run `compaction init` anytime.\n"));
600
+ if (result.card.key !== "gateway") {
601
+ printSelection(result.card);
602
+ return;
603
+ }
604
+ // Gateway setup card → guided flow (a sibling Ink screen). The routing step resolves to one of:
605
+ // dev (preferred, no-copy), configure (approval-gated), or start (manual routing).
606
+ const { runGatewayTui } = await import("../onboarding/GatewayTui.js");
607
+ const g = await runGatewayTui();
608
+ if (g.action === "back")
609
+ continue; // back to the chooser
610
+ if (g.action === "dev") {
611
+ printGatewayDevGuidance(g.config.listen);
612
+ return;
613
+ }
614
+ if (g.action === "configure") {
615
+ printGatewayConfigurePreview(g.config.listen);
616
+ return;
617
+ }
618
+ if (g.action !== "start") {
619
+ console.log(chalk.dim("\nNo gateway started. Run `compaction` anytime.\n"));
620
+ return;
621
+ }
622
+ try {
623
+ const listen = parseListen(g.config.listen);
624
+ await runGatewayStart({
625
+ provider: g.config.provider,
626
+ upstream: g.config.upstream,
627
+ mode: g.config.mode, // record by default; apply only when explicitly armed + confirmed in the TUI
628
+ ...(g.config.policy ? { policy: g.config.policy } : {}),
629
+ host: listen.host,
630
+ port: listen.port
631
+ });
632
+ }
633
+ catch (err) {
634
+ console.error(`error: could not start the gateway — ${err.message}`);
635
+ process.exitCode = 1;
636
+ }
637
+ return;
232
638
  }
233
- return;
234
639
  }
235
640
  // If the user explicitly asked for --interactive but we are not in a TTY,
236
641
  // tell them why we fell back, then serve the static screen.
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerInputCompactionAbCommand(program: Command): void;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `compaction input-compaction-ab` — the operator-run path to produce + ingest a real provider-reported
3
+ * INPUT-compaction A/B for Codex (control = full input context, treatment = Compaction-compacted context),
4
+ * gated for a confirmed claim on a CONTEXT-PRESERVATION eval. Design: `docs/design/input-compaction-ab.md`.
5
+ * Mirrors `output-shaping-ab` but for INPUT tokens with a different (context-preservation) eval.
6
+ *
7
+ * compaction input-compaction-ab init --experiment <id> --out <file> [--task-shape <label>]
8
+ * compaction input-compaction-ab add --experiment <file> --arm control|treatment --usage <capture-usage.json>
9
+ * [treatment: --context-preserved <yes|no> --task-solved <yes|no>
10
+ * --source-recoverable <yes|no> --commitment-preserved <yes|no>
11
+ * --context-complete <yes|no>] // --context-complete = no material loss
12
+ * compaction input-compaction-ab status --experiment <file>
13
+ *
14
+ * Honesty (binding): this PUBLIC CLI shows the OBSERVED input-token delta + a conservative confidence; it
15
+ * NEVER shows a confirmed input-savings number (the engine gate does). Output-token delta is shown as
16
+ * secondary only. Input eval = context-preservation (NOT short-but-sufficient).
17
+ */
18
+ import { readFile, writeFile } from "node:fs/promises";
19
+ import chalk from "chalk";
20
+ import { addInputCompactionAbRun, initInputCompactionAbExperiment, runFromSidecar, summarizeInputCompactionAb } from "../../core/input-compaction-ab.js";
21
+ const PRIVACY_NOTE = "Operator-run, local-first. compaction.dev makes NO provider call, reads NO credential, and has NO network access. " +
22
+ "Token counts come from YOUR capture artifacts. This CLI shows the OBSERVED input-token A/B only — a CONFIRMED " +
23
+ "input-savings number is produced solely by the engine gate (provider-reported, measured criterion, context-preservation pass).";
24
+ async function loadExperiment(file) {
25
+ return JSON.parse(await readFile(file, "utf8"));
26
+ }
27
+ async function saveExperiment(file, experiment) {
28
+ await writeFile(file, JSON.stringify(experiment, null, 2), "utf8");
29
+ }
30
+ function resolveArm(arm) {
31
+ if (arm !== "control" && arm !== "treatment")
32
+ throw new Error('--arm must be "control" or "treatment".');
33
+ return arm;
34
+ }
35
+ /** Parse a yes/no flag into a tri-state outcome: "yes"→true, "no"→false, absent→null (not evaluated). */
36
+ function yesNo(value, flag) {
37
+ if (value === undefined)
38
+ return null;
39
+ const v = value.toLowerCase();
40
+ if (v === "yes" || v === "true" || v === "pass")
41
+ return true;
42
+ if (v === "no" || v === "false" || v === "fail")
43
+ return false;
44
+ throw new Error(`${flag} must be "yes" or "no".`);
45
+ }
46
+ export function registerInputCompactionAbCommand(program) {
47
+ const ab = program
48
+ .command("input-compaction-ab")
49
+ .description("Operator-run, content-free INPUT-compaction A/B harness: link a control arm (full context) and a " +
50
+ "treatment arm (Compaction-compacted context) of Codex captures and show the OBSERVED provider-reported " +
51
+ "INPUT-token delta, gated for a confirmed claim on a CONTEXT-PRESERVATION eval (NOT output sufficiency). " +
52
+ "Shows confirmed savings NEVER — the engine gate does that.");
53
+ ab.command("init")
54
+ .description("Create a new input-compaction A/B experiment file.")
55
+ .requiredOption("--experiment <id>", "Experiment id (a short label, not content).")
56
+ .requiredOption("--out <file>", "Path to write the experiment JSON.")
57
+ .option("--task-shape <label>", "Short generic label of the shared task/context SHAPE (not prompt content).", "unspecified-task-shape")
58
+ .action(async (options) => {
59
+ console.log(chalk.cyan("compaction input-compaction-ab init"));
60
+ console.log(PRIVACY_NOTE);
61
+ const experiment = initInputCompactionAbExperiment({ experimentId: options.experiment, taskShape: options.taskShape ?? "unspecified-task-shape" });
62
+ await saveExperiment(options.out, experiment);
63
+ console.log(chalk.green(`Wrote ${options.out}`));
64
+ console.log(" Next: run both arms (full vs Compaction-compacted context), then `add` each capture-usage.json.");
65
+ console.log(" 1. compaction compact <trace> --eval --out <dir> # context-preservation eval");
66
+ console.log(" 2. compaction apply-context <trace> --approve-in-workflow-use --out <dir> # compacted context");
67
+ console.log(' control: compaction capture codex --out runs/c1 -- codex exec --json "<task> + FULL context"');
68
+ console.log(' treatment: compaction capture codex --out runs/t1 -- codex exec --json "<task> + COMPACTED context"');
69
+ });
70
+ ab.command("add")
71
+ .description("Ingest a capture-usage.json into an arm. For treatment, record the context-preservation outcome.")
72
+ .requiredOption("--experiment <file>", "Path to the experiment JSON (from `init`).")
73
+ .requiredOption("--arm <arm>", 'Which arm: "control" (full context) or "treatment" (compacted context).')
74
+ .requiredOption("--usage <file>", "Path to a capture-usage.json sidecar written by `compaction capture codex`.")
75
+ .option("--context-preserved <yes|no>", "Treatment: task-critical context preserved?")
76
+ .option("--task-solved <yes|no>", "Treatment: did the treatment output still solve the same task?")
77
+ .option("--source-recoverable <yes|no>", "Treatment: source recoverability preserved (from `compact --eval`)?")
78
+ .option("--commitment-preserved <yes|no>", "Treatment: instruction/commitment preservation (from `compact --eval`)?")
79
+ .option("--context-complete <yes|no>", "Treatment: no material context loss (the compacted context is complete for the task)?")
80
+ .action(async (options) => {
81
+ console.log(chalk.cyan("compaction input-compaction-ab add"));
82
+ const arm = resolveArm(options.arm);
83
+ const sidecar = JSON.parse(await readFile(options.usage, "utf8"));
84
+ const run = runFromSidecar(arm, sidecar, arm === "treatment"
85
+ ? {
86
+ contextPreserved: yesNo(options.contextPreserved, "--context-preserved"),
87
+ taskSolved: yesNo(options.taskSolved, "--task-solved"),
88
+ sourceRecoverability: yesNo(options.sourceRecoverable, "--source-recoverable"),
89
+ commitmentPreservation: yesNo(options.commitmentPreserved, "--commitment-preserved"),
90
+ noMaterialLoss: yesNo(options.contextComplete, "--context-complete")
91
+ }
92
+ : {}, options.usage);
93
+ const experiment = addInputCompactionAbRun(await loadExperiment(options.experiment), run);
94
+ await saveExperiment(options.experiment, experiment);
95
+ const src = run.providerReported ? chalk.green("provider-reported") : chalk.yellow(`${run.tokenSource} (not provider-reported)`);
96
+ console.log(` Added ${chalk.bold(arm)} run: input=${run.inputTokens ?? "unavailable"} output=${run.outputTokens ?? "unavailable"} tokens, source=${src}.`);
97
+ if (!run.providerReported)
98
+ console.log(chalk.yellow(" ! this arm is not provider-reported → it can never produce a confirmed input-savings number."));
99
+ });
100
+ ab.command("status")
101
+ .description("Show the OBSERVED input-token A/B + conservative confidence (never a confirmed savings number).")
102
+ .requiredOption("--experiment <file>", "Path to the experiment JSON.")
103
+ .action(async (options) => {
104
+ console.log(chalk.cyan("compaction input-compaction-ab status"));
105
+ console.log(PRIVACY_NOTE);
106
+ const s = summarizeInputCompactionAb(await loadExperiment(options.experiment));
107
+ const fmt = (n) => (n === null ? chalk.gray("unavailable") : `${Math.round(n)}`);
108
+ console.log(` experiment: ${s.experimentId}`);
109
+ console.log(` N (control / treatment): ${s.nControl} / ${s.nTreatment}`);
110
+ console.log(` input_tokens_before: ${fmt(s.inputTokensBefore)}`);
111
+ console.log(` input_tokens_after: ${fmt(s.inputTokensAfter)}`);
112
+ console.log(` input_token_delta: ${fmt(s.inputTokenDelta)} ${chalk.gray("(observed, not confirmed)")}`);
113
+ console.log(` input_token_reduction_pct: ${s.inputTokenReductionPct === null ? chalk.gray("unavailable") : `${s.inputTokenReductionPct.toFixed(1)}%`} ${chalk.gray("(observed)")}`);
114
+ console.log(` output_token_delta: ${fmt(s.outputTokenDelta)} ${chalk.gray("(observed, SECONDARY — not the input claim)")}`);
115
+ console.log(` token_source: ${s.tokenSource}`);
116
+ console.log(` context-preservation eval: ${s.contextPreservation} ${chalk.gray("(recoverability + commitment + no-material-loss + task-solved; NOT short-but-sufficient)")}`);
117
+ console.log(` semantic eval: ${s.semanticEval}`);
118
+ const conf = s.confidence === "unavailable" ? chalk.gray(s.confidence) : chalk.yellow(s.confidence);
119
+ console.log(` confidence: ${conf}`);
120
+ console.log(` confirmed input savings: ${chalk.gray("unavailable in the public CLI — the engine gate produces a confirmed number.")}`);
121
+ for (const reason of s.reasons)
122
+ console.log(chalk.gray(` • ${reason}`));
123
+ });
124
+ }
125
+ //# sourceMappingURL=input-compaction-ab.js.map