@compaction/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +41 -12
  2. package/dist/cli/commands/activity.d.ts +8 -0
  3. package/dist/cli/commands/activity.js +35 -0
  4. package/dist/cli/commands/apply-context.js +68 -0
  5. package/dist/cli/commands/billing-delta.js +12 -1
  6. package/dist/cli/commands/capture-claude-code.d.ts +44 -0
  7. package/dist/cli/commands/capture-claude-code.js +206 -0
  8. package/dist/cli/commands/capture.js +283 -1
  9. package/dist/cli/commands/compact.js +9 -5
  10. package/dist/cli/commands/dev.d.ts +17 -0
  11. package/dist/cli/commands/dev.js +131 -0
  12. package/dist/cli/commands/gateway.d.ts +31 -0
  13. package/dist/cli/commands/gateway.js +312 -0
  14. package/dist/cli/commands/hooks.d.ts +5 -0
  15. package/dist/cli/commands/hooks.js +181 -0
  16. package/dist/cli/commands/init.js +422 -17
  17. package/dist/cli/commands/input-compaction-ab.d.ts +2 -0
  18. package/dist/cli/commands/input-compaction-ab.js +125 -0
  19. package/dist/cli/commands/optimize-hosted.d.ts +54 -0
  20. package/dist/cli/commands/optimize-hosted.js +123 -0
  21. package/dist/cli/commands/optimize.js +8 -0
  22. package/dist/cli/commands/output-shaping-ab.d.ts +2 -0
  23. package/dist/cli/commands/output-shaping-ab.js +132 -0
  24. package/dist/cli/commands/output-shaping.d.ts +7 -0
  25. package/dist/cli/commands/output-shaping.js +43 -0
  26. package/dist/cli/commands/policies.d.ts +2 -0
  27. package/dist/cli/commands/policies.js +80 -0
  28. package/dist/cli/commands/precall.d.ts +19 -0
  29. package/dist/cli/commands/precall.js +436 -0
  30. package/dist/cli/commands/recommend.js +1 -1
  31. package/dist/cli/commands/run.js +297 -12
  32. package/dist/cli/commands/upgrade-status.d.ts +42 -0
  33. package/dist/cli/commands/upgrade-status.js +152 -0
  34. package/dist/cli/cursor-export-read.d.ts +13 -0
  35. package/dist/cli/cursor-export-read.js +53 -0
  36. package/dist/cli/cursor-live-preflight.d.ts +19 -0
  37. package/dist/cli/cursor-live-preflight.js +46 -0
  38. package/dist/cli/index.js +36 -2
  39. package/dist/cli/onboarding/GatewayTui.d.ts +33 -0
  40. package/dist/cli/onboarding/GatewayTui.js +113 -0
  41. package/dist/cli/onboarding/model.d.ts +32 -1
  42. package/dist/cli/onboarding/model.js +27 -2
  43. package/dist/cli/terminal-logo.d.ts +27 -0
  44. package/dist/cli/terminal-logo.js +49 -0
  45. package/dist/core/activity-event.d.ts +107 -0
  46. package/dist/core/activity-event.js +182 -0
  47. package/dist/core/activity-store.d.ts +63 -0
  48. package/dist/core/activity-store.js +254 -0
  49. package/dist/core/activity-view.d.ts +76 -0
  50. package/dist/core/activity-view.js +120 -0
  51. package/dist/core/api-client/index.d.ts +3 -1
  52. package/dist/core/api-client/index.js +4 -0
  53. package/dist/core/api-client/payload.d.ts +5 -1
  54. package/dist/core/api-client/payload.js +8 -6
  55. package/dist/core/api-client/persisted-config.d.ts +57 -0
  56. package/dist/core/api-client/persisted-config.js +156 -0
  57. package/dist/core/api-client/tool.d.ts +29 -0
  58. package/dist/core/api-client/tool.js +50 -0
  59. package/dist/core/api-client/types.d.ts +15 -4
  60. package/dist/core/auto-apply-ask.d.ts +58 -0
  61. package/dist/core/auto-apply-ask.js +105 -0
  62. package/dist/core/auto-apply-gates.d.ts +76 -0
  63. package/dist/core/auto-apply-gates.js +113 -0
  64. package/dist/core/before-call-activity.d.ts +35 -0
  65. package/dist/core/before-call-activity.js +103 -0
  66. package/dist/core/before-call-recovery.d.ts +19 -0
  67. package/dist/core/before-call-recovery.js +46 -0
  68. package/dist/core/before-call-stdin.d.ts +59 -0
  69. package/dist/core/before-call-stdin.js +78 -0
  70. package/dist/core/before-call.d.ts +149 -0
  71. package/dist/core/before-call.js +358 -0
  72. package/dist/core/billing-delta/billing-delta-record.d.ts +28 -0
  73. package/dist/core/billing-delta/billing-delta-record.js +26 -0
  74. package/dist/core/capture-record.d.ts +40 -0
  75. package/dist/core/capture-record.js +55 -0
  76. package/dist/core/claude-code-before-call.d.ts +37 -0
  77. package/dist/core/claude-code-before-call.js +120 -0
  78. package/dist/core/claude-code-connect.d.ts +31 -0
  79. package/dist/core/claude-code-connect.js +87 -0
  80. package/dist/core/claude-code-hook-record.d.ts +71 -0
  81. package/dist/core/claude-code-hook-record.js +0 -0
  82. package/dist/core/claude-code-hooks.d.ts +77 -0
  83. package/dist/core/claude-code-hooks.js +141 -0
  84. package/dist/core/codex-capture.d.ts +45 -0
  85. package/dist/core/codex-capture.js +204 -0
  86. package/dist/core/command-runner.js +5 -1
  87. package/dist/core/compaction-artifacts.d.ts +51 -0
  88. package/dist/core/compaction-artifacts.js +242 -0
  89. package/dist/core/compactor.d.ts +9 -0
  90. package/dist/core/compactor.js +95 -0
  91. package/dist/core/cross-surface-event.d.ts +306 -0
  92. package/dist/core/cross-surface-event.js +330 -0
  93. package/dist/core/cursor-capture.d.ts +54 -0
  94. package/dist/core/cursor-capture.js +215 -0
  95. package/dist/core/cursor-preflight-probe.d.ts +8 -0
  96. package/dist/core/cursor-preflight-probe.js +88 -0
  97. package/dist/core/cursor-preflight.d.ts +86 -0
  98. package/dist/core/cursor-preflight.js +126 -0
  99. package/dist/core/gateway/apply-activation.d.ts +39 -0
  100. package/dist/core/gateway/apply-activation.js +84 -0
  101. package/dist/core/gateway/apply-policy.d.ts +64 -0
  102. package/dist/core/gateway/apply-policy.js +221 -0
  103. package/dist/core/gateway/apply-receipt.d.ts +36 -0
  104. package/dist/core/gateway/apply-receipt.js +75 -0
  105. package/dist/core/gateway/cache-proof.d.ts +45 -0
  106. package/dist/core/gateway/cache-proof.js +65 -0
  107. package/dist/core/gateway/configure.d.ts +50 -0
  108. package/dist/core/gateway/configure.js +169 -0
  109. package/dist/core/gateway/openai-usage.d.ts +56 -0
  110. package/dist/core/gateway/openai-usage.js +128 -0
  111. package/dist/core/gateway/receipt.d.ts +138 -0
  112. package/dist/core/gateway/receipt.js +120 -0
  113. package/dist/core/gateway/recovery.d.ts +23 -0
  114. package/dist/core/gateway/recovery.js +68 -0
  115. package/dist/core/gateway/server.d.ts +51 -0
  116. package/dist/core/gateway/server.js +276 -0
  117. package/dist/core/gateway/status.d.ts +45 -0
  118. package/dist/core/gateway/status.js +109 -0
  119. package/dist/core/hook-usage-aggregate.d.ts +47 -0
  120. package/dist/core/hook-usage-aggregate.js +161 -0
  121. package/dist/core/input-compaction-ab.d.ts +111 -0
  122. package/dist/core/input-compaction-ab.js +158 -0
  123. package/dist/core/local-run-record.d.ts +109 -0
  124. package/dist/core/local-run-record.js +223 -0
  125. package/dist/core/output-shaping-ab.d.ts +140 -0
  126. package/dist/core/output-shaping-ab.js +146 -0
  127. package/dist/core/output-shaping-attach.d.ts +31 -0
  128. package/dist/core/output-shaping-attach.js +57 -0
  129. package/dist/core/output-shaping.d.ts +56 -0
  130. package/dist/core/output-shaping.js +89 -0
  131. package/dist/core/policy-middleware.d.ts +121 -0
  132. package/dist/core/policy-middleware.js +919 -0
  133. package/dist/core/policy-preferences.d.ts +99 -0
  134. package/dist/core/policy-preferences.js +232 -0
  135. package/dist/core/run-aggregator.d.ts +11 -1
  136. package/dist/core/run-aggregator.js +29 -2
  137. package/dist/core/run-flow-report.d.ts +82 -0
  138. package/dist/core/run-flow-report.js +71 -0
  139. package/dist/core/safety-report.js +8 -1
  140. package/dist/core/shim-capture-bridge.d.ts +32 -0
  141. package/dist/core/shim-capture-bridge.js +88 -0
  142. package/dist/core/skill-injection-policy.d.ts +72 -0
  143. package/dist/core/skill-injection-policy.js +183 -0
  144. package/dist/core/spend-attribution.js +1 -1
  145. package/dist/core/token-accounting.d.ts +1 -1
  146. package/dist/core/tool-shim.d.ts +129 -0
  147. package/dist/core/tool-shim.js +447 -0
  148. package/dist/core/trace-parser.d.ts +13 -13
  149. package/dist/core/trace-parser.js +6 -6
  150. package/dist/core/types.d.ts +12 -2
  151. package/dist/core/waste-detector.d.ts +20 -0
  152. package/dist/core/waste-detector.js +160 -6
  153. package/package.json +1 -1
@@ -0,0 +1,182 @@
1
+ /**
2
+ * ADDITIVE activity extension of the cross-surface event (charter 2026-07-04-run-7, lane
3
+ * "shared activity/event model"; founder measurement-fields decision: "approval status, auto-apply
4
+ * status, recovery location, activity event id, sync status").
5
+ *
6
+ * WHY A SIBLING MODULE (not edits to `cross-surface-event.ts`): that file is the typed rendering
7
+ * of the run-5 authority doc `docs/ops/cross-surface-evidence-contract.md` (D1–D6) and is kept
8
+ * byte-stable here so "additive, existing writers/tests unmodified" is trivially provable. This
9
+ * module IMPORTS the same event union and extends it by intersection — one event model, the same
10
+ * arms and guardrails, plus the activity fields. Nothing here changes any existing wire shape,
11
+ * writer, or validator behavior.
12
+ *
13
+ * The activity fields, ALL optional/additive on the event:
14
+ * - `approval_status` — whether/how the user approved what Compaction did on this run.
15
+ * - `auto_apply` — the auto-apply STATE MODEL only (no auto-apply logic exists anywhere).
16
+ * Structurally OFF BY DEFAULT: `applied_automatically: true` is only *representable* when the
17
+ * user preference is the explicit opt-in `"auto-when-gates-pass"`, `eligible` is `true`, AND
18
+ * `gates_passed` is non-empty (a non-empty tuple type). An "auto-applied" event under the
19
+ * default `"ask-each-time"` preference is a TYPE ERROR and a validator problem — matching the
20
+ * founder decision: "If full gates are not implementable safely in one pass: fail-closed
21
+ * scaffold + tests proving auto-apply OFF by default."
22
+ * - `recovery` — whether the original was retained and WHERE (a content-free path/pointer, never
23
+ * content).
24
+ * - `sync_status` — `local-only | metrics-synced | hosted-private`; the default is `local-only`
25
+ * (no event syncs anywhere unless a hosted path is explicitly configured — none is here).
26
+ * - `activity_event_id` — a DETERMINISTIC content-free id (sha-256 over the canonicalized
27
+ * metrics-only fields), the dedupe key of the local activity store.
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ import { validateCrossSurfaceEvent } from "./cross-surface-event.js";
31
+ /** Founder D-list: whether/how the user approved what Compaction did on this run (exact values). */
32
+ export const ACTIVITY_APPROVAL_STATUSES = [
33
+ "not-required",
34
+ "asked-approved",
35
+ "asked-declined",
36
+ "auto-applied-by-policy",
37
+ "not-asked"
38
+ ];
39
+ /**
40
+ * The binary auto-apply preference (founder product model: after a first approval, ONE binary
41
+ * question — default no). `"ask-each-time"` IS the default; `"auto-when-gates-pass"` is only ever
42
+ * an explicit prior user opt-in. Preference STORAGE is a named residual (no storage exists here).
43
+ */
44
+ export const AUTO_APPLY_PREFERENCES = ["ask-each-time", "auto-when-gates-pass"];
45
+ /** Founder D-list sync status. `local-only` is the default: nothing syncs without explicit config. */
46
+ export const ACTIVITY_SYNC_STATUSES = ["local-only", "metrics-synced", "hosted-private"];
47
+ export const DEFAULT_ACTIVITY_SYNC_STATUS = "local-only";
48
+ /* ------------------------------------------------------------------------------------------------
49
+ * Deterministic content-free id.
50
+ * ---------------------------------------------------------------------------------------------- */
51
+ export const ACTIVITY_EVENT_ID_PATTERN = /^act-[0-9a-f]{24}$/;
52
+ /** Canonicalize: drop undefined, sort object keys recursively — same event, same bytes, same id. */
53
+ function canonicalize(value) {
54
+ if (Array.isArray(value))
55
+ return value.map(canonicalize);
56
+ if (typeof value === "object" && value !== null) {
57
+ return Object.fromEntries(Object.entries(value)
58
+ .filter(([, entry]) => entry !== undefined)
59
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
60
+ .map(([key, entry]) => [key, canonicalize(entry)]));
61
+ }
62
+ return value;
63
+ }
64
+ /**
65
+ * Compute the deterministic content-free id: sha-256 over the canonicalized event EXCLUDING any
66
+ * existing `activity_event_id` (so recomputing on a stored event reproduces the same id). The id
67
+ * is derived only from the metrics-only fields the event already carries — no randomness, no
68
+ * clock, no content.
69
+ */
70
+ export function computeActivityEventId(event) {
71
+ const { activity_event_id: _existing, ...rest } = event;
72
+ const digest = createHash("sha256").update(JSON.stringify(canonicalize(rest))).digest("hex");
73
+ return `act-${digest.slice(0, 24)}`;
74
+ }
75
+ function isPlainObject(value) {
76
+ return typeof value === "object" && value !== null && !Array.isArray(value);
77
+ }
78
+ function isOneOf(value, allowed) {
79
+ return typeof value === "string" && allowed.includes(value);
80
+ }
81
+ function isStringArray(value) {
82
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.trim() !== "");
83
+ }
84
+ function validateAutoApply(value, problems) {
85
+ if (value === undefined)
86
+ return;
87
+ if (!isPlainObject(value)) {
88
+ problems.push("auto_apply: must be an object ({ eligible, preference, applied_automatically, gates_* })");
89
+ return;
90
+ }
91
+ if (typeof value.eligible !== "boolean")
92
+ problems.push("auto_apply.eligible: must be a boolean");
93
+ if (!isOneOf(value.preference, AUTO_APPLY_PREFERENCES)) {
94
+ problems.push(`auto_apply.preference: must be one of ${AUTO_APPLY_PREFERENCES.join(" | ")} (exact)`);
95
+ }
96
+ if (typeof value.applied_automatically !== "boolean") {
97
+ problems.push("auto_apply.applied_automatically: must be a boolean");
98
+ }
99
+ for (const field of ["gates_passed", "gates_failed"]) {
100
+ if (value[field] !== undefined && !isStringArray(value[field])) {
101
+ problems.push(`auto_apply.${field}: must be an array of non-empty content-free gate identifiers`);
102
+ }
103
+ }
104
+ // OFF-BY-DEFAULT rules (mirror of the type-level arm) — auto-apply is opt-in + gated, always.
105
+ if (value.applied_automatically === true) {
106
+ if (value.preference !== "auto-when-gates-pass") {
107
+ problems.push('auto_apply: applied_automatically=true under preference "ask-each-time" is invalid — auto-apply is OFF by default and only ever an explicit prior opt-in ("auto-when-gates-pass")');
108
+ }
109
+ if (value.eligible !== true) {
110
+ problems.push("auto_apply: applied_automatically=true requires eligible=true (an ineligible change can never be auto-applied)");
111
+ }
112
+ if (!isStringArray(value.gates_passed) || value.gates_passed.length === 0) {
113
+ problems.push("auto_apply: applied_automatically=true requires a NON-EMPTY gates_passed — an auto-apply with zero passed safety gates is invalid (fail-closed)");
114
+ }
115
+ }
116
+ }
117
+ function validateRecovery(value, problems) {
118
+ if (value === undefined)
119
+ return;
120
+ if (!isPlainObject(value)) {
121
+ problems.push("recovery: must be an object ({ original_retained, location? })");
122
+ return;
123
+ }
124
+ if (typeof value.original_retained !== "boolean") {
125
+ problems.push("recovery.original_retained: must be a boolean (honest — never assumed)");
126
+ }
127
+ if (value.location !== undefined) {
128
+ if (typeof value.location !== "string" || value.location.trim() === "") {
129
+ problems.push("recovery.location: must be a non-empty content-free path/pointer string when present");
130
+ }
131
+ else if (value.location.includes("\n")) {
132
+ problems.push("recovery.location: must be a single-line path/pointer — multi-line values look like content, not a location");
133
+ }
134
+ }
135
+ }
136
+ /**
137
+ * Validate one parsed/unknown activity event. Runs the EXISTING cross-surface validator first
138
+ * (all D5/D6/tier/silent-zero rules stay binding), then the activity rules:
139
+ * 1. exact enums for approval_status / sync_status / auto_apply.preference;
140
+ * 2. auto-apply OFF-by-default: applied_automatically=true requires the explicit opt-in
141
+ * preference AND eligible=true AND non-empty gates_passed;
142
+ * 3. recovery.location is a single-line content-free pointer;
143
+ * 4. activity_event_id matches the deterministic `act-<24 hex>` shape.
144
+ */
145
+ export function validateActivityEvent(value) {
146
+ const problems = [...validateCrossSurfaceEvent(value).problems];
147
+ if (!isPlainObject(value))
148
+ return { problems };
149
+ const event = value;
150
+ if (event.approval_status !== undefined && !isOneOf(event.approval_status, ACTIVITY_APPROVAL_STATUSES)) {
151
+ problems.push(`approval_status: must be one of ${ACTIVITY_APPROVAL_STATUSES.join(" | ")} (exact)`);
152
+ }
153
+ if (event.sync_status !== undefined && !isOneOf(event.sync_status, ACTIVITY_SYNC_STATUSES)) {
154
+ problems.push(`sync_status: must be one of ${ACTIVITY_SYNC_STATUSES.join(" | ")} (exact)`);
155
+ }
156
+ if (event.activity_event_id !== undefined &&
157
+ (typeof event.activity_event_id !== "string" || !ACTIVITY_EVENT_ID_PATTERN.test(event.activity_event_id))) {
158
+ problems.push("activity_event_id: must match act-<24 hex chars> (the deterministic content-free id)");
159
+ }
160
+ validateAutoApply(event.auto_apply, problems);
161
+ validateRecovery(event.recovery, problems);
162
+ return { problems };
163
+ }
164
+ /* ------------------------------------------------------------------------------------------------
165
+ * Measure-only builder — the ONLY activity-event writer this cycle. `run` / `run codex` /
166
+ * `run cursor` measure and report; they never modify the user's session or apply anything, so:
167
+ * approval "not-required" (there was nothing to approve), auto_apply not eligible + the default
168
+ * "ask-each-time" preference + applied_automatically false (no auto-apply logic exists),
169
+ * sync "local-only" (no hosted path is configured on the `run` flow), recovery = what the record
170
+ * HONESTLY knows (the captured-trace artifact the front-end just wrote, by path).
171
+ * ---------------------------------------------------------------------------------------------- */
172
+ export function buildMeasureOnlyActivityEvent(event, recovery) {
173
+ const activity = {
174
+ ...event,
175
+ approval_status: "not-required",
176
+ auto_apply: { eligible: false, preference: "ask-each-time", applied_automatically: false },
177
+ recovery,
178
+ sync_status: DEFAULT_ACTIVITY_SYNC_STATUS
179
+ };
180
+ return { ...activity, activity_event_id: computeActivityEventId(activity) };
181
+ }
182
+ //# sourceMappingURL=activity-event.js.map
@@ -0,0 +1,63 @@
1
+ import { type ActivityEvent } from "./activity-event.js";
2
+ /** Default local activity directory (sibling of `.compaction/run-records`; gitignored via `.compaction/`). */
3
+ export declare const DEFAULT_ACTIVITY_DIRECTORY = ".compaction/activity";
4
+ /** The single append-only JSONL log inside it. */
5
+ export declare const ACTIVITY_LOG_FILENAME = "activity.jsonl";
6
+ /** Top-level keys an activity event may carry: the cross-surface contract fields + the activity fields. */
7
+ export declare const ACTIVITY_EVENT_ALLOWED_KEYS: readonly string[];
8
+ /**
9
+ * Longest string a metrics-only event may carry per field. The longest honest values today are
10
+ * unavailability reasons / caveats (~120 chars); 600 leaves headroom while making prompt/response
11
+ * smuggling in a "label" field impossible.
12
+ */
13
+ export declare const ACTIVITY_MAX_STRING_LENGTH = 600;
14
+ /**
15
+ * The write-time invariant: the FULL contract validator + the metrics-only allowlist + string
16
+ * bounds + store requirements (`activity_event_id` and `sync_status` present — the store's dedupe
17
+ * key and sync default are always materialized before this runs). Report-only: returns problems.
18
+ */
19
+ export declare function validateActivityEventForStore(value: unknown): {
20
+ problems: string[];
21
+ };
22
+ export type AppendActivityEventResult = {
23
+ appended: true;
24
+ path: string;
25
+ activity_event_id: string;
26
+ } | {
27
+ appended: false;
28
+ reason: string;
29
+ problems?: string[];
30
+ };
31
+ /**
32
+ * Append ONE metrics-only event to the local JSONL log. Fail-closed: a rejected event writes
33
+ * NOTHING (and the rejection carries the exact problems). Dedupe: an id already in the log is a
34
+ * reported no-op. Local file I/O only — never a network call.
35
+ */
36
+ export declare function appendActivityEvent(event: ActivityEvent, directory?: string): Promise<AppendActivityEventResult>;
37
+ export interface SkippedActivityLine {
38
+ /** 1-based line number in the JSONL log. */
39
+ line: number;
40
+ reason: string;
41
+ }
42
+ /**
43
+ * Read all events from the local log. A missing directory/file means "no activity yet" (empty,
44
+ * not an error). Invalid lines are SKIPPED with a reason — never guessed at; a duplicate id keeps
45
+ * the FIRST occurrence (defensive read-side dedupe; the write side already prevents this).
46
+ */
47
+ export declare function readActivityEvents(directory?: string): Promise<{
48
+ events: ActivityEvent[];
49
+ skipped: SkippedActivityLine[];
50
+ }>;
51
+ /** A content-free one-line summary per event — ids, surface, statuses; never counts recomputed. */
52
+ export interface ActivityEventSummary {
53
+ activity_event_id: string;
54
+ surface: string;
55
+ run_id?: string;
56
+ approval_status?: string;
57
+ sync_status?: string;
58
+ }
59
+ /** List the stored events as content-free summaries (the future `compaction activity` command's data). */
60
+ export declare function listActivityEvents(directory?: string): Promise<{
61
+ summaries: ActivityEventSummary[];
62
+ skipped: SkippedActivityLine[];
63
+ }>;
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Minimal LOCAL metrics-only activity store (charter 2026-07-04-run-7, lane "shared activity/event
3
+ * model … minimal local metrics-only activity store"). Founder product model: "all runs captured
4
+ * as metrics-only activity".
5
+ *
6
+ * What it is: an append-only JSONL file under `.compaction/activity/` (already gitignored via the
7
+ * `.compaction/` rule) holding ONE metrics-only `ActivityEvent` per line. Local file I/O only —
8
+ * no network, no new dependencies, no CLI surface (the `compaction activity` command is a NAMED
9
+ * RESIDUAL, not built here).
10
+ *
11
+ * Invariants ENFORCED AT WRITE TIME (fail-closed — a rejected event writes NOTHING):
12
+ * 1. CONTENT-FREE / metrics-only: every key must be on the known metrics-only allowlist (unknown
13
+ * keys are rejected — an unknown field could carry content; classically content-shaped names
14
+ * like `prompt`/`response`/`messages` get the explicit content message), and every string
15
+ * value is length-bounded (a multi-hundred-char "label" is content wearing a label's clothes).
16
+ * 2. The FULL event contract: `validateActivityEvent` (which itself runs the existing
17
+ * cross-surface validator — D5/D6, tier ceilings, no silent zero) must return zero problems,
18
+ * including the auto-apply OFF-by-default rules.
19
+ * 3. DEDUPE by `activity_event_id`: the deterministic id is the primary key; appending an event
20
+ * whose id is already in the log is a no-op (reported, not silent).
21
+ *
22
+ * `sync_status` defaults to `"local-only"` and `activity_event_id` is computed when absent —
23
+ * the store never invents any other field.
24
+ */
25
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
26
+ import { join } from "node:path";
27
+ import { DEFAULT_ACTIVITY_SYNC_STATUS, computeActivityEventId, validateActivityEvent } from "./activity-event.js";
28
+ /** Default local activity directory (sibling of `.compaction/run-records`; gitignored via `.compaction/`). */
29
+ export const DEFAULT_ACTIVITY_DIRECTORY = ".compaction/activity";
30
+ /** The single append-only JSONL log inside it. */
31
+ export const ACTIVITY_LOG_FILENAME = "activity.jsonl";
32
+ /* ------------------------------------------------------------------------------------------------
33
+ * Metrics-only (content-free) key allowlist — the write-time invariant of rule 1.
34
+ * ---------------------------------------------------------------------------------------------- */
35
+ /** Top-level keys an activity event may carry: the cross-surface contract fields + the activity fields. */
36
+ export const ACTIVITY_EVENT_ALLOWED_KEYS = [
37
+ // cross-surface event (src/core/cross-surface-event.ts — contract §2)
38
+ "user_id",
39
+ "team_id",
40
+ "surface",
41
+ "provider",
42
+ "model_label",
43
+ "workflow_id",
44
+ "session_id",
45
+ "run_id",
46
+ "input_before",
47
+ "input_after",
48
+ "output_before",
49
+ "output_after",
50
+ "output_estimate",
51
+ "token_source",
52
+ "cost_source",
53
+ "cost_unavailable_reason",
54
+ "billing_model",
55
+ "policy_used",
56
+ "acceptance",
57
+ "recoverability",
58
+ "eval_status",
59
+ "evidence_level",
60
+ "claim_scope",
61
+ "caveats",
62
+ "plan_efficiency",
63
+ // activity extension (src/core/activity-event.ts — founder D-list)
64
+ "activity_event_id",
65
+ "approval_status",
66
+ "auto_apply",
67
+ "recovery",
68
+ "sync_status"
69
+ ];
70
+ /** Nested-object key allowlists (content can hide one level down just as easily). */
71
+ const NESTED_ALLOWED_KEYS = {
72
+ token_source: ["input", "output"],
73
+ "token_source.input": ["source", "unavailable_reason"],
74
+ "token_source.output": ["source", "unavailable_reason"],
75
+ auto_apply: ["eligible", "preference", "applied_automatically", "gates_passed", "gates_failed"],
76
+ recovery: ["original_retained", "location"],
77
+ plan_efficiency: [
78
+ "evidence_type",
79
+ "billing_confirmed",
80
+ "quota_window_id",
81
+ "tasks_completed_in_window",
82
+ "cap_events_in_window",
83
+ "visible_tokens_per_task_estimate",
84
+ "successful_tasks_before_reset"
85
+ ]
86
+ };
87
+ /** Classic content-shaped key names — rejected with the explicit content message (case-insensitive). */
88
+ const CONTENT_SHAPED_KEY_NAMES = [
89
+ "prompt",
90
+ "response",
91
+ "content",
92
+ "text",
93
+ "message",
94
+ "messages",
95
+ "transcript",
96
+ "stdout",
97
+ "stderr",
98
+ "body",
99
+ "completion",
100
+ "diff",
101
+ "code"
102
+ ];
103
+ /**
104
+ * Longest string a metrics-only event may carry per field. The longest honest values today are
105
+ * unavailability reasons / caveats (~120 chars); 600 leaves headroom while making prompt/response
106
+ * smuggling in a "label" field impossible.
107
+ */
108
+ export const ACTIVITY_MAX_STRING_LENGTH = 600;
109
+ function isPlainObject(value) {
110
+ return typeof value === "object" && value !== null && !Array.isArray(value);
111
+ }
112
+ function looksContentShaped(key) {
113
+ return CONTENT_SHAPED_KEY_NAMES.includes(key.toLowerCase());
114
+ }
115
+ function checkStrings(path, value, problems) {
116
+ if (typeof value === "string") {
117
+ if (value.length > ACTIVITY_MAX_STRING_LENGTH) {
118
+ problems.push(`${path}: string exceeds the metrics-only bound (${value.length} > ${ACTIVITY_MAX_STRING_LENGTH} chars) — that is content-sized, not a count/source/id/label`);
119
+ }
120
+ return;
121
+ }
122
+ if (Array.isArray(value)) {
123
+ value.forEach((entry, index) => checkStrings(`${path}[${index}]`, entry, problems));
124
+ return;
125
+ }
126
+ if (isPlainObject(value)) {
127
+ for (const [key, entry] of Object.entries(value))
128
+ checkStrings(`${path}.${key}`, entry, problems);
129
+ }
130
+ }
131
+ function checkAllowedKeys(path, value, allowed, problems) {
132
+ for (const key of Object.keys(value)) {
133
+ if (allowed.includes(key))
134
+ continue;
135
+ problems.push(looksContentShaped(key)
136
+ ? `${path === "" ? key : `${path}.${key}`}: content-shaped field — activity events are metrics-only (counts, sources, ids, labels); prompt/response/output content is never stored`
137
+ : `${path === "" ? key : `${path}.${key}`}: not on the metrics-only activity-event allowlist — unknown fields are rejected at write time (fail-closed content-free invariant)`);
138
+ }
139
+ }
140
+ /**
141
+ * The write-time invariant: the FULL contract validator + the metrics-only allowlist + string
142
+ * bounds + store requirements (`activity_event_id` and `sync_status` present — the store's dedupe
143
+ * key and sync default are always materialized before this runs). Report-only: returns problems.
144
+ */
145
+ export function validateActivityEventForStore(value) {
146
+ const problems = [...validateActivityEvent(value).problems];
147
+ if (!isPlainObject(value))
148
+ return { problems };
149
+ checkAllowedKeys("", value, ACTIVITY_EVENT_ALLOWED_KEYS, problems);
150
+ for (const [path, allowed] of Object.entries(NESTED_ALLOWED_KEYS)) {
151
+ const segments = path.split(".");
152
+ let cursor = value;
153
+ for (const segment of segments) {
154
+ cursor = isPlainObject(cursor) ? cursor[segment] : undefined;
155
+ }
156
+ if (isPlainObject(cursor))
157
+ checkAllowedKeys(path, cursor, allowed, problems);
158
+ }
159
+ checkStrings("event", value, problems);
160
+ if (typeof value.activity_event_id !== "string" || value.activity_event_id === "") {
161
+ problems.push("activity_event_id: required by the activity store (the dedupe key)");
162
+ }
163
+ if (value.sync_status === undefined) {
164
+ problems.push('sync_status: required by the activity store (default "local-only" is applied before validation)');
165
+ }
166
+ return { problems };
167
+ }
168
+ /**
169
+ * Append ONE metrics-only event to the local JSONL log. Fail-closed: a rejected event writes
170
+ * NOTHING (and the rejection carries the exact problems). Dedupe: an id already in the log is a
171
+ * reported no-op. Local file I/O only — never a network call.
172
+ */
173
+ export async function appendActivityEvent(event, directory = DEFAULT_ACTIVITY_DIRECTORY) {
174
+ const withDefaults = {
175
+ ...event,
176
+ sync_status: event.sync_status ?? DEFAULT_ACTIVITY_SYNC_STATUS
177
+ };
178
+ const materialized = {
179
+ ...withDefaults,
180
+ activity_event_id: withDefaults.activity_event_id ?? computeActivityEventId(withDefaults)
181
+ };
182
+ const { problems } = validateActivityEventForStore(materialized);
183
+ if (problems.length > 0) {
184
+ return { appended: false, reason: "metrics-only invariant rejected the event — nothing was written", problems };
185
+ }
186
+ const id = materialized.activity_event_id;
187
+ const { events } = await readActivityEvents(directory);
188
+ if (events.some((existing) => existing.activity_event_id === id)) {
189
+ return { appended: false, reason: `duplicate activity_event_id ${id} — already recorded (dedupe; nothing written)` };
190
+ }
191
+ await mkdir(directory, { recursive: true });
192
+ const path = join(directory, ACTIVITY_LOG_FILENAME);
193
+ await appendFile(path, `${JSON.stringify(materialized)}\n`, "utf8");
194
+ return { appended: true, path, activity_event_id: id };
195
+ }
196
+ /**
197
+ * Read all events from the local log. A missing directory/file means "no activity yet" (empty,
198
+ * not an error). Invalid lines are SKIPPED with a reason — never guessed at; a duplicate id keeps
199
+ * the FIRST occurrence (defensive read-side dedupe; the write side already prevents this).
200
+ */
201
+ export async function readActivityEvents(directory = DEFAULT_ACTIVITY_DIRECTORY) {
202
+ let raw;
203
+ try {
204
+ raw = await readFile(join(directory, ACTIVITY_LOG_FILENAME), "utf8");
205
+ }
206
+ catch (error) {
207
+ const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "unknown";
208
+ if (code === "ENOENT")
209
+ return { events: [], skipped: [] };
210
+ throw error;
211
+ }
212
+ const events = [];
213
+ const skipped = [];
214
+ const seen = new Set();
215
+ raw.split("\n").forEach((line, index) => {
216
+ if (line.trim() === "")
217
+ return;
218
+ let parsed;
219
+ try {
220
+ parsed = JSON.parse(line);
221
+ }
222
+ catch {
223
+ skipped.push({ line: index + 1, reason: "invalid JSON" });
224
+ return;
225
+ }
226
+ const { problems } = validateActivityEventForStore(parsed);
227
+ if (problems.length > 0) {
228
+ skipped.push({ line: index + 1, reason: `invalid activity event: ${problems[0]}` });
229
+ return;
230
+ }
231
+ const event = parsed;
232
+ const id = event.activity_event_id;
233
+ if (seen.has(id)) {
234
+ skipped.push({ line: index + 1, reason: `duplicate activity_event_id ${id} — first occurrence kept` });
235
+ return;
236
+ }
237
+ seen.add(id);
238
+ events.push(event);
239
+ });
240
+ return { events, skipped };
241
+ }
242
+ /** List the stored events as content-free summaries (the future `compaction activity` command's data). */
243
+ export async function listActivityEvents(directory = DEFAULT_ACTIVITY_DIRECTORY) {
244
+ const { events, skipped } = await readActivityEvents(directory);
245
+ const summaries = events.map((event) => ({
246
+ activity_event_id: event.activity_event_id,
247
+ surface: event.surface,
248
+ ...(event.run_id !== undefined ? { run_id: event.run_id } : {}),
249
+ ...(event.approval_status !== undefined ? { approval_status: event.approval_status } : {}),
250
+ ...(event.sync_status !== undefined ? { sync_status: event.sync_status } : {})
251
+ }));
252
+ return { summaries, skipped };
253
+ }
254
+ //# sourceMappingURL=activity-store.js.map
@@ -0,0 +1,76 @@
1
+ /**
2
+ * READ-ONLY, metrics-only rendering of the local activity store (charter 2026-07-04-run-7, lane 4:
3
+ * "compaction activity"). Turns stored `ActivityEvent`s into a content-free table / JSON view of
4
+ * recent runs. Pure functions — no I/O here (the command reads the store and calls these).
5
+ *
6
+ * HONESTY RAILS (product-critic gate for this surface):
7
+ * - NO wall-clock: activity events carry no timestamp (deterministic content-free ids, no clock).
8
+ * So there is NO fabricated "time" column — runs are ordered by append recency and numbered
9
+ * (`#1` = most recent), with a one-line note that the order is append order, not clock time.
10
+ * - Token counts render at the EVENT'S OWN axis tier: `input`/`output` say `local-estimate` vs
11
+ * `provider-reported` vs `unavailable` verbatim; a tier is never upgraded in rendering.
12
+ * - OUTPUT is "output tokens measured" (a count), NEVER "output savings". No cost, no billing, no
13
+ * savings, no projection is rendered on this surface.
14
+ * - An input before→after delta is shown ONLY when BOTH input_before and input_after are present
15
+ * (a real reshaping happened). A measure-only run (input_before only) shows the measured count,
16
+ * never an implied reduction.
17
+ */
18
+ import type { ActivityEvent } from "./activity-event.js";
19
+ /** One rendered, content-free row. All labels are honest; nothing is inferred. */
20
+ export interface ActivityRow {
21
+ /** 1 = most recent (append recency; events carry no wall-clock). */
22
+ position: number;
23
+ activity_event_id: string;
24
+ surface: string;
25
+ provider: string;
26
+ model_label: string;
27
+ /** Present only when a real before→after reshaping happened (both counts present). */
28
+ input_before: number | null;
29
+ input_after: number | null;
30
+ input_source: string;
31
+ input_unavailable_reason?: string;
32
+ /** The measured output token COUNT (never a savings), or null when the axis is unavailable. */
33
+ output_tokens: number | null;
34
+ output_source: string;
35
+ output_unavailable_reason?: string;
36
+ policy_used: string;
37
+ approval_status: string;
38
+ auto_apply_status: string;
39
+ sync_status: string;
40
+ }
41
+ export interface BuildActivityRowsOptions {
42
+ /** Max rows to render (most recent first). Default 20. */
43
+ limit?: number;
44
+ /** Filter to one surface (exact match) before limiting. */
45
+ surface?: string;
46
+ }
47
+ export declare const DEFAULT_ACTIVITY_LIMIT = 20;
48
+ export declare const NO_ACTIVITY_MESSAGE = "No activity recorded yet \u2014 run a supported workflow (e.g. `compaction run <tool> -- \u2026`) and it will appear here.";
49
+ /** Build the rendered rows: newest first, optional surface filter, then limit. */
50
+ export declare function buildActivityRows(events: ActivityEvent[], options?: BuildActivityRowsOptions): ActivityRow[];
51
+ /** Machine output for `--json`: rows plus honest meta. Pure data (the command JSON-stringifies it). */
52
+ export interface ActivityJson {
53
+ activity: ActivityRow[];
54
+ meta: {
55
+ total_events: number;
56
+ shown: number;
57
+ limit: number;
58
+ surface_filter: string | null;
59
+ ordering: "append-recency (events carry no wall-clock time); position 1 = most recent";
60
+ labels: "token counts are shown at each event's own axis tier (local-estimate | provider-reported | unavailable); output is a measured token count, never a savings figure; no cost or billing figure is shown";
61
+ };
62
+ skipped_note?: string;
63
+ }
64
+ export declare function buildActivityJson(rows: ActivityRow[], meta: {
65
+ totalEvents: number;
66
+ limit: number;
67
+ surface?: string;
68
+ skippedCount?: number;
69
+ }): ActivityJson;
70
+ /** Human table. Content-free; honest labels; a header note that ordering is append-recency. */
71
+ export declare function formatActivityTable(rows: ActivityRow[], meta: {
72
+ totalEvents: number;
73
+ limit: number;
74
+ surface?: string;
75
+ skippedCount?: number;
76
+ }): string;