@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
@@ -1,3 +1,4 @@
1
+ import { ADAPTERS, definitionOf, kindOf, familyOf } from "../adapters/registry.ts";
1
2
  import { readFileSync, statSync } from "node:fs";
2
3
  import { join, resolve } from "node:path";
3
4
  import { CLASSES, type Task, type TaskClass } from "./board.ts";
@@ -17,7 +18,7 @@ export interface ClassPolicy {
17
18
  /** Switchyard route the local worker asks for on a task of this class */
18
19
  route?: string;
19
20
  fixed_model?: string;
20
- /** on two consecutive changes_requested */
21
+ /** on `tasks.escalate_after` consecutive changes_requested (default 3) */
21
22
  escalate_to?: PeerId[];
22
23
  /** false: `local` is never owner or reviewer for this class */
23
24
  local_allowed?: boolean;
@@ -108,6 +109,56 @@ const TEMPLATE = join(import.meta.dir, "..", "..", "templates", "routing.toml");
108
109
  export const LOCAL: PeerId = "local";
109
110
  export const PI: PeerId = "pi";
110
111
 
112
+ /** Startup-bound project data, never inferred from a suffix or read from configuration here. */
113
+ export interface InstanceRoutingOptions {
114
+ instances?: Readonly<Record<string, { kind: string }>>;
115
+ wip?: Record<string, number>;
116
+ load?: Record<string, number>;
117
+ /** Positive host verification of the live extra runtime's noncanonical execution root. */
118
+ ownerIsolation?: Readonly<Record<string, boolean>>;
119
+ }
120
+ const hasInstances = (opts: InstanceRoutingOptions): boolean => !!opts.instances && Object.keys(opts.instances).length > 0;
121
+ function siblingOrder(states: Readonly<Record<string, PeerState>>, opts: InstanceRoutingOptions): (a: string, b: string) => number {
122
+ const count = (map: Record<string, number> | undefined, id: string) => {
123
+ const value = peerEntry(map, id);
124
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
125
+ };
126
+ return (a, b) => Number(states[a] !== "idle") - Number(states[b] !== "idle") || count(opts.wip, a) - count(opts.wip, b) || count(opts.load, a) - count(opts.load, b);
127
+ }
128
+ /** A class kind expands once; an explicitly declared instance remains an exact id. */
129
+ function expandClassPeers(peers: readonly PeerId[], states: Readonly<Record<string, PeerState>>, opts: InstanceRoutingOptions): PeerId[] {
130
+ if (!hasInstances(opts)) return [...peers];
131
+ return [...new Set(peers.flatMap(peer => {
132
+ const kind = kindOf(peer, opts.instances);
133
+ return kind && kind === peer ? [peer, ...Object.keys(opts.instances!).filter(id => kindOf(id, opts.instances) === kind)].sort(siblingOrder(states, opts)) : [peer];
134
+ }))];
135
+ }
136
+ /** Quota/adaptive policy retains kind slots; sibling workload chooses only within that kind's slots. */
137
+ function orderSiblingSlots(peers: PeerId[], states: Readonly<Record<string, PeerState>>, opts: InstanceRoutingOptions): PeerId[] {
138
+ if (!hasInstances(opts)) return peers;
139
+ const groups = new Map<string, number[]>(), out = [...peers];
140
+ for (const [index, peer] of peers.entries()) {
141
+ const kind = kindOf(peer, opts.instances);
142
+ if (kind) { const slots = groups.get(kind) ?? []; slots.push(index); groups.set(kind, slots); }
143
+ }
144
+ for (const slots of groups.values()) {
145
+ const sorted = slots.map(index => peers[index]!).sort(siblingOrder(states, opts));
146
+ slots.forEach((index, position) => { out[index] = sorted[position]!; });
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /** The quota/roster relay keeps its native preference order, then the class's original tail. */
152
+ export function relayCandidates(classPeers: readonly PeerId[], pii: boolean, opts: InstanceRoutingOptions & { states?: Readonly<Record<string, PeerState>> } = {}): PeerId[] {
153
+ const preferred = ADAPTERS.filter(adapter => adapter.routing?.handoffPriority !== undefined)
154
+ .sort((a, b) => a.routing!.handoffPriority! - b.routing!.handoffPriority!);
155
+ const candidates = [
156
+ ...preferred.filter(adapter => !pii || adapter.trust.onPrem).map(adapter => adapter.kind),
157
+ ...classPeers.filter(peer => definitionOf(peer, opts.instances)?.routing?.handoffPriority === undefined),
158
+ ].filter((peer): peer is PeerId => !!peer);
159
+ return expandClassPeers(candidates, opts.states ?? {}, opts);
160
+ }
161
+
111
162
  /**
112
163
  * `.agenthub/routing.toml`, or the shipped default when the project has none, with `.agenthub/routing.local.toml`
113
164
  * merged over it key by key (#269). Throws on a file that does not parse or lacks a fixed model.
@@ -234,9 +285,11 @@ export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, rou
234
285
  /** A peer's entry in a peer-indexed map: own keys only, so a peer named `constructor` is never an inherited Object member. */
235
286
  export const peerEntry = <T>(map: Readonly<Record<string, T>> | undefined, peer: string): T | undefined => (map && Object.hasOwn(map, peer) ? map[peer] : undefined);
236
287
 
237
- export type RoutingRefusalCode = "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
288
+ export type RoutingRefusalCode = "instance" | "family" | "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
238
289
  export interface RoutingRefusal { peer: PeerId; role: "owner" | "reviewer"; code: RoutingRefusalCode; reason: string }
239
290
  export interface Assignment {
291
+ /** A same-family reviewer was chosen only because no other family was eligible. */
292
+ sameFamilyFallback?: true;
240
293
  refusals?: RoutingRefusal[];
241
294
  owner?: PeerId;
242
295
  reviewer?: PeerId;
@@ -257,7 +310,7 @@ export function assign(
257
310
  task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external">>,
258
311
  states: Record<PeerId, PeerState>,
259
312
  routing: Routing,
260
- opts: {
313
+ opts: InstanceRoutingOptions & {
261
314
  capabilities?: Record<PeerId, PeerCapabilities>;
262
315
  repository?: string;
263
316
  exclude?: PeerId[];
@@ -290,13 +343,14 @@ export function assign(
290
343
  ): Assignment {
291
344
  if (task.state === "cancelled" || task.state === "approved") return { trace: [`${task.state}: closed; not routed`] };
292
345
  if (task.state === "backlog") return { trace: ["backlog: not routed; pull into To Do first"] };
346
+ const instanceMode = hasInstances(opts);
293
347
  const policy = routing.classes[task.class];
294
348
  const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
295
349
  // Which file each of this class's values comes from (#269), only when the machine-local overlay sets one.
296
350
  const local = Object.keys(routing.sources ?? {}).filter((key) => key.startsWith(`classes.${task.class}.`)).map((key) => key.slice(`classes.${task.class}.`.length));
297
351
  if (local.length) trace.push(`policy source: ${local.join(", ")} from ${OVERLAY_FILE}; the rest from routing.toml`);
298
352
  // A reserved owner (issue #207) is offered the task first. Named candidates (an assign, an escalation, a relay) replace it.
299
- const reserved = opts.candidates ? undefined : task.reserved ?? undefined;
353
+ const reserved = opts.candidates || instanceMode && opts.namedOwner !== undefined ? undefined : task.reserved ?? undefined;
300
354
  // Readiness is an input like peer states (issue #34): a task that waits for others goes to nobody yet.
301
355
  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`] : [])] };
302
356
  const pii = task.signals.includes("pii_path") || (task.signals.includes("pii") && routing.constraints.pii === "local_only");
@@ -319,19 +373,24 @@ export function assign(
319
373
  if (!Object.hasOwn(states, peer)) return deny("not-attached", "not attached");
320
374
  if (opts.exclude?.includes(peer)) return deny("excluded", "excluded (declined or replaced)");
321
375
  if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
376
+ const adapter = definitionOf(peer, opts.instances);
377
+ if (role === "owner" && needsTests && adapter && adapter.kind !== peer) {
378
+ if (!peerEntry(opts.roles, peer)?.includes("implementer")) return deny("owner-admission", "owner admission: declared write owner requires the current implementer role");
379
+ if (peerEntry(opts.ownerIsolation, peer) !== true) return deny("owner-admission", "owner admission: declared write owner requires a verified isolated execution root");
380
+ }
322
381
  if (states[peer] === "offline") return deny("offline", "offline");
323
382
  if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return deny("paused", "paused");
324
383
  const failing = peerEntry(opts.failing, peer);
325
384
  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");
329
- if (peer === PI && task.signals.includes("long_context")) {
385
+ if (pii && definitionOf(peer, opts.instances)?.trust.onPrem !== true) return deny("pii", "pii: on-prem peers only");
386
+ const nativeRouting = definitionOf(peer, opts.instances)?.routing;
387
+ if (nativeRouting?.localAllowedRefusal && policy?.local_allowed === false) return deny("policy", nativeRouting.localAllowedRefusal);
388
+ if (nativeRouting?.longContext === "backend-limit" && task.signals.includes("long_context")) {
330
389
  const backend = policy?.pi_backend ?? "dgx";
331
390
  const limit = backend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens;
332
- if (routing.signals.long_context_tokens > limit) return deny("policy", `pi ${backend} capability limit ${limit} tokens`);
391
+ if (routing.signals.long_context_tokens > limit) return deny("policy", `${nativeRouting.backendLabel} ${backend} capability limit ${limit} tokens`);
333
392
  }
334
- if (peer === LOCAL && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return deny("policy", "long_context: skip local");
393
+ if (nativeRouting?.longContext === "skip-owner" && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return deny("policy", "long_context: skip local");
335
394
  return undefined;
336
395
  };
337
396
  /** 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). */
@@ -372,16 +431,16 @@ export function assign(
372
431
  return role === "reviewer" ? byRecord(ranked, owner) : ranked;
373
432
  };
374
433
 
375
- const pick = (list: PeerId[], role: "owner" | "reviewer", not?: PeerId): PeerId | undefined => {
434
+ const pick = (list: PeerId[], role: "owner" | "reviewer", not?: PeerId, sortSiblings = false): PeerId | undefined => {
376
435
  const ok: PeerId[] = [];
377
436
  for (const peer of list) {
378
437
  const why = peer === not ? "is the owner" : blocked(peer, role);
379
438
  trace.push(` ${role} candidate ${peer}: ${why ? `skipped, ${why}` : states[peer]}`);
380
439
  if (!why) ok.push(peer);
381
440
  }
382
- const ranked = rank(ok, role, not);
441
+ const ranked = sortSiblings ? orderSiblingSlots(rank(ok, role, not), states, opts) : rank(ok, role, not);
383
442
  // Demotion applies to local and Pi too: a demoted one loses its place ahead of the cloud peers.
384
- const localTier = ranked.filter((p) => (p === LOCAL || p === PI) && !(role === "owner" && peerEntry(opts.demoted, p)));
443
+ const localTier = ranked.filter((p) => definitionOf(p, opts.instances)?.routing?.preferredTier === true && !(role === "owner" && peerEntry(opts.demoted, p)));
385
444
  if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
386
445
  return ranked.find((p) => states[p] === "idle") ?? ranked[0];
387
446
  };
@@ -390,13 +449,15 @@ export function assign(
390
449
  const unreserved = reserved ? blocked(reserved, "owner") : undefined;
391
450
  if (reserved) trace.push(`reserved owner ${reserved}: ${unreserved ? `not honored, ${unreserved}; routing proceeds` : "honored"}`);
392
451
  // Never the task's current owner by default: a decline or an escalation has to reach the next peer in the list.
393
- const wanted = reserved && !unreserved ? [reserved] : opts.candidates ?? policy?.peers ?? [];
394
- const owner = pick(wanted, "owner");
452
+ const automaticOwner = !(reserved && !unreserved) && opts.candidates === undefined && !(instanceMode && opts.namedOwner !== undefined);
453
+ const wanted = reserved && !unreserved ? [reserved] : opts.candidates ?? (instanceMode && opts.namedOwner !== undefined ? [opts.namedOwner] : expandClassPeers(policy?.peers ?? [], states, opts));
454
+ const owner = pick(wanted, "owner", undefined, automaticOwner);
395
455
  trace.push(owner ? `owner: ${owner}` : "owner: none available, task stays proposed (ahub task assign <id> <peer>)");
396
456
  const ownerHold = owner ? peerEntry(opts.held, owner) : undefined;
397
457
  if (ownerHold) trace.push(` hold: ${owner}'s queue is held: ${ownerHold} (it receives the task once the hold is resolved)`);
398
458
 
399
459
  let reviewer: PeerId | undefined;
460
+ let sameFamilyFallback = false;
400
461
  if (task.class !== "review") {
401
462
  if (pii) trace.push("reviewer: user (pii: no second on-prem peer; ahub review <id> <verdict>)");
402
463
  else {
@@ -404,7 +465,21 @@ export function assign(
404
465
  const classPeers = routing.classes.review?.peers ?? [];
405
466
  const rolePeers = Object.keys(opts.roles ?? {}).filter((p) => opts.roles![p]!.includes("reviewer"));
406
467
  trace.push(opts.namedReviewer === undefined ? ` reviewer candidates: [classes.review] peers ${classPeers.join(", ") || "none"}${rolePeers.length ? `; reviewer role ${rolePeers.join(", ")}` : ""}` : ` reviewer candidate explicitly named: ${opts.namedReviewer}`);
407
- reviewer = pick(opts.namedReviewer === undefined ? [...new Set([...classPeers, ...rolePeers])] : [opts.namedReviewer], "reviewer", owner ?? opts.notReviewer);
468
+ const reviewOwner = owner ?? opts.notReviewer;
469
+ const automatic = [...new Set([...expandClassPeers(classPeers, states, opts), ...rolePeers])];
470
+ const ownerFamily = instanceMode && reviewOwner ? familyOf(reviewOwner, opts.instances) : undefined;
471
+ const sameFamily = (peer: PeerId) => ownerFamily !== undefined && familyOf(peer, opts.instances) === ownerFamily;
472
+ const independent = ownerFamily === undefined ? [] : automatic.filter(peer => peer !== reviewOwner && !blocked(peer, "reviewer") && !sameFamily(peer));
473
+ const named = opts.namedReviewer;
474
+ if (named !== undefined && named !== reviewOwner && !blocked(named, "reviewer") && sameFamily(named) && independent.length) {
475
+ const reason = `same family as ${reviewOwner}; eligible other-family reviewer available`;
476
+ refusals.set(`reviewer:${named}`, { peer: named, role: "reviewer", code: "family", reason });
477
+ trace.push(` reviewer candidate ${named}: skipped, ${reason}`);
478
+ } else reviewer = pick(named === undefined ? independent.length ? independent : automatic : [named], "reviewer", reviewOwner, named === undefined);
479
+ if (reviewer && sameFamily(reviewer)) {
480
+ sameFamilyFallback = true;
481
+ trace.push(` same-family fallback: ${reviewer} and ${reviewOwner} share ${ownerFamily}; no eligible other family`);
482
+ }
408
483
  trace.push(reviewer ? `reviewer: ${reviewer}` : "reviewer: none, done will approve directly");
409
484
  const reviewerHold = reviewer ? peerEntry(opts.held, reviewer) : undefined;
410
485
  if (reviewerHold) trace.push(` hold: ${reviewer}'s queue is held: ${reviewerHold} (it receives the review once the hold is resolved)`);
@@ -413,9 +488,9 @@ export function assign(
413
488
  const route = policy?.route ?? routing.local.route;
414
489
  const fixedModel = policy?.fixed_model ?? routing.local.fixed_model;
415
490
  const piBackend = policy?.pi_backend;
416
- if (owner === LOCAL) trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
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}` : ""}`);
418
- return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
491
+ if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
492
+ if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "backend") trace.push(`${definitionOf(owner, opts.instances)!.routing!.backendLabel} 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}` : ""}`);
493
+ return { ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
419
494
  }
420
495
 
421
496
  /**
@@ -1,6 +1,6 @@
1
- import type { PhaseView } from "./workflow.ts";
1
+ import type { CriteriaView } from "./workflow.ts";
2
2
  import { parsePrEvidence, readSdlcPr, sdlcGh, sdlcStatus, type PrEvidence, type SdlcConfig, type SdlcGh, type SdlcReceipt } from "./sdlc.ts";
3
- export interface DeliveryTask { id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: PhaseView; }
3
+ export interface DeliveryTask { id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView; }
4
4
  export interface MergeQueueRow { pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
5
5
  export interface MergeConflict { left: number; right: number; files: number; order: "dependency" | "person_required"; }
6
6
  export interface MergeQueue { repo: string; rows: MergeQueueRow[]; order: number[]; unordered?: number[]; conflicts: MergeConflict[]; complete: boolean; truncated: boolean; }
package/src/hub/sdlc.ts CHANGED
@@ -1,20 +1,39 @@
1
- import { phaseReceipt, type PhaseReceipt, type PhaseView } from "./workflow.ts";
1
+ import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
2
2
  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
6
  import { hostExecutable } from "./host-executable.ts";
7
7
 
8
+ /** The off-mode default (#257). Off output, receipts and schemas keep this shape. */
8
9
  export const SDLC_STAGES = ["spec", "plan", "implement", "review", "verify", "merge", "release"] as const;
9
- export type SdlcBaseStage = typeof SDLC_STAGES[number];
10
+ /** The enabled default (#481 S0): the AI-Native SDLC Playbook stage model. */
11
+ export const PLAYBOOK_STAGES = ["intent", "spec", "plan", "build", "test", "review", "merge", "release", "maintain"] as const;
12
+ export type SdlcBaseStage = typeof SDLC_STAGES[number] | typeof PLAYBOOK_STAGES[number];
13
+ const BASES: readonly string[] = [...new Set<string>([...SDLC_STAGES, ...PLAYBOOK_STAGES])];
14
+ /** Legacy ids read as their playbook stage; a receipt written with one stores the canonical id. */
15
+ const ALIASES: Record<string, string> = { implement: "build", verify: "test" };
16
+ export const canonicalStage = (stage: string): string => ALIASES[stage] ?? stage;
17
+ const PLAYBOOK_LABEL: Record<string, string> = { intent: "Plan", spec: "Design", plan: "Build", build: "Build", test: "Test", review: "Deploy", merge: "Deploy", release: "Deploy", maintain: "Maintain" };
18
+ export const playbookStage = (base: string): string | undefined => PLAYBOOK_LABEL[canonicalStage(base)];
19
+ /** #481 S8: `workflow` and the `phase` gate/receipt name load with a warning before this hub version and refuse from it. */
20
+ export const RETIRED_WORKFLOW_FROM = "0.13.0";
21
+ const olderThan = (version: string, limit: string): boolean => {
22
+ const a = version.split(/[.-]/).slice(0, 3).map(Number), b = limit.split(".").map(Number);
23
+ for (let i = 0; i < 3; i++) if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) < (b[i] ?? 0);
24
+ return false;
25
+ };
10
26
  export type SdlcStage = string;
11
- export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "phase";
27
+ export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "criteria";
12
28
  export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
13
29
  export type SdlcMode = "off" | "advise" | "enforce";
14
- export interface SdlcConfig { workflow?: "gsd-lite"; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; }
30
+ /** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
31
+ export interface SdlcConfig { workflow?: "gsd-lite"; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
15
32
  export interface SdlcReceipt {
16
33
  stage: SdlcStage;
17
- phase?: PhaseReceipt;
34
+ criteria?: CriteriaReceipt;
35
+ /** Stored before #481 S8 and read as `criteria`; never written again. */
36
+ phase?: LegacyPhaseReceipt;
18
37
  repo?: string;
19
38
  issue?: number;
20
39
  pr?: number;
@@ -43,33 +62,42 @@ const repo = (value: unknown): value is string => typeof value === "string" && /
43
62
  const id = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
44
63
 
45
64
  const STAGE_ID = /^[a-z][a-z0-9_-]{0,47}$/;
46
- const STAGE_GATES: StageGate[] = ["issue", "pr", "local", "review", "checks", "phase"];
47
- const baseActor = (base: SdlcBaseStage) => base === "review" || base === "verify" ? "reviewer" : base === "merge" || base === "release" ? "person" : "owner/planner";
65
+ // #451 froze this literal as non-peer gate names ("local" is the local-check gate). "phase" is read as criteria (#481 S8).
66
+ const STAGE_GATES = ["issue", "pr", "local", "review", "checks", "phase"];
67
+ const GATES = STAGE_GATES.map(gate => gate === "phase" ? "criteria" : gate) as StageGate[];
68
+ const baseActor = (base: string) => base === "review" || canonicalStage(base) === "test" ? "reviewer" : base === "merge" || base === "release" ? "person" : "owner/planner";
69
+ /** A receipt's checklist under either stored name. */
70
+ export const checklistOf = (receipt: SdlcReceipt | undefined): CriteriaReceipt | undefined => receipt?.criteria ?? receipt?.phase;
48
71
  export function stageDefinition(config: SdlcConfig, stage: string): StageDefinition | undefined {
49
- if (!config.stages.includes(stage)) return undefined;
72
+ if (!config.stages.includes(stage)) return stage !== canonicalStage(stage) ? stageDefinition(config, canonicalStage(stage)) : undefined;
50
73
  if (config.definitions && Object.hasOwn(config.definitions, stage)) return config.definitions[stage];
51
- if (!(SDLC_STAGES as readonly string[]).includes(stage)) return undefined;
74
+ if (!BASES.includes(stage)) return undefined;
52
75
  const base = stage as SdlcBaseStage;
53
76
  return { base, actor: baseActor(base), entry_gates: [], exit_gates: [], after: [] };
54
77
  }
55
78
  export function lifecycleStage(config: SdlcConfig, base: SdlcBaseStage): string {
56
79
  return config.stages.find(stage => stageDefinition(config, stage)?.base === base) ?? base;
57
80
  }
58
- function definitions(raw: unknown, stages: unknown): { stages: string[]; definitions: Record<string, StageDefinition> } {
81
+ function definitions(raw: unknown, stages: unknown, phaseAlias: { allowed: boolean; used: boolean }): { stages: string[]; definitions: Record<string, StageDefinition> } {
59
82
  if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).length > 32) throw new Error("sdlc.definitions: expected at most 32 stage definitions");
60
83
  if (!Array.isArray(stages) || !stages.length || stages.length > 32 || stages.some(s => typeof s !== "string" || !STAGE_ID.test(s)) || new Set(stages).size !== stages.length) throw new Error("sdlc.stages: expected 1..32 distinct bounded stage ids");
61
84
  const result: Record<string, StageDefinition> = Object.create(null);
62
85
  for (const [id, value] of Object.entries(raw)) {
63
86
  if (!STAGE_ID.test(id) || !stages.includes(id) || !value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc.definitions: invalid or unlisted stage");
64
87
  const def = value as Record<string, unknown>;
65
- if (Object.keys(def).some(k => !["base", "actor", "entry_gates", "exit_gates", "after"].includes(k)) || !(SDLC_STAGES as readonly unknown[]).includes(def.base)) throw new Error("sdlc.definitions: unknown field or base");
88
+ if (Object.keys(def).some(k => !["base", "actor", "entry_gates", "exit_gates", "after"].includes(k)) || !BASES.includes(def.base as string)) throw new Error("sdlc.definitions: unknown field or base");
66
89
  const base = def.base as SdlcBaseStage;
67
90
  if ((SDLC_STAGES as readonly string[]).includes(id) && id !== base) throw new Error("sdlc.definitions: built-in stage base cannot change");
91
+ if (!(SDLC_STAGES as readonly string[]).includes(id) && (PLAYBOOK_STAGES as readonly string[]).includes(id) && canonicalStage(id) !== canonicalStage(base)) throw new Error("sdlc.definitions: built-in playbook stage base cannot change");
68
92
  const actor = def.actor ?? baseActor(base);
69
93
  if (typeof actor !== "string" || actor !== baseActor(base) && !["owner", "reviewer", "person", "planner"].includes(actor)) throw new Error("sdlc.definitions.actor: unknown actor");
70
94
  const gates = (key: string): StageGate[] => {
71
- const list = def[key] ?? [];
72
- if (!Array.isArray(list) || list.length > 6 || list.some(g => !STAGE_GATES.includes(g)) || new Set(list).size !== list.length) throw new Error(`sdlc.definitions.${key}: unknown or duplicate gate`);
95
+ const given = def[key] ?? [];
96
+ const retired = Array.isArray(given) && given.includes("phase");
97
+ if (retired && !phaseAlias.allowed) throw new Error(`sdlc.definitions.${key}: the phase gate was retired in ${RETIRED_WORKFLOW_FROM} (#481); name it criteria`);
98
+ if (retired) phaseAlias.used = true;
99
+ const list = Array.isArray(given) ? given.map(g => g === "phase" ? "criteria" : g) : given;
100
+ if (!Array.isArray(list) || list.length > 6 || list.some(g => !GATES.includes(g)) || new Set(list).size !== list.length) throw new Error(`sdlc.definitions.${key}: unknown or duplicate gate`);
73
101
  return [...list] as StageGate[];
74
102
  };
75
103
  const after = def.after ?? [];
@@ -81,28 +109,38 @@ function definitions(raw: unknown, stages: unknown): { stages: string[]; definit
81
109
  return { stages: [...stages], definitions: result };
82
110
  }
83
111
 
84
- export function sdlcConfig(value: unknown): SdlcConfig {
112
+ /** `version` is the running hub's, which closes the #481 S8 window; the daemon and Tasks pass it. Without one the window is open. */
113
+ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
85
114
  if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
86
115
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
87
116
  const raw = value as Record<string, unknown>;
88
117
  for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
89
118
  const mode = raw.mode ?? "off";
90
119
  if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
120
+ // ponytail: the #481 S8 window is a fixed version; delete `workflow`, the phase aliases and their tests once it passes.
121
+ const window = version === undefined || olderThan(version, RETIRED_WORKFLOW_FROM);
122
+ if (raw.workflow !== undefined && !window) throw new Error(`sdlc.workflow: retired in ${RETIRED_WORKFLOW_FROM} (#481); remove it and record a criteria checklist instead`);
91
123
  const workflow = raw.workflow ?? "none";
92
124
  if (workflow !== "none" && workflow !== "gsd-lite") throw new Error("sdlc.workflow: expected none or gsd-lite");
125
+ const notes = workflow === "gsd-lite" ? [`sdlc.workflow = "gsd-lite" is deprecated and stops loading in ${RETIRED_WORKFLOW_FROM} (#481): remove it; every task then needs a criteria checklist recorded with hub_sdlc_record`] : [];
126
+ const phaseAlias = { allowed: window, used: false };
93
127
  const checks = raw.required_checks ?? [];
94
128
  if (!Array.isArray(checks) || checks.length > 32 || checks.some(c => typeof c !== "string" || !c.trim() || c.length > 120 || /[\r\n\x00-\x1f]/.test(c)) || new Set(checks).size !== checks.length) throw new Error("sdlc.required_checks: expected at most 32 unique nonempty names");
95
129
  if (raw.definitions !== undefined) {
96
- const custom = definitions(raw.definitions, raw.stages);
97
- if (workflow !== "gsd-lite" && Object.values(custom.definitions).some(d => [...d.entry_gates, ...d.exit_gates].includes("phase"))) throw new Error("sdlc.definitions: phase gates require workflow = gsd-lite");
98
- return { mode, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}) };
130
+ const custom = definitions(raw.definitions, raw.stages, phaseAlias);
131
+ if (phaseAlias.used) notes.push(`the sdlc phase gate is deprecated and stops loading in ${RETIRED_WORKFLOW_FROM} (#481): name it criteria`);
132
+ return { mode, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
99
133
  }
100
- const stages = raw.stages ?? [...SDLC_STAGES];
101
- if (!Array.isArray(stages) || stages.length !== SDLC_STAGES.length || stages.some((stage, i) => stage !== SDLC_STAGES[i])) throw new Error("sdlc.stages: use spec, plan, implement, review, verify, merge, release in that order; custom stages are not supported yet");
102
- return { mode, requiredChecks: [...checks] as string[], stages: [...stages] as SdlcStage[], ...(workflow === "gsd-lite" ? { workflow } : {}) };
134
+ // Off keeps the #257 seven stages byte for byte; an enabled lifecycle defaults to the playbook (#481 S0).
135
+ const given = raw.stages ?? (mode === "off" ? [...SDLC_STAGES] : [...PLAYBOOK_STAGES]);
136
+ const exactly = (list: readonly string[]) => Array.isArray(given) && given.length === list.length && given.every((stage, i) => stage === list[i]);
137
+ // An enabled #257 list keeps its order under canonical ids, so implement/verify stay read-only aliases there too.
138
+ const stages = mode !== "off" && exactly(SDLC_STAGES) ? SDLC_STAGES.map(canonicalStage) : given;
139
+ if (!exactly(SDLC_STAGES) && !exactly(PLAYBOOK_STAGES)) throw new Error(`sdlc.stages: use ${PLAYBOOK_STAGES.join(", ")} in that order (or the #257 list ${SDLC_STAGES.join(", ")}); other lists need definitions`);
140
+ return { mode, requiredChecks: [...checks] as string[], stages: [...stages as string[]], ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
103
141
  }
104
142
 
105
- export function loadSdlc(cwd: string): SdlcConfig {
143
+ export function loadSdlc(cwd: string, version?: string): SdlcConfig {
106
144
  try {
107
145
  const fd = openSync(join(cwd, ".agenthub", "sdlc.toml"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
108
146
  try {
@@ -112,23 +150,29 @@ export function loadSdlc(cwd: string): SdlcConfig {
112
150
  while ((n = readSync(fd, bytes, length, bytes.length - length, null)) > 0) { length += n; if (length > cap) throw new Error("sdlc.toml exceeds 64 KiB"); }
113
151
  const after = fstatSync(fd);
114
152
  if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || length !== after.size) throw new Error("sdlc.toml changed during read");
115
- return sdlcConfig(Bun.TOML.parse(bytes.subarray(0, length).toString("utf8")));
153
+ return sdlcConfig(Bun.TOML.parse(bytes.subarray(0, length).toString("utf8")), version);
116
154
  } finally { closeSync(fd); }
117
155
  }
118
156
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return sdlcConfig(undefined); throw error; }
119
157
  }
120
158
 
121
159
  /** Identities and claimed local receipts only. External facts are read, never accepted as input. */
122
- export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined = undefined, config: SdlcConfig = sdlcConfig(undefined)): SdlcReceipt {
160
+ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined = undefined, config: SdlcConfig = sdlcConfig(undefined), version?: string): SdlcReceipt {
123
161
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc receipt: expected an object");
124
162
  const raw = value as Record<string, unknown>;
125
- for (const key of Object.keys(raw)) if (!["stage", "repo", "issue", "pr", "reviewedHead", "localHead", "localResult", "phase"].includes(key)) throw new Error(`sdlc receipt.${key}: unsupported or externally observed field`);
163
+ for (const key of Object.keys(raw)) if (!["stage", "repo", "issue", "pr", "reviewedHead", "localHead", "localResult", "criteria", "phase"].includes(key)) throw new Error(`sdlc receipt.${key}: unsupported or externally observed field`);
164
+ if (raw.criteria !== undefined && raw.phase !== undefined) throw new Error("sdlc receipt: give criteria only; phase is its retired name");
165
+ if (raw.phase !== undefined && version !== undefined && !olderThan(version, RETIRED_WORKFLOW_FROM)) throw new Error(`sdlc receipt.phase: retired in ${RETIRED_WORKFLOW_FROM} (#481); use criteria`);
126
166
  previous ??= { stage: config.stages[0] ?? "spec" };
127
167
  const next = { ...previous };
128
- if (raw.phase !== undefined) next.phase = phaseReceipt(raw.phase);
168
+ const checklist = raw.criteria ?? raw.phase;
169
+ // Off keeps the #257 receipt bytes for a phase write (task import stays retired); everything else stores criteria.
170
+ if (raw.phase !== undefined && config.mode === "off" && !config.workflow) { const legacy = criteriaReceipt(raw.phase); next.phase = { id: legacy.id, ...(legacy.head ? { head: legacy.head } : {}), tasks: [], criteria: legacy.criteria }; delete next.criteria; }
171
+ else if (checklist !== undefined) { next.criteria = criteriaReceipt(checklist); delete next.phase; }
129
172
  if (raw.stage !== undefined) {
130
173
  if (typeof raw.stage !== "string" || !stageDefinition(config, raw.stage)) throw new Error("sdlc receipt.stage: unknown stage");
131
- next.stage = raw.stage as SdlcStage;
174
+ // A legacy id is accepted from older clients but stored under the stage the config actually lists (#481 S0).
175
+ next.stage = (config.stages.includes(raw.stage) ? raw.stage : canonicalStage(raw.stage)) as SdlcStage;
132
176
  }
133
177
  if (raw.repo !== undefined) { if (!repo(raw.repo)) throw new Error("sdlc receipt.repo: expected owner/repository"); next.repo = raw.repo; }
134
178
  for (const key of ["issue", "pr"] as const) if (raw[key] !== undefined) { if (!id(raw[key])) throw new Error(`sdlc receipt.${key}: expected a positive integer`); next[key] = raw[key]; }
@@ -140,7 +184,7 @@ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined =
140
184
  // A new artifact cannot inherit another PR's review or local gate.
141
185
  if (next.repo !== previous.repo || next.pr !== previous.pr) {
142
186
  delete next.head; delete next.observed;
143
- if (raw.phase === undefined) delete next.phase;
187
+ if (checklist === undefined) { delete next.criteria; delete next.phase; }
144
188
  if (raw.reviewedHead === undefined) delete next.reviewedHead;
145
189
  if (raw.localHead === undefined) delete next.localHead;
146
190
  if (raw.localResult === undefined) delete next.localResult;
@@ -166,8 +210,10 @@ export function parsePrEvidence(value: unknown): PrEvidence {
166
210
  ...(sha(p.mergeCommit?.oid) ? { mergeCommit: p.mergeCommit.oid } : {}) };
167
211
  }
168
212
 
169
- export interface SdlcStatus { workflow?: "gsd-lite"; phase?: PhaseView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
170
- export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, phase?: PhaseView): SdlcStatus {
213
+ export interface SdlcStatus { playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
214
+ /** The acceptance-criteria checklist (#481 S8, Compliance pass) counts once recorded, and on every task under the retired gsd-lite setting. Off keeps #257's output. */
215
+ export const criteriaRequired = (config: SdlcConfig, receipt: SdlcReceipt | undefined): boolean => config.workflow === "gsd-lite" || (config.mode !== "off" && !!checklistOf(receipt));
216
+ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView): SdlcStatus {
171
217
  const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
172
218
  const definition = stageDefinition(config, stage);
173
219
  if (!definition) blockers.push("lifecycle stage definition unknown");
@@ -187,20 +233,20 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
187
233
  if (evidence.pendingReviews !== 0) blockers.push("review requests pending or unknown");
188
234
  if (evidence.unresolved !== 0) blockers.push("review threads unresolved or unknown");
189
235
  }
190
- if (config.workflow === "gsd-lite") {
191
- blockers.unshift(...(phase?.blockers ?? ["phase board evidence unknown"]));
192
- if (!evidence || receipt?.phase?.head !== evidence.head) blockers.push("phase checklist head missing or stale");
193
- }
194
- const custom = config.definitions && definition ? stageGates(definition, blockers, receipt, evidence, phase) : undefined;
236
+ const viewBlockers = criteria?.blockers ?? ["criteria board evidence unknown"];
237
+ const staleHead = !evidence || checklistOf(receipt)?.head !== evidence.head ? ["criteria checklist head missing or stale"] : [];
238
+ if (criteriaRequired(config, receipt)) { blockers.unshift(...viewBlockers); blockers.push(...staleHead); }
239
+ const custom = config.definitions && definition ? stageGates(definition, blockers, [...viewBlockers, ...staleHead]) : undefined;
195
240
  if (custom) for (const blocker of [...custom.entry, ...custom.exit]) if (!blockers.includes(blocker)) blockers.push(blocker);
196
- return { ...(config.workflow ? { workflow: config.workflow, ...(phase ? { phase } : {}) } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
197
- actor: !definition ? "person" : phase?.blockers.length ? "reviewer/person" : config.definitions && definition ? definition.actor : stage === "review" || stage === "verify" ? "reviewer" : stage === "merge" || stage === "release" ? "person" : "owner/planner",
241
+ const shown = criteria && (criteriaRequired(config, receipt) || definition && [...definition.entry_gates, ...definition.exit_gates].includes("criteria"));
242
+ return { ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
243
+ actor: !definition ? "person" : shown && criteria.blockers.length ? "reviewer/person" : config.definitions ? definition.actor : baseActor(definition.base),
198
244
  next: blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
199
245
  }
200
246
 
201
- function stageGates(def: StageDefinition, blockers: string[], receipt: SdlcReceipt | undefined, evidence: PrEvidence | undefined, phase: PhaseView | undefined): { entry: string[]; exit: string[] } {
247
+ function stageGates(def: StageDefinition, blockers: string[], checklist: string[]): { entry: string[]; exit: string[] } {
202
248
  const select = (gates: StageGate[]) => [...new Set(gates.flatMap(gate => {
203
- if (gate === "phase") return [...(phase?.blockers ?? ["phase board evidence unknown"]), ...(!evidence || receipt?.phase?.head !== evidence.head ? ["phase checklist head missing or stale"] : [])];
249
+ if (gate === "criteria") return checklist;
204
250
  const matching = (text: string) => gate === "issue" ? text === "issue identity missing" : gate === "pr" ? ["PR identity missing", "current PR evidence unknown", "PR is not open"].includes(text) : gate === "local" ? ["current PR evidence unknown", "local gate on current head missing or failed"].includes(text) : gate === "checks" ? text === "current PR evidence unknown" || text === "required check set unknown" || text.startsWith("required check ") : ["current PR evidence unknown", "current head has not been reviewed", "GitHub review approval missing or changes requested", "review requests pending or unknown", "review threads unresolved or unknown"].includes(text);
205
251
  return blockers.filter(matching);
206
252
  }))];
@@ -1,12 +1,13 @@
1
- import { PLANNER_PEERS, SUPPORTED_PEERS } from "./peer-catalog.ts";
1
+ import { definitionOf, type KindMap, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
2
2
  import { createHash } from "node:crypto";
3
+ import { types } from "node:util";
3
4
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
5
  import { tmpdir } from "node:os";
5
6
  import { basename, dirname, join } from "node:path";
6
7
  import { CLASSES, type TaskClass } from "./board.ts";
7
8
  import { configTracked, configRepositoryPresent } from "./config-trust.ts";
8
9
  import { PERMISSION_MODES, permissionBoundary } from "./permission-mode.ts";
9
- import { START_MODE_PEERS, START_MODES } from "./start-mode.ts";
10
+ import { START_MODES } from "./start-mode.ts";
10
11
  import { OVERLAY_FILE, overlayToml, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "./routing.ts";
11
12
 
12
13
  /**
@@ -49,14 +50,15 @@ export const SETTINGS: readonly SettingDef[] = [
49
50
  ...["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
51
  ...["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
52
  ...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] })),
53
+ { key: "tasks.escalate_after", group: "Routing", label: "Escalate after N consecutive changes_requested", type: "integer", min: 1, max: 10, store: "config", applies: "live", risk: "raises", path: ["tasks", "escalate_after"] },
52
54
  { 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
55
  ...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] })),
54
56
  { 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"] },
55
57
  ...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] })),
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] })),
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
+ ...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).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] })),
59
+ ...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).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
60
  ...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"] },
61
+ ...ADAPTERS.flatMap(adapter => adapter.configuration?.settings ?? []),
60
62
  { 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"] },
61
63
  ...CLASSES.flatMap(perClass),
62
64
  { key: "research.enabled", group: "Switches", label: "Research records", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["research", "enabled"] },
@@ -69,12 +71,37 @@ export const SETTINGS: readonly SettingDef[] = [
69
71
  * Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
70
72
  * text go. A key equal to an entry, or below it, is refused by name.
71
73
  */
72
- export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal"] as const;
74
+ export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "instances"] as const;
75
+
76
+ /** Derive project-bound rows from the same builtin templates used by lookup, without registering aliases globally. */
77
+ export function projectSettings(instances?: KindMap): readonly SettingDef[] {
78
+ if (!instances || typeof instances !== "object" || types.isProxy(instances) || Array.isArray(instances)
79
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(instances)) || Object.getOwnPropertySymbols(instances).length) return SETTINGS;
80
+ const extra: SettingDef[] = [];
81
+ for (const peer of Object.keys(instances)) {
82
+ if (definitionOf(peer)) continue; // An explicit declaration never changes a builtin's setting definition.
83
+ const adapter = definitionOf(peer, instances);
84
+ if (!adapter) continue;
85
+ const copy = (base: SettingDef, key: string): SettingDef => Object.freeze({ ...base, key,
86
+ label: base.label.replace(`${adapter.kind}:`, `${peer}:`),
87
+ path: Object.freeze(base.path.map(part => part === adapter.kind ? peer : part)),
88
+ ...(base.values ? { values: Object.freeze([...base.values]) } : {}),
89
+ });
90
+ for (const prefix of ["roster", "permission", "permission_modes"]) {
91
+ const base = SETTINGS.find(def => def.key === `${prefix}.${adapter.kind}`);
92
+ if (base) extra.push(copy(base, `${prefix}.${peer}`));
93
+ }
94
+ // I2 declarations select the headless engine; settings cannot opt an extra into a native TUI.
95
+ extra.push(Object.freeze({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (headless only)`,
96
+ type: "enum", values: Object.freeze(["headless"]), store: "config", applies: "next use", risk: "raises", floor: "headless", path: Object.freeze(["peers", peer, "start_mode"]) } satisfies SettingDef));
97
+ }
98
+ return extra.length ? Object.freeze([...SETTINGS, ...extra]) : SETTINGS;
99
+ }
73
100
 
74
101
  /** The definition of a key, or why there is none. */
75
- export function settingDef(key: unknown): SettingDef | string {
102
+ export function settingDef(key: unknown, instances?: KindMap): SettingDef | string {
76
103
  if (typeof key !== "string" || key.length > 80) return "unknown setting";
77
- const found = SETTINGS.find((s) => s.key === key);
104
+ const found = projectSettings(instances).find((s) => s.key === key);
78
105
  if (found) return found;
79
106
  if (NEVER_EDITABLE.some((never) => key === never || key.startsWith(`${never}.`))) return `${key} chooses what the hub runs or where secrets and task text go: it is never editable from settings; edit .agenthub/config.local.json in your editor`;
80
107
  return "unknown setting; run ahub settings list";
@@ -389,6 +416,8 @@ const same = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) ===
389
416
  */
390
417
  export function settingRows(input: {
391
418
  cwd: string; routing: Routing;
419
+ /** Captured trusted declarations for this daemon/project, never taken from a settings request. */
420
+ instances?: KindMap;
392
421
  running: (path: readonly string[]) => unknown;
393
422
  next: ((path: readonly string[]) => unknown) | undefined;
394
423
  mode: (peer: string) => { value?: string; note?: string };
@@ -403,9 +432,9 @@ export function settingRows(input: {
403
432
  const projectRouting = existsSync(join(input.cwd, ".agenthub", "routing.toml"));
404
433
  const routes = [...Object.keys(input.routing.routes), ...Object.keys(input.routing.hub_routes ?? {})];
405
434
  const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
406
- return SETTINGS.map((def): SettingRow => {
435
+ return projectSettings(input.instances).map((def): SettingRow => {
407
436
  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 }),
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)!) } : {}) };
437
+ ...(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)!, input.instances) } : {}) };
409
438
  if (def.store === "runtime") {
410
439
  const mode = input.mode(def.path[0]!);
411
440
  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 } : {}) };
@@ -1,5 +1,5 @@
1
1
  import { conductorPeer } from "./conductor.ts";
2
- import { PLANNER_PEERS } from "./peer-catalog.ts";
2
+ import { PLANNER_PEERS } from "../adapters/registry.ts";
3
3
 
4
4
  export interface SettledRoles {
5
5
  roles: Record<string, string[]>;