@staix/agent-mem 0.1.1 → 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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
4
4
 
5
- Version: 0.1.1. Requires Bun 1.4.2 or later; no runtime dependencies.
5
+ Version: 0.3.0. Requires Bun 1.4.2 or later; no runtime dependencies.
6
6
 
7
7
  ## Install
8
8
 
@@ -46,6 +46,29 @@ amem sync --remote <private-git-url> --policy-free
46
46
 
47
47
  Use `ahub memory` for the policy-applying path. `--policy-free` does not bypass the secret screen or the sync remote's privacy checks. Forgetting a shared note appends an operation; its previous content remains in the remote's git history.
48
48
 
49
+ ## Candidate review
50
+
51
+ `amem review` lists this project's pending machine-local candidates. They are proposals, not memory notes and not evidence. Use JSON to inspect the complete candidate payload before accepting or rejecting its full 64-character ID:
52
+
53
+ ```sh
54
+ amem review --json
55
+ amem review list --kind correction --status all --limit 50 --json
56
+ amem review accept <full-candidate-id> --policy-free --json
57
+ amem review reject <full-candidate-id> --json
58
+ ```
59
+
60
+ All review commands remain person-only; acceptance and rejection require a terminal on stdin. Acceptance additionally requires `--policy-free`, acknowledging that the standalone CLI cannot apply a host's privacy policy; use `ahub memory` for the host policy path. The CLI refuses secret-looking candidate input or proposed note content. Listing does not open the ordinary memory index, and acceptance defers unrelated held-note promotions.
61
+
62
+ Candidates live in `candidates.db` beside the memory logs. This sidecar is machine-local: it is never synced, imported, or read by recall, briefs, ask or graph evidence. Hosts must supply an explicit public privacy verdict before putting candidates or counters; private, PII and unknown verdicts are refused. Library APIs are available, but this phase adds no automatic candidate producer or telemetry consumer. Candidate retention is 500 per project and 30 days; counters retain 10,000 per project and 90 days. Candidate payloads are capped at 16 KiB.
63
+
64
+ Acceptance creates ordinary v1 memory data. A correction retains its target's scope, project and storage reach, writes the corrected note and applies the existing `supersede` operation; its exact current target revision must match. A local target stays short-term; a synced target stays synced, including a demoted target whose effective tier is short. A fail becomes a long-term project note. The accepted text binds the candidate ID, target revision and target ID; a fail proposal without a target records `none` for both target fields. Both paths require the actual MemoryStore promotion screen to return `promote` at acceptance time; `keep`, `skip` or an absent screen refuses acceptance without falling back to short-term storage. Accepted v1 notes and operations follow the existing storage and sync rules; the candidate/counter sidecar remains local and outside evidence. No new v1 operation is introduced. Decay candidates are listing-only and cannot be accepted; a person may separately choose `ahub memory demote` or `forget`. The sidecar receipt, note append and correction's supersede append are separate durable writes. An interrupted acceptance can leave a note without its supersede; it is held for explicit inspection and never replayed automatically.
65
+
66
+ The library entry exports `CandidateStore({ home?, now? })`, `put(input, verdict)`, `recordCounter(key, values, verdict)`, and `accept(id, { project, actor, verdict, store, policy })`. `home` retains the parent-home convention (`<home>/memory`); the default uses `memoryHome()`. The host must authenticate person intent, bind the current project and privacy verdict, and supply a current acceptance policy plus the ordinary store's actual promotion screen. An `actor` string or caller-supplied public verdict alone is not authentication. Counter writes are idempotent snapshots for the same contextual key, not increments: observed `0` stays zero, while `null` means unknown (a later unknown value does not erase an observed value).
67
+
68
+ Open a review-only `MemoryStore` with `deferPromotions: true` to prevent unrelated automatic promotions during construction and reads. Explicit candidate acceptance still runs its promotion screen. This package registers no capture observer or host candidate tools; capture exclusion and authenticated producers must be established by the host before those integrations are enabled.
69
+
70
+ Acceptance normalizes its note once, including timestamp, content ID and short-term expiry, then screens and appends that exact body. The final target-revision check and correction appends hold the same machine-local `mutation-lock.db` writer transaction used by this version's ordinary memory, import and sync writers. This lock is independent of the derived index, so an ordinary write still persists when SQLite is busy. External or older writers that edit segments without this lock do not participate in serialization. The logs remain authoritative after an interrupted acceptance; the next read refreshes the derived index.
71
+
49
72
  ## Shell completion
50
73
 
51
74
  Generate completion for `zsh`, `bash`, or `fish` with `amem completion <shell>`. Generation prints a script to stdout; it does not install files or edit shell configuration. The following commands are for a person's terminal.
@@ -126,6 +149,53 @@ The root entry needs Bun (it loads `bun:sqlite`). Node hosts that need only the
126
149
 
127
150
  The entry point exports the store, types and identity helpers, sync and claude-mem import, project/home helpers, raw memory/goal command handlers, and platform helpers. Hosts inject their own privacy policy through `StoreOptions.screen`; CLI wrappers can supply screened reads and import policy through the raw command options.
128
151
 
152
+ ## Contracts
153
+
154
+ Shared segments retain the `agent-hub.memory/v1` profile. `OP_CONTRACTS` and `V1_OPS` keep the shared-v1 operation contract; both shared append paths refuse unknown or truth-changing operations. The separate `GENERATION_OP_CONTRACTS` enables only `prune` after an explicit generation selection. `correct`, `lift`, `consolidate` and `group` remain blocked. Reader announcements cannot authorize a new operation or relax generation isolation.
155
+
156
+ The shared-v1 contract suites run the published 0.1.1 reader and compare all six v1 derived tables after replay and rebuild; generation fixtures cover their expanded derived state separately. Privacy and evidence matrices test the core's actual callbacks and sidecar boundaries; model-facing reads, PII turns, staleness checks and capture remain the host's responsibility. Direct person reads remain unscreened. Expiry after a wall-clock rollback retains the existing C05 ceiling: a rebuild can restore a previously expired row until its deadline passes again. A generation can persist exact-entry prune; shared-v1 and unpruned entries retain their existing expiry limitations.
157
+
158
+ See [the contract and coverage tables](https://github.com/STAIxBWLB/agent-mem/blob/main/docs/contracts.md) for the fixtures, host-owned rows and declared ceilings. These are automated synthetic checks, not proof of the separate multi-machine or managed-agent live legs.
159
+
160
+
161
+ ## Generation migration and exact-entry prune
162
+
163
+ Available from 0.3.0. Do not migrate a memory home that agent-hub also uses until agent-hub pins a generation-capable agent-mem and binds the selected profile (rollout order in [#9](https://github.com/STAIxBWLB/agent-mem/issues/9)); until then a running hub keeps writing shared v1 and the person's notes would split.
164
+
165
+ Shared v1 remains the default until a person explicitly migrates the memory home. A generation uses `M/generations/G/` and a separate Git repository/ref (`refs/heads/amem/gen/G`); legacy directories and `main` are unchanged. The one-way readonly bridge consumes complete validated legacy prefixes without using the old index or writing legacy notes. Old programs continue using their legacy view, and generation-created notes never flow back to it.
166
+
167
+ Complete malformed legacy lines (invalid JSON, unknown operations or mismatched content IDs) are skipped and counted, matching shared-v1 handling. Migration dry-run/apply and the person's human CLI status report the skipped count without printing the bodies; JSON list output stays an array of notes. Prefix rewrites, truncation and non-fast-forward legacy history still refuse access. A generation never promotes a bridged note or applies its own settlement to one; only generation-born notes receive generation settlements and promotions.
168
+
169
+ Generation bridge reads use plain-file snapshots captured during explicit legacy sync and never invoke Git. Unchanged segments use metadata checkpoints without reading or hashing the data, including after reopen. A seeded live handle hashes only the appended suffix, while comparing existing prefix bytes on changed input; the first changed read by a cold handle and an explicit rebuild verify the full prefix. These are read-path bounds, not a latency or speedup promise.
170
+
171
+ The following interfaces are for a person's terminal and an already initialized synthetic memory home. They do not authorize real-data migration. Use the same current project directory throughout planning and apply:
172
+
173
+ ```sh
174
+ AMEM_HOME=/path/to/synthetic-memory amem migrate --dry-run
175
+ AMEM_HOME=/path/to/synthetic-memory amem migrate
176
+ # If interrupted, resume the recorded generation rather than creating another:
177
+ AMEM_HOME=/path/to/synthetic-memory amem migrate --resume
178
+ umask 077
179
+ AMEM_HOME=/path/to/synthetic-memory amem prune --cutoff 2026-10-01T00:00:00.000Z --dry-run > prune-plan.json
180
+ AMEM_HOME=/path/to/synthetic-memory amem prune --apply --plan prune-plan.json
181
+ # Explicitly select shared v1 again while preserving both histories:
182
+ AMEM_HOME=/path/to/synthetic-memory amem migrate --deactivate
183
+ ```
184
+
185
+ All migrate/prune modes, including dry-run, refuse agent markers and require stdin TTY. `--generation <32-hex-G>` selects an explicitly named generation during migration/join; it is never discovered and activated from a remote automatically. Invalid active metadata blocks access rather than silently opening shared v1. Existing handles remain bound to the profile they opened; a selector change does not restart or rebind a running hub.
186
+
187
+ Explicit `--deactivate` is the recovery path even when the selected generation is corrupt or missing. It reads the selector under the home-wide profile lock, selects shared v1 and retains all generation and legacy data. Normal reads still refuse corrupt active state. Shared-v1 sync keeps its pre-generation Git invocation and caller environment; generation-only sync adds repository-environment isolation and a private child-process umask.
188
+
189
+ A prune plan is a person-local JSON receipt containing exact target/basis hashes, selector/project/policy revisions, fixed row bytes, local counts and a seal. Apply commits that same receipt after revalidation; it is not an authorization token. Maximums are 256 KiB per plan, 16 rows, 256 targets, and 32 targets/16 KiB per row. Larger automatic selections report `hasMore`; another plan handles remaining targets. Keep redirected plans private and out of source control, model context and sync.
190
+
191
+ Only explicit serialized expiry is eligible: a note's `expires`, or an exact demote/settle row's `by`. Implicit backstop or instance-TTL deadlines cannot prove prune eligibility. Exact pruned IDs stay unavailable after clock rollback, including get/alias/graph-start paths. A different later promotion or replacement ID remains an independent note; this is not text/family removal, log erasure or storage reclamation. Unpruned expiry still follows the clock.
192
+
193
+ Standalone CLI prune is local-only, even over a synced target. No `--policy-free` escape enables public prune. A later policy-aware host integration must authenticate intent and attest the exact public payload and current policy revision; only verified public target/basis provenance may sync. Local masks, plans, journals, caches and sidecars never sync. Generation sync refuses public or unknown remote visibility. An interrupted apply resumes the same durable row IDs and exact partial tail; it never rewrites complete lines.
194
+
195
+ Candidate/counter sidecars are not migrated. `amem review` currently refuses a selected generation rather than using an unbound legacy sidecar. A profile-aware host integration must handle that context explicitly.
196
+
197
+ Real migration remains blocked until the separately reviewed generation-capable amem release and exact-pinned agent-hub integration are installed and their running stores prove the same home/G/selector revision. Synthetic cross-writes and exact prune readbacks precede separately confirmed real migration; known local handles must be quiesced/closed and reopened through the supported session-preserving lifecycle. Release, deployment, real migration, host evidence/capture receipts and real multi-machine sync are not established by these source changes. Deactivation omits generation-only notes/prunes from the v1 view and is not a lossless rollback.
198
+
129
199
  ## Development
130
200
 
131
201
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-mem",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Curated, shared memory for coding agents: append-only logs, a derived index, tiers and a typed graph.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,7 +13,9 @@
13
13
  },
14
14
  "devDependencies": {
15
15
  "@types/bun": "^1.3.0",
16
- "typescript": "^5.8.0"
16
+ "typescript": "^5.8.0",
17
+ "@staix/agent-mem-v1": "npm:@staix/agent-mem@0.1.1",
18
+ "@staix/agent-mem-v02": "npm:@staix/agent-mem@0.2.0"
17
19
  },
18
20
  "exports": {
19
21
  ".": "./src/index.ts",
package/src/cli.ts CHANGED
@@ -1,14 +1,55 @@
1
- import { readFileSync } from "node:fs";
1
+ import { closeSync, constants, fstatSync, openSync, readFileSync, readSync } from "node:fs";
2
2
  import { MemoryStore, looksSecret } from "./memory/store.ts";
3
3
  import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts";
4
+ import { memoryProjectKey } from "./memory/project.ts";
5
+ import { CandidateStore, type CandidateFilter } from "./memory/candidates.ts";
4
6
 
5
7
  import { commandHelp, parseCommand } from "./commands-table.ts";
6
8
  import { completionScript } from "./completion.ts";
9
+ import { memoryHome } from "./memory/home.ts";
10
+ import { resolveProfile } from "./memory/generation.ts";
11
+ import { migrate } from "./memory/migrate.ts";
12
+ import { applyPrune, planPrune, PRUNE_PLAN_MAX_BYTES } from "./memory/prune.ts";
7
13
 
8
14
  export const PERSON_ONLY = "amem is for the person; agents read memory through the hub's hub_memory_* tools";
9
15
  const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
10
16
  export interface CliContext { cwd?: string; env?: Readonly<Record<string, string | undefined>>; stdinTTY?: boolean }
11
17
 
18
+ /** Narrow descriptor seam for deterministic race fixtures; the CLI always uses the native implementation. */
19
+ export interface PrunePlanFileIO {
20
+ open(path: string, flags: number): number;
21
+ stat(fd: number): { size: number; isFile(): boolean };
22
+ read(fd: number, buffer: Buffer, offset: number, length: number, position: number): number;
23
+ close(fd: number): void;
24
+ }
25
+ const planFileIO: PrunePlanFileIO = {
26
+ open: (path, flags) => openSync(path, flags), stat: fd => fstatSync(fd),
27
+ read: (fd, buffer, offset, length, position) => readSync(fd, buffer, offset, length, position), close: fd => closeSync(fd),
28
+ };
29
+
30
+ /** Open once: pathname replacement cannot redirect the read and growth cannot exceed the fixed byte budget. */
31
+ export function readPrunePlanFile(path: string, io: PrunePlanFileIO = planFileIO): string {
32
+ try {
33
+ const fd = io.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
34
+ try {
35
+ const before = io.stat(fd);
36
+ if (!before.isFile() || !Number.isSafeInteger(before.size) || before.size < 0 || before.size > PRUNE_PLAN_MAX_BYTES) throw new Error();
37
+ const data = Buffer.alloc(PRUNE_PLAN_MAX_BYTES + 1);
38
+ let length = 0;
39
+ while (length < data.length) {
40
+ const n = io.read(fd, data, length, data.length - length, length);
41
+ if (!Number.isSafeInteger(n) || n < 0 || n > data.length - length) throw new Error();
42
+ if (n === 0) break;
43
+ length += n;
44
+ }
45
+ if (length > PRUNE_PLAN_MAX_BYTES) throw new Error();
46
+ const after = io.stat(fd);
47
+ if (!after.isFile() || after.size !== before.size || length !== before.size) throw new Error();
48
+ return new TextDecoder("utf-8", { fatal: true }).decode(data.subarray(0, length));
49
+ } finally { io.close(fd); }
50
+ } catch { throw new Error("prune plan unavailable or oversized"); }
51
+ }
52
+
12
53
  /** Honest native-shell defaults, matching every marker detectCliIdentity treats as non-console (invalid included). */
13
54
  export function agentMarked(env: Readonly<Record<string, string | undefined>>): boolean {
14
55
  return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID"].some(key => env[key] !== undefined);
@@ -31,12 +72,59 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
31
72
  throw error;
32
73
  }
33
74
  const { definition, options, rawArgs: args } = parsed;
34
- const goal = parsed.path[0] === "goal", sub = goal ? parsed.path[1]! : parsed.path[0]!;
75
+ const goal = parsed.path[0] === "goal", review = parsed.path[0] === "review", sub = goal || review ? parsed.path[1]! : parsed.path[0]!;
35
76
  const policyFree = options["--policy-free"] === true;
36
77
  const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined;
37
78
  if (mutating && !(context.stdinTTY ?? process.stdin.isTTY)) throw new Error("mutating commands require a terminal on stdin");
38
79
  if (definition.policyFree && !policyFree) {
39
- throw new Error(`${sub} requires --policy-free; use ahub memory ${sub} to apply the host's privacy policy`);
80
+ throw new Error(review ? "review accept requires --policy-free; use ahub memory to apply the host's privacy policy" : `${sub} requires --policy-free; use ahub memory ${sub} to apply the host's privacy policy`);
81
+ }
82
+ if (sub === "migrate") {
83
+ const modes = ["--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
84
+ if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
85
+ const mode = options["--dry-run"] ? "dry-run" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "apply";
86
+ return JSON.stringify(await migrate({ memoryDir: memoryHome(), mode, ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true }), null, 2);
87
+ }
88
+ if (sub === "prune") {
89
+ const applying = options["--apply"] === true;
90
+ if (applying ? options["--dry-run"] || options["--cutoff"] || !options["--plan"] : !options["--dry-run"] || !options["--cutoff"] || options["--plan"]) throw new Error("usage: amem prune --cutoff <UTC> --dry-run | --apply --plan <file>");
91
+ let input: string | undefined;
92
+ if (applying) {
93
+ input = readPrunePlanFile(String(options["--plan"]));
94
+ }
95
+ const store = new MemoryStore({ deferPromotions: true });
96
+ try {
97
+ const auth = { project: memoryProjectKey(context.cwd ?? process.cwd()), actor: "person" as const, authorize: () => true };
98
+ return JSON.stringify(applying ? applyPrune(store.prunePort(), input, auth) : planPrune(store.prunePort(), { cutoff: String(options["--cutoff"]) }, auth), null, 2);
99
+ } finally { store.close(); }
100
+ }
101
+ if (review) {
102
+ if (resolveProfile(memoryHome()).kind === "generation") throw new Error("candidate review is not yet integrated with this generation; use the profile-aware host after its integration");
103
+ const id = parsed.positionals[0];
104
+ if (sub !== "list" && (!id || !/^[a-f0-9]{64}$/.test(id))) throw new Error("review accept/reject requires a full 64-character candidate id");
105
+ const project = memoryProjectKey(context.cwd ?? process.cwd()), candidates = new CandidateStore();
106
+ try {
107
+ if (sub === "list") {
108
+ const rows = candidates.list({ project, ...(options["--status"] ? { status: options["--status"] as CandidateFilter["status"] } : {}),
109
+ ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
110
+ return options["--json"] ? JSON.stringify(rows, null, 2) : rows.length ? rows.map(candidate => `${candidate.id} ${candidate.input.kind} ${candidate.status} ${candidate.createdAt}`).join("\n") : "no candidates waiting for review";
111
+ }
112
+ if (sub === "reject") {
113
+ candidates.reject(id!, { project, actor: "person", verdict: "public" });
114
+ return options["--json"] ? JSON.stringify({ candidateId: id, status: "rejected" }, null, 2) : `rejected candidate ${id}`;
115
+ }
116
+ const candidate = candidates.get(id!, project);
117
+ if (!candidate) throw new Error("candidate unavailable");
118
+ if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; use ahub memory demote or forget after review");
119
+ // Candidate acceptance uses the actual secret screen; the core defers unrelated automatic promotions.
120
+ const store = new MemoryStore({ deferPromotions: true, screen: note => looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
121
+ try {
122
+ const result = candidates.accept(id!, { project, actor: "person", verdict: "public", store,
123
+ policy: (proposal, _target, note) => !looksSecret(JSON.stringify(proposal.input)) && !looksSecret(JSON.stringify(note)),
124
+ });
125
+ return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ordinary v1 note ${result.noteId}`;
126
+ } finally { store.close(); }
127
+ } finally { candidates.close(); }
40
128
  }
41
129
  // ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
42
130
  if (goal && sub === "link") throw new Error("goal link needs host integration; use ahub goal link");
@@ -45,9 +133,12 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
45
133
  const open = () => new MemoryStore({ screen: note => !crosses ? "skip" : looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
46
134
  const cwd = context.cwd ?? process.cwd();
47
135
  // A person's reads are unscreened. Writes crossing the boundary remain secret-screened without host policy.
48
- return goal
49
- ? rawGoalCommand(args, cwd, { open, clean: (title, milestones) => !mutating || !looksSecret(`${title}\n${milestones.join("\n")}`) })
50
- : rawMemoryCommand(args, cwd, { open, readable: notes => notes, importPolicy: () => () => false });
136
+ if (goal) return rawGoalCommand(args, cwd, { open, clean: (title, milestones) => !mutating || !looksSecret(`${title}\n${milestones.join("\n")}`) });
137
+ const receipt: { bridgeStatus?: { skippedRows: number } } = {};
138
+ const output = await rawMemoryCommand(args, cwd, { open, readable: notes => notes, importPolicy: () => () => false,
139
+ onBridgeStatus: status => { receipt.bridgeStatus = status; } });
140
+ return !options["--json"] && receipt.bridgeStatus
141
+ ? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output;
51
142
  }
52
143
 
53
144
  export async function main(argv = process.argv.slice(2)): Promise<void> {
@@ -47,6 +47,22 @@ export const COMMANDS: Record<string, CommandDefinition> = {
47
47
  done: { description: "Complete a goal", args: [{ name: "id" }], mutates: true },
48
48
  take: { description: "Take a goal lease", options: { "--force": flag("Override the prior lease") }, args: [{ name: "id" }], mutates: true },
49
49
  } },
50
+ review: { description: "Review machine-local non-evidentiary candidates", options: json, defaultCommand: "list", commands: {
51
+ list: { description: "List review candidates", options: {
52
+ "--status": { description: "Candidate status", value: ["pending", "accepting", "accepted", "rejected", "all"] },
53
+ "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay"] }, "--limit": text("Maximum candidates (1..500)"),
54
+ } },
55
+ accept: { description: "Accept one candidate through the current memory policy", options: policy, args: [{ name: "full-candidate-id" }], mutates: true, policyFree: true },
56
+ reject: { description: "Reject one candidate", args: [{ name: "full-candidate-id" }], mutates: true },
57
+ } },
58
+ migrate: { description: "Select a person-approved generation or deactivate it", mutates: true, options: {
59
+ ...json, "--dry-run": flag("Read-only migration receipt"), "--generation": text("Explicit 32-hex generation ID"),
60
+ "--resume": flag("Resume the recorded migration"), "--deactivate": flag("Select shared v1 without deleting a generation"),
61
+ } },
62
+ prune: { description: "Plan or apply exact-entry durable expiry in a generation", mutates: true, options: {
63
+ ...json, "--cutoff": text("Canonical UTC expiry cutoff"), "--dry-run": flag("Print a local sealed plan without writes"),
64
+ "--apply": flag("Apply the exact local plan"), "--plan": { description: "Sealed local plan JSON file", value: "path" },
65
+ } },
50
66
  completion: { description: "Print static shell completion", args: [{ name: "shell", values: ["zsh", "bash", "fish"] }] },
51
67
  help: { description: "Show help" }, version: { description: "Show version" },
52
68
  };
package/src/index.ts CHANGED
@@ -5,3 +5,8 @@ export * from "./memory/project.ts";
5
5
  export * from "./memory/home.ts";
6
6
  export * from "./memory/platform.ts";
7
7
  export * from "./memory/commands.ts";
8
+ export * from "./memory/candidates.ts";
9
+ export * from "./memory/ops-contract.ts";
10
+ export * from "./memory/generation.ts";
11
+ export * from "./memory/migrate.ts";
12
+ export * from "./memory/prune.ts";