@staix/agent-hub 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +9 -4
  7. package/docs/agent-notes/daemon.md +15 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +139 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +22 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +307 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +18 -5
  21. package/docs/security.md +154 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  31. package/docs/verified.json +14 -14
  32. package/package.json +3 -4
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +4393 -3609
  35. package/src/adapters/acp.ts +5 -4
  36. package/src/adapters/channel-peer.ts +72 -3
  37. package/src/adapters/claude-channel-evidence.ts +52 -0
  38. package/src/adapters/claude-channel.ts +61 -18
  39. package/src/adapters/claude-implementer.ts +29 -7
  40. package/src/adapters/claude-print-child.ts +12 -2
  41. package/src/adapters/codex-appserver.ts +13 -12
  42. package/src/adapters/compat.ts +3 -3
  43. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  44. package/src/adapters/definitions/qwen.ts +1 -0
  45. package/src/adapters/local-worker.ts +61 -18
  46. package/src/adapters/pi.ts +48 -6
  47. package/src/adapters/registry-types.ts +12 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/adapters/reviewer-print.ts +50 -11
  50. package/src/broker/server.ts +44 -13
  51. package/src/broker/store.ts +38 -8
  52. package/src/cli/answer-args.ts +50 -0
  53. package/src/cli/attention.ts +17 -2
  54. package/src/cli/backlog-return-args.ts +37 -0
  55. package/src/cli/console-question.ts +110 -0
  56. package/src/cli/console-state.ts +77 -16
  57. package/src/cli/console.ts +54 -1
  58. package/src/cli/handoff-args.ts +15 -5
  59. package/src/cli/handoff-output.ts +15 -2
  60. package/src/cli/help.ts +8 -5
  61. package/src/cli/identity.ts +13 -3
  62. package/src/cli/main.ts +128 -16
  63. package/src/cli/memory.ts +107 -3
  64. package/src/cli/output.ts +29 -6
  65. package/src/cli/pet.ts +4 -4
  66. package/src/cli/results-args.ts +30 -0
  67. package/src/cli/runtime-version-warning.ts +50 -0
  68. package/src/cli/status-lines.ts +41 -1
  69. package/src/cli/tail-render.ts +1 -0
  70. package/src/cli/upgrade-interactive.ts +14 -2
  71. package/src/hub/action-class.ts +9 -1
  72. package/src/hub/board.ts +369 -18
  73. package/src/hub/bus.ts +493 -53
  74. package/src/hub/completion-detail.ts +50 -0
  75. package/src/hub/completions-store.ts +168 -0
  76. package/src/hub/completions.ts +120 -0
  77. package/src/hub/config-apply.ts +7 -5
  78. package/src/hub/config-reload.ts +10 -1
  79. package/src/hub/control-client.ts +40 -4
  80. package/src/hub/daemon.ts +1569 -134
  81. package/src/hub/delivery-failure.ts +14 -0
  82. package/src/hub/delivery-hints.ts +14 -4
  83. package/src/hub/delivery-journal.ts +751 -42
  84. package/src/hub/delivery-superseded.ts +4 -1
  85. package/src/hub/envelope.ts +3 -3
  86. package/src/hub/events.ts +6 -0
  87. package/src/hub/handoff.ts +145 -6
  88. package/src/hub/hub-tools.ts +56 -7
  89. package/src/hub/manager.ts +59 -3
  90. package/src/hub/memory-policy.ts +44 -1
  91. package/src/hub/memory-sync-policy.ts +109 -0
  92. package/src/hub/memory-sync-state.ts +145 -0
  93. package/src/hub/no-writes.ts +1 -1
  94. package/src/hub/operational-settings.ts +11 -2
  95. package/src/hub/operational-view.ts +12 -1
  96. package/src/hub/peers.ts +25 -2
  97. package/src/hub/planning.ts +6 -1
  98. package/src/hub/question-desk.ts +201 -0
  99. package/src/hub/question-detach.ts +6 -0
  100. package/src/hub/questions.ts +260 -0
  101. package/src/hub/research-evidence.ts +106 -0
  102. package/src/hub/restart.ts +14 -0
  103. package/src/hub/review-anchor.ts +181 -0
  104. package/src/hub/review-brief.ts +13 -0
  105. package/src/hub/review-comments.ts +115 -0
  106. package/src/hub/review-findings.ts +7 -2
  107. package/src/hub/review-material.ts +76 -0
  108. package/src/hub/routing.ts +3 -1
  109. package/src/hub/task-sweep.ts +2 -0
  110. package/src/hub/tasks.ts +577 -63
  111. package/src/memory/capture.ts +5 -2
  112. package/src/pet/config.ts +25 -13
  113. package/src/rooms/link.ts +209 -21
  114. package/src/rooms/remote-peer.ts +186 -19
  115. package/src/rooms/service.ts +338 -22
  116. package/src/rooms/store.ts +437 -65
  117. package/src/rooms/wire.ts +24 -6
  118. package/src/ui/index.html +138 -7
  119. package/src/ui/needs.ts +10 -4
  120. package/src/ui/plugin-questions.ts +56 -0
  121. package/src/ui/plugin-results.ts +81 -0
  122. package/src/ui/plugin.ts +207 -27
  123. package/src/pet/window.html +0 -114
  124. package/src/pet/window.jxa.js +0 -122
  125. package/src/pet/window.ts +0 -154
package/src/cli/main.ts CHANGED
@@ -3,6 +3,7 @@ import { assertConductorOperationLocal, authorizeConductorOperation, authorizeCo
3
3
  import { runtimePruneHealth } from "../hub/runtime-prune-health.ts";
4
4
  import { conductorLocalOperation, conductorOperation, OPERATIONAL_PERSON_GUIDANCE } from "../hub/operational-authority.ts";
5
5
  import { sayRecipients } from "./say-input.ts";
6
+ import { parseAnswerArgs } from "./answer-args.ts";
6
7
  import type { DeliverableRow } from "../hub/tasks.ts";
7
8
  import { kimiSourceRow } from "../hub/budget-notes.ts";
8
9
  import { hubGitSync, hubGitEnv } from "../hub/git.ts";
@@ -30,6 +31,8 @@ import { renderIntake, renderIntakeChange } from "./intake-output.ts";
30
31
  import { renderLearnChange, renderLearnList } from "./learn-output.ts";
31
32
  import { renderCapabilities } from "./capabilities-output.ts";
32
33
  import { parseStudyArgs } from "./study.ts";
34
+ import { parseBacklogReturn } from "./backlog-return-args.ts";
35
+ import { parseResultsArgs } from "./results-args.ts";
33
36
  import { renderExperiments, renderStudyList, renderStudyReceipt, renderStudyShow } from "./study-output.ts";
34
37
  import { renderHandoffReceipt, renderHandoffs } from "./handoff-output.ts";
35
38
  import { parseHandoffArgs } from "./handoff-args.ts";
@@ -44,8 +47,8 @@ import { discoverClaudeHomes } from "./claude-homes.ts";
44
47
  import { setupClaudeHomes } from "./setup-homes.ts";
45
48
  import { registerAconPlugin } from "./acon-register.ts";
46
49
  import { petView, readPet, renderPet } from "./pet.ts";
47
- import { startPetWindow, closePetWindow } from "../pet/window.ts";
48
- import { loadPetConfig, savePetPalette } from "./pet-config.ts";
50
+ import { PET_WINDOW_RETIRED_NOTICE, petPalette } from "../pet/config.ts";
51
+ import { loadPetConfig } from "./pet-config.ts";
49
52
  import { projectProfile, resolveProfile } from "../hub/profiles.ts";
50
53
  import { catalogAssignments, loadRoleCatalog, selectedRoles } from "../hub/roles.ts";
51
54
  import { attachWatch } from "./watch.ts";
@@ -88,7 +91,7 @@ import { childEnv, personEnv } from "../hub/child-process.ts";
88
91
  import { abortRecovery, createOperation, disposeRecovery, recordRecoveryDelegation, repairedFreshSession, reattachRecovery, liveProjects, nextChoices, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation, type Inspection } from "./upgrade.ts";
89
92
  import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
90
93
  import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
91
- import { runtimeVersionWarnings } from "./runtime-version-warning.ts";
94
+ import { globalUpdateAdvice, runtimeVersionWarnings } from "./runtime-version-warning.ts";
92
95
  import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
93
96
  import { readRecoveryPresentationFlags } from "./upgrade-presentation-flags.ts";
94
97
  import { renderOperation, renderBlocked, renderSummary, type RecoveryPresentationOptions } from "./upgrade-presentation.ts";
@@ -717,6 +720,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
717
720
  runner: recoveryRunner,
718
721
  live: (op) => liveProjects(op, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer).inspect),
719
722
  endInspection: recoveryEndInspection,
723
+ afterCompleted: (op) => globalUpdateLines(op.plan.version),
720
724
  endPeer: (planned, peer) => endPlannedPeer(planned, peer),
721
725
  runnerStoppable: (id) => signedRunner(id) !== undefined,
722
726
  stopRunner: (id) => stopSignedRunner(id),
@@ -725,6 +729,16 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
725
729
  };
726
730
  }
727
731
 
732
+ /**
733
+ * #718 (#1179): the step for the global CLI and the shared Claude plugin, when either is older than the daemon `target` installs. The plugin version comes from the same `claude plugin list`
734
+ * probe `ahub doctor` reads (10 s, no shell); a missing `claude`, a failure or an unreadable list is an unknown plugin, which is never a claim. The CLI is this process, or, when an older CLI handed the
735
+ * upgrade to this release's own coordinator (`AGENTHUB_UPGRADE_HANDOVER=1`), the older one that started it. Advice only: nothing here can fail an upgrade.
736
+ */
737
+ function globalUpdateLines(target: string): string[] {
738
+ return globalUpdateAdvice({ target, cli: VERSION, handedOver: process.env.AGENTHUB_UPGRADE_HANDOVER === "1", packageRoot: join(import.meta.dir, "..", ".."),
739
+ probe: () => spawnSync(DOCTOR_PLUGIN_PROBE.command, [...DOCTOR_PLUGIN_PROBE.argv], { encoding: "utf8", timeout: 10_000 }) });
740
+ }
741
+
728
742
  async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
729
743
  const display = recoveryDisplay();
730
744
  const { one, rest } = takeFlags(display.args, ["--to"], []);
@@ -799,7 +813,11 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
799
813
  writeOperation(id, operation);
800
814
  } else operation = createOperation(plan, sourceRoot, undefined, delegatedPeer ? { peer: delegatedPeer, command: kind } : undefined);
801
815
  if (display.json) { spawnRecovery(operation, true); console.log(JSON.stringify(publicOperation(operation), null, 2)); }
802
- else spawnRecovery(operation, false, humanRecovery(false) ? display.options : undefined);
816
+ else {
817
+ spawnRecovery(operation, false, humanRecovery(false) ? display.options : undefined);
818
+ // #718: the upgrade is only scheduled here, but the step is already known: the CLI and the plugin are compared with the daemon this upgrade installs. Never in --json, which stays the raw document.
819
+ if (kind === "upgrade") printRecovery(globalUpdateLines(plan.version));
820
+ }
803
821
  }
804
822
 
805
823
  /** Strict person command: recorded identity only, no session or terminal overrides. */
@@ -1162,8 +1180,6 @@ const commands: Record<string, () => Promise<void> | void> = {
1162
1180
  const status = await startProject(registeredProject(), { unattended, liveTest, ...(recoveryLauncherCurrent ? { current: recoveryLauncherCurrent, env: { ...personEnv(process.env), AGENTHUB_RECOVERY_OPERATION: continuationId, AGENTHUB_PEER_ID: continuationPeer, AGENTHUB_RECOVERY_CONDUCTOR: continuationPeer, AGENTHUB_RECOVERY_CONTINUATION: continuationId } } : {}) });
1163
1181
  console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
1164
1182
  if (recoveryLauncherCurrent) { recoveryLauncherCurrent(); return; }
1165
- try { if (loadPetConfig(cwd).window) console.log(await startPetWindow(stateDir, cwd)); }
1166
- catch { console.error("Pet window was not started; run ahub pet --window to inspect it."); }
1167
1183
  // #608: the default agent set, only for a person's own `ahub up` (never an agent's, a live test's, or an upgrade or recovery relaunch, which set AGENTHUB_RECOVERY_OPERATION).
1168
1184
  if (!args.includes("--no-agents") && !liveTest && caller.role === "console" && identity.role === "console" && !process.env.AGENTHUB_RECOVERY_OPERATION) {
1169
1185
  try {
@@ -1410,7 +1426,10 @@ const commands: Record<string, () => Promise<void> | void> = {
1410
1426
  }
1411
1427
  if (sub === "groom" && operands.length === 1) return shown(renderGroom)(taskOp("hub_planner_groom", {}));
1412
1428
  if (["return", "return-untouched"].includes(sub ?? "") && /^[1-9]\d*$/.test(id ?? "") && /^\d+$/.test(revision ?? "") && operands.length >= 4) {
1413
- return shown((data, columns, now) => renderBacklogResult("return-untouched", data, columns, now))(taskOp("hub_backlog_return_untouched", { id: Number(id), revision: Number(revision), reason: operands.slice(3).join(" ") }));
1429
+ // #1177: `--reserved <peer>` / `--unreserve` edit the reservation atomically with the return (fields.reserved); with neither, the arguments are what they always were.
1430
+ const returned = parseBacklogReturn(operands.slice(3));
1431
+ if (!returned.ok) return fail(returned.error);
1432
+ return shown((data, columns, now) => renderBacklogResult("return-untouched", data, columns, now))(taskOp("hub_backlog_return_untouched", { id: Number(id), revision: Number(revision), reason: returned.value.reason, ...(returned.value.fields ? { fields: returned.value.fields } : {}) }));
1414
1433
  }
1415
1434
  if (sub === "add" && args.length >= 3) return console.log(await taskOp("hub_task_propose", { class: id, title: args.slice(2).join(" "), backlog: true }));
1416
1435
  if (!/^[1-9]\d*$/.test(id ?? "")) fail("usage: ahub backlog list|add <class> <title...>|pull|return <id>|plan|pin <id> <revision> <value> <reason...>|undo <id> <revision>");
@@ -1422,12 +1441,12 @@ const commands: Record<string, () => Promise<void> | void> = {
1422
1441
  return shown((data, columns, now) => renderBacklogResult("split", data, columns, now))(taskOp("hub_backlog_split", { id: Number(id), revision: Number(revision), parts, reason: reason.join(" ") }));
1423
1442
  }
1424
1443
  if (sub === "undo" && operands.length === 3) return shown((data, columns, now) => renderBacklogResult("undo", data, columns, now))(taskOp("hub_backlog_undo", { id: Number(id), revision: Number(revision) }));
1425
- if (!["plan", "pin"].includes(sub ?? "") || !value || !reason.length) fail("planning needs fields and a reason");
1444
+ if (!["plan", "pin", "person"].includes(sub ?? "") || !value || !reason.length) fail("planning needs fields and a reason");
1426
1445
  let fields: unknown;
1427
- try { fields = sub === "pin" ? { pinned: value === "true" ? true : value === "false" ? false : undefined } : JSON.parse(value); }
1446
+ try { fields = sub === "pin" ? { pinned: value === "true" ? true : value === "false" ? false : undefined } : sub === "person" ? { needsPerson: value === "true" ? true : value === "false" ? false : undefined } : JSON.parse(value); }
1428
1447
  catch { return fail("planning fields must be JSON"); }
1429
- if (sub === "pin" && value !== "true" && value !== "false") fail("pin must be true or false");
1430
- return shown((data, columns, now) => renderBacklogResult(sub as "plan" | "pin", data, columns, now))(taskOp("hub_backlog_plan", { id: Number(id), revision: Number(revision), fields, reason: reason.join(" ") }));
1448
+ if ((sub === "pin" || sub === "person") && value !== "true" && value !== "false") fail(`${sub} must be true or false`);
1449
+ return shown((data, columns, now) => renderBacklogResult(sub === "person" ? "plan" : sub as "plan" | "pin", data, columns, now))(taskOp("hub_backlog_plan", { id: Number(id), revision: Number(revision), fields, reason: reason.join(" ") }));
1431
1450
  },
1432
1451
 
1433
1452
  release: async () => {
@@ -1927,9 +1946,11 @@ const commands: Record<string, () => Promise<void> | void> = {
1927
1946
  if (sub === "handoff") {
1928
1947
  // #604: work that belongs to ANOTHER project that runs agent-hub goes onto that project's board. The person's form (a console command: the classifier gives an agent `console` for it, and an
1929
1948
  // agent uses hub_task_handoff); the hub resolves the target, asks its daemon and records the answer. Nothing is queued: a target that is not running is named with the action.
1930
- const present = outputOptions(splitPresentation(rest, { valueFlags: ["--project", "--path", "--issue", "--task", "--detail"], edges: true }).flags);
1931
- const parsed = parseHandoffArgs(splitPresentation(rest, { valueFlags: ["--project", "--path", "--issue", "--task", "--detail"], edges: true }).operands);
1949
+ const present = outputOptions(splitPresentation(rest, { valueFlags: ["--project", "--path", "--issue", "--task", "--detail", "--duplicate-ok", "--key"], edges: true }).flags);
1950
+ const parsed = parseHandoffArgs(splitPresentation(rest, { valueFlags: ["--project", "--path", "--issue", "--task", "--detail", "--duplicate-ok", "--key"], edges: true }).operands);
1932
1951
  if ("usage" in parsed) return fail(parsed.usage);
1952
+ // #734: the person's list (age and reachability of each target) and sync (ask the targets now); both read the same rows `ahub board --handoffs` shows.
1953
+ if ("view" in parsed) return showToolText(data => renderHandoffs(data, present.columns), await taskOp(parsed.view === "sync" ? "hub_task_handoff_sync" : "hub_task_handoffs", {}), present);
1933
1954
  const text = await taskOp("hub_task_handoff", { ...parsed });
1934
1955
  return showToolText(data => renderHandoffReceipt(data, present.columns), text, present);
1935
1956
  }
@@ -2005,6 +2026,35 @@ const commands: Record<string, () => Promise<void> | void> = {
2005
2026
  } finally { hub.close(); }
2006
2027
  },
2007
2028
 
2029
+ peer: async () => {
2030
+ const options = outputOptions();
2031
+ const { operands, flags } = splitPresentation(args);
2032
+ const usage = "usage: ahub peer takeover <peer> [standby-id] [--json] [--color=auto|always|never]";
2033
+ const [sub, peer, standbyId, ...extra] = operands;
2034
+ const uuid = (value: string | undefined): value is string => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
2035
+ if (sub !== "takeover" || !peer || !/^[a-z][a-z0-9-]{0,31}$/.test(peer) || extra.length || standbyId !== undefined && !uuid(standbyId)
2036
+ || flags.some(flag => flag !== "--json" && !flag.startsWith("--color="))) fail(usage);
2037
+ const hub = await connect();
2038
+ try {
2039
+ if (!hub.features.includes("peer_takeover")) fail("running hub lacks peer_takeover; upgrade and restart the running hub first");
2040
+ let reply: any;
2041
+ try { reply = await hub.request({ t: "peer_takeover", peer, ...(standbyId ? { standbyId } : {}) }, 5000); }
2042
+ catch (error) {
2043
+ const message = String((error as Error).message ?? "");
2044
+ if (/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know \"peer_takeover\"/i.test(message)) fail(`${message}; upgrade and restart the running hub to use ahub peer takeover`);
2045
+ fail(`${message || "peer takeover result is unconfirmed"}; inspect ahub status before retrying; takeover may already have started`);
2046
+ }
2047
+ const transportError = String(reply.error ?? "");
2048
+ if (reply.ok === false && (transportError === "hub connection closed" || transportError === "hub request could not be sent" || /^no answer from the hub within \d+ s$/.test(transportError))) {
2049
+ fail(`${transportError}; inspect ahub status before retrying; takeover may already have started`);
2050
+ }
2051
+ if (reply.ok !== true) fail(String(reply.error ?? "peer takeover was refused"), reply.refusalAudited === true, reply.authorityRefused);
2052
+ if (reply.outcome !== "requested" || !uuid(reply.standbyId) || standbyId !== undefined && reply.standbyId !== standbyId) fail("peer takeover result was not confirmed; refresh ahub status");
2053
+ if (options.json) console.log(JSON.stringify(reply, null, 2));
2054
+ else console.log(`${peer}: old session detached; standby attachment requested`);
2055
+ } finally { hub.close(); }
2056
+ },
2057
+
2008
2058
  title: async () => {
2009
2059
  const [peer, ...words] = args;
2010
2060
  if (!peer || !words.length) fail("usage: ahub title <peer> <text...>");
@@ -2274,15 +2324,77 @@ const commands: Record<string, () => Promise<void> | void> = {
2274
2324
  hub.close();
2275
2325
  },
2276
2326
 
2327
+ // #1053 T4: the person's answer to a question a peer asked with hub_ask. No operands lists the open questions; `<set> <question>=<option>[,<option>] [--other <text>]` answers one.
2328
+ // The hub validates every answer against the stored row and names a refusal by position, never by the offered text. Console role only (classifyPeerCommand's default).
2329
+ answer: async () => {
2330
+ const options = outputOptions();
2331
+ const operands = args.filter(arg => arg !== "--json" && !arg.startsWith("--color=") && arg !== "--full");
2332
+ const hub = await connect();
2333
+ try {
2334
+ if (!operands.length) {
2335
+ const reply = await hub.request({ t: "questions" });
2336
+ if (reply.ok === false) return fail(String(reply.error ?? "questions are unavailable"));
2337
+ const rows = Array.isArray(reply.questions) ? reply.questions : [];
2338
+ if (options.json) return console.log(JSON.stringify(rows, null, 2));
2339
+ if (!rows.length) return console.log("no open questions");
2340
+ for (const row of rows) {
2341
+ console.log(`${row.set} ${row.peer} asks${row.private ? " (private)" : ""}`);
2342
+ for (const question of row.questions) {
2343
+ console.log(` ${question.id}: ${terminalText(String(question.prompt))}${question.multi ? " (choose any)" : ""}${question.allowOther ? " (or --other <text>)" : ""}`);
2344
+ for (const option of question.options) console.log(` ${option.id} ${terminalText(String(option.label))}${option.description ? ` - ${terminalText(String(option.description))}` : ""}`);
2345
+ }
2346
+ console.log(` answer with: ahub answer ${row.set} ${row.questions.map((question: { id: string; options: { id: string }[] }) => `${question.id}=${question.options[0]?.id ?? "<option>"}`).join(" ")}`);
2347
+ }
2348
+ return;
2349
+ }
2350
+ const parsed = parseAnswerArgs(operands);
2351
+ if (!parsed.ok) return fail(parsed.error);
2352
+ const reply = await hub.request({ t: "answer", surface: "terminal", questionSet: parsed.value.set, answers: parsed.value.answers });
2353
+ if (reply.ok === false) return fail(String(reply.error ?? "the answer was refused"));
2354
+ if (options.json) return console.log(JSON.stringify({ ok: true, questionSet: reply.questionSet, state: reply.state }));
2355
+ console.log(`answered ${reply.questionSet ?? parsed.value.set}`);
2356
+ } finally { hub.close(); }
2357
+ },
2358
+
2359
+ // #1054 T4: the results a run a person started left behind. A person's command (an agent shell is refused before the hub); reads change nothing, and only an explicit flag writes a receipt.
2360
+ results: async () => {
2361
+ const options = outputOptions();
2362
+ const parsed = parseResultsArgs(args.filter(arg => arg !== "--json" && !arg.startsWith("--color=") && arg !== "--full"));
2363
+ if (!parsed.ok) return fail(parsed.error);
2364
+ const request = parsed.value;
2365
+ const hub = await connect();
2366
+ try {
2367
+ if (request.action === "read") {
2368
+ const reply = await hub.request({ t: "result_seen", id: request.id, surface: "terminal" });
2369
+ if (reply.ok === false) return fail(String(reply.error ?? "the result could not be marked read"));
2370
+ return options.json ? console.log(JSON.stringify({ ok: true, id: request.id, read: true })) : console.log(`marked ${request.id} read`);
2371
+ }
2372
+ if (request.action === "read-all" || request.action === "unread-last") {
2373
+ const reply = await hub.request({ t: "results", action: request.action, surface: "terminal" });
2374
+ if (reply.ok === false) return fail(String(reply.error ?? "the receipt could not be written"));
2375
+ if (options.json) return console.log(JSON.stringify(request.action === "read-all" ? { ok: true, marked: reply.marked } : { ok: true, unread: reply.unread }));
2376
+ return console.log(request.action === "read-all" ? `marked ${reply.marked} ${reply.marked === 1 ? "result" : "results"} read` : `marked ${reply.unread} unread again`);
2377
+ }
2378
+ const reply = await hub.request({ t: "results", unread: request.unread });
2379
+ if (reply.ok === false) return fail(String(reply.error ?? "results are unavailable"));
2380
+ const rows = Array.isArray(reply.results) ? reply.results : [];
2381
+ if (options.json) return console.log(JSON.stringify(rows, null, 2));
2382
+ if (!rows.length) return console.log(request.unread ? "no unread results" : "no results");
2383
+ for (const row of rows) console.log(`${row.read ? " " : "*"} ${row.id} ${terminalText(String(row.title))} ${new Date(Number(row.settledAt)).toISOString().replace("T", " ").slice(0, 16)} ${Math.round(Number(row.ms) / 1000)}s${row.read ? "" : ` ahub results --read ${row.id}`}`);
2384
+ // The listing holds the newest 100. Unread results are never pruned, so when more exist than shown, say how many and how to reach them (status still counts every one).
2385
+ if (Number(reply.more) > 0) console.log(`... and ${Number(reply.more)} older unread; ahub results --read-all marks them all, or read the newest ones first`);
2386
+ } finally { hub.close(); }
2387
+ },
2388
+
2277
2389
  pet: async () => {
2278
2390
  if (args.includes("--window")) {
2279
2391
  if (args.some(arg => !["--window", "--close"].includes(arg))) return fail("usage: ahub pet --window [--close]");
2280
- return console.log(await (args.includes("--close") ? closePetWindow(stateDir) : startPetWindow(stateDir, cwd)));
2392
+ return console.log(PET_WINDOW_RETIRED_NOTICE);
2281
2393
  }
2282
2394
  if (args.includes("--palette")) {
2283
2395
  if (args.length !== 2 || args[0] !== "--palette") return fail("usage: ahub pet [--short] | --window [--close] | --palette <blue|teal|violet|ochre>");
2284
- try { return console.log(savePetPalette(cwd, args[1]!)); }
2285
- catch (error) { return fail((error as Error).message); }
2396
+ if (!petPalette(args[1])) return fail("Unknown pet palette; use blue, teal, violet or ochre (a/b/c/d).");
2397
+ return console.log(PET_WINDOW_RETIRED_NOTICE);
2286
2398
  }
2287
2399
  if (args.some(arg => arg !== "--short")) return fail("usage: ahub pet [--short] | --window [--close] | --palette <blue|teal|violet|ochre>");
2288
2400
  const short = args.includes("--short");
package/src/cli/memory.ts CHANGED
@@ -14,7 +14,7 @@ import { memoryAnchorTaskIdentity, memoryStaleAnchors, unavailableMemoryStaleAnc
14
14
  import { MEMORY_SYMBOL_START_BUDGET_MS } from "../hub/memory-symbols.ts";
15
15
  import { detectCliIdentity } from "./identity.ts";
16
16
  import { createStalenessProbe } from "../hub/memory-staleness.ts";
17
- import { looksSecret, memoryProjectKey, pathCandidates, readProfileJSON, resolveShardProfile, type MemoryStore } from "@staix/agent-mem";
17
+ import { looksSecret, memoryHome, memoryProjectKey, pathCandidates, readProfileJSON, resolveShardProfile, type MemoryStore } from "@staix/agent-mem";
18
18
  import type { SyncOptions } from "@staix/agent-mem";
19
19
  import { memoryUsage, goalUsage, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
20
20
 
@@ -24,9 +24,10 @@ import { mentionsPrivatePath, promotionScreen, withoutPii } from "../hub/memory-
24
24
  import { currentRouting, detectSignals } from "../hub/routing.ts";
25
25
  import { loadConfig } from "../hub/daemon.ts";
26
26
  import { bindProfileMemory, memoryProjectFor, memoryProjectForStore, openProfileMemory, MemoryProfileBinding, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
27
+ import { createMemorySyncPolicy, inspectDeclinedMemoryRows, projectMemorySyncText } from "../hub/memory-sync-policy.ts";
27
28
 
28
29
  export const GOAL_USAGE = goalUsage(AHUB_NAMES.goal);
29
- export const MEMORY_USAGE = `${memoryUsage(AHUB_NAMES.memory)} | decay [--days N] [--json] | profile-check [--json]`;
30
+ export const MEMORY_USAGE = `${memoryUsage(AHUB_NAMES.memory)} | decay [--days N] [--json] | declined [--json] | profile-check [--json]`;
30
31
 
31
32
  const fail = (message: string): never => { throw new Error(message); };
32
33
  /** The project's config, or none when there is no valid one (reading memory needs none). */
@@ -36,6 +37,75 @@ const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch
36
37
  /** The project key: an injected store resolves the alias in its own home, the default open in the ambient one (#240). */
37
38
  const projectOf = (cwd: string, provided: MemoryStore | undefined): string => provided ? memoryProjectForStore(provided, cwd) : memoryProjectFor(cwd);
38
39
 
40
+ /** Bounded, read-only lookup of current task paths that the daemon's promotion screen also considers. */
41
+ function memoryTaskPaths(cwd: string, boundProject: string, rowProject: string, key: string): string[] | undefined {
42
+ if (rowProject !== boundProject) return [];
43
+ const identity = memoryAnchorTaskIdentity(key, boundProject);
44
+ if (!identity) return [];
45
+ let db: Database | undefined;
46
+ try {
47
+ const file = join(stateDirFor(cwd), "hub.db");
48
+ let stat;
49
+ try { stat = lstatSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
50
+ if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
51
+ db = new Database(file, { readonly: true, create: false });
52
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
53
+ const row = db.query(`SELECT id,created,length(refs) AS refsLength,length(plan) AS planLength,
54
+ CASE WHEN length(refs)<=65536 THEN refs END AS refs,CASE WHEN length(plan)<=65536 THEN plan END AS plan
55
+ FROM tasks WHERE id=? AND created=?`).get(identity.id, identity.created) as { id: number; created: number; refsLength: number; planLength: number; refs: string | null; plan: string | null } | null;
56
+ if (!row || row.id !== identity.id || row.created !== identity.created) return [];
57
+ if (row.refsLength > 65536 || row.planLength > 65536 || typeof row.refs !== "string" || typeof row.plan !== "string") return undefined;
58
+ const refs: unknown = JSON.parse(row.refs), plan: unknown = JSON.parse(row.plan);
59
+ if (!refs || typeof refs !== "object" || Array.isArray(refs) || !plan || typeof plan !== "object" || Array.isArray(plan)) return undefined;
60
+ const refPaths = (refs as TaskRefs).paths ?? [], planPaths = (plan as TaskPlan).paths ?? [];
61
+ if (!Array.isArray(refPaths) || !refPaths.every(path => typeof path === "string") || !Array.isArray(planPaths) || !planPaths.every(path => typeof path === "string")) return undefined;
62
+ return [...refPaths, ...planPaths];
63
+ } catch { return undefined; }
64
+ finally { db?.close(); }
65
+ }
66
+
67
+ function hostSyncPolicyInputs(cwd: string, home: string, boundProject: string, profile?: MemoryProfileReceipt): { version: string; denyPaths: string[]; piiPaths: string[]; piiPatterns: string[]; routing: ReturnType<typeof currentRouting> } {
68
+ const config = loadConfig(cwd), routing = currentRouting(cwd);
69
+ const denyPaths = [...config.local.deny], piiPaths = [...(config.pii?.paths ?? [])], piiPatterns = [...routing.signals.pii_patterns];
70
+ return { version: JSON.stringify({ home, boundProject, profile: profile ? { profile: profile.profile, generation: profile.generation, selectorRevision: profile.selectorRevision } : null, denyPaths, piiPaths, piiPatterns }), denyPaths, piiPaths, piiPatterns, routing };
71
+ }
72
+ function createHubMemorySyncPolicy(cwd: string, home: string, boundProject: string, profile?: MemoryProfileReceipt) {
73
+ const inputs = hostSyncPolicyInputs(cwd, home, boundProject, profile);
74
+ const policy = createMemorySyncPolicy({
75
+ home, cwd, denyPaths: inputs.denyPaths, piiPatterns: inputs.piiPatterns,
76
+ hostScreen: row => {
77
+ const task = typeof row.task === "string" ? row.task : "";
78
+ const project = typeof row.project === "string" ? row.project : "";
79
+ const about = Array.isArray(row.about) ? row.about.filter((node): node is string => typeof node === "string") : [];
80
+ const taskPaths = memoryTaskPaths(cwd, boundProject, project, task);
81
+ if (task && taskPaths === undefined) return { ok: false, rule: "hub privacy policy unavailable" };
82
+ const enrichedAbout = [...about, ...(taskPaths ?? []).map(path => `path:${path}`)];
83
+ const visible = withoutPii([{
84
+ title: typeof row.title === "string" ? row.title : null,
85
+ text: JSON.stringify(row), about: enrichedAbout,
86
+ ...(task ? { task } : {}), ...(project ? { project } : {}),
87
+ }], inputs.routing, cwd, inputs.piiPaths).length > 0;
88
+ return visible ? { ok: true } : { ok: false, rule: "hub privacy policy: PII pattern or private path" };
89
+ },
90
+ });
91
+ return { policy, version: inputs.version };
92
+ }
93
+
94
+ /** Preserve any trusted caller's stricter sync screen and bind its epoch into the host policy stamp. */
95
+ export function composeMemorySyncOptions(host: Pick<SyncOptions, "screen" | "policyVersion">, caller?: Omit<SyncOptions, "remote">, canonical = false): Omit<SyncOptions, "remote"> {
96
+ return {
97
+ ...caller,
98
+ screen: row => {
99
+ try {
100
+ const content = projectMemorySyncText(row, canonical);
101
+ return host.screen?.(content) === true && (caller?.screen ? caller.screen(content) === true : true);
102
+ }
103
+ catch { return false; }
104
+ },
105
+ policyVersion: () => JSON.stringify([host.policyVersion?.() ?? null, caller?.policyVersion?.() ?? null]),
106
+ };
107
+ }
108
+
39
109
  /** Bind each command at entry; the CLI closes default opens and injected stores remain caller-owned. */
40
110
  function commandStores(provided: MemoryStore | undefined, options: ProfileMemoryOptions, cwd: string) {
41
111
  try { return { ...(provided ? bindProfileMemory(provided, undefined, memoryProjectKey(cwd)) : openProfileMemory(options)), owned: !provided }; }
@@ -86,13 +156,47 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
86
156
  return profileOutput(text, false, profile, person);
87
157
  } finally { if (context.owned) store.close(); }
88
158
  }
159
+ if (rest[0] === "declined") {
160
+ if (!person) fail("memory declined requires person authority");
161
+ if (rest.length !== 1) fail("usage: ahub memory declined [--json]");
162
+ const project = projectOf(cwd, opts.store);
163
+ const context = commandStores(opts.store, { project, deferPromotions: true, now: opts.now }, cwd);
164
+ try {
165
+ context.binding.assertCurrent();
166
+ const boundPolicy = createHubMemorySyncPolicy(cwd, context.binding.home, project, context.binding.status(context.store));
167
+ const result = inspectDeclinedMemoryRows(context.store, { screen: (row, canonical) => {
168
+ const content = projectMemorySyncText(row, canonical);
169
+ const host = boundPolicy.policy.screen(content, true);
170
+ if (!host.ok) return host;
171
+ try { return opts.sync?.screen ? (opts.sync.screen(content) === true ? { ok: true } : { ok: false, rule: "caller sync policy" }) : { ok: true }; }
172
+ catch { return { ok: false, rule: "caller sync policy" }; }
173
+ } }, () => JSON.stringify([boundPolicy.version, opts.sync?.policyVersion?.() ?? null]));
174
+ context.binding.assertCurrent();
175
+ const profile = context.binding.status(context.store);
176
+ if (hostSyncPolicyInputs(cwd, context.binding.home, project, profile).version !== boundPolicy.version)
177
+ fail("memory declined policy changed during inspection; retry with stable privacy policy");
178
+ if (args.includes("--json")) return JSON.stringify({ rows: result.rows, complete: result.complete, ...(result.reason ? { reason: result.reason } : {}), memoryProfile: profile });
179
+ const text = result.complete
180
+ ? result.rows.length
181
+ ? [`${result.rows.length} declined physical outbound row${result.rows.length === 1 ? "" : "s"}:`, ...result.rows.map(row => `mem:${row.id} ${row.rule}`), "Review the source or approved policy, then run `ahub memory sync`. `forget` does not remove append-only physical rows."].join("\n")
182
+ : "no declined physical outbound rows; inspection complete"
183
+ : [`declined-row inspection incomplete: ${result.reason ?? "physical memory rows could not be inspected"}`, ...(result.rows.length ? [`${result.rows.length} declined row${result.rows.length === 1 ? "" : "s"} found before inspection stopped:`, ...result.rows.map(row => `mem:${row.id} ${row.rule}`)] : []), "No complete result is available; resolve the inspection issue before treating the list as empty."].join("\n");
184
+ return text;
185
+ } finally { if (context.owned) context.store.close(); }
186
+ }
89
187
  let showStale = false;
90
188
  const project = projectOf(cwd, opts.store);
189
+ let policyHome = opts.store?.profile.home ?? memoryHome();
91
190
  const context = commandStores(opts.store, { project, ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000,
92
- screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths) }, cwd);
191
+ screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths, task => memoryTaskPaths(cwd, project, project, task) ?? [], () => createHubMemorySyncPolicy(cwd, policyHome, project).policy) }, cwd);
192
+ policyHome = context.binding.home;
93
193
  try {
94
194
  context.binding.assertCurrent();
195
+ let boundSyncPolicy: ReturnType<typeof createHubMemorySyncPolicy> | undefined;
196
+ const syncPolicy = () => boundSyncPolicy ??= createHubMemorySyncPolicy(cwd, context.binding.home, project, context.binding.status(context.store));
95
197
  const text = await rawMemoryCommand(args, cwd, { ...opts, names: AHUB_NAMES, project, store: context.store, open: () => context.store,
198
+ sync: composeMemorySyncOptions({ screen: row => syncPolicy().policy.screen(row, true).ok,
199
+ policyVersion: () => { context.binding.assertCurrent(); return hostSyncPolicyInputs(cwd, context.binding.home, project, context.binding.status(context.store)).version; } }, opts.sync, true),
96
200
  readable: notes => {
97
201
  const paths = privatePaths(), visible = withoutPii(notes, currentRouting(cwd), cwd, paths);
98
202
  if (args[0] !== "show" || args.includes("--json")) return visible;
package/src/cli/output.ts CHANGED
@@ -5,7 +5,7 @@ import { boardColumns, type BoardPeerInput } from "../ui/task-board.ts";
5
5
  import { peerStyle } from "../ui/peer-palette.ts";
6
6
  import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
7
7
  import { taskProgress } from "../ui/task-progress.ts";
8
- import { backendLabel } from "./status-lines.ts";
8
+ import { backendLabel, memorySyncLine, reviewInputHealthLine } from "./status-lines.ts";
9
9
  import { ownerIsolationText } from "./role-holders.ts";
10
10
  import { schedulePeriodText, scheduleWhen } from "../hub/schedules.ts";
11
11
  import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
@@ -17,6 +17,8 @@ export interface StatusOutput {
17
17
  controlWriteTiming?: { active: number; slowRequests: number; blockedLoopSamples: number; droppedActive: number; droppedRecent: number; droppedLogs: number; droppedSpans?: number; droppedMetadata?: number; expiredRequests?: number };
18
18
  specs?: SpecsStatus & { missingBlockHint?: boolean };
19
19
  queueBackpressure?: QueueBackpressureStatus;
20
+ /** Unresolved failed delivery counts, absent when an older hub or storage cannot answer. */
21
+ deliveryFailures?: Record<string, number>;
20
22
  pendingRestart?: readonly { key: string; reason: string }[];
21
23
  retainedConfig?: readonly { key: string; reason: string }[];
22
24
  /** #572 AC3: advisory, present while the project root checkout is on a branch other than the default. */
@@ -29,12 +31,12 @@ export interface StatusOutput {
29
31
  pendingTaskOffers?: readonly { task: number; peer: string }[];
30
32
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
31
33
  memoryRead?: { truncated: boolean; scanned: number; limit: number };
32
- memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
34
+ memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string; sync?: { state: "disabled" | "unavailable" | "notrun" | "not-configured" | "stopped" | "ok" | "failed" | "unknown"; at?: number; cause?: string; declinedCount?: number; declinedIdsKnown?: false; withheldCount?: number; exportedCount?: number; placementActive?: false; nextStep?: string } };
33
35
  /** #784 AC3: `active`, `reviewWait` and `reserved` add up to `wip`; absent from an older hub. */
34
36
  taskWip?: Record<string, { wip: number; wipLimit: number; active?: number; reviewWait?: number; reserved?: number }>;
35
37
  pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; actionPolicyInfo?: Record<string, { note: string; enforceable: boolean }>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
36
38
  ownerIsolation?: Record<string, boolean>; ownerIsolationNotes?: Record<string, string>;
37
- tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
39
+ tasks?: Record<string, number>; personRun?: number[]; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; reviewInputHealth?: { state?: string; cause?: string | null }; crash?: string[];
38
40
  }
39
41
  /** `peers`: one note per peer that shows no number, and why (no quota, nothing read yet); absent from an older hub. */
40
42
  export interface BudgetOutput { budget: Record<string, any>; gate?: number; peers?: { peer: string; kind?: string; note: string }[] }
@@ -252,6 +254,8 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
252
254
  const memory = data.memoryProfile;
253
255
  const profile = memory.profile === "generation" ? `generation ${memory.generation?.slice(0, 8) ?? "unknown"}` : memory.profile ?? "unbound";
254
256
  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));
257
+ const sync = memory.sync;
258
+ out.push(...detail("memory sync", memorySyncLine(sync ?? { state: "unknown" }), columns, sync?.state === "failed" ? "attention" : undefined));
255
259
  }
256
260
  if (data.memoryRead?.truncated) out.push(...detail("memory view", `truncated: newest ${data.memoryRead.limit} project candidates considered`, columns, "attention"));
257
261
  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));
@@ -260,6 +264,13 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
260
264
  if (text) out.push(...detail(`${id} owner isolation`, text, columns, isolated ? undefined : "attention"));
261
265
  }
262
266
  if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
267
+ for (const [peer, count] of Object.entries(data.deliveryFailures ?? {})) {
268
+ if (Number.isSafeInteger(count) && count > 0) out.push(...detail(`${peer} failed deliveries`, `${count}; next: ahub queue discard --peer ${peer} --state failed`, columns, "attention"));
269
+ }
270
+ if (data.reviewInputHealth) {
271
+ const health = reviewInputHealthLine(data.reviewInputHealth);
272
+ if (health) out.push(...detail("review input provenance", health, columns, data.reviewInputHealth.state === "corrupt" ? "failure" : "attention"));
273
+ }
263
274
  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"));
264
275
  for (const crash of data.crash ?? []) out.push(...detail("crash recovery", crash, columns, "attention"));
265
276
  for (const warning of data.isolatedChecks?.orphans ?? []) out.push(...detail("isolated check warning", warning, columns, "attention"));
@@ -275,11 +286,18 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
275
286
  let settling = false;
276
287
  const peerDetails = Object.entries(data.peers).map(([id, p]) => {
277
288
  const out: Span[][] = [];
289
+ for (const session of Array.isArray(p.standbySessions) ? p.standbySessions : []) {
290
+ if (!session || typeof session.id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(session.id)) continue;
291
+ const pid = Number.isSafeInteger(session.pid) && session.pid > 0 ? String(session.pid) : "unknown";
292
+ const since = Number.isFinite(session.since) ? `; waiting ${relative(session.since, now)}` : "";
293
+ out.push(...detail(`${id} standby`, `pid ${pid}; session ${session.id}${since}; ahub peer takeover ${id} ${session.id}`, columns, "attention"));
294
+ }
278
295
  if (typeof p.form === "string") out.push(...detail(`${id} form`, p.form, columns));
279
296
  if (Array.isArray(p.capableRoles)) out.push(...detail(`${id} capable roles`, p.capableRoles.join(", ") || "none", columns));
280
297
  if (p.flowRoles) out.push(...detail(`${id} flow roles`, p.flowRoles.join(", ") || "none", columns));
281
298
  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")); }
282
299
  if (p.liveAccepted?.length) { settling = true; out.push(...detail("settling", p.liveAccepted.map((v: string) => shortId(v, columns, full)).join(", "), columns, "attention")); }
300
+ if (typeof p.reviewObserverHoldNote === "string" && p.reviewObserverHoldNote) out.push(...detail(`${id} review input`, p.reviewObserverHoldNote, columns, "attention"));
283
301
  if (p.heldRows?.length) {
284
302
  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"));
285
303
  } else if (p.heldBy) {
@@ -297,6 +315,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
297
315
  : flat(p.paused).replace(/^(budget: .*), resets .+$/, "$1; reset unknown");
298
316
  out.push(...detail("paused", pauseText, columns, "attention"));
299
317
  }
318
+ if (p.attachMode === "channel" || p.attachMode === "tools-only") out.push(...detail("attach", `${p.attachMode}; evidence: ${flat(p.attachEvidence ?? "unknown")}`, columns, p.attachMode === "channel" ? "muted" : "attention"));
300
319
  if (p.toolsOnly) out.push(...detail(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") && p.pullOnly ? "pull-only" : "tools-only", flat(p.toolsOnly).replace(/^(?:tools-only|pull-only):\s*/, ""), columns, "attention"));
301
320
  if (p.pullOnly && definitionOf(id)?.attach?.pullOnlyForms?.includes("tui")) out.push(...detail(`${id} last read`, typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
302
321
  const actionPolicy = data.actionPolicyInfo?.[id];
@@ -322,6 +341,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
322
341
  }
323
342
  const counts = Object.entries(data.tasks ?? {}).map(([state, count]) => `${count} ${state}`).join(", ");
324
343
  if (counts) out.push([], ...lines(`TASKS ${counts} (ahub board)`, columns));
344
+ if (data.personRun?.length) out.push(...lines(`WAITING ON YOU ${data.personRun.map(id => `#${id}`).join(", ")} (person-run: no agent is offered it; ahub backlog list)`, columns, "attention"));
325
345
  return out;
326
346
  }
327
347
  // #724: short next-step codes for the compact board, keyed on the blocker texts of src/hub/sdlc.ts; the full advice stays in --full and ahub task show.
@@ -345,7 +365,7 @@ export function renderBacklog(cards: any[], columns?: number, now = Date.now(),
345
365
  let table: OutputCell[][] = sorted.map(card => {
346
366
  const deps: number[] = Array.isArray(card.deps) ? card.deps : [];
347
367
  return [span(typeof card.rank === "number" ? card.rank : "-", "muted", true), span(`#${card.id}`, "info", true), span(typeof card.priority === "number" ? `P${card.priority}` : "-", undefined, true),
348
- span(card.reserved ?? "-", peerTone(card.reserved ?? ""), true), span(deps.length ? (full || columns === undefined ? deps.map(id => `#${id}`).join(",") : `#${deps[0]}${deps.length > 1 ? `,+${deps.length - 1}` : ""}`) : "-", undefined, true), span(card.class ?? "-", undefined, true),
368
+ span(card.needsPerson === true ? `${card.reserved ?? ""}${card.reserved ? " " : ""}(person)` : card.reserved ?? "-", card.needsPerson === true ? "attention" : peerTone(card.reserved ?? ""), true), span(deps.length ? (full || columns === undefined ? deps.map(id => `#${id}`).join(",") : `#${deps[0]}${deps.length > 1 ? `,+${deps.length - 1}` : ""}`) : "-", undefined, true), span(card.class ?? "-", undefined, true),
349
369
  span(typeof card.created === "number" ? duration(now - card.created) : "-", "muted", true), span(flat(card.title))];
350
370
  });
351
371
  if (!full && columns !== undefined) {
@@ -357,8 +377,10 @@ export function renderBacklog(cards: any[], columns?: number, now = Date.now(),
357
377
  table = table.map(row => [...row.slice(0, -1), { ...row[row.length - 1]!, text: ellipsis(row[row.length - 1]!.text, titleRoom) }]);
358
378
  }
359
379
  const owners = new Map<string, number>();
360
- for (const card of sorted) owners.set(card.reserved ?? "unreserved", (owners.get(card.reserved ?? "unreserved") ?? 0) + 1);
361
- const summary = `${plural(sorted.length, "backlog card")}; reserved: ${[...owners].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([owner, n]) => `${owner} ${n}`).join(", ")}`;
380
+ const person = sorted.filter(card => card.needsPerson === true);
381
+ for (const card of sorted.filter(card => card.needsPerson !== true)) owners.set(card.reserved ?? "unreserved", (owners.get(card.reserved ?? "unreserved") ?? 0) + 1);
382
+ const summary = `${plural(sorted.length, "backlog card")}; reserved: ${[...owners].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([owner, n]) => `${owner} ${n}`).join(", ") || "none"}`
383
+ + (person.length ? `; waiting on you: ${person.map(card => `#${card.id}`).join(", ")} (a person-run leg; no agent is offered it)` : "");
362
384
  return [...rows(names, table, columns), [], ...lines(summary, columns)];
363
385
  }
364
386
  /** #724: one line per task. TITLE goes last and is cut to the room left; at a narrow width REVIEWER, CLASS and AGE give way first. */
@@ -545,6 +567,7 @@ function fields(value: unknown, columns: number | undefined, now: number, prefix
545
567
  }
546
568
  export function renderQueueShow(delivery: any, columns?: number, now = Date.now()): Span[][] {
547
569
  const out = [...lines("Delivery", columns, "strong"), ...fields(delivery, columns, now)];
570
+ if (delivery?.state === "failed" && delivery.failureCause === undefined) out.push(...detail("failure cause", "unknown (not recorded by this hub version)", columns, "muted"));
548
571
  if (delivery?.state === "needs_review" && delivery.id) out.push([], ...detail("resolve", `ahub queue resolve ${delivery.id} --action completed|retry|discard --reason <text>`, columns, "failure"));
549
572
  // #1096: a failed row that no one has settled can be cleared by the person or the conductor; one a resolution already settled says what is left (nothing).
550
573
  else if (delivery?.state === "failed" && delivery.id) {
package/src/cli/pet.ts CHANGED
@@ -18,7 +18,7 @@ export function parseNeedsSummary(raw: unknown): NeedsSummary | undefined {
18
18
  if (row.first !== undefined) {
19
19
  if (!row.first || typeof row.first !== "object" || Array.isArray(row.first)) return;
20
20
  const item = row.first as Record<string, unknown>;
21
- if (typeof item.kind !== "string" || typeof item.level !== "string" || !["approval", "default", "storage", "recovery", "delivery", "review", "budget", "offline"].includes(item.kind) || !["act", "watch"].includes(item.level)) return;
21
+ if (typeof item.kind !== "string" || typeof item.level !== "string" || !["approval", "default", "question", "storage", "recovery", "delivery", "review", "result", "budget", "offline"].includes(item.kind) || !["act", "watch"].includes(item.level)) return;
22
22
  if (item.peer !== undefined && !id(item.peer)) return;
23
23
  if (item.id !== undefined && !id(item.id) && !(count(item.id) && item.id > 0)) return;
24
24
  if (item.until !== undefined && !count(item.until) || item.since !== undefined && !count(item.since)) return;
@@ -29,8 +29,8 @@ export function parseNeedsSummary(raw: unknown): NeedsSummary | undefined {
29
29
  if ((row.act + row.watch > 0) !== !!first) return;
30
30
  if (row.act > 0 && first?.level !== "act" || row.act === 0 && first?.level === "act") return;
31
31
  if (row.act === 0 && ["waiting", "blocked", "review"].includes(row.state) || row.act > 0 && ["working", "idle", "sleeping"].includes(row.state)) return;
32
- if (first?.level === "watch" && !["recovery", "budget", "offline"].includes(first.kind) || first?.level === "act" && ["budget", "offline"].includes(first.kind)) return;
33
- if (row.state === "waiting" && !["approval", "default"].includes(first?.kind ?? "")) return;
32
+ if (first?.level === "watch" && !["recovery", "result", "budget", "offline"].includes(first.kind) || first?.level === "act" && ["budget", "offline"].includes(first.kind)) return;
33
+ if (row.state === "waiting" && !["approval", "default", "question"].includes(first?.kind ?? "")) return;
34
34
  if (row.state === "blocked" && !["storage", "recovery", "delivery"].includes(first?.kind ?? "")) return;
35
35
  if (row.state === "review" && first?.kind !== "review") return;
36
36
  return { act: row.act, watch: row.watch, state: row.state as NeedsSummary["state"], ...(first ? { first } : {}) };
@@ -57,7 +57,7 @@ export function petView(status: unknown, reader: PetRecoveryReader = recoveryRea
57
57
  const included = base && reported && typeof reported === "object" && (reported as Record<string, unknown>).operationId === operation && base.watch > 0 ? 1 : 0;
58
58
  const act = (base?.act ?? 0) + (active ? 0 : 1), watch = (base?.watch ?? 0) - included + (active ? 1 : 0);
59
59
  const first = base?.first;
60
- const precedes = !active && (!first || first.level === "watch" || !["approval", "default", "storage"].includes(first.kind));
60
+ const precedes = !active && (!first || first.level === "watch" || !["approval", "default", "question", "storage"].includes(first.kind));
61
61
  const selected = precedes || first?.kind === "recovery" && first.id === operation ? item : first ?? item;
62
62
  const state = !active && base?.state !== "waiting" ? "blocked" : base?.state ?? "sleeping";
63
63
  return { summary: { act, watch, state, first: selected }, unavailable: !base, ...(runner === "unknown" ? { runnerUnknown: true } : {}) };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * #1054 T4: `ahub results [--unread] [--read <id> | --read-all | --unread-last]`. Pure: argv to one closed request, never a decision about whether it applies (the hub checks
3
+ * that a result exists and is unread). At most one action flag; `--unread` only narrows the list and takes no action.
4
+ */
5
+ export const RESULT_ID = /^cmp-[a-f0-9]{12}$/;
6
+ export type ResultsRequest = { action: "list"; unread: boolean } | { action: "read"; id: string } | { action: "read-all" } | { action: "unread-last" };
7
+ export type ResultsArgs = { ok: true; value: ResultsRequest } | { ok: false; error: string };
8
+ export const RESULTS_USAGE = "usage: ahub results [--unread] [--read <id> | --read-all | --unread-last] [--json] [--color=auto|always|never]";
9
+
10
+ export function parseResultsArgs(args: readonly string[]): ResultsArgs {
11
+ const fail = (error: string): { ok: false; error: string } => ({ ok: false, error: `${RESULTS_USAGE}; ${error}` });
12
+ let unread = false;
13
+ const actions: ResultsRequest[] = [];
14
+ for (let index = 0; index < args.length; index++) {
15
+ const word = args[index]!;
16
+ if (word === "--unread") { unread = true; continue; }
17
+ if (word === "--read-all") { actions.push({ action: "read-all" }); continue; }
18
+ if (word === "--unread-last") { actions.push({ action: "unread-last" }); continue; }
19
+ if (word === "--read" || word.startsWith("--read=")) {
20
+ const id = word === "--read" ? args[++index] : word.slice("--read=".length);
21
+ if (id === undefined || !RESULT_ID.test(id)) return fail("--read needs a result id like cmp-0123456789ab (ahub results lists them)");
22
+ actions.push({ action: "read", id });
23
+ continue;
24
+ }
25
+ return fail(word.startsWith("--") ? `unknown flag ${word.split("=")[0]}` : `unexpected operand ${word.slice(0, 40)}`);
26
+ }
27
+ if (actions.length > 1) return fail("give one of --read, --read-all and --unread-last");
28
+ if (actions.length === 1) { if (unread) return fail("--unread only narrows the list; it does not combine with an action"); return { ok: true, value: actions[0]! }; }
29
+ return { ok: true, value: { action: "list", unread } };
30
+ }