@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.
- package/CHANGELOG.md +39 -0
- package/README.md +92 -218
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +3 -2
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +7 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +14 -0
- package/docs/agent-notes/tests.md +6 -3
- package/docs/memory.md +70 -152
- package/docs/operations.md +317 -25
- package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
- package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
- package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
- package/docs/plans/amem-cases.json +482 -0
- package/docs/quickstart.md +24 -2
- package/docs/security.md +42 -2
- package/docs/smoke.md +11 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +142 -7
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
- package/docs/verification/2026-10-17-0.12.30.md +70 -0
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -0
- package/docs/verified.json +13 -13
- package/package.json +5 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +129 -62
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +5 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +145 -29
- package/src/cli/console.ts +40 -10
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +4 -3
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.js +2 -2
- package/src/cli/main.ts +43 -14
- package/src/cli/memory.ts +87 -187
- package/src/cli/output.ts +19 -2
- package/src/cli/recovery-package.ts +59 -10
- package/src/cli/upgrade-interactive.ts +2 -1
- package/src/cli/upgrade-restore-state.ts +133 -0
- package/src/cli/upgrade-runtime.ts +204 -63
- package/src/cli/upgrade.ts +57 -24
- package/src/hub/board.ts +83 -24
- package/src/hub/bus.ts +108 -38
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/control-client.ts +5 -1
- package/src/hub/daemon.ts +529 -89
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +31 -1
- package/src/hub/git.ts +2 -38
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +14 -2
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-candidates.ts +103 -0
- package/src/hub/memory-fail.ts +25 -0
- package/src/hub/memory-observations.ts +47 -0
- package/src/hub/memory-plan-guard.ts +33 -0
- package/src/hub/memory-policy.ts +42 -0
- package/src/hub/memory-profile-check.ts +170 -0
- package/src/hub/memory-profile.ts +192 -0
- package/src/hub/memory-staleness.ts +55 -0
- package/src/hub/path-policy.ts +8 -0
- package/src/hub/peer-catalog.ts +3 -0
- package/src/hub/project.ts +4 -37
- package/src/hub/read-tools.ts +127 -0
- package/src/hub/routing.ts +35 -16
- package/src/hub/settings.ts +2 -1
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +21 -6
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +370 -71
- package/src/local/deny.ts +4 -17
- package/src/memory/brief.ts +8 -5
- package/src/memory/capture.ts +3 -1
- package/src/memory/recall.ts +50 -110
- package/src/pi/process-signature.ts +2 -42
- package/src/ui/index.html +12 -9
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
- package/src/memory/import.ts +0 -90
- package/src/memory/store.ts +0 -805
- 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(
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
95
|
+
const context = commandStores(opts.store);
|
|
155
96
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
85
|
+
else file(join(directory, item.name), name);
|
|
43
86
|
}
|
|
44
87
|
};
|
|
45
|
-
// Only runtime assets
|
|
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.
|