@staix/agent-hub 0.12.34 → 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 (76) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +2 -0
  4. package/docs/agent-notes/budget.md +3 -1
  5. package/docs/agent-notes/bus.md +2 -1
  6. package/docs/agent-notes/daemon.md +5 -1
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +4 -1
  9. package/docs/gsd-collaboration-analysis.md +5 -1
  10. package/docs/operations.md +67 -13
  11. package/docs/quickstart.md +8 -2
  12. package/docs/sdlc.md +59 -34
  13. package/docs/security.md +3 -3
  14. package/docs/specs/2026-09-19-agent-hub-design.md +161 -6
  15. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  16. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  17. package/docs/verified.json +21 -16
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +15438 -14863
  21. package/src/adapters/acp.ts +3 -1
  22. package/src/adapters/claude-channel.ts +59 -29
  23. package/src/adapters/codex-appserver.ts +1 -1
  24. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
  25. package/src/adapters/definitions/native-cli-entry-jcode.ts +4 -1
  26. package/src/adapters/definitions/native-cli-entry-kimi.ts +16 -11
  27. package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
  28. package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
  29. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
  30. package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
  31. package/src/adapters/definitions/native-recovery.ts +2 -2
  32. package/src/adapters/definitions/qwen-native.ts +2 -2
  33. package/src/adapters/local-worker.ts +3 -2
  34. package/src/adapters/pi.ts +3 -1
  35. package/src/adapters/registry-types.ts +3 -0
  36. package/src/adapters/registry.ts +24 -8
  37. package/src/cli/identity.ts +3 -3
  38. package/src/cli/launch.ts +29 -4
  39. package/src/cli/main.ts +101 -28
  40. package/src/cli/preview.ts +16 -5
  41. package/src/cli/recovery-guidance.ts +39 -22
  42. package/src/cli/terminal-recovery.ts +68 -16
  43. package/src/cli/upgrade-restore-state.ts +16 -7
  44. package/src/cli/upgrade-runtime.ts +67 -39
  45. package/src/cli/upgrade.ts +43 -27
  46. package/src/hub/budget.ts +55 -3
  47. package/src/hub/bus.ts +1 -1
  48. package/src/hub/conductor.ts +16 -10
  49. package/src/hub/config-apply.ts +13 -2
  50. package/src/hub/config-reload.ts +11 -1
  51. package/src/hub/config-trust.ts +1 -1
  52. package/src/hub/control-client.ts +1 -1
  53. package/src/hub/crash.ts +14 -5
  54. package/src/hub/daemon.ts +296 -125
  55. package/src/hub/execution-budget.ts +3 -3
  56. package/src/hub/hub-tools.ts +17 -6
  57. package/src/hub/instance-budget.ts +73 -0
  58. package/src/hub/instance-config.ts +47 -0
  59. package/src/hub/instance-execution.ts +50 -0
  60. package/src/hub/instances.ts +100 -0
  61. package/src/hub/lifecycle-schema.ts +7 -3
  62. package/src/hub/permission-mode.ts +7 -7
  63. package/src/hub/roster.ts +7 -6
  64. package/src/hub/routing.ts +82 -18
  65. package/src/hub/sdlc-queue.ts +2 -2
  66. package/src/hub/sdlc.ts +84 -38
  67. package/src/hub/settings.ts +34 -6
  68. package/src/hub/snapshots.ts +40 -9
  69. package/src/hub/start-mode.ts +7 -4
  70. package/src/hub/status-roster.ts +8 -0
  71. package/src/hub/tasks.ts +53 -23
  72. package/src/hub/text-projection.ts +7 -3
  73. package/src/hub/workflow.ts +30 -52
  74. package/src/pi/launch.ts +4 -3
  75. package/src/ui/index.html +1 -1
  76. package/templates/AGENTS.block.md +2 -1
@@ -1,4 +1,4 @@
1
- import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
1
+ import { ADAPTERS, definitionOf, kindOf, familyOf } from "../adapters/registry.ts";
2
2
  import { readFileSync, statSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { CLASSES, type Task, type TaskClass } from "./board.ts";
@@ -109,14 +109,54 @@ const TEMPLATE = join(import.meta.dir, "..", "..", "templates", "routing.toml");
109
109
  export const LOCAL: PeerId = "local";
110
110
  export const PI: PeerId = "pi";
111
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
+
112
151
  /** The quota/roster relay keeps its native preference order, then the class's original tail. */
113
- export function relayCandidates(classPeers: readonly PeerId[], pii: boolean): PeerId[] {
152
+ export function relayCandidates(classPeers: readonly PeerId[], pii: boolean, opts: InstanceRoutingOptions & { states?: Readonly<Record<string, PeerState>> } = {}): PeerId[] {
114
153
  const preferred = ADAPTERS.filter(adapter => adapter.routing?.handoffPriority !== undefined)
115
154
  .sort((a, b) => a.routing!.handoffPriority! - b.routing!.handoffPriority!);
116
- return [
155
+ const candidates = [
117
156
  ...preferred.filter(adapter => !pii || adapter.trust.onPrem).map(adapter => adapter.kind),
118
- ...classPeers.filter(peer => definitionOf(peer)?.routing?.handoffPriority === undefined),
157
+ ...classPeers.filter(peer => definitionOf(peer, opts.instances)?.routing?.handoffPriority === undefined),
119
158
  ].filter((peer): peer is PeerId => !!peer);
159
+ return expandClassPeers(candidates, opts.states ?? {}, opts);
120
160
  }
121
161
 
122
162
  /**
@@ -245,9 +285,11 @@ export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, rou
245
285
  /** A peer's entry in a peer-indexed map: own keys only, so a peer named `constructor` is never an inherited Object member. */
246
286
  export const peerEntry = <T>(map: Readonly<Record<string, T>> | undefined, peer: string): T | undefined => (map && Object.hasOwn(map, peer) ? map[peer] : undefined);
247
287
 
248
- 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";
249
289
  export interface RoutingRefusal { peer: PeerId; role: "owner" | "reviewer"; code: RoutingRefusalCode; reason: string }
250
290
  export interface Assignment {
291
+ /** A same-family reviewer was chosen only because no other family was eligible. */
292
+ sameFamilyFallback?: true;
251
293
  refusals?: RoutingRefusal[];
252
294
  owner?: PeerId;
253
295
  reviewer?: PeerId;
@@ -268,7 +310,7 @@ export function assign(
268
310
  task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external">>,
269
311
  states: Record<PeerId, PeerState>,
270
312
  routing: Routing,
271
- opts: {
313
+ opts: InstanceRoutingOptions & {
272
314
  capabilities?: Record<PeerId, PeerCapabilities>;
273
315
  repository?: string;
274
316
  exclude?: PeerId[];
@@ -301,13 +343,14 @@ export function assign(
301
343
  ): Assignment {
302
344
  if (task.state === "cancelled" || task.state === "approved") return { trace: [`${task.state}: closed; not routed`] };
303
345
  if (task.state === "backlog") return { trace: ["backlog: not routed; pull into To Do first"] };
346
+ const instanceMode = hasInstances(opts);
304
347
  const policy = routing.classes[task.class];
305
348
  const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
306
349
  // Which file each of this class's values comes from (#269), only when the machine-local overlay sets one.
307
350
  const local = Object.keys(routing.sources ?? {}).filter((key) => key.startsWith(`classes.${task.class}.`)).map((key) => key.slice(`classes.${task.class}.`.length));
308
351
  if (local.length) trace.push(`policy source: ${local.join(", ")} from ${OVERLAY_FILE}; the rest from routing.toml`);
309
352
  // A reserved owner (issue #207) is offered the task first. Named candidates (an assign, an escalation, a relay) replace it.
310
- const reserved = opts.candidates ? undefined : task.reserved ?? undefined;
353
+ const reserved = opts.candidates || instanceMode && opts.namedOwner !== undefined ? undefined : task.reserved ?? undefined;
311
354
  // Readiness is an input like peer states (issue #34): a task that waits for others goes to nobody yet.
312
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`] : [])] };
313
356
  const pii = task.signals.includes("pii_path") || (task.signals.includes("pii") && routing.constraints.pii === "local_only");
@@ -330,12 +373,17 @@ export function assign(
330
373
  if (!Object.hasOwn(states, peer)) return deny("not-attached", "not attached");
331
374
  if (opts.exclude?.includes(peer)) return deny("excluded", "excluded (declined or replaced)");
332
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
+ }
333
381
  if (states[peer] === "offline") return deny("offline", "offline");
334
382
  if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return deny("paused", "paused");
335
383
  const failing = peerEntry(opts.failing, peer);
336
384
  if (failing) return deny("failing", `failing: ${failing}`);
337
- if (pii && definitionOf(peer)?.trust.onPrem !== true) return deny("pii", "pii: on-prem peers only");
338
- const nativeRouting = definitionOf(peer)?.routing;
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;
339
387
  if (nativeRouting?.localAllowedRefusal && policy?.local_allowed === false) return deny("policy", nativeRouting.localAllowedRefusal);
340
388
  if (nativeRouting?.longContext === "backend-limit" && task.signals.includes("long_context")) {
341
389
  const backend = policy?.pi_backend ?? "dgx";
@@ -383,16 +431,16 @@ export function assign(
383
431
  return role === "reviewer" ? byRecord(ranked, owner) : ranked;
384
432
  };
385
433
 
386
- 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 => {
387
435
  const ok: PeerId[] = [];
388
436
  for (const peer of list) {
389
437
  const why = peer === not ? "is the owner" : blocked(peer, role);
390
438
  trace.push(` ${role} candidate ${peer}: ${why ? `skipped, ${why}` : states[peer]}`);
391
439
  if (!why) ok.push(peer);
392
440
  }
393
- const ranked = rank(ok, role, not);
441
+ const ranked = sortSiblings ? orderSiblingSlots(rank(ok, role, not), states, opts) : rank(ok, role, not);
394
442
  // Demotion applies to local and Pi too: a demoted one loses its place ahead of the cloud peers.
395
- const localTier = ranked.filter((p) => definitionOf(p)?.routing?.preferredTier === true && !(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)));
396
444
  if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
397
445
  return ranked.find((p) => states[p] === "idle") ?? ranked[0];
398
446
  };
@@ -401,13 +449,15 @@ export function assign(
401
449
  const unreserved = reserved ? blocked(reserved, "owner") : undefined;
402
450
  if (reserved) trace.push(`reserved owner ${reserved}: ${unreserved ? `not honored, ${unreserved}; routing proceeds` : "honored"}`);
403
451
  // Never the task's current owner by default: a decline or an escalation has to reach the next peer in the list.
404
- const wanted = reserved && !unreserved ? [reserved] : opts.candidates ?? policy?.peers ?? [];
405
- 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);
406
455
  trace.push(owner ? `owner: ${owner}` : "owner: none available, task stays proposed (ahub task assign <id> <peer>)");
407
456
  const ownerHold = owner ? peerEntry(opts.held, owner) : undefined;
408
457
  if (ownerHold) trace.push(` hold: ${owner}'s queue is held: ${ownerHold} (it receives the task once the hold is resolved)`);
409
458
 
410
459
  let reviewer: PeerId | undefined;
460
+ let sameFamilyFallback = false;
411
461
  if (task.class !== "review") {
412
462
  if (pii) trace.push("reviewer: user (pii: no second on-prem peer; ahub review <id> <verdict>)");
413
463
  else {
@@ -415,7 +465,21 @@ export function assign(
415
465
  const classPeers = routing.classes.review?.peers ?? [];
416
466
  const rolePeers = Object.keys(opts.roles ?? {}).filter((p) => opts.roles![p]!.includes("reviewer"));
417
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}`);
418
- 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
+ }
419
483
  trace.push(reviewer ? `reviewer: ${reviewer}` : "reviewer: none, done will approve directly");
420
484
  const reviewerHold = reviewer ? peerEntry(opts.held, reviewer) : undefined;
421
485
  if (reviewerHold) trace.push(` hold: ${reviewer}'s queue is held: ${reviewerHold} (it receives the review once the hold is resolved)`);
@@ -424,9 +488,9 @@ export function assign(
424
488
  const route = policy?.route ?? routing.local.route;
425
489
  const fixedModel = policy?.fixed_model ?? routing.local.fixed_model;
426
490
  const piBackend = policy?.pi_backend;
427
- if (owner && definitionOf(owner)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
428
- if (owner && definitionOf(owner)?.routing?.trace === "backend") trace.push(`${definitionOf(owner)!.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}` : ""}`);
429
- 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 };
430
494
  }
431
495
 
432
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,5 +1,6 @@
1
- import { definitionOf, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.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";
@@ -70,12 +71,37 @@ export const SETTINGS: readonly SettingDef[] = [
70
71
  * Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
71
72
  * text go. A key equal to an entry, or below it, is refused by name.
72
73
  */
73
- 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
+ }
74
100
 
75
101
  /** The definition of a key, or why there is none. */
76
- export function settingDef(key: unknown): SettingDef | string {
102
+ export function settingDef(key: unknown, instances?: KindMap): SettingDef | string {
77
103
  if (typeof key !== "string" || key.length > 80) return "unknown setting";
78
- const found = SETTINGS.find((s) => s.key === key);
104
+ const found = projectSettings(instances).find((s) => s.key === key);
79
105
  if (found) return found;
80
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`;
81
107
  return "unknown setting; run ahub settings list";
@@ -390,6 +416,8 @@ const same = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) ===
390
416
  */
391
417
  export function settingRows(input: {
392
418
  cwd: string; routing: Routing;
419
+ /** Captured trusted declarations for this daemon/project, never taken from a settings request. */
420
+ instances?: KindMap;
393
421
  running: (path: readonly string[]) => unknown;
394
422
  next: ((path: readonly string[]) => unknown) | undefined;
395
423
  mode: (peer: string) => { value?: string; note?: string };
@@ -404,9 +432,9 @@ export function settingRows(input: {
404
432
  const projectRouting = existsSync(join(input.cwd, ".agenthub", "routing.toml"));
405
433
  const routes = [...Object.keys(input.routing.routes), ...Object.keys(input.routing.hub_routes ?? {})];
406
434
  const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
407
- return SETTINGS.map((def): SettingRow => {
435
+ return projectSettings(input.instances).map((def): SettingRow => {
408
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 }),
409
- ...(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) } : {}) };
410
438
  if (def.store === "runtime") {
411
439
  const mode = input.mode(def.path[0]!);
412
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 } : {}) };