@compaction/cli 0.1.2 → 0.2.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 (38) hide show
  1. package/README.md +11 -1
  2. package/dist/cli/commands/compact.js +15 -0
  3. package/dist/cli/commands/context.d.ts +2 -0
  4. package/dist/cli/commands/context.js +130 -0
  5. package/dist/cli/commands/import.js +3 -1
  6. package/dist/cli/commands/init.js +184 -157
  7. package/dist/cli/commands/run.js +13 -1
  8. package/dist/cli/index.js +4 -1
  9. package/dist/cli/onboarding/InitTui.d.ts +52 -0
  10. package/dist/cli/onboarding/InitTui.js +153 -0
  11. package/dist/cli/onboarding/model.d.ts +39 -0
  12. package/dist/cli/onboarding/model.js +77 -0
  13. package/dist/cli/onboarding/should-use-tui.d.ts +25 -0
  14. package/dist/cli/onboarding/should-use-tui.js +35 -0
  15. package/dist/cli/onboarding/wordmark.d.ts +28 -0
  16. package/dist/cli/onboarding/wordmark.js +72 -0
  17. package/dist/core/aggregate-format.d.ts +3 -1
  18. package/dist/core/aggregate-format.js +18 -0
  19. package/dist/core/context-store-eval-cases.d.ts +6 -0
  20. package/dist/core/context-store-eval-cases.js +331 -0
  21. package/dist/core/context-store-eval.d.ts +140 -0
  22. package/dist/core/context-store-eval.js +138 -0
  23. package/dist/core/context-store-fs.d.ts +73 -0
  24. package/dist/core/context-store-fs.js +157 -0
  25. package/dist/core/context-store-sufficiency.d.ts +98 -0
  26. package/dist/core/context-store-sufficiency.js +135 -0
  27. package/dist/core/context-store.d.ts +155 -0
  28. package/dist/core/context-store.js +228 -0
  29. package/dist/core/report-generator.d.ts +32 -1
  30. package/dist/core/report-generator.js +67 -0
  31. package/dist/core/run-aggregator.d.ts +64 -0
  32. package/dist/core/run-aggregator.js +132 -3
  33. package/dist/core/session-aggregate.d.ts +43 -0
  34. package/dist/core/session-aggregate.js +89 -7
  35. package/dist/core/trace-adapters.d.ts +8 -0
  36. package/dist/core/trace-adapters.js +4 -0
  37. package/dist/core/types.d.ts +55 -0
  38. package/package.json +5 -1
package/README.md CHANGED
@@ -24,6 +24,14 @@ Everything below runs on your machine and writes local files only:
24
24
  billing-confirmed**.
25
25
  - **Inspect spend and roll up runs.** `spend`, `summary`, and `aggregate` summarize where context
26
26
  spend came from and roll up local runs — local files only, no hosted dashboard.
27
+ - **Build a local context store and retrieve from it.** `context add` accumulates source-pointed
28
+ items from a captured/imported trace into a local, size-capped store; `context get`
29
+ deterministically retrieves and assembles the active context for a query under a token budget,
30
+ printing it **with source pointers** plus local retrieval **diagnostics** (what was
31
+ considered / included / dropped, the recoverable fraction, latency). Local-only and
32
+ deterministic — **no model, no embeddings, no network**. It reports **diagnostics only** (no
33
+ quality or sufficiency verdict on your data) and makes **no savings claim** (see `spend` /
34
+ `summary` for cost).
27
35
  - **Write local reports.** `analyze` writes local JSON + Markdown reports under an
28
36
  operator-chosen output directory. Local files only.
29
37
  - **Create redacted, content-free share bundles.** `feedback --redact` writes a redacted,
@@ -66,7 +74,7 @@ npx @compaction/cli --help # no install at all
66
74
  ```
67
75
 
68
76
  The installed binary is `compaction`. After a global install, sanity-check with
69
- `compaction --version` (→ `0.1.2`) and `compaction --help`.
77
+ `compaction --version` (→ `0.2.0`) and `compaction --help`.
70
78
 
71
79
  ### curl one-liner
72
80
 
@@ -133,6 +141,8 @@ compaction capture provider-usage --endpoint <url> [--credential-env-var <NAME>]
133
141
  compaction import <trace-file> --source <source> --out <dir>
134
142
  compaction import --list-sources
135
143
  compaction analyze <trace-file>
144
+ compaction context add <trace-file> # build a local context store from a captured/imported trace
145
+ compaction context get "<query>" # retrieve + assemble the active context (with source pointers)
136
146
  compaction spend <trace-or-optimization-path> # context-spend attribution
137
147
  compaction summary # roll up local run reports
138
148
  compaction aggregate # per-session / cross-session local rollups
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import chalk from "chalk";
3
3
  import { Option } from "commander";
4
4
  import { writeJsonArtifact, writeTextArtifact } from "../../core/artifact-writer.js";
5
+ import { formatCompactionMarkdownReport, withSavingsEvidence, withTraceFingerprint } from "../../core/report-generator.js";
5
6
  import { describeTokenAccounting } from "../../core/token-accounting.js";
6
7
  import { parseTraceFile } from "../../core/trace-parser.js";
7
8
  import { buildRunLabelsFile, hasAnyLabel } from "../../core/run-labels.js";
@@ -123,6 +124,20 @@ export function registerCompactCommand(program) {
123
124
  // hand-assembled bundle — the eval consumes the identical PolicyMiddlewareResult.
124
125
  const policyResult = applyCompactionPolicy({ trace, compactSkillInjections: approveSkillInjectionPolicy });
125
126
  const artifacts = await writeCompactionArtifacts(trace, options.out, runId, policyResult, undefined, { reviewerType, reviewSummaryPresent, approveSkillInjectionPolicy });
127
+ // Attribution/integrity + per-run evidence label (V0.2 savings-evidence): re-persist
128
+ // report.json with (1) the content-addressed trace fingerprint so `aggregate`/`summary` can
129
+ // attribute each saving to a verifiable run and de-duplicate a run rolled up twice, and
130
+ // (2) the composed `savings_evidence` record stating the per-run label explicitly. The
131
+ // compact path's figures are local estimates (chars/4) — `local_estimate` rung 1; never
132
+ // billing-confirmed, never semantic. Additive, backward-compatible; changes no existing field.
133
+ const evidenceReport = withSavingsEvidence(withTraceFingerprint(artifacts.report, trace), {
134
+ costSource: "local_estimate"
135
+ });
136
+ await writeJsonArtifact(options.out, "report.json", evidenceReport);
137
+ // Re-render report.md from the ENRICHED report so the per-run savings_evidence record
138
+ // appears in the human-readable artifact too (the engine rendered report.md from the base
139
+ // report before the CLI added the public-layer fingerprint/evidence enrichment).
140
+ await writeTextArtifact(options.out, "report.md", formatCompactionMarkdownReport(evidenceReport, artifacts.policy));
126
141
  console.log(chalk.cyan("compaction compact"));
127
142
  console.log(artifacts.consoleReport);
128
143
  // Operator-visible only when the approval gesture was given; default output is unchanged.
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerContextCommand(program: Command): void;
@@ -0,0 +1,130 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import chalk from "chalk";
4
+ import { assembleContext, contextItemsFromTrace, retrieveContextItems } from "../../core/context-store.js";
5
+ import { appendContextItems, loadContextStore } from "../../core/context-store-fs.js";
6
+ /**
7
+ * V0.4 `compaction context` — a LOCAL memory + retrieval surface over the user's own captured
8
+ * traces (design: docs/design/v0.4-context-command.md, accepted 2026-06-24).
9
+ *
10
+ * HARD RAILS (load-bearing): local-only — no network, no model, no embeddings, no engine. It
11
+ * makes NO savings/cost claim (cost lives in `spend`/`summary`) and NO quality/recall/correctness
12
+ * verdict on user data (the four-axis sufficiency harness stays internal — there is no ground
13
+ * truth for an arbitrary user query). `get` prints local retrieval DIAGNOSTICS only.
14
+ */
15
+ const DEFAULT_STORE_DIR = ".compaction/context-store";
16
+ function readTrace(path) {
17
+ if (!existsSync(path)) {
18
+ throw new Error(`File not found: ${path}`);
19
+ }
20
+ let parsed;
21
+ try {
22
+ parsed = JSON.parse(readFileSync(path, "utf8"));
23
+ }
24
+ catch {
25
+ throw new Error(`Not valid JSON: ${path}`);
26
+ }
27
+ const trace = parsed;
28
+ if (!trace || typeof trace.id !== "string" || !Array.isArray(trace.messages)) {
29
+ throw new Error("Not a normalized AgentTrace (expected { id, messages: [...] }). " +
30
+ "Produce one with `compaction capture` or `compaction import`.");
31
+ }
32
+ for (const message of trace.messages) {
33
+ const m = message;
34
+ if (!m || typeof m.id !== "string" || typeof m.content !== "string") {
35
+ throw new Error("Malformed trace: every message needs a string `id` and `content`. " +
36
+ "Produce a trace with `compaction capture` or `compaction import`.");
37
+ }
38
+ }
39
+ return parsed;
40
+ }
41
+ function collect(value, previous) {
42
+ return [...previous, value];
43
+ }
44
+ export function registerContextCommand(program) {
45
+ const context = program
46
+ .command("context")
47
+ .description("Local memory: build a durable context store from your own captured/imported traces and " +
48
+ "retrieve the active context for a next step (local-only; no model, no network, no savings claim).");
49
+ context
50
+ .command("add")
51
+ .argument("<artifact>", "Path to a local normalized AgentTrace JSON (from `compaction capture`/`import`)")
52
+ .option("--store-dir <dir>", "Context store directory (local, gitignored)", DEFAULT_STORE_DIR)
53
+ .description("Append context items derived from a local trace into the local context store (size-cap enforced).")
54
+ .action(async (artifact, options) => {
55
+ const storeDir = resolve(options.storeDir ?? DEFAULT_STORE_DIR);
56
+ const trace = readTrace(resolve(artifact));
57
+ const items = contextItemsFromTrace(trace, { created_at: new Date().toISOString() });
58
+ const result = await appendContextItems(storeDir, items);
59
+ console.log(chalk.cyan("compaction context add"));
60
+ console.log(`Source: ${artifact}`);
61
+ console.log(`Store: ${storeDir}`);
62
+ console.log(`Items added: ${result.appended.length} skipped (duplicate): ${result.skipped.length} ` +
63
+ `evicted (size cap): ${result.evicted.length}`);
64
+ console.log(`Store now holds: ${result.total} items.`);
65
+ console.log("Local-only: nothing was uploaded. This makes no savings claim (see `compaction spend`/`summary` for cost).");
66
+ console.log(chalk.bold('Next: compaction context get "<your query>"'));
67
+ });
68
+ context
69
+ .command("get")
70
+ .argument("<query>", "Free-text description of the current step")
71
+ .option("--store-dir <dir>", "Context store directory (local, gitignored)", DEFAULT_STORE_DIR)
72
+ .option("--budget <tokens>", "Token budget for the assembled context (local estimate)", "4000")
73
+ .option("--source <pointer>", "Required source pointer for coverage diagnostics (repeatable)", collect, [])
74
+ .option("--limit <n>", "Max candidate items to consider after ranking")
75
+ .description("Retrieve + assemble the active context for a query from the local store (deterministic, local). " +
76
+ "Prints the assembled context WITH source pointers + local retrieval diagnostics — not a quality score.")
77
+ .action(async (query, options) => {
78
+ const storeDir = resolve(options.storeDir ?? DEFAULT_STORE_DIR);
79
+ const budget = Number.parseInt(options.budget ?? "4000", 10);
80
+ if (Number.isNaN(budget) || budget < 0) {
81
+ console.error("--budget must be a non-negative integer number of tokens.");
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+ let limit;
86
+ if (options.limit !== undefined) {
87
+ limit = Number.parseInt(options.limit, 10);
88
+ if (Number.isNaN(limit) || limit < 0) {
89
+ console.error("--limit must be a non-negative integer.");
90
+ process.exitCode = 1;
91
+ return;
92
+ }
93
+ }
94
+ const items = await loadContextStore(storeDir);
95
+ const retrievalOptions = limit !== undefined ? { limit } : {};
96
+ const start = performance.now();
97
+ const ranked = retrieveContextItems(items, { text: query, source_pointers: options.source && options.source.length > 0 ? options.source : undefined }, retrievalOptions);
98
+ const assembled = assembleContext(ranked, { tokenBudget: budget });
99
+ const latencyMs = performance.now() - start;
100
+ console.log(chalk.cyan("compaction context get"));
101
+ console.log(`Query: ${JSON.stringify(query)}`);
102
+ console.log(`Store: ${storeDir} (${items.length} items)`);
103
+ if (assembled.included.length === 0) {
104
+ console.log("Assembled context: (no matching context in the store for this query)");
105
+ }
106
+ else {
107
+ console.log(`Assembled context (~${assembled.estimated_tokens} tokens, local estimate; budget ${budget}):`);
108
+ for (const item of assembled.included) {
109
+ const pointer = item.source_pointer ?? "(no source pointer)";
110
+ const oneLine = item.content.replace(/\s+/g, " ");
111
+ console.log(` [${pointer}] ${oneLine}`);
112
+ }
113
+ }
114
+ const recoverable = assembled.included.filter((i) => i.recoverability === true && i.source_pointer).length;
115
+ console.log("Diagnostics (local retrieval, not a quality score):");
116
+ console.log(` considered: ${ranked.length} included: ${assembled.included.length} dropped: ${assembled.dropped.length}`);
117
+ console.log(` recoverable (has source pointer): ${recoverable}/${assembled.included.length}`);
118
+ if (options.source && options.source.length > 0) {
119
+ const includedPointers = new Set(assembled.included.map((i) => i.source_pointer).filter((p) => Boolean(p)));
120
+ const covered = options.source.filter((s) => includedPointers.has(s)).length;
121
+ console.log(` source coverage: ${covered}/${options.source.length}`);
122
+ }
123
+ else {
124
+ console.log(" source coverage: n/a (no --source given)");
125
+ }
126
+ console.log(` latency: ${latencyMs.toFixed(1)} ms`);
127
+ console.log("No model, no network. Local memory/retrieval view — it makes no savings claim and no quality/correctness claim.");
128
+ });
129
+ }
130
+ //# sourceMappingURL=context.js.map
@@ -5,8 +5,10 @@ function printSupportedSources() {
5
5
  console.log(chalk.cyan("compaction import sources"));
6
6
  for (const adapter of listTraceAdapters()) {
7
7
  console.log(`- ${adapter.id}: ${adapter.displayName}`);
8
+ console.log(` readiness: ${adapter.readiness}`);
8
9
  }
9
- console.log("Live provider/runtime integrations remain future work; codex-exec-jsonl reads only local export files.");
10
+ console.log("All sources are local file import (no live integration). Readiness above is an honest integration-readiness " +
11
+ "label (AGENTS.md Level 0–6) + validation status — not a stronger integration claim than the evidence supports.");
10
12
  }
11
13
  export function registerImportCommand(program) {
12
14
  program
@@ -1,216 +1,243 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+ import path from "node:path";
2
5
  import chalk from "chalk";
3
6
  import { defaultProjectsDir, discoverClaudeCodeSessions } from "../../core/adapters/claude-code-discovery.js";
7
+ import { BRAND_HEX, VALUE_PROMISE, STATUS_PILLS, PRIMARY, SECONDARY, FREE_FLOW, HONESTY_LINES, FOOTER_LINES, ALL_WORKFLOW_KEYS } from "../onboarding/model.js";
8
+ import { decideInteractiveTuiFromProcess } from "../onboarding/should-use-tui.js";
4
9
  /**
5
10
  * `compaction init` — first-run, value-led terminal onboarding.
6
11
  *
7
- * Converts "I installed a CLI" into "I am one command away from seeing my own
8
- * workflow's context waste." It is guidance plus ONE read-only local detection
9
- * (does a Claude Code projects directory exist, and how many sessions are in it).
12
+ * In a real interactive terminal this launches a graphical Ink TUI (a workflow
13
+ * chooser). Everywhere else pipes, CI, NO_COLOR, dumb terminals, the test
14
+ * runner, `--static`, or `--path <focus>` it prints the byte-stable static
15
+ * screen. Both surfaces share ONE model (`../onboarding/model`) so they always
16
+ * feature the same workflows, the same free follow-ups, and the same honest
17
+ * claim labels.
10
18
  *
11
- * Invariants (non-negotiable, hold on every path):
19
+ * Invariants (non-negotiable, hold on EVERY path — static and TUI):
12
20
  * - writes nothing
13
21
  * - makes no network call
14
22
  * - reads no provider credential
15
23
  * - needs no repo access
16
- * A provider credential is ONLY ever used if the operator explicitly runs
17
- * `capture provider-usage` (or enters operator-run measurement records).
24
+ * The TUI is a chooser, not an agent: no text input, no model call, no upload.
18
25
  *
19
- * Proprietary boundary (per mvp-plan.md §13): output names high-level waste
20
- * CATEGORIES and policy-FAMILY names plus before/after deltas, risk, safety,
21
- * approval requirement, artifacts, and the next action never the proprietary
22
- * scoring formulas, thresholds, hidden heuristics, future-policy internals, or
23
- * commercial / moat logic.
26
+ * Free-CLI scope: onboarding features ONLY the free, local-first commands
27
+ * (capture / import / analyze / spend / summary / feedback). The optimization &
28
+ * verification engine commands (recommend / compact / inspect / approve /
29
+ * apply-context) are the opt-in Compaction API NOT in the free CLI — and so
30
+ * are deliberately NOT featured here.
24
31
  *
25
- * Presentation note: this is a calm, structured, minimal first-run surface.
26
- * Hierarchy comes from spacing, dim secondary text, and a few restrained
27
- * symbols not from a wall of enumerated text.
32
+ * Honesty rails: token counts are provider-reported when usage metadata is
33
+ * present, otherwise a labeled local estimate (chars/4); cost is a price-table
34
+ * estimate; nothing is called a saving until it is measured. No billing-confirmed,
35
+ * no semantic-preservation, no hosted-API-live, no auto-apply, no demo-first.
28
36
  */
29
- // Clean one-line wordmark. The hand-drawn ASCII banner read busy at small
30
- // terminal sizes; a spaced, bold mark with a rule under it reads premium.
31
- const MARK = chalk.bold("compaction");
37
+ // Brand accent. chalk.hex degrades gracefully: on a non-color / non-truecolor
38
+ // terminal chalk down-samples or no-ops, so the text is never lost.
39
+ const ACCENT = chalk.hex(BRAND_HEX);
40
+ // Spaced wordmark reads as a premium brand mark at any terminal width.
41
+ const WORDMARK = ACCENT.bold("C O M P A C T I O N");
32
42
  const RULE = chalk.dim("─".repeat(58));
33
- const HEADLINE = "Context optimization for AI agents";
34
- const VALUE_PROP = chalk.dim("Reduce avoidable input/output tokens while preserving task-critical\n context. Local-first — nothing is applied without your approval.");
35
- // The value loop, compressed to one line per step. Each step earns its place.
36
- const LOOP = [
37
- ["capture", "a real trace from your own agent workflow"],
38
- ["analyze", "where token spend and avoidable context waste are"],
39
- ["recommend", "a candidate policy family + estimated before/after delta"],
40
- ["compact", "the before/after token delta, and which family produced it"],
41
- ["inspect", "safety evidence — source pointers, hashes, recoverable originals"],
42
- ["approve", "emit a compacted context for your next call. No auto-apply."]
43
- ];
44
- // Real-workflow paths only. compaction operates on the user's OWN agent
45
- // workflow; there is no demo / synthetic first-value path here. Demo fixtures
46
- // exist solely for tests, package smoke, and explicitly-labeled docs.
47
- const PRIMARY = {
48
- key: "claude-code",
49
- title: "Claude Code",
50
- command: "compaction capture claude-code --discover"
51
- };
52
- const SECONDARY = [
53
- {
54
- key: "codex",
55
- title: "Codex",
56
- command: "codex exec --json '<task>' > run.jsonl && compaction import run.jsonl --source codex-exec-jsonl --out ./my-trace"
57
- },
58
- {
59
- key: "openai-agents",
60
- title: "OpenAI Agents",
61
- command: "compaction capture openai-agents --out ./my-trace -- <your-command>"
62
- },
63
- {
64
- key: "import",
65
- title: "Local trace",
66
- command: "compaction import --list-sources # then: import <file> --source <source> --out ./my-trace"
43
+ async function readPackageVersion() {
44
+ try {
45
+ const here = path.dirname(fileURLToPath(import.meta.url));
46
+ // dist/cli/commands -> dist -> package root
47
+ const pkgPath = path.resolve(here, "..", "..", "..", "package.json");
48
+ const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
49
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
67
50
  }
68
- ];
69
- // The follow-up sequence is identical for every path once a trace exists.
70
- const FOLLOW_UP = [
71
- "compaction analyze ./my-trace/captured-trace.json",
72
- "compaction recommend ./my-trace/captured-trace.json",
73
- "compaction compact ./my-trace/captured-trace.json --out ./my-trace/compacted",
74
- "compaction apply-context ./my-trace/captured-trace.json --out ./my-trace/apply"
75
- ];
51
+ catch {
52
+ return "unknown";
53
+ }
54
+ }
55
+ async function detectClaudeCode(projectsDir) {
56
+ let claudeDetected = false;
57
+ let sessionCount = 0;
58
+ if (existsSync(projectsDir)) {
59
+ try {
60
+ const discovery = await discoverClaudeCodeSessions({ projectsDir });
61
+ sessionCount = discovery.sessions.length;
62
+ claudeDetected = sessionCount > 0;
63
+ }
64
+ catch {
65
+ claudeDetected = false; // detection is best-effort; onboarding never fails on it
66
+ }
67
+ }
68
+ return { claudeDetected, sessionCount, projectsDir };
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // Static screen (the universal fallback). Output is intentionally byte-stable:
72
+ // scripts, snapshots, and smoke tests depend on it.
73
+ // ---------------------------------------------------------------------------
76
74
  function header(text) {
77
75
  return chalk.bold(text);
78
76
  }
79
- function loopBlock() {
80
- const width = Math.max(...LOOP.map(([verb]) => verb.length));
81
- return LOOP.map(([verb, desc]) => ` ${chalk.cyan(verb.padEnd(width))} ${chalk.dim(desc)}`);
82
- }
83
77
  function primaryBlock(sessionCount) {
84
- const tag = chalk.green(` ✓ ${sessionCount} session(s) found`);
78
+ const tag = chalk.green(` ✓ ${sessionCount} session(s) found`);
85
79
  return [
86
- `${chalk.bold("▸ " + PRIMARY.title)}${chalk.dim(" · recommended")}${tag}`,
87
- ` ${chalk.cyan(PRIMARY.command)}`
80
+ `${chalk.bold("▸ " + PRIMARY.title)}${chalk.dim(" · recommended first run")}${tag}`,
81
+ ` ${ACCENT(PRIMARY.command)}`
88
82
  ];
89
83
  }
90
- function secondaryBlock() {
91
- const lines = [chalk.dim("Other workflows")];
92
- for (const p of SECONDARY) {
93
- lines.push(` ${chalk.dim(p.title)}`);
94
- lines.push(` ${chalk.cyan(p.command)}`);
95
- }
96
- return lines;
97
- }
98
84
  function emptyStateBlock(projectsDir) {
99
- // Calm, premium no-sessions state: explain, show how to create one, and give
100
- // the exact rerun command. The secondary real paths follow below.
101
85
  return [
102
- `${chalk.bold("▸ " + PRIMARY.title)}${chalk.dim(" · recommended")}`,
86
+ `${chalk.bold("▸ " + PRIMARY.title)}${chalk.dim(" · recommended first run")}`,
103
87
  chalk.dim(` No sessions found under ${projectsDir}.`),
104
88
  chalk.dim(" Start or run a Claude Code session in any project, then re-run:"),
105
- ` ${chalk.cyan("compaction capture claude-code --discover")}`
89
+ ` ${ACCENT("compaction capture claude-code --discover")}`
106
90
  ];
107
91
  }
92
+ function supportedBlock() {
93
+ const summary = "Claude Code · OpenAI Agents SDK · Codex / local import · local trace import";
94
+ const lines = [header("Supported workflows"), ` ${chalk.dim(summary)}`, ""];
95
+ for (const p of SECONDARY) {
96
+ lines.push(` ${chalk.bold(p.title)}`);
97
+ lines.push(` ${ACCENT(p.command)}`);
98
+ }
99
+ return lines;
100
+ }
108
101
  function afterCaptureBlock() {
102
+ const width = Math.max(...FREE_FLOW.map((f) => f.command.length));
109
103
  return [
110
104
  header("Then, on the trace you captured"),
111
105
  "",
112
- ...FOLLOW_UP.map((c) => ` ${chalk.cyan(c)}`),
106
+ ...FREE_FLOW.map((f) => ` ${ACCENT(f.command.padEnd(width))} ${chalk.dim(f.desc)}`),
107
+ "",
108
+ chalk.dim(` ${HONESTY_LINES[0]}`),
109
+ chalk.dim(` ${HONESTY_LINES[1]}`),
110
+ chalk.dim(` ${HONESTY_LINES[2]}`),
113
111
  "",
114
- chalk.dim(" You'll see: waste pattern CATEGORY, high-level POLICY FAMILY,"),
115
- chalk.dim(" before/after input/output tokens, the TOKEN-COUNT SOURCE"),
116
- chalk.dim(" (provider-reported vs estimated — never an estimate as reported),"),
117
- chalk.dim(" RISK LEVEL, safety + source_recoverability, the APPROVAL REQUIREMENT"),
118
- chalk.dim(" (--approve-in-workflow-use; no auto-apply), and the next action.")
112
+ chalk.dim(` ${HONESTY_LINES[3]}`),
113
+ chalk.dim(` ${HONESTY_LINES[4]}`)
119
114
  ];
120
115
  }
121
116
  function footerBlock() {
122
- return [
123
- chalk.dim("Local-first: no network, no credentials, no repo access for any of the above."),
124
- chalk.dim("A provider credential is only ever used if YOU explicitly run"),
125
- chalk.dim("`compaction capture provider-usage` or enter operator-run records.")
117
+ return FOOTER_LINES.map((l) => chalk.dim(l));
118
+ }
119
+ function buildStaticScreen(version, focus, det) {
120
+ const lines = [];
121
+ // 1. Wordmark + status + value promise.
122
+ lines.push("");
123
+ lines.push(` ${WORDMARK}`);
124
+ lines.push("");
125
+ lines.push(` ${chalk.dim(`v${version} · ${STATUS_PILLS.join(" · ")}`)}`);
126
+ lines.push("");
127
+ lines.push(` ${VALUE_PROMISE}`);
128
+ lines.push("");
129
+ lines.push(RULE);
130
+ lines.push("");
131
+ // 2. Start here — primary path (Claude Code) or a focused secondary path.
132
+ if (!focus || focus === "claude-code") {
133
+ lines.push(header("Start here"));
134
+ lines.push("");
135
+ if (det.claudeDetected) {
136
+ lines.push(...primaryBlock(det.sessionCount));
137
+ }
138
+ else {
139
+ lines.push(...emptyStateBlock(det.projectsDir));
140
+ }
141
+ lines.push("");
142
+ if (!focus) {
143
+ lines.push(...supportedBlock());
144
+ lines.push("");
145
+ }
146
+ }
147
+ else {
148
+ const p = SECONDARY.find((s) => s.key === focus);
149
+ lines.push(header("Start here"));
150
+ lines.push("");
151
+ lines.push(` ${chalk.bold(p.title)}`);
152
+ lines.push(` ${ACCENT(p.command)}`);
153
+ lines.push("");
154
+ }
155
+ lines.push(RULE);
156
+ lines.push("");
157
+ // 3. The free follow-up sequence + honest labeling + opt-in-API positioning.
158
+ lines.push(...afterCaptureBlock());
159
+ lines.push("");
160
+ lines.push(RULE);
161
+ lines.push("");
162
+ // 4. Local-first footer.
163
+ lines.push(...footerBlock());
164
+ lines.push("");
165
+ return lines.join("\n");
166
+ }
167
+ // After the TUI exits with a selection, print a clean, copy-ready next-command
168
+ // block to the scrollback so the user can paste and run it.
169
+ function printSelection(card) {
170
+ const out = [
171
+ "",
172
+ `${chalk.bold("▸ " + card.title)}`,
173
+ chalk.dim(` ${card.blurb}`),
174
+ "",
175
+ chalk.dim(" Run this next:"),
176
+ ` ${ACCENT(card.command)}`,
177
+ "",
178
+ chalk.dim(` Then: ${FREE_FLOW.map((f) => f.command.replace("compaction ", "")).join(" · ")}`),
179
+ chalk.dim(" Local-first: no network, no credentials, no upload. Nothing is called a"),
180
+ chalk.dim(" saving until it is measured."),
181
+ ""
126
182
  ];
183
+ console.log(out.join("\n"));
127
184
  }
128
185
  export function registerInitCommand(program) {
129
186
  program
130
187
  .command("init")
131
- .description("First-run onboarding: the value loop, how to capture YOUR first real trace, and the exact next commands. " +
132
- "Guidance + one read-only local detection only writes nothing, no network, no credentials.")
133
- .option("--path <path>", "Focus one input path: claude-code | codex | openai-agents | import")
188
+ .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.")
191
+ .option("--path <path>", "Focus one input path: claude-code | openai-agents | codex | import")
134
192
  .option("--projects-dir <dir>", "Claude Code projects directory to detect against (default: the standard local location)")
193
+ .option("--interactive", "prefer the interactive TUI; if no TTY is available, show the static screen and why")
194
+ .option("--static", "always show the plain static screen (no TUI)")
135
195
  .action(async (options) => {
136
196
  // Validate --path early so the error stays a clean one-liner.
137
- const allKeys = [PRIMARY.key, ...SECONDARY.map((p) => p.key)];
138
- if (options.path && !allKeys.includes(options.path)) {
139
- console.error(`unknown --path '${options.path}'. Valid: ${allKeys.join(" | ")}`);
197
+ if (options.path && !ALL_WORKFLOW_KEYS.includes(options.path)) {
198
+ console.error(`unknown --path '${options.path}'. Valid: ${ALL_WORKFLOW_KEYS.join(" | ")}`);
140
199
  process.exitCode = 1;
141
200
  return;
142
201
  }
202
+ // Version read at runtime so it always matches the installed package.
203
+ const version = await readPackageVersion();
143
204
  // Read-only local detection (no writes, no network).
144
205
  const projectsDir = options.projectsDir ?? defaultProjectsDir();
145
- let claudeDetected = false;
146
- let sessionCount = 0;
147
- if (existsSync(projectsDir)) {
148
- try {
149
- const discovery = await discoverClaudeCodeSessions({ projectsDir });
150
- sessionCount = discovery.sessions.length;
151
- claudeDetected = sessionCount > 0;
152
- }
153
- catch {
154
- claudeDetected = false; // detection is best-effort; onboarding never fails on it
155
- }
156
- }
206
+ const det = await detectClaudeCode(projectsDir);
207
+ // Decide static vs interactive. A focused path (--path) or --static always
208
+ // serves static; --interactive forces the TUI iff a TTY is present;
209
+ // otherwise auto-detect.
210
+ // Note: --interactive cannot conjure a TTY (raw-mode keyboard nav needs
211
+ // one), so it does not override the TTY check; it only documents intent.
212
+ // When asked for but unavailable, we fall back to static with a reason.
157
213
  const focus = options.path;
158
- const lines = [];
159
- // 1. Mark + value prop.
160
- lines.push("");
161
- lines.push(`${MARK} ${chalk.dim("·")} ${HEADLINE}`);
162
- lines.push(` ${VALUE_PROP}`);
163
- lines.push("");
164
- lines.push(RULE);
165
- lines.push("");
166
- // 2. The value loop — what the tool actually does, one line per step.
167
- lines.push(header("How it works"));
168
- lines.push("");
169
- lines.push(...loopBlock());
170
- lines.push("");
171
- lines.push(chalk.dim(" Estimates are labeled as estimates; provider-reported as provider-reported."));
172
- lines.push(chalk.dim(" Nothing is called a saving until it is measured."));
173
- lines.push("");
174
- lines.push(RULE);
175
- lines.push("");
176
- // 3. Start here — primary path (Claude Code) or a focused secondary path.
177
- if (!focus || focus === "claude-code") {
178
- lines.push(header("Start here"));
179
- lines.push("");
180
- if (claudeDetected) {
181
- lines.push(...primaryBlock(sessionCount));
214
+ const ttyDecision = decideInteractiveTuiFromProcess();
215
+ const wantTui = !focus && !options.static && ttyDecision.useTui;
216
+ if (wantTui) {
217
+ // Dynamic import keeps Ink/React off every non-TTY code path.
218
+ 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
225
+ }
226
+ });
227
+ if (result.action === "select") {
228
+ printSelection(result.card);
182
229
  }
183
230
  else {
184
- lines.push(...emptyStateBlock(projectsDir));
185
- }
186
- lines.push("");
187
- // 4. Secondary real-workflow paths (compact, secondary styling).
188
- // Only on the default (unfocused) view.
189
- if (!focus) {
190
- lines.push(...secondaryBlock());
191
- lines.push("");
231
+ console.log(chalk.dim("\nNo workflow selected. Run `compaction init` anytime.\n"));
192
232
  }
233
+ return;
193
234
  }
194
- else {
195
- // Focused secondary path.
196
- const p = SECONDARY.find((s) => s.key === focus);
197
- lines.push(header("Start here"));
198
- lines.push("");
199
- lines.push(` ${chalk.bold(p.title)}`);
200
- lines.push(` ${chalk.cyan(p.command)}`);
201
- lines.push("");
235
+ // If the user explicitly asked for --interactive but we are not in a TTY,
236
+ // tell them why we fell back, then serve the static screen.
237
+ if (options.interactive && !ttyDecision.useTui) {
238
+ console.error(chalk.dim(`(interactive TUI unavailable here: ${ttyDecision.reason}; showing static screen)`));
202
239
  }
203
- lines.push(RULE);
204
- lines.push("");
205
- // 5. The follow-up sequence + what to expect.
206
- lines.push(...afterCaptureBlock());
207
- lines.push("");
208
- lines.push(RULE);
209
- lines.push("");
210
- // 6. Local-first footer.
211
- lines.push(...footerBlock());
212
- lines.push("");
213
- console.log(lines.join("\n"));
240
+ console.log(buildStaticScreen(version, focus, det));
214
241
  });
215
242
  }
216
243
  //# sourceMappingURL=init.js.map