@staix/agent-hub 0.12.29 → 0.12.31

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 (95) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +92 -218
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +3 -2
  5. package/docs/agent-notes/budget.md +1 -1
  6. package/docs/agent-notes/bus.md +7 -1
  7. package/docs/agent-notes/daemon.md +14 -1
  8. package/docs/agent-notes/local-worker.md +4 -2
  9. package/docs/agent-notes/tasks.md +14 -0
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/memory.md +70 -152
  12. package/docs/operations.md +317 -25
  13. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  14. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  15. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  16. package/docs/plans/amem-cases.json +482 -0
  17. package/docs/quickstart.md +24 -2
  18. package/docs/security.md +42 -2
  19. package/docs/smoke.md +11 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +142 -7
  21. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
  22. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  23. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  24. package/docs/verification/2026-10-17-418-probes.md +21 -0
  25. package/docs/verified.json +13 -13
  26. package/package.json +5 -2
  27. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  28. package/plugins/agent-hub/server.js +129 -62
  29. package/src/adapters/claude-channel.ts +29 -11
  30. package/src/adapters/local-worker.ts +2 -2
  31. package/src/adapters/pi.ts +5 -0
  32. package/src/cli/attention.ts +5 -1
  33. package/src/cli/console-state.ts +145 -29
  34. package/src/cli/console.ts +40 -10
  35. package/src/cli/help.ts +3 -2
  36. package/src/cli/identity.ts +4 -3
  37. package/src/cli/jcode-mcp.ts +1 -1
  38. package/src/cli/main.js +2 -2
  39. package/src/cli/main.ts +43 -14
  40. package/src/cli/memory.ts +87 -187
  41. package/src/cli/output.ts +19 -2
  42. package/src/cli/recovery-package.ts +59 -10
  43. package/src/cli/upgrade-interactive.ts +2 -1
  44. package/src/cli/upgrade-restore-state.ts +133 -0
  45. package/src/cli/upgrade-runtime.ts +204 -63
  46. package/src/cli/upgrade.ts +57 -24
  47. package/src/hub/board.ts +83 -24
  48. package/src/hub/bus.ts +108 -38
  49. package/src/hub/check-runner.ts +514 -0
  50. package/src/hub/child-process.ts +1 -0
  51. package/src/hub/control-client.ts +5 -1
  52. package/src/hub/daemon.ts +529 -89
  53. package/src/hub/delivery-hints.ts +30 -0
  54. package/src/hub/delivery-journal.ts +185 -12
  55. package/src/hub/delivery-superseded.ts +52 -0
  56. package/src/hub/envelope.ts +31 -1
  57. package/src/hub/git.ts +2 -38
  58. package/src/hub/host-executable.ts +11 -0
  59. package/src/hub/hub-tools.ts +14 -2
  60. package/src/hub/manager.ts +1 -1
  61. package/src/hub/memory-candidates.ts +103 -0
  62. package/src/hub/memory-fail.ts +25 -0
  63. package/src/hub/memory-observations.ts +47 -0
  64. package/src/hub/memory-plan-guard.ts +33 -0
  65. package/src/hub/memory-policy.ts +42 -0
  66. package/src/hub/memory-profile-check.ts +170 -0
  67. package/src/hub/memory-profile.ts +192 -0
  68. package/src/hub/memory-staleness.ts +55 -0
  69. package/src/hub/path-policy.ts +8 -0
  70. package/src/hub/peer-catalog.ts +3 -0
  71. package/src/hub/project.ts +4 -37
  72. package/src/hub/read-tools.ts +127 -0
  73. package/src/hub/routing.ts +35 -16
  74. package/src/hub/settings.ts +2 -1
  75. package/src/hub/spec-read.ts +120 -0
  76. package/src/hub/supervision.ts +21 -6
  77. package/src/hub/task-sweep.ts +7 -6
  78. package/src/hub/tasks.ts +370 -71
  79. package/src/local/deny.ts +4 -17
  80. package/src/memory/brief.ts +8 -5
  81. package/src/memory/capture.ts +3 -1
  82. package/src/memory/recall.ts +50 -110
  83. package/src/pi/process-signature.ts +2 -42
  84. package/src/ui/index.html +12 -9
  85. package/src/ui/needs.ts +7 -5
  86. package/src/ui/office-model.ts +4 -1
  87. package/src/ui/task-board.ts +1 -1
  88. package/src/ui/task-progress.ts +6 -2
  89. package/templates/AGENTS.block.md +1 -1
  90. package/templates/profiles/general.json +1 -1
  91. package/templates/profiles/research.json +1 -1
  92. package/templates/profiles/writing.json +1 -1
  93. package/src/memory/import.ts +0 -90
  94. package/src/memory/store.ts +0 -805
  95. package/src/memory/sync.ts +0 -145
package/src/cli/main.ts CHANGED
@@ -59,6 +59,8 @@ import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatRepo
59
59
  import { benchPreflight, defaultIo, runBench } from "./bench.ts";
60
60
  import { ADDONS, isAddon, type AddonName } from "../hub/tooling.ts";
61
61
  import { goalCommand, memoryCommand } from "./memory.ts";
62
+ import { selectedMemoryProfile } from "../hub/memory-profile.ts";
63
+ import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
62
64
  import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
63
65
  import { summarize, summarizeByTask } from "../hub/report.ts";
64
66
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
@@ -351,6 +353,7 @@ function fail(message: string): never {
351
353
  async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient): Promise<string> {
352
354
  const hub = connection ?? await connect();
353
355
  try {
356
+ if (op === "hub_task_cancel" && !hub.features.includes("task_cancel")) fail("this daemon does not support task cancellation; upgrade it first");
354
357
  const res = await hub.request({ t: "task", op, args: a }, op === "github_import" ? 60_000 : 30_000);
355
358
  if (!res.ok) { if (throwOnError) throw new Error(res.error); audit("refused"); fail(res.error); }
356
359
  return res.text;
@@ -975,17 +978,24 @@ const commands: Record<string, () => Promise<void> | void> = {
975
978
  return;
976
979
  }
977
980
  const [id, ...flags] = rest;
978
- if (!id || !["show", "resolve"].includes(operation)) fail("usage: ahub queue show <id> | resolve <id> --action completed|retry|discard --reason <text>");
979
- const shown = await hub.request({ t: "queue", op: "show", id });
980
- if (!shown.ok) fail(shown.error);
981
+ if (!id || !["show", "resolve"].includes(operation)) fail("usage: ahub queue show <id> | resolve <id> --action completed|retry|discard --reason <text> [--revision N]");
981
982
  if (operation === "show") {
982
983
  if (flags.length) fail("usage: ahub queue show <id>");
984
+ const shown = await hub.request({ t: "queue", op: "show", id });
985
+ if (!shown.ok) fail(shown.error);
983
986
  if (options.json) return console.log(JSON.stringify(shown.delivery, null, 2));
984
987
  return printOutput(renderQueueShow(shown.delivery, options.columns, Date.now()), options.color);
985
988
  }
986
- const { one, rest: extra } = takeFlags(args.slice(2), ["--action", "--reason"], []);
987
- if (extra.length || !["completed", "retry", "discard"].includes(one["--action"] ?? "") || !one["--reason"]?.trim()) fail("usage: ahub queue resolve <id> --action completed|retry|discard --reason <text>");
988
- const result = await hub.request({ t: "queue", op: "resolve", id, revision: shown.delivery.revision, action: one["--action"], reason: one["--reason"] });
989
+ const { one, rest: extra } = takeFlags(flags, ["--action", "--reason", "--revision"], []);
990
+ const rawRevision = one["--revision"], parsedRevision = rawRevision === undefined ? undefined : Number(rawRevision);
991
+ if (extra.length || !["completed", "retry", "discard"].includes(one["--action"] ?? "") || !one["--reason"]?.trim() || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(parsedRevision)))) fail("usage: ahub queue resolve <id> --action completed|retry|discard --reason <text> [--revision N]");
992
+ let revision = parsedRevision;
993
+ if (revision === undefined) {
994
+ const shown = await hub.request({ t: "queue", op: "show", id });
995
+ if (!shown.ok) fail(shown.error);
996
+ revision = shown.delivery.revision;
997
+ }
998
+ const result = await hub.request({ t: "queue", op: "resolve", id, revision, action: one["--action"], reason: one["--reason"] });
989
999
  if (!result.ok) fail(result.error);
990
1000
  console.log(JSON.stringify(result.delivery, null, 2));
991
1001
  } finally { hub.close(); }
@@ -1180,13 +1190,25 @@ const commands: Record<string, () => Promise<void> | void> = {
1180
1190
  finally { hub.close(); }
1181
1191
  return;
1182
1192
  }
1193
+ if (sub === "cancel") {
1194
+ const { one, rest: positional } = takeFlags(rest, ["--reason", "--superseded-by", "--revision"], []);
1195
+ const revision = one["--revision"] === undefined ? undefined : Number(one["--revision"]);
1196
+ if (positional.length !== 1 || !/^[1-9]\d*$/.test(positional[0]!) || !one["--reason"]?.trim() || (revision !== undefined && (!/^\d+$/.test(one["--revision"]!) || !Number.isSafeInteger(revision)))) fail("usage: ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]");
1197
+ return console.log(await taskOp("hub_task_cancel", { id: Number(positional[0]), reason: one["--reason"], ...(one["--superseded-by"] === undefined ? {} : { superseded_by: one["--superseded-by"] }), ...(revision === undefined ? {} : { revision }) }));
1198
+ }
1183
1199
  if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
1184
1200
  if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
1185
1201
  if (sub === "assign") {
1186
- const { one, rest: positional } = takeFlags(rest, ["--role"], []);
1187
- const role = one["--role"] ?? "owner";
1188
- if (positional.length !== 2 || !["owner", "reviewer"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer]");
1189
- return console.log(await taskOp("task_assign", { id: positional[0], peer: positional[1], ...(role === "reviewer" ? { role } : {}) }));
1202
+ const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
1203
+ const role = one["--role"] ?? "owner", rawRevision = one["--revision"], revision = rawRevision === undefined ? undefined : Number(rawRevision);
1204
+ if (positional.length !== 2 || !["owner", "reviewer"].includes(role) || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(revision)))) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]");
1205
+ return console.log(await taskOp(revision === undefined ? "task_assign" : "task_assign_checked", { id: positional[0], peer: positional[1], ...(role === "reviewer" ? { role } : {}), ...(revision === undefined ? {} : { revision }) }));
1206
+ }
1207
+ if (sub === "unassign") {
1208
+ const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
1209
+ const role = one["--role"] ?? "owner", rawRevision = one["--revision"], revision = rawRevision === undefined ? undefined : Number(rawRevision);
1210
+ if (positional.length !== 1 || !["owner", "reviewer"].includes(role) || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(revision)))) fail("usage: ahub task unassign <id> [--role owner|reviewer] [--revision N]");
1211
+ return console.log(await taskOp("task_unassign", { id: positional[0], role, ...(revision === undefined ? {} : { revision }) }));
1190
1212
  }
1191
1213
  if (sub === "label") {
1192
1214
  // #247: a person's later verdict on an approved task (the identity gate keeps it human-only), for research records.
@@ -1199,7 +1221,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1199
1221
  appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString() }]);
1200
1222
  return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
1201
1223
  }
1202
- if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | escalate <id>");
1224
+ if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | unassign <id> | cancel <id> --reason <text> | escalate <id>");
1203
1225
  // `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
1204
1226
  // short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
1205
1227
  const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog");
@@ -1411,7 +1433,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1411
1433
  printOutput(renderStatus({ ...status, budget: quota.budget ?? {} }, options.columns, Date.now(), options.full), options.color);
1412
1434
  if (status.needs) { const pet = loadPetConfig(cwd); if (pet.enabled) console.log(renderPet(petView(status), false, pet.character)); }
1413
1435
  // #258: open long-term tasks and who holds them, as of this machine's last memory sync.
1414
- if (existsSync(join(hubHome(), "memory", "index.db"))) {
1436
+ if (status.memoryProfile ? status.memoryProfile.state === "ready" : existsSync(join(hubHome(), "memory", "index.db"))) {
1415
1437
  try { const lines = await goalCommand(["list"], cwd); if (!lines.startsWith("no open")) console.log(`\nlong-term tasks\n${lines}`); } catch { /* memory unreadable: status goes on */ }
1416
1438
  }
1417
1439
  } finally { hub.close(); }
@@ -1487,7 +1509,14 @@ const commands: Record<string, () => Promise<void> | void> = {
1487
1509
  },
1488
1510
  memory: async () => {
1489
1511
  // #258: the hub's own memory on this machine; reads are anyone's, changes a person's.
1490
- try { console.log(await memoryCommand(args, cwd)); } catch (e) { fail((e as Error).message); }
1512
+ if (args.filter(arg => arg !== "--json")[0] === "profile-check") {
1513
+ if (args.some(arg => arg !== "profile-check" && arg !== "--json")) fail("usage: ahub memory profile-check [--json]");
1514
+ const result = await checkMemoryProfiles(selectedMemoryProfile());
1515
+ console.log(args.includes("--json") ? JSON.stringify(result, null, 2) : formatMemoryProfileCheck(result));
1516
+ if (!result.ready) process.exitCode = 1;
1517
+ return;
1518
+ }
1519
+ try { console.log(await memoryCommand(args, cwd, { person: caller.role === "console" })); } catch (e) { fail((e as Error).message); }
1491
1520
  },
1492
1521
  goal: async () => {
1493
1522
  // #258: long-term tasks; a link goes through the project's hub, so Tasks validates the board task.
@@ -1496,7 +1525,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1496
1525
  try { const r = await hub.request({ t: "goal_link", goal, task: Number(task) }); if (!r.ok) throw new Error(String(r.error)); return String(r.text); }
1497
1526
  finally { hub.close(); }
1498
1527
  };
1499
- try { console.log(await goalCommand(args, cwd, { link })); } catch (e) { fail((e as Error).message); }
1528
+ try { console.log(await goalCommand(args, cwd, { link, person: caller.role === "console" })); } catch (e) { fail((e as Error).message); }
1500
1529
  },
1501
1530
  research: () => {
1502
1531
  // #247: measures from the opt-in research records; `export` writes them out, `backfill` builds them from events.jsonl.
package/src/cli/memory.ts CHANGED
@@ -1,203 +1,103 @@
1
- import { extractNodes, GOAL_STATES, KINDS, looksSecret, MemoryStore, setMachineLabel, type GoalState, type GoalView, type Kind, type Tier, type View } from "../memory/store.ts";
2
- import { syncMemory, type SyncOptions } from "../memory/sync.ts";
3
- import { importClaudeMem } from "../memory/import.ts";
1
+ import { detectCliIdentity } from "./identity.ts";
2
+ import { createStalenessProbe } from "../hub/memory-staleness.ts";
3
+ import { looksSecret, type MemoryStore, type StoreOptions } from "@staix/agent-mem";
4
+ import type { SyncOptions } from "@staix/agent-mem";
5
+ import { MEMORY_USAGE as CORE_MEMORY_USAGE, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
6
+ import { memoryProjectKey } from "@staix/agent-mem";
7
+ import { mentionsPrivatePath, promotionScreen, withoutPii } from "../hub/memory-policy.ts";
4
8
  import { currentRouting, detectSignals } from "../hub/routing.ts";
5
- import { memoryProjectKey, mentionsPrivatePath, projectChain, promotionScreen, withoutPii } from "../memory/recall.ts";
6
9
  import { loadConfig } from "../hub/daemon.ts";
10
+ import { bindProfileMemory, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, type MemoryProfileReceipt } from "../hub/memory-profile.ts";
7
11
 
12
+ export { GOAL_USAGE } from "@staix/agent-mem";
13
+ export const MEMORY_USAGE = `${CORE_MEMORY_USAGE} | decay [--days N] [--json] | profile-check [--json]`;
14
+
15
+ const fail = (message: string): never => { throw new Error(message); };
8
16
  /** The project's config, or none when there is no valid one (reading memory needs none). */
9
17
  const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch { return undefined; } };
10
18
 
11
- /**
12
- * #258: `ahub memory`, straight on the machine's store (no hub needed). Reading is anyone's; promote, demote, forget,
13
- * supersede and rebuild are a person's (the identity gate refuses agent shells).
14
- */
15
- 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]";
16
- 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]";
17
19
 
18
- const fail = (message: string): never => { throw new Error(message); };
19
- 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)})` : ""}`;
20
- function detail(v: View): string[] {
21
- return [`${v.id} ${v.kind}, ${v.tier}-term${v.expiresAt ? ` until ${v.expiresAt}` : ""}, ${v.scope}`, `by ${v.peer} on machine ${v.machine}, ${v.at}, project ${v.project}${v.task ? `, task ${v.task}` : ""}`,
22
- ...(v.promotedFrom ? [`promoted from ${v.promotedFrom}`] : []), ...(v.supersededBy ? [`superseded by ${v.supersededBy}`] : []), ...(v.about.length ? [`about: ${v.about.join(", ")}`] : []), "", ...(v.title ? [v.title] : []), v.text];
20
+ /** Bind each command at entry; the CLI closes default opens and injected stores remain caller-owned. */
21
+ function commandStores(provided?: MemoryStore, options: StoreOptions = {}) {
22
+ try { return { ...(provided ? bindProfileMemory(provided) : openProfileMemory(options)), owned: !provided }; }
23
+ catch { throw new Error(MEMORY_PROFILE_UNAVAILABLE); }
24
+ }
25
+ function profileOutput(text: string, json: boolean, profile: MemoryProfileReceipt | undefined, person: boolean): string {
26
+ if (!profile) return text;
27
+ if (profile.state !== "ready") throw new Error(MEMORY_PROFILE_UNAVAILABLE);
28
+ if (json) return text;
29
+ return `${text}\nmemory profile: ${profile.profile}${profile.generation ? ` ${person ? profile.generation : profile.generation.slice(0, 8)}` : ""}${person && profile.skippedRows ? `; skipped malformed legacy rows: ${profile.skippedRows}` : ""}`;
23
30
  }
24
31
 
25
- export async function memoryCommand(args: string[], cwd: string, opts: { store?: MemoryStore; sync?: Omit<SyncOptions, "remote"> } = {}): Promise<string> {
26
- const json = args.includes("--json");
27
- const rest = args.filter((a) => a !== "--json");
28
- 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; };
29
- const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
30
- // The project's private paths (`pii.paths`): a note about one is withheld like a note matching a PII pattern. A config
31
- // that does not load fails the read closed: its privacy policy is not dropped for agent-readable output.
32
+ /** Hub wrapper: current project policy and TTL stay here; parsing and storage commands belong to the core. */
33
+ export async function memoryCommand(args: string[], cwd: string, opts: { store?: MemoryStore; sync?: Omit<SyncOptions, "remote">; now?: () => number; person?: boolean } = {}): Promise<string> {
34
+ const person = opts.person ?? detectCliIdentity(process.env).role === "console";
32
35
  const privatePaths = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before reading hub memory`); } };
33
- // A store opened here promotes this project's notes only through its current policy (privatePaths fails closed).
34
- // The project's own short-term lifetime, so a deadline this command writes (a demotion) matches what its hub would write.
35
- const store = opts.store ?? new MemoryStore({ ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000, screen: promotionScreen(memoryProjectKey(cwd), cwd, () => currentRouting(cwd), () => privatePaths()) });
36
- const actor = "user";
37
- const project = memoryProjectKey(cwd);
38
- const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (ahub memory list shows the ids)`);
39
- try {
40
- switch (sub) {
41
- case "list": case "search": {
42
- const tier = flag("--tier"), kind = flag("--kind"), peer = flag("--peer"), machine = flag("--machine"), since = flag("--since"), limit = flag("--limit");
43
- if (since !== undefined && Number.isNaN(Date.parse(since))) fail(`--since needs a date or time (ISO 8601), not ${since}`);
44
- if (tier && tier !== "short" && tier !== "long") fail("--tier takes short or long");
45
- if (kind && !KINDS.includes(kind as Kind)) fail(`--kind takes ${KINDS.join(", ")}`);
46
- if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
47
- const q = sub === "search" ? rest.join(" ") || fail("usage: ahub memory search <words>") : rest.length ? fail(MEMORY_USAGE) : undefined;
48
- // Agent shells may run this: notes are screened under this project's current PII policy, as briefs and recall are
49
- // (a shared note was accepted under another project's patterns). The dashboard shows a person everything.
50
- 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 });
51
- // Screened first, then cut to the limit: withheld entries never crowd out readable ones.
52
- const readable = withoutPii(all, currentRouting(cwd), cwd, privatePaths()), rows = readable.slice(0, limit ? Number(limit) : 50);
53
- if (json) return JSON.stringify(rows, null, 2);
54
- const c = store.counts(project);
55
- const withheld = all.length - readable.length;
56
- return [`hub memory for ${project} on machine ${c.machine.label}: ${c.long} long-term, ${c.short} short-term`, ...(rows.length ? rows.map(one) : ["(nothing)"]),
57
- ...(withheld ? [`(${withheld} withheld: they match a PII pattern; the dashboard shows them)`] : [])].join("\n");
58
- }
59
- case "show": {
60
- const id = rest[0] ?? fail("usage: ahub memory show <id>");
61
- const v = need(store.get(id, project), id);
62
- if (!withoutPii([v], currentRouting(cwd), cwd, privatePaths()).length) fail(`${v.id.slice(0, 8)} is withheld: it matches a PII pattern (the dashboard shows it)`);
63
- return json ? JSON.stringify(v, null, 2) : detail(v).join("\n");
64
- }
65
- case "graph": {
66
- const hops = Number(flag("--hops") ?? 1);
67
- if (hops !== 1 && hops !== 2) fail("--hops takes 1 or 2");
68
- const start = rest[0] ?? fail("usage: ahub memory graph <id|node>");
69
- const node = start.includes(":") && !start.startsWith("mem:") ? start : `mem:${need(store.get(start, project), start).id}`;
70
- const g = store.graph(project, node, hops);
71
- 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");
72
- }
73
- 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)}`; }
74
- 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"}`; }
75
- 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)"}`; }
76
- case "supersede": {
77
- if (rest.length !== 2) fail("usage: ahub memory supersede <old> <new>");
78
- 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)");
79
- }
80
- case "sync": {
81
- const remote = flag("--remote");
82
- if (rest.length) fail("usage: ahub memory sync [--remote <private git url>]");
83
- const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
84
- if (json) return JSON.stringify(r, null, 2);
85
- if (!r.ok) fail(`hub memory sync: ${r.error}`);
86
- return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
87
- }
88
- case "dedupe": {
89
- const apply = rest.includes("--apply");
90
- if (rest.some(arg => arg !== "--apply")) fail("usage: ahub memory dedupe [--apply] [--json]");
91
- const result = store.dedupe(project, apply);
92
- if (json) return JSON.stringify(result, null, 2);
93
- 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",
94
- ...result.groups.map(g => `${g.members.map(v => v.id.slice(0, 8)).join(", ")} -> ${g.target.slice(0, 8)}: ${g.members[0]!.text}`)].join("\n");
95
- }
96
- case "import": {
97
- const db = flag("--db"), limit = flag("--limit");
98
- const restart = rest.includes("--restart");
99
- if (restart) rest.splice(rest.indexOf("--restart"), 1);
100
- if (rest[0] !== "claude-mem" || rest.length !== 1) fail("usage: ahub memory import claude-mem [--db <path>] [--limit N] [--restart]");
101
- if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
102
- const routing = currentRouting(cwd);
103
- const policy = privatePaths(); // a note about a private path never reaches the synced log
104
- const key = memoryProjectKey(cwd);
105
- const nodes = (text: string) => extractNodes(text, { cwd, project: key, repo: key.startsWith("github.com/") ? key.slice("github.com/".length) : key, checkExists: false });
106
- const r = importClaudeMem(store, { project: key, chain: projectChain(cwd), nodes, restart, isPii: (text) => detectSignals({ title: text, detail: "", refs: {} }, routing, cwd).includes("pii") || mentionsPrivatePath(text, [], policy, cwd), ...(db ? { db } : {}), ...(limit ? { limit: Number(limit) } : {}) });
107
- if (json) return JSON.stringify(r, null, 2);
108
- return `imported ${r.added} of ${r.read} hub notes from claude-mem as long-term entries (skipped: ${r.skipped.model} model answers, ${r.skipped.pii} PII, ${r.skipped.secret} secret-looking; other candidates were empty, malformed or already imported)${r.more ? "\nmore remain: run it again" : ""}`;
109
- }
110
- case "suggestions": {
111
- const pending = store.suggestions(project).filter((x) => x.status === "suggested");
112
- if (json) return JSON.stringify(pending, null, 2);
113
- 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";
114
- }
115
- case "confirm": case "reject": {
116
- 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)`);
117
- return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
118
- }
119
- case "machine": {
120
- const label = flag("--label");
121
- const m = label ? setMachineLabel(store.dir, label) : store.machine;
122
- return json ? JSON.stringify(m) : `this machine: ${m.id} (${m.label})`;
123
- }
124
- 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`; }
125
- default: return fail(MEMORY_USAGE);
36
+ const rest = args.filter(arg => arg !== "--json");
37
+ if (rest[0] === "decay") {
38
+ if (!person) fail("memory decay requires person authority");
39
+ let days = 90;
40
+ if (rest.length !== 1) {
41
+ if (rest.length !== 3 || rest[1] !== "--days" || !/^[1-9][0-9]*$/.test(rest[2]!)) fail("usage: ahub memory decay [--days N] [--json] (N must be a positive integer)");
42
+ days = Number(rest[2]);
126
43
  }
127
- } finally { if (!opts.store) store.close(); }
128
- }
129
-
130
- const goalOne = (g: GoalView, me: string) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me)}`;
131
- function leaseText(g: GoalView, me: string): string {
132
- const who = (m: string) => m === me ? "this machine" : `machine ${m.slice(0, 8)}`;
133
- 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)` : ""}`;
134
- return g.lease.expired ? `free (${who(g.lease.lastHolder!)}'s lease ended ${g.lease.until})` : "free";
44
+ const cutoffMs = (opts.now ?? Date.now)() - days * 24 * 3600_000;
45
+ if (!Number.isSafeInteger(days) || !Number.isFinite(cutoffMs) || Math.abs(cutoffMs) > 8_640_000_000_000_000) fail("--days is outside the supported date range");
46
+ const cutoff = new Date(cutoffMs).toISOString();
47
+ const project = memoryProjectKey(cwd), routing = currentRouting(cwd), paths = privatePaths();
48
+ // Review must not promote a held source while reading it: only explicit person actions may append rows.
49
+ const context = commandStores(opts.store, { deferPromotions: true, now: opts.now });
50
+ const store = context.store;
51
+ try {
52
+ context.binding.assertCurrent();
53
+ const candidates = store.withDeferredPromotions(() => {
54
+ const goals = store.goals(project).filter(goal => goal.state !== "done" && goal.state !== "dropped");
55
+ const linkedTasks = new Set(goals.flatMap(goal => goal.links.map(link => link.task)));
56
+ const openGoals = new Set(goals.map(goal => `goal:${goal.id}`));
57
+ return withoutPii(store.list({ project, tier: "long", limit: Number.MAX_SAFE_INTEGER }).filter(note =>
58
+ note.project === project && Date.parse(note.at) < cutoffMs
59
+ && !(note.task && linkedTasks.has(note.task))
60
+ && !note.about.some(node => openGoals.has(node) || (node.startsWith("task:") && linkedTasks.has(node.slice(5))))), routing, cwd, paths);
61
+ });
62
+ const profile = context.binding.status(store);
63
+ if (args.includes("--json")) return profileOutput(JSON.stringify({ days, cutoff, candidates, memoryProfile: profile }), true, profile, person);
64
+ const text = candidates.length
65
+ ? [`${candidates.length} decay candidate${candidates.length === 1 ? "" : "s"} older than ${days} days (review only):`, ...candidates.map(note => `mem:${note.id} ${note.at} ${note.kind}: ${note.title ?? note.text}`)].join("\n")
66
+ : `no decay candidates older than ${days} days`;
67
+ return profileOutput(text, false, profile, person);
68
+ } finally { if (context.owned) store.close(); }
69
+ }
70
+ let showStale = false;
71
+ const context = commandStores(opts.store, { ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000,
72
+ screen: promotionScreen(memoryProjectKey(cwd), cwd, () => currentRouting(cwd), privatePaths) });
73
+ try {
74
+ context.binding.assertCurrent();
75
+ const text = await rawMemoryCommand(args, cwd, { ...opts, store: context.store, open: () => context.store,
76
+ readable: notes => {
77
+ const paths = privatePaths(), visible = withoutPii(notes, currentRouting(cwd), cwd, paths);
78
+ if (args[0] !== "show" || args.includes("--json")) return visible;
79
+ const stale = createStalenessProbe({ cwd, privatePaths: paths, deny: loadConfigSafe(cwd)?.local.deny ?? [] });
80
+ showStale = visible.some(note => stale(note.about));
81
+ return visible;
82
+ },
83
+ importPolicy: () => {
84
+ const routing = currentRouting(cwd), policy = privatePaths();
85
+ return text => detectSignals({ title: text, detail: "", refs: {} }, routing, cwd).includes("pii") || mentionsPrivatePath(text, [], policy, cwd);
86
+ },
87
+ });
88
+ return profileOutput(showStale ? `${text}\n(stale?)` : text, args.includes("--json"), context.binding.status(context.store), person);
89
+ } finally { if (context.owned) context.store.close(); }
135
90
  }
136
91
 
137
- /**
138
- * #258: `ahub goal`, long-term tasks. Listing and showing are anyone's; new, link, state, done and take are a
139
- * person's. A peer links its own board task with hub_task_propose's `goal`.
140
- */
141
- export async function goalCommand(args: string[], cwd: string, opts: { store?: MemoryStore; link?: (goal: string, task: string) => Promise<string> } = {}): Promise<string> {
142
- const json = args.includes("--json");
143
- const rest = args.filter((a) => a !== "--json");
144
- 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; };
145
- const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
146
- const store = opts.store ?? new MemoryStore();
147
- const project = memoryProjectKey(cwd);
148
- const me = store.machine.id;
149
- // Only this project's long-term tasks and shared ones, like every other lookup an agent may run.
150
- 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)`);
151
- // Agent shells may read goals and a goal syncs to every machine: titles and milestones pass the same screen as notes
152
- // (PII patterns, pii.paths; a config that does not load fails closed).
92
+ export async function goalCommand(args: string[], cwd: string, opts: { store?: MemoryStore; link?: (goal: string, task: string) => Promise<string>; person?: boolean } = {}): Promise<string> {
93
+ const person = opts.person ?? detectCliIdentity(process.env).role === "console";
153
94
  const policy = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before using long-term tasks`); } };
154
- const clean = (title: string, milestones: string[], of = project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project: of }], currentRouting(cwd), cwd, policy()).length > 0;
95
+ const context = commandStores(opts.store);
155
96
  try {
156
- switch (sub) {
157
- case "list": {
158
- const all = rest.includes("--all");
159
- const listed = store.goals(project).filter((g) => all || (g.state !== "done" && g.state !== "dropped"));
160
- const goals = listed.filter((g) => clean(g.title, g.milestones, g.project)), withheld = listed.length - goals.length;
161
- if (json) return JSON.stringify(goals, null, 2);
162
- return [...(goals.length ? goals.map((g) => goalOne(g, me)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (ahub goal new <title>)`]),
163
- ...(withheld ? [`(${withheld} withheld: they match a PII pattern or a private path)`] : [])].join("\n");
164
- }
165
- case "show": {
166
- const g = need(rest[0]);
167
- 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`);
168
- if (json) return JSON.stringify(g, null, 2);
169
- return [goalOne(g, me), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
170
- ...g.milestones.map((m) => `milestone: ${m}`), ...g.dependsOn.map((d) => `depends on ${d.slice(0, 8)}`),
171
- ...(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");
172
- }
173
- case "new": {
174
- const milestones = take("--milestone"), depends = take("--depends").map((d) => need(d).id);
175
- const shared = rest.includes("--shared");
176
- const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(GOAL_USAGE);
177
- 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");
178
- const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor: "user" });
179
- return `long-term task ${id.slice(0, 8)} created: ${title}`;
180
- }
181
- case "link": {
182
- const g = need(rest[0]);
183
- const task = rest[1] ?? fail("usage: ahub goal link <id> <task id>");
184
- if (!/^\d+$/.test(task)) fail("the task is a board task id of this project");
185
- return await (opts.link ?? fail("linking needs this project's hub running (ahub up)"))(g.id, task);
186
- }
187
- case "state": case "done": {
188
- const g = need(rest[0]);
189
- const state = (sub === "done" ? "done" : rest[1]) as GoalState;
190
- if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
191
- store.goalState(g.id, state, "user");
192
- return `long-term task ${g.id.slice(0, 8)} is ${state}`;
193
- }
194
- case "take": {
195
- const g = need(rest[0]);
196
- const r = store.take(g.id, "user", { force: rest.includes("--force") });
197
- 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`);
198
- return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
199
- }
200
- default: return fail(GOAL_USAGE);
201
- }
202
- } finally { if (!opts.store) store.close(); }
97
+ context.binding.assertCurrent();
98
+ const text = await rawGoalCommand(args, cwd, { ...opts, store: context.store, open: () => context.store,
99
+ clean: (title, milestones, project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project }], currentRouting(cwd), cwd, policy()).length > 0,
100
+ });
101
+ return profileOutput(text, args.includes("--json"), context.binding.status(context.store), person);
102
+ } finally { if (context.owned) context.store.close(); }
203
103
  }
package/src/cli/output.ts CHANGED
@@ -7,8 +7,12 @@ import { formatReport, formatTaskReport, type Report, type TaskReport } from "..
7
7
 
8
8
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
9
9
  export interface StatusOutput {
10
+ taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
11
+ pendingTaskOffers?: readonly { task: number; peer: string }[];
12
+ isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
13
+ memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
10
14
  taskWip?: Record<string, { wip: number; wipLimit: number }>;
11
- pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>;
15
+ pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
12
16
  tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[]; switchyard?: string;
13
17
  }
14
18
  export interface BudgetOutput { budget: Record<string, any>; gate?: number }
@@ -184,9 +188,20 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
184
188
  if (data.roleInfo) data = { ...data, peers: Object.fromEntries(Object.entries(data.peers).map(([peer, value]) => [peer, { ...value, ...data.roleInfo?.[peer] }])) };
185
189
  const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
186
190
  if (data.cwd) out.push(...lines(data.cwd, columns));
191
+ for (const stall of data.taskStalls ?? []) out.push(...detail(`task #${stall.task} stalled`, `${stall.cause.kind}${stall.cause.peer ? ` ${stall.cause.peer}` : ""}${stall.cause.dependency ? ` #${stall.cause.dependency}` : ""}: ${stall.cause.code}; configure the peer, ahub task assign ${stall.task} <peer>, or ahub task cancel ${stall.task} --reason <text>`, columns, "attention"));
192
+ for (const offer of data.pendingTaskOffers ?? []) out.push(...detail("task offer", `open ${offer.peer}: task #${offer.task} is waiting for it`, columns, "attention"));
193
+ if (data.memoryProfile) {
194
+ const memory = data.memoryProfile;
195
+ const profile = memory.profile === "generation" ? `generation ${memory.generation?.slice(0, 8) ?? "unknown"}` : memory.profile ?? "unbound";
196
+ out.push(...detail("memory", `${profile}; ${memory.state ?? "unknown"}; agent-mem ${memory.agentMemVersion ?? "unknown"}${memory.skippedRows ? `; malformed legacy rows skipped: ${memory.skippedRows}` : ""}${memory.error ? `; ${memory.error}` : ""}`, columns, memory.state === "unavailable" ? "attention" : undefined));
197
+ }
198
+ if (data.planner) out.push(...detail("planner", `${data.planner.peers?.join(", ") || "nobody"} (${data.planner.source ?? "unknown"})${data.planner.source === "automatic" || data.planner.source === "none" ? "; pin it with ahub settings set planner <peer>" : ""}`, columns, data.planner.source === "none" ? "attention" : undefined));
187
199
  if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
188
200
  for (const pending of data.permissionDefaults ?? []) out.push(...detail("permission default", `${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`, columns, "attention"));
189
201
  for (const crash of data.crash ?? []) out.push(...detail("crash recovery", crash, columns, "attention"));
202
+ for (const warning of data.isolatedChecks?.orphans ?? []) out.push(...detail("isolated check warning", warning, columns, "attention"));
203
+ for (const id of data.isolatedChecks?.unknownReceipts ?? []) out.push(...detail("check receipt", `${id}: unknown; receipt storage unavailable`, columns, "attention"));
204
+ if (data.isolatedChecks?.maintenanceUnavailable) out.push(...detail("check maintenance", "unavailable; saved run outcomes are unchanged", columns, "attention"));
190
205
  const s = initialConsoleState(); s.panel = 1; s.peers = data.peers; s.budget = data.budget ?? {};
191
206
  const head = Object.values(data.peers).some(p => p.permissionMode && p.permissionMode !== "ask") ? ["PEER", "STATE", "MODE", ...TABLES[1]!.slice(2)] : [...TABLES[1]!];
192
207
  head.push("CONTEXT");
@@ -200,7 +215,9 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
200
215
  if (p.flowRoles) out.push(...detail(`${id} flow roles`, p.flowRoles.join(", ") || "none", columns));
201
216
  if (p.catalogRoles) { out.push(...detail(`${id} catalog roles`, Array.isArray(p.catalogRoles.value) ? p.catalogRoles.value.join(", ") || "none" : "unconfirmed", columns)); if (p.catalogRoles.pending !== undefined) out.push(...detail(`${id} pending roles`, p.catalogRoles.pending.join(", ") || "none", columns, "attention")); }
202
217
  if (p.liveAccepted?.length) { settling = true; out.push(...detail("settling", p.liveAccepted.map((v: string) => shortId(v, columns, full)).join(", "), columns, "attention")); }
203
- if (p.heldBy) {
218
+ if (p.heldRows?.length) {
219
+ for (const row of p.heldRows) out.push(...detail("held", `${id}: needs_review ${shortId(row.id, columns, full)}; from ${row.senders.join(",")}; kind ${row.kinds.join(",")}; age ${duration(Math.max(0, now - row.createdAt))}${row.context ? `; ${row.context}` : ""}; ahub queue show ${row.id}`, columns, "failure"));
220
+ } else if (p.heldBy) {
204
221
  const note = flat(p.holdNote ?? "");
205
222
  const command = `ahub queue resolve ${p.heldBy} --action completed|retry|discard --reason <text>`;
206
223
  const normalized = note.startsWith(`held by needs_review ${p.heldBy}`)
@@ -1,8 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { hubHome } from "../hub/project.ts";
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { createRequire } from "node:module";
6
+ import { hubHome, realPath } from "../hub/project.ts";
6
7
 
7
8
  export type CommandResult = { code: number; stdout: string; stderr: string };
8
9
  export type RunCommand = (args: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }) => Promise<CommandResult>;
@@ -32,28 +33,76 @@ export function exactVersion(version: string): string {
32
33
  return version;
33
34
  }
34
35
 
35
- export function packageDigest(root: string): string {
36
+ const MEMORY_ASSETS = ["src", "bin", "package.json", "README.md", "LICENSE"] as const;
37
+
38
+ /** The sole runtime dependency is a bounded first-party package, never an arbitrary dependency tree. */
39
+ function memoryRuntime(root: string, dependencyRoot?: string): string | undefined {
40
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
41
+ const deps = pkg.dependencies ?? {};
42
+ if (!Object.keys(deps).length) return undefined;
43
+ const version = deps["@staix/agent-mem"];
44
+ if (Object.keys(deps).length !== 1 || typeof version !== "string" || !/^\d+\.\d+\.\d+$/.test(version)) throw new Error("unsupported runtime dependencies in recovery package");
45
+ let dir = dependencyRoot ? join(dependencyRoot, "node_modules/@staix/agent-mem")
46
+ : dirname(createRequire(join(root, "package.json")).resolve("@staix/agent-mem"));
47
+ if (dependencyRoot && !existsSync(dir)) throw new Error("memory runtime dependency is unavailable");
48
+ if (dependencyRoot) for (const name of ["node_modules", "node_modules/@staix", "node_modules/@staix/agent-mem"]) {
49
+ if (lstatSync(join(dependencyRoot, name)).isSymbolicLink()) throw new Error("runtime package must not contain symlinks");
50
+ }
51
+ for (;;) {
52
+ const manifest = join(dir, "package.json");
53
+ if (existsSync(manifest)) {
54
+ const memory = JSON.parse(readFileSync(manifest, "utf8"));
55
+ if (memory.name === "@staix/agent-mem") {
56
+ if (memory.version !== version || Object.keys(memory.dependencies ?? {}).length) throw new Error("memory runtime dependency identity/version mismatch");
57
+ return realPath(dir);
58
+ }
59
+ }
60
+ const parent = dirname(dir);
61
+ if (dependencyRoot || parent === dir) throw new Error("memory runtime dependency is unavailable");
62
+ dir = parent;
63
+ }
64
+ }
65
+
66
+ export function preserveRuntimeDependencies(root: string, destination: string): void {
67
+ const memory = memoryRuntime(root);
68
+ if (!memory) return;
69
+ const target = join(destination, "node_modules/@staix/agent-mem");
70
+ mkdirSync(target, { recursive: true });
71
+ for (const name of MEMORY_ASSETS) {
72
+ if (lstatSync(join(memory, name)).isSymbolicLink()) throw new Error("runtime package must not contain symlinks");
73
+ cpSync(join(memory, name), join(target, name), { recursive: true, dereference: false });
74
+ }
75
+ }
76
+
77
+ export function packageDigest(root: string, options: { ownDependencies?: boolean; dependencyRoot?: string } = {}): string {
36
78
  const hash = createHash("sha256");
79
+ const file = (path: string, name: string) => { hash.update(name); hash.update("\0"); hash.update(readFileSync(path)); hash.update("\0"); };
37
80
  const visit = (directory: string, prefix = "") => {
38
81
  for (const item of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
39
82
  if (item.isSymbolicLink()) throw new Error("runtime package must not contain symlinks");
40
83
  const name = `${prefix}${item.name}`;
41
84
  if (item.isDirectory()) visit(join(directory, item.name), `${name}/`);
42
- else { hash.update(name); hash.update("\0"); hash.update(readFileSync(join(directory, item.name))); hash.update("\0"); }
85
+ else file(join(directory, item.name), name);
43
86
  }
44
87
  };
45
- // Only runtime assets; never copy or hash a checkout's private state or dependencies.
88
+ // Only runtime assets and the exact first-party package; never private state or unrelated dependencies.
46
89
  for (const name of ["src", "plugins", "templates", ".claude-plugin"]) visit(join(root, name), `${name}/`);
47
90
  hash.update(readFileSync(join(root, "package.json")));
91
+ const memory = memoryRuntime(root, options.ownDependencies ? root : options.dependencyRoot);
92
+ if (memory) for (const name of MEMORY_ASSETS) {
93
+ const path = join(memory, name), stat = lstatSync(path), prefix = `node_modules/@staix/agent-mem/${name}`;
94
+ if (stat.isSymbolicLink()) throw new Error("runtime package must not contain symlinks");
95
+ if (stat.isDirectory()) visit(path, `${prefix}/`); else file(path, prefix);
96
+ }
48
97
  return hash.digest("hex");
49
98
  }
50
99
 
51
- export function verifyPackage(root: string, version: string): string {
100
+ export function verifyPackage(root: string, version: string, options: { ownDependencies?: boolean; dependencyRoot?: string } = {}): string {
52
101
  const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
53
102
  const plugin = JSON.parse(readFileSync(join(root, "plugins/agent-hub/.claude-plugin/plugin.json"), "utf8"));
54
103
  if (pkg.name !== "@staix/agent-hub" || pkg.version !== version || plugin.version !== version ||
55
104
  !existsSync(join(root, "src/cli/main.js"))) throw new Error("staged package identity/version mismatch");
56
- return packageDigest(root);
105
+ return packageDigest(root, options);
57
106
  }
58
107
 
59
108
  export async function registryRelease(version: string, run: RunCommand = runCommand): Promise<{ version: string; integrity: string }> {
@@ -92,7 +141,7 @@ export async function stageRelease(version: string, integrity: string, run: RunC
92
141
  const root = join(destination, "node_modules/@staix/agent-hub");
93
142
  if (existsSync(destination)) {
94
143
  const saved = JSON.parse(readFileSync(join(destination, "verified.json"), "utf8"));
95
- const digest = verifyPackage(root, version);
144
+ const digest = verifyPackage(root, version, { dependencyRoot: destination });
96
145
  if (saved.integrity !== integrity || saved.digest !== digest) throw new Error("cached release changed; refusing it");
97
146
  return { root, digest };
98
147
  }
@@ -104,7 +153,7 @@ export async function stageRelease(version: string, integrity: string, run: RunC
104
153
  const install = await run([process.execPath, "install", "--ignore-scripts", "--cwd", temporary], { timeoutMs: 180_000 });
105
154
  if (install.code !== 0) throw new Error("package staging failed; installed runtime was not changed");
106
155
  const staged = join(temporary, "node_modules/@staix/agent-hub");
107
- const digest = verifyPackage(staged, version);
156
+ const digest = verifyPackage(staged, version, { dependencyRoot: temporary });
108
157
  // Bun's lock records the registry integrity actually used, not a later metadata query.
109
158
  const lock = JSON.parse(readFileSync(join(temporary, "bun.lock"), "utf8").replace(/,\s*([}\]])/g, "$1"));
110
159
  const row = lock.packages?.["@staix/agent-hub"];
@@ -1,3 +1,4 @@
1
+ import { restoreReceiptStatus } from "./upgrade-restore-state.ts";
1
2
  import { recoveryArgv } from "../hub/recovery-store.ts";
2
3
  import { cancellableWait, nextChoices, publicOperation, type Inspection, type PlannedProject, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
3
4
 
@@ -108,7 +109,7 @@ export function operationLines(op: RecoveryOperation, runner: number | "unknown"
108
109
  const held = runner === "unknown" ? "; whether a runner holds it cannot be read" : runner ? `; runner ${runner} is working` : op.phase === "running" ? "; no runner holds it (the last one stopped mid-step)" : "";
109
110
  const lines = [`${op.plan.kind} to ${op.plan.version}: operation ${op.id}`, ` ${op.phase} at "${stepLabel(op.step)}", updated ${ago(now - op.updatedAt)} ago${held}`];
110
111
  for (const p of op.projects) {
111
- const effects = Object.entries(p.terminals).map(([key, value]) => value === "pending" || value === "failed" ? `${key} ${value}` : key);
112
+ const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value)}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
112
113
  lines.push(` ${p.id} ${p.phase}${effects.length ? ` (${effects.join(", ")})` : ""}${p.restarts?.length ? ` restarts ${p.restarts.length}` : ""}`);
113
114
  }
114
115
  // The error ends with the commands status would print; the menu below offers them instead.