@staix/agent-mem 0.6.0 → 0.7.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 (63) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +135 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +476 -39
  6. package/src/commands-table.ts +53 -15
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +14 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +141 -33
  12. package/src/mcp.ts +42 -7
  13. package/src/memory/aliases.ts +4 -0
  14. package/src/memory/bridge.ts +8 -2
  15. package/src/memory/candidate-pending.ts +119 -0
  16. package/src/memory/candidates.ts +144 -47
  17. package/src/memory/capacity-split.ts +76 -0
  18. package/src/memory/capacity.ts +139 -11
  19. package/src/memory/capture-input.ts +8 -3
  20. package/src/memory/capture.ts +30 -5
  21. package/src/memory/catalog.ts +43 -10
  22. package/src/memory/commands.ts +98 -44
  23. package/src/memory/config.ts +167 -0
  24. package/src/memory/cross-project.ts +75 -0
  25. package/src/memory/curated-read.ts +29 -0
  26. package/src/memory/decay-review.ts +136 -0
  27. package/src/memory/doctor.ts +412 -0
  28. package/src/memory/editor.ts +58 -0
  29. package/src/memory/eval-fixture.ts +137 -0
  30. package/src/memory/eval-set.json +443 -0
  31. package/src/memory/eval.ts +150 -0
  32. package/src/memory/gc.ts +97 -0
  33. package/src/memory/generation-index.ts +22 -17
  34. package/src/memory/generation-sync.ts +3 -0
  35. package/src/memory/goal-promotion.ts +71 -0
  36. package/src/memory/home.ts +15 -4
  37. package/src/memory/intent-brief.ts +89 -0
  38. package/src/memory/lifecycle-log.ts +54 -0
  39. package/src/memory/lifecycle.ts +61 -0
  40. package/src/memory/metrics.ts +2 -2
  41. package/src/memory/note-add.ts +100 -0
  42. package/src/memory/path-glob.ts +94 -0
  43. package/src/memory/plan-guard.ts +47 -0
  44. package/src/memory/policy.ts +44 -13
  45. package/src/memory/private-spans.ts +32 -0
  46. package/src/memory/project-export.ts +122 -0
  47. package/src/memory/recall-staleness.ts +56 -0
  48. package/src/memory/recall.ts +74 -0
  49. package/src/memory/repo-scope.ts +404 -0
  50. package/src/memory/shard-sync-format.ts +116 -0
  51. package/src/memory/shard-sync.ts +421 -0
  52. package/src/memory/store.ts +282 -27
  53. package/src/memory/sync-cadence.ts +139 -0
  54. package/src/memory/sync-screen.ts +86 -0
  55. package/src/memory/sync.ts +79 -11
  56. package/src/memory/usage.ts +35 -0
  57. package/src/memory/use-telemetry.ts +51 -0
  58. package/src/memory/work-lists.ts +10 -4
  59. package/src/session-brief-worker.ts +3 -2
  60. package/src/session-brief.ts +74 -15
  61. package/src/setup-owned.ts +120 -0
  62. package/src/setup-toml.ts +291 -0
  63. package/src/setup.ts +327 -0
package/src/cli.ts CHANGED
@@ -1,30 +1,87 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Writable } from "node:stream";
3
+ import { buildIntentBrief } from "./memory/intent-brief.ts";
4
+ import { createIntentBriefSource } from "./memory/recall.ts";
5
+ import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
6
+ import { recordMemoryUse } from "./memory/use-telemetry.ts";
7
+ import { DecayApplyError } from "./memory/decay-review.ts";
1
8
  import { createMemoryMcp } from "./mcp-memory.ts";
2
- import { serveMcp } from "./mcp.ts";
9
+ import { serveMcp, writeConfirmed } from "./mcp.ts";
3
10
  import { createStandalonePolicy } from "./memory/policy.ts";
11
+ import { configuredPolicy, configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
4
12
  import { vaultCandidates } from "./memory/vault.ts";
5
13
  import { importClaudeMemWorkState } from "./memory/import.ts";
6
- import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
14
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
7
15
  import { join } from "node:path";
8
16
  import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts";
9
17
  import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts";
10
18
  import { memoryProjectKey } from "./memory/project.ts";
11
- import { CandidateStore, type CandidateFilter } from "./memory/candidates.ts";
19
+ import { CandidateStore, candidateTargetRevision, type CandidateFilter } from "./memory/candidates.ts";
12
20
  import { aliasedKeys, appendAlias, appendUnalias, listAliases, readShardedSelection, resolveAlias } from "./memory/shard.ts";
13
21
  import { capacityReport, capacityText } from "./memory/capacity.ts";
14
22
  import { readCaptureConfigFile, writeCaptureConfigFile, type CaptureConfig } from "./memory/capture.ts";
15
23
 
16
24
  import { commandHelp, parseCommand } from "./commands-table.ts";
17
25
  import { completionScript } from "./completion.ts";
18
- import { memoryHome } from "./memory/home.ts";
19
- import { resolveProfile } from "./memory/generation.ts";
26
+ import { memoryHome, resolveMemoryHome } from "./memory/home.ts";
27
+ import { realPath } from "./memory/platform.ts";
28
+ import { hashJSON, readSelector, resolveProfile } from "./memory/generation.ts";
20
29
  import { migrate } from "./memory/migrate.ts";
21
30
  import { migrateShards, shardMigrationStatus } from "./memory/shard-migration.ts";
22
31
  import { applyPrune, planPrune, PRUNE_PLAN_MAX_BYTES } from "./memory/prune.ts";
32
+ import { readCatalogFresh } from "./memory/catalog.ts";
33
+ import { appendLifecycle, assertProjectActive, projectState } from "./memory/lifecycle.ts";
34
+ import { gcText, runGc } from "./memory/gc.ts";
35
+ import { usageReport, usageText } from "./memory/usage.ts";
36
+ import { exportProject } from "./memory/project-export.ts";
37
+ import { searchAllProjects, vaultCandidatesAll } from "./memory/cross-project.ts";
38
+ import { requireShard } from "./memory/curated-read.ts";
39
+ import { appendGitattributes, appendRepoLine, boundRepoRoot, planGitattributes, planRepoPromotion, readRepoScope, repoLogIgnored, repoLogPath, repoReadPolicy, repoVisibility, type RepoView } from "./memory/repo-scope.ts";
40
+ import type { Visibility } from "./memory/sync.ts";
41
+ import { applySplit, previewSplit } from "./memory/capacity-split.ts";
42
+ import { evalText, parseKs } from "./memory/eval.ts";
43
+ import { evalSeeded, evalStore, FIXTURE_PATH, readEvalSet } from "./memory/eval-fixture.ts";
44
+ import { markShown, pendingSummary } from "./memory/candidate-pending.ts";
45
+ import { buildNote, FAIL_TEMPLATE, hasFailLabel, noteAdd, parseFailTemplate, type FailFields } from "./memory/note-add.ts";
46
+ import { runEditor } from "./memory/editor.ts";
47
+ import { doctorReport, doctorText } from "./memory/doctor.ts";
48
+ import { nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, syncDue, stopSignal, syncOutcome, usableState, SYNC_IF_DUE_DEADLINE_MS, type StopCause } from "./memory/sync-cadence.ts";
23
49
 
24
- export const PERSON_ONLY = "amem is for the person; agents read memory through the hub's hub_memory_* tools";
50
+ export const PERSON_ONLY = "amem is for the person: an agent reads memory through its `amem mcp` server (see `amem --help`); the person reviews what agents propose with `amem review`";
25
51
  const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
26
- export interface CliContext { cwd?: string; env?: Readonly<Record<string, string | undefined>>; stdinTTY?: boolean }
52
+ export interface CliOutputReceipt { project: string; ids: readonly string[]; rendered(): boolean }
53
+ export interface CliContext {
54
+ /** Prepared output metadata only. Invoke rendered only after the output writer succeeds. */
55
+ onOutputReady?: (receipt: CliOutputReceipt) => void;
56
+ cwd?: string; env?: Readonly<Record<string, string | undefined>>; stdinTTY?: boolean;
57
+ /** Narrow seam for deterministic fixtures standing in for `gh` (#15 T4): the CLI always uses the native remote visibility check. */
58
+ visibility?: (url: string) => Promise<Visibility>;
59
+ /** Trusted isolated-HOME setup fixture; ordinary CLI use asks through its terminal. */
60
+ setupConfirm?: (plan: import("./setup.ts").SetupPlan) => Promise<boolean>;
61
+ /** Narrow seams for `amem note add` and `amem review walk`; each defaults to the real terminal. Fixtures stand in for the person at the keyboard. */
62
+ prompt?: (question: string) => string | null | Promise<string | null>;
63
+ /** Where `review walk` prints each candidate BEFORE it asks about it; defaults to stdout, which must then be a terminal. A fixture that supplies this stands in for the terminal. */
64
+ write?: (text: string) => void;
65
+ /** Whether stdout is a terminal, for the real stdout only (default `process.stdout.isTTY`). */
66
+ stdoutTTY?: boolean;
67
+ edit?: (initial: string) => string | Promise<string>;
68
+ /** Test seam: the `sync --if-due` deadline in milliseconds (the CLI always uses 30 seconds). */
69
+ syncDeadlineMs?: number;
70
+ stdin?: () => string;
71
+ }
27
72
 
73
+ /** Candidate and note text came from an agent or the person, and goes to a terminal: flatten control characters (an ANSI escape included) and bound it. */
74
+ const terminalSafe = (value: string, max = 600): string => { const flat = value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").replace(/ {2,}/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max)}...` : flat; };
75
+ /** Every value given for a repeatable flag, in order (`parseCommand` keeps only the last). */
76
+ function repeated(argv: readonly string[], flag: string): string[] {
77
+ const out: string[] = [];
78
+ for (let i = 0; i < argv.length; i++) {
79
+ const token = argv[i]!;
80
+ if (token === flag) { const value = argv[++i]; if (value !== undefined && !value.startsWith("--")) out.push(value); }
81
+ else if (token.startsWith(`${flag}=`)) out.push(token.slice(flag.length + 1));
82
+ }
83
+ return out;
84
+ }
28
85
  /** Narrow descriptor seam for deterministic race fixtures; the CLI always uses the native implementation. */
29
86
  export interface PrunePlanFileIO {
30
87
  open(path: string, flags: number): number;
@@ -62,7 +119,7 @@ export function readPrunePlanFile(path: string, io: PrunePlanFileIO = planFileIO
62
119
 
63
120
  /** Honest native-shell defaults, matching every marker detectCliIdentity treats as non-console (invalid included). */
64
121
  export function agentMarked(env: Readonly<Record<string, string | undefined>>): boolean {
65
- return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID"].some(key => env[key] !== undefined);
122
+ return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "PI_SESSION_ID"].some(key => env[key] !== undefined);
66
123
  }
67
124
 
68
125
  function shardStatePresent(): boolean {
@@ -72,6 +129,9 @@ function shardStatePresent(): boolean {
72
129
 
73
130
  /** `reconciled` (#27): the project filter also covers every key aliased to `project`; prune and other planners keep the exact key. */
74
131
  function curatedStore(project: string, opts: StoreOptions, reconciled = true): MemoryStore {
132
+ // #15 T5: the person's own project-local recall (search, list, show, graph, goals, prune) leaves an archived or forgotten project too, on a split home and on
133
+ // an unsplit one alike. `search --all-projects --include-archived` and `project export` are the explicit ways to reach an archived project.
134
+ assertProjectActive(memoryHome(), project);
75
135
  const aliases = reconciled ? aliasedKeys(memoryHome(), project) : [];
76
136
  const withAliases: StoreOptions = aliases.length ? { ...opts, reconcile: { canonical: project, aliases } } : opts;
77
137
  if (shardStatePresent()) {
@@ -81,10 +141,17 @@ function curatedStore(project: string, opts: StoreOptions, reconciled = true): M
81
141
  return new MemoryStore(withAliases);
82
142
  }
83
143
 
144
+ /** The same store `curatedStore` opens, on another base: doctor's private scratch copy. No lifecycle gate here, the copy carries the home's lifecycle log and doctor read it already. */
145
+ function curatedStoreAt(base: string, project: string, opts: StoreOptions): MemoryStore {
146
+ const aliases = aliasedKeys(base, project), withAliases: StoreOptions = aliases.length ? { ...opts, reconcile: { canonical: project, aliases } } : opts;
147
+ if (existsSync(join(base, "shards.json"))) { readShardedSelection(base); return openShardStore({ ...withAliases, memoryDir: base, project }); }
148
+ return new MemoryStore({ ...withAliases, memoryDir: base });
149
+ }
150
+
84
151
  export async function runCli(argv: readonly string[], context: CliContext = {}): Promise<string> {
85
152
  if (argv.length === 1 && argv[0] === "mcp") {
86
153
  const memory = createMemoryMcp({ cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env) });
87
- await serveMcp({ tools: memory.tools, close: memory.close, version: VERSION });
154
+ await serveMcp({ tools: memory.tools, close: memory.close, onEmitted: memory.onEmitted, version: VERSION });
88
155
  return "";
89
156
  }
90
157
  if (argv[0] === "hook") {
@@ -103,6 +170,9 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
103
170
  ...(typeof parsed.options["--since"] === "string" ? { since: parsed.options["--since"] as string } : {}),
104
171
  }));
105
172
  }
173
+ // F1: `--help` and `--version` only print text, so they work in an agent shell too. Every other command keeps the honest-session refusal below.
174
+ if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "version")) return `amem ${VERSION}`;
175
+ if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "help")) return commandHelp();
106
176
  if (agentMarked(context.env ?? process.env)) throw new Error(PERSON_ONLY);
107
177
  if (argv[0] === "--version" || argv[0] === "version") return `amem ${VERSION}`;
108
178
  if (argv[0] === "--help" || argv[0] === "help" || argv.includes("--help")) return commandHelp();
@@ -119,15 +189,65 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
119
189
  throw error;
120
190
  }
121
191
  const { definition, options, rawArgs: args } = parsed;
192
+ if (parsed.path[0] === "setup") {
193
+ if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true) throw new Error("usage: amem setup claude|codex|kimi|pi|all [--apply|--undo]");
194
+ const { runSetup } = await import("./setup.ts");
195
+ return runSetup(parsed.positionals[0]!, options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", {
196
+ env: context.env ?? process.env, stdinTTY: context.stdinTTY ?? process.stdin.isTTY,
197
+ ...(context.setupConfirm ? { confirm: context.setupConfirm } : {}),
198
+ });
199
+ }
122
200
  const goal = parsed.path[0] === "goal", review = parsed.path[0] === "review", sub = goal || review ? parsed.path[1]! : parsed.path[0]!;
123
201
  const policyFree = options["--policy-free"] === true;
124
- const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined;
202
+ const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined
203
+ || definition.mutates === "confirm" && options["--confirm"] === true;
125
204
  if (mutating && !(context.stdinTTY ?? process.stdin.isTTY)) throw new Error("mutating commands require a terminal on stdin");
126
- if (definition.policyFree && !policyFree) {
127
- 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`);
128
- }
205
+ // F4: the configured policy (the machine's `amem.config.json`, narrowed by the repository's `.amem/config`) applies to every command that used to need
206
+ // `--policy-free`. The flag is still accepted and means nothing more, so existing scripts keep working.
207
+ const policyCwd = context.cwd ?? process.cwd();
208
+ const policyOf = () => configuredPolicy(memoryHome(), policyCwd); // the same home every other command here resolves
129
209
  let canonicalProject: string | undefined;
130
210
  const boundProject = () => canonicalProject ??= resolveAlias(memoryHome(), memoryProjectKey(context.cwd ?? process.cwd()));
211
+ const commandBinding = (home: string, project: string): (() => void) => {
212
+ const base = realPath(home), selector = readSelector(base).revision, aliases = aliasedKeys(base, project).join("\n");
213
+ const split = shardStatePresent(), shards = split ? JSON.stringify(readShardedSelection(base)) : undefined;
214
+ return () => {
215
+ const currentSplit = shardStatePresent();
216
+ if (realPath(memoryHome()) !== base || resolveAlias(base, memoryProjectKey(policyCwd)) !== project || readSelector(base).revision !== selector || aliasedKeys(base, project).join("\n") !== aliases || projectState(base, project) !== "active" || split !== currentSplit || split && JSON.stringify(readShardedSelection(base)) !== shards) throw new Error("memory command binding changed since preview");
217
+ };
218
+ };
219
+ const outputReady = (ids: readonly string[], project = boundProject()) => {
220
+ if (!context.onOutputReady || !ids.length) return;
221
+ const base = realPath(memoryHome()), assertCurrent = commandBinding(base, project), deliveryId = `cli:${randomUUID()}`, selected = [...new Set(ids)].slice(0, 256);
222
+ context.onOutputReady({ project, ids: selected, rendered: () => {
223
+ let candidates: CandidateStore | undefined;
224
+ try { assertCurrent(); candidates = new CandidateStore({ memoryDir: base }); let known = true;
225
+ for (const entryId of selected) known = recordMemoryUse(candidates, { project, entryId, deliveryId }, { rendered: 1 }, { assertCurrent }) && known;
226
+ return known;
227
+ } catch { return false; } finally { try { candidates?.close(); } catch { /* optional telemetry cannot fail emitted output */ } }
228
+ } });
229
+ };
230
+ if (sub === "brief") {
231
+ const intent = options["--for"]; if (typeof intent !== "string" || !intent.trim() || intent.length > 1000 || parsed.positionals.length) throw Error("usage: amem brief --for <intent> [--paths <path>] [--symbols <symbol>] [--bytes N] [--json]");
232
+ const rawBytes = options["--bytes"];
233
+ if (rawBytes !== undefined && !/^[1-9][0-9]*$/.test(String(rawBytes))) throw Error("brief bytes must be a positive decimal integer");
234
+ const maxBytes = rawBytes === undefined ? 2048 : Number(rawBytes); if (!Number.isSafeInteger(maxBytes) || maxBytes > 8192) throw Error("brief bytes must be 1..8192");
235
+ const project = boundProject(), base = realPath(memoryHome()), paths = repeated(argv, "--paths"), symbols = repeated(argv, "--symbols");
236
+ if (paths.length > 150 || symbols.length > 150 || [...paths, ...symbols].some(value => value.length > 512)) throw Error("brief paths and symbols exceed the input bound");
237
+ const store = curatedStore(project, { deferPromotions: true });
238
+ try {
239
+ const assertCurrent = commandBinding(base, project), repository = boundRepoRoot(policyCwd, base, project), root = repository ?? policyCwd;
240
+ const rootConfirmed = repository !== undefined || project.startsWith("local:") && resolveAlias(base, memoryProjectKey(policyCwd)) === project;
241
+ const settings = () => configuredPolicyOptions(base, policyCwd), policyKey = () => hashJSON(settings()), revision = policyKey(), snapshot = createStandalonePolicy(settings());
242
+ const policy = () => snapshot, policyVersion = () => policyKey();
243
+ if (policyVersion() !== revision) throw Error("brief policy changed before read");
244
+ const result = await buildIntentBrief(createIntentBriefSource(store, project),
245
+ { project, cwd: root, repo: project, intent, paths, symbols, maxBytes, policy, policyVersion, assertCurrent, now: () => store.readNow(), stale: createRecallStalenessProbe({ cwd: root, rootConfirmed, policy }) });
246
+ assertCurrent(); outputReady(result.items.map(view => view.id), project);
247
+ return options["--json"] ? JSON.stringify({ brief: result.text, bytes: result.bytes, truncated: result.truncated, state: result.state }, null, 2) : result.text;
248
+ } finally { store.close(); }
249
+ }
250
+ if ((options["--history"] === true || options["--as-of"] !== undefined) && (options["--all-projects"] === true || options["--tier"] === "repo")) throw Error("historical recall selects this active project's curated store; cross-project/repo history is unavailable");
131
251
  if (sub === "migrate") {
132
252
  const modes = ["--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
133
253
  if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
@@ -140,7 +260,27 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
140
260
  }
141
261
  return JSON.stringify(await migrate({ ...common, mode }), null, 2);
142
262
  }
143
- if (sub === "status") return JSON.stringify(shardMigrationStatus(memoryHome()), null, 2);
263
+ if (sub === "status") {
264
+ // F1: say where the memory lives and which configuration is in effect. The migration fields keep their names and shape; `home` and `config` are added.
265
+ const where = resolveMemoryHome(), loaded = loadAmemConfig(where.home, repoConfigRoot(policyCwd));
266
+ const { config, source } = loaded;
267
+ return JSON.stringify({ home: { path: where.home, source: where.source },
268
+ config: { machine: source.machine, repo: source.repo, denyPaths: config.denyPaths.length, piiPatterns: config.piiPatterns.length,
269
+ ...(config.briefBytes !== undefined ? { briefBytes: config.briefBytes } : {}), ...(config.excerptBytes !== undefined ? { excerptBytes: config.excerptBytes } : {}),
270
+ ...(config.capturePaths !== undefined ? { capturePaths: config.capturePaths } : {}), ...(Object.keys(config.capture).length ? { capture: config.capture } : {}) },
271
+ // F7: pending candidates, and a warning when any is within 7 days of its age expiry (a shown one is then deleted after its grace period; an unshown one is kept until it is shown).
272
+ candidates: (() => { const bound = boundProject(), summary = pendingSummary(where.home, [bound, ...aliasedKeys(where.home, bound)], Date.now(), true);
273
+ if (summary === null) return { unavailable: "candidates.db exists but cannot be read" };
274
+ return { ...summary, ...(summary.dueWithin7Days ? { warning: `${summary.dueWithin7Days} candidate${summary.dueWithin7Days === 1 ? "" : "s"} within 7 days of expiry: run \`amem review walk\`` } : {}) }; })(),
275
+ ...shardMigrationStatus(where.home) }, null, 2);
276
+ }
277
+ if (sub === "doctor") {
278
+ // F15: one read-only report; see src/memory/doctor.ts for exactly what "read-only" covers.
279
+ const where = resolveMemoryHome(), project = boundProject();
280
+ const report = await doctorReport({ home: where.home, homeSource: where.source, cwd: policyCwd, project, aliases: aliasedKeys(where.home, project), now: Date.now(),
281
+ openCurated: base => curatedStoreAt(base, project, { deferPromotions: true }) });
282
+ return options["--json"] ? JSON.stringify(report, null, 2) : doctorText(report);
283
+ }
144
284
  if (sub === "prune") {
145
285
  const applying = options["--apply"] === true;
146
286
  if (applying ? options["--dry-run"] || options["--cutoff"] || !options["--plan"] : !options["--dry-run"] || !options["--cutoff"] || options["--plan"]) throw new Error("usage: amem prune [--user] --cutoff <UTC> --dry-run | [--user] --apply --plan <file>");
@@ -161,17 +301,124 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
161
301
  return JSON.stringify(applying ? applyPrune(store.prunePort(), input, auth) : planPrune(store.prunePort(), { cutoff: String(options["--cutoff"]) }, auth), null, 2);
162
302
  } finally { store.close(); }
163
303
  }
304
+ if (sub === "decay") {
305
+ const applying = options["--apply"] === true;
306
+ if (applying && context.write === undefined && !(context.stdoutTTY ?? process.stdout.isTTY)) throw new Error("decay apply shows a revision preview before confirmation, so stdout must be a terminal");
307
+ const { decayPreview, applyDecay, readDecayCounters, DECAY_DEFAULT_DAYS, assertExactDecayForget } = await import("./memory/decay-review.ts");
308
+ const project = boundProject(), base = memoryHome(), aliases = aliasedKeys(base, project);
309
+ const store = curatedStore(project, { deferPromotions: true, screen: note => policyOf().screenStandalone(note) ? "promote" : "keep" });
310
+ try {
311
+ const assertCurrent = commandBinding(store.profile.home, project);
312
+ const selected = parsed.positionals.length ? parsed.positionals : undefined;
313
+ if (options["--days"] !== undefined && !/^[1-9][0-9]*$/.test(String(options["--days"]))) throw new Error("decay days must be a positive decimal integer");
314
+ const days = options["--days"] === undefined ? DECAY_DEFAULT_DAYS : Number(options["--days"]);
315
+ const settings = { days, ...(selected ? { ids: selected } : {}), aliases, counters: () => readDecayCounters(base, [project, ...aliases]) };
316
+ const current = { ...settings, screen: (note: import("./memory/store.ts").View) => policyOf().screenStandalone(note), assertCurrent };
317
+ const preview = decayPreview(store, project, current), action = options["--forget"] === true ? "FORGET" : "demote";
318
+ const display = [`${action} preview: ${preview.entries.length} notes older than ${days} days; citation history unknown (${preview.citedExemptions ?? "unknown"} observed retained-positive exemptions)${preview.truncated ? "; truncated" : ""}`,
319
+ ...preview.entries.map(entry => `${entry.target.id} revision ${entry.target.revision} ${entry.view.at} ${entry.view.kind}: ${terminalSafe(entry.view.title ?? entry.view.text)}`)].join("\n");
320
+ if (!applying) return options["--json"] ? JSON.stringify(preview, null, 2) : display;
321
+ if (!preview.entries.length) return options["--json"] ? JSON.stringify({ action, ids: [] }) : "no eligible notes to apply";
322
+ if (options["--forget"] === true) assertExactDecayForget(store, preview.entries.map(entry => entry.target.id), project);
323
+ const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); }); write(display);
324
+ const ask = context.prompt ?? ((question: string) => prompt(question));
325
+ const answer = await ask(`Type yes to ${action} these ${preview.entries.length} exact entries${options["--forget"] ? " permanently (hidden claude-mem-imported copies require separate review; expanded groups are refused)" : ` into the short tier, expires ${preview.demoteExpiresAt}`}: `);
326
+ if (answer?.trim() !== "yes") return options["--json"] ? JSON.stringify({ cancelled: true }) : "decay apply cancelled";
327
+ assertCurrent();
328
+ const result = applyDecay(store, preview, { ...current, actor: "person", confirmed: true, forget: options["--forget"] === true });
329
+ return options["--json"] ? JSON.stringify(result, null, 2) : `${result.action}: ${result.ids.length} entries${result.expiresAt ? `; expires ${result.expiresAt}` : ""}`;
330
+ } catch (error) { if (error instanceof DecayApplyError) error.jsonOutput = options["--json"] === true; throw error; }
331
+ finally { store.close(); }
332
+ }
333
+ if (sub === "note") {
334
+ // F6: the person writes a curated note. Person-only (the agent-marker refusal and the terminal requirement above already ran, because `note add` mutates).
335
+ const env = context.env ?? process.env, edit = context.edit ?? ((initial: string) => runEditor(env, initial));
336
+ const kind = (options["--kind"] ?? "finding") as Parameters<typeof buildNote>[0]["kind"];
337
+ const fail: Partial<FailFields> = { ...(typeof options["--approach"] === "string" ? { approach: options["--approach"] } : {}), ...(typeof options["--symptom"] === "string" ? { symptom: options["--symptom"] } : {}),
338
+ ...(typeof options["--cause"] === "string" ? { cause: options["--cause"] } : {}), ...(typeof options["--retry-when"] === "string" ? { retryWhen: options["--retry-when"] } : {}) };
339
+ const evidence = repeated(argv, "--evidence"); if (evidence.length) fail.evidence = evidence;
340
+ let text: string | undefined = parsed.positionals.join(" ");
341
+ if (text === "-") text = (context.stdin ?? (() => readFileSync(0, "utf8")))();
342
+ else if (!text.trim()) {
343
+ text = undefined;
344
+ // No text: a fail note with no field given opens the labelled template, any other note opens an empty file.
345
+ if (kind === "fail" && !Object.keys(fail).length) Object.assign(fail, parseFailTemplate(await edit(FAIL_TEMPLATE)));
346
+ else if (kind !== "fail") text = await edit("");
347
+ }
348
+ const project = boundProject(), policy = policyOf();
349
+ const store = curatedStore(project, { deferPromotions: true, screen: note => policy.screenStandalone(note) ? "promote" : "keep" });
350
+ try {
351
+ const { id } = noteAdd(store, project, { kind, tier: (options["--tier"] ?? "long") as "short" | "long", scope: (options["--scope"] ?? "project") as "project" | "shared",
352
+ ...(text !== undefined ? { text } : {}), ...(typeof options["--title"] === "string" ? { title: options["--title"] } : {}), ...(Object.keys(fail).length ? { fail } : {}) }, policy);
353
+ return options["--json"] ? JSON.stringify({ id, kind }, null, 2) : `added ${kind} note ${id}`;
354
+ } finally { store.close(); }
355
+ }
164
356
  if (review) {
165
- if (sub === "accept" && options["--kind"] !== "memory" && shardStatePresent()) throw new Error("candidate acceptance requires shard-aware review integration; use --kind memory only for a general memory candidate");
166
- if (sub === "accept" && options["--kind"] !== "memory" && resolveProfile(memoryHome()).kind === "generation") throw new Error("candidate review is not yet integrated with this generation; use --kind memory only for a general memory candidate");
167
- if (sub === "list" && options["--kind"] !== "memory" && resolveProfile(memoryHome()).kind === "generation") throw new Error("candidate review is not yet integrated with this generation; select --kind memory for the standalone memory candidate surface");
168
357
  const id = parsed.positionals[0];
169
- if (sub !== "list" && (!id || !/^[a-f0-9]{64}$/.test(id))) throw new Error("review accept/reject requires a full 64-character candidate id");
358
+ if (sub !== "list" && sub !== "walk" && (!id || !/^[a-f0-9]{64}$/.test(id))) throw new Error("review accept/reject requires a full 64-character candidate id");
170
359
  const project = boundProject(), aliases = aliasedKeys(memoryHome(), project), candidates = new CandidateStore(aliases.length ? { reconcile: { canonical: project, aliases } } : {});
171
360
  try {
361
+ if (sub === "walk") {
362
+ // F7 / AC4: each candidate is stamped shown as it is put in front of the person, then accepted, edited (written as the person's own note and the candidate closed), rejected or skipped.
363
+ // The candidate is WRITTEN to the terminal before the question, and stamped shown only after that write succeeded: a person who answers a/e/r must have seen what it is, and a Ctrl-C
364
+ // or a closed pipe before the print leaves nothing stamped. Each outcome is written as it happens, so an interrupted walk still shows what it did.
365
+ // The walk asks accept, edit or reject questions, so what it prints must reach a person: with stdout redirected to a file or a pipe nobody sees the candidate, and stamping it shown
366
+ // would start the 7-day clock for something unread. Refused before anything is read or stamped. (A caller-supplied `write` is a fixture standing in for the terminal.)
367
+ if (context.write === undefined && !(context.stdoutTTY ?? process.stdout.isTTY)) throw new Error("review walk prints each candidate and asks about it, so stdout must be a terminal (not redirected to a file or a pipe)");
368
+ const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); });
369
+ const ask = context.prompt ?? ((question: string) => prompt(question)), scope = [project, ...aliases], base = realPath(candidates.dir), assertWalkCurrent = commandBinding(candidates.dir, project);
370
+ // Oldest first: those are the ones nearest their expiry (`list` is newest first).
371
+ const rows = candidates.list({ project, status: "pending", ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}) }).reverse();
372
+ if (!rows.length) return "no candidates pending";
373
+ const done = { accepted: 0, edited: 0, rejected: 0, skipped: 0, failed: 0 };
374
+ const lines = { push: (...texts: string[]) => { for (const text of texts) write(text); return 0; } };
375
+ const body = (candidate: (typeof rows)[number]): string => { const input = candidate.input;
376
+ if (input.kind === "promotion") {
377
+ const viewStore = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
378
+ try {
379
+ const target = viewStore.get(input.target.id, project);
380
+ return target && target.id === input.target.id && candidateTargetRevision(target) === input.target.revision
381
+ ? `promotion. Goal goal:${input.goalId}, mem:${target.id}. ${target.title ? `${target.title}: ` : ""}${target.text}`
382
+ : `promotion. Goal goal:${input.goalId}, target mem:${input.target.id} changed or is unavailable; reject or skip`;
383
+ } finally { viewStore.close(); }
384
+ }
385
+ return input.kind === "memory" ? `${input.title ? `${input.title}: ` : ""}${input.text}` : input.kind === "fail" ? `fail. Approach: ${input.approach} Symptom: ${input.symptom} Cause: ${input.cause} Retry when: ${input.retryWhen}`
386
+ : input.kind === "correction" ? `correction. ${input.proposedText} Reason: ${input.reason}` : `decay. ${input.reason}`; };
387
+ const step = async (args: string[]) => { try { await runCli(args, context); return true; } catch (error) { lines.push(` ${(error as Error).message.split("\n")[0]}`); done.failed++; return false; } };
388
+ for (const [index, candidate] of rows.entries()) {
389
+ assertWalkCurrent();
390
+ lines.push(`${index + 1}/${rows.length} ${candidate.id.slice(0, 12)} ${candidate.input.kind} ${candidate.createdAt.slice(0, 10)}`, ` ${terminalSafe(body(candidate))}`);
391
+ assertWalkCurrent();
392
+ markShown(base, scope, Date.now(), [candidate.id]); // after the write: shown means the person could read it
393
+ const answer = ((await ask("[a]ccept [e]dit [r]eject [s]kip [q]uit: ")) ?? "q").trim().toLowerCase()[0] ?? "s";
394
+ assertWalkCurrent();
395
+ if (answer === "q") break;
396
+ if (answer === "a") { if (await step(["review", "accept", candidate.id, ...(candidate.input.kind === "memory" ? ["--kind", "memory"] : []), "--json"])) done.accepted++; }
397
+ else if (answer === "r") { if (await step(["review", "reject", candidate.id])) done.rejected++; }
398
+ else if (answer === "e") {
399
+ const input = candidate.input;
400
+ if (input.kind !== "memory" && input.kind !== "fail") { lines.push(" edit is for memory and fail candidates; accept, reject or skip this one"); done.failed++; continue; }
401
+ const edit = context.edit ?? ((initial: string) => runEditor(context.env ?? process.env, initial));
402
+ // A memory candidate proposed as a fail note opens the template (its own text pre-filled under Approach when it is not already labelled), so the person can structure it.
403
+ const first = input.kind === "fail" ? `Approach: ${input.approach}\nSymptom: ${input.symptom}\nCause: ${input.cause}\nEvidence: ${input.evidenceRefs.join(", ")}\nRetry when: ${input.retryWhen}\n`
404
+ : input.noteKind === "fail" && !hasFailLabel(input.text) ? `Approach: ${input.text}\nSymptom: \nCause: \nEvidence: \nRetry when: \n` : input.text;
405
+ try {
406
+ const edited = await edit(first); assertWalkCurrent(); const standalone = policyOf(), store = curatedStore(project, { deferPromotions: true, screen: note => standalone.screenStandalone(note) ? "promote" : "keep" });
407
+ // A fail candidate, and a general memory candidate proposed as a fail note, are both written as a structured fail note from the labelled body the person edited.
408
+ const asFail = input.kind === "fail" || input.noteKind === "fail";
409
+ try { noteAdd(store, project, asFail ? { kind: "fail", tier: "long", scope: "project", fail: parseFailTemplate(edited), ...(input.kind === "memory" && input.title ? { title: input.title } : {}) } : { kind: input.kind === "memory" ? input.noteKind ?? "finding" : "finding", tier: "long", scope: "project", text: edited, ...(input.kind === "memory" && input.title ? { title: input.title } : {}) }, standalone); }
410
+ finally { store.close(); }
411
+ candidates.reject(candidate.id, { project, actor: "person", verdict: "public" }); done.edited++;
412
+ } catch (error) { lines.push(` ${(error as Error).message.split("\n")[0]}`); done.failed++; }
413
+ } else done.skipped++;
414
+ }
415
+ return `accepted ${done.accepted}, edited ${done.edited}, rejected ${done.rejected}, skipped ${done.skipped}, failed ${done.failed}`;
416
+ }
172
417
  if (sub === "list") {
173
418
  const rows = candidates.list({ project, ...(options["--status"] ? { status: options["--status"] as CandidateFilter["status"] } : {}),
174
419
  ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
420
+ // AC4, "shown" means the person read the candidate's content, and the list cannot know that: it prints an id, a kind and a date (no content), it is run by scripts and cron, and
421
+ // `--json` may be piped to a program. So `review list` stamps nothing, exactly like the hook; only `review walk`, which prints each candidate before it asks, does.
175
422
  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";
176
423
  }
177
424
  if (sub === "reject") {
@@ -180,33 +427,56 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
180
427
  }
181
428
  const candidate = candidates.get(id!, project);
182
429
  if (!candidate) throw new Error("candidate unavailable");
183
- if (options["--kind"] === "memory" && candidate.input.kind !== "memory") throw new Error("--kind memory accepts only a general memory candidate");
184
- if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; use ahub memory demote or forget after review");
185
- if (candidate.input.kind !== "memory" && (shardStatePresent() || resolveProfile(memoryHome()).kind === "generation")) throw new Error("candidate acceptance requires shard-aware and profile-aware host integration for this candidate kind; the candidate remains pending");
186
- const standalone = createStandalonePolicy();
187
- if (!await standalone.screen(candidate.input)) throw new Error("candidate refused by current host policy; current standalone privacy policy declined it");
188
- // Candidate acceptance uses the actual secret screen; the core defers unrelated automatic promotions.
189
- const store = curatedStore(project, { deferPromotions: true, screen: note => looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
430
+ if (options["--kind"] !== undefined && candidate.input.kind !== options["--kind"]) throw new Error("candidate kind does not match --kind");
431
+ if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; use `amem demote` or `amem forget` after review");
432
+ const store = curatedStore(project, { deferPromotions: true, screen: note => policyOf().screenStandalone(note) ? "promote" : "keep" });
190
433
  try {
434
+ const assertCurrent = commandBinding(store.profile.home, project);
435
+ if (!await policyOf().screen(candidate.input)) throw new Error("candidate refused by the current standalone privacy policy (secrets, personal data, deny paths and the patterns in amem.config.json)");
436
+ assertCurrent();
191
437
  const result = candidates.accept(id!, { project, actor: "person", verdict: "public", store,
192
- policy: (proposal, _target, note) => !looksSecret(JSON.stringify(proposal.input)) && !looksSecret(JSON.stringify(note)),
438
+ policy: (proposal, target, note, goal) => { assertCurrent(); return policyOf().screenStandalone({ ...proposal.input, ...(target ? { targetView: target } : {}), note, ...(goal ? { goalView: goal } : {}) }); },
193
439
  });
194
- return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ordinary note ${result.noteId}`;
440
+ return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ${candidate.input.kind === "promotion" ? "promoted" : "ordinary"} note ${result.noteId}`;
195
441
  } finally { store.close(); }
196
442
  } finally { candidates.close(); }
197
443
  }
444
+ if (sub === "vault-candidates" && (options["--all-projects"] === true || options["--project"] !== undefined)) {
445
+ if (options["--all-projects"] === true && options["--project"] !== undefined) throw new Error("choose either --all-projects or --project <key>, not both");
446
+ const result = await vaultCandidatesAll(memoryHome(), { policy: policyOf(), ...(options["--project"] !== undefined ? { project: resolveAlias(memoryHome(), String(options["--project"])) } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
447
+ return JSON.stringify(result, null, 2);
448
+ }
198
449
  if (sub === "vault-candidates") {
199
450
  const project = boundProject(), store = curatedStore(project, { deferPromotions: true });
200
451
  try {
201
- const result = await vaultCandidates(store, { project, policy: createStandalonePolicy(), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
452
+ const result = await vaultCandidates(store, { project, policy: policyOf(), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
202
453
  return JSON.stringify(result, null, 2);
203
454
  } finally { store.close(); }
204
455
  }
456
+ if (sub === "eval") {
457
+ // F13: read-only. A set with entries is scored against a throwaway store seeded with them (touches no memory home); one without scores the bound project's real entries.
458
+ const ks = parseKs(typeof options["--k"] === "string" ? options["--k"] : undefined);
459
+ const file = typeof options["--set"] === "string" ? options["--set"] : undefined;
460
+ const set = readEvalSet(file ?? FIXTURE_PATH);
461
+ let report;
462
+ if (set.entries?.length) report = evalSeeded(set, ks);
463
+ else {
464
+ const project = boundProject(), store = curatedStore(project, { deferPromotions: true });
465
+ try { report = evalStore(store, project, set, ks); } finally { store.close(); }
466
+ }
467
+ return options["--json"] ? JSON.stringify(report, null, 2) : evalText(report, file ?? "built-in fixture");
468
+ }
205
469
  if (sub === "capacity") {
206
470
  const share = options["--disk-share"];
207
471
  if (share !== undefined && (!Number.isFinite(Number(share)) || Number(share) <= 0 || Number(share) > 1)) throw new Error("--disk-share must be a number in (0, 1]");
208
472
  const report = capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) });
209
473
  if (parsed.path[1] !== "apply") return options["--json"] ? JSON.stringify(report, null, 2) : capacityText(report);
474
+ if (options["--split"] === true) {
475
+ // #15 D7: each shard's own share of the global budget, written to its own config. The same two steps as the home default below, bound to the shards it was made for.
476
+ if (options["--cap-mb"] !== undefined || options["--retention-days"] !== undefined) throw new Error("--split computes each shard's values; do not combine it with --cap-mb or --retention-days");
477
+ const diskShare = share !== undefined ? { diskShare: Number(share) } : {};
478
+ return JSON.stringify(options["--confirm"] === true ? applySplit(memoryHome(), diskShare) : previewSplit(memoryHome(), diskShare), null, 2);
479
+ }
210
480
  // Person-only (agent marker and TTY are refused above): preview first, --confirm writes the home default config.
211
481
  const file = join(memoryHome(), "capture-config.json");
212
482
  const target: CaptureConfig = {
@@ -231,6 +501,53 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
231
501
  rmSync(previewFile, { force: true });
232
502
  return JSON.stringify({ preview, written: { cap_mb: written.capMb, retention_days: written.retentionDays } }, null, 2);
233
503
  }
504
+ if (sub === "projects") {
505
+ // Archived projects stay listed (the person must see what is cold); a forgotten one is out of the default view, and --all brings it back with its state.
506
+ const rows = readCatalogFresh(memoryHome()).filter(project => options["--all"] === true || project.state !== "forgotten");
507
+ if (options["--json"]) return JSON.stringify(rows, null, 2);
508
+ if (!rows.length) return "no project shards yet";
509
+ const mb = (n: number) => `${(n / 1048576).toFixed(1)} MB`;
510
+ // Per-project curated counts exist only after the split; before it every note is in one shared store, so the honest figure is unavailable, never 0.
511
+ const split = shardStatePresent();
512
+ return rows.map(p => `${p.key} ${p.state}${p.local ? " local-only key" : ""}${p.aliases.length ? ` aliases: ${p.aliases.join(", ")}` : ""} capture ${p.captureRows} rows ${mb(p.captureBytes)} curated ${split ? p.curatedEntries ?? "unavailable" : "unavailable (home not split)"} disk ${mb(p.diskBytes)} last ${p.captureLastAt ?? "never"}`).join("\n");
513
+ }
514
+ if (sub === "usage") {
515
+ const limit = options["--limit"] === undefined ? undefined : Number(options["--limit"]);
516
+ const report = usageReport(memoryHome(), { ...(limit !== undefined ? { limit } : {}), includeForgotten: options["--all"] === true });
517
+ return options["--json"] ? JSON.stringify(report, null, 2) : usageText(report);
518
+ }
519
+ if (sub === "gc") {
520
+ // A preview reads; only --confirm evicts (and then the terminal and agent-marker refusals above already ran, because `mutates: "confirm"`).
521
+ const batches = options["--batches"] === undefined ? undefined : Number(options["--batches"]);
522
+ const result = runGc(memoryHome(), { dryRun: options["--confirm"] !== true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) });
523
+ return options["--json"] ? JSON.stringify(result, null, 2) : `${result.dryRun ? "preview (nothing evicted; add --confirm):\n" : ""}${gcText(result)}`;
524
+ }
525
+ if (sub === "project") {
526
+ const op = parsed.path[1]!, [requested] = parsed.positionals;
527
+ if (!requested) throw new Error(`usage: amem project ${op} <key>`);
528
+ if (op === "export") {
529
+ if (typeof options["--to"] !== "string") throw new Error("usage: amem project export <key> --to <file>");
530
+ // The bundle leaves the home, so it is screened by the configured policy (secrets, personal data, the configured patterns and deny paths).
531
+ const result = await exportProject(memoryHome(), requested, { to: options["--to"], policy: policyOf() });
532
+ return options["--json"] ? JSON.stringify(result, null, 2) : `exported ${result.project} to ${result.file}: ${result.counts.capture} capture rows, ${result.counts.curated} notes, ${result.counts.goals} goals, ${result.counts.workLists} work lists${result.withheld ? `; ${result.withheld} withheld by the screen` : ""}`;
533
+ }
534
+ const key = requireShard(memoryHome(), requested);
535
+ if (op === "forget" && options["--confirm"] !== true) {
536
+ const state = projectState(memoryHome(), key), shard = readCatalogFresh(memoryHome()).find(p => p.key === key);
537
+ return `preview: forget ${key} (now ${state}; capture ${shard?.captureRows ?? 0} rows, curated ${shardStatePresent() ? shard?.curatedEntries ?? "unavailable" : "unavailable (home not split)"}). Forget is an append-only record that takes the project out of recall and every listing for good; nothing is erased here and it cannot be undone. Re-run with --confirm to record it.`;
538
+ }
539
+ const written = appendLifecycle(memoryHome(), { op: op as "archive" | "unarchive" | "forget", key, actor: "person", machine: machineId(memoryHome()).id });
540
+ return options["--json"] ? JSON.stringify(written, null, 2) : `${op === "forget" ? "forgot" : op === "archive" ? "archived" : "unarchived"} ${written.key}${requested !== written.key ? ` (alias ${requested})` : ""}`;
541
+ }
542
+ if (sub === "search" && options["--all-projects"] === true) {
543
+ const words = parsed.positionals.join(" ");
544
+ if (!words.trim()) throw new Error("usage: amem search --all-projects <words> [--limit N] [--include-archived] [--json]");
545
+ const result = searchAllProjects(memoryHome(), words, { ...(options["--limit"] !== undefined ? { limit: Number(options["--limit"]) } : {}), includeArchived: options["--include-archived"] === true });
546
+ if (options["--json"]) return JSON.stringify(result, null, 2);
547
+ const one = (hit: (typeof result.hits)[number]) => hit.view ? `${hit.project} ${hit.view.id.slice(0, 8)} ${hit.view.kind} ${(hit.view.title ?? hit.view.text).split("\n")[0]!.slice(0, 100)}` : `${hit.project} capture ${hit.capture!.id} ${hit.capture!.event} ${hit.capture!.excerpt.split("\n")[0]!.slice(0, 100)}`;
548
+ return [`crossed projects: searched ${result.projects.length} (${result.projects.join(", ")})`, ...(result.hits.length ? result.hits.map(one) : ["(nothing)"])].join("\n");
549
+ }
550
+ if (sub === "search" && options["--include-archived"] === true) throw new Error("--include-archived only applies with --all-projects");
234
551
  if (sub === "alias") {
235
552
  if (parsed.path[1] === "add") {
236
553
  const [from, to] = parsed.positionals;
@@ -249,18 +566,132 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
249
566
  return options["--json"] ? JSON.stringify(ops, null, 2) : ops.length ? ops.map((op) => op.op === "alias" ? `${op.at} ${op.alias} -> ${op.key} (${op.actor})` : `${op.at} ${op.alias} removed (${op.actor})`).join("\n") : "no aliases recorded";
250
567
  }
251
568
  // ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
252
- if (goal && sub === "link") throw new Error("goal link needs host integration; use ahub goal link");
253
- const crosses = definition.policyFree === true && policyFree;
569
+ if (goal && sub === "link") throw new Error("goal link needs a host with a task board; amem alone has none");
570
+ // #15 T4 / D6: the repo scope. Person-only and a terminal were enforced above (agent markers, `mutates`); amem never runs git.
571
+ const repoCwd = context.cwd ?? process.cwd();
572
+ if (sub === "repo") {
573
+ const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project);
574
+ if (!root) throw new Error("this directory is not inside the working tree of the repository that is this project (amem repo works on the current repository only)");
575
+ if (parsed.path[1] === "attributes") {
576
+ const plan = planGitattributes(root);
577
+ if (plan.unsafe) throw new Error(plan.unsafe);
578
+ if (plan.present) return `${plan.path} already has the merge=union entry for .amem/log.jsonl`;
579
+ if (options["--confirm"] !== true) return `preview: append ".amem/log.jsonl merge=union" to ${plan.path} (nothing written; add --confirm). amem never commits it: you do.`;
580
+ const written = appendGitattributes(root);
581
+ return `${written.added ? "added" : "already had"} the merge=union entry in ${written.path}; commit it through normal review`;
582
+ }
583
+ const scoped = repoReadPolicy(root);
584
+ const read = "reason" in scoped ? undefined : await readRepoScope(root, scoped.policy);
585
+ const status = { root, file: repoLogPath(root), state: read?.state ?? "unavailable", entries: read?.views.length ?? 0, skipped: read?.skipped ?? null, ...(read?.reason ? { reason: read.reason } : {}), ...("reason" in scoped ? { reason: scoped.reason } : {}) };
586
+ return options["--json"] ? JSON.stringify(status, null, 2) : [`repo scope of ${project}: ${status.state}`, `file: ${status.file}`, `entries: ${status.entries}${read ? ` (skipped: ${read.skipped.malformed} malformed, ${read.skipped.mismatched} id mismatch, ${read.skipped.duplicate} duplicate, ${read.skipped.withheld} withheld by the screen)` : ""}`,
587
+ ...(status.reason ? [`unavailable: ${status.reason}`] : [])].join("\n");
588
+ }
589
+ if (sub === "promote" && options["--to"] === undefined && (options["--confirm"] !== undefined || options["--confirm-public"] !== undefined)) throw new Error("--confirm and --confirm-public only apply with --to repo");
590
+ if (sub === "promote" && options["--to"] !== undefined) {
591
+ const idText = parsed.positionals[0];
592
+ if (!idText || parsed.positionals.length !== 1) throw new Error("usage: amem promote <id> --to repo --policy-free [--confirm [--confirm-public <entry id prefix>]]");
593
+ const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project);
594
+ if (!root) throw new Error("repo scope needs the working tree of the repository that is this project: run it from inside that repository");
595
+ const store = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
596
+ let view;
597
+ try { view = store.get(idText, project); } finally { store.close(); }
598
+ if (!view) throw new Error(`no memory entry ${idText} in this project (amem list shows the ids)`);
599
+ // `get` also returns shared-scope notes of other projects: only this project's own private notes go into this repository.
600
+ if (resolveAlias(memoryHome(), view.project) !== project) throw new Error(`${view.id.slice(0, 8)} belongs to another project; only this project's notes can be promoted into its repository`);
601
+ const plan = await planRepoPromotion(root, view, configuredPolicyOptions(memoryHome(), root));
602
+ if (plan.refusals.length) throw new Error(`not promoted to repo scope:\n- ${plan.refusals.join("\n- ")}`);
603
+ const seen = await repoVisibility(root, context.visibility);
604
+ const preview = [`the exact line that would be appended to ${plan.path}:`, plan.text, ...(plan.droppedNodes.length ? [`(left out, private references: ${plan.droppedNodes.join(", ")})`] : []),
605
+ `repository visibility: ${seen.state}${seen.remotes.length ? ` (${[...new Set(seen.remotes.map(r => `${r.name}${r.kind === "push" ? " (push)" : ""} ${r.visibility}`))].join(", ")})` : " (no remote)"}`];
606
+ if (plan.alreadyPresent) return [...preview, "already in the file: nothing to write"].join("\n");
607
+ const entryPrefix = plan.line.id.slice(0, 8);
608
+ if (options["--confirm"] !== true) return [...preview, seen.state === "private" ? "nothing written; add --confirm to append it" : `NOT a verified private repository: add --confirm --confirm-public ${entryPrefix} to append this entry (per entry, never blanket)`].join("\n");
609
+ if (seen.state !== "private") {
610
+ const given = options["--confirm-public"];
611
+ if (typeof given !== "string" || given.length < 8 || !/^[a-f0-9]+$/.test(given) || !plan.line.id.startsWith(given)) throw new Error(`the repository is ${seen.state === "public" ? "public" : "not verified private"}: repo scope needs your confirmation of this entry: --confirm-public ${entryPrefix} (the first 8 characters of the id in the line above)`);
612
+ }
613
+ const written = appendRepoLine(root, plan.line);
614
+ return [...preview, `appended to ${written.path}. amem does not commit or push: review and commit it yourself.`,
615
+ ...(repoLogIgnored(root) ? ["WARNING: git ignores .amem/log.jsonl here, so `git status` will not show it and it cannot be committed until you un-ignore it (a .gitignore or .git/info/exclude rule)."] : [])].join("\n");
616
+ }
617
+ if (sub === "show" && /^repo:/.test(parsed.positionals[0] ?? "")) {
618
+ const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project), scoped = root ? repoReadPolicy(root) : undefined;
619
+ const rows = root && scoped && !("reason" in scoped) ? (await readRepoScope(root, scoped.policy)).views : [];
620
+ const wanted = parsed.positionals[0]!.slice(5), hit = rows.filter(row => row.id === wanted || wanted.length >= 6 && row.id.startsWith(wanted));
621
+ if (hit.length !== 1) throw new Error(`no repo entry ${parsed.positionals[0]} in this repository's .amem/log.jsonl`);
622
+ const row = hit[0]!;
623
+ return options["--json"] ? JSON.stringify(row, null, 2) : [`repo:${row.id} ${row.kind}, repo scope, ${row.at}`, ...(row.about.length ? [`about: ${row.about.join(", ")}`] : []), "", ...(row.title ? [row.title] : []), row.text].join("\n");
624
+ }
625
+ // Recall (D4): the current project plus, when the repository IS this project, that repository's `.amem/`. Never another project's.
626
+ const withRepoRows = async (output: string): Promise<string> => {
627
+ if (options["--history"] === true || options["--as-of"] !== undefined) return output;
628
+ if (sub !== "list" && sub !== "search") return output;
629
+ const root = boundRepoRoot(repoCwd, memoryHome(), boundProject());
630
+ if (!root) return output;
631
+ const scoped = repoReadPolicy(root);
632
+ if ("reason" in scoped) return options["--json"] ? output : `${output}\n(repo scope unavailable: ${scoped.reason})`;
633
+ const read = await readRepoScope(root, scoped.policy);
634
+ if (read.state === "absent" && !read.skipped.malformed) return output;
635
+ const words = sub === "search" ? parsed.positionals.join(" ").toLowerCase().split(/\s+/).filter(Boolean) : [];
636
+ const kind = options["--kind"], since = options["--since"], limit = options["--limit"] !== undefined ? Number(options["--limit"]) : 50;
637
+ const rows = read.views.filter(row => options["--tier"] !== "short" && (kind === undefined || row.kind === kind) && (typeof since !== "string" || Date.parse(row.at) >= Date.parse(since))
638
+ && words.every(word => `${row.title ?? ""}\n${row.text}\n${row.about.join(" ")}`.toLowerCase().includes(word))).slice(0, limit);
639
+ if (options["--json"]) { try { const base = JSON.parse(output) as unknown[]; return JSON.stringify([...base, ...rows], null, 2); } catch { return output; } }
640
+ const line = (row: RepoView) => `repo:${row.id.slice(0, 8)} ${row.at.slice(0, 16).replace("T", " ")} repo ${row.kind.padEnd(8)} ${`${row.title ? `${row.title}: ` : ""}${row.text}`.replace(/\s+/g, " ").slice(0, 100)}`;
641
+ const note = read.state === "unsafe" ? [`(repo scope unavailable: ${read.reason})`] : read.skipped.malformed + read.skipped.mismatched + read.skipped.withheld ? [`(${read.skipped.malformed + read.skipped.mismatched} repo lines skipped as invalid, ${read.skipped.withheld} withheld by the screen)`] : [];
642
+ return [output, ...(rows.length ? ["repo scope (.amem/log.jsonl):", ...rows.map(line)] : []), ...note].join("\n");
643
+ };
644
+ const crosses = definition.policyFree === true;
254
645
  const cwd = context.cwd ?? process.cwd();
255
- // Reads can trigger held-note reconciliation. Defer it unless this terminal explicitly authorized crossing policy.
256
- const open = () => curatedStore(boundProject(), { screen: note => !crosses ? "skip" : looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
257
- // A person's reads are unscreened. Writes crossing the boundary remain secret-screened without host policy.
258
- if (goal) return rawGoalCommand(args, cwd, { project: boundProject(), open, clean: (title, milestones) => !mutating || !looksSecret(`${title}\n${milestones.join("\n")}`) });
646
+ const configured = policyOf(), recallRoot = boundRepoRoot(cwd, memoryHome(), boundProject());
647
+ const rootConfirmed = recallRoot !== undefined || boundProject().startsWith("local:") && resolveAlias(memoryHome(), memoryProjectKey(cwd)) === boundProject();
648
+ // Reads can trigger held-note reconciliation. Defer it unless this command is one that moves notes across the boundary (promote, sync, import, review accept), and then the configured policy decides each note.
649
+ const open = () => curatedStore(boundProject(), { screen: note => !crosses ? "skip" : configured.screenStandalone(note) ? "promote" : "keep" });
650
+ // A person's reads are unscreened. A write is screened by the configured policy, which includes the built-in secret screen.
651
+ if (goal) return rawGoalCommand(args, cwd, { project: boundProject(), open, clean: (title, milestones) => !mutating || configured.screenStandalone({ title, milestones }),
652
+ onDone: async (done, store) => {
653
+ const project = boundProject(), base = realPath(store.profile.home), assertCurrent = commandBinding(store.profile.home, project);
654
+ if (readSelector(base).revision !== store.profile.selectorRevision) throw new Error("goal promotion store selector changed");
655
+ assertCurrent();
656
+ const { offerGoalPromotions } = await import("./memory/goal-promotion.ts");
657
+ assertCurrent();
658
+ const candidates = new CandidateStore({ memoryDir: base, reconcile: { canonical: project, aliases: aliasedKeys(base, project) } });
659
+ try { return await offerGoalPromotions(store, candidates, project, done.id, { policy: () => configuredPolicy(base, cwd), assertCurrent }); }
660
+ finally { candidates.close(); }
661
+ } });
259
662
  const receipt: { bridgeStatus?: { skippedRows: number } } = {};
260
- const output = await rawMemoryCommand(args, cwd, { project: boundProject(), open, readable: notes => notes, importPolicy: () => () => false,
261
- onBridgeStatus: status => { receipt.bridgeStatus = status; } });
663
+ // F16: `sync --if-due` is the entry for the person's own shell (an EXIT trap; it needs a terminal like every mutating command). amem registers no sync hook and runs nothing between sessions. It does nothing without a
664
+ // configured remote, nothing when the schedule or the failure backoff says it is not due (`--force` skips that), and it stops a running git step after 30 seconds.
665
+ const ifDue = sub === "sync" && options["--if-due"] === true, syncHome = memoryHome();
666
+ if (options["--force"] === true && !ifDue) throw new Error("--force only applies with sync --if-due");
667
+ if (ifDue) {
668
+ if (options["--remote"] === undefined && !remoteConfigured(syncHome)) return options["--json"] ? JSON.stringify({ ok: true, skipped: "no sync remote configured" }) : "no sync remote configured: nothing to do";
669
+ const state = usableState(readSyncState(syncHome), Date.now());
670
+ if (!syncDue(state, Date.now(), options["--force"] === true)) {
671
+ const next = nextSyncAt(state);
672
+ return options["--json"] ? JSON.stringify({ ok: true, skipped: "not due", nextAttemptAt: next === null ? null : new Date(next).toISOString(), failures: state.failures })
673
+ : `sync not due until ${next === null ? "now" : new Date(next).toISOString()}${state.failures ? ` (${state.failures} failure${state.failures === 1 ? "" : "s"} in a row: the wait doubles up to a day; --force skips it)` : ""}`;
674
+ }
675
+ }
676
+ // Only a real attempt is stamped: a local refusal (lock held, no remote, policy, usage) is not, and a future stamp is no history (src/memory/sync-cadence.ts).
677
+ const stampSync = (error: string | undefined, cause?: StopCause) => { if (syncOutcome(error, cause) !== "local") recordSyncAttempt(syncHome, error === undefined, Date.now()); };
678
+ const syncArgs = args.filter(arg => arg !== "--if-due" && arg !== "--force");
679
+ let output: string;
680
+ const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
681
+ try {
682
+ output = await withRepoRows(await rawMemoryCommand(syncArgs, cwd, { person: true, project: boundProject(), open, readable: notes => notes, stale: createRecallStalenessProbe({ cwd: recallRoot ?? cwd, rootConfirmed, policy: () => configured }), outputReady: notes => outputReady(notes.map(view => view.id)), importPolicy: () => text => configured.declinesPersonal(text),
683
+ sync: { screen: row => configured.screenStandalone(row), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
684
+ onBridgeStatus: status => { receipt.bridgeStatus = status; } }));
685
+ } catch (error) { if (sub === "sync") stampSync((error as Error).message, stop?.cause()); throw error; }
686
+ finally { stop?.dispose(); }
687
+ if (sub === "sync") {
688
+ // `--json` returns the failed result instead of throwing: read the real outcome from it, never assume a returned string means the sync worked.
689
+ let failure: string | undefined;
690
+ if (options["--json"]) { try { const result = JSON.parse(output) as { ok?: unknown; error?: unknown }; if (result.ok === false) failure = typeof result.error === "string" ? result.error : "sync failed"; } catch { /* not a sync result */ } }
691
+ stampSync(failure, stop?.cause());
692
+ }
262
693
  if (sub === "import" && parsed.path[1] === "claude-mem") {
263
- const policy = createStandalonePolicy();
694
+ const policy = configured;
264
695
  const work = await importClaudeMemWorkState({ base: memoryHome(), project: boundProject(), policy: row => policy.screen(row), ...(options["--db"] ? { db: String(options["--db"]) } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}), restart: options["--restart"] === true });
265
696
  return options["--json"] ? JSON.stringify({ ...JSON.parse(output), work }, null, 2) : `${output}\nwork lists: ${JSON.stringify(work)}`;
266
697
  }
@@ -268,9 +699,15 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
268
699
  ? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output;
269
700
  }
270
701
 
702
+ export function formatCliError(error: unknown): string { return error instanceof DecayApplyError ? error.render() : `amem: ${(error as Error).message}`; }
703
+
704
+ /** A completed write means rendered output, never evidence of host delivery or citation. */
705
+ export async function writeCliOutput(text: string, output: Writable = process.stdout): Promise<void> { await writeConfirmed(output, text); }
271
706
  export async function main(argv = process.argv.slice(2)): Promise<void> {
272
- try { const output = await runCli(argv); if (output) console.log(output); }
273
- catch (error) { console.error(`amem: ${(error as Error).message}`); process.exitCode = (error as { exitCode?: number }).exitCode ?? 1; }
707
+ try {
708
+ const receipts: CliOutputReceipt[] = [], output = await runCli(argv, { onOutputReady: receipt => receipts.push(receipt) });
709
+ if (output) { await writeCliOutput(output + "\n"); for (const receipt of receipts) { try { receipt.rendered(); } catch { /* telemetry stays optional */ } } }
710
+ } catch (error) { console.error(formatCliError(error)); process.exitCode = (error as { exitCode?: number }).exitCode ?? 1; }
274
711
  }
275
712
 
276
713
  if (import.meta.main) await main();