@compaction/cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +142 -64
  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
@@ -0,0 +1,99 @@
1
+ /** Default local preference file (under the gitignored `.compaction/`). */
2
+ export declare const DEFAULT_POLICY_PREFERENCES_DIRECTORY = ".compaction";
3
+ export declare const POLICY_PREFERENCES_FILENAME = "policy-preferences.json";
4
+ /** The binary preference (mirror of `AUTO_APPLY_PREFERENCES` in activity-event.ts). Default = ask. */
5
+ export declare const POLICY_PREFERENCE_VALUES: readonly ["ask-each-time", "auto-when-gates-pass"];
6
+ export type PolicyPreferenceValue = (typeof POLICY_PREFERENCE_VALUES)[number];
7
+ /** Tool values that are NEVER an allowed scope — a preference is never global or cross-tool. */
8
+ export declare const REJECTED_GLOBAL_TOOL_VALUES: readonly string[];
9
+ /**
10
+ * The safety gates a preference's auto-apply WOULD require (founder decision: "explicit opt-in +
11
+ * matching scope + original retained + capsule provenance + recoverability pass + threshold + risk
12
+ * checks + evidence label emitted + rollback path"). POPULATED for provenance/explanation; NOT
13
+ * EVALUATED this cycle — no gate engine exists, so no preference can drive an application.
14
+ */
15
+ export declare const DEFAULT_GATES_REQUIRED: readonly string[];
16
+ /** The inferred safest scope of a preference: one tool/workflow, optionally one repo, one policy type. */
17
+ export interface PolicyPreferenceScope {
18
+ /** REQUIRED. The workflow/tool this preference is scoped to (never "global"/"all"/cross-tool). */
19
+ tool: string;
20
+ /** OPTIONAL. The repo the preference is scoped to, when detectable (content-free identifier). */
21
+ repo?: string;
22
+ /** REQUIRED. Which policy type this preference concerns (content-free identifier). */
23
+ policy_type: string;
24
+ }
25
+ /** One stored preference record. Content-free; no wall-clock; id is deterministic over the scope. */
26
+ export interface PolicyPreference {
27
+ /** Deterministic content-free id: `pref-` + 24 hex of sha-256 over the canonical scope. */
28
+ id: string;
29
+ scope: PolicyPreferenceScope;
30
+ preference: PolicyPreferenceValue;
31
+ /** Whether this preference is live. `disable` sets this false; there is NO enable in the CLI. */
32
+ enabled: boolean;
33
+ /** Safety gates the (future) gated auto-apply slice would require. POPULATED, NOT EVALUATED here. */
34
+ gates_required: string[];
35
+ }
36
+ /** Input to save a preference — id, enabled default, and gates_required are derived when absent. */
37
+ export interface SavePolicyPreferenceInput {
38
+ scope: PolicyPreferenceScope;
39
+ preference: PolicyPreferenceValue;
40
+ /** Defaults to true (the record is stored so the future live ask can honor it); disable turns it off. */
41
+ enabled?: boolean;
42
+ /** Defaults to `DEFAULT_GATES_REQUIRED`; a caller may pass a narrower list (never evaluated here). */
43
+ gates_required?: string[];
44
+ }
45
+ export interface PolicyPreferenceValidation {
46
+ problems: string[];
47
+ }
48
+ /** The longest a content-free scope/gate identifier may be — anything longer looks like content. */
49
+ export declare const POLICY_PREFERENCE_MAX_STRING_LENGTH = 200;
50
+ /**
51
+ * Validate a scope: `tool` and `policy_type` REQUIRED and content-free; `repo` optional; the tool
52
+ * is NEVER a global/all/cross-tool value (case-insensitive). Report-only (never throws).
53
+ */
54
+ export declare function validatePolicyPreferenceScope(scope: unknown): PolicyPreferenceValidation;
55
+ /** Deterministic content-free id over the scope only (so a scope has ONE preference record). */
56
+ export declare function computePolicyPreferenceId(scope: PolicyPreferenceScope): string;
57
+ export declare const POLICY_PREFERENCE_ID_PATTERN: RegExp;
58
+ /** Read all stored preferences. A missing file means "none saved yet" (empty, not an error). */
59
+ export declare function readPolicyPreferences(directory?: string): Promise<{
60
+ preferences: PolicyPreference[];
61
+ }>;
62
+ export type SavePolicyPreferenceResult = {
63
+ saved: true;
64
+ preference: PolicyPreference;
65
+ path: string;
66
+ replacedExisting: boolean;
67
+ } | {
68
+ saved: false;
69
+ problems: string[];
70
+ };
71
+ /**
72
+ * Save (upsert by deterministic scope id) ONE preference to the local store. Fail-closed: an
73
+ * invalid scope saves NOTHING. THIS DOES NOT APPLY ANYTHING — it writes one JSON file and returns;
74
+ * there is no application path here (proven by the fail-closed test).
75
+ */
76
+ export declare function savePolicyPreference(input: SavePolicyPreferenceInput, directory?: string): Promise<SavePolicyPreferenceResult>;
77
+ export type DisablePolicyPreferenceResult = {
78
+ disabled: true;
79
+ preference: PolicyPreference;
80
+ path: string;
81
+ alreadyDisabled: boolean;
82
+ } | {
83
+ disabled: false;
84
+ reason: string;
85
+ };
86
+ /**
87
+ * Disable a preference by id (set enabled=false). The safe management verb — disabling can only
88
+ * ever REDUCE what could apply, so it is always safe. Unknown id → a clear error, nothing changed.
89
+ * Idempotent: disabling an already-disabled preference is a reported no-op (no throw).
90
+ */
91
+ export declare function disablePolicyPreference(id: string, directory?: string): Promise<DisablePolicyPreferenceResult>;
92
+ /** Look up one preference by id (read-only). */
93
+ export declare function findPolicyPreference(id: string, directory?: string): Promise<PolicyPreference | undefined>;
94
+ /**
95
+ * Plain-language explanation of what a preference MEANS — honest that auto-apply is NOT YET ACTIVE
96
+ * (the preference is stored; application is pending the gated auto-apply slice, which does not
97
+ * exist yet). No wall-clock, no content — scope, policy, gates, recovery, and how to disable.
98
+ */
99
+ export declare function explainPolicyPreference(preference: PolicyPreference): string;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * LOCAL auto-apply PREFERENCE store (charter 2026-07-04-run-7, lane 4: "compaction policies
3
+ * list/disable/explain" + "auto-apply preference model: binary ask-after-approve, inferred safest
4
+ * scope, fail-closed gates scaffold, tests proving OFF by default").
5
+ *
6
+ * WHAT THIS IS — and, more importantly, WHAT IT IS NOT:
7
+ * This module STORES and READS a user's binary auto-apply *preference* (the one the founder's live
8
+ * post-approval ask would save: "Apply this automatically next time for this workflow when safety
9
+ * gates pass? [yes/no, default no]"). It is a PREFERENCE record only. It is NOT an application
10
+ * engine:
11
+ *
12
+ * FAIL-CLOSED INVARIANT: a preference EXISTING (even enabled=true, even
13
+ * preference="auto-when-gates-pass") does NOT cause anything to be applied anywhere. There is no
14
+ * apply path in this module — it imports nothing that mutates a user session, trace, run record,
15
+ * or context, and it exports no apply function. `gates_required` is POPULATED but NEVER EVALUATED
16
+ * this cycle (no gate engine exists). Proven by `tests/core/policy-preferences.test.ts`
17
+ * (the fail-closed proof: saving/enabling a preference mutates ONLY this store's JSON file and
18
+ * invokes no application).
19
+ *
20
+ * SCOPE IS NEVER GLOBAL AND NEVER CROSS-TOOL (founder decision): `tool` is REQUIRED, and a
21
+ * "global"/"all"/"*" tool value is REJECTED at validation. A preference is always scoped to one
22
+ * workflow/tool (+ repo where detectable + policy type).
23
+ *
24
+ * NO WALL-CLOCK: the record carries no `created_at` — the id is DETERMINISTIC over the scope, so
25
+ * the same scope always maps to the same preference record (an upsert key, content-free).
26
+ *
27
+ * LOCAL FILE I/O ONLY: one JSON file under `.compaction/` (already gitignored). No network, no new
28
+ * dependencies.
29
+ */
30
+ import { createHash } from "node:crypto";
31
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
32
+ import { dirname, join } from "node:path";
33
+ /** Default local preference file (under the gitignored `.compaction/`). */
34
+ export const DEFAULT_POLICY_PREFERENCES_DIRECTORY = ".compaction";
35
+ export const POLICY_PREFERENCES_FILENAME = "policy-preferences.json";
36
+ /** The binary preference (mirror of `AUTO_APPLY_PREFERENCES` in activity-event.ts). Default = ask. */
37
+ export const POLICY_PREFERENCE_VALUES = ["ask-each-time", "auto-when-gates-pass"];
38
+ /** Tool values that are NEVER an allowed scope — a preference is never global or cross-tool. */
39
+ export const REJECTED_GLOBAL_TOOL_VALUES = ["global", "all", "*", "any", "cross-tool"];
40
+ /**
41
+ * The safety gates a preference's auto-apply WOULD require (founder decision: "explicit opt-in +
42
+ * matching scope + original retained + capsule provenance + recoverability pass + threshold + risk
43
+ * checks + evidence label emitted + rollback path"). POPULATED for provenance/explanation; NOT
44
+ * EVALUATED this cycle — no gate engine exists, so no preference can drive an application.
45
+ */
46
+ export const DEFAULT_GATES_REQUIRED = [
47
+ "scope-match",
48
+ "original-retained",
49
+ "capsule-provenance",
50
+ "recoverability-pass",
51
+ "reduction-threshold",
52
+ "risk-checks",
53
+ "evidence-label-emitted",
54
+ "rollback-path"
55
+ ];
56
+ function isNonEmptyString(value) {
57
+ return typeof value === "string" && value.trim() !== "";
58
+ }
59
+ /** The longest a content-free scope/gate identifier may be — anything longer looks like content. */
60
+ export const POLICY_PREFERENCE_MAX_STRING_LENGTH = 200;
61
+ /**
62
+ * Validate a scope: `tool` and `policy_type` REQUIRED and content-free; `repo` optional; the tool
63
+ * is NEVER a global/all/cross-tool value (case-insensitive). Report-only (never throws).
64
+ */
65
+ export function validatePolicyPreferenceScope(scope) {
66
+ const problems = [];
67
+ if (typeof scope !== "object" || scope === null || Array.isArray(scope)) {
68
+ return { problems: ["scope: must be an object ({ tool, repo?, policy_type })"] };
69
+ }
70
+ const record = scope;
71
+ if (!isNonEmptyString(record.tool)) {
72
+ problems.push("scope.tool: required — a preference is always scoped to one workflow/tool (never global)");
73
+ }
74
+ else if (REJECTED_GLOBAL_TOOL_VALUES.includes(record.tool.trim().toLowerCase())) {
75
+ problems.push(`scope.tool: "${record.tool}" is rejected — a preference is NEVER global and NEVER cross-tool (founder rule); scope it to one tool`);
76
+ }
77
+ if (!isNonEmptyString(record.policy_type)) {
78
+ problems.push("scope.policy_type: required — which policy this preference concerns (content-free identifier)");
79
+ }
80
+ if (record.repo !== undefined && !isNonEmptyString(record.repo)) {
81
+ problems.push("scope.repo: when present, must be a non-empty content-free repo identifier");
82
+ }
83
+ for (const [key, value] of Object.entries(record)) {
84
+ if (!["tool", "repo", "policy_type"].includes(key)) {
85
+ problems.push(`scope.${key}: not an allowed scope field (only tool, repo, policy_type)`);
86
+ continue;
87
+ }
88
+ if (typeof value === "string" && value.length > POLICY_PREFERENCE_MAX_STRING_LENGTH) {
89
+ problems.push(`scope.${key}: string exceeds the content-free bound (${value.length} chars) — that is content-sized`);
90
+ }
91
+ }
92
+ return { problems };
93
+ }
94
+ /** Deterministic content-free id over the scope only (so a scope has ONE preference record). */
95
+ export function computePolicyPreferenceId(scope) {
96
+ const canonical = JSON.stringify({
97
+ policy_type: scope.policy_type,
98
+ repo: scope.repo ?? null,
99
+ tool: scope.tool
100
+ });
101
+ const digest = createHash("sha256").update(canonical).digest("hex");
102
+ return `pref-${digest.slice(0, 24)}`;
103
+ }
104
+ export const POLICY_PREFERENCE_ID_PATTERN = /^pref-[0-9a-f]{24}$/;
105
+ function preferencesPath(directory) {
106
+ return join(directory, POLICY_PREFERENCES_FILENAME);
107
+ }
108
+ /** Read all stored preferences. A missing file means "none saved yet" (empty, not an error). */
109
+ export async function readPolicyPreferences(directory = DEFAULT_POLICY_PREFERENCES_DIRECTORY) {
110
+ let raw;
111
+ try {
112
+ raw = await readFile(preferencesPath(directory), "utf8");
113
+ }
114
+ catch (error) {
115
+ const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "unknown";
116
+ if (code === "ENOENT")
117
+ return { preferences: [] };
118
+ throw error;
119
+ }
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(raw);
123
+ }
124
+ catch {
125
+ throw new Error(`${preferencesPath(directory)} is not valid JSON — refusing to guess (fail-closed)`);
126
+ }
127
+ const list = Array.isArray(parsed)
128
+ ? parsed
129
+ : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.preferences)
130
+ ? parsed.preferences
131
+ : [];
132
+ const preferences = list.filter((entry) => {
133
+ if (typeof entry !== "object" || entry === null)
134
+ return false;
135
+ const candidate = entry;
136
+ return (typeof candidate.id === "string" &&
137
+ typeof candidate.enabled === "boolean" &&
138
+ POLICY_PREFERENCE_VALUES.includes(candidate.preference) &&
139
+ validatePolicyPreferenceScope(candidate.scope).problems.length === 0);
140
+ });
141
+ return { preferences };
142
+ }
143
+ /**
144
+ * Save (upsert by deterministic scope id) ONE preference to the local store. Fail-closed: an
145
+ * invalid scope saves NOTHING. THIS DOES NOT APPLY ANYTHING — it writes one JSON file and returns;
146
+ * there is no application path here (proven by the fail-closed test).
147
+ */
148
+ export async function savePolicyPreference(input, directory = DEFAULT_POLICY_PREFERENCES_DIRECTORY) {
149
+ const problems = validatePolicyPreferenceScope(input.scope).problems;
150
+ if (!POLICY_PREFERENCE_VALUES.includes(input.preference)) {
151
+ problems.push(`preference: must be one of ${POLICY_PREFERENCE_VALUES.join(" | ")} (exact)`);
152
+ }
153
+ if (problems.length > 0)
154
+ return { saved: false, problems };
155
+ const scope = {
156
+ tool: input.scope.tool,
157
+ policy_type: input.scope.policy_type,
158
+ ...(input.scope.repo !== undefined ? { repo: input.scope.repo } : {})
159
+ };
160
+ const record = {
161
+ id: computePolicyPreferenceId(scope),
162
+ scope,
163
+ preference: input.preference,
164
+ enabled: input.enabled ?? true,
165
+ gates_required: input.gates_required ? [...input.gates_required] : [...DEFAULT_GATES_REQUIRED]
166
+ };
167
+ const { preferences } = await readPolicyPreferences(directory);
168
+ const existingIndex = preferences.findIndex((entry) => entry.id === record.id);
169
+ const replacedExisting = existingIndex >= 0;
170
+ const next = replacedExisting
171
+ ? preferences.map((entry, index) => (index === existingIndex ? record : entry))
172
+ : [...preferences, record];
173
+ const path = preferencesPath(directory);
174
+ await mkdir(dirname(path), { recursive: true });
175
+ await writeFile(path, `${JSON.stringify({ preferences: next }, null, 2)}\n`, "utf8");
176
+ return { saved: true, preference: record, path, replacedExisting };
177
+ }
178
+ /**
179
+ * Disable a preference by id (set enabled=false). The safe management verb — disabling can only
180
+ * ever REDUCE what could apply, so it is always safe. Unknown id → a clear error, nothing changed.
181
+ * Idempotent: disabling an already-disabled preference is a reported no-op (no throw).
182
+ */
183
+ export async function disablePolicyPreference(id, directory = DEFAULT_POLICY_PREFERENCES_DIRECTORY) {
184
+ const { preferences } = await readPolicyPreferences(directory);
185
+ const index = preferences.findIndex((entry) => entry.id === id);
186
+ if (index < 0) {
187
+ return { disabled: false, reason: `no preference with id "${id}" — nothing changed (run "compaction policies list")` };
188
+ }
189
+ const target = preferences[index];
190
+ if (target.enabled === false) {
191
+ return { disabled: true, preference: target, path: preferencesPath(directory), alreadyDisabled: true };
192
+ }
193
+ const updated = { ...target, enabled: false };
194
+ const next = preferences.map((entry, i) => (i === index ? updated : entry));
195
+ const path = preferencesPath(directory);
196
+ await mkdir(dirname(path), { recursive: true });
197
+ await writeFile(path, `${JSON.stringify({ preferences: next }, null, 2)}\n`, "utf8");
198
+ return { disabled: true, preference: updated, path, alreadyDisabled: false };
199
+ }
200
+ /** Look up one preference by id (read-only). */
201
+ export async function findPolicyPreference(id, directory = DEFAULT_POLICY_PREFERENCES_DIRECTORY) {
202
+ const { preferences } = await readPolicyPreferences(directory);
203
+ return preferences.find((entry) => entry.id === id);
204
+ }
205
+ /**
206
+ * Plain-language explanation of what a preference MEANS — honest that auto-apply is NOT YET ACTIVE
207
+ * (the preference is stored; application is pending the gated auto-apply slice, which does not
208
+ * exist yet). No wall-clock, no content — scope, policy, gates, recovery, and how to disable.
209
+ */
210
+ export function explainPolicyPreference(preference) {
211
+ const scopeLine = preference.scope.repo
212
+ ? `the "${preference.scope.tool}" workflow in repo "${preference.scope.repo}"`
213
+ : `the "${preference.scope.tool}" workflow`;
214
+ const lines = [
215
+ `Preference ${preference.id}`,
216
+ ``,
217
+ `Scope: ${scopeLine}`,
218
+ `Policy: ${preference.scope.policy_type}`,
219
+ `Preference: ${preference.preference}`,
220
+ `Enabled: ${preference.enabled ? "yes" : "no (disabled)"}`,
221
+ ``
222
+ ];
223
+ if (preference.preference === "auto-when-gates-pass") {
224
+ lines.push(`What it would mean: for ${scopeLine}, you chose "apply automatically next time WHEN ALL`, `safety gates pass". Auto-apply would happen ONLY if every one of these gates passes:`, ...preference.gates_required.map((gate) => ` - ${gate}`), ``, `NOT YET ACTIVE: no auto-apply engine exists yet. This preference is STORED only; nothing is`, `applied automatically today. When the gated auto-apply slice ships, an application will run`, `ONLY inside these gates, the original will be retained, and every application will be`, `recoverable and logged with an evidence label.`);
225
+ }
226
+ else {
227
+ lines.push(`What it means: for ${scopeLine}, Compaction ASKS every time before applying anything`, `(the default). Nothing is ever applied automatically under this preference.`);
228
+ }
229
+ lines.push(``, `Originals are always retained; every application (once the gated slice exists) is reversible.`, `To turn this preference off: compaction policies disable ${preference.id}`);
230
+ return lines.join("\n");
231
+ }
232
+ //# sourceMappingURL=policy-preferences.js.map
@@ -1,3 +1,4 @@
1
+ import { type PerToolRunRecordRollup } from "./local-run-record.js";
1
2
  import type { CompactionReport, WasteFinding } from "./types.js";
2
3
  /**
3
4
  * Duplicate-safety summary for the `summary` rollup, by content fingerprint. A count of DISTINCT
@@ -83,6 +84,15 @@ export interface RunSummary extends RunSummaryBucket {
83
84
  * are never inflated. Runs without a fingerprint are each counted as distinct.
84
85
  */
85
86
  distinctness: RunDistinctnessSummary;
87
+ /**
88
+ * ADDITIVE (unified-run-flow LOCAL layer, 2026-07-02): per-tool rollup of the locally accumulated
89
+ * per-run token_source records (`.compaction/run-records/*.json`). Observed token COUNTS + their
90
+ * honest per-axis `token_source` tiers ONLY (mirrors docs/ops/app-source-status-contract.md) —
91
+ * no cost figures, and `output_savings` is the LITERAL "unavailable" for every tool (gated).
92
+ */
93
+ per_tool_token_records: PerToolRunRecordRollup[];
94
+ run_records_source_glob: string;
95
+ run_record_skipped_files: SkippedReportFile[];
86
96
  generated_at: string;
87
97
  source_glob: string;
88
98
  }
@@ -108,7 +118,7 @@ export declare function readCompactionRunReports(runsDirectory?: string): Promis
108
118
  skippedFiles: SkippedReportFile[];
109
119
  }>;
110
120
  export declare function aggregateCompactionReports(reports: LoadedReport[], skippedFiles?: SkippedReportFile[], generatedAt?: string, sourceGlob?: string): RunSummary;
111
- export declare function createRunSummary(runsDirectory?: string): Promise<RunSummary>;
121
+ export declare function createRunSummary(runsDirectory?: string, recordsDirectory?: string): Promise<RunSummary>;
112
122
  export declare function formatRunSummary(summary: RunSummary): string;
113
123
  export declare function formatRunSummaryMarkdown(summary: RunSummary): string;
114
124
  export {};
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { DEFAULT_RUN_RECORDS_DIRECTORY, formatPerToolRunRecordLines, PER_TOOL_RECORDS_HEADER, readLocalRunTokenRecords, summarizePerToolRunRecords } from "./local-run-record.js";
3
4
  import { classifyDistinctness } from "./trace-fingerprint.js";
4
5
  const REQUIRED_STRING_FIELDS = ["run_id", "trace_title", "policy_name"];
5
6
  const REQUIRED_NUMBER_FIELDS = [
@@ -211,6 +212,9 @@ export function aggregateCompactionReports(reports, skippedFiles = [], generated
211
212
  top_savings_runs: [],
212
213
  skipped_files: skippedFiles,
213
214
  distinctness,
215
+ per_tool_token_records: [],
216
+ run_records_source_glob: `${DEFAULT_RUN_RECORDS_DIRECTORY}/*.json`,
217
+ run_record_skipped_files: [],
214
218
  generated_at: generatedAt,
215
219
  source_glob: sourceGlob
216
220
  };
@@ -256,9 +260,16 @@ export function aggregateCompactionReports(reports, skippedFiles = [], generated
256
260
  .slice(0, 5);
257
261
  return summary;
258
262
  }
259
- export async function createRunSummary(runsDirectory = ".compaction/runs") {
263
+ export async function createRunSummary(runsDirectory = ".compaction/runs", recordsDirectory = DEFAULT_RUN_RECORDS_DIRECTORY) {
260
264
  const { reports, skippedFiles } = await readCompactionRunReports(runsDirectory);
261
- return aggregateCompactionReports(reports, skippedFiles, new Date().toISOString(), `${runsDirectory}/*/report.json`);
265
+ const summary = aggregateCompactionReports(reports, skippedFiles, new Date().toISOString(), `${runsDirectory}/*/report.json`);
266
+ // ADDITIVE per-tool token_source rollup from the locally accumulated run records (LOCAL layer of the
267
+ // unified run flow). Counts + honest sources only — never content, never an output-savings figure.
268
+ const { records, skipped } = await readLocalRunTokenRecords(recordsDirectory);
269
+ summary.per_tool_token_records = summarizePerToolRunRecords(records.map((loaded) => loaded.record));
270
+ summary.run_records_source_glob = `${recordsDirectory}/*.json`;
271
+ summary.run_record_skipped_files = skipped;
272
+ return summary;
262
273
  }
263
274
  function formatCurrency(value) {
264
275
  return `$${value.toFixed(6)}`;
@@ -317,6 +328,14 @@ export function formatRunSummary(summary) {
317
328
  "Top savings runs",
318
329
  ...(topRunLines.length === 0 ? ["- none"] : topRunLines),
319
330
  "",
331
+ // LOCAL layer of the unified run flow: per-tool token_source rollup of the accumulated
332
+ // run records. Counts + sources only — no cost/savings figures in this section.
333
+ "Per-tool run records (token sources)",
334
+ `Source: ${summary.run_records_source_glob}`,
335
+ PER_TOOL_RECORDS_HEADER,
336
+ ...formatPerToolRunRecordLines(summary.per_tool_token_records),
337
+ ...summary.run_record_skipped_files.map((file) => `- skipped record ${file.path}: ${file.reason}`),
338
+ "",
320
339
  "Skipped files",
321
340
  ...(skippedLines.length === 0 ? ["- none"] : skippedLines)
322
341
  ].join("\n");
@@ -370,6 +389,14 @@ export function formatRunSummaryMarkdown(summary) {
370
389
  "| --- | --- | ---: | ---: | ---: |",
371
390
  ...(topRunRows.length === 0 ? ["| none | none | 0 | 0.00% | $0.000000 |"] : topRunRows),
372
391
  "",
392
+ "## Per-tool run records (token sources)",
393
+ "",
394
+ `- Source: ${summary.run_records_source_glob}`,
395
+ `- ${PER_TOOL_RECORDS_HEADER}`,
396
+ "",
397
+ ...formatPerToolRunRecordLines(summary.per_tool_token_records),
398
+ ...summary.run_record_skipped_files.map((file) => `- skipped record ${file.path}: ${file.reason}`),
399
+ "",
373
400
  "## Skipped files",
374
401
  "",
375
402
  "| Path | Reason |",
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Shared HONEST report for the unified `compaction run <tool> -- …` flow (PUBLIC CLI/SDK — engine-free).
3
+ * Design: `docs/design/unified-run-flow.md` (ACCEPTED 2026-06-28, founder D1/D2-amended/D3), specifically
4
+ * the D2 honesty matrix.
5
+ *
6
+ * This renders the per-tool token honesty for a captured run, keeping the THREE axes distinct and never
7
+ * conflating them (founder D2):
8
+ * 1. provider-reported tokens (Codex / Claude Code)
9
+ * 2. locally observed / estimated tokens (Cursor input; Cursor output where separable)
10
+ * 3. savings ← output savings are NEVER shown here
11
+ *
12
+ * Binding rails encoded here (see the design's honesty matrix):
13
+ * - `token_source` is surfaced explicitly as **provider-reported | local-estimate | unavailable**.
14
+ * - **Input and output tokens are shown SEPARATELY.**
15
+ * - Output tokens are shown **as tokens, never as a saving** (output-shaping savings are a gated
16
+ * first-impl; no output-savings figure until measured + eval-confirmed).
17
+ * - Input before/after + reduction (the REAL compaction saving) may be shown by the caller from the
18
+ * compaction report; this module labels that reduction measured-vs-estimated per the token source.
19
+ *
20
+ * This module computes NOTHING proprietary and reads NO engine internals — it only formats counts and
21
+ * honest source labels already produced by the per-tool capture (`UsageMetadata` + output status).
22
+ */
23
+ import type { UsageMetadata } from "./usage-metadata.js";
24
+ /** The honest per-FIELD token source, per the D2 honesty matrix. */
25
+ export type RunFlowTokenSource = "provider-reported" | "local-estimate" | "unavailable";
26
+ /** Whether the wrapper could safely separate/count output tokens for this run. */
27
+ export type RunFlowOutputStatus = "present" | "unavailable";
28
+ export interface RunFlowTokenReport {
29
+ tool: string;
30
+ /** Source for the INPUT token count (never stronger than the evidence). */
31
+ input_token_source: RunFlowTokenSource;
32
+ /** Source for the OUTPUT token count; `unavailable` when the field could not be safely counted. */
33
+ output_token_source: RunFlowTokenSource;
34
+ input_tokens?: number;
35
+ output_tokens?: number;
36
+ /**
37
+ * Label for how the (real, input) compaction reduction shown by the caller should be read:
38
+ * `measured` when the capture is provider-reported, `estimated` when it is a local estimate.
39
+ */
40
+ input_reduction_label: "measured" | "estimated";
41
+ /** Honest notes carried from the capture (e.g. "not billing-confirmed", "no separable result field"). */
42
+ notes: string[];
43
+ }
44
+ /**
45
+ * Build the honest per-run token report from a capture's `UsageMetadata` and the wrapper's output status.
46
+ * `outputStatus === "unavailable"` forces `output_token_source: "unavailable"` regardless of the input
47
+ * source — a genuinely-missing output field is never silently labeled provider-reported or local-estimate.
48
+ */
49
+ export declare function buildRunFlowTokenReport(params: {
50
+ tool: string;
51
+ usage: UsageMetadata;
52
+ outputStatus: RunFlowOutputStatus;
53
+ }): RunFlowTokenReport;
54
+ /** Optional TRUE per-axis unavailability reasons from the capture (printed on the token lines). */
55
+ export interface RunFlowUnavailableReasons {
56
+ input?: string;
57
+ output?: string;
58
+ }
59
+ export interface RunFlowFormatOptions {
60
+ reasons?: RunFlowUnavailableReasons;
61
+ /**
62
+ * Set false on surfaces that do NOT print a compaction input-reduction below the block (e.g.
63
+ * `capture cursor`, which captures but does not compact) so the block never points the reader at
64
+ * reduction figures that are not there. Default true (the `run` front-ends compact + report below).
65
+ */
66
+ inputReductionFollows?: boolean;
67
+ }
68
+ /**
69
+ * Render the shared honest token block for a run-flow front-end. This block:
70
+ * - states `token_source` explicitly per field (provider-reported | local-estimate | unavailable);
71
+ * - shows input and output SEPARATELY;
72
+ * - shows output as TOKENS only — it contains NO output-savings figure and no savings language for output.
73
+ *
74
+ * `options.reasons` carries the capture's TRUE per-axis unavailability reasons so an unavailable axis
75
+ * prints WHY (e.g. "a saved Cursor export does not contain the prompt …") instead of the generic fallback.
76
+ * The same reasons already ride in `report.notes` (capture limitations), so the persisted run record and
77
+ * the summary rollup carry the identical reason — no divergence between surfaces.
78
+ *
79
+ * The caller prints the (real) INPUT before/after reduction from the compaction report separately; the
80
+ * `input_reduction_label` in the report tells the caller whether that reduction is measured or estimated.
81
+ */
82
+ export declare function formatRunFlowTokenReport(report: RunFlowTokenReport, options?: RunFlowFormatOptions): string[];
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Map a capture's whole-usage source to the per-field honest token source. Output can independently be
3
+ * `unavailable` even when input is counted (the Cursor case), so the two fields carry sources separately.
4
+ */
5
+ function fieldSource(usage) {
6
+ if (usage.provider_reported_tokens)
7
+ return "provider-reported";
8
+ if (usage.estimated_tokens)
9
+ return "local-estimate";
10
+ return "unavailable";
11
+ }
12
+ /**
13
+ * Build the honest per-run token report from a capture's `UsageMetadata` and the wrapper's output status.
14
+ * `outputStatus === "unavailable"` forces `output_token_source: "unavailable"` regardless of the input
15
+ * source — a genuinely-missing output field is never silently labeled provider-reported or local-estimate.
16
+ */
17
+ export function buildRunFlowTokenReport(params) {
18
+ const source = fieldSource(params.usage);
19
+ const inputTokens = params.usage.input_tokens;
20
+ const outputTokens = params.usage.output_tokens;
21
+ // Input source is `unavailable` when there is no input count at all; otherwise the capture's source.
22
+ const inputSource = inputTokens === undefined ? "unavailable" : source;
23
+ // Output source is `unavailable` when the wrapper could not separate output, OR there is no output count.
24
+ const outputSource = params.outputStatus === "unavailable" || outputTokens === undefined ? "unavailable" : source;
25
+ return {
26
+ tool: params.tool,
27
+ input_token_source: inputSource,
28
+ output_token_source: outputSource,
29
+ ...(inputTokens !== undefined ? { input_tokens: inputTokens } : {}),
30
+ ...(outputTokens !== undefined ? { output_tokens: outputTokens } : {}),
31
+ input_reduction_label: params.usage.provider_reported_tokens ? "measured" : "estimated",
32
+ notes: [...params.usage.limitations]
33
+ };
34
+ }
35
+ function tokenLine(label, tokens, source, reason) {
36
+ if (source === "unavailable" || tokens === undefined) {
37
+ // An unavailable axis always carries a reason: the capture's TRUE per-run reason when it has one,
38
+ // else the generic honest fallback — never a bare "unavailable"/"unknown" with nothing after it.
39
+ const fallback = source === "unavailable" ? "not safely separable / not reported" : "no count";
40
+ return ` ${label} tokens: unavailable (${reason ?? fallback})`;
41
+ }
42
+ return ` ${label} tokens: ${tokens} (source: ${source})`;
43
+ }
44
+ /**
45
+ * Render the shared honest token block for a run-flow front-end. This block:
46
+ * - states `token_source` explicitly per field (provider-reported | local-estimate | unavailable);
47
+ * - shows input and output SEPARATELY;
48
+ * - shows output as TOKENS only — it contains NO output-savings figure and no savings language for output.
49
+ *
50
+ * `options.reasons` carries the capture's TRUE per-axis unavailability reasons so an unavailable axis
51
+ * prints WHY (e.g. "a saved Cursor export does not contain the prompt …") instead of the generic fallback.
52
+ * The same reasons already ride in `report.notes` (capture limitations), so the persisted run record and
53
+ * the summary rollup carry the identical reason — no divergence between surfaces.
54
+ *
55
+ * The caller prints the (real) INPUT before/after reduction from the compaction report separately; the
56
+ * `input_reduction_label` in the report tells the caller whether that reduction is measured or estimated.
57
+ */
58
+ export function formatRunFlowTokenReport(report, options) {
59
+ return [
60
+ `Token reality for ${report.tool} (honest per-field source):`,
61
+ tokenLine("input", report.input_tokens, report.input_token_source, options?.reasons?.input),
62
+ tokenLine("output", report.output_tokens, report.output_token_source, options?.reasons?.output),
63
+ // The one binding output rail, stated plainly so no reader mistakes observed output for a saving.
64
+ " output is shown as TOKENS ONLY — output-token savings are not claimed (gated on measured + eval-confirmed output-shaping).",
65
+ ...(options?.inputReductionFollows === false
66
+ ? []
67
+ : [` input-reduction figures below are ${report.input_reduction_label} (from the token source above).`]),
68
+ ...report.notes.map((n) => ` note: ${n}`)
69
+ ];
70
+ }
71
+ //# sourceMappingURL=run-flow-report.js.map
@@ -83,7 +83,14 @@ export function evidenceSourceTypeFromTrace(source) {
83
83
  // "real_captured" does), so a passing report on a codex_import trace caps at
84
84
  // "conditional". This mirrors the provider_usage classification below
85
85
  // (docs/design/provider-api-tracesource-classification.md, Option 2).
86
- if (source === "cli_wrapper" || source === "local_command" || source === "codex_import") {
86
+ //
87
+ // `cursor_import` is the SAME class for an imported real Cursor EXPORT
88
+ // (docs/design/cursor-capture-adapter.md, D2): genuine local input, stronger than a
89
+ // `manual` fixture, strictly BELOW `real_captured`, caps at "conditional".
90
+ if (source === "cli_wrapper" ||
91
+ source === "local_command" ||
92
+ source === "codex_import" ||
93
+ source === "cursor_import") {
87
94
  return "imported_local";
88
95
  }
89
96
  if (source === "real_captured") {
@@ -0,0 +1,32 @@
1
+ import { type AppendActivityEventResult } from "./activity-store.js";
2
+ export interface CodexShimBridgeResult {
3
+ result: AppendActivityEventResult;
4
+ /** "present" when a turn.completed.usage block was found (provider-reported), else "missing". */
5
+ tokenMetadataStatus: "present" | "missing";
6
+ }
7
+ /**
8
+ * Bridge a captured `codex exec --json` output copy into ONE metrics-only activity event.
9
+ * `surface: "codex"`, `provider: "openai"`; provider-reported tokens where usage existed, else
10
+ * unavailable-with-reason. Content-free + best-effort: a local failure is returned as an unappended
11
+ * result, never thrown, so it can never break the transparent shim.
12
+ */
13
+ export declare function bridgeCodexShimActivity(input: {
14
+ rawOutput: string;
15
+ cwd?: string;
16
+ }): Promise<CodexShimBridgeResult>;
17
+ export interface CursorShimBridgeResult {
18
+ result: AppendActivityEventResult;
19
+ inputStatus: "present" | "unavailable";
20
+ outputStatus: "present" | "unavailable";
21
+ }
22
+ /**
23
+ * Bridge a captured Cursor headless output copy into ONE metrics-only activity event.
24
+ * `surface: "cursor"`, `provider: "cursor"`; LOCAL-ESTIMATE only (NEVER provider-reported), output
25
+ * unavailable-with-reason when the result is not separable. `commandParts` (the ORIGINAL invocation)
26
+ * lets input be locally estimated from the prompt (counted, never stored). Content-free, best-effort.
27
+ */
28
+ export declare function bridgeCursorShimActivity(input: {
29
+ rawOutput: string;
30
+ commandParts?: string[];
31
+ cwd?: string;
32
+ }): Promise<CursorShimBridgeResult>;