@staix/agent-hub 0.26.0 → 0.28.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 (101) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +13 -4
  3. package/docs/agent-notes/adapters.md +4 -2
  4. package/docs/agent-notes/bus.md +4 -4
  5. package/docs/agent-notes/daemon.md +8 -4
  6. package/docs/agent-notes/tasks.md +7 -2
  7. package/docs/agent-notes/tests.md +6 -5
  8. package/docs/design/adapters.md +76 -0
  9. package/docs/design/agent-hub-architecture.md +67 -0
  10. package/docs/design/agent-hub.md +52 -0
  11. package/docs/design/bus.md +48 -0
  12. package/docs/design/task-board.md +86 -0
  13. package/docs/dogfood.md +19 -2
  14. package/docs/operations.md +337 -15
  15. package/docs/profiles.md +2 -0
  16. package/docs/quickstart.md +15 -3
  17. package/docs/sdlc.md +4 -4
  18. package/docs/security.md +9 -4
  19. package/docs/smoke.md +2 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +3 -1
  21. package/docs/specs/2026-11-01-research-campaigns-design.md +12 -1
  22. package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-11-01-0.27.0.md +49 -0
  24. package/docs/verification/2026-11-02-0.28.0.md +54 -0
  25. package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
  26. package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
  27. package/docs/verification/2026-11-02-control-write-after.json +139 -0
  28. package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
  29. package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
  30. package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
  31. package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
  32. package/docs/verified.json +13 -13
  33. package/package.json +2 -2
  34. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  35. package/plugins/agent-hub/server.js +344 -116
  36. package/src/adapters/claude-channel.ts +12 -2
  37. package/src/adapters/compat.ts +1 -1
  38. package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
  39. package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
  40. package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
  41. package/src/adapters/jcode.ts +37 -4
  42. package/src/adapters/qwen-dual.ts +3 -2
  43. package/src/cli/agents.ts +4 -2
  44. package/src/cli/bench.ts +4 -2
  45. package/src/cli/board-read.ts +25 -0
  46. package/src/cli/claude-homes.ts +2 -2
  47. package/src/cli/console-state.ts +8 -3
  48. package/src/cli/help.ts +8 -6
  49. package/src/cli/identity.ts +14 -6
  50. package/src/cli/init-prompt.ts +179 -0
  51. package/src/cli/init-settings.ts +229 -0
  52. package/src/cli/init.ts +102 -9
  53. package/src/cli/launch.ts +3 -1
  54. package/src/cli/main.ts +112 -36
  55. package/src/cli/output.ts +14 -0
  56. package/src/cli/setup-homes.ts +18 -3
  57. package/src/cli/specs.ts +150 -0
  58. package/src/cli/study-output.ts +56 -0
  59. package/src/cli/study.ts +26 -3
  60. package/src/cli/terminal-recovery.ts +35 -11
  61. package/src/hub/action-policy.ts +5 -3
  62. package/src/hub/board.ts +56 -3
  63. package/src/hub/bus.ts +54 -7
  64. package/src/hub/child-process.ts +6 -4
  65. package/src/hub/config-apply.ts +4 -1
  66. package/src/hub/config-edit.ts +1 -1
  67. package/src/hub/config-tier.ts +1 -0
  68. package/src/hub/control-client.ts +28 -4
  69. package/src/hub/cost-governance.ts +22 -6
  70. package/src/hub/crash.ts +20 -0
  71. package/src/hub/daemon.ts +324 -64
  72. package/src/hub/delivery-hints.ts +6 -3
  73. package/src/hub/delivery-journal.ts +14 -3
  74. package/src/hub/events.ts +2 -1
  75. package/src/hub/hub-tools.ts +15 -9
  76. package/src/hub/no-writes.ts +1 -1
  77. package/src/hub/peers.ts +1 -1
  78. package/src/hub/project-runtime-state.ts +38 -13
  79. package/src/hub/review-brief.ts +18 -0
  80. package/src/hub/routing.ts +7 -3
  81. package/src/hub/sdlc-release-authorization.ts +11 -5
  82. package/src/hub/session-identity.ts +1 -1
  83. package/src/hub/settings.ts +2 -0
  84. package/src/hub/specs-config.ts +43 -0
  85. package/src/hub/specs-task.ts +108 -0
  86. package/src/hub/specs.ts +1008 -0
  87. package/src/hub/task-list-page.ts +75 -0
  88. package/src/hub/task-sweep.ts +10 -1
  89. package/src/hub/tasks.ts +448 -40
  90. package/src/hub/terminal-open.ts +10 -5
  91. package/src/hub/tool-request.ts +131 -0
  92. package/src/ui/office-model.ts +17 -1
  93. package/src/ui/office-space.ts +1 -1
  94. package/templates/config.json +1 -0
  95. package/templates/presets/confirmation-matrix.json +5 -5
  96. package/templates/profiles/dev.json +1 -0
  97. package/templates/profiles/general.json +1 -0
  98. package/templates/profiles/research.json +1 -0
  99. package/templates/profiles/writing.json +1 -0
  100. package/templates/release-guard.block.md +2 -1
  101. package/templates/specs.block.md +5 -0
package/src/hub/daemon.ts CHANGED
@@ -38,11 +38,13 @@ import { isDenied, sbplString } from "../local/deny.ts";
38
38
  import { hostShellPath } from "./host-executable.ts";
39
39
  import { supersededDeliveryReason, deliveryTaskContext } from "./delivery-superseded.ts";
40
40
  import { renderHeldHint } from "./delivery-hints.ts";
41
+ import { compactRow, pageTasks, parseListQuery } from "./task-list-page.ts";
41
42
  import { correctionInput, MemoryTelemetry } from "./memory-candidates.ts";
42
43
  import { MemoryObservations } from "./memory-observations.ts";
43
44
  import { createStalenessProbe } from "./memory-staleness.ts";
44
45
  import { matchingPlanFails, planGuardNodes, taskMemoryQueryNodes } from "./memory-plan-guard.ts";
45
46
  import { planningWipLimits } from "./planning.ts";
47
+ import { ToolWriteRegistry, toolWriteOperation, toolWriteUnknownReply } from "./tool-request.ts";
46
48
  import { projectedDiff } from "./projected-diff.ts";
47
49
  import { listenWithoutPortCollision } from "./listen.ts";
48
50
  import { needs, type NeedsInput } from "../ui/needs.ts";
@@ -54,7 +56,7 @@ import { buildReviewBrief, withCodeSymbols } from "./review-brief.ts";
54
56
  import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
55
57
  import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
56
58
  import { ActivityRing } from "./activity.ts";
57
- import { JcodeOptionMismatch, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
59
+ import { JcodeOptionMismatch, JcodeTuiRefusal, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
58
60
  import { VERSION_REFUSAL, configuredCommands, describe, isCompatId } from "../adapters/compat.ts";
59
61
  import { agentVersionsAsync, type AgentVersionRow } from "../cli/agents.ts";
60
62
  import { driftedAgents, noticeDrift, type DriftedAgent } from "./agent-drift.ts";
@@ -104,6 +106,7 @@ import { conductorRecoveryBinding } from "../cli/conductor-recovery.ts";
104
106
  import { createConfigEditor } from "./config-edit-host.ts";
105
107
  import { readEditable } from "./config-edit.ts";
106
108
  import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
109
+ import { SpecTaskInspector } from "./specs-task.ts";
107
110
  import { KimiUsage, kimiSeedRows } from "./kimi-usage.ts";
108
111
  import { TokenWindow } from "./token-window.ts";
109
112
  import { budgetNotes, type BudgetPeerNote } from "./budget-notes.ts";
@@ -193,7 +196,7 @@ import { projectChain } from "@staix/agent-mem";
193
196
  import { recallFor } from "../memory/recall.ts";
194
197
  import { conflictsOf } from "./conflicts.ts";
195
198
  import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
196
- import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
199
+ import { crashPlan, lossNotice, openCrashNotices, readSessions, removeSessions, writeSessions, type CrashNotice, type SessionsFile } from "./crash.ts";
197
200
  import type { JournalDelivery } from "./delivery-journal.ts";
198
201
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
199
202
  import { soleWorktree, branchHeads, changedPaths, dirtySubmodules, submodulePaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, budgetNow, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
@@ -218,6 +221,7 @@ import { SourceRegistry } from "./sources.ts";
218
221
  import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead, sourceRead, validateSourceRead } from "./read-tools.ts";
219
222
  import { nativeRecall } from "../memory/recall.ts";
220
223
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
224
+ import { DEFAULT_SPECS_CONFIG, parseSpecsConfig, type SpecsConfig } from "./specs-config.ts";
221
225
 
222
226
  export interface HubConfig {
223
227
  adapter_modules?: readonly AdapterModuleDeclaration[];
@@ -260,6 +264,7 @@ export interface HubConfig {
260
264
  text_projection?: ProjectionConfig;
261
265
  /** #510: machine-local folders of plain notes the hub may index, read-only; vault folders are refused when the index reads them. */
262
266
  corpus: { paths: string[] };
267
+ specs: SpecsConfig;
263
268
  catalog_roles?: Record<string, string[]>;
264
269
  conductor: { feed: "own" | "all" | "off"; approval_wait_s: number };
265
270
  budget: BudgetConfig;
@@ -352,6 +357,7 @@ export const DEFAULT_CONFIG: HubConfig = {
352
357
  schedules: [],
353
358
  folder_triggers: [],
354
359
  corpus: { paths: [] },
360
+ specs: { ...DEFAULT_SPECS_CONFIG, roots: [] },
355
361
  checks: { timeout_s: 600 },
356
362
  // Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
357
363
  snapshots: { enabled: false, keep: 20 },
@@ -370,14 +376,14 @@ export const DEFAULT_CONFIG: HubConfig = {
370
376
  export { stateDirFor };
371
377
 
372
378
  /** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
373
- const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
379
+ const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status", "hub_specs"]);
374
380
  const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
375
381
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
376
382
 
377
383
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
378
384
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
379
385
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
380
- const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet"];
386
+ const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "specs", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet"];
381
387
 
382
388
  /** Older targets parse raw keys before placement/trust checks, so even an empty or refused declaration needs the floor. */
383
389
  function rawResearchRunnersPresent(cwd: string): boolean {
@@ -483,6 +489,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
483
489
  resolveProfile(file.profile, file.profile_overrides);
484
490
  projectionConfig(file.text_projection);
485
491
  corpusConfig(file.corpus);
492
+ if (file.specs !== undefined) parseSpecsConfig(file.specs);
486
493
  queueBackpressurePolicy(file.tasks?.queue_backpressure);
487
494
  validateOperationalConfig(file);
488
495
  for (const [block, key] of [["pii", "paths"], ["data", "readonly"]] as const) {
@@ -599,6 +606,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
599
606
  schedules: schedulesConfig(file.schedules),
600
607
  folder_triggers: foldersConfig(file.folder_triggers),
601
608
  corpus: corpusConfig(file.corpus),
609
+ specs: parseSpecsConfig(file.specs),
602
610
  checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "remote" ? "remote" : file.checks?.runner === "container" ? "container" : undefined,
603
611
  ...(file.checks?.remote !== undefined || file.checks?.runner === "remote" ? { remote: remoteRunnerConfig(file.checks?.remote) } : {}) },
604
612
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
@@ -656,6 +664,8 @@ export interface DaemonOptions {
656
664
  releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
657
665
  /** Host-only (#285): Kimi's data home for its wire-log usage reader; default `KIMI_CODE_HOME`, else `~/.kimi-code`. Never read from config or tools. */
658
666
  kimiHome?: string;
667
+ /** Host-only replay-window test ceiling (1..512); never read from project config or a tool. */
668
+ toolWriteReplayMaxEntries?: number;
659
669
  /** Host-only evidence seam; never supplied by project config or control messages. */
660
670
  releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
661
671
  projectId?: string;
@@ -752,6 +762,9 @@ const RESOLUTION_CAUSES: [RegExp, string][] = [
752
762
  [/active delivery/i, "active delivery: it was handed to the peer and is not settled yet; wait for it, or resolve it once it is needs_review"],
753
763
  [/peer busy/i, "peer busy: the peer is working, and only a queued delivery can be discarded meanwhile"],
754
764
  [/stale delivery revision/i, "stale revision: the delivery changed since you read it; run ahub queue show again"],
765
+ // #1096: a row that already carries a resolution is settled history, not a stale read. `retry` leaves its parent in `failed` for good because the redelivery is a separate row.
766
+ [/already resolved by retry/i, "already resolved by retry: this failed row is settled history, its redelivery is a separate delivery (ahub queue list shows it); nothing is left to do for this row"],
767
+ [/already resolved by (completed|discard)/i, "already resolved: this delivery was already settled by a person or the conductor; nothing is left to do for this row"],
755
768
  [/unknown delivery|delivery not found/i, "unknown delivery: no such delivery id"],
756
769
  [/already terminal|already resolved/i, "already resolved: the delivery is settled"],
757
770
  [/already scheduled/i, "queued delivery is already scheduled: discard it, or wait for its turn"],
@@ -778,6 +791,7 @@ function capturedRecoverySourceDigest(): string | undefined {
778
791
  export async function startDaemon(opts: DaemonOptions) {
779
792
  // Refuse unknown formats before logs, session cleanup, module factories or any writable store is opened.
780
793
  assertHubDbCompatible(join(opts.stateDir, "hub.db"));
794
+ if (opts.toolWriteReplayMaxEntries !== undefined && (!Number.isSafeInteger(opts.toolWriteReplayMaxEntries) || opts.toolWriteReplayMaxEntries < 1 || opts.toolWriteReplayMaxEntries > 512)) throw new Error("invalid host tool write replay capacity");
781
795
  const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
782
796
  resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
783
797
  const projectId = opts.projectId ?? randomUUID();
@@ -1268,8 +1282,12 @@ export async function startDaemon(opts: DaemonOptions) {
1268
1282
  const continuityKind = ADAPTERS.find(adapter => adapter.start.daemonPort === "pi-rpc")!.kind;
1269
1283
  const piAutoStart = () => !!autoStartAdapter && onRoster(autoStartAdapter.kind) && config.pi.auto_start;
1270
1284
  const startedAt = Date.now();
1271
- /** What crash recovery did or asks the user to do, for `ahub status`. */
1272
- const crashReport: string[] = [];
1285
+ /** What crash recovery did or asks the user to do, for `ahub status` (#1082: `crashView` shows the lines that still stand). */
1286
+ const crashNotices: CrashNotice[] = [];
1287
+ /** A crash was reported this run. It never clears: the Jcode headless hint (#1085) has its own end (`tuiServed`), not the notices'. */
1288
+ let crashReported = false;
1289
+ /** The deliveries the crash left in needs_review, by id; filled below once the journal is open. */
1290
+ const crashLostIds = new Set<string>();
1273
1291
 
1274
1292
  // The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
1275
1293
  let inference: Inference | undefined;
@@ -1279,7 +1297,7 @@ export async function startDaemon(opts: DaemonOptions) {
1279
1297
  const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation, terminalHistoryLimit });
1280
1298
  // What the crash left in flight, per recipient: opening the journal just marked these needs_review.
1281
1299
  const lost = new Map<PeerId, JournalDelivery[]>();
1282
- if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]);
1300
+ if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) { lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]); crashLostIds.add(d.id); }
1283
1301
  // Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
1284
1302
  // A listed peer whose value is not a list gets nothing: whoever listed it meant to narrow it.
1285
1303
  const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important" | "spec_read" | "check_run" | "invoke"): boolean => {
@@ -2033,8 +2051,13 @@ export async function startDaemon(opts: DaemonOptions) {
2033
2051
  // Keep the last confirmed capability during a bounded, single-flight refresh; replace it only on a result.
2034
2052
  if (Date.now() - runnerStateAt >= 60_000) void refreshRunner();
2035
2053
  return Object.fromEntries([...new Set([...SUPPORTED_PEERS, ...bus.peers.keys()])].map(peer => {
2036
- if (!definitionOf(peer)?.trust.hubSandboxed) return [peer, { tests: true, spec: true }];
2037
- return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"),
2054
+ if (!definitionOf(peer)?.trust.hubSandboxed) return [peer, { tests: true, spec: true, network: true, worktree: true }];
2055
+ // #1081: Pi and the local worker run their shell under one sandbox network setting (`local.bash_network`): false is no network at all, an egress proxy or "direct" is some.
2056
+ const network = sandboxNetwork !== false;
2057
+ // A hub sandbox never lets its shell run `git worktree add` (#270: it writes under the git common dir), so such a peer cannot create the task worktree. The hub-made writable worktree is #1115.
2058
+ return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"), network, worktree: false,
2059
+ worktreeReason: "git worktree add runs outside the sandbox; this peer cannot create the task worktree (#270; a hub-made worktree is #1115)",
2060
+ ...(network ? {} : { networkReason: "local.bash_network is off, so its shell has no network (ahub settings local.bash_network)" }),
2038
2061
  testsReason: !may(peer, "check_run") ? `capabilities.${peer}` : `runner unavailable: ${runnerState.reason ?? "unknown"}`,
2039
2062
  specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
2040
2063
  }));
@@ -2077,6 +2100,27 @@ export async function startDaemon(opts: DaemonOptions) {
2077
2100
  return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
2078
2101
  ? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
2079
2102
  };
2103
+ type SpecRootSnapshot = { peer: PeerId; root: string; executionRoot: string; verifiedRoot?: string; owner?: PeerAdapter; attached?: PeerAdapter; cwd?: string; socket?: Sock; launchId?: string; generation?: string | number; repo?: string };
2104
+ const specInspectors = new Map<string, SpecTaskInspector>();
2105
+ const specInspectorAt = (root: string): SpecTaskInspector => {
2106
+ const cached = specInspectors.get(root);
2107
+ if (cached) { specInspectors.delete(root); specInspectors.set(root, cached); return cached; }
2108
+ const created = new SpecTaskInspector(root, () => config.specs);
2109
+ specInspectors.set(root, created);
2110
+ if (specInspectors.size > 8) specInspectors.delete(specInspectors.keys().next().value!);
2111
+ return created;
2112
+ };
2113
+ const specRootSnapshot = (task: Task): SpecRootSnapshot => {
2114
+ const peer = task.owner ?? USER, binding = executionOwners.get(peer), execution = executionRoots.get(peer) ?? opts.cwd;
2115
+ const root = binding?.socket && !currentExecutionOwner(peer) ? opts.cwd : execution;
2116
+ const verifiedRoot = ownerWriteRoot(peer), attached = bus.peers.get(peer);
2117
+ return { peer, root, executionRoot: execution, ...(verifiedRoot ? { verifiedRoot } : {}), ...(binding ? { owner: binding.owner, cwd: binding.cwd, ...(binding.socket ? { socket: binding.socket } : {}), ...(binding.launchId ? { launchId: binding.launchId } : {}), ...(binding.generation === undefined ? {} : { generation: binding.generation }) } : {}), ...(attached ? { attached } : {}), ...(task.refs.repo ? { repo: task.refs.repo } : {}) };
2118
+ };
2119
+ const sameSpecRoot = (left: SpecRootSnapshot, right: SpecRootSnapshot): boolean => left.peer === right.peer && left.root === right.root
2120
+ && left.executionRoot === right.executionRoot && left.verifiedRoot === right.verifiedRoot && left.owner === right.owner && left.attached === right.attached
2121
+ && left.cwd === right.cwd && left.socket === right.socket && left.launchId === right.launchId && left.generation === right.generation && left.repo === right.repo;
2122
+ const projectSpecs = specInspectorAt(opts.cwd);
2123
+ let specsMissingBlockHintShown = false;
2080
2124
  const tasks: Tasks = new Tasks({
2081
2125
  releaseWindow,
2082
2126
  researchRunners: () => {
@@ -2163,6 +2207,20 @@ export async function startDaemon(opts: DaemonOptions) {
2163
2207
  const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
2164
2208
  return "unavailable" in brief ? brief : withCodeSymbols(brief, reviewSymbols.get(JSON.stringify([root, brief.head, brief.mergeBase])) ?? { state: "unknown" }, task.plan?.symbols, tasks.publicMetadataText);
2165
2209
  },
2210
+ specsConfig: () => config.specs,
2211
+ specNotice: task => {
2212
+ const snapshot = specRootSnapshot(task);
2213
+ return specInspectorAt(snapshot.root).notice(task.refs.paths ?? []);
2214
+ },
2215
+ inspectSpecs: async (task, head, changedFiles, acks) => {
2216
+ const snapshot = specRootSnapshot(task), inspector = specInspectorAt(snapshot.root);
2217
+ const report = await Promise.resolve(inspector.inspect(head, changedFiles, { complete: changedFiles !== undefined, acks }));
2218
+ const current = specRootSnapshot(task), currentTask = board.get(task.id);
2219
+ if (!sameSpecRoot(snapshot, current) || !currentTask || currentTask.owner !== task.owner) throw new PreEffectToolRefusal("task repository scope changed during spec inspection; retry hub_task_done");
2220
+ if (config.specs.enabled !== true || config.specs.review === "off") return undefined;
2221
+ return report;
2222
+ },
2223
+ specsRead: (action, paths) => projectSpecs.read(action, paths),
2166
2224
  sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
2167
2225
  board,
2168
2226
  bus,
@@ -2514,7 +2572,7 @@ export async function startDaemon(opts: DaemonOptions) {
2514
2572
  return typeof session === "string" ? session : undefined;
2515
2573
  };
2516
2574
  const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
2517
- const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.list().some(t => !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer) && tasks.isPii(t));
2575
+ const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.peerTaskSignals(peer).some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
2518
2576
  // #878: what each peer is doing. Daemon memory only: never persisted (status.json leaves it out), logged, captured or relayed.
2519
2577
  const doings = new Map<PeerId, DoingTracker>();
2520
2578
  const observeDoing = (peer: PeerId, kind: DoingKind, text?: unknown, learnedPrivate = false) => {
@@ -3244,6 +3302,73 @@ export async function startDaemon(opts: DaemonOptions) {
3244
3302
  // commit waits for those in flight, or its integrity digest misses their later writes. Completion checks outlive
3245
3303
  // their op, so recoveryReady() also waits for `tasks.checksPending()`: a check the commit's stop kills would write.
3246
3304
  let taskOpsInFlight = 0;
3305
+ const toolWriteRegistry = new ToolWriteRegistry(instanceId, opts.toolWriteReplayMaxEntries === undefined ? {} : { maxEntries: opts.toolWriteReplayMaxEntries });
3306
+ const toolWriteScope = (c: Client, operation: string): string => {
3307
+ const actor = c.peer ?? USER, peer = c.peer ? bus.peers.get(c.peer) : undefined;
3308
+ const binding = c.toolsOwner ?? c.externalOwner;
3309
+ return JSON.stringify({ actor, role: c.role ?? "", operation, session: c.session === true, launchId: c.launchId ?? null,
3310
+ generation: binding?.generation ?? (c.peer ? connectionGeneration(c.peer) ?? null : null), owner: binding?.owner.id ?? peer?.id ?? null,
3311
+ executionRoot: c.peer ? executionRoots.get(c.peer) ?? null : null });
3312
+ };
3313
+ const taskWriteId = (value: unknown): number | undefined => {
3314
+ const id = typeof value === "number" ? value : typeof value === "string" && /^\s*\d+\s*$/.test(value) ? Number(value) : NaN;
3315
+ return Number.isInteger(id) ? id : undefined;
3316
+ };
3317
+ const toolWriteCurrent = (c: Client, sock: Sock, operation: string, payload: Record<string, unknown>, scope: string, result?: Record<string, unknown>): boolean => {
3318
+ try {
3319
+ const actor = c.peer ?? USER;
3320
+ if (!c.authed || c.closed || sock.readyState !== WebSocket.OPEN || !c.role || stopping || recoveryActive() && recoveryPhase !== "preparing"
3321
+ || c.peer && !membershipAllowed(c.peer) || c.externalOwner && !currentExternalTools(c, true) || c.toolsOwner && !currentImplementerTools(c)) return false;
3322
+ if (c.session && sessionGate(actor, payload)) return false;
3323
+ if (toolWriteScope(c, operation) !== scope || readOnlyPeer(actor) && !READ_ONLY_HUB_TOOLS.has(operation)
3324
+ || c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(operation)) return false;
3325
+ const privateContext = actor !== USER && (!!turns.get(actor)?.private || contextPii(actor));
3326
+ const args = payload.args && typeof payload.args === "object" && !Array.isArray(payload.args) ? payload.args as Record<string, unknown> : {};
3327
+ const requestedOwner = typeof args.owner === "string" ? args.owner.trim().toLowerCase() : "";
3328
+ if (operation === "hub_task_propose" && (!may(actor, "propose") || requestedOwner && PEER_ID.test(requestedOwner)
3329
+ && ![actor, USER, HUB, "digest"].includes(requestedOwner) && !may(actor, "assign"))) return false;
3330
+ const safePiiProposalReceipt = operation === "hub_task_propose" && actor !== USER && result?.ok === true && typeof result.text === "string"
3331
+ && /^task #[1-9]\d*: (?:backlog|proposed|in_progress|in_review|approved|changes_requested|cancelled), owner (?:none|[a-z][a-z0-9-]{0,31}), reviewer (?:none|[a-z][a-z0-9-]{0,31})$/.test(result.text);
3332
+ const taskId = operation === "hub_review" ? taskWriteId(args.id)
3333
+ : result?.ok === true && typeof result.text === "string" ? Number(/task #(\d+):/.exec(result.text)?.[1]) : undefined;
3334
+ if (taskId !== undefined) {
3335
+ const task = Number.isSafeInteger(taskId) && taskId > 0 ? board.get(taskId) : undefined;
3336
+ if (!task) {
3337
+ // Let the original Tasks.review validation return its specific pre-effect refusal for a new/bad target.
3338
+ // A successful cached verdict without its task row is no longer safe to replay.
3339
+ if (operation !== "hub_review" || result?.ok === true) return false;
3340
+ } else {
3341
+ const piiTask = tasks.isPii(task);
3342
+ // ponytail: non-console review/PII replays remain held; narrower privacy needs host-proven per-operation private-turn authority.
3343
+ if (operation === "hub_review" && actor !== USER && privateContext) return false;
3344
+ if (actor !== USER && piiTask && !safePiiProposalReceipt
3345
+ || operation === "hub_review" && actor !== USER && task.reviewer !== actor
3346
+ || actor !== USER && result?.ok === true && typeof result.text === "string" && !tasks.publicMetadataText(result.text) && !safePiiProposalReceipt) return false;
3347
+ }
3348
+ }
3349
+ if (actor !== USER && operation === "hub_task_propose" && result?.ok === true && typeof result.text === "string") {
3350
+ const overlapLines = result.text.split(/\r?\n/).filter(line => line.startsWith("Overlaps "));
3351
+ if (overlapLines.length > 1) return false;
3352
+ if (overlapLines.length === 1) {
3353
+ const line = overlapLines[0]!;
3354
+ const markers = [...line.matchAll(/#([1-9]\d*) \(owner ([^)\r\n]{1,128})\)/g)];
3355
+ const markerPrefixes = [...line.matchAll(/#([^()\r\n]{1,128}) \(owner /g)];
3356
+ if (!markers.length || markers.length > 64 || markerPrefixes.length !== markers.length) return false;
3357
+ const refs = new Set<number>();
3358
+ for (const marker of markers) {
3359
+ const id = Number(marker[1]);
3360
+ if (!Number.isSafeInteger(id) || id < 1) return false;
3361
+ refs.add(id);
3362
+ }
3363
+ if (refs.size !== markers.length) return false;
3364
+ const facts = board.taskPrivacySignals([...refs]);
3365
+ if (facts.length !== refs.size || facts.some(task => tasks.isPii(task))) return false;
3366
+ }
3367
+ }
3368
+ if (actor !== USER && typeof result?.error === "string" && result.outcome !== "unknown" && !tasks.publicMetadataText(result.error)) return false;
3369
+ return true;
3370
+ } catch { return false; }
3371
+ };
3247
3372
  /**
3248
3373
  * #604 ORIGIN side. Order matters: the caller's authority and the PII gates run first (nothing has left this project), then the target is resolved from the machine registry and its own
3249
3374
  * status descriptor is read, and only then is the request sent. The target answers with the task id it created, which is recorded here. A refusal at any step leaves no row in either board.
@@ -3354,6 +3479,10 @@ export async function startDaemon(opts: DaemonOptions) {
3354
3479
  try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), deliveryRetryAllowed(bus.peers.get(peer))); }
3355
3480
  catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
3356
3481
  }
3482
+ function inboxHeldMetadata(peer: PeerId) {
3483
+ try { return bus.heldDeliveryHints(peer); }
3484
+ catch { return undefined; } // the inbox pull is already settled; advisory held-row metadata cannot hide its result
3485
+ }
3357
3486
  /** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
3358
3487
  const peerArg = (v: unknown, name: string): PeerId | undefined => {
3359
3488
  if (v == null || v === "") return undefined;
@@ -3504,14 +3633,19 @@ export async function startDaemon(opts: DaemonOptions) {
3504
3633
  return JSON.stringify({ invocation: row.id, state: "queued", note: "the result arrives later as one message from the hub, addressed to you only; do not wait for it in this turn" });
3505
3634
  }
3506
3635
  async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
3636
+ if (inProcess && (op === "hub_task_propose" || op === "hub_review") && a && typeof a === "object" && Object.hasOwn(a, "request_id")) throw new PreEffectToolRefusal("request_id replay keys require the control-protocol task transport");
3507
3637
  if (BOUNDED_TASK_TOOLS.has(op)) {
3508
3638
  const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
3509
3639
  if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !keys.includes(key))
3510
3640
  || (keys.includes("id") ? !Number.isSafeInteger(a.id) || a.id < 1 : typeof a.run !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(a.run))) throw new PreEffectToolRefusal("invalid bounded task request");
3511
3641
  if (by !== USER && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("bounded task tools unavailable during a PII turn");
3512
3642
  }
3643
+ if (op === "hub_specs" && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("spec inspection unavailable during a private turn");
3513
3644
  if (MEMORY_READ_TOOLS.has(op)) validateMemoryRead(op, a);
3514
3645
  if (op === "hub_memory_correct") correctionInput(a);
3646
+ if (op === "hub_task_propose" && by !== USER && (piiTurn || turns.get(by)?.private)) {
3647
+ throw new PreEffectToolRefusal("hub_task_propose is not available while working on a PII task: its text must not leave this machine");
3648
+ }
3515
3649
  if (CONDUCTOR_TOOL_NAMES.has(op)) {
3516
3650
  if (piiTurn && ["hub_task_assign", "hub_task_escalate", "hub_peer_start"].includes(op)) throw new PreEffectToolRefusal("conductor mutations are unavailable during a PII turn");
3517
3651
  const result = await conductor.execute(by, op, a);
@@ -3520,6 +3654,7 @@ export async function startDaemon(opts: DaemonOptions) {
3520
3654
  }
3521
3655
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
3522
3656
  // shared memory and a new task could be routed to a cloud peer without matching any pattern.
3657
+ if (op === "hub_experiment_record" && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
3523
3658
  if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new" || op === "hub_study_draft" || op === "hub_study_revise")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
3524
3659
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
3525
3660
  // completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
@@ -3681,6 +3816,9 @@ export async function startDaemon(opts: DaemonOptions) {
3681
3816
  return JSON.stringify(publicCheck(receipt));
3682
3817
  }
3683
3818
  switch (op) {
3819
+ case "hub_specs":
3820
+ if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "action" && key !== "paths")) throw new PreEffectToolRefusal("hub_specs accepts only action and paths");
3821
+ return JSON.stringify(tasks.specsRead(by, a.action, a.paths));
3684
3822
  case "hub_delivery_retry": {
3685
3823
  if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "delivery_id") || typeof a.delivery_id !== "string" || a.delivery_id.length < 1 || a.delivery_id.length > 256) throw new PreEffectToolRefusal("hub_delivery_retry accepts only your delivery_id; completed and discard require the person");
3686
3824
  if (!deliveryRetryAllowed(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
@@ -3688,6 +3826,16 @@ export async function startDaemon(opts: DaemonOptions) {
3688
3826
  bus.retryDelivery(by, a.delivery_id);
3689
3827
  return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
3690
3828
  }
3829
+ case "hub_delivery_resolve": {
3830
+ if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["delivery_id", "revision", "action", "reason"].includes(key))
3831
+ || typeof a.delivery_id !== "string" || !a.delivery_id.trim() || a.delivery_id.length > 256
3832
+ || !Number.isSafeInteger(a.revision) || a.revision < 0 || !["completed", "discard"].includes(a.action)
3833
+ || typeof a.reason !== "string" || !a.reason.trim() || a.reason.trim().length > 1000 || a.reason.length > 1000
3834
+ || !tasks.publicMetadataText(a.reason.trim()) || !boundedSafe(a.reason.trim())) throw new PreEffectToolRefusal("hub_delivery_resolve needs only delivery_id, revision (the whole number hub_inbox showed), action completed or discard, and a public reason of 1 to 1000 characters; call hub_inbox for the revision and keep private or sensitive text out of the reason");
3835
+ if (stopping || recoveryActive()) throw new PreEffectToolRefusal("the hub is not accepting delivery changes now (recovery or shutdown); run ahub status, then call hub_delivery_resolve again after it recovers");
3836
+ bus.resolveOwnDelivery(by, a.delivery_id, a.revision, a.action, a.reason.trim());
3837
+ return `your held delivery was marked ${a.action}`;
3838
+ }
3691
3839
  case "hub_backlog_list":
3692
3840
  return JSON.stringify(tasks.backlogList(by));
3693
3841
  case "hub_release_new":
@@ -3734,6 +3882,11 @@ export async function startDaemon(opts: DaemonOptions) {
3734
3882
  return JSON.stringify(await tasks.studyRevise(by, a));
3735
3883
  case "hub_study_show":
3736
3884
  return JSON.stringify(a.id === undefined ? tasks.studyList(by, a) : tasks.studyShow(by, a));
3885
+ // #602 (issue 932 T3): the read of the experiment records Tasks already keeps; it runs nothing and reads no file, and a private record is blanked inside the view.
3886
+ case "hub_experiment_show":
3887
+ return JSON.stringify(await tasks.experimentShow(by, a));
3888
+ case "hub_experiment_record":
3889
+ return JSON.stringify(await tasks.experimentRecord(by, a));
3737
3890
  case "hub_study_handoff":
3738
3891
  if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "handoff")) throw new PreEffectToolRefusal("study handoff takes only a handoff object");
3739
3892
  return JSON.stringify(await tasks.studyHandoff(by, a.handoff));
@@ -3758,19 +3911,21 @@ export async function startDaemon(opts: DaemonOptions) {
3758
3911
  const t = await tasks.propose(by, a);
3759
3912
  const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
3760
3913
  const submodules = tasks.submoduleNotice(t); // #798 D1: advice only too
3761
- if (t.state === "backlog") return [line(t), repoHint, submodules].filter(Boolean).join("\n");
3914
+ const specs = tasks.specNotice(t, by);
3915
+ if (t.state === "backlog") return [line(t), specs, repoHint, submodules].filter(Boolean).join("\n");
3762
3916
  const overlap = tasks.overlaps(t, t.owner === by);
3763
3917
  if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
3764
- return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint, submodules].filter(Boolean).join("\n");
3918
+ return [line(t), specs, tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint, submodules].filter(Boolean).join("\n");
3765
3919
  }
3766
3920
  case "hub_task_accept": {
3767
3921
  // S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
3768
3922
  const t = await tasks.acceptReviewed(by, a.id, a.plan, a.refs);
3923
+ const specs = tasks.specNotice(t, by);
3769
3924
  // A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
3770
3925
  const silent = tasks.silentFor(t.id);
3771
3926
  const overlap = a.plan == null && a.refs == null && !silent ? "" : tasks.overlaps(t);
3772
3927
  if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
3773
- return [line(t), overlap, tasks.planGuardText(t), tasks.submoduleNotice(t)].filter(Boolean).join("\n"); // #798 D1
3928
+ return [line(t), specs, overlap, tasks.planGuardText(t), tasks.submoduleNotice(t)].filter(Boolean).join("\n"); // #798 D1
3774
3929
  }
3775
3930
  case "hub_task_cancel": {
3776
3931
  if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
@@ -3783,7 +3938,7 @@ export async function startDaemon(opts: DaemonOptions) {
3783
3938
  case "hub_task_decline":
3784
3939
  return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
3785
3940
  case "hub_task_done": {
3786
- const t = await tasks.done(by, a.id, a.summary, a.refs, a.deliverables);
3941
+ const t = await tasks.done(by, a.id, a.summary, a.refs, a.deliverables, a.spec_ack);
3787
3942
  const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
3788
3943
  if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
3789
3944
  citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
@@ -3792,8 +3947,10 @@ export async function startDaemon(opts: DaemonOptions) {
3792
3947
  const last = t.history.at(-1);
3793
3948
  if (last?.event === "integration requested" && last.by === HUB) return last.note ?? "";
3794
3949
  const extra = [tasks.takeDoneNote(t.id), tasks.checkoutNote()].filter(Boolean).join("\n"); // held silence notices, then the #572 checkout advisory
3795
- const result = tasks.isChecking(t.id) ? `${line(t)}; its check is queued or running, and the result comes as a task message` : line(t);
3796
- return extra ? `${result}\n${extra}` : result;
3950
+ const doneLine = by === USER ? tasks.resultLine(t, by) : line(t);
3951
+ const result = tasks.isChecking(t.id) ? `${doneLine}; its check is queued or running, and the result comes as a task message` : doneLine;
3952
+ const specReport = by === USER ? "" : tasks.specReportText(t, by);
3953
+ return [result, specReport, extra].filter(Boolean).join("\n");
3797
3954
  }
3798
3955
  case "hub_sdlc_record":
3799
3956
  await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
@@ -3833,7 +3990,13 @@ export async function startDaemon(opts: DaemonOptions) {
3833
3990
  if (!release) throw new PreEffectToolRefusal("no such release");
3834
3991
  releaseId = release.id;
3835
3992
  }
3836
- return JSON.stringify(board.list(a.ready === true ? "proposed" : a.state).filter((t) => (by === USER || t.state !== "backlog") && (releaseId === undefined || t.release === releaseId) && (a.ready !== true || !tasks.waitsFor(t).length)).map((t) => { const publicTask = tasks.publicView(t); const view: Record<string, unknown> = onPrem ? { ...t, ...holderReasons(publicTask) } : publicTask; const { history: _h, ...rest } = view; return { ...rest, flowRevision: t.history.length, ...(a.columnMetadata === true ? { started: taskHasStarted(t) } : {}) }; }));
3993
+ // #1083: an agent always pages (default 50 compact rows) because the whole board is over every tool-output limit; the person's console keeps the full array unless it names a paging argument.
3994
+ const listQuery = parseListQuery(a, by !== USER);
3995
+ const shown = board.list(a.ready === true ? "proposed" : a.state).filter((t) => (by === USER || t.state !== "backlog") && (releaseId === undefined || t.release === releaseId) && (a.ready !== true || !tasks.waitsFor(t).length));
3996
+ const listRow = (t: Task) => { const publicTask = tasks.publicView(t); const view: Record<string, unknown> = onPrem ? { ...t, ...holderReasons(publicTask) } : publicTask; const { history: _h, ...rest } = view; return { ...rest, flowRevision: t.history.length, ...(a.columnMetadata === true ? { started: taskHasStarted(t) } : {}) }; };
3997
+ if (!listQuery) return JSON.stringify(shown.map(listRow));
3998
+ const page = pageTasks(shown, listQuery);
3999
+ return JSON.stringify({ tasks: page.rows.map(t => listQuery.detail ? listRow(t) : compactRow(listRow(t))), total: page.total, ...(page.next ? { next: page.next } : {}) });
3837
4000
  }
3838
4001
  }
3839
4002
  if (op === "task_show" && by !== USER) {
@@ -4254,7 +4417,11 @@ export async function startDaemon(opts: DaemonOptions) {
4254
4417
  docs: opts.releaseDocEvidence,
4255
4418
  gh: (args, timeout) => sdlcGh(args, timeout, opts.cwd),
4256
4419
  current: () => !stopping && !recoveryActive() && !bus.isRecoveryHeld,
4257
- record: receipt => event({ type: "release_authorization", by: "user", authorization: receipt.id, commit: receipt.commit, version: receipt.version, docsDigest: receipt.docs.digest, documents: receipt.docs.documents }),
4420
+ conductor: () => {
4421
+ const peer = conductorPeer(config.roles);
4422
+ return peer && !turns.get(peer)?.private && !contextPii(peer) ? peer : undefined;
4423
+ },
4424
+ record: receipt => event({ type: "release_authorization", by: receipt.by, authorization: receipt.id, commit: receipt.commit, version: receipt.version, docsDigest: receipt.docs.digest, documents: receipt.docs.documents }),
4258
4425
  });
4259
4426
  const terminalReads = terminalOwnerReads();
4260
4427
  const terminalLaunches = new TerminalLaunches({
@@ -4523,6 +4690,18 @@ export async function startDaemon(opts: DaemonOptions) {
4523
4690
  }
4524
4691
  return checkoutInTick.branch;
4525
4692
  };
4693
+ /**
4694
+ * The crash-recovery lines that still stand (#1082), read when status is asked: a line about a peer goes once that peer is attached, and the headline's count is the crash's own deliveries that are
4695
+ * still in needs_review, so it matches `ahub queue list` and reaches zero when the operator has resolved them.
4696
+ */
4697
+ const crashView = (): string[] => {
4698
+ if (!crashNotices.length) return [];
4699
+ const attached = new Set<string>();
4700
+ for (const notice of crashNotices) if (notice.peer !== undefined && bus.peers.has(notice.peer) && bus.peers.get(notice.peer)!.state !== "offline") attached.add(notice.peer);
4701
+ let inFlight = 0;
4702
+ if (crashLostIds.size) { try { for (const row of bus.queueList(undefined, true)) if (row.state === "needs_review" && crashLostIds.has(row.id)) inFlight++; } catch { inFlight = crashLostIds.size; } }
4703
+ return openCrashNotices(crashNotices, { attached, inFlight });
4704
+ };
4526
4705
  const status = (includeAttentionItems = false, includeAttentionInput = false, includeDoing = false) => {
4527
4706
  const taskNeeds = board.statusTasks();
4528
4707
  const ownerIsolation = ownerIsolationProjection();
@@ -4530,9 +4709,10 @@ export async function startDaemon(opts: DaemonOptions) {
4530
4709
  const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
4531
4710
  const peerIds = byKind(statusPeerIds(taskNeeds));
4532
4711
  const queueBackpressure = tasks.queuePressureStatus(peerIds);
4712
+ const crashLines = crashView();
4533
4713
  const view = {
4534
4714
  ...(queueBackpressure ? { queueBackpressure } : {}),
4535
- ...(crashReport.length ? { crash: crashReport } : {}),
4715
+ ...(crashLines.length ? { crash: crashLines } : {}),
4536
4716
  projectId,
4537
4717
  instanceId,
4538
4718
  rooms: roomCommands.statusView(),
@@ -4890,34 +5070,35 @@ export async function startDaemon(opts: DaemonOptions) {
4890
5070
  } catch (error) { log(`session record not written: ${(error as Error).message}`); }
4891
5071
  };
4892
5072
  const recoverAfterCrash = async (prev: SessionsFile) => {
4893
- const report = (line: string) => (crashReport.push(line), notify(`crash recovery: ${line}`));
5073
+ // `peer` marks a line that asks something of that peer's owner (it clears when the peer attaches); a line with neither is what recovery found or did and stays (#1082).
5074
+ const report = (line: string, notice: { peer?: string; inFlight?: true } = {}, shown = line) => { crashNotices.push({ text: line, ...notice }); crashReported = true; notify(`crash recovery: ${shown}`); };
4894
5075
  const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
4895
- report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
5076
+ report("the previous hub run stopped without shutting down", { inFlight: true }, `the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
4896
5077
  const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
4897
5078
  for (const step of crashPlan(prev.peers, declarations, adapterCatalog)) {
4898
- if (isExternalInstance(step.peer)) { report(`${step.peer}: external module instance stays stopped; use an explicit same-binding start`); continue; }
4899
- if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session"); continue; }
5079
+ if (isExternalInstance(step.peer)) { report(`${step.peer}: external module instance stays stopped; use an explicit same-binding start`, { peer: step.peer }); continue; }
5080
+ if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session", { peer: step.peer }); continue; }
4900
5081
  // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
4901
5082
  // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
4902
5083
  if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
4903
5084
  if (step.resume) {
4904
5085
  const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
4905
5086
  if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
4906
- report(`pi not resumed (${String(r.error)}); ${step.how}`);
5087
+ report(`pi not resumed (${String(r.error)}); ${step.how}`); // what happened, true after a fresh start too: history. Whether Pi is back is the last line of this branch.
4907
5088
  } else report(step.how);
4908
5089
  // #269: a Pi that ran in a terminal is not replaced by a headless one unless headless is its start mode.
4909
- if (step.tui && startMode(step.peer) === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless"); continue; }
5090
+ if (step.tui && startMode(step.peer) === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless", { peer: step.peer }); continue; }
4910
5091
  const fresh = await start(step.peer, { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
4911
5092
  const back = step.tui ? `; to go back to the recorded session, run ahub kill, start the hub without pi.auto_start, then the command above` : "";
4912
- report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
5093
+ report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`, fresh.ok ? {} : { peer: step.peer });
4913
5094
  continue;
4914
5095
  }
4915
5096
  if (!step.resume || !autoResume()) {
4916
- report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`);
5097
+ report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`, { peer: step.peer });
4917
5098
  continue;
4918
5099
  }
4919
5100
  const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
4920
- report(r.ok ? `${step.peer} resumed: ${step.how}` : `${step.peer} not resumed (${String(r.error)}); ${step.how}`);
5101
+ report(r.ok ? `${step.peer} resumed: ${step.how}` : `${step.peer} not resumed (${String(r.error)}); ${step.how}`, r.ok ? {} : { peer: step.peer });
4921
5102
  }
4922
5103
  writeStatus();
4923
5104
  };
@@ -5351,7 +5532,7 @@ export async function startDaemon(opts: DaemonOptions) {
5351
5532
  if (!isStartablePeer(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, qwen (when on roster), pi (when enabled) and local" };
5352
5533
  if (!membershipAllowed(peer)) return { ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` };
5353
5534
  if (!startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
5354
- if (recoveryActive()) return { ok: false, error: "recovery is holding mutations" };
5535
+ if (recoveryActive()) return { ok: false, error: startHeldByRecovery(peer) };
5355
5536
  const continuity = definitionOf(peer)?.start.autoStart === by
5356
5537
  ? piContinuity.state === "ready" ? (piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd })) : piContinuity
5357
5538
  : undefined;
@@ -5413,6 +5594,15 @@ export async function startDaemon(opts: DaemonOptions) {
5413
5594
  /** The CLI command that starts a peer: `ahub <kind> --as <id>` for a declared instance. */
5414
5595
  const startCommand = (peer: string): string => { const kind = definitionOf(peer)?.kind; return kind && kind !== peer ? `ahub ${kind} --as ${peer}` : `ahub ${peer}`; };
5415
5596
 
5597
+ /** The refusal of a start request while recovery holds mutations (#1088): the old sentence first, then whether the peer already runs and the step that works. */
5598
+ const startHeldByRecovery = (peer: unknown): string => {
5599
+ const held = "recovery is holding mutations";
5600
+ if (typeof peer !== "string" || !PEER_ID.test(peer)) return `${held}; no peer was started. Wait for the recovery to finish (ahub recovery status shows where it is), then start the peer again`;
5601
+ const owner = bus.peers.get(peer);
5602
+ if (owner && owner.state !== "offline") return `${held}, and ${peer} is already running, so there is nothing to start. Wait for the recovery to finish (ahub recovery status shows where it is), then check ${peer} with ahub status. If the recovery names ${peer} as blocked, a person repairs it with ahub recovery reattach <operation-id> ${peer}, or ends the hold with ahub recovery resume or abort`;
5603
+ return `${held}, so ${peer} was not started. Wait for the recovery to finish (ahub recovery status shows where it is), then start it with ${startCommand(peer)}`;
5604
+ };
5605
+
5416
5606
  async function stopPeer(peer: unknown, rosterRemoval = false, operational?: {actor:string;current:()=>void}): Promise<Record<string, unknown>> {
5417
5607
  operational?.current();
5418
5608
  if (!rosterRemoval && rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
@@ -5647,6 +5837,7 @@ export async function startDaemon(opts: DaemonOptions) {
5647
5837
  ...(instance?.provider ? { provider: instance.provider } : {}),
5648
5838
  ...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
5649
5839
  toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(), steerTimeoutMs: adapterTiming.jcodeSteerTimeoutMs(),
5840
+ tuiHintContext: () => ({ tui: startMode(peer) === "tui", afterCrash: crashReported }),
5650
5841
  preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld"),
5651
5842
  // #801: Jcode 0.99.1's usage_update is `{ used, size }`, the live session's context occupancy: a context reading bound to the adapter's own session id, never a tokens event
5652
5843
  // or a quota window. An invalid shape reports a null reading (unknown, not the previous value).
@@ -6427,6 +6618,7 @@ export async function startDaemon(opts: DaemonOptions) {
6427
6618
  return {
6428
6619
  id: row.id, peer: row.peer, state: row.state, revision: row.revision,
6429
6620
  ...roomChat.evidence(row.peer, row.id),
6621
+ ...(detail && bus.deliveryResolution(row.id) ? { resolvedBy: bus.deliveryResolution(row.id) } : {}),
6430
6622
  ...(!membershipAllowed(row.peer) ? { rostered: false, holdReason: "off roster" } : {}),
6431
6623
  createdAt: row.createdAt, updatedAt: row.updatedAt,
6432
6624
  envelopeIds: originals.map((env) => env.id),
@@ -7595,6 +7787,7 @@ export async function startDaemon(opts: DaemonOptions) {
7595
7787
  ? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
7596
7788
  };
7597
7789
  const incompatibleRecoveryTarget = () => {
7790
+ if (config.specs.enabled && config.specs.review === "require" && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("design_specs"))) return "target lacks design_specs; specs.enabled is on and specs.review is require; use a supporting release or explicitly change specs.enabled/specs.review";
7598
7791
  if (board.hasConductorManualState() && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("conductor_recovery"))) return "target lacks conductor_recovery; open conductor manual-assignment intent cannot be preserved";
7599
7792
  if (allExternalInstanceIds().length && (!Array.isArray(msg.targetFeatures) || !["external_adapter_instances", "external_adapter_modules", "peer_instances"].every(feature => msg.targetFeatures.includes(feature)))) return "target lacks external_adapter_instances or its module/instance prerequisites";
7600
7793
  if (!(msg.op === "inspect" && recoveryPhase === "restored")) for (const id of allExternalInstanceIds()) if (!retainedExternalBinding(id, true)) return "external instance retained binding changed or unavailable";
@@ -7945,9 +8138,12 @@ export async function startDaemon(opts: DaemonOptions) {
7945
8138
  const c = sock.data;
7946
8139
  let refusalRecorded=false;
7947
8140
  const reply = (body: Record<string, unknown>) => {
8141
+ const writeRequestId = typeof msg.requestId === "string" && msg.requestId.length <= 256 && !/[\p{Cc}\p{Cf}]/u.test(msg.requestId)
8142
+ && toolWriteOperation(msg) !== undefined && body.requestId === msg.requestId ? msg.requestId : undefined;
8143
+ let writeRefusal: string | undefined;
7948
8144
  const label=operationalRequestLabel(msg);
7949
- if (msg.t !== "hello" && c.externalOwner && !currentExternalTools(c)) body = { ok: false, error: "external instance launch or execution root changed" };
7950
- if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed", ...(label ? {authorityRefused:true,authorityScope:"operational"}: {}) };
8145
+ if (msg.t !== "hello" && c.externalOwner && !currentExternalTools(c)) { writeRefusal = "external instance launch or execution root changed"; body = { ok: false, error: writeRefusal }; }
8146
+ if (c.toolsOwner && !currentImplementerTools(c)) { writeRefusal = "Claude child generation or execution root changed"; body = { ok: false, error: writeRefusal, ...(label ? {authorityRefused:true,authorityScope:"operational"}: {}) }; }
7951
8147
  if(c.authed && c.role==="tools" && c.peer && body.ok===false && label) {
7952
8148
  if(body.authorityRefused===true) {
7953
8149
  if(!refusalRecorded)refusalRecorded=event({type:"agent_cli",peer:c.peer,command:label,refused:true});
@@ -7955,8 +8151,45 @@ export async function startDaemon(opts: DaemonOptions) {
7955
8151
  body={...body,error,refusalAudited:refusalRecorded};
7956
8152
  } else body={...body,authorityRefused:false};
7957
8153
  }
8154
+ if (writeRequestId && body.requestId !== writeRequestId) {
8155
+ const authority = { ...(body.authorityRefused === true || body.authorityRefused === false ? { authorityRefused: body.authorityRefused } : {}),
8156
+ ...(typeof body.authorityScope === "string" ? { authorityScope: body.authorityScope } : {}), ...(typeof body.refusalAudited === "boolean" ? { refusalAudited: body.refusalAudited } : {}) };
8157
+ body = { ...body, ...toolWriteUnknownReply(msg, writeRequestId, writeRefusal ?? "caller authority changed before the result could be returned"), ...authority };
8158
+ }
7958
8159
  if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
7959
8160
  };
8161
+ const runTaskRequest = (run: () => Promise<string>, extraCurrent: () => void = () => {}, formatError: (error: unknown) => Record<string, unknown> = error => ({ t: "task", ok: false, error: error instanceof Error ? error.message : "task write refused" })): void => {
8162
+ const operation = toolWriteOperation(msg);
8163
+ const payload = { t: "task", op: msg.op, args: msg.args ?? {} };
8164
+ const actor = c.peer ?? USER, args = payload.args && typeof payload.args === "object" && !Array.isArray(payload.args) ? payload.args as Record<string, unknown> : {};
8165
+ const knownPreEffectRefusal = operation !== undefined && (
8166
+ readOnlyPeer(actor) && !READ_ONLY_HUB_TOOLS.has(operation)
8167
+ || operation === "hub_task_propose" && (!may(actor, "propose") || (() => {
8168
+ const owner = typeof args.owner === "string" ? args.owner.trim().toLowerCase() : "";
8169
+ return !!owner && PEER_ID.test(owner) && ![actor, USER, HUB, "digest"].includes(owner) && !may(actor, "assign");
8170
+ })())
8171
+ || operation === "hub_review" && actor !== USER && (() => {
8172
+ const id = taskWriteId(args.id);
8173
+ if (id === undefined || id < 1) return false;
8174
+ const task = board.get(id);
8175
+ return !!task && task.reviewer !== actor;
8176
+ })()
8177
+ );
8178
+ if (!operation || msg.requestId === undefined || knownPreEffectRefusal) {
8179
+ void run().then(text => reply({ t: "task", ok: true, text }), error => reply(formatError(error)));
8180
+ return;
8181
+ }
8182
+ const scope = toolWriteScope(c, operation);
8183
+ void toolWriteRegistry.run({ id: msg.requestId, scope, bucket: actor, payload,
8184
+ current: result => toolWriteCurrent(c, sock, operation, payload, scope, result) && (() => { try { extraCurrent(); return true; } catch { return false; } })(),
8185
+ }, async () => {
8186
+ try { return { t: "task", ok: true, text: await run() }; }
8187
+ catch (error) {
8188
+ if (error instanceof PreEffectToolRefusal || error instanceof OperationalAuthorityError) return formatError(error);
8189
+ throw error;
8190
+ }
8191
+ }).then(result => reply(result), () => reply(toolWriteUnknownReply(payload, typeof msg.requestId === "string" ? msg.requestId : undefined)));
8192
+ };
7960
8193
  if (!c.authed) {
7961
8194
  if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
7962
8195
  if (typeof msg.peer === "string" && msg.peer.includes("@")) return sock.close(4403, "remote identities have no control connection");
@@ -8176,7 +8409,11 @@ export async function startDaemon(opts: DaemonOptions) {
8176
8409
  if(c.role!=="console" && msg.reason!==undefined && (typeof msg.reason!=="string" || !tasks.publicMetadataText(msg.reason)))return void reply({ok:false,error:"queue reason withheld by privacy policy"});
8177
8410
  if (msg.op === "list") {
8178
8411
  if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer) && !parseRemotePeerId(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
8179
- return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row,false,c.role!=="console")) });
8412
+ if (msg.all !== undefined && typeof msg.all !== "boolean") return void reply({ ok: false, error: "all is true or false" });
8413
+ // #1096: a row an operator action already settled (a retry parent left `failed` for good, a discarded or completed row) is history, not open work: hidden unless `all`.
8414
+ const everything = bus.queueList(msg.peer), settled = msg.all === true ? new Set<string>() : bus.settledIds();
8415
+ const shown = everything.filter((row) => !settled.has(row.id));
8416
+ return void reply({ ok: true, deliveries: shown.map((row) => queueView(row,false,c.role!=="console")), ...(shown.length < everything.length ? { hiddenSettled: everything.length - shown.length } : {}) });
8180
8417
  }
8181
8418
  if (msg.op === "discard") {
8182
8419
  // #713: the person's bulk clear of one peer's backlog. Without `confirmed` it only counts; with it, every queued (or needs_review) delivery of that peer is discarded
@@ -8184,10 +8421,18 @@ export async function startDaemon(opts: DaemonOptions) {
8184
8421
  // because only the named peer is read. Refused while recovery or shutdown holds queue mutations, like every queue resolution.
8185
8422
  if (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer) && !parseRemotePeerId(msg.peer)) return void reply({ ok: false, error: "peer must be a peer id" });
8186
8423
  if (msg.peer.includes("@") && c.role !== "console") return void reply({ ok: false, error: "remote delivery disposition is person-only" });
8187
- const state = msg.state === undefined ? "queued" : msg.state;
8188
- if (state !== "queued" && state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
8189
- const rows = bus.queueList(msg.peer).filter((row) => row.state === state);
8190
- if (msg.confirmed !== true) return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded: 0, refused: 0 });
8424
+ // #1096: `states` (a list of queued, needs_review, failed) clears several kinds in one call, and a `failed` row counts only while no resolution settled it. The older single `state` keeps its meaning.
8425
+ const KINDS = ["queued", "needs_review", "failed"] as const;
8426
+ if (msg.states !== undefined && msg.state !== undefined) return void reply({ ok: false, error: "give state or states, not both" });
8427
+ if (msg.states !== undefined && (!Array.isArray(msg.states) || !msg.states.length || msg.states.some((entry: unknown) => !(KINDS as readonly unknown[]).includes(entry)) || new Set(msg.states).size !== msg.states.length)) return void reply({ ok: false, error: `states is a list of ${KINDS.join(", ")}` });
8428
+ if (msg.states === undefined && msg.state !== undefined && msg.state !== "queued" && msg.state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
8429
+ const wanted: readonly string[] = msg.states ?? [msg.state === undefined ? "queued" : msg.state];
8430
+ const settledRows = wanted.includes("failed") ? bus.settledIds() : new Set<string>();
8431
+ const rows = bus.queueList(msg.peer).filter((row) => wanted.includes(row.state) && !(row.state === "failed" && settledRows.has(row.id)));
8432
+ const byState = Object.fromEntries(wanted.map((kind) => [kind, rows.filter((row) => row.state === kind).length]));
8433
+ const state = wanted.length === 1 ? wanted[0]! : wanted.join("+");
8434
+ const many = msg.states !== undefined ? { states: wanted, byState } : {};
8435
+ if (msg.confirmed !== true) return void reply({ ok: true, op: "discard", peer: msg.peer, state, ...many, count: rows.length, discarded: 0, refused: 0 });
8191
8436
  if (typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "discard needs a reason" });
8192
8437
  if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
8193
8438
  let discarded = 0; const refused: Record<string, number> = {};
@@ -8196,7 +8441,7 @@ export async function startDaemon(opts: DaemonOptions) {
8196
8441
  // discarding it, so the 12th discard is not refused as stale because of the 11 before it. A row that is gone or changed state meanwhile is counted as refused.
8197
8442
  const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
8198
8443
  try {
8199
- if (!current || current.state !== state) throw new Error("unknown delivery");
8444
+ if (!current || current.state !== row.state) throw new Error("unknown delivery");
8200
8445
  bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim());
8201
8446
  if (row.peer.includes("@")) roomChat.disposition(row.peer, row.id, "discard");
8202
8447
  discarded++;
@@ -8204,7 +8449,7 @@ export async function startDaemon(opts: DaemonOptions) {
8204
8449
  catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
8205
8450
  }
8206
8451
  writeStatus();
8207
- return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded, refused: rows.length - discarded, ...(rows.length - discarded ? { refusedBy: refused } : {}) });
8452
+ return void reply({ ok: true, op: "discard", peer: msg.peer, state, ...many, count: rows.length, discarded, refused: rows.length - discarded, ...(rows.length - discarded ? { refusedBy: refused } : {}) });
8208
8453
  }
8209
8454
  if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
8210
8455
  if (msg.op === "show") {
@@ -8238,6 +8483,7 @@ export async function startDaemon(opts: DaemonOptions) {
8238
8483
  return void reply({ ok: true });
8239
8484
  }
8240
8485
  case "inbox": {
8486
+ if (stopping || recoveryActive()) return void reply({ ok: false, error: "the hub is not serving inbox reads now (recovery or shutdown); run ahub status, then call hub_inbox again after it recovers" });
8241
8487
  if (c.toolsOwner) {
8242
8488
  const peer = c.peer!, current = captureImplementerTools(c), launch = currentImplementerTools(c);
8243
8489
  try {
@@ -8245,9 +8491,9 @@ export async function startDaemon(opts: DaemonOptions) {
8245
8491
  const pending = bus.pendingEnvelopes(peer);
8246
8492
  if (!current() || pending.some(env => { const task = env.refs?.task === undefined ? undefined : board.get(Number(env.refs.task)); return env.private || contextPii(peer) || !!task && tasks.isPii(task); })) return void reply({ ok: false, error: "private inbox delivery refused" });
8247
8493
  if (!launch || pending.some(env => (env.refs?.task === undefined ? implementerChatTask(peer) : Number(env.refs.task)) !== launch.task)) return void reply({ ok: false, error: "inbox delivery belongs to another task session" });
8248
- const envs = bus.pull(peer);
8494
+ const envs = bus.pull(peer, { allowNeedsReviewHold: true });
8249
8495
  if (!envs) return void reply({ ok: false, error: queueHold(peer) ?? "delivery held" });
8250
- return void reply({ ok: true, envs, waiting: bus.queued(peer), waitingImportant: bus.queuedImportant(peer) });
8496
+ return void reply({ ok: true, envs, held: inboxHeldMetadata(peer), waiting: bus.queued(peer), waitingImportant: bus.queuedImportant(peer) });
8251
8497
  } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
8252
8498
  }
8253
8499
  // The attached session can explicitly claim queued work even during a native turn (#493).
@@ -8255,7 +8501,7 @@ export async function startDaemon(opts: DaemonOptions) {
8255
8501
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
8256
8502
  if (c.role !== "peer" || !isWsPeer(peer) || !peer.owns(sock)) return void reply({ ok: false, error: "not attached to the hub yet; retry hub_inbox in a moment" });
8257
8503
  try {
8258
- const envs = bus.pull(peer.id);
8504
+ const envs = bus.pull(peer.id, { allowNeedsReviewHold: true });
8259
8505
  if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
8260
8506
  peer.lastInboxAt = Date.now();
8261
8507
  let polling: ReturnType<InboxPolling["read"]>;
@@ -8266,7 +8512,7 @@ export async function startDaemon(opts: DaemonOptions) {
8266
8512
  } catch { polling = undefined; } // The pull already completed: advice must never discard its envelopes.
8267
8513
  writeStatus();
8268
8514
  if (envs.length) log(`${peer.id} read ${envs.length} queued message(s) through hub_inbox`);
8269
- return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id), ...(polling ? { pollingAdvice: { count: polling.count, minutes: polling.minutes } } : {}) });
8515
+ return void reply({ ok: true, envs, held: inboxHeldMetadata(peer.id), waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id), ...(polling ? { pollingAdvice: { count: polling.count, minutes: polling.minutes } } : {}) });
8270
8516
  } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
8271
8517
  }
8272
8518
  case "delivery_complete": {
@@ -8791,6 +9037,15 @@ export async function startDaemon(opts: DaemonOptions) {
8791
9037
  );
8792
9038
  return;
8793
9039
  }
9040
+ if (msg.op === "authorize-release") {
9041
+ const asker = c.role === "console" ? USER : c.role === "tools" && c.peer && conductorPeer(config.roles) === c.peer && !c.toolsOwner && !turns.get(c.peer)?.private && !contextPii(c.peer) ? c.peer : undefined;
9042
+ if (!asker) return void reply({ ok: false, error: "only the console user or the configured conductor may authorize a release" });
9043
+ void releaseAuthorizations.authorize(asker, msg.version, msg.commit).then(
9044
+ authorization => reply({ ok: true, authorization }),
9045
+ error => reply({ ok: false, error: releaseAuthorizationError(error) }),
9046
+ );
9047
+ return;
9048
+ }
8794
9049
  if (c.role !== "console" && ["status", "explain", "queue", "release"].includes(msg.op)) {
8795
9050
  let current: () => void;
8796
9051
  try { current = publicReadCurrent(sock); current(); } catch { return void reply({ ok: false, error: "public lifecycle read unavailable for this caller" }); }
@@ -8831,13 +9086,6 @@ export async function startDaemon(opts: DaemonOptions) {
8831
9086
  return;
8832
9087
  }
8833
9088
  if (c.role !== "console") return void reply({ ok: false, error: "SDLC readback is console only; task owners record ids through hub_sdlc_record" });
8834
- if (msg.op === "authorize-release") {
8835
- void releaseAuthorizations.authorize(USER, msg.version, msg.commit).then(
8836
- authorization => reply({ ok: true, authorization }),
8837
- error => reply({ ok: false, error: releaseAuthorizationError(error) }),
8838
- );
8839
- return;
8840
- }
8841
9089
  if (msg.op === "queue" || msg.op === "release") {
8842
9090
  if (typeof msg.repo !== "string" || !Array.isArray(msg.liveIssues ?? []) || (msg.liveIssues ?? []).length > 32) return void reply({ ok: false, error: "invalid delivery readback arguments" });
8843
9091
  const read = msg.op === "queue" ? tasks.deliveryQueue(msg.repo) : tasks.releaseReadiness(msg.repo, msg.liveIssues ?? []);
@@ -8947,8 +9195,18 @@ export async function startDaemon(opts: DaemonOptions) {
8947
9195
  }
8948
9196
  return void reply({ ok: false, error: "unknown room operation" });
8949
9197
  }
8950
- case "status":
8951
- return void reply({ t: "status", status: status(c.role === "console", false, true) });
9198
+ case "status": {
9199
+ const current = status(c.role === "console", false, true);
9200
+ if ((c.role === "console" || c.role === "tools") && msg.specs === true) {
9201
+ const summary = projectSpecs.status();
9202
+ if (summary) {
9203
+ const missingBlockHint = !specsMissingBlockHintShown && projectSpecs.missingBlock() === true;
9204
+ if (missingBlockHint) specsMissingBlockHintShown = true;
9205
+ return void reply({ t: "status", status: { ...current, specs: { ...summary, ...(missingBlockHint ? { missingBlockHint: true } : {}) } } });
9206
+ }
9207
+ }
9208
+ return void reply({ t: "status", status: current });
9209
+ }
8952
9210
  case "cli_audit_ack": {
8953
9211
  if (c.role !== "console") return void reply({ ok: false, error: "cli_audit_ack is a console command" });
8954
9212
  if (typeof msg.auditId !== "string" || !/^[a-f0-9-]{36}$/.test(msg.auditId)) return void reply({ ok: false, error: "invalid audit id" });
@@ -9024,8 +9282,11 @@ export async function startDaemon(opts: DaemonOptions) {
9024
9282
  case "native_tui_launch": {
9025
9283
  if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
9026
9284
  const peer = String(msg.peer), owner = bus.peers.get(peer);
9027
- if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer) ||
9028
- peer !== definitionOf(peer)?.kind || !isJcodePeer(owner) || !jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return void reply({ ok: false, error: "owned native TUI launch is unavailable" });
9285
+ // #1085: every refusal names its cause class, in the reply and as one hub.log line. The class is a closed word: never a session id, launch id, socket or path.
9286
+ const refuseTui = (code: string, error: string, resumed?: boolean) => { log(`native_tui_launch jcode refused: ${code}${resumed ? " (resumed session)" : ""}`); return void reply({ ok: false, code, ...(resumed ? { resumed: true } : {}), error }); };
9287
+ if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer)) return refuseTui("hub_busy", "owned native TUI launch is unavailable");
9288
+ if (peer !== definitionOf(peer)?.kind || !isJcodePeer(owner)) return refuseTui("not_running", "owned native TUI launch is unavailable");
9289
+ if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return refuseTui("mcp_stale", "owned native TUI launch is unavailable");
9029
9290
  try {
9030
9291
  if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
9031
9292
  const options = msg.args ?? {};
@@ -9033,7 +9294,8 @@ export async function startDaemon(opts: DaemonOptions) {
9033
9294
  return void reply({ ok: true, ...owner.tuiLaunch(msg.binding, options) });
9034
9295
  } catch (error) {
9035
9296
  if (error instanceof JcodeOptionMismatch) return void reply({ ok: false, option: error.option, error: `owned Jcode launch differs from its running session in ${error.option}; start without it or stop jcode first` });
9036
- return void reply({ ok: false, error: "owned Jcode launch or saved session could not be verified; details withheld" });
9297
+ if (error instanceof JcodeTuiRefusal) return refuseTui(error.code, "owned Jcode launch or saved session could not be verified; details withheld", owner.resumedSession);
9298
+ return refuseTui("bad_request", "owned Jcode launch or saved session could not be verified; details withheld");
9037
9299
  }
9038
9300
  }
9039
9301
  case "peer_start":
@@ -9048,7 +9310,7 @@ export async function startDaemon(opts: DaemonOptions) {
9048
9310
  return;
9049
9311
  case "start":
9050
9312
  if (c.role !== "console") return void reply({ t: "started", ok: false, error: "start is a console command; use hub_peer_start with the conductor role" });
9051
- if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error: "recovery is holding mutations" });
9313
+ if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error: startHeldByRecovery(msg.peer) });
9052
9314
  if (recoveryActive() && !recoveryPeerAllowed(String(msg.peer))) return void reply({ t: "started", ok: false, error: "peer is not part of the recovery roster" });
9053
9315
  startPeer(String(msg.peer), msg.args ?? {}, msg.args?.replaceSession === true ? piReplacementAuthority : undefined)
9054
9316
  .catch((e: Error) => ({ ok: false, error: e.message }))
@@ -9081,10 +9343,8 @@ export async function startDaemon(opts: DaemonOptions) {
9081
9343
  // #933: a console task op the conductor may run: admitted (role recheck and one audit row) before Tasks sees it; Tasks asks `operational` again.
9082
9344
  let current: () => void;
9083
9345
  try { current = admitOperational(sock, CONDUCTOR_TASK_OPS[String(msg.op)]!); } catch (error) { return void reply({ t: "task", ...operationalFailure(error) }); }
9084
- void operationalContext.run({ actor: c.peer, current }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})).then(
9085
- (text) => reply({ t: "task", ok: true, text }),
9086
- (e: Error) => reply({ t: "task", ...operationalFailure(e) }),
9087
- );
9346
+ runTaskRequest(() => operationalContext.run({ actor: c.peer!, current }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})), current,
9347
+ error => ({ t: "task", ...operationalFailure(error) }));
9088
9348
  return;
9089
9349
  }
9090
9350
  let invocation: AbortController | undefined;
@@ -9097,14 +9357,14 @@ export async function startDaemon(opts: DaemonOptions) {
9097
9357
  invocation = new AbortController(); c.invocationRequests.set(msg.rid, invocation);
9098
9358
  }
9099
9359
  const run = async () => c.externalOwner ? childToolAuthority.run({ by: c.peer!, current: () => currentExternalTools(c, true) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : c.toolsOwner ? childToolAuthority.run({ by: c.peer!, current: captureImplementerTools(c) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {});
9100
- (invocation ? invocationRequestSignal.run({ signal: invocation.signal, current: () => {
9360
+ const execute = () => (invocation ? invocationRequestSignal.run({ signal: invocation.signal, current: () => {
9101
9361
  const live = !c.closed && sock.readyState === WebSocket.OPEN && c.invocationRequests?.get(msg.rid) === invocation;
9102
9362
  if (!live) invocation?.abort();
9103
9363
  return live;
9104
- } }, run) : run()).then(
9105
- (text) => reply({ t: "task", ok: true, text }),
9106
- (e: Error) => reply({ t: "task", ok: false, error: e.message }),
9107
- ).finally(() => { if (invocation && c.invocationRequests?.get(msg.rid) === invocation) c.invocationRequests.delete(msg.rid); });
9364
+ } }, run) : run());
9365
+ if (invocation) void execute().then(text => reply({ t: "task", ok: true, text }), (error: Error) => reply({ t: "task", ok: false, error: error.message }))
9366
+ .finally(() => { if (c.invocationRequests?.get(msg.rid) === invocation) c.invocationRequests.delete(msg.rid); });
9367
+ else runTaskRequest(execute);
9108
9368
  return;
9109
9369
  case "pause":
9110
9370
  case "resume": {