@staix/agent-hub 0.12.33 → 0.12.35

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 (126) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +9 -3
  4. package/docs/agent-notes/budget.md +7 -3
  5. package/docs/agent-notes/bus.md +2 -1
  6. package/docs/agent-notes/daemon.md +7 -3
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +6 -2
  9. package/docs/agent-notes/tests.md +2 -1
  10. package/docs/gsd-collaboration-analysis.md +5 -1
  11. package/docs/operations.md +80 -16
  12. package/docs/quickstart.md +10 -2
  13. package/docs/sdlc.md +59 -34
  14. package/docs/security.md +7 -4
  15. package/docs/smoke.md +1 -1
  16. package/docs/specs/2026-09-19-agent-hub-design.md +407 -10
  17. package/docs/verification/2026-10-18-0.12.34.md +48 -0
  18. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  19. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  20. package/docs/verified.json +35 -19
  21. package/package.json +1 -1
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +23567 -16606
  24. package/src/adapters/acp.ts +3 -1
  25. package/src/adapters/claude-channel.ts +59 -29
  26. package/src/adapters/codex-appserver.ts +82 -12
  27. package/src/adapters/definitions/builtin-metadata.ts +101 -7
  28. package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
  29. package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
  30. package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
  31. package/src/adapters/definitions/native-cli-doctor.ts +46 -0
  32. package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
  33. package/src/adapters/definitions/native-cli-entry-codex.ts +34 -0
  34. package/src/adapters/definitions/native-cli-entry-jcode.ts +27 -0
  35. package/src/adapters/definitions/native-cli-entry-kimi.ts +57 -0
  36. package/src/adapters/definitions/native-cli-entry-local.ts +15 -0
  37. package/src/adapters/definitions/native-cli-entry-pi.ts +53 -0
  38. package/src/adapters/definitions/native-cli-entry-qwen.ts +21 -0
  39. package/src/adapters/definitions/native-cli-entry-types.ts +41 -0
  40. package/src/adapters/definitions/native-cli-entry.ts +19 -0
  41. package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
  42. package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
  43. package/src/adapters/definitions/native-cli-pi.ts +33 -0
  44. package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
  45. package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
  46. package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
  47. package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
  48. package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
  49. package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
  50. package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
  51. package/src/adapters/definitions/native-cli-preview.ts +16 -0
  52. package/src/adapters/definitions/native-config-defaults.ts +19 -0
  53. package/src/adapters/definitions/native-config-policy.ts +35 -0
  54. package/src/adapters/definitions/native-context.ts +35 -0
  55. package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
  56. package/src/adapters/definitions/native-daemon-policy.ts +18 -0
  57. package/src/adapters/definitions/native-display.ts +18 -0
  58. package/src/adapters/definitions/native-factories.ts +48 -0
  59. package/src/adapters/definitions/native-policy-aux.ts +130 -0
  60. package/src/adapters/definitions/native-quota.ts +29 -0
  61. package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
  62. package/src/adapters/definitions/native-recovery.ts +88 -0
  63. package/src/adapters/definitions/qwen-native.ts +2 -2
  64. package/src/adapters/definitions/qwen.ts +8 -4
  65. package/src/adapters/local-worker.ts +3 -2
  66. package/src/adapters/pi.ts +3 -1
  67. package/src/adapters/registry-types.ts +100 -10
  68. package/src/adapters/registry.ts +127 -11
  69. package/src/cli/console-state.ts +5 -5
  70. package/src/cli/identity.ts +8 -7
  71. package/src/cli/jcode-mcp.ts +2 -58
  72. package/src/cli/launch.ts +41 -102
  73. package/src/cli/main.ts +126 -290
  74. package/src/cli/output.ts +3 -2
  75. package/src/cli/preview.ts +26 -126
  76. package/src/cli/recovery-guidance.ts +43 -22
  77. package/src/cli/setup.ts +2 -80
  78. package/src/cli/shell-quote.ts +3 -0
  79. package/src/cli/terminal-recovery.ts +81 -22
  80. package/src/cli/upgrade-interactive.ts +4 -2
  81. package/src/cli/upgrade-restore-state.ts +17 -7
  82. package/src/cli/upgrade-runtime.ts +126 -147
  83. package/src/cli/upgrade.ts +44 -27
  84. package/src/hub/budget.ts +59 -31
  85. package/src/hub/bus.ts +1 -1
  86. package/src/hub/conductor.ts +19 -9
  87. package/src/hub/config-apply.ts +22 -15
  88. package/src/hub/config-reload.ts +18 -3
  89. package/src/hub/config-trust.ts +1 -1
  90. package/src/hub/context-window.ts +3 -27
  91. package/src/hub/control-client.ts +1 -1
  92. package/src/hub/crash.ts +15 -39
  93. package/src/hub/daemon.ts +506 -341
  94. package/src/hub/execution-budget.ts +3 -2
  95. package/src/hub/hub-tools.ts +19 -8
  96. package/src/hub/instance-budget.ts +73 -0
  97. package/src/hub/instance-config.ts +47 -0
  98. package/src/hub/instance-execution.ts +50 -0
  99. package/src/hub/instances.ts +100 -0
  100. package/src/hub/lifecycle-schema.ts +7 -3
  101. package/src/hub/peer-catalog.ts +4 -13
  102. package/src/hub/permission-mode.ts +14 -17
  103. package/src/hub/recovery-continuity.ts +6 -5
  104. package/src/hub/report.ts +9 -8
  105. package/src/hub/roster.ts +9 -9
  106. package/src/hub/routing.ts +94 -19
  107. package/src/hub/sdlc-queue.ts +2 -2
  108. package/src/hub/sdlc.ts +84 -38
  109. package/src/hub/settings.ts +39 -10
  110. package/src/hub/settled-roles.ts +1 -1
  111. package/src/hub/snapshots.ts +40 -9
  112. package/src/hub/start-mode.ts +13 -8
  113. package/src/hub/status-roster.ts +8 -0
  114. package/src/hub/tasks.ts +60 -27
  115. package/src/hub/text-projection.ts +7 -3
  116. package/src/hub/tooling.ts +4 -60
  117. package/src/hub/ui.ts +3 -1
  118. package/src/hub/workflow.ts +30 -52
  119. package/src/memory/recall.ts +4 -5
  120. package/src/models/route/signals.ts +3 -1
  121. package/src/pi/launch.ts +5 -3
  122. package/src/ui/index.html +4 -3
  123. package/src/ui/office-model.ts +3 -1
  124. package/src/ui/peer-palette.ts +16 -6
  125. package/templates/AGENTS.block.md +2 -1
  126. package/templates/routing.toml +2 -2
@@ -216,6 +216,7 @@ export class Turns {
216
216
  // ponytail: rows of finished tasks stay (a few per file per task); prune by task state if hub.db ever grows.
217
217
  this.db.run("CREATE TABLE IF NOT EXISTS touches (task INTEGER NOT NULL, peer TEXT NOT NULL, path TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (task, peer, path))");
218
218
  this.db.run("CREATE INDEX IF NOT EXISTS touches_path ON touches (path)"); // the PreToolUse hook looks up one path
219
+ this.db.run("CREATE TABLE IF NOT EXISTS turn_workspaces (turn TEXT PRIMARY KEY, root TEXT NOT NULL, start_tree TEXT, end_tree TEXT, changed TEXT NOT NULL DEFAULT '[]')");
219
220
  this.db.run("CREATE TABLE IF NOT EXISTS task_snapshots (task INTEGER NOT NULL, proposed_at INTEGER NOT NULL, start_tree TEXT, PRIMARY KEY (task, proposed_at))");
220
221
  }
221
222
  /** A proposal's original project snapshot; task ids may be reused after reset. Never repair a missing baseline later. */
@@ -231,27 +232,55 @@ export class Turns {
231
232
  throw error;
232
233
  }
233
234
  }
234
- begin(id: string, peer: string, startTree: string | undefined): void {
235
- this.db.query("INSERT OR REPLACE INTO turns (id, peer, started, start_tree) VALUES (?, ?, ?, ?)").run(id, peer, Date.now(), startTree ?? null);
235
+ begin(id: string, peer: string, startTree: string | undefined, workspace?: string): void {
236
+ this.db.transaction(() => {
237
+ this.db.query("INSERT OR REPLACE INTO turns (id, peer, started, start_tree) VALUES (?, ?, ?, ?)").run(id, peer, Date.now(), workspace === undefined ? startTree ?? null : null);
238
+ this.db.query("DELETE FROM turn_workspaces WHERE turn = ?").run(id);
239
+ if (workspace !== undefined) this.db.query("INSERT INTO turn_workspaces (turn, root, start_tree) VALUES (?, ?, ?)").run(id, workspace, startTree ?? null);
240
+ })();
241
+ }
242
+ /** Only new extra-instance turns carry this evidence; absence preserves legacy external-peer workspace semantics. */
243
+ workspace(id: string): string | undefined {
244
+ try {
245
+ const row = this.db.query("SELECT root FROM turn_workspaces WHERE turn = ?").get(id) as { root: string } | null;
246
+ if (row && (typeof row.root !== "string" || !row.root)) throw new Error("invalid turn workspace evidence");
247
+ return row?.root;
248
+ } catch (error) {
249
+ if (/no such table/.test((error as Error).message)) return undefined;
250
+ throw error;
251
+ }
252
+ }
253
+ private snapshotFields<T extends { id: string; start_tree: string | null; end_tree: string | null; changed: string }>(row: T): T {
254
+ if (this.workspace(row.id) === undefined) return row;
255
+ const fields = this.db.query("SELECT start_tree, end_tree, changed FROM turn_workspaces WHERE turn = ?").get(row.id) as { start_tree: string | null; end_tree: string | null; changed: string };
256
+ return { ...row, ...fields };
236
257
  }
237
258
  /** The first native turn of a hub turn: reverting the conversation drops it and every later one. */
238
259
  native(id: string, native: string): void {
239
260
  this.db.query("UPDATE turns SET native = ? WHERE id = ? AND native IS NULL").run(native, id);
240
261
  }
241
262
  end(id: string, endTree: string | undefined, changed: string[], keep: number): void {
242
- this.db.query("UPDATE turns SET ended = ?, end_tree = ?, changed = ? WHERE id = ?").run(Date.now(), endTree ?? null, JSON.stringify(changed), id);
263
+ this.db.transaction(() => {
264
+ const scoped = this.workspace(id) !== undefined;
265
+ // Older CLIs do not know workspace receipts. Leave their canonical-checkout snapshot view unknown.
266
+ this.db.query("UPDATE turns SET ended = ?, end_tree = ?, changed = ? WHERE id = ?").run(Date.now(), scoped ? null : endTree ?? null, scoped ? "[]" : JSON.stringify(changed), id);
267
+ if (scoped) this.db.query("UPDATE turn_workspaces SET end_tree = ?, changed = ? WHERE turn = ?").run(endTree ?? null, JSON.stringify(changed), id);
268
+ })();
243
269
  const peer = (this.db.query("SELECT peer FROM turns WHERE id = ?").get(id) as { peer: string } | null)?.peer;
244
270
  if (peer) this.db.query("DELETE FROM turns WHERE peer = ? AND id NOT IN (SELECT id FROM turns WHERE peer = ? ORDER BY started DESC, rowid DESC LIMIT ?)").run(peer, peer, keep);
271
+ this.db.run("DELETE FROM turn_workspaces WHERE turn NOT IN (SELECT id FROM turns)");
245
272
  }
246
273
  list(peer?: string, limit = 20): TurnRecord[] {
247
274
  const rows = (peer
248
275
  ? this.db.query("SELECT * FROM turns WHERE peer = ? ORDER BY started DESC, rowid DESC LIMIT ?").all(peer, limit)
249
276
  : this.db.query("SELECT * FROM turns ORDER BY started DESC, rowid DESC LIMIT ?").all(limit)) as (Omit<TurnRecord, "changed"> & { changed: string })[];
250
- return rows.map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }));
277
+ return rows.map(row => this.snapshotFields(row)).map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }));
251
278
  }
252
279
  get(id: string): TurnRecord | undefined {
253
280
  const r = this.db.query("SELECT * FROM turns WHERE id = ?").get(id) as (Omit<TurnRecord, "changed"> & { changed: string }) | null;
254
- return r ? { ...r, changed: JSON.parse(r.changed) as string[] } : undefined;
281
+ if (!r) return undefined;
282
+ const record = this.snapshotFields(r);
283
+ return { ...record, changed: JSON.parse(record.changed) as string[] };
255
284
  }
256
285
  /**
257
286
  * Other peers' turns that overlapped this one in time: their changes are in this turn's diff too. `unknown` lists
@@ -263,21 +292,23 @@ export class Turns {
263
292
  // before" time in Turns.end if that ever matters.
264
293
  overlapping(turn: TurnRecord, keep: number): { paths: string[]; unknown: string[] } {
265
294
  const rows = this.db.query("SELECT id, changed, ended, start_tree, end_tree FROM turns WHERE id != ? AND peer != ? AND started < ? AND (ended IS NULL OR ended > ?)").all(turn.id, turn.peer, turn.ended ?? Date.now(), turn.started) as { id: string; changed: string; ended: number | null; start_tree: string | null; end_tree: string | null }[];
266
- const known = rows.filter((r) => r.ended !== null && r.start_tree !== null && r.end_tree !== null);
295
+ const scopedRows = rows.map(row => this.snapshotFields(row));
296
+ const known = scopedRows.filter((r) => r.ended !== null && r.start_tree !== null && r.end_tree !== null);
267
297
  const pruned = (this.db.query("SELECT peer, MIN(started) AS oldest FROM turns WHERE peer != ? GROUP BY peer HAVING COUNT(*) >= ?").all(turn.peer, keep) as { peer: string; oldest: number }[])
268
298
  .filter((p) => p.oldest > turn.started)
269
299
  .map((p) => `${p.peer} (its turns from then were pruned)`);
270
- return { paths: [...new Set(known.flatMap((r) => JSON.parse(r.changed) as string[]))], unknown: [...rows.filter((r) => !known.includes(r)).map((r) => r.id), ...pruned] };
300
+ return { paths: [...new Set(known.flatMap((r) => JSON.parse(r.changed) as string[]))], unknown: [...scopedRows.filter((r) => !known.includes(r)).map((r) => r.id), ...pruned] };
271
301
  }
272
302
 
273
303
  /** Other peers' completed, snapshotted turns that overlapped this turn and changed at least one of the same files. */
274
304
  concurrentWith(turn: TurnRecord): TurnRecord[] {
275
305
  if (turn.ended === null || turn.start_tree === null || turn.end_tree === null || !turn.changed.length) return [];
276
306
  const rows = this.db.query(`SELECT * FROM turns WHERE id != ? AND peer != ? AND started < ? AND ended > ?
277
- AND start_tree IS NOT NULL AND end_tree IS NOT NULL ORDER BY started, rowid`)
307
+ ORDER BY started, rowid`)
278
308
  .all(turn.id, turn.peer, turn.ended, turn.started) as (Omit<TurnRecord, "changed"> & { changed: string })[];
279
309
  const changed = new Set(turn.changed);
280
- return rows.map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }))
310
+ return rows.map(row => this.snapshotFields(row)).filter(row => row.start_tree !== null && row.end_tree !== null)
311
+ .map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }))
281
312
  .filter((other) => other.changed.some((path) => changed.has(path)));
282
313
  }
283
314
 
@@ -1,4 +1,4 @@
1
- import { SUPPORTED_PEERS, isSupportedPeer } from "./peer-catalog.ts";
1
+ import { definitionOf, SUPPORTED_PEERS, START_MODE_PEERS as REGISTRY_START_MODE_PEERS, type KindMap } from "../adapters/registry.ts";
2
2
 
3
3
  /**
4
4
  * #269: how a peer comes up when the hub starts it: its own TUI in a terminal, or headless. A person who types
@@ -8,34 +8,39 @@ import { SUPPORTED_PEERS, isSupportedPeer } from "./peer-catalog.ts";
8
8
  export const PEER_START_RETRY_MS = 30_000;
9
9
  export const START_MODES = ["tui", "headless"] as const;
10
10
  export type StartMode = typeof START_MODES[number];
11
+ // ponytail: released coordinators source-probe this exact literal before restoring headless Kimi.
12
+ // Keep it until those readers negotiate the registry capability.
11
13
  /** The peers with both forms. Kimi's TUI is pull-only; Claude has only a TUI and local only headless. */
12
14
  export const START_MODE_PEERS = ["pi", "codex", "kimi"] as const;
13
15
  /** Every peer the hub can start itself. */
14
16
  export const STARTABLE_PEERS = SUPPORTED_PEERS;
15
17
  export type StartablePeer = typeof STARTABLE_PEERS[number];
16
- export const isStartablePeer = (value: unknown): value is StartablePeer => isSupportedPeer(value);
18
+ export const isStartablePeer = (value: unknown): value is StartablePeer => typeof value === "string" && !!definitionOf(value)?.start.conductorStartable;
19
+
17
20
 
18
21
  export type PeerStartConfig = Record<string, { start_mode?: StartMode }>;
19
22
  /** The checked per-peer start modes; only peers with both forms may be configured. */
20
- export function peerStartConfig(value: unknown): PeerStartConfig {
23
+ export function peerStartConfig(value: unknown, instances?: KindMap): PeerStartConfig {
21
24
  if (value === undefined) return {};
22
25
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("peers must be an object of per-peer settings");
23
26
  const result: PeerStartConfig = {};
24
27
  for (const [peer, block] of Object.entries(value)) {
25
- if (!(START_MODE_PEERS as readonly string[]).includes(peer)) throw new Error(`peers.${peer}: only ${START_MODE_PEERS.join(" and ")} have a start mode`);
28
+ const extra = definitionOf(peer, instances)?.kind !== peer && !!definitionOf(peer, instances);
29
+ if (!(REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer) && !extra) throw new Error(`peers.${peer}: only ${REGISTRY_START_MODE_PEERS.join(" and ")} have a start mode`);
26
30
  if (!block || typeof block !== "object" || Array.isArray(block)) throw new Error(`peers.${peer} must be an object`);
27
31
  const mode = (block as { start_mode?: unknown }).start_mode;
28
32
  if (mode !== undefined && !(START_MODES as readonly unknown[]).includes(mode)) throw new Error(`peers.${peer}.start_mode must be tui or headless`);
33
+ if (extra && mode !== undefined && mode !== "headless") throw new Error(`peers.${peer}.start_mode: extra instances require headless mode in I2`);
29
34
  result[peer] = mode === undefined ? {} : { start_mode: mode as StartMode };
30
35
  }
31
36
  return result;
32
37
  }
33
38
 
34
39
  /** The mode a start of `peer` takes when nothing more specific was asked: `tui` wherever the peer has one. */
35
- export function startModeOf(peers: PeerStartConfig | undefined, peer: string): StartMode {
36
- if (peer === "claude") return "tui";
37
- if ((START_MODE_PEERS as readonly string[]).includes(peer)) return peers?.[peer]?.start_mode ?? "tui";
38
- return "headless";
40
+ export function startModeOf(peers: PeerStartConfig | undefined, peer: string, instances?: KindMap): StartMode {
41
+ if (definitionOf(peer, instances)?.kind !== peer && definitionOf(peer, instances)) return peers?.[peer]?.start_mode ?? "headless";
42
+ if ((REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer)) return peers?.[peer]?.start_mode ?? definitionOf(peer)?.start.defaultForm ?? "tui";
43
+ return definitionOf(peer)?.start.defaultForm ?? "headless";
39
44
  }
40
45
 
41
46
  /**
@@ -0,0 +1,8 @@
1
+ // No imports: native CLI entries load this from inside the adapter registry's import graph (#484).
2
+ /** A peer's membership from a `status` reply: undefined when the hub sends no roster (older than #484), never read as off. */
3
+ export function rosterInStatus(status: unknown, peer: string): boolean | undefined {
4
+ const rows = (status as { roster?: unknown } | undefined)?.roster;
5
+ if (!Array.isArray(rows)) return undefined;
6
+ const row = rows.find((r: unknown) => !!r && typeof r === "object" && (r as { peer?: unknown }).peer === peer) as { on?: unknown } | undefined;
7
+ return typeof row?.on === "boolean" ? row.on : undefined;
8
+ }
package/src/hub/tasks.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import type { InstancesConfig } from "./instances.ts";
1
2
  import { composeRememberText } from "./memory-fail.ts";
3
+ import { VERSION } from "../version.ts";
2
4
  import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
3
5
  import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
4
6
  import { readMergeQueue } from "./sdlc-queue.ts";
@@ -9,7 +11,7 @@ import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
9
11
  import type { MemoryScreen } from "./read-tools.ts";
10
12
  import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
11
13
  import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
12
- import { phaseView, validatePhaseLinks, workflowHash } from "./workflow.ts";
14
+ import { criteriaView, workflowHash } from "./workflow.ts";
13
15
  import { isAbsolute, relative } from "node:path";
14
16
  import type { Briefs, NativeItem } from "../memory/brief.ts";
15
17
  import type { MemoryClient } from "../memory/client.ts";
@@ -18,14 +20,14 @@ import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasSta
18
20
  import type { Bus } from "./bus.ts";
19
21
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
20
22
  import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
21
- import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignment, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
23
+ import { assign, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
22
24
  import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
23
25
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
24
26
  import { realPath } from "./project.ts";
25
27
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
26
- import { SUPPORTED_PEERS } from "./peer-catalog.ts";
28
+ import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
27
29
  import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
28
- import { readSdlcPr, sdlcConfig, sdlcReceipt, sdlcStatus, stageDefinition, lifecycleStage, type SdlcConfig, sdlcGh, type SdlcGh, type SdlcStatus } from "./sdlc.ts";
30
+ import { checklistOf, readSdlcPr, sdlcConfig, sdlcReceipt, sdlcStatus, stageDefinition, lifecycleStage, type SdlcConfig, sdlcGh, type SdlcGh, type SdlcStatus } from "./sdlc.ts";
29
31
 
30
32
  export type TaskAssignmentRole = "owner" | "reviewer";
31
33
 
@@ -36,6 +38,10 @@ export interface TasksDeps {
36
38
  board: Board;
37
39
  bus: Bus;
38
40
  routing: () => Routing;
41
+ /** Startup-bound project declarations; routing never discovers instances from peer names. */
42
+ instances?: () => InstancesConfig;
43
+ /** Host proof of each live extra runtime's actual isolated execution root; config paths alone are not proof. */
44
+ ownerIsolation?: () => Readonly<Record<string, boolean>>;
39
45
  cwd: string;
40
46
  project: string;
41
47
  profile?: ProfileName;
@@ -125,9 +131,12 @@ export interface TasksDeps {
125
131
  sweep?: TaskSweepConfig;
126
132
  /** Shutdown or coordinator work holds task sweeps too. */
127
133
  sweepHeld?: () => boolean;
134
+ /** Consecutive changes_requested verdicts that move a task to another peer (`tasks.escalate_after`, #488). */
135
+ escalateAfter?: () => number;
128
136
  }
129
137
 
130
- const ESCALATE_AFTER = 2;
138
+ /** The `tasks.escalate_after` default (#488): the third consecutive changes_requested escalates. */
139
+ export const ESCALATE_AFTER = 3;
131
140
  // Demotion (issue #36): a failure counts half after a day. A peer is demoted for a class once its decayed failures
132
141
  // reach 1.5 (two within about half a day, three within two days) and outweigh its decayed successes there.
133
142
  // ponytail: fixed constants; make them config when someone needs to tune them.
@@ -505,19 +514,31 @@ export class Tasks {
505
514
  return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
506
515
  }
507
516
 
517
+ /** Workload is board/queue data only: wip() never calls health() or weights(). */
518
+ private instanceRouting(): InstanceRoutingOptions {
519
+ const instances = this.d.instances?.();
520
+ if (!instances || !Object.keys(instances).length) return {};
521
+ const peers = [...new Set([...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(instances), ...this.d.bus.peers.keys()])];
522
+ return { instances, ownerIsolation: this.d.ownerIsolation?.() ?? {}, wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])), load: Object.fromEntries(peers.map(peer => [peer, this.d.bus.queued(peer)])) };
523
+ }
524
+
508
525
  /** Peer health and roles the daemon feeds routing (issues #89, #90, #92): failing peers are skipped, held queues explained. */
509
526
  private health() {
510
527
  const failing = this.d.failing?.();
511
528
  const held = this.d.held?.();
512
- return { ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => peer.id === "kimi" && peer.pullOnly).map(peer => peer.id), offRoster: SUPPORTED_PEERS.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
529
+ const workload = this.instanceRouting();
530
+ const peers = workload.instances ? [...new Set([...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(workload.instances), ...this.d.bus.peers.keys()])] : ADAPTERS.map(adapter => adapter.kind);
531
+ return { ...workload, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => definitionOf(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
513
532
  }
514
533
 
515
534
  /** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
516
535
  private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: TaskAssignmentRole = "owner", preEffect = true): void {
517
- if (!this.d.capabilities || this.isPii(task)) return;
536
+ const instances = this.d.instances?.(), adapter = definitionOf(peer, instances);
537
+ const declaredExtra = !!adapter && adapter.kind !== peer;
538
+ if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
518
539
  const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
519
540
  const admission = assign(draft, this.states(), this.d.routing(), { candidates: role === "owner" ? [peer] : [], ...(role === "owner" ? { namedOwner: peer } : { namedReviewer: peer }), ...this.weights(task.class) });
520
- const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
541
+ const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec" || declaredExtra && role === "owner" && row.code === "owner-admission")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
521
542
  if (missing.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot assign ${peer}: ${missing.join("; ")}`);
522
543
  }
523
544
 
@@ -799,7 +820,7 @@ export class Tasks {
799
820
  if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
800
821
  if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
801
822
  if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
802
- if (patch.reserved && !(SUPPORTED_PEERS as readonly string[]).includes(patch.reserved) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
823
+ if (patch.reserved && !definitionOf(patch.reserved, this.d.instances?.()) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
803
824
  if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
804
825
  if (patch.reserved) this.requireCapabilities(task, patch.reserved);
805
826
  const rows = new Map(this.d.board.list().map(row => [row.id, row]));
@@ -1387,7 +1408,7 @@ export class Tasks {
1387
1408
 
1388
1409
  /** Same code as assignment, without doing it. `routing`: a candidate policy to explain under (the settings preview, #269). */
1389
1410
  explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing: Routing = this.d.routing()): string[] {
1390
- const workflow = this.d.sdlc?.config().workflow === "gsd-lite" ? [`workflow: gsd-lite (${this.d.sdlc.config().mode}); checklist guidance, board dependencies authoritative`] : [];
1411
+ const workflow = this.d.sdlc?.config().workflow === "gsd-lite" ? [`criteria checklist required on every task (retired gsd-lite setting, ${this.d.sdlc.config().mode}); board dependencies authoritative`] : [];
1391
1412
  if (typeof target === "number") {
1392
1413
  const task = this.d.board.get(target);
1393
1414
  if (!task) throw new Error(`no task #${target}`);
@@ -1458,6 +1479,10 @@ export class Tasks {
1458
1479
  if (hold) this.d.notify(`task ${this.publicTitle(task)}: ${a.owner}'s queue is held (${hold}); the task arrives once the hold is resolved`);
1459
1480
  }
1460
1481
 
1482
+ private assignmentNote(note: string, assignment: Assignment): string {
1483
+ return assignment.sameFamilyFallback ? `${note}; sameFamilyFallback=true` : note;
1484
+ }
1485
+
1461
1486
  /** A later move to another owner means the caller's assignment was superseded, so its success tail is not authoritative. */
1462
1487
  private ownOwnerMove(before: Task, after: Task, by: PeerId, event: string): boolean {
1463
1488
  const moves = (task: Task) => task.history.filter(entry => ["assigned", "reassigned", "escalated", "unassigned"].includes(entry.event));
@@ -1496,7 +1521,7 @@ export class Tasks {
1496
1521
  // A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
1497
1522
  // out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
1498
1523
  const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
1499
- const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, `${opts.note ?? `to ${a.owner}`}${passed}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
1524
+ const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a), { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
1500
1525
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
1501
1526
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
1502
1527
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -1596,7 +1621,7 @@ export class Tasks {
1596
1621
  const pii = this.isPii(task), delivery = { ...a, reviewer: task.reviewer ?? undefined };
1597
1622
  const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", delivery.owner, h.withheld)}`);
1598
1623
  const handoff = context && !pii ? this.screen(task, context, "handoff", delivery.owner, contextWithheld).slice(0, 3000) : "";
1599
- const facts = [`class ${profileTaskLabel(task)}`, delivery.owner === PI ? `backend pi/${delivery.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", delivery.reviewer ? `reviewer ${delivery.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(delivery)].filter(Boolean).join("; ");
1624
+ const facts = [`class ${profileTaskLabel(task)}`, delivery.owner && definitionOf(delivery.owner, this.d.instances?.())?.routing?.backendLabel ? `backend ${definitionOf(delivery.owner, this.d.instances?.())!.routing!.backendLabel}/${delivery.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", delivery.reviewer ? `reviewer ${delivery.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(delivery)].filter(Boolean).join("; ");
1600
1625
  const plan = planText(task.plan);
1601
1626
  const body = [
1602
1627
  `Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
@@ -1710,7 +1735,9 @@ export class Tasks {
1710
1735
  }
1711
1736
  private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
1712
1737
  const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
1713
- return refusal ? { kind: "reservation", peer: refusal.peer, code: refusal.code } : { kind: "no-eligible-peer", code: "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
1738
+ // Persist the existing stall vocabulary; the assignment/explain keeps the precise instance/family refusal.
1739
+ const persisted = (code: NonNullable<Assignment["refusals"]>[number]["code"]): TaskStall["cause"]["code"] => code === "instance" || code === "family" ? "policy" : code;
1740
+ return refusal ? { kind: "reservation", peer: refusal.peer, code: persisted(refusal.code) } : { kind: "no-eligible-peer", code: "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code: persisted(code) })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
1714
1741
  }
1715
1742
  private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now()): void {
1716
1743
  const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
@@ -1813,17 +1840,18 @@ export class Tasks {
1813
1840
  const task = this.need(id, false, true);
1814
1841
  if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
1815
1842
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1816
- const next = sdlcReceipt(input, task.sdlc, config);
1843
+ const next = sdlcReceipt(input, task.sdlc, config, VERSION);
1817
1844
  const definition = stageDefinition(config, next.stage);
1818
1845
  if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
1819
1846
  const actor = definition?.actor;
1820
1847
  const acts = actor === "owner" ? by === task.owner : actor === "reviewer" ? by === task.reviewer : actor === "planner" ? this.d.roles?.[by]?.includes("planner") : actor === "owner/planner" && (by === task.owner || this.d.roles?.[by]?.includes("planner"));
1821
1848
  if (!acts) throw new PreEffectToolRefusal(`lifecycle stage requires its ${actor ?? "unknown"} actor or the person`);
1822
1849
  }
1823
- if (input && typeof input === "object" && "phase" in input) {
1850
+ if (input && typeof input === "object" && ("criteria" in input || "phase" in input)) {
1824
1851
  this.mine(task, by, "reviewer", true);
1825
- if (next.phase && !this.isPii(task) && !this.nameable(JSON.stringify(next.phase))) throw new PreEffectToolRefusal("phase metadata matches a PII pattern; use private work");
1826
- if (next.phase) validatePhaseLinks(task, next.phase, id => this.d.board.get(id), t => this.isPii(t));
1852
+ // Screen whichever name stored the checklist: off keeps the #257 phase key (#481 S8).
1853
+ const checklist = checklistOf(next);
1854
+ if (checklist && !this.isPii(task) && !this.nameable(JSON.stringify(checklist))) throw new PreEffectToolRefusal("checklist metadata matches a PII pattern; use private work");
1827
1855
  }
1828
1856
  // Recording a head is the reviewer's; a head dropped because the repo or PR changed is not a recording, and a
1829
1857
  // head supplied with a new PR identity is one even when its value is unchanged (#382).
@@ -1857,12 +1885,17 @@ export class Tasks {
1857
1885
  }
1858
1886
 
1859
1887
  private workflowView(task: Task) {
1860
- return phaseView(this.d.sdlc?.config() ?? sdlcConfig(undefined), task, id => this.d.board.get(id), t => this.isPii(t));
1888
+ return criteriaView(this.d.sdlc?.config() ?? sdlcConfig(undefined), task, t => this.isPii(t));
1861
1889
  }
1862
1890
  workflowExport(id: unknown): Record<string, unknown> {
1863
- const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
1864
- const state = { id: task.id, state: task.state, stage: task.sdlc?.stage ?? config.stages[0] ?? "spec", ...(this.isPii(task) ? { private: true } : { phase: this.workflowView(task) ?? null }) };
1865
- return { schema: 1, workflow: config.workflow ?? "none", state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
1891
+ const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined), stage = task.sdlc?.stage ?? config.stages[0] ?? "spec";
1892
+ // Off keeps the #257 export bytes; schema 2 (criteria, no workflow framing) is for an enabled or retired-setting lifecycle.
1893
+ if (config.mode === "off" && !config.workflow) {
1894
+ const legacy = { id: task.id, state: task.state, stage, ...(this.isPii(task) ? { private: true } : { phase: null }) };
1895
+ return { schema: 1, workflow: "none", state: legacy, stateHash: workflowHash(legacy), sourceHash: workflowHash(config) };
1896
+ }
1897
+ const state = { id: task.id, state: task.state, stage, ...(this.isPii(task) ? { private: true } : { criteria: this.workflowView(task) ?? null }) };
1898
+ return { schema: 2, state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
1866
1899
  }
1867
1900
  async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
1868
1901
  const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
@@ -2338,7 +2371,7 @@ export class Tasks {
2338
2371
  if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
2339
2372
  this.contradict(rejected);
2340
2373
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
2341
- if (rejected.rejections >= ESCALATE_AFTER) {
2374
+ if (rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
2342
2375
  let moved: Task;
2343
2376
  try {
2344
2377
  moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
@@ -2442,7 +2475,7 @@ export class Tasks {
2442
2475
  this.ready(task, preEffect);
2443
2476
  const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
2444
2477
  if (a.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot name ${peer} as reviewer: ${a.trace.filter(line => line.includes("reviewer") || line.includes("pii")).join("; ")}`);
2445
- const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, `named reviewer ${peer}`, { reason: "manual" }, expectedFlowRevision);
2478
+ const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, this.assignmentNote(`named reviewer ${peer}`, a), { reason: "manual" }, expectedFlowRevision);
2446
2479
  if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
2447
2480
  this.withdrawRoleRequest(task, next, "reviewer");
2448
2481
  }
@@ -2494,7 +2527,7 @@ export class Tasks {
2494
2527
  const owner = task.owner === peer;
2495
2528
  const weights = this.weights(task.class);
2496
2529
  const pii = this.isPii(task);
2497
- const candidates = owner ? [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter(p => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p) : [];
2530
+ const candidates = owner ? relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() }) : [];
2498
2531
  const a = assign(task, this.states(), routing, { ...weights, offRoster: [...(weights.offRoster ?? []), peer], exclude: [peer], excludeOwners: owner && this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], candidates, ...(owner ? { waitsFor: this.waitsFor(task) } : task.owner ? { notReviewer: task.owner } : {}) });
2499
2532
  return { id: task.id, title: this.publicTitle(task), role: owner ? "owner" : "reviewer", to: (owner ? a.owner : a.reviewer) ?? null };
2500
2533
  });
@@ -2515,7 +2548,7 @@ export class Tasks {
2515
2548
  if (urgentOnly && !task.signals.includes("urgent")) continue;
2516
2549
  if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
2517
2550
  const pii = this.isPii(task);
2518
- const candidates = [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p);
2551
+ const candidates = relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() });
2519
2552
  if (this.plannerPullRecord(task)?.pull && !assign(task, this.states(), routing, { candidates, exclude: [...this.excluded(task), peer], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
2520
2553
  // Budget keeps its original release/unassign behaviour for ordinary work. Roster changes the state in
2521
2554
  // assignOwner's same owner write only after routing has found somebody to take it.
@@ -2543,7 +2576,7 @@ export class Tasks {
2543
2576
  this.releaseNotice(`task ${this.publicTitle(task)}: its reviewer ${peer} is off roster and nobody else can review; the reviewer stays ${peer}; use ahub review ${task.id}`);
2544
2577
  continue;
2545
2578
  }
2546
- const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, why);
2579
+ const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a));
2547
2580
  moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
2548
2581
  if (next.state === "in_review" && reviewer && reviewer !== USER) {
2549
2582
  if (roster) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, `(${peer} was reviewing this and is off roster.)`));
@@ -2735,4 +2768,4 @@ export class Tasks {
2735
2768
  }
2736
2769
  }
2737
2770
 
2738
- export { LOCAL };
2771
+ export { LOCAL } from "./routing.ts";
@@ -54,7 +54,7 @@ export class TextProjections {
54
54
  private readonly warned = new Set<string>();
55
55
  /** Inputs whose full-budget conversion failed: the same bytes under the same command are not converted again. */
56
56
  private readonly failed = new Set<string>();
57
- constructor(private readonly options: { cwd: string; stateDir: string; config: ProjectionConfig; deny?: string[]; readAllow?: string[]; log?: (line: string) => void }) {
57
+ constructor(private readonly options: { cwd: string; projectRoot?: string; stateDir: string; config: ProjectionConfig; deny?: string[]; readAllow?: string[]; log?: (line: string) => void }) {
58
58
  // Rooted at an empty system directory, not the project. The project guarantee: project file data is denied, then the
59
59
  // snapshot, the argv-named program/script files and the directory listings above an in-project script are granted, so
60
60
  // no other project file is readable (system, toolchain, home and local.read_allow paths outside the project remain granted).
@@ -105,7 +105,9 @@ export class TextProjections {
105
105
  try {
106
106
  if (deadline !== undefined && (!Number.isFinite(deadline) || deadline <= performance.now())) throw new Error("projection deadline");
107
107
  const rawHash = createHash("sha1").update(bytes).digest("hex"), rawBlob = createHash("sha1").update("blob " + bytes.length + "\0").update(bytes).digest("hex");
108
- const inputHash = hash(bytes), configHash = commandFingerprint(this.options.cwd, argv), key = hash(inputHash + "\n" + configHash + "\n" + ext);
108
+ const inputHash = hash(bytes), commandHash = commandFingerprint(this.options.cwd, argv);
109
+ const configHash = this.options.projectRoot === undefined ? commandHash : hash(commandHash + "\nproject-root:" + realPath(this.options.projectRoot));
110
+ const key = hash(inputHash + "\n" + configHash + "\n" + ext);
109
111
  const cacheDir = join(this.options.stateDir, "text-projections"), cache = join(cacheDir, key + ".json");
110
112
  try {
111
113
  const cached = boundedCache(cache);
@@ -121,7 +123,9 @@ export class TextProjections {
121
123
  // A read_allow entry may enclose the project. Seatbelt lets a later rule win only for the operation it names, so the
122
124
  // deny and the literal grants after it both name file-read-data. The converter runs in its scratch directory (getcwd
123
125
  // needs that directory's data), with its program and script arguments resolved to absolute paths.
124
- const root = realPath(this.options.cwd), projectDeny = "\n(deny file-read-data (subpath " + sbplString(root) + "))";
126
+ const root = realPath(this.options.cwd), protectedRoot = realPath(this.options.projectRoot ?? this.options.cwd);
127
+ if (root !== protectedRoot && !root.startsWith(protectedRoot + "/")) throw new Error("execution root outside protected project");
128
+ const projectDeny = "\n(deny file-read-data (subpath " + sbplString(protectedRoot) + "))";
125
129
  const resolved = programFiles(this.options.cwd, argv);
126
130
  // A module loader lists the directories above its script: grant those listings (not their files) up to the root.
127
131
  const listings = new Set<string>();
@@ -1,7 +1,8 @@
1
+ import { definitionOf } from "../adapters/registry.ts";
1
2
  import { spawn, type ChildProcess } from "node:child_process";
2
- import { accessSync, closeSync, constants, fstatSync, openSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { accessSync, constants, statSync } from "node:fs";
3
4
  import { homedir } from "node:os";
4
- import { delimiter, join, relative, resolve, isAbsolute } from "node:path";
5
+ import { delimiter, relative, resolve, isAbsolute } from "node:path";
5
6
  import { realPath } from "./project.ts";
6
7
 
7
8
  /**
@@ -15,11 +16,6 @@ export const ADDONS = ["claude-mem", "ponytail", "ripwire", "open-code-review",
15
16
  export type AddonName = (typeof ADDONS)[number];
16
17
  export interface Addon { name: AddonName; version: string | null }
17
18
 
18
- /** Plugin names (before the `@marketplace`) that stand for an add-on. */
19
- // Maps, not objects: a plugin named `constructor` or `toString` must find nothing, not an inherited property.
20
- const PLUGINS = new Map<string, AddonName>([["claude-mem", "claude-mem"], ["ponytail", "ponytail"], ["open-code-review", "open-code-review"]]);
21
- /** Claude Code only: the Codex plugin is a Claude plugin named `codex` from the `openai-codex` marketplace. */
22
- const CLAUDE_ONLY = new Map<string, AddonName>([["codex@openai-codex", "codex-plugin"]]);
23
19
  /**
24
20
  * Executables any peer with a shell can run, and how each is told apart from another program of the same name: the
25
21
  * first one on PATH is asked for its version (bounded, no stdin), so Graphviz's `dot` is never resource admission.
@@ -32,60 +28,9 @@ const PROBE_MS = 2000, PROBE_BYTES = 4096;
32
28
  /** `env` supplies CLAUDE_CONFIG_DIR and CODEX_HOME, the runtimes' own config roots (the hub's environment by default). */
33
29
  export interface ToolingSources { home?: string; cwd?: string; path?: string; env?: Record<string, string | undefined> }
34
30
 
35
- /** A regular file of at most 1 MiB, opened without blocking: a FIFO or device under a project's `.claude/` reads as absent. */
36
- const text = (file: string): string | undefined => {
37
- let fd: number | undefined;
38
- try {
39
- fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
40
- const st = fstatSync(fd);
41
- if (!st.isFile() || st.size > 1024 * 1024) return undefined;
42
- return readFileSync(fd, "utf8");
43
- } catch { return undefined; } finally { if (fd !== undefined) closeSync(fd); }
44
- };
45
- const json = (file: string): Record<string, unknown> | undefined => {
46
- try { const v = JSON.parse(text(file) ?? ""); return v && typeof v === "object" ? v as Record<string, unknown> : undefined; } catch { return undefined; }
47
- };
48
31
  /** A version string as data, never anything longer or stranger. */
49
32
  const version = (v: unknown): string | null => typeof v === "string" && /^[0-9A-Za-z.+_-]{1,40}$/.test(v) ? v : null;
50
33
 
51
- function claudePlugins(root: string, cwd: string | undefined): Addon[] {
52
- const enabled = new Map<string, boolean>();
53
- for (const file of [join(root, "settings.json"), ...(cwd ? [join(cwd, ".claude", "settings.json"), join(cwd, ".claude", "settings.local.json")] : [])]) {
54
- const map = json(file)?.enabledPlugins;
55
- if (map && typeof map === "object") for (const [key, on] of Object.entries(map)) enabled.set(key, on === true);
56
- }
57
- const installed = (json(join(root, "plugins", "installed_plugins.json"))?.plugins ?? {}) as Record<string, { scope?: string; projectPath?: string; version?: unknown }[]>;
58
- const out: Addon[] = [];
59
- for (const [key, on] of enabled) {
60
- if (!on) continue;
61
- const name = CLAUDE_ONLY.get(key) ?? PLUGINS.get(key.split("@")[0]!);
62
- const entries = Array.isArray(installed[key]) ? installed[key]! : [];
63
- // An install for another project is not this one's.
64
- const entry = entries.find((e) => e?.scope === "user") ?? entries.find((e) => e?.projectPath === cwd);
65
- if (name && entry) out.push({ name, version: version(entry.version) });
66
- }
67
- return out;
68
- }
69
-
70
- function codexPlugins(root: string): Addon[] {
71
- const config = text(join(root, "config.toml"));
72
- if (config === undefined) return [];
73
- const found = new Map<string, string>(), disabled = new Set<string>(); // plugin name -> marketplace
74
- // `[plugins."name@market"]` with no `enabled = false` below it, and plugins whose hooks Codex was told to trust
75
- // unless their plugin entry disables them: a trust entry outlives the plugin's own switch.
76
- for (const m of config.matchAll(/^\[plugins\."([^"@]+)@([^"]+)"\]\s*\n((?:(?!\[)[^\n]*\n?)*)/gm)) if (!/^\s*enabled\s*=\s*false\b/m.test(m[3] ?? "")) found.set(m[1]!, m[2]!); else disabled.add(m[1]! + "@" + m[2]!);
77
- for (const m of config.matchAll(/^\[hooks\.state\."([^"@]+)@([^":]+):/gm)) if (!disabled.has(m[1]! + "@" + m[2]!)) found.set(m[1]!, m[2]!);
78
- const out: Addon[] = [];
79
- for (const [plugin, market] of found) {
80
- const name = PLUGINS.get(plugin);
81
- if (!name) continue;
82
- let versions: string[] = [];
83
- try { versions = readdirSync(join(root, "plugins", "cache", market, plugin)); } catch { /* no cache: version unknown */ }
84
- out.push({ name, version: versions.length === 1 ? version(versions[0]) : null });
85
- }
86
- return out;
87
- }
88
-
89
34
  /** Never throws: a path that cannot be resolved (a project directory that went away) is compared as given. */
90
35
  const real = (path: string): string => { try { return realPath(path); } catch { return resolve(path); } };
91
36
  const within = (file: string, dir: string): boolean => { const r = relative(dir, file); return r === "" || (!r.startsWith("..") && !isAbsolute(r)); };
@@ -145,8 +90,7 @@ export async function detectTooling(peer: string, src: ToolingSources = {}): Pro
145
90
  const found = new Map<AddonName, Addon>();
146
91
  const add = (a: Addon) => { if (!found.has(a.name) || (found.get(a.name)!.version === null && a.version !== null)) found.set(a.name, a); };
147
92
  const env = src.env ?? process.env;
148
- if (peer === "claude") claudePlugins(env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude"), src.cwd).forEach(add);
149
- if (peer === "codex") codexPlugins(env.CODEX_HOME?.trim() || join(home, ".codex")).forEach(add);
93
+ definitionOf(peer)?.hooks?.pluginInventory?.({ env, home, cwd: src.cwd }).forEach(add);
150
94
  const path = src.path ?? process.env.PATH ?? "";
151
95
  for (const [bin, spec] of Object.entries(BINARIES)) {
152
96
  const file = onPath(bin, path, src.cwd ?? process.cwd());
package/src/hub/ui.ts CHANGED
@@ -5,6 +5,7 @@ import { PEER_PALETTE, peerPaletteCss } from "../ui/peer-palette.ts";
5
5
  import { PEER_START_RETRY_MS } from "./start-mode.ts";
6
6
  import { taskProgress } from "../ui/task-progress.ts";
7
7
  import { officeModel } from "../ui/office-model.ts";
8
+ import { OFFICE_PEER_ORDER, ADAPTER_DISPLAY } from "../adapters/registry.ts";
8
9
  import { needs } from "../ui/needs.ts";
9
10
  import { createHash, randomBytes } from "node:crypto";
10
11
  import { readFileSync } from "node:fs";
@@ -35,10 +36,11 @@ export function startDashboard(options: DashboardOptions) {
35
36
  // Inject the same pure public-board model used by the console before computing CSP hashes.
36
37
  const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
37
38
  .replace("/* BOARD_MODEL */", () => `const boardColumns = ${boardColumns.toString()}; const peerPalette = ${JSON.stringify(PEER_PALETTE)};`)
39
+ .replace("/* ADAPTER_DISPLAY */", () => `const adapterDisplay = ${JSON.stringify(ADAPTER_DISPLAY)};`)
38
40
  .replace("/* PEER_PALETTE_CSS */", () => peerPaletteCss())
39
41
  .replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`)
40
42
  .replace("/* PEER_START_RETRY_MS */", () => `const PEER_START_RETRY_MS = ${PEER_START_RETRY_MS};`)
41
- .replace("/* OFFICE_MODEL */", () => `const officeModel = ${officeModel.toString()};`)
43
+ .replace("/* OFFICE_MODEL */", () => `const officeModel = ((OFFICE_PEER_ORDER) => ${officeModel.toString()})(${JSON.stringify(OFFICE_PEER_ORDER)});`)
42
44
  .replace("/* NEEDS_MODEL */", () => `const needs = ${needs.toString()};`);
43
45
  const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
44
46
  .map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");