@staix/agent-mem 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +135 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +476 -39
  6. package/src/commands-table.ts +53 -15
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +14 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +141 -33
  12. package/src/mcp.ts +42 -7
  13. package/src/memory/aliases.ts +4 -0
  14. package/src/memory/bridge.ts +8 -2
  15. package/src/memory/candidate-pending.ts +119 -0
  16. package/src/memory/candidates.ts +144 -47
  17. package/src/memory/capacity-split.ts +76 -0
  18. package/src/memory/capacity.ts +139 -11
  19. package/src/memory/capture-input.ts +8 -3
  20. package/src/memory/capture.ts +30 -5
  21. package/src/memory/catalog.ts +43 -10
  22. package/src/memory/commands.ts +98 -44
  23. package/src/memory/config.ts +167 -0
  24. package/src/memory/cross-project.ts +75 -0
  25. package/src/memory/curated-read.ts +29 -0
  26. package/src/memory/decay-review.ts +136 -0
  27. package/src/memory/doctor.ts +412 -0
  28. package/src/memory/editor.ts +58 -0
  29. package/src/memory/eval-fixture.ts +137 -0
  30. package/src/memory/eval-set.json +443 -0
  31. package/src/memory/eval.ts +150 -0
  32. package/src/memory/gc.ts +97 -0
  33. package/src/memory/generation-index.ts +22 -17
  34. package/src/memory/generation-sync.ts +3 -0
  35. package/src/memory/goal-promotion.ts +71 -0
  36. package/src/memory/home.ts +15 -4
  37. package/src/memory/intent-brief.ts +89 -0
  38. package/src/memory/lifecycle-log.ts +54 -0
  39. package/src/memory/lifecycle.ts +61 -0
  40. package/src/memory/metrics.ts +2 -2
  41. package/src/memory/note-add.ts +100 -0
  42. package/src/memory/path-glob.ts +94 -0
  43. package/src/memory/plan-guard.ts +47 -0
  44. package/src/memory/policy.ts +44 -13
  45. package/src/memory/private-spans.ts +32 -0
  46. package/src/memory/project-export.ts +122 -0
  47. package/src/memory/recall-staleness.ts +56 -0
  48. package/src/memory/recall.ts +74 -0
  49. package/src/memory/repo-scope.ts +404 -0
  50. package/src/memory/shard-sync-format.ts +116 -0
  51. package/src/memory/shard-sync.ts +421 -0
  52. package/src/memory/store.ts +282 -27
  53. package/src/memory/sync-cadence.ts +139 -0
  54. package/src/memory/sync-screen.ts +86 -0
  55. package/src/memory/sync.ts +79 -11
  56. package/src/memory/usage.ts +35 -0
  57. package/src/memory/use-telemetry.ts +51 -0
  58. package/src/memory/work-lists.ts +10 -4
  59. package/src/session-brief-worker.ts +3 -2
  60. package/src/session-brief.ts +74 -15
  61. package/src/setup-owned.ts +120 -0
  62. package/src/setup-toml.ts +291 -0
  63. package/src/setup.ts +327 -0
@@ -7,7 +7,10 @@ import { normalizeCaptureFields, type CaptureFields } from "./capture-input.ts";
7
7
  export { CAPTURE_MAX_EXCERPT, CAPTURE_MAX_PATHS } from "./capture-input.ts";
8
8
  import { memoryBase } from "./home.ts";
9
9
  import { captureShardDir, ensureShardDescriptor, readShardDescriptor, resolveAlias, shardKeyHash } from "./shard.ts";
10
+ import { assertProjectActive } from "./lifecycle.ts";
10
11
  import { machineId } from "./machine.ts";
12
+ import { loadAmemConfig, repoConfigRoot } from "./config.ts";
13
+ import { fold } from "./policy.ts";
11
14
  import { readWindowSql, readRef, type ReadWindow, type ReadRef, ftsTerms, fts5Available } from "./search-fts.ts";
12
15
 
13
16
  /**
@@ -81,6 +84,15 @@ export interface CaptureOptions {
81
84
  recentReadRows?: boolean;
82
85
  /** Excerpt screen; defaults to the built-in secret screen. A host passes its stricter policy. */
83
86
  screen?: (text: string) => boolean;
87
+ /** Caps a caller may lower (F4); the built-in caps and the person's configured caps are the ceiling, so a larger value is ignored (a host can tighten, never loosen). */
88
+ excerptBytes?: number; capturePaths?: number;
89
+ /** The working directory the capture belongs to: its repository's `.amem/config` narrows the policy (deny paths, caps) for this store. Absent: the machine configuration only. */
90
+ cwd?: string;
91
+ /**
92
+ * #15 T5: a writable open of an archived or forgotten project is refused (the person took it out of recall; `amem project unarchive` brings an archived one back).
93
+ * `ignore` is for the maintenance that must still reach the shard (gc, export): it never appends.
94
+ */
95
+ lifecycle?: "enforce" | "ignore";
84
96
  }
85
97
  export interface CaptureAppendOptions {
86
98
  /** Opaque stable source receipt for deterministic transcript replay. Requires an explicit source timestamp. */
@@ -125,13 +137,17 @@ export function writeCaptureConfigFile(path: string, config: Partial<CaptureConf
125
137
  return next;
126
138
  }
127
139
 
128
- /** The effective R6 settings for a shard directory: shard config over the home default over the built-in defaults. */
140
+ /**
141
+ * The effective R6 settings for a shard directory: the shard's config over the home default (`capture-config.json`, which the capacity advisor writes)
142
+ * over the person's machine configuration (`amem.config.json` `capture`, F4) over the built-in defaults. A malformed machine configuration throws.
143
+ */
129
144
  export function readCaptureConfig(dir: string, base?: string): CaptureConfig {
130
145
  const homeDefault = base === undefined ? {} : readCaptureConfigFile(join(base, "capture-config.json"));
131
146
  const shard = readCaptureConfigFile(join(dir, "config.json"));
147
+ const machine = base === undefined ? {} : loadAmemConfig(base).config.capture;
132
148
  return {
133
- capMb: shard.capMb ?? homeDefault.capMb ?? CAPTURE_DEFAULT_CAP_MB,
134
- retentionDays: shard.retentionDays ?? homeDefault.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
149
+ capMb: shard.capMb ?? homeDefault.capMb ?? machine.capMb ?? CAPTURE_DEFAULT_CAP_MB,
150
+ retentionDays: shard.retentionDays ?? homeDefault.retentionDays ?? machine.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
135
151
  };
136
152
  }
137
153
 
@@ -157,6 +173,7 @@ export class CaptureStore {
157
173
  private readonly db: Database;
158
174
  private readonly now: () => number;
159
175
  private readonly screen: (text: string) => boolean;
176
+ private readonly caps: import("./capture-input.ts").CaptureCaps;
160
177
  private readonly capMb: number;
161
178
  private readonly retentionDays: number;
162
179
  /** Set when the schema setup itself met the lock: appends spool without touching the database again. */
@@ -179,6 +196,7 @@ export class CaptureStore {
179
196
  if (this.readOnly && !/^[a-f0-9]{16}$/.test(machine)) throw new Error("capture machine identity unavailable");
180
197
  this.requestedKey = opts.project;
181
198
  this.key = resolveAlias(this.base, opts.project);
199
+ if (!this.readOnly && opts.lifecycle !== "ignore") assertProjectActive(this.base, this.key);
182
200
  if (this.readOnly) {
183
201
  const descriptor = lstatSync(join(this.base, "projects", shardKeyHash(this.key), "project.json"));
184
202
  if (!descriptor.isFile() || descriptor.isSymbolicLink() || descriptor.size > 4096
@@ -197,7 +215,14 @@ export class CaptureStore {
197
215
  this.capMb = opts.capMb ?? config.capMb;
198
216
  this.retentionDays = opts.retentionDays ?? config.retentionDays;
199
217
  this.now = opts.now ?? Date.now;
200
- this.screen = opts.screen ?? looksSecret;
218
+ const configured = loadAmemConfig(this.base, opts.cwd === undefined ? undefined : repoConfigRoot(opts.cwd)).config;
219
+ // F4: the person's PII patterns screen every excerpt and their deny paths filter every path, whoever appends (hook, ingest or library). A host's own `screen` replaces only the secret part.
220
+ const patterns = configured.piiPatterns.map(fold).filter(Boolean);
221
+ const base = opts.screen ?? looksSecret;
222
+ this.screen = patterns.length ? (text: string) => base(text) || patterns.some(pattern => fold(text).includes(pattern)) : base;
223
+ const lowest = (a: number | undefined, b: number | undefined) => a === undefined ? b : b === undefined ? a : Math.min(a, b);
224
+ this.caps = { excerptBytes: lowest(opts.excerptBytes, configured.excerptBytes), paths: lowest(opts.capturePaths, configured.capturePaths),
225
+ ...(configured.denyPaths.length ? { denyPaths: configured.denyPaths } : {}) };
201
226
  const file = join(this.dir, "capture.db");
202
227
  if (this.readOnly) {
203
228
  // Refuse absent/cold state without authoring machine, shard, schema, FTS or spool metadata.
@@ -412,7 +437,7 @@ export class CaptureStore {
412
437
 
413
438
  /** The one place input becomes a row: caps fields, deny-filters paths, screens the excerpt, counts its bytes. */
414
439
  private normalize(input: CaptureInput): NormalizedCapture {
415
- const { at, agent, event, tool, paths, status, excerpt, bytes } = normalizeCaptureFields(input, () => this.now(), text => this.screen(text));
440
+ const { at, agent, event, tool, paths, status, excerpt, bytes } = normalizeCaptureFields(input, () => this.now(), text => this.screen(text), this.caps);
416
441
  return { at, project: input.project, agent, event, tool, paths, status, excerpt, bytes };
417
442
  }
418
443
 
@@ -1,11 +1,12 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { chmodSync, existsSync, readdirSync } from "node:fs";
2
+ import { chmodSync, existsSync, lstatSync, readdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { memoryBase } from "./home.ts";
5
5
  import { effectiveAliases, readShardDescriptor, SHARDED_SCHEMA } from "./shard.ts";
6
6
  import { readProfileJSON, validGeneration } from "./generation.ts";
7
7
  import { captureShardStats, readCaptureConfig } from "./capture.ts";
8
8
  import { openShardStore } from "./store.ts";
9
+ import { lifecycleResolver, type LifecycleState } from "./lifecycle.ts";
9
10
 
10
11
  /**
11
12
  * #15 D3 (storage part only): the user-level catalog of project shards. `catalog.db` in the memory home is derived
@@ -20,12 +21,16 @@ export interface CatalogProject {
20
21
  captureRows: number; captureBytes: number; captureLastAt: string | null;
21
22
  curatedEntries: number | null; curatedLastAt: string | null; curatedAvailable: boolean;
22
23
  capMb: number; retentionDays: number;
24
+ /** #15 T5: the person's lifecycle decision (`lifecycle.jsonl`); forgotten projects stay in the scan so the state is visible, and listings leave them out. */
25
+ state: LifecycleState;
26
+ /** Bytes the shard directory holds on disk (its logs, indexes, capture and work lists), counting files and never following a link. */
27
+ diskBytes: number;
23
28
  }
24
29
 
25
- interface CatalogRow { hash: string; key: string; local: number; aliases: string; capture_rows: number; capture_bytes: number; capture_last_at: string | null; curated_entries: number | null; curated_last_at: string | null; cap_mb: number; retention_days: number }
30
+ interface CatalogRow { hash: string; key: string; local: number; aliases: string; capture_rows: number; capture_bytes: number; capture_last_at: string | null; curated_entries: number | null; curated_last_at: string | null; cap_mb: number; retention_days: number; state: string; disk_bytes: number }
26
31
 
27
32
  const fromRow = (r: CatalogRow): CatalogProject => ({ hash: r.hash, key: r.key, local: r.local === 1, aliases: JSON.parse(r.aliases) as string[],
28
- captureRows: r.capture_rows, captureBytes: r.capture_bytes, captureLastAt: r.capture_last_at, curatedEntries: r.curated_entries, curatedLastAt: r.curated_last_at, curatedAvailable: r.curated_entries !== null, capMb: r.cap_mb, retentionDays: r.retention_days });
33
+ captureRows: r.capture_rows, captureBytes: r.capture_bytes, captureLastAt: r.capture_last_at, curatedEntries: r.curated_entries, curatedLastAt: r.curated_last_at, curatedAvailable: r.curated_entries !== null, capMb: r.cap_mb, retentionDays: r.retention_days, state: r.state === "archived" || r.state === "forgotten" ? r.state : "active", diskBytes: r.disk_bytes });
29
34
 
30
35
  /** Interrupted splits expose capture facts, but cannot authorize any curated store open or initialization. */
31
36
  function capturedSelection(base: string): boolean {
@@ -41,7 +46,7 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
41
46
  const dir = join(base, "projects", hash);
42
47
  // A capture-only descriptor is not permission to initialize a curated store while listing the catalog.
43
48
  if (!existsSync(join(dir, "generation.json")) && !existsSync(join(dir, "generation-seal.json"))) return { entries: 0, lastAt: null };
44
- const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true });
49
+ const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true, lifecycle: "ignore" });
45
50
  try {
46
51
  const db = new Database(join(dir, "index.db"), { readonly: true });
47
52
  try {
@@ -52,6 +57,23 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
52
57
  } finally { store.close(); }
53
58
  }
54
59
 
60
+ /** Bytes of regular files under a shard directory. A link is not followed (counted as nothing), an unreadable entry as nothing: the figure is a lower bound, never a guess. */
61
+ export function directoryBytes(dir: string, depth = 0): number {
62
+ if (depth > 12) return 0;
63
+ let total = 0;
64
+ let entries: import("node:fs").Dirent[] = [];
65
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
66
+ for (const entry of entries) {
67
+ const path = join(dir, entry.name);
68
+ try {
69
+ if (entry.isSymbolicLink()) continue;
70
+ if (entry.isDirectory()) total += directoryBytes(path, depth + 1);
71
+ else if (entry.isFile()) total += lstatSync(path).size;
72
+ } catch { /* vanished or unreadable: not counted */ }
73
+ }
74
+ return total;
75
+ }
76
+
55
77
  /** Every readable shard with its machine-scope directories; the walk behind the catalog and the capacity advisor. */
56
78
  export function walkShards(base: string): { hash: string; key: string; local: boolean; machineDirs: string[] }[] {
57
79
  const projectsDir = join(base, "projects");
@@ -75,6 +97,7 @@ export function scanCatalog(base: string): CatalogProject[] {
75
97
  const captured = capturedSelection(base);
76
98
  // The latest op per alias (a removal included): an alias lists only under the key it currently points at, never under a past target or after `alias remove`.
77
99
  const latest = effectiveAliases(base);
100
+ const stateOf = lifecycleResolver(base);
78
101
  const out: CatalogProject[] = [];
79
102
  for (const shard of walkShards(base)) {
80
103
  let rows = 0, bytes = 0, lastAt: string | null = null;
@@ -88,11 +111,17 @@ export function scanCatalog(base: string): CatalogProject[] {
88
111
  const curated = curatedStats(base, shard.hash, shard.key, captured);
89
112
  out.push({ hash: shard.hash, key: shard.key, local: shard.local,
90
113
  aliases: [...latest.entries()].filter(([, key]) => key === shard.key).map(([alias]) => alias).sort(),
91
- captureRows: rows, captureBytes: bytes, captureLastAt: lastAt, curatedEntries: curated.entries, curatedLastAt: curated.lastAt, curatedAvailable: curated.entries !== null, capMb: config.capMb, retentionDays: config.retentionDays });
114
+ captureRows: rows, captureBytes: bytes, captureLastAt: lastAt, curatedEntries: curated.entries, curatedLastAt: curated.lastAt, curatedAvailable: curated.entries !== null, capMb: config.capMb, retentionDays: config.retentionDays,
115
+ state: stateOf(shard.key), diskBytes: directoryBytes(join(base, "projects", shard.hash)) });
92
116
  }
93
117
  return out.sort((a, b) => a.key.localeCompare(b.key));
94
118
  }
95
119
 
120
+ /** A fresh scan of the shards for a read-only command (`amem usage`, `amem projects`): it writes nothing, `catalog.db` included. */
121
+ export function readCatalogFresh(base: string): CatalogProject[] {
122
+ return scanCatalog(base);
123
+ }
124
+
96
125
  /** Rewrite `catalog.db` from the shards in one transaction: the same shards always reproduce the same rows. */
97
126
  export function rebuildCatalog(opts: { home?: string } = {}): CatalogProject[] {
98
127
  const base = memoryBase(opts.home);
@@ -105,12 +134,13 @@ export function rebuildCatalog(opts: { home?: string } = {}): CatalogProject[] {
105
134
  db.transaction(() => {
106
135
  const columns = db.query("PRAGMA table_info(projects)").all() as { name: string; notnull: number }[];
107
136
  // This database is disposable. Rebuild older derived schemas instead of coercing unavailable into zero.
108
- if (columns.length && (!columns.some(column => column.name === "curated_entries" && column.notnull === 0) || !columns.some(column => column.name === "curated_last_at"))) db.run("DROP TABLE projects");
137
+ if (columns.length && (!columns.some(column => column.name === "curated_entries" && column.notnull === 0) || !columns.some(column => column.name === "curated_last_at")
138
+ || !columns.some(column => column.name === "state") || !columns.some(column => column.name === "disk_bytes"))) db.run("DROP TABLE projects");
109
139
  db.run(`CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, key TEXT NOT NULL, local INTEGER NOT NULL, aliases TEXT NOT NULL,
110
- capture_rows INTEGER NOT NULL, capture_bytes INTEGER NOT NULL, capture_last_at TEXT, curated_entries INTEGER, curated_last_at TEXT, cap_mb REAL NOT NULL, retention_days REAL NOT NULL)`);
140
+ capture_rows INTEGER NOT NULL, capture_bytes INTEGER NOT NULL, capture_last_at TEXT, curated_entries INTEGER, curated_last_at TEXT, cap_mb REAL NOT NULL, retention_days REAL NOT NULL, state TEXT NOT NULL DEFAULT 'active', disk_bytes INTEGER NOT NULL DEFAULT 0)`);
111
141
  db.run("DELETE FROM projects");
112
- for (const p of projects) db.query("INSERT INTO projects (hash, key, local, aliases, capture_rows, capture_bytes, capture_last_at, curated_entries, curated_last_at, cap_mb, retention_days) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
113
- .run(p.hash, p.key, p.local ? 1 : 0, JSON.stringify(p.aliases), p.captureRows, p.captureBytes, p.captureLastAt, p.curatedEntries, p.curatedLastAt, p.capMb, p.retentionDays);
142
+ for (const p of projects) db.query("INSERT INTO projects (hash, key, local, aliases, capture_rows, capture_bytes, capture_last_at, curated_entries, curated_last_at, cap_mb, retention_days, state, disk_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
143
+ .run(p.hash, p.key, p.local ? 1 : 0, JSON.stringify(p.aliases), p.captureRows, p.captureBytes, p.captureLastAt, p.curatedEntries, p.curatedLastAt, p.capMb, p.retentionDays, p.state, p.diskBytes);
114
144
  }).immediate();
115
145
  } finally { db.close(); }
116
146
  return projects;
@@ -127,8 +157,11 @@ export function readCatalog(opts: { home?: string } = {}): CatalogProject[] {
127
157
  db = new Database(file, { readonly: true });
128
158
  db.run("PRAGMA busy_timeout = 3000");
129
159
  const columns = new Set((db.query("PRAGMA table_info(projects)").all() as { name: string }[]).map(column => column.name));
130
- if (!columns.has("curated_entries") || !columns.has("curated_last_at")) throw new Error("catalog schema needs rebuild");
160
+ if (!columns.has("curated_entries") || !columns.has("curated_last_at") || !columns.has("state") || !columns.has("disk_bytes")) throw new Error("catalog schema needs rebuild");
161
+ // The state is the person's decision in lifecycle.jsonl, which changes without touching a shard: read it live, never from the cached row.
162
+ const stateOf = lifecycleResolver(base);
131
163
  return (db.query("SELECT * FROM projects ORDER BY key").all() as CatalogRow[]).map(fromRow)
164
+ .map(project => ({ ...project, state: stateOf(project.key) }))
132
165
  .map(project => captured ? { ...project, curatedEntries: null, curatedLastAt: null, curatedAvailable: false } : project);
133
166
  } catch { return rebuildCatalog(opts); }
134
167
  finally { db?.close(); }
@@ -2,32 +2,56 @@ import { extractNodes, GOAL_STATES, KINDS, MemoryStore, setMachineLabel, type Go
2
2
  import { syncMemory, type SyncOptions } from "./sync.ts";
3
3
  import { importClaudeMem } from "./import.ts";
4
4
  import { memoryProjectKey, projectChain } from "./project.ts";
5
+ import { recallForViews, recallGraph, recallRows, recallShow } from "./recall.ts";
6
+ import type { GoalPromotionReport } from "./goal-promotion.ts";
7
+
8
+ /**
9
+ * What the commands call themselves in their own messages. The default is amem's own surface; a host that exposes the same commands under its own
10
+ * name (agent-hub's `ahub memory` and `ahub goal`) passes it, so no host name is baked into the library.
11
+ */
12
+ export interface CommandNames { memory: string; goal: string }
13
+ export const AMEM_NAMES: CommandNames = { memory: "amem", goal: "amem goal" };
5
14
 
6
15
  export interface MemoryCommandOptions {
16
+ /** Host-authenticated person intent. Never accept this from model arguments. Required for retained history/as-of. */
17
+ person?: boolean;
18
+ /** Defaults to amem's own names. */
19
+ names?: CommandNames;
7
20
  /** Startup-bound canonical project supplied by a host; no model-facing selector. */
8
21
  project?: string;
9
22
  store?: MemoryStore;
10
23
  sync?: Omit<SyncOptions, "remote">;
11
24
  open: () => MemoryStore;
12
25
  readable: (notes: View[]) => View[];
26
+ stale?: (view: View) => boolean | undefined;
27
+ /** Prepared row ids only: the host must wait for successful output before recording rendered. */
28
+ outputReady?: (notes: readonly View[]) => void;
13
29
  importPolicy: () => (text: string) => boolean;
14
30
  /** Explicit person-status receipt; model-facing hosts omit this callback. */
15
31
  onBridgeStatus?: (status: { skippedRows: number } | undefined) => void;
16
32
  }
17
33
  export interface GoalCommandOptions {
34
+ /** Defaults to amem's own names. */
35
+ names?: CommandNames;
18
36
  project?: string;
19
37
  store?: MemoryStore;
20
38
  link?: (goal: string, task: string) => Promise<string>;
39
+ /** Optional host proposal hook; the goal state is saved before it runs. It never grants mutation authority. */
40
+ onDone?: (goal: GoalView, store: MemoryStore) => Promise<GoalPromotionReport>;
21
41
  open: () => MemoryStore;
22
42
  clean: (title: string, milestones: string[], project: string) => boolean;
23
43
  }
24
44
 
25
45
  /**
26
- * #258: `ahub memory`, straight on the machine's store (no hub needed). Reading is anyone's; promote, demote, forget,
46
+ * #258: the memory commands, straight on the machine's store (no host needed; agent-hub exposes the same as `ahub memory`). Reading is anyone's; promote, demote, forget,
27
47
  * supersede and rebuild are a person's (the identity gate refuses agent shells).
28
48
  */
29
- export const MEMORY_USAGE = "usage: ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> | show <id> | graph <id|node> [--hops 1|2] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]";
30
- export const GOAL_USAGE = "usage: ahub goal [list] [--all] | show <id> | new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task id> | state <id> open|active|blocked|done|dropped | done <id> | take <id> [--force] [--json]";
49
+ /** The usage line of the memory commands under `name` (`amem` by default; agent-hub passes `ahub memory`). */
50
+ export const memoryUsage = (name: string = AMEM_NAMES.memory): string => `usage: ${name} [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> [--history] [--as-of <UTC>] | show <id> [--history] [--as-of <UTC>] | graph <id|node> [--hops 1|2] [--history] [--as-of <UTC>] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]`;
51
+ export const MEMORY_USAGE = memoryUsage();
52
+ /** The usage line of the goal commands under `name` (`amem goal` by default; agent-hub passes `ahub goal`). */
53
+ export const goalUsage = (name: string = AMEM_NAMES.goal): string => `usage: ${name} [list] [--all] | show <id> | new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task id> | state <id> open|active|blocked|done|dropped | done <id> | take <id> [--force] [--json]`;
54
+ export const GOAL_USAGE = goalUsage();
31
55
 
32
56
  const fail = (message: string): never => { throw new Error(message); };
33
57
  const one = (v: View) => `${v.id.slice(0, 8)} ${v.at.slice(0, 16).replace("T", " ")} ${v.tier.padEnd(5)} ${v.kind.padEnd(8)} ${v.peer.padEnd(8)} ${v.scope === "shared" ? "shared " : ""}${`${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").slice(0, 100)}${v.supersededBy ? ` (superseded by ${v.supersededBy.slice(0, 8)})` : ""}`;
@@ -41,64 +65,86 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
41
65
  const rest = args.filter((a) => a !== "--json");
42
66
  const flag = (name: string) => { const i = rest.indexOf(name); if (i < 0) return undefined; const v = rest[i + 1] ?? fail(`${name} needs a value`); rest.splice(i, 2); return v; };
43
67
  const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
68
+ const names = opts.names ?? AMEM_NAMES, usage = memoryUsage(names.memory);
69
+ if (args.some(arg => arg === "--history" || arg === "--as-of" || arg.startsWith("--history=") || arg.startsWith("--as-of=")) && opts.person !== true) fail("historical recall requires authenticated person intent from the host");
44
70
  const store = opts.store ?? opts.open();
45
71
  const actor = "user";
46
72
  const project = opts.project ?? memoryProjectKey(cwd);
47
- const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (ahub memory list shows the ids)`);
73
+ const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (${names.memory} list shows the ids)`);
74
+ // #15 D4: this command is bound to one project. Crossing projects is the person's `amem search --all-projects`; here the flags would otherwise read as query words.
75
+ for (const crossing of ["--all-projects", "--include-archived"]) if (rest.includes(crossing)) fail(`${crossing} crosses projects: run amem search --all-projects; ${names.memory} reads this project only`);
76
+ // #15 T4: repo scope is the person's `amem promote --to repo`, which screens, previews the committed line and checks the repository's visibility. Here the flag would otherwise be ignored and the note promoted privately.
77
+ for (const repoOnly of ["--to", "--confirm", "--confirm-public"]) if (rest.includes(repoOnly)) fail(`${repoOnly} belongs to the person's amem promote <id> --to repo; ${names.memory} never writes a repository file`);
78
+ const stale = (view: View) => { try { return opts.stale?.(view) === true; } catch { return false; } };
79
+ const ready = (rows: readonly View[]) => { try { opts.outputReady?.(rows); } catch { /* optional output metadata cannot fail recall */ } };
80
+ const decorated = (view: View) => ({ ...view, ...(stale(view) ? { stale: true } : {}) });
81
+ if (!["list", "search", "show", "graph"].includes(sub) && (rest.includes("--history") || rest.includes("--as-of"))) fail("history/as-of apply only to memory reads");
48
82
  try {
49
83
  switch (sub) {
50
84
  case "list": case "search": {
51
- const tier = flag("--tier"), kind = flag("--kind"), peer = flag("--peer"), machine = flag("--machine"), since = flag("--since"), limit = flag("--limit");
85
+ const tier = flag("--tier"), kind = flag("--kind"), peer = flag("--peer"), machine = flag("--machine"), since = flag("--since"), limit = flag("--limit"), asOf = flag("--as-of");
86
+ const history = rest.includes("--history"); if (history) rest.splice(rest.indexOf("--history"), 1);
52
87
  if (since !== undefined && Number.isNaN(Date.parse(since))) fail(`--since needs a date or time (ISO 8601), not ${since}`);
53
88
  if (tier && tier !== "short" && tier !== "long") fail("--tier takes short or long");
54
89
  if (kind && !KINDS.includes(kind as Kind)) fail(`--kind takes ${KINDS.join(", ")}`);
55
90
  if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
56
- const q = sub === "search" ? rest.join(" ") || fail("usage: ahub memory search <words>") : rest.length ? fail(MEMORY_USAGE) : undefined;
57
- // Agent shells may run this: notes are screened under this project's current PII policy, as briefs and recall are
58
- // (a shared note was accepted under another project's patterns). The dashboard shows a person everything.
59
- const all = store.list({ project, ...(tier ? { tier: tier as Tier } : {}), ...(kind ? { kind: kind as Kind } : {}), ...(peer ? { peer } : {}), ...(machine ? { machine } : {}), ...(since ? { since } : {}), ...(q ? { q } : {}), limit: Number.MAX_SAFE_INTEGER });
60
- // Screened first, then cut to the limit: withheld entries never crowd out readable ones.
91
+ const q = sub === "search" ? rest.join(" ") : undefined;
92
+ const state = recallRows(store, project, { person: opts.person, history, asOf, query: q, limit: Number.MAX_SAFE_INTEGER });
93
+ const all = state.views.filter(view => (!tier || view.tier === tier) && (!kind || view.kind === kind) && (!peer || view.peer === peer) && (!machine || view.machine === machine) && (!since || Date.parse(view.at) >= Date.parse(since)));
61
94
  const readable = opts.readable(all), rows = readable.slice(0, limit ? Number(limit) : 50);
95
+ ready(rows);
96
+ if (history || asOf !== undefined) {
97
+ const emitted = recallForViews(state, rows), publicIds = new Set(rows.map(view => view.id)), allForgotten = q?.trim() ? [] : state.forgotten;
98
+ const forgotten = allForgotten.slice(0, Math.max(0, (limit ? Math.floor(Number(limit)) : 50) - rows.length));
99
+ const result = { at: state.at, entries: rows.map(decorated), forgotten, relations: state.relations.filter(edge => publicIds.has(edge.from) && publicIds.has(edge.to)), incomplete: emitted.incomplete || readable.length > rows.length || forgotten.length < allForgotten.length, notices: emitted.notices };
100
+ return json ? JSON.stringify(result, null, 2) : [`retained memory as of ${state.at}`, ...result.notices, ...rows.map(view => `${stale(view) ? "(stale?) " : ""}${one(view)}${view.expiresAt && Date.parse(view.expiresAt) <= Date.parse(state.at) ? ` (expired at ${view.expiresAt})` : ""}`), ...forgotten.map(row => `forgotten ${row.id} at ${row.at} (no retained text shown)`), ...(result.incomplete ? ["(retained state is incomplete)"] : [])].join("\n");
101
+ }
62
102
  if (json) return JSON.stringify(rows, null, 2);
63
- // Preserve project-wide header semantics, but count only rows the host policy lets this reader see.
64
- const counted = opts.readable(store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }));
65
- const c = { machine: store.machine, long: counted.filter(v => v.tier === "long").length, short: counted.filter(v => v.tier === "short").length };
103
+ const counted = opts.readable(store.withDeferredPromotions(() => store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true })));
66
104
  const withheld = all.length - readable.length;
67
- return [`hub memory for ${project} on machine ${c.machine.label}: ${c.long} long-term, ${c.short} short-term`, ...(rows.length ? rows.map(one) : ["(nothing)"]),
105
+ return [`memory for ${project} on machine ${store.machine.label}: ${counted.filter(view => view.tier === "long").length} long-term, ${counted.filter(view => view.tier === "short").length} short-term`, ...(rows.length ? rows.map(view => `${stale(view) ? "(stale?) " : ""}${one(view)}`) : ["(nothing)"]),
68
106
  ...(withheld ? [`(${withheld} withheld: they match a PII pattern; the dashboard shows them)`] : [])].join("\n");
69
107
  }
70
108
  case "show": {
71
- const id = rest[0] ?? fail("usage: ahub memory show <id>");
72
- const v = need(store.get(id, project), id);
73
- if (!opts.readable([v]).length) fail(`${v.id.slice(0, 8)} is withheld: it matches a PII pattern (the dashboard shows it)`);
74
- return json ? JSON.stringify(v, null, 2) : detail(v).join("\n");
109
+ const asOf = flag("--as-of"), history = rest.includes("--history"); if (history) rest.splice(rest.indexOf("--history"), 1);
110
+ const id = rest[0] ?? fail(`usage: ${names.memory} show <id>`), shown = recallShow(store, project, id, { person: opts.person, history, asOf });
111
+ if (!shown.ids.length) return fail(`no memory entry ${id} (${names.memory} list shows the ids)`);
112
+ if (shown.state) {
113
+ const rows = opts.readable(shown.state.views), ids = new Set(rows.map(view => view.id)); ready(rows);
114
+ const state = { ...recallForViews(shown.state, rows), views: rows.map(decorated), relations: shown.state.relations.filter(edge => ids.has(edge.from) && ids.has(edge.to)) };
115
+ return json ? JSON.stringify(state, null, 2) : [`retained memory as of ${state.at}`, ...state.notices, ...rows.flatMap(view => [...(stale(view) ? ["(stale?)"] : []), ...detail(view)]), ...state.forgotten.map(row => `forgotten ${row.id} at ${row.at} (no retained text shown)`), ...(state.incomplete ? ["(retained state is incomplete)"] : [])].join("\n");
116
+ }
117
+ const view = need(shown.entry, id); if (!opts.readable([view]).length) fail(`${view.id.slice(0, 8)} is withheld: it matches a PII pattern (the dashboard shows it)`);
118
+ ready([view]); return json ? JSON.stringify(view, null, 2) : [...(stale(view) ? ["(stale?)"] : []), ...detail(view)].join("\n");
75
119
  }
76
120
  case "graph": {
77
121
  const hops = Number(flag("--hops") ?? 1);
78
122
  if (hops !== 1 && hops !== 2) fail("--hops takes 1 or 2");
79
- const start = rest[0] ?? fail("usage: ahub memory graph <id|node>");
80
- const node = start.includes(":") && !start.startsWith("mem:") ? start : `mem:${need(store.get(start, project), start).id}`;
81
- const g = store.graph(project, node, hops);
123
+ const asOf = flag("--as-of"), history = rest.includes("--history"); if (history) rest.splice(rest.indexOf("--history"), 1);
124
+ const start = rest[0] ?? fail(`usage: ${names.memory} graph <id|node>`);
125
+ const g = recallGraph(store, project, start, hops, { person: opts.person, history, asOf });
126
+ if ((!start.includes(":") || start.startsWith("mem:")) && !g.nodes.length) fail(`no memory entry ${start} (${names.memory} list shows the ids)`);
127
+ const node = !start.includes(":") || start.startsWith("mem:") ? g.nodes[0]!.id : start;
82
128
  return json ? JSON.stringify(g, null, 2) : [`${g.nodes.length} nodes, ${g.edges.length} edges around ${node}`, ...g.edges.map((e) => `${e.from} -${e.rel}-> ${e.to}`)].join("\n");
83
129
  }
84
- case "promote": { const v = need(store.get(rest[0] ?? fail(MEMORY_USAGE), project), rest[0]!); if (v.supersededBy) fail(`${v.id.slice(0, 8)} is replaced by ${v.supersededBy.slice(0, 8)}: promote that one instead`); const id = store.promote(v.id, actor, project) ?? fail(v.project !== project ? `${v.id.slice(0, 8)} is another project's note: promote it from that project` : `${v.id.slice(0, 8)} stays short-term: it holds a secret, matches a PII pattern or is about a private path`); return id === v.id ? `${v.id.slice(0, 8)} is already long-term` : `promoted to long-term as ${id.slice(0, 8)}`; }
85
- case "demote": { const v = need(store.get(rest[0] ?? fail(MEMORY_USAGE), project), rest[0]!); if (v.tier !== "long") return `${v.id.slice(0, 8)} is already short-term`; store.demote(v.id, actor, project); return `demoted ${v.id.slice(0, 8)}: it expires ${store.get(v.id, project)?.expiresAt ?? "with its short-term lifetime"}`; }
86
- case "forget": { const v = need(store.forget(rest[0] ?? fail(MEMORY_USAGE), actor, project), rest[0]!); return `forgot ${v.id.slice(0, 8)}${v.local ? "" : "; it stays in the sync remote's git history until a person rewrites it (docs/memory.md)"}`; }
130
+ case "promote": { const v = need(store.get(rest[0] ?? fail(usage), project), rest[0]!); if (v.supersededBy) fail(`${v.id.slice(0, 8)} is replaced by ${v.supersededBy.slice(0, 8)}: promote that one instead`); const id = store.promote(v.id, actor, project) ?? fail(v.project !== project ? `${v.id.slice(0, 8)} is another project's note: promote it from that project` : `${v.id.slice(0, 8)} stays short-term: it holds a secret, matches a PII pattern or is about a private path`); return id === v.id ? `${v.id.slice(0, 8)} is already long-term` : `promoted to long-term as ${id.slice(0, 8)}`; }
131
+ case "demote": { const v = need(store.get(rest[0] ?? fail(usage), project), rest[0]!); if (v.tier !== "long") return `${v.id.slice(0, 8)} is already short-term`; store.demote(v.id, actor, project); return `demoted ${v.id.slice(0, 8)}: it expires ${store.get(v.id, project)?.expiresAt ?? "with its short-term lifetime"}`; }
132
+ case "forget": { const v = need(store.forget(rest[0] ?? fail(usage), actor, project), rest[0]!); return `forgot ${v.id.slice(0, 8)}${v.local ? "" : "; it stays in the sync remote's git history until a person rewrites it (docs/memory.md)"}`; }
87
133
  case "supersede": {
88
- if (rest.length !== 2) fail("usage: ahub memory supersede <old> <new>");
89
- return store.supersede(rest[0]!, rest[1]!, actor, project) ? `${rest[0]} is superseded by ${rest[1]}` : fail("both ids must name different entries, and the newer one must reach as far: shared for a shared entry, long-term for a long-term one (ahub memory promote it first)");
134
+ if (rest.length !== 2) fail(`usage: ${names.memory} supersede <old> <new>`);
135
+ return store.supersede(rest[0]!, rest[1]!, actor, project) ? `${rest[0]} is superseded by ${rest[1]}` : fail(`both ids must name different entries, and the newer one must reach as far: shared for a shared entry, long-term for a long-term one (${names.memory} promote it first)`);
90
136
  }
91
137
  case "sync": {
92
138
  const remote = flag("--remote");
93
- if (rest.length) fail("usage: ahub memory sync [--remote <private git url>]");
139
+ if (rest.length) fail(`usage: ${names.memory} sync [--remote <private git url>]`);
94
140
  const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
95
141
  if (json) return JSON.stringify(r, null, 2);
96
- if (!r.ok) fail(`hub memory sync: ${r.error}`);
142
+ if (!r.ok) fail(`${names.memory} sync: ${r.error}`);
97
143
  return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
98
144
  }
99
145
  case "dedupe": {
100
146
  const apply = rest.includes("--apply");
101
- if (rest.some(arg => arg !== "--apply")) fail("usage: ahub memory dedupe [--apply] [--json]");
147
+ if (rest.some(arg => arg !== "--apply")) fail(`usage: ${names.memory} dedupe [--apply] [--json]`);
102
148
  const result = store.dedupe(project, apply);
103
149
  if (json) return JSON.stringify(result, null, 2);
104
150
  return [result.groups.length ? `${result.groups.length} duplicate import group(s)${apply ? `; appended ${result.applied} supersede operation(s)` : "; use --apply to reconcile"}` : "no duplicate import groups to reconcile",
@@ -108,7 +154,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
108
154
  const db = flag("--db"), limit = flag("--limit");
109
155
  const restart = rest.includes("--restart");
110
156
  if (restart) rest.splice(rest.indexOf("--restart"), 1);
111
- if (rest[0] !== "claude-mem" || rest.length !== 1) fail("usage: ahub memory import claude-mem [--db <path>] [--limit N] [--restart]");
157
+ if (rest[0] !== "claude-mem" || rest.length !== 1) fail(`usage: ${names.memory} import claude-mem [--db <path>] [--limit N] [--restart]`);
112
158
  if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
113
159
  const isPii = opts.importPolicy();
114
160
  const key = opts.project ?? memoryProjectKey(cwd);
@@ -120,10 +166,10 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
120
166
  case "suggestions": {
121
167
  const pending = store.suggestions(project).filter((x) => x.status === "suggested");
122
168
  if (json) return JSON.stringify(pending, null, 2);
123
- return pending.length ? pending.map((x) => `${x.id.slice(0, 8)} ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)} (suggested by the hub's model ${x.at}; not evidence until confirmed)`).join("\n") : "no edge suggestions waiting";
169
+ return pending.length ? pending.map((x) => `${x.id.slice(0, 8)} ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)} (suggested by a model ${x.at}; not evidence until confirmed)`).join("\n") : "no edge suggestions waiting";
124
170
  }
125
171
  case "confirm": case "reject": {
126
- const x = store.judge(rest[0] ?? fail(`usage: ahub memory ${sub} <suggestion id>`), sub, actor, project) ?? fail(`no suggestion ${rest[0]} (ahub memory suggestions lists them)`);
172
+ const x = store.judge(rest[0] ?? fail(`usage: ${names.memory} ${sub} <suggestion id>`), sub, actor, project) ?? fail(`no suggestion ${rest[0]} (${names.memory} suggestions lists them)`);
127
173
  return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
128
174
  }
129
175
  case "machine": {
@@ -132,7 +178,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
132
178
  return json ? JSON.stringify(m) : `this machine: ${m.id} (${m.label})`;
133
179
  }
134
180
  case "rebuild": { store.rebuild(); const c = store.counts(); return `rebuilt the index from ${c.segments} segment${c.segments === 1 ? "" : "s"}: ${c.long} long-term, ${c.short} short-term entries`; }
135
- default: return fail(MEMORY_USAGE);
181
+ default: return fail(usage);
136
182
  }
137
183
  } finally {
138
184
  try { opts.onBridgeStatus?.(store.bridgeStatus()); }
@@ -140,15 +186,15 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
140
186
  }
141
187
  }
142
188
 
143
- const goalOne = (g: GoalView, me: string) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me)}`;
144
- function leaseText(g: GoalView, me: string): string {
189
+ const goalOne = (g: GoalView, me: string, goal: string = AMEM_NAMES.goal) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me, goal)}`;
190
+ function leaseText(g: GoalView, me: string, goal: string): string {
145
191
  const who = (m: string) => m === me ? "this machine" : `machine ${m.slice(0, 8)}`;
146
- if (g.lease.holder) return `held by ${who(g.lease.holder)} until ${g.lease.until}${g.lease.conflict.length ? `, CONFLICT with ${g.lease.conflict.map(who).join(", ")} (the earlier claim holds; a person settles it with ahub goal take --force)` : ""}`;
192
+ if (g.lease.holder) return `held by ${who(g.lease.holder)} until ${g.lease.until}${g.lease.conflict.length ? `, CONFLICT with ${g.lease.conflict.map(who).join(", ")} (the earlier claim holds; a person settles it with ${goal} take --force)` : ""}`;
147
193
  return g.lease.expired ? `free (${who(g.lease.lastHolder!)}'s lease ended ${g.lease.until})` : "free";
148
194
  }
149
195
 
150
196
  /**
151
- * #258: `ahub goal`, long-term tasks. Listing and showing are anyone's; new, link, state, done and take are a
197
+ * #258: the goal commands (agent-hub exposes the same as `ahub goal`), long-term tasks. Listing and showing are anyone's; new, link, state, done and take are a
152
198
  * person's. A peer links its own board task with hub_task_propose's `goal`.
153
199
  */
154
200
  export async function rawGoalCommand(args: string[], cwd: string, opts: GoalCommandOptions): Promise<string> {
@@ -156,11 +202,12 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
156
202
  const rest = args.filter((a) => a !== "--json");
157
203
  const take = (name: string) => { const out: string[] = []; for (let i = rest.indexOf(name); i >= 0; i = rest.indexOf(name)) { out.push(rest[i + 1] ?? fail(`${name} needs a value`)); rest.splice(i, 2); } return out; };
158
204
  const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
205
+ const names = opts.names ?? AMEM_NAMES, usage = goalUsage(names.goal);
159
206
  const store = opts.store ?? opts.open();
160
207
  const project = opts.project ?? memoryProjectKey(cwd);
161
208
  const me = store.machine.id;
162
209
  // Only this project's long-term tasks and shared ones, like every other lookup an agent may run.
163
- const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(GOAL_USAGE), project) ?? fail(`no long-term task ${id} for this project (ahub goal list shows them)`);
210
+ const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(usage), project) ?? fail(`no long-term task ${id} for this project (${names.goal} list shows them)`);
164
211
  const clean = (title: string, milestones: string[], of = project) => opts.clean(title, milestones, of);
165
212
  try {
166
213
  switch (sub) {
@@ -169,36 +216,43 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
169
216
  const listed = store.goals(project).filter((g) => all || (g.state !== "done" && g.state !== "dropped"));
170
217
  const goals = listed.filter((g) => clean(g.title, g.milestones, g.project)), withheld = listed.length - goals.length;
171
218
  if (json) return JSON.stringify(goals, null, 2);
172
- return [...(goals.length ? goals.map((g) => goalOne(g, me)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (ahub goal new <title>)`]),
219
+ return [...(goals.length ? goals.map((g) => goalOne(g, me, names.goal)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (${names.goal} new <title>)`]),
173
220
  ...(withheld ? [`(${withheld} withheld: they match a PII pattern or a private path)`] : [])].join("\n");
174
221
  }
175
222
  case "show": {
176
223
  const g = need(rest[0]);
177
224
  if (!clean(g.title, g.milestones, g.project)) fail(`${g.id.slice(0, 8)} is withheld: it matches a PII pattern or a private path`);
178
225
  if (json) return JSON.stringify(g, null, 2);
179
- return [goalOne(g, me), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
226
+ return [goalOne(g, me, names.goal), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
180
227
  ...g.milestones.map((m) => `milestone: ${m}`), ...g.dependsOn.map((d) => `depends on ${d.slice(0, 8)}`),
181
228
  ...(g.links.length ? g.links.map((l) => `board task ${l.task} (linked ${l.at} on machine ${l.machine.slice(0, 8)})`) : ["no board tasks linked yet"])].join("\n");
182
229
  }
183
230
  case "new": {
184
231
  const milestones = take("--milestone"), depends = take("--depends").map((d) => need(d).id);
185
232
  const shared = rest.includes("--shared");
186
- const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(GOAL_USAGE);
233
+ const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(usage);
187
234
  if (!clean(title, milestones)) fail("a long-term task syncs to every machine: this one holds a secret, a PII pattern or a private path and is not saved");
188
235
  const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor: "user" });
189
236
  return `long-term task ${id.slice(0, 8)} created: ${title}`;
190
237
  }
191
238
  case "link": {
192
239
  const g = need(rest[0]);
193
- const task = rest[1] ?? fail("usage: ahub goal link <id> <task id>");
240
+ const task = rest[1] ?? fail(`usage: ${names.goal} link <id> <task id>`);
194
241
  if (!/^\d+$/.test(task)) fail("the task is a board task id of this project");
195
- return await (opts.link ?? fail("linking needs this project's hub running (ahub up)"))(g.id, task);
242
+ return await (opts.link ?? fail("linking a goal to a task needs a host that has a task board; amem alone has none"))(g.id, task);
196
243
  }
197
244
  case "state": case "done": {
198
245
  const g = need(rest[0]);
199
246
  const state = (sub === "done" ? "done" : rest[1]) as GoalState;
200
247
  if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
201
248
  store.goalState(g.id, state, "user");
249
+ if (state === "done" && opts.onDone) {
250
+ const current = store.goalOf(g.id, project);
251
+ if (!current || current.id !== g.id || current.state !== "done") return fail("goal state changed before promotion offering");
252
+ const promotion = await opts.onDone(current, store);
253
+ if (json) return JSON.stringify({ goalId: current.id, state: current.state, promotion }, null, 2);
254
+ return `long-term task ${current.id.slice(0, 8)} is done\npromotion candidates: ${promotion.offered} offered, ${promotion.alreadyOffered} already offered, ${promotion.withheld} withheld${promotion.truncated ? "; more linked notes remain (truncated)" : ""}`;
255
+ }
202
256
  return `long-term task ${g.id.slice(0, 8)} is ${state}`;
203
257
  }
204
258
  case "take": {
@@ -207,7 +261,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
207
261
  if (!r.ok) fail(`held by machine ${r.holder.slice(0, 8)} until ${r.until}; wait for it to expire or take it over with --force`);
208
262
  return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
209
263
  }
210
- default: return fail(GOAL_USAGE);
264
+ default: return fail(usage);
211
265
  }
212
266
  } finally { if (!opts.store) store.close(); }
213
267
  }