@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.
- package/CHANGELOG.md +22 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +9 -3
- package/docs/agent-notes/budget.md +7 -3
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +7 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +6 -2
- package/docs/agent-notes/tests.md +2 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +80 -16
- package/docs/quickstart.md +10 -2
- package/docs/sdlc.md +59 -34
- package/docs/security.md +7 -4
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +407 -10
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verified.json +35 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +23567 -16606
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/codex-appserver.ts +82 -12
- package/src/adapters/definitions/builtin-metadata.ts +101 -7
- package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
- package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
- package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
- package/src/adapters/definitions/native-cli-doctor.ts +46 -0
- package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +34 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +27 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +57 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +15 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +53 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +21 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +41 -0
- package/src/adapters/definitions/native-cli-entry.ts +19 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
- package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
- package/src/adapters/definitions/native-cli-pi.ts +33 -0
- package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
- package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
- package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
- package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
- package/src/adapters/definitions/native-cli-preview.ts +16 -0
- package/src/adapters/definitions/native-config-defaults.ts +19 -0
- package/src/adapters/definitions/native-config-policy.ts +35 -0
- package/src/adapters/definitions/native-context.ts +35 -0
- package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
- package/src/adapters/definitions/native-daemon-policy.ts +18 -0
- package/src/adapters/definitions/native-display.ts +18 -0
- package/src/adapters/definitions/native-factories.ts +48 -0
- package/src/adapters/definitions/native-policy-aux.ts +130 -0
- package/src/adapters/definitions/native-quota.ts +29 -0
- package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
- package/src/adapters/definitions/native-recovery.ts +88 -0
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/definitions/qwen.ts +8 -4
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +100 -10
- package/src/adapters/registry.ts +127 -11
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +8 -7
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +41 -102
- package/src/cli/main.ts +126 -290
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +26 -126
- package/src/cli/recovery-guidance.ts +43 -22
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +81 -22
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-restore-state.ts +17 -7
- package/src/cli/upgrade-runtime.ts +126 -147
- package/src/cli/upgrade.ts +44 -27
- package/src/hub/budget.ts +59 -31
- package/src/hub/bus.ts +1 -1
- package/src/hub/conductor.ts +19 -9
- package/src/hub/config-apply.ts +22 -15
- package/src/hub/config-reload.ts +18 -3
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +3 -27
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +15 -39
- package/src/hub/daemon.ts +506 -341
- package/src/hub/execution-budget.ts +3 -2
- package/src/hub/hub-tools.ts +19 -8
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +100 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +14 -17
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/roster.ts +9 -9
- package/src/hub/routing.ts +94 -19
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +84 -38
- package/src/hub/settings.ts +39 -10
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +13 -8
- package/src/hub/status-roster.ts +8 -0
- package/src/hub/tasks.ts +60 -27
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/hub/workflow.ts +30 -52
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +5 -3
- package/src/ui/index.html +4 -3
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- package/templates/AGENTS.block.md +2 -1
- package/templates/routing.toml +2 -2
package/src/hub/routing.ts
CHANGED
|
@@ -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
|
|
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 !==
|
|
327
|
-
|
|
328
|
-
if (
|
|
329
|
-
if (
|
|
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",
|
|
391
|
+
if (routing.signals.long_context_tokens > limit) return deny("policy", `${nativeRouting.backendLabel} ${backend} capability limit ${limit} tokens`);
|
|
333
392
|
}
|
|
334
|
-
if (
|
|
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
|
|
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
|
|
394
|
-
const
|
|
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
|
-
|
|
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 ===
|
|
417
|
-
if (owner ===
|
|
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
|
/**
|
package/src/hub/sdlc-queue.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
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?:
|
|
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 {
|
|
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
|
-
|
|
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" | "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
const
|
|
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 (!
|
|
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)) || !
|
|
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
|
|
72
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 {
|
|
170
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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[],
|
|
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 === "
|
|
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
|
}))];
|
package/src/hub/settings.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { PLANNER_PEERS, SUPPORTED_PEERS } from "
|
|
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 {
|
|
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
|
|
57
|
-
...SETTING_PEERS.filter(peer => 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
|
-
|
|
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 =
|
|
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
|
|
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 } : {}) };
|
package/src/hub/settled-roles.ts
CHANGED