@staix/agent-mem 0.12.0 → 0.13.0

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