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