@staix/agent-mem 0.12.0 → 0.14.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 (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +70 -44
  3. package/package.json +5 -2
  4. package/src/cli-agent-reads.ts +105 -0
  5. package/src/cli.ts +219 -56
  6. package/src/commands-table.ts +10 -9
  7. package/src/index.ts +1 -0
  8. package/src/ingest.ts +21 -4
  9. package/src/mcp.ts +8 -8
  10. package/src/memory/candidates.ts +15 -13
  11. package/src/memory/capacity-split.ts +4 -1
  12. package/src/memory/capacity.ts +48 -4
  13. package/src/memory/catalog.ts +4 -4
  14. package/src/memory/cli-attribution.ts +23 -0
  15. package/src/memory/commands.ts +63 -11
  16. package/src/memory/consolidate.ts +3 -2
  17. package/src/memory/decay-review.ts +4 -4
  18. package/src/memory/doctor.ts +36 -7
  19. package/src/memory/generation-index.ts +56 -11
  20. package/src/memory/generation.ts +73 -7
  21. package/src/memory/metrics.ts +20 -8
  22. package/src/memory/migrate.ts +31 -0
  23. package/src/memory/mutation.ts +24 -4
  24. package/src/memory/note-add.ts +4 -4
  25. package/src/memory/prune-publisher-lineage.ts +139 -0
  26. package/src/memory/prune.ts +28 -1
  27. package/src/memory/public-prune-activation.ts +260 -0
  28. package/src/memory/public-prune-advance.ts +149 -0
  29. package/src/memory/public-prune-input-chain.ts +73 -0
  30. package/src/memory/public-prune-inputs.ts +455 -0
  31. package/src/memory/public-prune-view.ts +139 -0
  32. package/src/memory/public-prune-witness.ts +168 -0
  33. package/src/memory/reader-lease.ts +46 -34
  34. package/src/memory/shard-migration.ts +42 -3
  35. package/src/memory/shard-sync-format.ts +40 -2
  36. package/src/memory/shard-sync-graph.ts +87 -0
  37. package/src/memory/shard-sync.ts +341 -53
  38. package/src/memory/shard.ts +50 -9
  39. package/src/memory/store.ts +51 -12
  40. package/src/memory/style.ts +3 -0
  41. package/src/memory/sync.ts +141 -5
  42. package/src/memory/vault.ts +31 -0
  43. package/src/setup.ts +59 -31
package/src/cli.ts CHANGED
@@ -1,8 +1,11 @@
1
+ import { activatePublicPruneView } from "./memory/public-prune-activation.ts";
2
+ import { agentCliRead } from "./cli-agent-reads.ts";
3
+ import { cliAttribution, withCliAttribution } from "./memory/cli-attribution.ts";
1
4
  import { randomUUID } from "node:crypto";
2
5
  import type { Writable } from "node:stream";
3
6
  import { buildIntentBrief } from "./memory/intent-brief.ts";
4
7
  import { proposeConsolidation } from "./memory/consolidate.ts";
5
- import { createIntentBriefSource } from "./memory/recall.ts";
8
+ import { createIntentBriefSource, recallViewIsLive } from "./memory/recall.ts";
6
9
  import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
7
10
  import { recordMemoryUse } from "./memory/use-telemetry.ts";
8
11
  import { DecayApplyError } from "./memory/decay-review.ts";
@@ -10,16 +13,17 @@ import { createMemoryMcp } from "./mcp-memory.ts";
10
13
  import { serveMcp, writeConfirmed } from "./mcp.ts";
11
14
  import { createStandalonePolicy } from "./memory/policy.ts";
12
15
  import { configuredPolicy, configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
13
- import { vaultCandidates } from "./memory/vault.ts";
16
+ import { vaultCandidates, vaultCandidatesText, vaultCandidatesAllText } from "./memory/vault.ts";
17
+ import type { IngestKind, IngestReceipt } from "./ingest.ts";
14
18
  import { importClaudeMemCapture, importClaudeMemWorkState } from "./memory/import.ts";
15
19
  import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
16
20
  import { join } from "node:path";
17
21
  import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts";
18
- import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts";
22
+ import { rawMemoryCommand, rawGoalCommand, claudeMemImportText } from "./memory/commands.ts";
19
23
  import { memoryProjectKey } from "./memory/project.ts";
20
24
  import { CandidateStore, candidateTargetRevision, type CandidateFilter } from "./memory/candidates.ts";
21
25
  import { aliasedKeys, appendAlias, appendUnalias, listAliases, readShardedSelection, resolveAlias } from "./memory/shard.ts";
22
- import { capacityReport, capacityText } from "./memory/capacity.ts";
26
+ import { capacityReport, capacityText, capacityApplyText, capacitySplitText } from "./memory/capacity.ts";
23
27
  import { readCaptureConfigFile, writeCaptureConfigFile, type CaptureConfig } from "./memory/capture.ts";
24
28
 
25
29
  import { commandHelp, parseCommand } from "./commands-table.ts";
@@ -27,9 +31,9 @@ import { completionScript } from "./completion.ts";
27
31
  import { memoryHome, resolveMemoryHome } from "./memory/home.ts";
28
32
  import { realPath } from "./memory/platform.ts";
29
33
  import { hashJSON, readSelector, resolveProfile } from "./memory/generation.ts";
30
- import { migrate } from "./memory/migrate.ts";
31
- import { migrateShards, shardMigrationStatus } from "./memory/shard-migration.ts";
32
- import { applyPrune, planPrune, PRUNE_PLAN_MAX_BYTES } from "./memory/prune.ts";
34
+ import { migrate, migrationText } from "./memory/migrate.ts";
35
+ import { migrateShards, shardMigrationStatus, shardMigrationText } from "./memory/shard-migration.ts";
36
+ import { applyPrune, planPrune, pruneText, PRUNE_PLAN_MAX_BYTES } from "./memory/prune.ts";
33
37
  import { readCatalogFresh } from "./memory/catalog.ts";
34
38
  import { appendLifecycle, assertProjectActive, projectState } from "./memory/lifecycle.ts";
35
39
  import { gcText, runGc } from "./memory/gc.ts";
@@ -50,10 +54,9 @@ import { buildNote, FAIL_TEMPLATE, hasFailLabel, noteAdd, parseFailTemplate, typ
50
54
  import { runEditor } from "./memory/editor.ts";
51
55
  import { doctorReport, doctorText } from "./memory/doctor.ts";
52
56
  import { statusText, type StatusView } from "./memory/status-text.ts";
53
- import { colorEnabled, style, type Style } from "./memory/style.ts";
57
+ import { colorEnabled, style, facts, label, PLAIN, type Style } from "./memory/style.ts";
54
58
  import { endLikeSignal, nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, syncDue, stopSignal, syncOutcome, usableState, SYNC_IF_DUE_DEADLINE_MS, type StopCause } from "./memory/sync-cadence.ts";
55
59
 
56
- 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`";
57
60
  const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
58
61
  export interface CliOutputReceipt { project: string; ids: readonly string[]; rendered(): boolean }
59
62
  export interface CliContext {
@@ -62,7 +65,7 @@ export interface CliContext {
62
65
  cwd?: string; env?: Readonly<Record<string, string | undefined>>; stdinTTY?: boolean;
63
66
  /** Narrow seam for deterministic fixtures standing in for `gh` (#15 T4): the CLI always uses the native remote visibility check. */
64
67
  visibility?: (url: string) => Promise<Visibility>;
65
- /** Trusted isolated-HOME setup fixture; ordinary CLI use asks through its terminal. */
68
+ /** Legacy setup fixture seam; explicit apply/undo flags no longer prompt. */
66
69
  setupConfirm?: (plan: import("./setup.ts").SetupPlan) => Promise<boolean>;
67
70
  /** 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. */
68
71
  prompt?: (question: string) => string | null | Promise<string | null>;
@@ -130,6 +133,13 @@ export function agentMarked(env: Readonly<Record<string, string | undefined>>):
130
133
  return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "PI_SESSION_ID"].some(key => env[key] !== undefined);
131
134
  }
132
135
 
136
+ /** Administrative metadata is checked as a complete value, never by stripping individual fields. */
137
+ function assertAgentAdministrative(value: unknown, policy: ReturnType<typeof configuredPolicy>): void {
138
+ if (cliAttribution()?.actor !== "agent") return;
139
+ let plain: unknown;
140
+ try { plain = JSON.parse(JSON.stringify(value)); } catch { throw Error("Memory operation unavailable or refused; no partial result is shown."); }
141
+ if (!policy.screenStandalone(plain)) throw Error("Memory operation unavailable or refused; no partial result is shown.");
142
+ }
133
143
  function shardStatePresent(): boolean {
134
144
  try { lstatSync(join(memoryHome(), "shards.json")); return true; }
135
145
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
@@ -156,7 +166,58 @@ function curatedStoreAt(base: string, project: string, opts: StoreOptions): Memo
156
166
  return new MemoryStore({ ...withAliases, memoryDir: base });
157
167
  }
158
168
 
169
+ /** Fixed metadata only: never include the withheld result or its error cause. */
170
+ class CliPartialApplyError extends Error {
171
+ constructor(readonly appliedCount: number, readonly jsonOutput: boolean) {
172
+ super(appliedCount > 0 ? "Operation partially applied; receipt withheld by policy. Inspect state before retrying."
173
+ : "Operation stopped; the failed target may have changed. Receipt withheld by policy. Inspect state before retrying.");
174
+ }
175
+ render(): string {
176
+ return this.jsonOutput ? JSON.stringify({ ok: false, partial: this.appliedCount > 0, appliedCount: this.appliedCount, failedMayHaveChanged: true, receiptWithheld: true }) : this.message;
177
+ }
178
+ }
179
+
159
180
  export async function runCli(argv: readonly string[], context: CliContext = {}): Promise<string> {
181
+ // Native capture and the MCP server retain their existing surfaces and attribution.
182
+ if (argv[0] === "hook" || argv[0] === "ingest" || argv.length === 1 && argv[0] === "mcp") return runCliCommand(argv, context);
183
+ const env = context.env ?? process.env, marked = agentMarked(env);
184
+ const peer = !marked ? "person" : env.AGENTHUB_PEER_ID !== undefined ? env.AGENTHUB_PEER_ID || "agent"
185
+ : env.CLAUDECODE !== undefined || env.CLAUDE_CODE_SESSION_ID !== undefined ? "claude" : env.CODEX_THREAD_ID !== undefined ? "codex" : "pi";
186
+ return withCliAttribution({ actor: marked ? "agent" : "person", peer, policy: () => configuredPolicy(memoryHome(), context.cwd ?? process.cwd()) }, async () => {
187
+ const output = await runCliCommand(argv, context);
188
+ if (marked && !argv.includes("--help") && !["--version", "version", "--help", "help", "completion"].includes(argv[0] ?? "")) {
189
+ const parsed = parseCommand(argv);
190
+ if (parsed.definition.mutates !== undefined || parsed.path[0] === "setup") {
191
+ let receipt: unknown = output;
192
+ // Import receipts contain fixed counters named `pii`; they are not row privacy flags.
193
+ // Screen the complete rendered receipt, including every key and value, as text.
194
+ if (!(parsed.path[0] === "import" && parsed.path[1] === "claude-mem")) {
195
+ try { receipt = JSON.parse(output); } catch { /* human receipts remain strings */ }
196
+ }
197
+ try { assertAgentAdministrative(receipt, configuredPolicy(parsed.path[0] === "setup" ? memoryHome({ ...process.env, ...env }) : memoryHome(), context.cwd ?? process.cwd())); }
198
+ catch {
199
+ // The command returned successfully: withholding its receipt cannot undo its effects.
200
+ const failed = receipt !== null && typeof receipt === "object" && "ok" in receipt && receipt.ok === false;
201
+ return parsed.options["--json"] === true ? JSON.stringify({ ok: !failed, completed: !failed, receiptWithheld: true })
202
+ : failed ? "Operation did not complete; receipt withheld by policy. Inspect state before retrying." : "Operation completed; receipt withheld by policy.";
203
+ }
204
+ }
205
+ }
206
+ return output;
207
+ }).catch((error: unknown) => {
208
+ // Preserve the authoritative partial outcome, without exposing a cause or a withheld target.
209
+ if (marked && error instanceof DecayApplyError) {
210
+ let readable = false;
211
+ try { readable = configuredPolicy(memoryHome(), context.cwd ?? process.cwd()).screenStandalone(error.report); } catch { /* Withhold if policy is unavailable. */ }
212
+ if (!readable) throw new CliPartialApplyError(error.applied.length, error.jsonOutput);
213
+ const safe = new DecayApplyError(error.report, undefined); safe.jsonOutput = error.jsonOutput; throw safe;
214
+ }
215
+ // Marked administrative failures must not reveal a target's private state or native errors.
216
+ if (marked) throw Error("Memory operation unavailable or refused; no partial result is shown.");
217
+ throw error;
218
+ });
219
+ }
220
+ async function runCliCommand(argv: readonly string[], context: CliContext): Promise<string> {
160
221
  if (argv.length === 1 && argv[0] === "mcp") {
161
222
  const memory = createMemoryMcp({ cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env) });
162
223
  await serveMcp({ tools: memory.tools, close: memory.close, onEmitted: memory.onEmitted, version: VERSION });
@@ -173,15 +234,15 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
173
234
  const kind = parsed.positionals[0];
174
235
  if (parsed.positionals.length !== 1 || !["codex", "kimi", "pi", "qwen"].includes(kind ?? "")) throw new Error("usage: amem ingest codex|kimi|pi|qwen [--since ISO] [--json]");
175
236
  const { ingestTranscript } = await import("./ingest.ts");
176
- return JSON.stringify(await ingestTranscript(kind as "codex" | "kimi" | "pi" | "qwen", {
237
+ const result = await ingestTranscript(kind as IngestKind, {
177
238
  cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env),
178
239
  ...(typeof parsed.options["--since"] === "string" ? { since: parsed.options["--since"] as string } : {}),
179
- }));
240
+ });
241
+ return parsed.options["--json"] === true || !(context.stdoutTTY ?? process.stdout.isTTY) ? JSON.stringify(result) : ingestText(kind as IngestKind, result, outputStyle(context));
180
242
  }
181
- // F1: `--help` and `--version` only print text, so they work in an agent shell too. Every other command keeps the honest-session refusal below.
243
+ // Help and version carry no memory data.
182
244
  if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "version")) return `amem ${VERSION}`;
183
245
  if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "help")) return commandHelp();
184
- if (agentMarked(context.env ?? process.env)) throw new Error(PERSON_ONLY);
185
246
  if (argv[0] === "--version" || argv[0] === "version") return `amem ${VERSION}`;
186
247
  if (argv[0] === "--help" || argv[0] === "help" || argv.includes("--help")) return commandHelp();
187
248
  let parsed;
@@ -197,6 +258,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
197
258
  throw error;
198
259
  }
199
260
  const { definition, options, rawArgs: args } = parsed;
261
+ const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
200
262
  if (parsed.path[0] === "setup") {
201
263
  if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true) throw new Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|agy|all [--apply|--undo]");
202
264
  const { runSetup } = await import("./setup.ts");
@@ -205,15 +267,19 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
205
267
  ...(context.setupConfirm ? { confirm: context.setupConfirm } : {}), json: options["--json"] === true, style: outputStyle(context),
206
268
  });
207
269
  }
270
+ if (identity.actor === "agent") {
271
+ const read = await agentCliRead(parsed, argv, context);
272
+ if (read !== undefined) return read;
273
+ }
208
274
  const goal = parsed.path[0] === "goal", review = parsed.path[0] === "review", sub = goal || review ? parsed.path[1]! : parsed.path[0]!;
209
275
  const policyFree = options["--policy-free"] === true;
210
276
  const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined
211
277
  || definition.mutates === "confirm" && options["--confirm"] === true;
212
- if (mutating && !(context.stdinTTY ?? process.stdin.isTTY)) throw new Error("mutating commands require a terminal on stdin");
213
278
  // 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
214
279
  // `--policy-free`. The flag is still accepted and means nothing more, so existing scripts keep working.
215
280
  const policyCwd = context.cwd ?? process.cwd();
216
281
  const policyOf = () => configuredPolicy(memoryHome(), policyCwd); // the same home every other command here resolves
282
+ const administrative = <T>(value: T): T => { assertAgentAdministrative(value, policyOf()); return value; };
217
283
  let canonicalProject: string | undefined;
218
284
  const boundProject = () => canonicalProject ??= resolveAlias(memoryHome(), memoryProjectKey(context.cwd ?? process.cwd()));
219
285
  const commandBinding = (home: string, project: string): (() => void) => {
@@ -235,6 +301,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
235
301
  } catch { return false; } finally { try { candidates?.close(); } catch { /* optional telemetry cannot fail emitted output */ } }
236
302
  } });
237
303
  };
304
+ if (["migrate", "prune", "capacity", "gc", "project", "alias", "machine", "sync", "import", "dedupe", "consolidate"].includes(sub)) administrative({ command: parsed.path, arguments: args });
238
305
  if (sub === "consolidate") {
239
306
  const requested = typeof options["--project"] === "string" ? options["--project"] as string : boundProject();
240
307
  if (!requested.trim() || requested.length > 400 || requested.includes("\0")) throw Error("invalid consolidation project");
@@ -248,7 +315,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
248
315
  const binding = commandBinding(store.profile.home, boundProject()), policyKey = () => JSON.stringify(configuredPolicyOptions(base, policyCwd));
249
316
  const assertCurrent = () => { binding(); assertProjectActive(base, project); if (resolveAlias(base, requested) !== project || aliasedKeys(base, project).join("\n") !== aliases.join("\n")) throw Error("consolidation project binding changed"); };
250
317
  candidates = new CandidateStore({ memoryDir: base, reconcile: { canonical: project, aliases } });
251
- const report = await proposeConsolidation(store, candidates, { project, actor: "person", policy: policyOf, policyVersion: policyKey, assertCurrent });
318
+ const report = await proposeConsolidation(store, candidates, { project, actor, policy: policyOf, policyVersion: policyKey, assertCurrent });
252
319
  return options["--json"] ? JSON.stringify(report, null, 2) : `${report.candidates.length} consolidation candidates queued for amem review; no curated notes changed${report.alreadyReviewed ? `; ${report.alreadyReviewed} already reviewed` : ""}${report.truncated ? "; scan truncated" : ""}${report.oversized ? `; ${report.oversized} proposals too large` : ""}${report.localExcluded ? `; ${report.localExcluded} local sources excluded` : ""}`;
253
320
  } finally { candidates?.close(); store.close(); }
254
321
  }
@@ -274,16 +341,27 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
274
341
  }
275
342
  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");
276
343
  if (sub === "migrate") {
277
- const modes = ["--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
344
+ const modes = ["--apply", "--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
278
345
  if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
279
- const mode = options["--dry-run"] ? "dry-run" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "apply";
346
+ const mode = options["--apply"] ? "apply" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "dry-run";
280
347
  const common = { memoryDir: memoryHome(), ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true };
348
+ if (options["--public-prune-view"] === true) {
349
+ if (options["--deactivate"] || options["--generation"] || options["--shards"]) throw new Error("public prune view is forward-only on an active split; no deactivate, generation replacement or new split");
350
+ if (actor === "agent" && mode !== "dry-run") administrative({ command: "migrate --public-prune-view", mode });
351
+ const result = await activatePublicPruneView({ memoryDir: memoryHome(), mode: mode as "dry-run" | "apply" | "resume", authorizedPerson: true, tty: true });
352
+ return options["--json"] ? JSON.stringify(result, null, 2) : `public prune view: ${result.mode}; generation ${result.generation}; mask build ${result.maskBuildCheck}; forward-only, no deactivation`;
353
+ }
281
354
  const shardRequested = options["--shards"] === true || (mode === "resume" && shardStatePresent());
282
355
  if (shardRequested) {
283
356
  if (mode === "deactivate") throw new Error("--deactivate is not a shard migration mode");
284
- return JSON.stringify(await migrateShards({ ...common, mode }), null, 2);
357
+ if (actor === "agent" && mode !== "dry-run") administrative(await migrateShards({ ...common, mode: "dry-run" }));
358
+ const result = await migrateShards({ ...common, mode });
359
+ return options["--json"] ? JSON.stringify(result, null, 2) : shardMigrationText(result, outputStyle(context));
285
360
  }
286
- return JSON.stringify(await migrate({ ...common, mode }), null, 2);
361
+ if (actor === "agent" && mode === "deactivate") administrative({ target: common.memoryDir, selector: readSelector(common.memoryDir), mode });
362
+ else if (actor === "agent" && mode !== "dry-run") administrative(await migrate({ ...common, mode: "dry-run" }));
363
+ const result = await migrate({ ...common, mode });
364
+ return options["--json"] ? JSON.stringify(result, null, 2) : migrationText(result, outputStyle(context));
287
365
  }
288
366
  if (sub === "status") {
289
367
  // 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.
@@ -323,13 +401,23 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
323
401
  ? openShardStore({ memoryDir: memoryHome(), user: true, deferPromotions: true })
324
402
  : curatedStore(project, { deferPromotions: true }, false);
325
403
  try {
326
- const auth = { project, actor: "person" as const, authorize: () => true };
327
- return JSON.stringify(applying ? applyPrune(store.prunePort(), input, auth) : planPrune(store.prunePort(), { cutoff: String(options["--cutoff"]) }, auth), null, 2);
404
+ const auth = { project, actor, authorize: () => true };
405
+ const port = store.prunePort();
406
+ let plan: ReturnType<typeof planPrune>;
407
+ if (applying) { try { plan = JSON.parse(input!) as ReturnType<typeof planPrune>; } catch { throw Error("prune plan unavailable or oversized"); } }
408
+ else plan = planPrune(port, { cutoff: String(options["--cutoff"]) }, auth);
409
+ administrative(plan);
410
+ if (actor === "agent") {
411
+ const ids = new Set(plan.rows?.flatMap(row => row.payload.targets.map(target => target.id)) ?? []);
412
+ administrative(port.snapshot().rows.filter(source => ids.has(source.row.id)).map(source => source.row));
413
+ }
414
+ const result = applying ? applyPrune(port, input, auth) : plan;
415
+ return options["--json"] ? JSON.stringify(result, null, 2) : pruneText(result, outputStyle(context));
328
416
  } finally { store.close(); }
329
417
  }
330
418
  if (sub === "decay") {
331
- const applying = options["--apply"] === true;
332
- 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");
419
+ const applying = options["--apply"] === true && options["--confirm"] === true;
420
+ if (options["--confirm"] === true && options["--apply"] !== true) throw new Error("decay --confirm requires --apply");
333
421
  const { decayPreview, applyDecay, readDecayCounters, DECAY_DEFAULT_DAYS, assertExactDecayForget } = await import("./memory/decay-review.ts");
334
422
  const project = boundProject(), base = memoryHome(), aliases = aliasedKeys(base, project);
335
423
  const store = curatedStore(project, { deferPromotions: true, screen: note => policyOf().screenStandalone(note) ? "promote" : "keep" });
@@ -340,25 +428,24 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
340
428
  const days = options["--days"] === undefined ? DECAY_DEFAULT_DAYS : Number(options["--days"]);
341
429
  const settings = { days, ...(selected ? { ids: selected } : {}), aliases, counters: () => readDecayCounters(base, [project, ...aliases]) };
342
430
  const current = { ...settings, screen: (note: import("./memory/store.ts").View) => policyOf().screenStandalone(note), assertCurrent };
343
- const preview = decayPreview(store, project, current), action = options["--forget"] === true ? "FORGET" : "demote";
431
+ const preview = administrative(decayPreview(store, project, current)), action = options["--forget"] === true ? "FORGET" : "demote";
344
432
  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" : ""}`,
345
433
  ...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");
346
434
  if (!applying) return options["--json"] ? JSON.stringify(preview, null, 2) : display;
347
435
  if (!preview.entries.length) return options["--json"] ? JSON.stringify({ action, ids: [] }) : "no eligible notes to apply";
348
436
  if (options["--forget"] === true) assertExactDecayForget(store, preview.entries.map(entry => entry.target.id), project);
349
- const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); }); write(display);
350
- const ask = context.prompt ?? ((question: string) => prompt(question));
351
- 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}`}: `);
352
- if (answer?.trim() !== "yes") return options["--json"] ? JSON.stringify({ cancelled: true }) : "decay apply cancelled";
353
437
  assertCurrent();
354
- const result = applyDecay(store, preview, { ...current, actor: "person", confirmed: true, forget: options["--forget"] === true });
438
+ const result = applyDecay(store, preview, { ...current, actor, confirmed: true, forget: options["--forget"] === true });
355
439
  return options["--json"] ? JSON.stringify(result, null, 2) : `${result.action}: ${result.ids.length} entries${result.expiresAt ? `; expires ${result.expiresAt}` : ""}`;
356
440
  } catch (error) { if (error instanceof DecayApplyError) error.jsonOutput = options["--json"] === true; throw error; }
357
441
  finally { store.close(); }
358
442
  }
359
443
  if (sub === "note") {
360
- // F6: the person writes a curated note. Person-only (the agent-marker refusal and the terminal requirement above already ran, because `note add` mutates).
361
- const env = context.env ?? process.env, edit = context.edit ?? ((initial: string) => runEditor(env, initial));
444
+ // F6: the person writes a curated note. Current attribution and configured policy apply to the complete note.
445
+ const env = context.env ?? process.env, edit = (initial: string) => {
446
+ if (!(context.stdinTTY ?? process.stdin.isTTY)) throw Error("note editor requires a terminal on stdin; provide text or - for non-interactive input");
447
+ return (context.edit ?? ((value: string) => runEditor(env, value)))(initial);
448
+ };
362
449
  const kind = (options["--kind"] ?? "finding") as Parameters<typeof buildNote>[0]["kind"];
363
450
  const fail: Partial<FailFields> = { ...(typeof options["--approach"] === "string" ? { approach: options["--approach"] } : {}), ...(typeof options["--symptom"] === "string" ? { symptom: options["--symptom"] } : {}),
364
451
  ...(typeof options["--cause"] === "string" ? { cause: options["--cause"] } : {}), ...(typeof options["--retry-when"] === "string" ? { retryWhen: options["--retry-when"] } : {}) };
@@ -382,8 +469,28 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
382
469
  if (review) {
383
470
  const id = parsed.positionals[0];
384
471
  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");
472
+ if (sub === "walk" && !(context.stdinTTY ?? process.stdin.isTTY)) throw Error("review walk requires a terminal on stdin; stdout must be a terminal too");
473
+ if (sub === "walk" && 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)");
474
+ if (sub === "walk" && actor === "agent") throw Error("Memory operation unavailable or refused; no partial result is shown.");
385
475
  const project = boundProject(), aliases = aliasedKeys(memoryHome(), project), candidates = new CandidateStore(aliases.length ? { reconcile: { canonical: project, aliases } } : {});
386
476
  try {
477
+ const candidatePermitted = (candidate: import("./memory/candidates.ts").Candidate): boolean => {
478
+ if (actor !== "agent") return true;
479
+ const policy = policyOf();
480
+ if (!policy.screenStandalone(candidate)) return false;
481
+ const input = candidate.input, targets = "targets" in input ? input.targets : "target" in input ? [input.target] : [];
482
+ if (!targets.length && input.kind !== "promotion") return true;
483
+ const targetStore = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
484
+ try {
485
+ if (targets.some(target => { if (!target) return true; const view = targetStore.get(target.id, project); return !view || view.project !== project || !policy.screenStandalone(view); })) return false;
486
+ if (input.kind === "promotion") { const goal = targetStore.goalOf(input.goalId, project); if (!goal || !policy.screenStandalone(goal)) return false; }
487
+ return true;
488
+ } finally { targetStore.close(); }
489
+ };
490
+ if (actor === "agent" && sub !== "list" && sub !== "walk") {
491
+ const selected = candidates.get(id!, project);
492
+ if (!selected || !candidatePermitted(selected)) throw Error("Memory operation unavailable or refused; no partial result is shown.");
493
+ }
387
494
  if (sub === "walk") {
388
495
  // 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.
389
496
  // 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
@@ -394,7 +501,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
394
501
  const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); });
395
502
  const ask = context.prompt ?? ((question: string) => prompt(question)), scope = [project, ...aliases], base = realPath(candidates.dir), assertWalkCurrent = commandBinding(candidates.dir, project);
396
503
  // Oldest first: those are the ones nearest their expiry (`list` is newest first).
397
- const rows = candidates.list({ project, status: "pending", ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}) }).reverse();
504
+ const rows = candidates.list({ project, status: "pending", ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}) }).reverse().filter(candidatePermitted);
398
505
  if (!rows.length) return "no candidates pending";
399
506
  const done = { accepted: 0, edited: 0, rejected: 0, skipped: 0, failed: 0 };
400
507
  const lines = { push: (...texts: string[]) => { for (const text of texts) write(text); return 0; } };
@@ -403,6 +510,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
403
510
  const viewStore = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
404
511
  try {
405
512
  const target = viewStore.get(input.target.id, project);
513
+ administrative({ candidate, target });
406
514
  return target && target.id === input.target.id && candidateTargetRevision(target) === input.target.revision
407
515
  ? `promotion. Goal goal:${input.goalId}, mem:${target.id}. ${target.title ? `${target.title}: ` : ""}${target.text}`
408
516
  : `promotion. Goal goal:${input.goalId}, target mem:${input.target.id} changed or is unavailable; reject or skip`;
@@ -415,6 +523,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
415
523
  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; } };
416
524
  for (const [index, candidate] of rows.entries()) {
417
525
  assertWalkCurrent();
526
+ if (!candidatePermitted(candidate)) throw Error("Memory operation unavailable or refused; no partial result is shown.");
418
527
  lines.push(`${index + 1}/${rows.length} ${candidate.id.slice(0, 12)} ${candidate.input.kind} ${candidate.createdAt.slice(0, 10)}`, ` ${terminalSafe(body(candidate))}`);
419
528
  assertWalkCurrent();
420
529
  markShown(base, scope, Date.now(), [candidate.id]); // after the write: shown means the person could read it
@@ -436,7 +545,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
436
545
  const asFail = input.kind === "fail" || input.noteKind === "fail";
437
546
  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); }
438
547
  finally { store.close(); }
439
- candidates.reject(candidate.id, { project, actor: "person", verdict: "public" }); done.edited++;
548
+ candidates.reject(candidate.id, { project, actor, verdict: "public" }); done.edited++;
440
549
  } catch (error) { lines.push(` ${(error as Error).message.split("\n")[0]}`); done.failed++; }
441
550
  } else done.skipped++;
442
551
  }
@@ -450,7 +559,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
450
559
  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";
451
560
  }
452
561
  if (sub === "reject") {
453
- candidates.reject(id!, { project, actor: "person", verdict: "public" });
562
+ candidates.reject(id!, { project, actor, verdict: "public" });
454
563
  return options["--json"] ? JSON.stringify({ candidateId: id, status: "rejected" }, null, 2) : `rejected candidate ${id}`;
455
564
  }
456
565
  const candidate = candidates.get(id!, project);
@@ -462,7 +571,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
462
571
  const assertCurrent = commandBinding(store.profile.home, project);
463
572
  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)");
464
573
  assertCurrent();
465
- const result = candidates.accept(id!, { project, actor: "person", verdict: "public", store,
574
+ const result = candidates.accept(id!, { project, actor, verdict: "public", store,
466
575
  policyVersion: () => JSON.stringify(configuredPolicyOptions(realPath(store.profile.home), policyCwd)),
467
576
  policy: (proposal, target, note, goal) => { assertCurrent(); return policyOf().screenStandalone({ ...proposal.input, ...(target ? { targetView: target } : {}), note, ...(goal ? { goalView: goal } : {}) }); },
468
577
  });
@@ -473,13 +582,13 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
473
582
  if (sub === "vault-candidates" && (options["--all-projects"] === true || options["--project"] !== undefined)) {
474
583
  if (options["--all-projects"] === true && options["--project"] !== undefined) throw new Error("choose either --all-projects or --project <key>, not both");
475
584
  const result = await vaultCandidatesAll(memoryHome(), { policy: policyOf(), ...(options["--project"] !== undefined ? { project: resolveAlias(memoryHome(), String(options["--project"])) } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
476
- return JSON.stringify(result, null, 2);
585
+ return options["--json"] ? JSON.stringify(result, null, 2) : vaultCandidatesAllText(result, outputStyle(context));
477
586
  }
478
587
  if (sub === "vault-candidates") {
479
588
  const project = boundProject(), store = curatedStore(project, { deferPromotions: true });
480
589
  try {
481
590
  const result = await vaultCandidates(store, { project, policy: policyOf(), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
482
- return JSON.stringify(result, null, 2);
591
+ return options["--json"] ? JSON.stringify(result, null, 2) : vaultCandidatesText(result, outputStyle(context));
483
592
  } finally { store.close(); }
484
593
  }
485
594
  if (sub === "eval") {
@@ -499,26 +608,28 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
499
608
  if (sub === "capacity") {
500
609
  const share = options["--disk-share"];
501
610
  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]");
502
- const report = capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) });
611
+ const report = administrative(capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) }));
503
612
  if (parsed.path[1] !== "apply") return options["--json"] ? JSON.stringify(report, null, 2) : capacityText(report);
504
613
  if (options["--split"] === true) {
505
614
  // #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.
506
615
  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");
507
616
  const diskShare = share !== undefined ? { diskShare: Number(share) } : {};
508
- return JSON.stringify(options["--confirm"] === true ? applySplit(memoryHome(), diskShare) : previewSplit(memoryHome(), diskShare), null, 2);
617
+ const result = options["--confirm"] === true ? applySplit(memoryHome(), diskShare) : previewSplit(memoryHome(), diskShare);
618
+ return options["--json"] ? JSON.stringify(result, null, 2) : capacitySplitText(result, outputStyle(context));
509
619
  }
510
- // Person-only (agent marker and TTY are refused above): preview first, --confirm writes the home default config.
620
+ // Preview first; --confirm writes the bound home default config.
511
621
  const file = join(memoryHome(), "capture-config.json");
512
622
  const target: CaptureConfig = {
513
623
  capMb: options["--cap-mb"] ? Number(options["--cap-mb"]) : report.proposal.capMb,
514
624
  retentionDays: options["--retention-days"] ? Number(options["--retention-days"]) : report.proposal.retentionDays,
515
625
  };
516
626
  if (!Number.isFinite(target.capMb) || target.capMb <= 0 || !Number.isFinite(target.retentionDays) || target.retentionDays <= 0) throw new Error("capacity apply needs positive --cap-mb and --retention-days");
517
- const preview = { file, current: readCaptureConfigFile(file), target, reason: report.proposal.reason };
627
+ const preview = administrative({ file, current: readCaptureConfigFile(file), target, reason: report.proposal.reason });
518
628
  const previewFile = join(memoryHome(), "capture-config.preview.json");
519
629
  if (options["--confirm"] !== true) {
520
630
  writeFileSync(previewFile, JSON.stringify(preview) + "\n", { mode: 0o600 });
521
- return JSON.stringify({ preview, confirm: "re-run with --confirm to write" }, null, 2);
631
+ const result = { preview, confirm: "re-run with --confirm to write" };
632
+ return options["--json"] ? JSON.stringify(result, null, 2) : capacityApplyText(result, outputStyle(context));
522
633
  }
523
634
  let approved: { file?: string; current?: unknown; target?: CaptureConfig };
524
635
  try { approved = JSON.parse(readFileSync(previewFile, "utf8")) as typeof approved; }
@@ -529,7 +640,8 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
529
640
  }
530
641
  const written = writeCaptureConfigFile(file, target);
531
642
  rmSync(previewFile, { force: true });
532
- return JSON.stringify({ preview, written: { cap_mb: written.capMb, retention_days: written.retentionDays } }, null, 2);
643
+ const result = { preview, written: { cap_mb: written.capMb, retention_days: written.retentionDays } };
644
+ return options["--json"] ? JSON.stringify(result, null, 2) : capacityApplyText(result, outputStyle(context));
533
645
  }
534
646
  if (sub === "projects") {
535
647
  // 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.
@@ -547,8 +659,9 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
547
659
  return options["--json"] ? JSON.stringify(report, null, 2) : usageText(report);
548
660
  }
549
661
  if (sub === "gc") {
550
- // A preview reads; only --confirm evicts (and then the terminal and agent-marker refusals above already ran, because `mutates: "confirm"`).
662
+ // A preview reads; only explicit --confirm evicts, after the administrative preflight.
551
663
  const batches = options["--batches"] === undefined ? undefined : Number(options["--batches"]);
664
+ if (actor === "agent") administrative(runGc(memoryHome(), { dryRun: true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) }));
552
665
  const result = runGc(memoryHome(), { dryRun: options["--confirm"] !== true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) });
553
666
  return options["--json"] ? JSON.stringify(result, null, 2) : `${result.dryRun ? "preview (nothing evicted; add --confirm):\n" : ""}${gcText(result)}`;
554
667
  }
@@ -584,12 +697,12 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
584
697
  : `${summary}; ${counts}; skipped ${result.skipped.invalid} invalid, ${result.skipped.policy} policy/secret and ${result.skipped.other} non-curated records. ${result.warnings.join(" ")}`;
585
698
  } finally { store.close(); }
586
699
  }
587
- const key = requireShard(memoryHome(), requested);
700
+ const key = administrative(requireShard(memoryHome(), requested));
588
701
  if (op === "forget" && options["--confirm"] !== true) {
589
702
  const state = projectState(memoryHome(), key), shard = readCatalogFresh(memoryHome()).find(p => p.key === key);
590
703
  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.`;
591
704
  }
592
- const written = appendLifecycle(memoryHome(), { op: op as "archive" | "unarchive" | "forget", key, actor: "person", machine: machineId(memoryHome()).id });
705
+ const written = appendLifecycle(memoryHome(), { op: op as "archive" | "unarchive" | "forget", key, actor, machine: machineId(memoryHome()).id });
593
706
  return options["--json"] ? JSON.stringify(written, null, 2) : `${op === "forget" ? "forgot" : op === "archive" ? "archived" : "unarchived"} ${written.key}${requested !== written.key ? ` (alias ${requested})` : ""}`;
594
707
  }
595
708
  if (sub === "search" && options["--all-projects"] === true) {
@@ -612,14 +725,14 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
612
725
  if (parsed.path[1] === "add") {
613
726
  const [from, to] = parsed.positionals;
614
727
  if (!from || !to) throw new Error("usage: amem alias add <from-key> <to-key>");
615
- const op = appendAlias(memoryHome(), { alias: from, key: to, actor: "person", machine: machineId(memoryHome()).id });
728
+ const op = appendAlias(memoryHome(), { alias: from, key: to, actor, machine: machineId(memoryHome()).id });
616
729
  return options["--json"] ? JSON.stringify(op, null, 2) : `alias ${op.alias} -> ${op.key}`;
617
730
  }
618
731
  if (parsed.path[1] === "remove") {
619
- // #27 option 2: person only (ordinary CLI refusal of agent markers above, a TTY through `mutates`), append only, the latest op wins.
732
+ // #27 option 2: append only, the latest op wins; attribution is not authentication.
620
733
  const [from] = parsed.positionals;
621
734
  if (!from || parsed.positionals.length !== 1) throw new Error("usage: amem alias remove <alias-key>");
622
- const op = appendUnalias(memoryHome(), { alias: from, actor: "person", machine: machineId(memoryHome()).id });
735
+ const op = appendUnalias(memoryHome(), { alias: from, actor, machine: machineId(memoryHome()).id });
623
736
  return options["--json"] ? JSON.stringify(op, null, 2) : `alias ${op.alias} removed`;
624
737
  }
625
738
  const ops = listAliases(memoryHome());
@@ -627,7 +740,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
627
740
  }
628
741
  // ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
629
742
  if (goal && sub === "link") throw new Error("goal link needs a host with a task board; amem alone has none");
630
- // #15 T4 / D6: the repo scope. Person-only and a terminal were enforced above (agent markers, `mutates`); amem never runs git.
743
+ // #15 T4 / D6: repository writes keep their explicit confirmation and visibility fences; amem never runs git.
631
744
  const repoCwd = context.cwd ?? process.cwd();
632
745
  if (sub === "repo") {
633
746
  const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project);
@@ -658,9 +771,23 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
658
771
  if (!view) throw new Error(`no memory entry ${idText} in this project (amem list shows the ids)`);
659
772
  // `get` also returns shared-scope notes of other projects: only this project's own private notes go into this repository.
660
773
  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`);
774
+ const sourceView = view, sourceRevision = candidateTargetRevision(view);
775
+ if (actor === "agent" && !recallViewIsLive(sourceView, Date.now())) throw Error("Memory operation unavailable or refused; no partial result is shown.");
776
+ administrative(sourceView); // Screen fields the repository representation deliberately drops, too.
777
+ const assertSourceReadable = () => {
778
+ if (actor !== "agent") return;
779
+ const fresh = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
780
+ try {
781
+ const current = fresh.get(sourceView.id, project);
782
+ if (!current || candidateTargetRevision(current) !== sourceRevision || !recallViewIsLive(current, fresh.readNow()) || resolveAlias(memoryHome(), current.project) !== project) throw Error("Memory operation unavailable or refused; no partial result is shown.");
783
+ administrative(current);
784
+ } finally { fresh.close(); }
785
+ };
661
786
  const plan = await planRepoPromotion(root, view, configuredPolicyOptions(memoryHome(), root));
662
787
  if (plan.refusals.length) throw new Error(`not promoted to repo scope:\n- ${plan.refusals.join("\n- ")}`);
663
788
  const seen = await repoVisibility(root, context.visibility);
789
+ assertSourceReadable(); // Visibility can await: recheck before either preview or publication.
790
+ administrative({ path: plan.path, line: plan.line });
664
791
  const preview = [`the exact line that would be appended to ${plan.path}:`, plan.text, ...(plan.droppedNodes.length ? [`(left out, private references: ${plan.droppedNodes.join(", ")})`] : []),
665
792
  `repository visibility: ${seen.state}${seen.remotes.length ? ` (${[...new Set(seen.remotes.map(r => `${r.name}${r.kind === "push" ? " (push)" : ""} ${r.visibility}`))].join(", ")})` : " (no remote)"}`];
666
793
  if (plan.alreadyPresent) return [...preview, "already in the file: nothing to write"].join("\n");
@@ -670,6 +797,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
670
797
  const given = options["--confirm-public"];
671
798
  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)`);
672
799
  }
800
+ assertSourceReadable();
673
801
  const written = appendRepoLine(root, plan.line);
674
802
  return [...preview, `appended to ${written.path}. amem does not commit or push: review and commit it yourself.`,
675
803
  ...(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");
@@ -708,8 +836,16 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
708
836
  const configured = policyOf(), recallRoot = boundRepoRoot(cwd, memoryHome(), boundProject());
709
837
  const rootConfirmed = recallRoot !== undefined || boundProject().startsWith("local:") && resolveAlias(memoryHome(), memoryProjectKey(cwd)) === boundProject();
710
838
  // 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.
711
- const open = () => curatedStore(boundProject(), { screen: note => !crosses ? "skip" : configured.screenStandalone(note) ? "promote" : "keep" });
839
+ const open = () => curatedStore(boundProject(), { ...(identity.actor === "agent" ? { deferPromotions: true } : {}), screen: note => !crosses ? "skip" : configured.screenStandalone(note) ? "promote" : "keep" });
712
840
  // A person's reads are unscreened. A write is screened by the configured policy, which includes the built-in secret screen.
841
+ if (goal && actor === "agent") {
842
+ const ids = sub === "new" ? repeated(argv, "--depends") : parsed.positionals.slice(0, 1);
843
+ if (ids.length) {
844
+ const targetStore = open();
845
+ try { for (const id of ids) { const target = targetStore.goalOf(id.replace(/^goal:/, ""), boundProject()); if (!target) throw Error("Memory operation unavailable or refused; no partial result is shown."); administrative(target); } }
846
+ finally { targetStore.close(); }
847
+ }
848
+ }
713
849
  if (goal) return rawGoalCommand(args, cwd, { project: boundProject(), open, clean: (title, milestones) => !mutating || configured.screenStandalone({ title, milestones }),
714
850
  onDone: async (done, store) => {
715
851
  const project = boundProject(), base = realPath(store.profile.home), assertCurrent = commandBinding(store.profile.home, project);
@@ -748,11 +884,12 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
748
884
  const legacySearch = options["--filter"] === true;
749
885
  const explainSearch = sub === "search" && options["--explain"] === true;
750
886
  const syncArgs = args.filter((arg, index) => arg !== "--if-due" && arg !== "--force" && !captureArgs.has(index) && (!legacySearch || arg !== "--filter") && (!explainSearch || arg !== "--explain"));
887
+ const memoryArgs = captureImport && !options["--json"] ? [...syncArgs, "--json"] : syncArgs;
751
888
  let output: string;
752
889
  const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
753
890
  try {
754
- output = await withRepoRows(await rawMemoryCommand(syncArgs, cwd, { person: true, rankedSearch: !legacySearch, ...(explainSearch ? { explainSearch: 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),
755
- sync: { screen: row => configured.screenStandalone(row), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
891
+ output = await withRepoRows(await rawMemoryCommand(memoryArgs, cwd, { person: identity.actor === "person", rankedSearch: identity.actor === "person" && !legacySearch, ...(explainSearch ? { explainSearch: true } : {}), project: boundProject(), open, readable: notes => identity.actor === "person" ? notes : notes.filter(view => view.project === boundProject() && policyOf().screenStandalone(view)), stale: createRecallStalenessProbe({ cwd: recallRoot ?? cwd, rootConfirmed, policy: () => configured }), outputReady: notes => outputReady(notes.map(view => view.id)), importPolicy: () => text => configured.declinesPersonal(text),
892
+ sync: { screen: row => configured.screenStandalone(row), policyVersion: () => JSON.stringify(configuredPolicyOptions(memoryHome(), policyCwd)), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
756
893
  onBridgeStatus: status => { receipt.bridgeStatus = status; } }));
757
894
  } catch (error) {
758
895
  if (sub === "sync") stampSync((error as Error).message, stop?.cause());
@@ -774,7 +911,8 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
774
911
  const policy = configured, db = options["--db"] ? { db: String(options["--db"]) } : {}, limit = options["--limit"] ? { limit: Number(options["--limit"]) } : {}, restart = options["--restart"] === true;
775
912
  const work = await importClaudeMemWorkState({ base: memoryHome(), project: boundProject(), policy: row => policy.screen(row), ...db, ...limit, restart });
776
913
  const capture = captureDays === undefined ? undefined : await importClaudeMemCapture({ base: memoryHome(), project: boundProject(), cwd, days: captureDays, ...db, ...limit, restart, policy: { host: policy } });
777
- return options["--json"] ? JSON.stringify({ ...JSON.parse(output), work, ...(capture ? { capture } : {}) }, null, 2) : `${output}\nwork lists: ${JSON.stringify(work)}${capture ? `\ncapture: ${JSON.stringify(capture)}` : ""}`;
914
+ return options["--json"] ? JSON.stringify({ ...JSON.parse(output), work, ...(capture ? { capture } : {}) }, null, 2)
915
+ : claudeMemImportText(JSON.parse(output), work, capture, { captureDays, restart }, outputStyle(context));
778
916
  }
779
917
  return !options["--json"] && receipt.bridgeStatus
780
918
  ? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output;
@@ -783,7 +921,32 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
783
921
  /** Color for human output only on an interactive stdout without NO_COLOR or TERM=dumb (#125). */
784
922
  function outputStyle(context: CliContext): Style { return style(colorEnabled(context.env ?? process.env, context.stdoutTTY ?? process.stdout.isTTY)); }
785
923
 
786
- export function formatCliError(error: unknown): string { return error instanceof DecayApplyError ? error.render() : `amem: ${(error as Error).message}`; }
924
+ /** Native ingest remains capture-only and agent-reachable; this formats only its returned receipt. */
925
+ export function ingestText(kind: IngestKind, result: IngestReceipt, s: Style = PLAIN): string {
926
+ const state = result.errors || result.remaining || result.spooled ? "attention" : result.files || result.read ? "complete" : "skipped";
927
+ const reason: string | undefined = result.reason;
928
+ const actions: Record<string, string> = {
929
+ source_unavailable: "check that this client's native transcript root is available, then ingest again",
930
+ source_bound: "review the native source size and supported discovery bounds before retrying",
931
+ cursor_busy: "wait for the active ingest writer, then ingest again",
932
+ scope_changed: "run ingest again from the same repository after its scope is stable",
933
+ schema_or_time: "check the native transcript format and timestamps; unreadable records were skipped",
934
+ unsupported_version: "check the reported writer version against the supported ingest contract before retrying",
935
+ };
936
+ const version = "version" in result && typeof result.version === "string" ? result.version : undefined;
937
+ const action = reason ? actions[reason] ?? "review the receipt before retrying"
938
+ : result.errors ? "review the ingest errors and supported native source contract before retrying"
939
+ : result.remaining ? "run the same ingest command again to continue"
940
+ : result.spooled ? "the next capture write replays rows spooled while the store was busy"
941
+ : state === "skipped" ? "open a native session in this repository before ingesting its transcript" : "none";
942
+ return [`amem ingest ${kind}: ${state}`,
943
+ `${label(s, state === "attention" ? "attention" : state === "skipped" ? "skipped" : "ok")} receipt: ${facts({ files: String(result.files), read: String(result.read), imported: String(result.imported), skipped: String(result.skipped), spooled: String(result.spooled), errors: String(result.errors) })}`,
944
+ `${label(s, result.remaining ? "attention" : "ok")} remaining: ${result.remaining ? "yes" : "no"}`,
945
+ ...(reason ? [`${label(s, "attention")} reason: ${reason}${version ? ` (version ${version})` : ""}`] : []),
946
+ `Next action: ${action}`].join("\n");
947
+ }
948
+
949
+ export function formatCliError(error: unknown): string { return error instanceof DecayApplyError || error instanceof CliPartialApplyError ? error.render() : `amem: ${(error as Error).message}`; }
787
950
 
788
951
  /** A completed write means rendered output, never evidence of host delivery or citation. */
789
952
  export async function writeCliOutput(text: string, output: Writable = process.stdout): Promise<void> { await writeConfirmed(output, text); }