@staix/agent-hub 0.16.0 → 0.17.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 (46) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/adapters.md +18 -5
  4. package/docs/agent-notes/daemon.md +4 -3
  5. package/docs/agent-notes/tasks.md +2 -2
  6. package/docs/agent-notes/tests.md +5 -3
  7. package/docs/dogfood.md +6 -3
  8. package/docs/operations.md +45 -18
  9. package/docs/quickstart.md +6 -2
  10. package/docs/roles.md +1 -0
  11. package/docs/sdlc.md +11 -11
  12. package/docs/security.md +15 -5
  13. package/docs/smoke.md +51 -3
  14. package/docs/specs/2026-09-19-agent-hub-design.md +40 -4
  15. package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
  16. package/docs/verification/2026-10-25-0.17.0.md +74 -0
  17. package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
  18. package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
  19. package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
  20. package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
  21. package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
  22. package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
  23. package/docs/verified.json +20 -17
  24. package/package.json +1 -1
  25. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  26. package/plugins/agent-hub/server.js +1022 -160
  27. package/src/adapters/codex-review-isolation.ts +10 -1
  28. package/src/adapters/compat.ts +7 -2
  29. package/src/adapters/conformance/enrollment.ts +18 -4
  30. package/src/adapters/conformance/factories.ts +4 -1
  31. package/src/adapters/conformance/kit.ts +11 -0
  32. package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
  33. package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
  34. package/src/adapters/definitions/native-factories.ts +8 -0
  35. package/src/adapters/definitions/native-recovery.ts +8 -0
  36. package/src/adapters/definitions/qwen.ts +7 -4
  37. package/src/adapters/qwen-dual.ts +578 -0
  38. package/src/adapters/registry-types.ts +5 -1
  39. package/src/adapters/registry.ts +9 -1
  40. package/src/adapters/reviewer-mcp.ts +12 -6
  41. package/src/adapters/reviewer-print.ts +100 -17
  42. package/src/cli/upgrade.ts +4 -3
  43. package/src/hub/daemon.ts +134 -22
  44. package/src/hub/memory-plan-guard.ts +8 -33
  45. package/src/hub/tasks.ts +25 -12
  46. package/src/memory/brief.ts +45 -5
package/src/hub/daemon.ts CHANGED
@@ -46,7 +46,7 @@ import { peerRosterConfig, rosterAllows, staleInstanceKey, type PeerRoster } fro
46
46
  import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
47
47
  import { RecoveryDeferredHubError } from "./deferred-hub.ts";
48
48
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
49
- import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog } from "../adapters/registry.ts";
49
+ import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog, isFixedBuiltinDefinition } from "../adapters/registry.ts";
50
50
  import { isSupportedPeer as registrySupportedPeer, definitionOf as builtinDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
51
51
  import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
52
52
  import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
@@ -79,6 +79,7 @@ import type { ServerWebSocket } from "bun";
79
79
  import { isAcpPeer, type AcpPeer, QwenIdentityError, ACTION_BUDGET_OPTION, type PermissionRequest, type ActionGateVerdict } from "../adapters/acp.ts";
80
80
  import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
81
81
  import { isPiPeer, type PiPeer } from "../adapters/pi.ts";
82
+ import { isQwenDualPeer, qwenDualVersionAsync, type QwenDualPeer } from "../adapters/qwen-dual.ts";
82
83
  import { startModelRelay, type ModelRelay } from "../models/relay.ts";
83
84
  import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
84
85
  import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
@@ -89,8 +90,8 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
89
90
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
90
91
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
91
92
  import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
92
- import { isReviewerPrintPeer, reviewerUnavailableReason, ReviewerUnavailableError, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
93
- import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
93
+ import { isReviewerPrintPeer, reviewerNoVerdictLine, reviewerUnavailableReason, ReviewerUnavailableError, REVIEWER_FALLBACK_UNCONFIRMED, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
94
+ import { createReviewerMcpSession, type ReviewerToolName } from "../adapters/reviewer-mcp.ts";
94
95
  import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
95
96
  import { reviewDiff } from "../adapters/claude-print.ts";
96
97
  import { Capture } from "../memory/capture.ts";
@@ -152,7 +153,7 @@ import { detectTooling } from "./tooling.ts";
152
153
  import { looksSecret, DEFAULT_LEASE_MS, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
153
154
  import { memoryProjectKey } from "@staix/agent-mem";
154
155
  import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
155
- import { syncMemory } from "@staix/agent-mem";
156
+ import { syncMemory, createIntentBriefSource, createStandalonePolicy } from "@staix/agent-mem";
156
157
  import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
157
158
  import { nativeRecall } from "../memory/recall.ts";
158
159
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
@@ -593,6 +594,9 @@ export function resolutionRefusal(error: unknown): string {
593
594
  return RESOLUTION_CAUSES.find(([pattern]) => pattern.test(text))?.[1] ?? "resolution refused: inspect ahub queue show for the delivery's state";
594
595
  }
595
596
 
597
+ /** No remote set and a stop are not failures: agent-mem words the missing remote "no sync remote" (v1) or "no generation sync remote configured". */
598
+ export const quietSyncError = (error: string | undefined): boolean => /^(no (generation )?sync remote|stopped)/.test(error ?? "");
599
+
596
600
  export async function startDaemon(opts: DaemonOptions) {
597
601
  const projectId = opts.projectId ?? randomUUID();
598
602
  const instanceId = opts.instanceId ?? randomUUID();
@@ -630,6 +634,7 @@ export async function startDaemon(opts: DaemonOptions) {
630
634
  const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
631
635
  const verifiedTerminalRootsThisTurn = new Set<object>();
632
636
  const reviewerUnavailable = new Map<string, string>();
637
+ const reviewerToolObservers = new Map<string, (task: number, tool: ReviewerToolName, refusal?: string) => void>();
633
638
  const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
634
639
  const permissionModesFromInstances: Record<string, PermissionMode> = {};
635
640
  const startModesFromInstances: PeerStartConfig = {};
@@ -1197,6 +1202,47 @@ export async function startDaemon(opts: DaemonOptions) {
1197
1202
  }
1198
1203
  async function taskMemoryItems(task: import("../memory/brief.ts").BriefTask) { return (await taskMemoryResult(task)).items; }
1199
1204
 
1205
+ async function taskIntentBrief(task: import("../memory/brief.ts").BriefTask): Promise<import("../memory/brief.ts").IntentBriefContext> {
1206
+ checkMemory();
1207
+ const initial = task.id === undefined ? undefined : board.get(task.id), revision = configRevision;
1208
+ const allowed = () => {
1209
+ const current = task.id === undefined ? undefined : board.get(task.id);
1210
+ return !stopping && configRevision === revision && !!initial && !!current && current.created === task.created && current.updated === initial.updated
1211
+ && current.history.length === initial.history.length && current.owner === initial.owner && !tasks.isPii(current)
1212
+ && !turns.get(current.owner ?? "")?.private && !contextPii(current.owner ?? "");
1213
+ };
1214
+ const assertCurrent = () => { checkMemory(); if (!allowed()) throw Error("memory brief context changed"); };
1215
+ assertCurrent();
1216
+ // Derived code neighbours are additional read-time selectors, never persisted memory or authority.
1217
+ const starts = taskNodes(task).filter(node => node.startsWith("symbol:"));
1218
+ let extra: string[] = [];
1219
+ if (starts.length && tasks.memoryCodeEligible(initial!)) {
1220
+ try {
1221
+ const prepared = await memoryCode(starts, allowed);
1222
+ assertCurrent();
1223
+ extra = currentMemoryCode(prepared).nodes.map(node => node.id).filter(node => node.startsWith("symbol:")).map(node => node.slice(7));
1224
+ } catch { /* Optional code evidence never removes direct intent memory. */ }
1225
+ }
1226
+ assertCurrent();
1227
+ const stale = staleness();
1228
+ const policy = createStandalonePolicy({ denyPaths: config.local.deny, host: { screen: async value => {
1229
+ assertCurrent();
1230
+ if (value && typeof value === "object" && "id" in value) {
1231
+ const row = value as MemoryView;
1232
+ return screenMemory([row]).length === 1 && tasks.publicMetadataText(JSON.stringify(row));
1233
+ }
1234
+ // The trusted host locator is not note content (a private directory can contain this project).
1235
+ const { cwd: _cwd, ...query } = value as Record<string, unknown>;
1236
+ return boundedSafe(JSON.stringify(query));
1237
+ } } });
1238
+ return { source: createIntentBriefSource(memoryStore!, memoryProject), options: {
1239
+ project: memoryProject, cwd: opts.cwd, repo: memoryRepo, intent: task.title,
1240
+ paths: [...task.refs.paths ?? [], ...task.plan?.paths ?? []], symbols: [...task.plan?.symbols ?? [], ...extra],
1241
+ policy: () => policy, policyVersion: () => String(configRevision), allowed, assertCurrent,
1242
+ stale: row => stale(row.about), now: () => memoryStore!.readNow(),
1243
+ } };
1244
+ }
1245
+
1200
1246
  const leaseNoticed = new Set<string>();
1201
1247
  const goalLine = (g: GoalView) => `goal:${g.id.slice(0, 8)} ${g.state} ${g.title}${g.lease.holder ? ` (held by ${g.lease.holder === memoryStore?.machine.id ? "this machine" : `machine ${g.lease.holder.slice(0, 8)}`} until ${g.lease.until}${g.lease.conflict.length ? `; conflicting claim from ${g.lease.conflict.map((m) => m.slice(0, 8)).join(", ")}` : ""})` : ""}`;
1202
1248
  /**
@@ -1256,7 +1302,7 @@ export async function startDaemon(opts: DaemonOptions) {
1256
1302
  try {
1257
1303
  checkMemory();
1258
1304
  const r = await (opts.memorySync ?? syncMemory)(memoryStore!, { signal: syncStop.signal });
1259
- if (!r.ok && !/^(no sync remote|stopped)/.test(r.error ?? "")) log(`hub memory sync failed: ${r.error}`);
1305
+ if (!r.ok && !quietSyncError(r.error)) log(`hub memory sync failed: ${r.error}`);
1260
1306
  else if (r.warning) log(`hub memory sync: ${r.warning}`);
1261
1307
  } catch { /* A stale binding disables native memory, never the bus. */ }
1262
1308
  finally { syncRun = undefined; }
@@ -1497,7 +1543,8 @@ export async function startDaemon(opts: DaemonOptions) {
1497
1543
  };
1498
1544
  // Bind both legacy print transports and task-MCP calls to the child's original review request.
1499
1545
  const runningReviewAuthority = (by: PeerId, task: number): (() => string | undefined) | undefined => {
1500
- if (!definitionOf(by)?.reviewer) return undefined;
1546
+ // #792: only a reviewer backend runs bound reviews; an unavailable placeholder slot (agy) fences nothing.
1547
+ if (!definitionOf(by)?.reviewer?.backend) return undefined;
1501
1548
  const attached=bus.peers.get(by), peer=isReviewerPrintPeer(attached)?attached:undefined, token=peer?.reviewToken(task), binding=peer?.reviewBinding(task);
1502
1549
  return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
1503
1550
  ? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
@@ -1584,7 +1631,8 @@ export async function startDaemon(opts: DaemonOptions) {
1584
1631
  ...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
1585
1632
  memoryStore ? taskMemoryItems : undefined,
1586
1633
  (peer, task, counts) => event({ type: "memory_brief", source: "amem", peer, task, ...counts }),
1587
- items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }) } : {}),
1634
+ items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); },
1635
+ memoryStore ? taskIntentBrief : undefined) } : {}),
1588
1636
  ...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
1589
1637
  checkMemory();
1590
1638
  const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny };
@@ -2141,7 +2189,7 @@ export async function startDaemon(opts: DaemonOptions) {
2141
2189
  const bound = executionOwners.get(peer);
2142
2190
  if (bound?.socket) {
2143
2191
  const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
2144
- if (!isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
2192
+ if (!isFixedBuiltinDefinition(definitionOf(peer)) || !isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
2145
2193
  revokeTerminalExecution(peer);
2146
2194
  return undefined;
2147
2195
  }
@@ -2423,6 +2471,9 @@ export async function startDaemon(opts: DaemonOptions) {
2423
2471
  if (nativeTurn && effectful) nativeTurn.effectsPending = (nativeTurn.effectsPending ?? 0) + 1;
2424
2472
  const conducted = conductorPeer(config.roles) === by && conductedMutations.has(op);
2425
2473
  const admittedId = typeof input?.id === "number" && Number.isSafeInteger(input.id) && input.id > 0 ? input.id : undefined;
2474
+ const reviewTool = op === "hub_review" || op === "hub_task_show" ? op : undefined;
2475
+ const traceId = admittedId ?? (typeof input?.id === "string" && /^[1-9][0-9]{0,15}$/.test(input.id) && Number.isSafeInteger(Number(input.id)) ? Number(input.id) : undefined);
2476
+ if (reviewTool && traceId !== undefined) reviewerToolObservers.get(by)?.(traceId, reviewTool);
2426
2477
  taskOpsInFlight++;
2427
2478
  try {
2428
2479
  const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
@@ -2438,6 +2489,7 @@ export async function startDaemon(opts: DaemonOptions) {
2438
2489
  const hint = args[3] ? ownHeldHint(by) : "";
2439
2490
  return hint ? `${text}\n\n${hint}` : text;
2440
2491
  } catch (error) {
2492
+ if (reviewTool && traceId !== undefined) reviewerToolObservers.get(by)?.(traceId, reviewTool, error instanceof Error ? error.message : "review tool refused");
2441
2493
  if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
2442
2494
  // Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
2443
2495
  const hint = args[3] ? ownHeldHint(by) : "";
@@ -2793,7 +2845,7 @@ export async function startDaemon(opts: DaemonOptions) {
2793
2845
  // `ahub undo --context` (issue #33): the conversation half of an undo; the CLI restores the files itself.
2794
2846
  const turn = turnLog?.get(String(a.turn));
2795
2847
  if (!turn) throw new Error(`no turn ${a.turn} recorded`);
2796
- if (!definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
2848
+ if (!isFixedBuiltinDefinition(definitionOf(turn.peer)) || !definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
2797
2849
  if (turnLog!.latest(turn.peer)?.id !== turn.id) throw new Error(`turn ${turn.id} is not Codex's latest turn; reverting it would drop the later ones too`);
2798
2850
  const codex = bus.peers.get(turn.peer);
2799
2851
  if (!isCodexPeer(codex)) throw new Error("codex is not attached");
@@ -3190,7 +3242,7 @@ export async function startDaemon(opts: DaemonOptions) {
3190
3242
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
3191
3243
  codexProxyPort: opts.codexProxyPort,
3192
3244
  peers: Object.fromEntries(
3193
- peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd, ...(!ownerWriteRoot(id) ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
3245
+ peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isFixedBuiltinDefinition(definitionOf(id)) && (isCodexPeer(p) || isQwenDualPeer(p)) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd, ...(!ownerWriteRoot(id) ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
3194
3246
  ),
3195
3247
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
3196
3248
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
@@ -3265,7 +3317,7 @@ export async function startDaemon(opts: DaemonOptions) {
3265
3317
  settlingSuperseded = true;
3266
3318
  try {
3267
3319
  for (const row of bus.queueList(undefined, true)) {
3268
- if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
3320
+ if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:") || row.reason?.startsWith(REVIEWER_FALLBACK_UNCONFIRMED)) continue;
3269
3321
  const reason = supersededDeliveryReason(row.originals, board, row.peer);
3270
3322
  if (!reason) continue;
3271
3323
  try { bus.resolveDelivery(row.id, row.revision, "completed", reason, "hub"); }
@@ -3984,6 +4036,7 @@ export async function startDaemon(opts: DaemonOptions) {
3984
4036
  const owner = bus.peers.get(peer);
3985
4037
  const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
3986
4038
  if (stopNotice) return { ok: false, error: stopNotice };
4039
+ if (isQwenDualPeer(owner)) return { ok: false, error: "Qwen belongs to its own terminal; end it there (the hub never signals a person's Qwen)" };
3987
4040
  if (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner) || definitionOf(peer)?.start.daemonPort === "claude-implementer" && !!owner?.ownedTools)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3988
4041
  if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
3989
4042
  if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
@@ -4083,6 +4136,12 @@ export async function startDaemon(opts: DaemonOptions) {
4083
4136
  if (args.model !== undefined && !["dgx/coding", "dgx/fast", "mlx/fast"].includes(args.model)) return { ok: false, error: "unknown Pi model alias" };
4084
4137
  }
4085
4138
  const existing = bus.peers.get(peer);
4139
+ if (adapter?.start.createTui && existing) {
4140
+ // One Qwen at a time, in one form: a headless worker and the person's TUI would both read the same session's work. The TUI is judged by the process it holds, not by peer
4141
+ // state: a detached TUI reads offline while the person's Qwen keeps running, and replacing that adapter would strand it and admit a second Qwen beside it.
4142
+ if (isQwenDualPeer(existing) && existing.holdsTerminal()) return args.mode === "tui" ? { ok: true, already: true } : { ok: false, error: "Qwen runs in its own terminal; end it there before starting a headless one" };
4143
+ if (args.mode === "tui" && !isQwenDualPeer(existing) && existing.state !== "offline") return { ok: false, error: "Qwen is running headless; run ahub stop qwen before starting its TUI" };
4144
+ }
4086
4145
  if (args.replaceSession) {
4087
4146
  if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
4088
4147
  if (!args.sessionFile || args.sessionId || args.fresh) return { ok: false, error: "Pi replacement requires --replace-session --session-file <managed-file>, without --session-id or fresh" };
@@ -4130,12 +4189,49 @@ export async function startDaemon(opts: DaemonOptions) {
4130
4189
  if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined && args.sessionId !== adapter.session.currentId(existing)) {
4131
4190
  return { ok: false, error: "configured ACP is already running a different native session; stop it before selecting another" };
4132
4191
  }
4133
- return { ok: true, already: true, ...(isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
4192
+ return { ok: true, already: true, ...(isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
4134
4193
  }
4135
4194
  if (!adapter?.start.reconfigureWhileAttached) {
4136
4195
  if (isPiPeer(existing)) await stopPiForReplacement(existing);
4137
4196
  else await existing?.stop();
4138
4197
  }
4198
+ if (adapter?.start.createTui && args.mode === "tui") {
4199
+ // #286 Part 3: Qwen's own TUI, driven through its dual-output files. The person's terminal runs it; the hub prepares the files and returns the launch.
4200
+ if (declarations[peer]) return { ok: false, error: "declared instances support headless starts only" };
4201
+ if (!onRoster(peer)) return { ok: false, error: "qwen is off roster; a person can add it with ahub roster add qwen" };
4202
+ if (existing && existing.state !== "offline") return { ok: false, error: "Qwen is running headless; run ahub stop qwen before starting its TUI" };
4203
+ // The compat entry of the dual-output contract, not the headless ACP entry: 0.26.1 only.
4204
+ const verdict = await qwenDualVersionAsync(config.qwen_cmd, executionCwd);
4205
+ if (!verdict.ok) return { ok: false, error: verdict.reason };
4206
+ if (verdict.note) log(verdict.note);
4207
+ if (args.sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId: args.sessionId }).sessionId) return { ok: false, error: "invalid Qwen session identity" };
4208
+ actionBudgets.revoke(peer);
4209
+ const nativeRoot = nativeStateDir(peer);
4210
+ mkdirSync(nativeRoot, { recursive: true });
4211
+ const qwenState = join(nativeRoot, "qwen");
4212
+ const tui: QwenDualPeer = adapter.start.createTui({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["qwen-dual"]({
4213
+ cwd: executionCwd, stateDir: qwenState, cmd: config.qwen_cmd, ...(args.model ? { model: args.model } : {}), ...(args.sessionId ? { sessionId: args.sessionId } : {}),
4214
+ mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: toolEnv(peer, true) }],
4215
+ watchdogMs: config.watchdog_ms, preamble: sessionRoleContract(peer),
4216
+ // The hub's own tools only, by their exact `mcp__agent-hub__<name>`; everything else asks the console. Never a prefix, never a title.
4217
+ autoApprove: name => !stopping && HUB_TOOL_TITLES.has(name),
4218
+ // `stateDir` is the hub's own state dir: the adapter refuses any Qwen write or command that names it (the input file is a prompt channel).
4219
+ guardDir: opts.stateDir,
4220
+ onPermission, actionGate: (call, session) => acpActionGate(peer)({ names: call.names, input: call.input }, session),
4221
+ onStalled: () => writeStatus(),
4222
+ onNotice: text => { try { notify(text); } catch { /* the notice is advisory */ } },
4223
+ onExit: () => { notify(`Qwen's terminal ended; start it again with ahub qwen --mode tui`); writeStatus(); },
4224
+ log,
4225
+ }) }) as QwenDualPeer;
4226
+ recoveryTaskPreface(peer); await ensurePreface(peer);
4227
+ executionOwners.set(peer, { owner: tui, cwd: executionCwd });
4228
+ try {
4229
+ await tui.start();
4230
+ bus.add(tui);
4231
+ if (tui.state !== "offline") tui.onState?.(tui.state);
4232
+ } catch (error) { await tui.stop().catch(stop => log(`qwen tui stop after a failed start: ${(stop as Error).message}`)); throw error; }
4233
+ return { ok: true, mode: "tui", ...(tui.adopted ? { adopted: true } : { launch: tui.launch }) };
4234
+ }
4139
4235
  if (adapter?.start.daemonPort === "owned-acp") {
4140
4236
  if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return { ok: false, error: "Jcode project MCP entry missing or stale; the person runs ahub jcode to configure it" };
4141
4237
  // #691: name a version refusal before the owned daemon starts; the ACP identity check stays the binding one.
@@ -4638,7 +4734,8 @@ export async function startDaemon(opts: DaemonOptions) {
4638
4734
  privatePaths: [opts.stateDir, ...(config.pii?.paths ?? []), ...config.local.deny],
4639
4735
  bindingCurrent: (task,binding) => tasks.reviewBindingCurrent(peer,task,binding),
4640
4736
  taskMcp: (task, current) => createReviewerMcpSession({ task, current, tools: [...TASK_TOOLS, ...CONDUCTOR_TOOLS],
4641
- call: (name, args) => taskOp(peer, name, args, true, contextPii(peer)) }),
4737
+ call: (name, args) => taskOp(peer, name, args, true, contextPii(peer)),
4738
+ onTool: (tool, refusal) => { if (bus.peers.get(peer) === reviewer) reviewer.recordToolTrace(task, { tool, ...(refusal === undefined ? {} : { refusal }) }); } }),
4642
4739
  admit: env => {
4643
4740
  if (env.private || contextPii(peer) || !reviewOnlyPeer(peer) || !tasks.reviewBindingCurrent(peer, Number(env.refs?.task),
4644
4741
  { head: env.refs?.commit, flowRevision: Number(env.refs?.reviewRevision) })) return false;
@@ -4652,16 +4749,26 @@ export async function startDaemon(opts: DaemonOptions) {
4652
4749
  facts: env => env.private ? undefined : reviewDiff(executionCwd, env.refs?.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(executionCwd, path, config.pii?.paths ?? [], true), nameable: tasks.nameable }),
4653
4750
  verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
4654
4751
  onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
4752
+ onNoVerdict: (diagnostic, current) => {
4753
+ const task = board.get(diagnostic.task);
4754
+ const visible = current() && bus.peers.get(peer) === reviewer && !!task && !tasks.isPii(task) && !contextPii(peer)
4755
+ && tasks.reviewBindingCurrent(peer, diagnostic.task, diagnostic);
4756
+ // Screen the complete refusal before any sanitization or bound; no task text or model output is logged.
4757
+ const hostSafe = visible && !diagnostic.refusalUncertain && (diagnostic.hostRefusal === undefined || boundedSafe(diagnostic.hostRefusal) && tasks.publicMetadataText(diagnostic.hostRefusal));
4758
+ const safe = visible && hostSafe && diagnostic.refusal !== undefined && boundedSafe(diagnostic.refusal) && tasks.publicMetadataText(diagnostic.refusal) ? diagnostic.refusal : undefined;
4759
+ log(reviewerNoVerdictLine(peer, diagnostic, safe, !visible));
4760
+ },
4655
4761
  onUnavailable: (failure, current) => {
4656
4762
  const attached = () => current() && bus.peers.get(peer) === reviewer;
4657
- if (!attached()) return;
4763
+ if (!attached()) return false;
4658
4764
  if (failure.cause === "start_failed") {
4659
4765
  // #722: the class (a closed list) is the only detail; never a path, native text or token.
4660
4766
  const reason = reviewerUnavailableReason(failure.causeClass ?? "unknown", failure.causeDetail);
4661
4767
  log(`reviewer ${peer} unavailable for task #${failure.task}: ${sanitize(reason).slice(0, 300)}`);
4662
4768
  reviewerUnavailable.set(peer, sanitize(reason).slice(0, 1000));
4663
4769
  }
4664
- return tasks.retryReviewer(failure.task, peer, failure, attached).then(() => undefined);
4770
+ let confirmed = false;
4771
+ return tasks.retryReviewer(failure.task, peer, failure, attached, () => { confirmed = true; }).then(() => confirmed);
4665
4772
  },
4666
4773
  log,
4667
4774
  }) }) as ReviewerPrintPeer;
@@ -4677,6 +4784,10 @@ export async function startDaemon(opts: DaemonOptions) {
4677
4784
  }
4678
4785
  reviewerUnavailable.delete(peer);
4679
4786
  executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
4787
+ reviewerToolObservers.set(peer, (task, tool, refusal) => {
4788
+ try { if (bus.peers.get(peer) === reviewer) reviewer.recordToolTrace(task, { tool, ...(refusal === undefined ? {} : { refusal }) }); }
4789
+ catch { /* Diagnostics never change a host tool's result or authority. */ }
4790
+ });
4680
4791
  bus.add(reviewer);
4681
4792
  return { ok: true };
4682
4793
  }
@@ -5797,6 +5908,7 @@ export async function startDaemon(opts: DaemonOptions) {
5797
5908
  }
5798
5909
 
5799
5910
  function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
5911
+ if (!isFixedBuiltinDefinition(definitionOf(peer))) return undefined;
5800
5912
  const row = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
5801
5913
  return row?.permissionHook ? row : undefined;
5802
5914
  }
@@ -5811,7 +5923,7 @@ export async function startDaemon(opts: DaemonOptions) {
5811
5923
  // A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
5812
5924
  // to it would run a mode the status no longer shows.
5813
5925
  // It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
5814
- const note = isCodexPeer(owner) && !owner.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
5926
+ const note = isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
5815
5927
  if (note) log(`permission mode codex: ${note}`);
5816
5928
  const from = permissionMode(peer);
5817
5929
  permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
@@ -5819,7 +5931,7 @@ export async function startDaemon(opts: DaemonOptions) {
5819
5931
  log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
5820
5932
  return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
5821
5933
  }
5822
- if (isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
5934
+ if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
5823
5935
  if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
5824
5936
  if (mode === undefined) return { ok: true, peer, permissionMode: permissionDisplay(peer, owner) };
5825
5937
  if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
@@ -5828,7 +5940,7 @@ export async function startDaemon(opts: DaemonOptions) {
5828
5940
  const launch = claudePermissionLaunch(peer);
5829
5941
  if (!launch || !isWsPeer(owner) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
5830
5942
  if (launch.unattended) return { ok: false, error: "Claude started --unattended already skips prompts; run ahub permission claude ask after it stops to clear the hub choice, then restart without --unattended" };
5831
- } else if (isCodexPeer(owner)) {
5943
+ } else if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner)) {
5832
5944
  if (!owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
5833
5945
  if (nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended already skips prompts; run ahub permission codex ask after it stops to clear the hub choice, then restart without --unattended" };
5834
5946
  }
@@ -5860,7 +5972,7 @@ export async function startDaemon(opts: DaemonOptions) {
5860
5972
  }
5861
5973
  const owner = bus.peers.get(peer);
5862
5974
  if (definitionOf(peer)?.permission.trackUnattended && nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended cannot confirm a permission default; run ahub permission codex ask after it stops, or restart without --unattended" };
5863
- if (!starting.has(peer) && isCodexPeer(owner) && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
5975
+ if (!starting.has(peer) && isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
5864
5976
  if (!starting.has(peer) && owner && owner.state !== "offline") {
5865
5977
  const result = await changePermission(peer, "never-ask", true);
5866
5978
  if (!result.ok) return result;
@@ -5891,9 +6003,9 @@ export async function startDaemon(opts: DaemonOptions) {
5891
6003
  const result = await permissionChange(peer, async () => {
5892
6004
  if (stopping) return { ok: false, error: "hub is stopping" };
5893
6005
  const selected = permissionMode(peer);
5894
- if (isCodexPeer(owner) && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
6006
+ if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
5895
6007
  if (!readOnlyPeer(peer) && !(isAcpPeer(owner) && owner.permissionModeState === "unmanaged") && owner.getPermissionMode() !== selected) {
5896
- if (isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
6008
+ if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
5897
6009
  else await owner.setPermissionMode(selected);
5898
6010
  }
5899
6011
  // The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
@@ -5972,7 +6084,7 @@ export async function startDaemon(opts: DaemonOptions) {
5972
6084
  preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
5973
6085
  preparedSessionRoleIdentities.delete(c.peer);
5974
6086
  ws.claim(sock, msg.channel === false);
5975
- if (declarations[c.peer] && definitionOf(c.peer)?.transport === "claude-channel") {
6087
+ if (declarations[c.peer] && isFixedBuiltinDefinition(definitionOf(c.peer)) && definitionOf(c.peer)?.transport === "claude-channel") {
5976
6088
  revokeTerminalExecution(c.peer);
5977
6089
  const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
5978
6090
  try {
@@ -1,42 +1,17 @@
1
- import { normalizeMemorySymbolInput } from "./memory-symbols.ts";
2
- import { extractNodes, nodePath, type View } from "@staix/agent-mem";
1
+ import { extractNodes, planGuardNodes as libraryPlanGuardNodes, matchingPlanFails as libraryMatchingPlanFails, type PlanGuardContext, type View } from "@staix/agent-mem";
3
2
  import type { BriefTask } from "../memory/brief.ts";
3
+ export type { PlanGuardContext } from "@staix/agent-mem";
4
4
 
5
- export interface PlanGuardContext { cwd: string; project: string; repo: string; deny?: string[] }
6
-
7
- /** Explicit plan/ref names only, with no filesystem probes or free-text inference. */
5
+ /** Preserve the hub task-shaped API while the library owns all matching logic. */
8
6
  export function planGuardNodes(task: BriefTask, context: PlanGuardContext): string[] {
9
- // Task refs/plan allow 150 explicit inputs; v1 note extraction caps each result at 64 nodes.
10
- // Normalize each input separately, retaining the original single-file symbol qualification rule.
11
- const nodes = new Set<string>();
12
- for (const path of [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])]) {
13
- for (const node of extractNodes("", { ...context, paths: [path], checkExists: false })) nodes.add(node);
14
- }
15
- const onlyPath = nodes.size === 1 ? nodePath([...nodes][0]!) : undefined;
16
- for (const symbol of task.plan?.symbols ?? []) {
17
- if (typeof symbol !== "string") continue;
18
- for (const form of new Set([symbol, normalizeMemorySymbolInput(symbol)])) {
19
- for (const node of extractNodes("", { ...context, paths: onlyPath ? [onlyPath] : [], symbols: [form], checkExists: false })) nodes.add(node);
20
- }
21
- }
22
- return [...nodes].sort();
7
+ return libraryPlanGuardNodes(task, context);
8
+ }
9
+ export function matchingPlanFails<T extends Pick<View, "kind" | "about" | "supersededBy">>(task: BriefTask, notes: readonly T[], context: PlanGuardContext): T[] {
10
+ return libraryMatchingPlanFails(task, notes, context);
23
11
  }
12
+
24
13
  /** Retrieval nodes retain the title/task context as well as the explicit plan query. */
25
14
  export function taskMemoryQueryNodes(task: BriefTask, context: PlanGuardContext): string[] {
26
15
  const ordinary = extractNodes(task.title, { ...context, paths: [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])], ...(task.id !== undefined && task.created !== undefined ? { task: { id: task.id, created: task.created } } : {}) });
27
16
  return [...new Set([...ordinary, ...planGuardNodes(task, context)])].sort();
28
17
  }
29
- const samePath = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
30
-
31
- /** Fail.about is the source of the graph's rules_out edges in v1; there is no additional stored rules_out field. */
32
- export function matchingPlanFails<T extends Pick<View, "kind" | "about" | "supersededBy">>(task: BriefTask, notes: readonly T[], context: PlanGuardContext): T[] {
33
- const nodes = planGuardNodes(task, context);
34
- if (!nodes.length) return [];
35
- return notes.filter(note => note.kind === "fail" && !note.supersededBy && note.about.some(about => nodes.some(node => {
36
- if (node === about) return true;
37
- if (node.startsWith("symbol:") && about.startsWith("symbol:")) return false; // Qualified symbols require both name and file identity.
38
- const path = nodePath(node), other = nodePath(about);
39
- // File/directory scope intersects at a whole path component, never a lexical substring.
40
- return !!path && !!other && samePath(path, other);
41
- })));
42
- }
package/src/hub/tasks.ts CHANGED
@@ -653,7 +653,10 @@ export class Tasks {
653
653
  if (this.isPii(row)) return false;
654
654
  const last = row.history.findLast(entry => entry.reviewFindings?.items.some(value => value.id === item.id));
655
655
  const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
656
- return !last?.withheld && finding && same(finding) && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
656
+ // #328: a follow-up that only records an earlier task's finding as fixed never had it; it counts once seen open.
657
+ // By id alone: a reclassified finding keeps its id (task note); a withheld review is never evidence.
658
+ const occurred = row.history.some(entry => !entry.withheld && entry.reviewFindings?.items.some(value => value.id === item.id && (value.disposition === "open" || value.disposition === "accepted")));
659
+ return !last?.withheld && finding && same(finding) && occurred && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
657
660
  });
658
661
  if (this.workUnits(holders) < 2) continue;
659
662
  const key = `Review finding key: ${item.pass}:${item.id}`;
@@ -4003,12 +4006,12 @@ export class Tasks {
4003
4006
  return admitted;
4004
4007
  }
4005
4008
 
4006
- private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): void {
4009
+ private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): boolean {
4007
4010
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
4008
4011
  if (mode === "enforce" && !admission?.ready) {
4009
4012
  // Background roster/quota role changes are retained, but never invent a remotely admitted review request.
4010
4013
  this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
4011
- return;
4014
+ return false;
4012
4015
  }
4013
4016
 
4014
4017
  const r = task.refs;
@@ -4070,7 +4073,7 @@ export class Tasks {
4070
4073
  // #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
4071
4074
  const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
4072
4075
  const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${carriedText}${conformanceText}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet, findings: [{id, pass: "bugs" | "security" | "compliance", severity: "important" | "nit", path?, disposition: "open" | "fixed" | "false_positive"}]}. Reuse finding ids when updating their disposition; only the person may accept a finding with a rationale.`;
4073
- this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) }));
4076
+ return this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) })).includes(reviewer);
4074
4077
  }
4075
4078
 
4076
4079
  private reviewRequestRevision(task: Task): number {
@@ -4086,25 +4089,35 @@ export class Tasks {
4086
4089
  }
4087
4090
 
4088
4091
  /** Host-only failure report, bound to the exact review request and its still-current child generation. */
4089
- async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean): Promise<Task> {
4092
+ async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean, onReoffered?: () => void): Promise<Task> {
4090
4093
  if (!failure || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || !/^[a-z][a-z0-9-]{0,63}$/.test(failedPeer) || !Number.isSafeInteger(failure.flowRevision) || failure.flowRevision! < 1 || failure.head !== undefined && (typeof failure.head !== "string" || text(failure.head) !== failure.head || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head))) throw new PreEffectToolRefusal("invalid reviewer failure binding");
4091
4094
  const valid = () => {
4092
4095
  const task = this.need(id, false, true);
4093
- if (!current() || task.state !== "in_review" || task.reviewer !== failedPeer || this.reviewRequestRevision(task) !== failure.flowRevision || this.reviewHead(task) !== failure.head || this.isPii(task) || this.manuallyNamedReviewer(task)) throw new PreEffectToolRefusal("reviewer failure is no longer current");
4096
+ if (!current() || task.state !== "in_review" || task.reviewer !== failedPeer || this.reviewRequestRevision(task) !== failure.flowRevision || this.reviewHead(task) !== failure.head || this.isPii(task) || this.manuallyNamedReviewer(task) && failure.cause !== "no_verdict") throw new PreEffectToolRefusal("reviewer failure is no longer current");
4094
4097
  return task;
4095
4098
  };
4096
4099
  const before = valid();
4097
4100
  const admission = await this.reviewRequestAdmission(before, undefined, true, before.owner ?? USER, undefined, () => { valid(); return true; });
4098
4101
  const task = valid();
4099
4102
  const options = this.reviewPoolOptions(task);
4100
- const selection = assign(task, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), reviewPool: { ...options, failed: [...options.failed, failedPeer] } });
4101
- if (!selection.reviewSelection) throw new PreEffectToolRefusal("no declared reviewer pool can receive the fallback");
4102
- const reviewer = selection.reviewer ?? USER;
4103
+ // A person-selected reviewer never falls through to an agent the person did not select.
4104
+ const manual = this.manuallyNamedReviewer(task);
4105
+ const selection = manual ? undefined : assign(task, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), reviewPool: { ...options, failed: [...options.failed, failedPeer] } });
4106
+ if (!selection?.reviewSelection && failure.cause !== "no_verdict") throw new PreEffectToolRefusal("no declared reviewer pool can receive the fallback");
4107
+ const reviewer = selection?.reviewSelection ? selection.reviewer ?? USER : USER;
4103
4108
  valid();
4104
- const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, this.assignmentNote(`reviewer unavailable: ${failure.cause}`, selection), { ...this.reviewMetadata(selection), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
4109
+ const note = manual ? "named reviewer recorded no verdict; the person must review"
4110
+ : selection?.reviewSelection ? this.assignmentNote(`reviewer unavailable: ${failure.cause}`, selection) : "no declared reviewer pool can receive the fallback; the person must review";
4111
+ const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, note, { ...this.reviewMetadata(selection), ...(reviewer === USER ? { reviewBlocked: true } : {}), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
4105
4112
  this.releaseNotice(`task ${this.publicTitle(next)}: former review request withdrawal incomplete`, () => this.withdrawRoleRequest(task, next, "reviewer"));
4106
- if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)}: blocked: no reviewer can run; the planner or person must review`);
4107
- else this.releaseNotice(`task ${this.publicTitle(next)}: fallback review request unavailable`, () => this.sendReview(next, reviewer, "", admission));
4113
+ let offered = false;
4114
+ if (reviewer === USER) {
4115
+ const notice = `task ${this.publicTitle(next)}: blocked: the person must review`;
4116
+ this.releaseNotice(notice, () => { this.d.notify(notice); offered = true; });
4117
+ } else this.releaseNotice(`task ${this.publicTitle(next)}: fallback review request unavailable`, () => { offered = this.sendReview(next, reviewer, "", admission); });
4118
+ if (offered && current() && this.reviewBindingCurrent(reviewer, next.id, { head: this.reviewHead(next), flowRevision: this.reviewRequestRevision(next) })) {
4119
+ try { onReoffered?.(); } catch { /* Unconfirmed host settlement retains the delivery hold. */ }
4120
+ }
4108
4121
  return next;
4109
4122
  }
4110
4123
 
@@ -1,3 +1,4 @@
1
+ import { buildIntentBrief, type IntentBriefOptions, type IntentBriefSource } from "@staix/agent-mem";
1
2
  import type { PeerId } from "../hub/envelope.ts";
2
3
  import type { MemoryClient } from "./client.ts";
3
4
 
@@ -30,17 +31,20 @@ export async function related(client: MemoryClient, project: string, query: stri
30
31
  /** A native memory line for a brief (#258): `mem:<id8> <kind> <tier>: <title or text>`, fails first. */
31
32
  export interface NativeItem { id: string; line: string; kind?: string; about?: readonly string[]; tier?: string; peer?: string; machine?: string; at?: string }
32
33
  export type NativeBrief = (task: BriefTask) => NativeItem[] | Promise<NativeItem[]>;
34
+ export interface IntentBriefContext { source: IntentBriefSource; options: IntentBriefOptions }
35
+ /** Host-owned policy and store binding; the library owns relevance, ordering and text. */
36
+ export type IntentBriefHost = (task: BriefTask) => IntentBriefContext | Promise<IntentBriefContext>;
33
37
  export interface BriefTask { id?: number; created?: number; title: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }
34
38
 
35
39
  /**
36
- * Task briefs: what memory already knows about a piece of work, handed over with it. The hub's own store first (#258:
37
- * entries one or two hops from the task's paths, symbols, issue and task, a linked fail first, superseded entries
38
- * dropped), then amem: index first (search), then the neighbourhood of the top hit (timeline); details stay in
39
- * the store for the receiver to fetch by id. `brief_items` caps both together. A peer is never shown the same entry
40
- * twice in one hub run, except a directly matching plan fail that must accompany a new task.
40
+ * Production task briefs use the library's intent retrieval and rendering with host policy and bindings.
41
+ * The hub applies its per-peer delivery dedupe and item cap to complete library rows, reserving the front
42
+ * for host-screened plan fails even when the library's byte cap omitted them. Injected clients without
43
+ * the library port retain the legacy index/timeline bridge; intent mode retires its related/readNotice path.
41
44
  */
42
45
  export class Briefs {
43
46
  private readonly seen = new Map<PeerId, Set<string>>();
47
+ private readonly libraryRuns = new Map<PeerId, Promise<unknown>>();
44
48
 
45
49
  constructor(
46
50
  private readonly client: MemoryClient | undefined,
@@ -50,11 +54,47 @@ export class Briefs {
50
54
  /** Counts of what a brief carried, for the research records (#258 AC11); never its text. */
51
55
  private readonly observe?: (peer: PeerId, task: number, counts: { native: number; fails: number; claudeMem: number }) => void,
52
56
  private readonly displayNative?: (items: readonly NativeItem[]) => string[],
57
+ private readonly intent?: IntentBriefHost,
53
58
  ) {}
54
59
 
55
60
  async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
56
61
  // A brief snapshots its configured cap once, before either optional source is selected.
57
62
  const maximum = typeof this.maxItems === "function" ? this.maxItems() : this.maxItems;
63
+ if (this.intent) {
64
+ // Serialize only this peer's library reads, so awaited policy cannot duplicate a delivered note.
65
+ const previous = this.libraryRuns.get(peer);
66
+ const run = (async () => {
67
+ await previous?.catch(() => undefined);
68
+ if (maximum < 1) return undefined;
69
+ try {
70
+ const context = await this.intent!(task);
71
+ const seen = this.seen.get(peer) ?? new Set<string>();
72
+ this.seen.set(peer, seen);
73
+ const revision = context.options.policyVersion();
74
+ const result = await buildIntentBrief(context.source, context.options);
75
+ if (result.state === "unknown" || context.options.allowed?.() === false || context.options.policyVersion() !== revision) return undefined;
76
+ context.options.assertCurrent?.();
77
+ const lines = result.text.split("\n");
78
+ const rendered = new Map(result.items.map((item, index) => [`mem:${item.id}`, lines[index + 1]!]));
79
+ const selected: NativeItem[] = [];
80
+ // Priority is already screened and sanitized by the host's plan guard. Its advice must survive
81
+ // the standalone library's byte cap or narrower retrieval policy, and recur with the next task.
82
+ for (const item of priority) if (selected.length < maximum && !selected.some(row => row.id === item.id)) selected.push({ ...item, line: rendered.get(item.id) ?? item.line });
83
+ for (const item of result.items) {
84
+ const id = `mem:${item.id}`;
85
+ if (selected.length < maximum && !seen.has(id) && !selected.some(row => row.id === id)) selected.push({ id, line: rendered.get(id)!, kind: item.kind });
86
+ }
87
+ if (!selected.length) return undefined;
88
+ // Preserve complete library rows (including graph neighbours after a seen direct bridge).
89
+ const text = [lines[0] || "Live fail notes for this plan (advice, not a block):", ...selected.map(item => item.line)].join("\n");
90
+ for (const item of selected) seen.add(item.id);
91
+ if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: selected.length, fails: selected.filter(item => item.kind === "fail").length, claudeMem: 0 }); } catch { /* telemetry never fails a brief */ }
92
+ return text;
93
+ } catch { return undefined; }
94
+ })();
95
+ this.libraryRuns.set(peer, run);
96
+ try { return await run; } finally { if (this.libraryRuns.get(peer) === run) this.libraryRuns.delete(peer); }
97
+ }
58
98
  const seen = this.seen.get(peer) ?? new Set<string>();
59
99
  this.seen.set(peer, seen);
60
100
  let native: NativeItem[] = [];