@staix/agent-mem 0.11.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";
@@ -49,9 +52,10 @@ import { markShown, pendingSummary } from "./memory/candidate-pending.ts";
49
52
  import { buildNote, FAIL_TEMPLATE, hasFailLabel, noteAdd, parseFailTemplate, type FailFields } from "./memory/note-add.ts";
50
53
  import { runEditor } from "./memory/editor.ts";
51
54
  import { doctorReport, doctorText } from "./memory/doctor.ts";
55
+ import { statusText, type StatusView } from "./memory/status-text.ts";
56
+ import { colorEnabled, style, facts, label, PLAIN, type Style } from "./memory/style.ts";
52
57
  import { endLikeSignal, nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, syncDue, stopSignal, syncOutcome, usableState, SYNC_IF_DUE_DEADLINE_MS, type StopCause } from "./memory/sync-cadence.ts";
53
58
 
54
- 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`";
55
59
  const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
56
60
  export interface CliOutputReceipt { project: string; ids: readonly string[]; rendered(): boolean }
57
61
  export interface CliContext {
@@ -60,7 +64,7 @@ export interface CliContext {
60
64
  cwd?: string; env?: Readonly<Record<string, string | undefined>>; stdinTTY?: boolean;
61
65
  /** Narrow seam for deterministic fixtures standing in for `gh` (#15 T4): the CLI always uses the native remote visibility check. */
62
66
  visibility?: (url: string) => Promise<Visibility>;
63
- /** Trusted isolated-HOME setup fixture; ordinary CLI use asks through its terminal. */
67
+ /** Legacy setup fixture seam; explicit apply/undo flags no longer prompt. */
64
68
  setupConfirm?: (plan: import("./setup.ts").SetupPlan) => Promise<boolean>;
65
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. */
66
70
  prompt?: (question: string) => string | null | Promise<string | null>;
@@ -128,6 +132,13 @@ export function agentMarked(env: Readonly<Record<string, string | undefined>>):
128
132
  return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "PI_SESSION_ID"].some(key => env[key] !== undefined);
129
133
  }
130
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
+ }
131
142
  function shardStatePresent(): boolean {
132
143
  try { lstatSync(join(memoryHome(), "shards.json")); return true; }
133
144
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
@@ -154,7 +165,58 @@ function curatedStoreAt(base: string, project: string, opts: StoreOptions): Memo
154
165
  return new MemoryStore({ ...withAliases, memoryDir: base });
155
166
  }
156
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
+
157
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> {
158
220
  if (argv.length === 1 && argv[0] === "mcp") {
159
221
  const memory = createMemoryMcp({ cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env) });
160
222
  await serveMcp({ tools: memory.tools, close: memory.close, onEmitted: memory.onEmitted, version: VERSION });
@@ -171,15 +233,15 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
171
233
  const kind = parsed.positionals[0];
172
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]");
173
235
  const { ingestTranscript } = await import("./ingest.ts");
174
- return JSON.stringify(await ingestTranscript(kind as "codex" | "kimi" | "pi" | "qwen", {
236
+ const result = await ingestTranscript(kind as IngestKind, {
175
237
  cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env),
176
238
  ...(typeof parsed.options["--since"] === "string" ? { since: parsed.options["--since"] as string } : {}),
177
- }));
239
+ });
240
+ return parsed.options["--json"] === true || !(context.stdoutTTY ?? process.stdout.isTTY) ? JSON.stringify(result) : ingestText(kind as IngestKind, result, outputStyle(context));
178
241
  }
179
- // 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.
180
243
  if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "version")) return `amem ${VERSION}`;
181
244
  if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "help")) return commandHelp();
182
- if (agentMarked(context.env ?? process.env)) throw new Error(PERSON_ONLY);
183
245
  if (argv[0] === "--version" || argv[0] === "version") return `amem ${VERSION}`;
184
246
  if (argv[0] === "--help" || argv[0] === "help" || argv.includes("--help")) return commandHelp();
185
247
  let parsed;
@@ -195,23 +257,28 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
195
257
  throw error;
196
258
  }
197
259
  const { definition, options, rawArgs: args } = parsed;
260
+ const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
198
261
  if (parsed.path[0] === "setup") {
199
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]");
200
263
  const { runSetup } = await import("./setup.ts");
201
264
  return runSetup(parsed.positionals[0]!, options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", {
202
265
  env: context.env ?? process.env, stdinTTY: context.stdinTTY ?? process.stdin.isTTY, cwd: context.cwd ?? process.cwd(),
203
- ...(context.setupConfirm ? { confirm: context.setupConfirm } : {}),
266
+ ...(context.setupConfirm ? { confirm: context.setupConfirm } : {}), json: options["--json"] === true, style: outputStyle(context),
204
267
  });
205
268
  }
269
+ if (identity.actor === "agent") {
270
+ const read = await agentCliRead(parsed, argv, context);
271
+ if (read !== undefined) return read;
272
+ }
206
273
  const goal = parsed.path[0] === "goal", review = parsed.path[0] === "review", sub = goal || review ? parsed.path[1]! : parsed.path[0]!;
207
274
  const policyFree = options["--policy-free"] === true;
208
275
  const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined
209
276
  || definition.mutates === "confirm" && options["--confirm"] === true;
210
- if (mutating && !(context.stdinTTY ?? process.stdin.isTTY)) throw new Error("mutating commands require a terminal on stdin");
211
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
212
278
  // `--policy-free`. The flag is still accepted and means nothing more, so existing scripts keep working.
213
279
  const policyCwd = context.cwd ?? process.cwd();
214
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; };
215
282
  let canonicalProject: string | undefined;
216
283
  const boundProject = () => canonicalProject ??= resolveAlias(memoryHome(), memoryProjectKey(context.cwd ?? process.cwd()));
217
284
  const commandBinding = (home: string, project: string): (() => void) => {
@@ -233,6 +300,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
233
300
  } catch { return false; } finally { try { candidates?.close(); } catch { /* optional telemetry cannot fail emitted output */ } }
234
301
  } });
235
302
  };
303
+ if (["migrate", "prune", "capacity", "gc", "project", "alias", "machine", "sync", "import", "dedupe", "consolidate"].includes(sub)) administrative({ command: parsed.path, arguments: args });
236
304
  if (sub === "consolidate") {
237
305
  const requested = typeof options["--project"] === "string" ? options["--project"] as string : boundProject();
238
306
  if (!requested.trim() || requested.length > 400 || requested.includes("\0")) throw Error("invalid consolidation project");
@@ -246,7 +314,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
246
314
  const binding = commandBinding(store.profile.home, boundProject()), policyKey = () => JSON.stringify(configuredPolicyOptions(base, policyCwd));
247
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"); };
248
316
  candidates = new CandidateStore({ memoryDir: base, reconcile: { canonical: project, aliases } });
249
- 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 });
250
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` : ""}`;
251
319
  } finally { candidates?.close(); store.close(); }
252
320
  }
@@ -272,22 +340,27 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
272
340
  }
273
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");
274
342
  if (sub === "migrate") {
275
- const modes = ["--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
343
+ const modes = ["--apply", "--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
276
344
  if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
277
- 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";
278
346
  const common = { memoryDir: memoryHome(), ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true };
279
347
  const shardRequested = options["--shards"] === true || (mode === "resume" && shardStatePresent());
280
348
  if (shardRequested) {
281
349
  if (mode === "deactivate") throw new Error("--deactivate is not a shard migration mode");
282
- 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));
283
353
  }
284
- 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));
285
358
  }
286
359
  if (sub === "status") {
287
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.
288
361
  const where = resolveMemoryHome(), loaded = loadAmemConfig(where.home, repoConfigRoot(policyCwd));
289
362
  const { config, source } = loaded;
290
- return JSON.stringify({ home: { path: where.home, source: where.source },
363
+ const status = { home: { path: where.home, source: where.source },
291
364
  config: { machine: source.machine, repo: source.repo, denyPaths: config.denyPaths.length, piiPatterns: config.piiPatterns.length,
292
365
  ...(config.briefBytes !== undefined ? { briefBytes: config.briefBytes } : {}), ...(config.excerptBytes !== undefined ? { excerptBytes: config.excerptBytes } : {}),
293
366
  ...(config.capturePaths !== undefined ? { capturePaths: config.capturePaths } : {}), ...(Object.keys(config.capture).length ? { capture: config.capture } : {}) },
@@ -295,14 +368,15 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
295
368
  candidates: (() => { const bound = boundProject(), summary = pendingSummary(where.home, [bound, ...aliasedKeys(where.home, bound)], Date.now(), true);
296
369
  if (summary === null) return { unavailable: "candidates.db exists but cannot be read" };
297
370
  return { ...summary, ...(summary.dueWithin7Days ? { warning: `${summary.dueWithin7Days} candidate${summary.dueWithin7Days === 1 ? "" : "s"} within 7 days of expiry: run \`amem review walk\`` } : {}) }; })(),
298
- ...shardMigrationStatus(where.home) }, null, 2);
371
+ ...shardMigrationStatus(where.home) };
372
+ return options["--json"] ? JSON.stringify(status, null, 2) : statusText(status as unknown as StatusView, outputStyle(context));
299
373
  }
300
374
  if (sub === "doctor") {
301
375
  // F15: one read-only report; see src/memory/doctor.ts for exactly what "read-only" covers.
302
376
  const where = resolveMemoryHome(), project = boundProject();
303
377
  const report = await doctorReport({ home: where.home, homeSource: where.source, cwd: policyCwd, project, aliases: aliasedKeys(where.home, project), now: Date.now(),
304
378
  openCurated: base => curatedStoreAt(base, project, { deferPromotions: true }) });
305
- return options["--json"] ? JSON.stringify(report, null, 2) : doctorText(report);
379
+ return options["--json"] ? JSON.stringify(report, null, 2) : doctorText(report, outputStyle(context));
306
380
  }
307
381
  if (sub === "prune") {
308
382
  const applying = options["--apply"] === true;
@@ -320,13 +394,23 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
320
394
  ? openShardStore({ memoryDir: memoryHome(), user: true, deferPromotions: true })
321
395
  : curatedStore(project, { deferPromotions: true }, false);
322
396
  try {
323
- const auth = { project, actor: "person" as const, authorize: () => true };
324
- 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));
325
409
  } finally { store.close(); }
326
410
  }
327
411
  if (sub === "decay") {
328
- const applying = options["--apply"] === true;
329
- 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");
330
414
  const { decayPreview, applyDecay, readDecayCounters, DECAY_DEFAULT_DAYS, assertExactDecayForget } = await import("./memory/decay-review.ts");
331
415
  const project = boundProject(), base = memoryHome(), aliases = aliasedKeys(base, project);
332
416
  const store = curatedStore(project, { deferPromotions: true, screen: note => policyOf().screenStandalone(note) ? "promote" : "keep" });
@@ -337,25 +421,24 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
337
421
  const days = options["--days"] === undefined ? DECAY_DEFAULT_DAYS : Number(options["--days"]);
338
422
  const settings = { days, ...(selected ? { ids: selected } : {}), aliases, counters: () => readDecayCounters(base, [project, ...aliases]) };
339
423
  const current = { ...settings, screen: (note: import("./memory/store.ts").View) => policyOf().screenStandalone(note), assertCurrent };
340
- 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";
341
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" : ""}`,
342
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");
343
427
  if (!applying) return options["--json"] ? JSON.stringify(preview, null, 2) : display;
344
428
  if (!preview.entries.length) return options["--json"] ? JSON.stringify({ action, ids: [] }) : "no eligible notes to apply";
345
429
  if (options["--forget"] === true) assertExactDecayForget(store, preview.entries.map(entry => entry.target.id), project);
346
- const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); }); write(display);
347
- const ask = context.prompt ?? ((question: string) => prompt(question));
348
- 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}`}: `);
349
- if (answer?.trim() !== "yes") return options["--json"] ? JSON.stringify({ cancelled: true }) : "decay apply cancelled";
350
430
  assertCurrent();
351
- 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 });
352
432
  return options["--json"] ? JSON.stringify(result, null, 2) : `${result.action}: ${result.ids.length} entries${result.expiresAt ? `; expires ${result.expiresAt}` : ""}`;
353
433
  } catch (error) { if (error instanceof DecayApplyError) error.jsonOutput = options["--json"] === true; throw error; }
354
434
  finally { store.close(); }
355
435
  }
356
436
  if (sub === "note") {
357
- // F6: the person writes a curated note. Person-only (the agent-marker refusal and the terminal requirement above already ran, because `note add` mutates).
358
- 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
+ };
359
442
  const kind = (options["--kind"] ?? "finding") as Parameters<typeof buildNote>[0]["kind"];
360
443
  const fail: Partial<FailFields> = { ...(typeof options["--approach"] === "string" ? { approach: options["--approach"] } : {}), ...(typeof options["--symptom"] === "string" ? { symptom: options["--symptom"] } : {}),
361
444
  ...(typeof options["--cause"] === "string" ? { cause: options["--cause"] } : {}), ...(typeof options["--retry-when"] === "string" ? { retryWhen: options["--retry-when"] } : {}) };
@@ -379,8 +462,28 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
379
462
  if (review) {
380
463
  const id = parsed.positionals[0];
381
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.");
382
468
  const project = boundProject(), aliases = aliasedKeys(memoryHome(), project), candidates = new CandidateStore(aliases.length ? { reconcile: { canonical: project, aliases } } : {});
383
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
+ }
384
487
  if (sub === "walk") {
385
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.
386
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
@@ -391,7 +494,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
391
494
  const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); });
392
495
  const ask = context.prompt ?? ((question: string) => prompt(question)), scope = [project, ...aliases], base = realPath(candidates.dir), assertWalkCurrent = commandBinding(candidates.dir, project);
393
496
  // Oldest first: those are the ones nearest their expiry (`list` is newest first).
394
- 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);
395
498
  if (!rows.length) return "no candidates pending";
396
499
  const done = { accepted: 0, edited: 0, rejected: 0, skipped: 0, failed: 0 };
397
500
  const lines = { push: (...texts: string[]) => { for (const text of texts) write(text); return 0; } };
@@ -400,6 +503,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
400
503
  const viewStore = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
401
504
  try {
402
505
  const target = viewStore.get(input.target.id, project);
506
+ administrative({ candidate, target });
403
507
  return target && target.id === input.target.id && candidateTargetRevision(target) === input.target.revision
404
508
  ? `promotion. Goal goal:${input.goalId}, mem:${target.id}. ${target.title ? `${target.title}: ` : ""}${target.text}`
405
509
  : `promotion. Goal goal:${input.goalId}, target mem:${input.target.id} changed or is unavailable; reject or skip`;
@@ -412,6 +516,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
412
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; } };
413
517
  for (const [index, candidate] of rows.entries()) {
414
518
  assertWalkCurrent();
519
+ if (!candidatePermitted(candidate)) throw Error("Memory operation unavailable or refused; no partial result is shown.");
415
520
  lines.push(`${index + 1}/${rows.length} ${candidate.id.slice(0, 12)} ${candidate.input.kind} ${candidate.createdAt.slice(0, 10)}`, ` ${terminalSafe(body(candidate))}`);
416
521
  assertWalkCurrent();
417
522
  markShown(base, scope, Date.now(), [candidate.id]); // after the write: shown means the person could read it
@@ -433,7 +538,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
433
538
  const asFail = input.kind === "fail" || input.noteKind === "fail";
434
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); }
435
540
  finally { store.close(); }
436
- candidates.reject(candidate.id, { project, actor: "person", verdict: "public" }); done.edited++;
541
+ candidates.reject(candidate.id, { project, actor, verdict: "public" }); done.edited++;
437
542
  } catch (error) { lines.push(` ${(error as Error).message.split("\n")[0]}`); done.failed++; }
438
543
  } else done.skipped++;
439
544
  }
@@ -447,7 +552,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
447
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";
448
553
  }
449
554
  if (sub === "reject") {
450
- candidates.reject(id!, { project, actor: "person", verdict: "public" });
555
+ candidates.reject(id!, { project, actor, verdict: "public" });
451
556
  return options["--json"] ? JSON.stringify({ candidateId: id, status: "rejected" }, null, 2) : `rejected candidate ${id}`;
452
557
  }
453
558
  const candidate = candidates.get(id!, project);
@@ -459,7 +564,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
459
564
  const assertCurrent = commandBinding(store.profile.home, project);
460
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)");
461
566
  assertCurrent();
462
- const result = candidates.accept(id!, { project, actor: "person", verdict: "public", store,
567
+ const result = candidates.accept(id!, { project, actor, verdict: "public", store,
463
568
  policyVersion: () => JSON.stringify(configuredPolicyOptions(realPath(store.profile.home), policyCwd)),
464
569
  policy: (proposal, target, note, goal) => { assertCurrent(); return policyOf().screenStandalone({ ...proposal.input, ...(target ? { targetView: target } : {}), note, ...(goal ? { goalView: goal } : {}) }); },
465
570
  });
@@ -470,13 +575,13 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
470
575
  if (sub === "vault-candidates" && (options["--all-projects"] === true || options["--project"] !== undefined)) {
471
576
  if (options["--all-projects"] === true && options["--project"] !== undefined) throw new Error("choose either --all-projects or --project <key>, not both");
472
577
  const result = await vaultCandidatesAll(memoryHome(), { policy: policyOf(), ...(options["--project"] !== undefined ? { project: resolveAlias(memoryHome(), String(options["--project"])) } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
473
- return JSON.stringify(result, null, 2);
578
+ return options["--json"] ? JSON.stringify(result, null, 2) : vaultCandidatesAllText(result, outputStyle(context));
474
579
  }
475
580
  if (sub === "vault-candidates") {
476
581
  const project = boundProject(), store = curatedStore(project, { deferPromotions: true });
477
582
  try {
478
583
  const result = await vaultCandidates(store, { project, policy: policyOf(), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
479
- return JSON.stringify(result, null, 2);
584
+ return options["--json"] ? JSON.stringify(result, null, 2) : vaultCandidatesText(result, outputStyle(context));
480
585
  } finally { store.close(); }
481
586
  }
482
587
  if (sub === "eval") {
@@ -496,26 +601,28 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
496
601
  if (sub === "capacity") {
497
602
  const share = options["--disk-share"];
498
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]");
499
- const report = capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) });
604
+ const report = administrative(capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) }));
500
605
  if (parsed.path[1] !== "apply") return options["--json"] ? JSON.stringify(report, null, 2) : capacityText(report);
501
606
  if (options["--split"] === true) {
502
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.
503
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");
504
609
  const diskShare = share !== undefined ? { diskShare: Number(share) } : {};
505
- 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));
506
612
  }
507
- // 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.
508
614
  const file = join(memoryHome(), "capture-config.json");
509
615
  const target: CaptureConfig = {
510
616
  capMb: options["--cap-mb"] ? Number(options["--cap-mb"]) : report.proposal.capMb,
511
617
  retentionDays: options["--retention-days"] ? Number(options["--retention-days"]) : report.proposal.retentionDays,
512
618
  };
513
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");
514
- const preview = { file, current: readCaptureConfigFile(file), target, reason: report.proposal.reason };
620
+ const preview = administrative({ file, current: readCaptureConfigFile(file), target, reason: report.proposal.reason });
515
621
  const previewFile = join(memoryHome(), "capture-config.preview.json");
516
622
  if (options["--confirm"] !== true) {
517
623
  writeFileSync(previewFile, JSON.stringify(preview) + "\n", { mode: 0o600 });
518
- 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));
519
626
  }
520
627
  let approved: { file?: string; current?: unknown; target?: CaptureConfig };
521
628
  try { approved = JSON.parse(readFileSync(previewFile, "utf8")) as typeof approved; }
@@ -526,7 +633,8 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
526
633
  }
527
634
  const written = writeCaptureConfigFile(file, target);
528
635
  rmSync(previewFile, { force: true });
529
- 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));
530
638
  }
531
639
  if (sub === "projects") {
532
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.
@@ -544,8 +652,9 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
544
652
  return options["--json"] ? JSON.stringify(report, null, 2) : usageText(report);
545
653
  }
546
654
  if (sub === "gc") {
547
- // 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.
548
656
  const batches = options["--batches"] === undefined ? undefined : Number(options["--batches"]);
657
+ if (actor === "agent") administrative(runGc(memoryHome(), { dryRun: true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) }));
549
658
  const result = runGc(memoryHome(), { dryRun: options["--confirm"] !== true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) });
550
659
  return options["--json"] ? JSON.stringify(result, null, 2) : `${result.dryRun ? "preview (nothing evicted; add --confirm):\n" : ""}${gcText(result)}`;
551
660
  }
@@ -581,12 +690,12 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
581
690
  : `${summary}; ${counts}; skipped ${result.skipped.invalid} invalid, ${result.skipped.policy} policy/secret and ${result.skipped.other} non-curated records. ${result.warnings.join(" ")}`;
582
691
  } finally { store.close(); }
583
692
  }
584
- const key = requireShard(memoryHome(), requested);
693
+ const key = administrative(requireShard(memoryHome(), requested));
585
694
  if (op === "forget" && options["--confirm"] !== true) {
586
695
  const state = projectState(memoryHome(), key), shard = readCatalogFresh(memoryHome()).find(p => p.key === key);
587
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.`;
588
697
  }
589
- 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 });
590
699
  return options["--json"] ? JSON.stringify(written, null, 2) : `${op === "forget" ? "forgot" : op === "archive" ? "archived" : "unarchived"} ${written.key}${requested !== written.key ? ` (alias ${requested})` : ""}`;
591
700
  }
592
701
  if (sub === "search" && options["--all-projects"] === true) {
@@ -609,14 +718,14 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
609
718
  if (parsed.path[1] === "add") {
610
719
  const [from, to] = parsed.positionals;
611
720
  if (!from || !to) throw new Error("usage: amem alias add <from-key> <to-key>");
612
- 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 });
613
722
  return options["--json"] ? JSON.stringify(op, null, 2) : `alias ${op.alias} -> ${op.key}`;
614
723
  }
615
724
  if (parsed.path[1] === "remove") {
616
- // #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.
617
726
  const [from] = parsed.positionals;
618
727
  if (!from || parsed.positionals.length !== 1) throw new Error("usage: amem alias remove <alias-key>");
619
- const op = appendUnalias(memoryHome(), { alias: from, actor: "person", machine: machineId(memoryHome()).id });
728
+ const op = appendUnalias(memoryHome(), { alias: from, actor, machine: machineId(memoryHome()).id });
620
729
  return options["--json"] ? JSON.stringify(op, null, 2) : `alias ${op.alias} removed`;
621
730
  }
622
731
  const ops = listAliases(memoryHome());
@@ -624,7 +733,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
624
733
  }
625
734
  // ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
626
735
  if (goal && sub === "link") throw new Error("goal link needs a host with a task board; amem alone has none");
627
- // #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.
628
737
  const repoCwd = context.cwd ?? process.cwd();
629
738
  if (sub === "repo") {
630
739
  const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project);
@@ -655,9 +764,23 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
655
764
  if (!view) throw new Error(`no memory entry ${idText} in this project (amem list shows the ids)`);
656
765
  // `get` also returns shared-scope notes of other projects: only this project's own private notes go into this repository.
657
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
+ };
658
779
  const plan = await planRepoPromotion(root, view, configuredPolicyOptions(memoryHome(), root));
659
780
  if (plan.refusals.length) throw new Error(`not promoted to repo scope:\n- ${plan.refusals.join("\n- ")}`);
660
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 });
661
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(", ")})`] : []),
662
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)"}`];
663
786
  if (plan.alreadyPresent) return [...preview, "already in the file: nothing to write"].join("\n");
@@ -667,6 +790,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
667
790
  const given = options["--confirm-public"];
668
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)`);
669
792
  }
793
+ assertSourceReadable();
670
794
  const written = appendRepoLine(root, plan.line);
671
795
  return [...preview, `appended to ${written.path}. amem does not commit or push: review and commit it yourself.`,
672
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");
@@ -705,8 +829,16 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
705
829
  const configured = policyOf(), recallRoot = boundRepoRoot(cwd, memoryHome(), boundProject());
706
830
  const rootConfirmed = recallRoot !== undefined || boundProject().startsWith("local:") && resolveAlias(memoryHome(), memoryProjectKey(cwd)) === boundProject();
707
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.
708
- 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" });
709
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
+ }
710
842
  if (goal) return rawGoalCommand(args, cwd, { project: boundProject(), open, clean: (title, milestones) => !mutating || configured.screenStandalone({ title, milestones }),
711
843
  onDone: async (done, store) => {
712
844
  const project = boundProject(), base = realPath(store.profile.home), assertCurrent = commandBinding(store.profile.home, project);
@@ -745,10 +877,11 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
745
877
  const legacySearch = options["--filter"] === true;
746
878
  const explainSearch = sub === "search" && options["--explain"] === true;
747
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;
748
881
  let output: string;
749
882
  const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
750
883
  try {
751
- 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),
752
885
  sync: { screen: row => configured.screenStandalone(row), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
753
886
  onBridgeStatus: status => { receipt.bridgeStatus = status; } }));
754
887
  } catch (error) {
@@ -771,13 +904,42 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
771
904
  const policy = configured, db = options["--db"] ? { db: String(options["--db"]) } : {}, limit = options["--limit"] ? { limit: Number(options["--limit"]) } : {}, restart = options["--restart"] === true;
772
905
  const work = await importClaudeMemWorkState({ base: memoryHome(), project: boundProject(), policy: row => policy.screen(row), ...db, ...limit, restart });
773
906
  const capture = captureDays === undefined ? undefined : await importClaudeMemCapture({ base: memoryHome(), project: boundProject(), cwd, days: captureDays, ...db, ...limit, restart, policy: { host: policy } });
774
- 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));
775
909
  }
776
910
  return !options["--json"] && receipt.bridgeStatus
777
911
  ? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output;
778
912
  }
779
913
 
780
- export function formatCliError(error: unknown): string { return error instanceof DecayApplyError ? error.render() : `amem: ${(error as Error).message}`; }
914
+ /** Color for human output only on an interactive stdout without NO_COLOR or TERM=dumb (#125). */
915
+ function outputStyle(context: CliContext): Style { return style(colorEnabled(context.env ?? process.env, context.stdoutTTY ?? process.stdout.isTTY)); }
916
+
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}`; }
781
943
 
782
944
  /** A completed write means rendered output, never evidence of host delivery or citation. */
783
945
  export async function writeCliOutput(text: string, output: Writable = process.stdout): Promise<void> { await writeConfirmed(output, text); }