@staix/agent-hub 0.12.30 → 0.12.32

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 (102) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +29 -9
  3. package/docs/agent-notes/adapters.md +9 -5
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -0
  6. package/docs/agent-notes/daemon.md +14 -8
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -1
  9. package/docs/agent-notes/tests.md +6 -2
  10. package/docs/cooperbench.md +1 -1
  11. package/docs/gsd-collaboration-analysis.md +1 -1
  12. package/docs/memory.md +2 -2
  13. package/docs/operations.md +236 -37
  14. package/docs/quickstart.md +24 -5
  15. package/docs/security.md +42 -10
  16. package/docs/smoke.md +34 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
  19. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  20. package/docs/verification/2026-10-17-418-probes.md +21 -0
  21. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  22. package/docs/verified.json +20 -15
  23. package/package.json +2 -2
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +1361 -44
  26. package/src/adapters/acp.ts +5 -6
  27. package/src/adapters/claude-channel.ts +29 -11
  28. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  29. package/src/adapters/definitions/kimi-native.ts +4 -0
  30. package/src/adapters/definitions/qwen-native.ts +24 -0
  31. package/src/adapters/definitions/qwen.ts +71 -0
  32. package/src/adapters/local-worker.ts +2 -2
  33. package/src/adapters/pi.ts +11 -2
  34. package/src/adapters/registry-types.ts +174 -0
  35. package/src/adapters/registry.ts +36 -0
  36. package/src/cli/attention.ts +5 -1
  37. package/src/cli/console-state.ts +130 -17
  38. package/src/cli/console.ts +54 -8
  39. package/src/cli/help.ts +3 -1
  40. package/src/cli/identity.ts +16 -5
  41. package/src/cli/jcode-mcp.ts +1 -1
  42. package/src/cli/launch.ts +1 -6
  43. package/src/cli/main.ts +208 -40
  44. package/src/cli/output.ts +15 -2
  45. package/src/cli/preview.ts +8 -5
  46. package/src/cli/recovery-guidance.ts +137 -0
  47. package/src/cli/recovery-presentation-options.ts +8 -0
  48. package/src/cli/recovery-session-file.ts +18 -0
  49. package/src/cli/terminal-recovery.ts +48 -0
  50. package/src/cli/upgrade-interactive.ts +129 -74
  51. package/src/cli/upgrade-labels.ts +30 -0
  52. package/src/cli/upgrade-presentation-data.ts +41 -0
  53. package/src/cli/upgrade-presentation-flags.ts +31 -0
  54. package/src/cli/upgrade-presentation.ts +217 -0
  55. package/src/cli/upgrade-restore-state.ts +137 -0
  56. package/src/cli/upgrade-runtime.ts +430 -175
  57. package/src/cli/upgrade.ts +325 -49
  58. package/src/hub/board.ts +82 -24
  59. package/src/hub/bus.ts +89 -29
  60. package/src/hub/check-runner.ts +514 -0
  61. package/src/hub/child-process.ts +1 -0
  62. package/src/hub/cohorts.ts +2 -2
  63. package/src/hub/config-apply.ts +242 -0
  64. package/src/hub/config-reload.ts +120 -0
  65. package/src/hub/config-trust.ts +11 -2
  66. package/src/hub/control-client.ts +5 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +931 -209
  69. package/src/hub/delivery-hints.ts +30 -0
  70. package/src/hub/delivery-journal.ts +185 -12
  71. package/src/hub/delivery-superseded.ts +52 -0
  72. package/src/hub/envelope.ts +1 -1
  73. package/src/hub/events.ts +2 -2
  74. package/src/hub/host-executable.ts +11 -0
  75. package/src/hub/hub-tools.ts +9 -1
  76. package/src/hub/manager.ts +1 -1
  77. package/src/hub/peer-catalog.ts +13 -13
  78. package/src/hub/peers.ts +12 -2
  79. package/src/hub/permission-mode.ts +5 -3
  80. package/src/hub/recovery-continuity.ts +152 -0
  81. package/src/hub/recovery-store.ts +10 -4
  82. package/src/hub/routing.ts +35 -16
  83. package/src/hub/sdlc.ts +5 -1
  84. package/src/hub/settings.ts +52 -37
  85. package/src/hub/settled-roles.ts +35 -0
  86. package/src/hub/spec-read.ts +120 -0
  87. package/src/hub/supervision.ts +24 -8
  88. package/src/hub/task-sweep.ts +7 -6
  89. package/src/hub/tasks.ts +199 -40
  90. package/src/hub/ui.ts +1 -1
  91. package/src/memory/brief.ts +6 -5
  92. package/src/memory/capture.ts +2 -1
  93. package/src/pi/session.ts +23 -4
  94. package/src/ui/index.html +35 -16
  95. package/src/ui/needs.ts +7 -5
  96. package/src/ui/office-model.ts +4 -1
  97. package/src/ui/task-board.ts +1 -1
  98. package/src/ui/task-progress.ts +6 -2
  99. package/templates/AGENTS.block.md +1 -1
  100. package/templates/profiles/general.json +1 -1
  101. package/templates/profiles/research.json +1 -1
  102. package/templates/profiles/writing.json +1 -1
@@ -0,0 +1,152 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { chmodSync, closeSync, constants, fstatSync, lstatSync, openSync, readSync, renameSync, unlinkSync } from "node:fs";
4
+ import { isAbsolute, join, normalize } from "node:path";
5
+ import { realPath } from "./project.ts";
6
+ import { atomicPrivateJSON, operationIdPattern } from "./recovery-store.ts";
7
+
8
+ export const RECOVERY_CONTINUITY_BUSY_TIMEOUT_MS = 3000;
9
+ const MAX_BOOKMARK_BYTES = 16 * 1024;
10
+ const BOOKMARK = "recovery-continuity.json", LOCK = "recovery-continuity-lock.db";
11
+
12
+ export interface RecoveryContinuityBookmark {
13
+ schema: 1;
14
+ operationId: string;
15
+ projectId: string;
16
+ projectRoot: string;
17
+ at: number;
18
+ pi: { sessionId: string; sessionFile?: string; backend?: "auto" | "dgx" | "mlx"; model?: string };
19
+ }
20
+ export interface RecoveryContinuityExpected { projectId: string; projectRoot: string; operationId?: string }
21
+ export type ContinuityRetirementReason = "authenticated_session_changed" | "resume_unavailable" | "recovery_released";
22
+ export type RecoveryContinuityReading =
23
+ | { state: "absent" }
24
+ | { state: "invalid"; reason: "unavailable" | "invalid_shape" | "invalid_path" | "identity_mismatch" | "oversized" }
25
+ | { state: "ready"; bookmark: RecoveryContinuityBookmark; digest: string };
26
+
27
+ const unavailable = (): never => { throw new Error("recovery continuity metadata unavailable or invalid"); };
28
+ const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
29
+ const keys = (value: Record<string, unknown>, allowed: readonly string[]): boolean => Object.keys(value).every(key => allowed.includes(key));
30
+ const text = (value: unknown, max: number): value is string => typeof value === "string" && value.length > 0 && value.length <= max && !/[\x00-\x1f\x7f]/.test(value);
31
+ const id = (value: unknown): value is string => text(value, 256) && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value);
32
+ const absolute = (value: unknown): value is string => text(value, 4096) && isAbsolute(value) && normalize(value) === value;
33
+ function canonicalRoot(value: unknown): string {
34
+ if (!absolute(value)) return unavailable();
35
+ const root = realPath(value);
36
+ if (!lstatSync(root).isDirectory()) return unavailable();
37
+ return root;
38
+ }
39
+ function checked(value: unknown): RecoveryContinuityBookmark {
40
+ if (!record(value) || !keys(value, ["schema", "operationId", "projectId", "projectRoot", "at", "pi"]) || value.schema !== 1 || typeof value.operationId !== "string" || !operationIdPattern.test(value.operationId) || !id(value.projectId) || !absolute(value.projectRoot) || !Number.isSafeInteger(value.at) || (value.at as number) < 0) return unavailable();
41
+ const pi = value.pi;
42
+ if (!record(pi) || !keys(pi, ["sessionId", "sessionFile", "backend", "model"]) || !id(pi.sessionId) || pi.sessionFile !== undefined && !absolute(pi.sessionFile) || pi.backend !== undefined && !["auto", "dgx", "mlx"].includes(String(pi.backend)) || pi.model !== undefined && !text(pi.model, 4096)) return unavailable();
43
+ const projectRoot = canonicalRoot(value.projectRoot);
44
+ return { schema: 1, operationId: value.operationId, projectId: value.projectId, projectRoot, at: value.at as number,
45
+ pi: { sessionId: pi.sessionId, ...(pi.sessionFile === undefined ? {} : { sessionFile: pi.sessionFile as string }), ...(pi.backend === undefined ? {} : { backend: pi.backend as "auto" | "dgx" | "mlx" }), ...(pi.model === undefined ? {} : { model: pi.model as string }) } };
46
+ }
47
+ function stateDirectory(stateDir: string): void {
48
+ if (!absolute(stateDir)) return unavailable();
49
+ const stat = lstatSync(stateDir);
50
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return unavailable();
51
+ }
52
+ function privateFile(path: string, missing = false): void {
53
+ try {
54
+ const stat = lstatSync(path);
55
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.mode & 0o077) return unavailable();
56
+ } catch (error) { if (!missing || (error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
57
+ }
58
+ const digest = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex");
59
+
60
+ /** Read one bounded project-local file; invalid evidence is deliberately distinct from no bookmark. */
61
+ export function readRecoveryContinuity(stateDir: string, expected: RecoveryContinuityExpected): RecoveryContinuityReading {
62
+ let fd: number | undefined;
63
+ let expectedRoot: string;
64
+ try { expectedRoot = canonicalRoot(expected.projectRoot); }
65
+ catch { return { state: "invalid", reason: "identity_mismatch" }; }
66
+ try {
67
+ stateDirectory(stateDir);
68
+ const file = join(stateDir, BOOKMARK);
69
+ privateFile(file);
70
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
71
+ const stat = fstatSync(fd);
72
+ if (!stat.isFile() || stat.mode & 0o077) return { state: "invalid", reason: "invalid_path" };
73
+ if (stat.size > MAX_BOOKMARK_BYTES) return { state: "invalid", reason: "oversized" };
74
+ const buffer = Buffer.alloc(MAX_BOOKMARK_BYTES + 1);
75
+ let length = 0;
76
+ while (length < buffer.length) { const n = readSync(fd, buffer, length, buffer.length - length, null); if (!n) break; length += n; }
77
+ if (length > MAX_BOOKMARK_BYTES) return { state: "invalid", reason: "oversized" };
78
+ const bytes = buffer.subarray(0, length);
79
+ let bookmark: RecoveryContinuityBookmark;
80
+ try {
81
+ const raw: unknown = JSON.parse(bytes.toString("utf8"));
82
+ bookmark = checked(raw);
83
+ if (!record(raw) || raw.projectRoot !== bookmark.projectRoot) return { state: "invalid", reason: "invalid_path" };
84
+ } catch { return { state: "invalid", reason: "invalid_shape" }; }
85
+ if (!id(expected.projectId) || expected.operationId !== undefined && !operationIdPattern.test(expected.operationId) || bookmark.projectId !== expected.projectId || bookmark.projectRoot !== expectedRoot || expected.operationId !== undefined && bookmark.operationId !== expected.operationId) return { state: "invalid", reason: "identity_mismatch" };
86
+ return { state: "ready", bookmark, digest: digest(bytes) };
87
+ } catch (error) {
88
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? { state: "absent" } : { state: "invalid", reason: "unavailable" };
89
+ } finally { if (fd !== undefined) closeSync(fd); }
90
+ }
91
+
92
+ /** A crash releases SQLite's lock; no lockfile ownership or stale-process inference is needed. */
93
+ function withWriter<T>(stateDir: string, action: () => T): T {
94
+ stateDirectory(stateDir);
95
+ const file = join(stateDir, LOCK);
96
+ privateFile(file, true);
97
+ for (const suffix of ["-journal", "-wal", "-shm"]) privateFile(file + suffix, true);
98
+ const db = new Database(file, { create: true });
99
+ db.run(`PRAGMA busy_timeout = ${RECOVERY_CONTINUITY_BUSY_TIMEOUT_MS}`);
100
+ try {
101
+ chmodSync(file, 0o600);
102
+ db.run("BEGIN IMMEDIATE");
103
+ try { return action(); } finally { db.run("ROLLBACK"); }
104
+ } finally { db.close(); }
105
+ }
106
+
107
+ /** Called only after the coordinator confirmed its target stopped and archived that operation's snapshot. */
108
+ export function writeRecoveryContinuity(stateDir: string, value: RecoveryContinuityBookmark): { digest: string } {
109
+ const bookmark = checked(value), bytes = Buffer.from(JSON.stringify(bookmark, null, 2) + "\n");
110
+ if (bytes.length > MAX_BOOKMARK_BYTES) return unavailable();
111
+ return withWriter(stateDir, () => {
112
+ privateFile(join(stateDir, BOOKMARK), true);
113
+ atomicPrivateJSON(join(stateDir, BOOKMARK), bookmark);
114
+ return { digest: digest(bytes) };
115
+ });
116
+ }
117
+
118
+ /** Consume only the exact bookmark whose recorded session has authenticated; stale receipts retain newer data. */
119
+ export function consumeRecoveryContinuity(stateDir: string, expected: RecoveryContinuityExpected & { operationId: string; digest: string }): boolean {
120
+ if (!operationIdPattern.test(expected.operationId) || !/^[a-f0-9]{64}$/.test(expected.digest)) return false;
121
+ return withWriter(stateDir, () => {
122
+ const current = readRecoveryContinuity(stateDir, { projectId: expected.projectId, projectRoot: expected.projectRoot });
123
+ if (current.state === "absent") return false;
124
+ if (current.state === "invalid") return unavailable();
125
+ if (current.bookmark.operationId !== expected.operationId || current.digest !== expected.digest) return false;
126
+ unlinkSync(join(stateDir, BOOKMARK));
127
+ return true;
128
+ });
129
+ }
130
+
131
+ /** Preserve superseded evidence privately; only a matching project/operation/digest may retire the active file. */
132
+ export function retireRecoveryContinuity(
133
+ stateDir: string,
134
+ expected: RecoveryContinuityExpected & { operationId: string; digest: string },
135
+ reason: ContinuityRetirementReason,
136
+ ): { retired: boolean; archive?: string } {
137
+ if (!["authenticated_session_changed", "resume_unavailable", "recovery_released"].includes(reason)) return unavailable();
138
+ if (!operationIdPattern.test(expected.operationId) || !/^[a-f0-9]{64}$/.test(expected.digest)) return { retired: false };
139
+ return withWriter(stateDir, () => {
140
+ const current = readRecoveryContinuity(stateDir, { projectId: expected.projectId, projectRoot: expected.projectRoot });
141
+ if (current.state === "absent") return { retired: false };
142
+ if (current.state === "invalid") return unavailable();
143
+ if (current.bookmark.operationId !== expected.operationId || current.digest !== expected.digest) return { retired: false };
144
+ // A unique destination preserves an earlier retirement even when the same bookmark was explicitly restored.
145
+ const archive = join(stateDir, `recovery-continuity.retired.${reason}.${current.digest}.${randomUUID()}.json`);
146
+ try { lstatSync(archive); return unavailable(); }
147
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
148
+ renameSync(join(stateDir, BOOKMARK), archive);
149
+ chmodSync(archive, 0o600);
150
+ return { retired: true, archive };
151
+ });
152
+ }
@@ -69,15 +69,21 @@ export function assertLifecycleAvailable(): void {
69
69
  * lacks dispose, refuses abort and resume on a lapsed hold and shows no next actions: then the running release is
70
70
  * named, which does all of that in-process (its resume still runs the operation's own runner).
71
71
  */
72
- export function recoveryCommand(op: { id: string; sourceRoot?: string }, action: "status" | "resume" | "abort" | "dispose", flags = ""): string {
73
- const entry = !op.sourceRoot ? undefined : coordinatorCurrent(op.sourceRoot) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
72
+ export type RecoveryAction = "status" | "resume" | "abort" | "dispose" | "reattach";
73
+ export function supportsAction(sourceRoot: string, action: RecoveryAction): boolean {
74
+ if (!coordinatorCurrent(sourceRoot)) return false;
75
+ if (action !== "reattach") return true;
76
+ try { return readFileSync(join(sourceRoot, "src/cli/upgrade.ts"), "utf8").includes("export async function reattachRecovery"); } catch { return false; }
77
+ }
78
+ export function recoveryCommand(op: { id: string; sourceRoot?: string }, action: RecoveryAction, flags = ""): string {
79
+ const entry = !op.sourceRoot ? undefined : supportsAction(op.sourceRoot, action) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
74
80
  const cli = !entry ? "ahub" : /^[\w./@+-]+$/.test(entry) ? `bun ${entry}` : `bun '${entry.replace(/'/g, `'\\''`)}'`;
75
81
  return `${cli} recovery ${action} ${op.id}${flags ? ` ${flags}` : ""}`;
76
82
  }
77
83
 
78
84
  /** #272: the command above as an argv for `bun`, which a screen runs instead of printing; with no coordinator of its own, this release's. */
79
- export function recoveryArgv(op: { id: string; sourceRoot?: string }, action: "status" | "resume" | "abort" | "dispose", flags: string[] = []): string[] {
80
- const entry = op.sourceRoot && coordinatorCurrent(op.sourceRoot) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
85
+ export function recoveryArgv(op: { id: string; sourceRoot?: string }, action: RecoveryAction, flags: string[] = []): string[] {
86
+ const entry = op.sourceRoot && supportsAction(op.sourceRoot, action) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
81
87
  return [entry, "recovery", action, op.id, ...flags];
82
88
  }
83
89
 
@@ -1,6 +1,9 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { CLASSES, type Task, type TaskClass } from "./board.ts";
4
+ import { isGitHubRepository, specRefs } from "./spec-read.ts";
5
+
6
+ export interface PeerCapabilities { tests: boolean; spec: boolean; testsReason?: string; specReason?: string; }
4
7
  import type { PeerId, PeerState } from "./envelope.ts";
5
8
 
6
9
  import { parseHubRoutes, type HubRoute } from "../models/route/config.ts";
@@ -231,7 +234,10 @@ export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, rou
231
234
  /** A peer's entry in a peer-indexed map: own keys only, so a peer named `constructor` is never an inherited Object member. */
232
235
  export const peerEntry = <T>(map: Readonly<Record<string, T>> | undefined, peer: string): T | undefined => (map && Object.hasOwn(map, peer) ? map[peer] : undefined);
233
236
 
237
+ export type RoutingRefusalCode = "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
238
+ export interface RoutingRefusal { peer: PeerId; role: "owner" | "reviewer"; code: RoutingRefusalCode; reason: string }
234
239
  export interface Assignment {
240
+ refusals?: RoutingRefusal[];
235
241
  owner?: PeerId;
236
242
  reviewer?: PeerId;
237
243
  /** what `local` should ask for on this task: a Switchyard route id and the model to fall back to */
@@ -248,10 +254,12 @@ export interface Assignment {
248
254
  * prints its trace. `states`: effective bus states of attached peers (a peer that is not in the map is not attached).
249
255
  */
250
256
  export function assign(
251
- task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state">>,
257
+ task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external">>,
252
258
  states: Record<PeerId, PeerState>,
253
259
  routing: Routing,
254
260
  opts: {
261
+ capabilities?: Record<PeerId, PeerCapabilities>;
262
+ repository?: string;
255
263
  exclude?: PeerId[];
256
264
  /** Owner admission limits do not remove otherwise eligible reviewers. */
257
265
  excludeOwners?: PeerId[];
@@ -280,6 +288,7 @@ export function assign(
280
288
  roles?: Record<string, string[]>;
281
289
  } = {},
282
290
  ): Assignment {
291
+ if (task.state === "cancelled" || task.state === "approved") return { trace: [`${task.state}: closed; not routed`] };
283
292
  if (task.state === "backlog") return { trace: ["backlog: not routed; pull into To Do first"] };
284
293
  const policy = routing.classes[task.class];
285
294
  const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
@@ -292,29 +301,39 @@ export function assign(
292
301
  if (opts.waitsFor?.length) return { trace: [...trace, `blocked: waits for ${opts.waitsFor.map((id) => `#${id}`).join(", ")} (not approved)`, ...(reserved ? [`reserved owner ${reserved}: offered first once it is ready`] : [])] };
293
302
  const pii = task.signals.includes("pii_path") || (task.signals.includes("pii") && routing.constraints.pii === "local_only");
294
303
 
304
+ let needsSpec = false;
305
+ if (!pii) try { needsSpec = specRefs({ title: task.title ?? "", detail: task.detail ?? "", refs: task.refs ?? {}, external: task.external }, opts.repository ?? "", { bareTitle: isGitHubRepository(opts.repository) }).length > 0; }
306
+ catch { needsSpec = true; } // an invalid/foreign reference never grants a sandboxed peer spec access
307
+ const needsTests = ["implement", "test", "bulk_edit"].includes(task.class);
308
+ const refusals = new Map<string, RoutingRefusal>();
295
309
  const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
296
- if (opts.offRoster?.includes(peer)) return "off roster";
310
+ const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
311
+ if (role === "owner" && opts.capabilities && !pii) {
312
+ const caps = peerEntry(opts.capabilities, peer);
313
+ if (needsTests && !caps?.tests) return deny("tests", `missing tests: ${caps?.testsReason ?? "capability unavailable"}`);
314
+ if (needsSpec && !caps?.spec) return deny("spec", `missing spec: ${caps?.specReason ?? "capability unavailable"}`);
315
+ }
316
+ if (opts.offRoster?.includes(peer)) return deny("roster", "off roster");
297
317
  const named = role === "owner" ? opts.namedOwner ?? reserved : opts.namedReviewer;
298
- if (opts.pullOnly?.includes(peer) && named !== peer) return "pull-only: takes work when named or when it claims it";
299
- if (!Object.hasOwn(states, peer)) return "not attached";
300
- if (opts.exclude?.includes(peer)) return "excluded (declined or replaced)";
301
- if (role === "owner" && opts.excludeOwners?.includes(peer)) return "owner admission: WIP or delivery hold";
302
- if (states[peer] === "offline") return "offline";
303
- if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return "paused";
318
+ if (opts.pullOnly?.includes(peer) && named !== peer) return deny("pull-only", "pull-only: takes work when named or when it claims it");
319
+ if (!Object.hasOwn(states, peer)) return deny("not-attached", "not attached");
320
+ if (opts.exclude?.includes(peer)) return deny("excluded", "excluded (declined or replaced)");
321
+ if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
322
+ if (states[peer] === "offline") return deny("offline", "offline");
323
+ if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return deny("paused", "paused");
304
324
  const failing = peerEntry(opts.failing, peer);
305
- if (failing) return `failing: ${failing}`;
306
- if (pii && peer !== LOCAL) return "pii: on-prem peers only";
307
- if (peer === LOCAL && policy?.local_allowed === false) return "local_allowed = false for this class";
308
- if (peer === PI && policy?.local_allowed === false) return "local_allowed = false also excludes pi for this class";
325
+ if (failing) return deny("failing", `failing: ${failing}`);
326
+ if (pii && peer !== LOCAL) return deny("pii", "pii: on-prem peers only");
327
+ if (peer === LOCAL && policy?.local_allowed === false) return deny("policy", "local_allowed = false for this class");
328
+ if (peer === PI && policy?.local_allowed === false) return deny("policy", "local_allowed = false also excludes pi for this class");
309
329
  if (peer === PI && task.signals.includes("long_context")) {
310
330
  const backend = policy?.pi_backend ?? "dgx";
311
331
  const limit = backend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens;
312
- if (routing.signals.long_context_tokens > limit) return `pi ${backend} capability limit ${limit} tokens`;
332
+ if (routing.signals.long_context_tokens > limit) return deny("policy", `pi ${backend} capability limit ${limit} tokens`);
313
333
  }
314
- if (peer === LOCAL && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return "long_context: skip local";
334
+ if (peer === LOCAL && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return deny("policy", "long_context: skip local");
315
335
  return undefined;
316
336
  };
317
-
318
337
  /** Quota that resets soonest gets used first: headroom per hour left in the window, at least 15 min (a task assigned that close to a reset mostly runs after it). */
319
338
  const drain = (p: PeerId): number | undefined => {
320
339
  const q = peerEntry(opts.quota, p);
@@ -396,7 +415,7 @@ export function assign(
396
415
  const piBackend = policy?.pi_backend;
397
416
  if (owner === LOCAL) trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
398
417
  if (owner === PI) trace.push(`pi decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
399
- return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), trace };
418
+ return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
400
419
  }
401
420
 
402
421
  /**
package/src/hub/sdlc.ts CHANGED
@@ -3,6 +3,7 @@ import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { childEnv, stopOwnedProcess } from "./child-process.ts";
6
+ import { hostExecutable } from "./host-executable.ts";
6
7
 
7
8
  export const SDLC_STAGES = ["spec", "plan", "implement", "review", "verify", "merge", "release"] as const;
8
9
  export type SdlcBaseStage = typeof SDLC_STAGES[number];
@@ -219,7 +220,10 @@ export type SdlcGh = (args: string[], timeoutMs?: number, cwd?: string, maxOutpu
219
220
  /** Read-only, fixed argv. Output and time are bounded; diagnostics never expose credentials or remote text. */
220
221
  export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 256 * 1024) => new Promise((resolve) => {
221
222
  const outputLimit = Number.isSafeInteger(maxOutputBytes) ? Math.min(2 * 1024 * 1024, Math.max(1, maxOutputBytes)) : 256 * 1024;
222
- const child = spawn("gh", args, { ...(cwd ? { cwd } : {}), env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
223
+ let child: ReturnType<typeof spawn>;
224
+ try {
225
+ child = spawn(hostExecutable("gh", cwd ?? process.cwd()), args, { ...(cwd ? { cwd } : {}), env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
226
+ } catch { resolve({ code: 1, stdout: "" }); return; }
223
227
  let stdout = "", failed = false;
224
228
  const stop = () => { failed = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
225
229
  const timer = setTimeout(stop, Math.max(1, Math.min(10_000, timeoutMs)));
@@ -1,10 +1,10 @@
1
- import { SUPPORTED_PEERS } from "./peer-catalog.ts";
1
+ import { PLANNER_PEERS, SUPPORTED_PEERS } from "./peer-catalog.ts";
2
2
  import { createHash } from "node:crypto";
3
3
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { basename, dirname, join } from "node:path";
6
6
  import { CLASSES, type TaskClass } from "./board.ts";
7
- import { configTracked } from "./config-trust.ts";
7
+ import { configTracked, configRepositoryPresent } from "./config-trust.ts";
8
8
  import { PERMISSION_MODES, permissionBoundary } from "./permission-mode.ts";
9
9
  import { START_MODE_PEERS, START_MODES } from "./start-mode.ts";
10
10
  import { OVERLAY_FILE, overlayToml, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "./routing.ts";
@@ -21,14 +21,15 @@ export interface SettingDef {
21
21
  key: string;
22
22
  group: "Permissions" | "Start" | "Routing" | "Switches" | "Roster" | "Roles";
23
23
  label: string;
24
- type: "enum" | "boolean" | "peers" | "roles" | "integer";
24
+ type: "enum" | "boolean" | "peers" | "roles" | "integer" | "number";
25
25
  min?: number; max?: number;
26
26
  /** The closed values of an enum; a route setting takes the project's own route ids instead. */
27
27
  values?: readonly string[];
28
28
  /** `runtime` lives in the running hub only; the others are the machine-local file that holds the value. */
29
29
  store: "runtime" | "config" | "routing";
30
- /** `peer start`: stored, and taken up by the running hub at that peer's next start. */
31
- applies: "live" | "peer start" | "hub start";
30
+ /** `next use` keeps an active turn/session intact; `restart` retains the running value until a new daemon. */
31
+ applies: "live" | "next use" | "restart";
32
+ restartReason?: string;
32
33
  /** `raises`: only a settings session or a person's terminal may set it, except to `floor`. */
33
34
  risk: "safe" | "raises";
34
35
  floor?: string | boolean | number;
@@ -45,20 +46,23 @@ const perClass = (name: TaskClass): SettingDef[] => [
45
46
  { key: `routing.classes.${name}.pi_backend`, group: "Routing", label: `${name}: Pi backend`, type: "enum", values: ["dgx", "mlx"], store: "routing", applies: "live", risk: "raises", path: ["classes", name, "pi_backend"] },
46
47
  ];
47
48
  export const SETTINGS: readonly SettingDef[] = [
48
- ...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "hub start", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
49
- ...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "peer start", risk: "safe", path: ["catalog_roles", peer] })),
49
+ ...["enabled", "auto_reassign"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["task_sweep", key] })),
50
+ ...["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "number", min: 1, max: 2_147_483_647 / (key === "interval_s" ? 1000 : 60_000), store: "config", applies: "live", risk: "raises", path: ["task_sweep", key] })),
51
+ ...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "live", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
52
+ { key: "planner", group: "Roles", label: "Planner (the one peer that plans)", type: "enum", values: PLANNER_PEERS, store: "config", applies: "live", risk: "raises", path: ["planner"] },
53
+ ...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "next use", risk: "safe", path: ["catalog_roles", peer] })),
50
54
  { key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
51
55
  ...SETTING_PEERS.map((peer): SettingDef => ({ key: `roster.${peer}`, group: "Roster", label: `${peer}: participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] })),
52
56
  ...SETTING_PEERS.filter(peer => peer !== "jcode").map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
53
- ...SETTING_PEERS.filter(peer => peer !== "jcode").map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at hub start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "hub start", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
54
- ...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "peer start", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
55
- { key: "pi.auto_start", group: "Start", label: "Start Pi with the hub", type: "boolean", store: "config", applies: "hub start", risk: "raises", floor: false, path: ["pi", "auto_start"] },
57
+ ...SETTING_PEERS.filter(peer => peer !== "jcode").map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at next peer start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "next use", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
58
+ ...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "next use", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
59
+ { key: "pi.auto_start", group: "Start", label: "Start Pi with the hub", type: "boolean", store: "config", applies: "next use", risk: "raises", floor: false, path: ["pi", "auto_start"] },
56
60
  { key: "routing.stay_switch", group: "Routing", label: "Stay or switch (hub/auto and stage routes)", type: "enum", values: ["off", "shadow", "enforce"], store: "routing", applies: "live", risk: "raises", path: ["stay_switch"] },
57
61
  ...CLASSES.flatMap(perClass),
58
- { key: "research.enabled", group: "Switches", label: "Research records", type: "boolean", store: "config", applies: "hub start", risk: "safe", path: ["research", "enabled"] },
59
- { key: "approvals.notify", group: "Switches", label: "Desktop notice for a waiting approval", type: "boolean", store: "config", applies: "hub start", risk: "safe", path: ["approvals", "notify"] },
60
- { key: "snapshots.enabled", group: "Switches", label: "Per-turn snapshots", type: "boolean", store: "config", applies: "hub start", risk: "safe", path: ["snapshots", "enabled"] },
61
- { key: "coordination", group: "Switches", label: "Coordination of overlapping owners", type: "enum", values: ["advisory", "turn-free"], store: "config", applies: "hub start", risk: "safe", path: ["coordination"] },
62
+ { key: "research.enabled", group: "Switches", label: "Research records", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["research", "enabled"] },
63
+ { key: "approvals.notify", group: "Switches", label: "Desktop notice for a waiting approval", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["approvals", "notify"] },
64
+ { key: "snapshots.enabled", group: "Switches", label: "Per-turn snapshots", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["snapshots", "enabled"] },
65
+ { key: "coordination", group: "Switches", label: "Coordination of overlapping owners", type: "enum", values: ["advisory", "turn-free"], store: "config", applies: "next use", risk: "safe", path: ["coordination"] },
62
66
  ];
63
67
 
64
68
  /**
@@ -82,8 +86,8 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
82
86
  if (def.store === "runtime") throw new Error(`${def.key} has no stored value to remove`);
83
87
  return null;
84
88
  }
85
- if (def.type === "integer") {
86
- if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < (def.min ?? 0) || raw > (def.max ?? Number.MAX_SAFE_INTEGER)) throw new Error(`${def.key} takes an integer from ${def.min ?? 0} to ${def.max ?? Number.MAX_SAFE_INTEGER}`);
89
+ if (def.type === "integer" || def.type === "number") {
90
+ if (typeof raw !== "number" || !(def.type === "integer" ? Number.isSafeInteger(raw) : Number.isFinite(raw)) || raw < (def.min ?? 0) || raw > (def.max ?? Number.MAX_SAFE_INTEGER)) throw new Error(`${def.key} takes ${def.type === "integer" ? "an integer" : "a number"} from ${def.min ?? 0} to ${def.max ?? Number.MAX_SAFE_INTEGER}`);
87
91
  return raw;
88
92
  }
89
93
  if (def.type === "boolean") {
@@ -107,6 +111,7 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
107
111
  /** A terminal's text for a value: `inherit` removes the machine-local value, a peers setting takes a comma list. */
108
112
  export function parseSettingText(def: SettingDef, text: string): unknown {
109
113
  if (text === "inherit") return null;
114
+ if (def.type === "number") return text.trim() && Number.isFinite(Number(text)) ? Number(text) : text;
110
115
  if (def.type === "integer") return /^(0|[1-9]\d*)$/.test(text) ? Number(text) : text;
111
116
  if (def.type === "boolean") return text === "true" ? true : text === "false" ? false : text;
112
117
  if (def.type === "peers" || def.type === "roles") return text.split(",").map((p) => p.trim()).filter(Boolean);
@@ -162,14 +167,7 @@ function refuseTracked(cwd: string, file: StoredFile): void {
162
167
  const tracked = configTracked(cwd, file);
163
168
  if (tracked) throw new Error(`.agenthub/${file} is committed to git; settings write only machine-local files. Remove it from the repository (git rm --cached) and ignore it`);
164
169
  // Unknown is not untracked where there is a repository to ask; only a project outside git has nothing tracked.
165
- if (tracked === undefined && insideRepository(cwd)) throw new Error(`git could not confirm that .agenthub/${file} is untracked; nothing was written`);
166
- }
167
- /** Whether this directory or one above it holds a `.git`: a repository git should have been able to answer for. */
168
- function insideRepository(dir: string): boolean {
169
- for (let at = dir; ; at = dirname(at)) {
170
- if (existsSync(join(at, ".git"))) return true;
171
- if (dirname(at) === at) return false;
172
- }
170
+ if (tracked === undefined && configRepositoryPresent(cwd)) throw new Error(`git could not confirm that .agenthub/${file} is untracked; nothing was written`);
173
171
  }
174
172
  /** Keeps the one previous version, then replaces (or removes) the file. */
175
173
  function store(cwd: string, stateDir: string, file: StoredFile, key: string, previous: string | null, next: string | null): void {
@@ -262,7 +260,7 @@ function checkConfigText(cwd: string, text: string | null, validate: (scratch: s
262
260
  * Sets (or with null removes) one key of `.agenthub/routing.local.toml`; the real parser reads the result first, and
263
261
  * `check` may refuse the policy it yields. Undefined when the overlay already holds that value: nothing is written.
264
262
  */
265
- export function writeRoutingSetting(cwd: string, stateDir: string, def: SettingDef, value: SettingValue, check?: (routing: Routing) => void): Routing | undefined {
263
+ export function writeRoutingSetting(cwd: string, stateDir: string, def: SettingDef, value: SettingValue, check?: (routing: Routing, overlay?: RoutingOverlay) => void): Routing | undefined {
266
264
  const file = join(cwd, ".agenthub", OVERLAY_FILE);
267
265
  const before = readText(file);
268
266
  const current = before === null ? {} : parseOverlay(before);
@@ -270,7 +268,7 @@ export function writeRoutingSetting(cwd: string, stateDir: string, def: SettingD
270
268
  if (value === null ? held === undefined : same(held, value)) return undefined;
271
269
  const overlay = routingCandidate(current, def, value);
272
270
  const routing = parseRouting(routingText(cwd), overlay);
273
- check?.(routing);
271
+ check?.(routing, overlay);
274
272
  const empty = !overlay.stay_switch && !Object.keys(overlay.classes ?? {}).length;
275
273
  store(cwd, stateDir, OVERLAY_FILE, def.key, before, empty ? null : overlayToml(overlay));
276
274
  return routing;
@@ -301,11 +299,13 @@ export function readOverlay(cwd: string): RoutingOverlay {
301
299
  * Puts back the one previous version of the file the last settings write changed. Refused when the file changed since
302
300
  * that write (a hand edit would be lost), when the loaders no longer accept that version, or when there is nothing to undo.
303
301
  */
304
- export function undoSetting(cwd: string, stateDir: string, validate: (scratch: string) => unknown, checkRouting?: (routing: Routing) => void): { key: string; file: StoredFile } {
302
+ export interface UndoIdentity { key: string; undoDigest: string }
303
+ export function undoSetting(cwd: string, stateDir: string, validate: (scratch: string) => unknown, checkRouting?: (routing: Routing, overlay?: RoutingOverlay) => void, expected?: UndoIdentity): { key: string; file: StoredFile } {
305
304
  const raw = readText(undoFile(stateDir));
306
305
  if (raw === null) throw new Error("nothing to undo");
307
306
  let record: UndoRecord;
308
307
  try { record = JSON.parse(raw) as UndoRecord; } catch { throw new Error("nothing to undo"); }
308
+ if (expected && (record.key !== expected.key || digest(raw) !== expected.undoDigest)) throw new Error("undo record changed since preview; preview again; nothing was undone");
309
309
  if (record.file !== CONFIG_LOCAL && record.file !== OVERLAY_FILE) throw new Error("nothing to undo");
310
310
  const path = join(cwd, ".agenthub", record.file);
311
311
  if (digest(readText(path)) !== record.written) throw new Error(`.agenthub/${record.file} changed since the last settings write; nothing was undone`);
@@ -313,10 +313,12 @@ export function undoSetting(cwd: string, stateDir: string, validate: (scratch: s
313
313
  refuseTracked(cwd, record.file);
314
314
  if (record.file === OVERLAY_FILE) {
315
315
  // The parser always reads what is put back; the caller's check is on top of it.
316
- const restored = parseRouting(routingText(cwd), record.previous === null ? undefined : parseOverlay(record.previous));
317
- checkRouting?.(restored);
316
+ const overlay = record.previous === null ? undefined : parseOverlay(record.previous);
317
+ const restored = parseRouting(routingText(cwd), overlay);
318
+ checkRouting?.(restored, overlay);
318
319
  }
319
320
  else checkConfigText(cwd, record.previous, validate);
321
+ if (expected && readText(undoFile(stateDir)) !== raw) throw new Error("undo record changed since preview; preview again; nothing was undone");
320
322
  replaceFile(path, record.file, record.previous);
321
323
  rmSync(undoFile(stateDir), { force: true });
322
324
  return { key: String(record.key), file: record.file };
@@ -334,13 +336,23 @@ export function restoredValue(cwd: string, stateDir: string, def: SettingDef, in
334
336
  const parse = (text: string | null): unknown => { try { return text === null ? {} : JSON.parse(text); } catch { return {}; } };
335
337
  let local: unknown = {};
336
338
  if (!inherit) {
337
- try { const record = JSON.parse(readFileSync(undoFile(stateDir), "utf8")) as UndoRecord; if (record.file !== CONFIG_LOCAL) return undefined; local = parse(record.previous); } catch { return undefined; }
339
+ try { const record = JSON.parse(readFileSync(undoFile(stateDir), "utf8")) as UndoRecord;
340
+ if (record.file === OVERLAY_FILE && def.store === "routing") return valueAt(parseRouting(routingText(cwd), record.previous === null ? undefined : parseOverlay(record.previous)), def.path);
341
+ if (record.file !== CONFIG_LOCAL) return undefined; local = parse(record.previous);
342
+ } catch { return undefined; }
338
343
  }
339
344
  return valueAt(local, def.path) ?? valueAt(parse(readText(join(cwd, ".agenthub", "config.json"))), def.path);
340
345
  }
341
346
  /** The key the next undo would put back, if any. */
342
347
  export function pendingUndo(stateDir: string): string | undefined {
343
- try { const record = JSON.parse(readFileSync(undoFile(stateDir), "utf8")) as UndoRecord; return typeof record.key === "string" ? record.key : undefined; } catch { return undefined; }
348
+ return pendingUndoIdentity(stateDir)?.key;
349
+ }
350
+ /** Snapshot identity of the exact record, including its previous bytes, file and write time. */
351
+ export function pendingUndoIdentity(stateDir: string): UndoIdentity | undefined {
352
+ try {
353
+ const raw = readFileSync(undoFile(stateDir), "utf8"), record = JSON.parse(raw) as UndoRecord;
354
+ return typeof record.key === "string" && record.key.length > 0 ? { key: record.key, undoDigest: digest(raw)! } : undefined;
355
+ } catch { return undefined; }
344
356
  }
345
357
 
346
358
  /** The loader's line saying it ignored this setting's field in the machine-local file, if it did (config trust, #17). */
@@ -354,9 +366,12 @@ export interface SettingRow {
354
366
  key: string; group: SettingDef["group"]; label: string; type: SettingDef["type"]; values?: readonly string[]; min?: number; max?: number;
355
367
  /** The file a write goes to, or "this hub run". */
356
368
  file: string;
357
- applies: SettingDef["applies"]; risk: SettingDef["risk"]; floor?: string | boolean | number;
369
+ applies: SettingDef["applies"] | "peer start" | "hub start"; risk: SettingDef["risk"]; floor?: string | boolean | number;
358
370
  /** What is in force now; null when nothing sets it. */
359
371
  value: SettingValue;
372
+ /** Optional for replies from pre-#439 daemons; current projections always supply it. */
373
+ fileValue?: SettingValue;
374
+ restartReason?: string;
360
375
  /** Which file the value in force comes from. */
361
376
  source: string;
362
377
  /** The stored value the next hub start reads, when it differs from the one in force. */
@@ -389,23 +404,23 @@ export function settingRows(input: {
389
404
  const routes = [...Object.keys(input.routing.routes), ...Object.keys(input.routing.hub_routes ?? {})];
390
405
  const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
391
406
  return SETTINGS.map((def): SettingRow => {
392
- const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(def.type === "integer" ? { min: def.min, max: def.max } : {}), applies: def.applies, risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
407
+ const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(["integer", "number"].includes(def.type) ? { min: def.min, max: def.max } : {}), applies: def.applies, ...(def.restartReason ? { restartReason: def.restartReason } : {}), risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
393
408
  ...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!) } : {}) };
394
409
  if (def.store === "runtime") {
395
410
  const mode = input.mode(def.path[0]!);
396
- return { ...base, values: def.values!, file: "this hub run", value: mode.value ?? null, source: "this hub run", ...(mode.note ? { note: mode.note } : {}) };
411
+ return { ...base, values: def.values!, file: "this hub run", value: mode.value ?? null, fileValue: mode.value ?? null, source: "this hub run", ...(mode.note ? { note: mode.note } : {}) };
397
412
  }
398
413
  if (def.store === "routing") {
399
414
  const value = scalar(def.path[0] === "stay_switch" ? input.routing.stay_switch : (input.routing.classes[def.path[1] as TaskClass] as Record<string, unknown> | undefined)?.[def.path[2]!]);
400
- return { ...base, ...(def.type === "enum" ? { values: def.values ?? routes } : {}), file: OVERLAY_FILE, value, source: input.routing.sources?.[def.key.slice("routing.".length)] ?? (value === null ? "default" : projectRouting ? "routing.toml" : "default"),
415
+ return { ...base, ...(def.type === "enum" ? { values: def.values ?? routes } : {}), file: OVERLAY_FILE, value, fileValue: value, source: input.routing.sources?.[def.key.slice("routing.".length)] ?? (value === null ? "default" : projectRouting ? "routing.toml" : "default"),
401
416
  ...(input.routingProblem ? { note: `the routing files do not load, so the last policy that did stays in force: ${input.routingProblem}` } : {}) };
402
417
  }
403
- if (def.type === "roles" && input.roles) return { ...base, ...input.roles(def.path[1]!), file: CONFIG_LOCAL, source: valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "profile/default", hint: "Guidance only; grants no tool or permission. At most two roles, applied at the next peer start." };
418
+ if (def.type === "roles" && input.roles) { const roles = input.roles(def.path[1]!); return { ...base, ...roles, fileValue: roles.pending !== undefined ? roles.pending : roles.value, file: CONFIG_LOCAL, source: valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "profile/default", hint: "Guidance only; grants no tool or permission. At most two roles, applied at the next peer start." }; }
404
419
  const value = scalar(input.running(def.path));
405
420
  const stored = input.next ? scalar(input.next(def.path)) : value;
406
421
  const source = valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "default";
407
422
  const dropped = ignoredLine(input.ignored, def);
408
- return { ...base, ...(def.values ? { values: def.values } : {}), file: CONFIG_LOCAL, value, source, ...(same(value, stored) ? {} : { pending: stored }),
423
+ return { ...base, ...(def.values ? { values: def.values } : {}), file: CONFIG_LOCAL, value, fileValue: stored, source, ...(same(value, stored) ? {} : { pending: stored }),
409
424
  ...(!input.next ? { note: "the config files do not load; fix them in your editor" } : dropped ? { note: `the hub does not read this value: ${dropped}` } : {}) };
410
425
  });
411
426
  }
@@ -0,0 +1,35 @@
1
+ import { conductorPeer } from "./conductor.ts";
2
+ import { PLANNER_PEERS } from "./peer-catalog.ts";
3
+
4
+ export interface SettledRoles {
5
+ roles: Record<string, string[]>;
6
+ source: "setting" | "roles" | "automatic" | "none";
7
+ planners: string[];
8
+ conductor?: string;
9
+ }
10
+
11
+ /** Resolve the runtime planner fallback without changing the configured policy role map. */
12
+ export function resolveSettledRoles(
13
+ policyRoles: Readonly<Record<string, readonly string[]>>,
14
+ planner: string | undefined,
15
+ rostered: (peer: string) => boolean,
16
+ ): SettledRoles {
17
+ const roles = Object.fromEntries(Object.entries(policyRoles).map(([peer, list]) => [peer, [...list]]));
18
+ const pinned = (PLANNER_PEERS as readonly string[]).includes(planner ?? "") ? planner! : undefined;
19
+ let source: SettledRoles["source"];
20
+ if (pinned) {
21
+ for (const peer of Object.keys(roles)) roles[peer] = roles[peer]!.filter(role => role !== "planner");
22
+ roles[pinned] = ["planner", ...(roles[pinned] ?? [])];
23
+ source = "setting";
24
+ } else if (Object.entries(roles).some(([peer, list]) => list.includes("planner") && rostered(peer))) source = "roles";
25
+ else {
26
+ const auto = PLANNER_PEERS.find(rostered);
27
+ if (auto) {
28
+ for (const peer of Object.keys(roles)) roles[peer] = roles[peer]!.filter(role => role !== "planner");
29
+ roles[auto] = [...(roles[auto] ?? []), "planner"];
30
+ }
31
+ source = auto ? "automatic" : "none";
32
+ }
33
+ const conductor = conductorPeer(roles);
34
+ return { roles, source, planners: Object.keys(roles).filter(peer => roles[peer]!.includes("planner")).sort(), ...(conductor === null ? {} : { conductor }) };
35
+ }