@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.
- package/CHANGELOG.md +13 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/budget.md +3 -1
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +5 -1
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +4 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +67 -13
- package/docs/quickstart.md +8 -2
- package/docs/sdlc.md +59 -34
- package/docs/security.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +161 -6
- 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 +21 -16
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +15438 -14863
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/codex-appserver.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +4 -1
- package/src/adapters/definitions/native-cli-entry-kimi.ts +16 -11
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
- package/src/adapters/definitions/native-recovery.ts +2 -2
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +3 -0
- package/src/adapters/registry.ts +24 -8
- package/src/cli/identity.ts +3 -3
- package/src/cli/launch.ts +29 -4
- package/src/cli/main.ts +101 -28
- package/src/cli/preview.ts +16 -5
- package/src/cli/recovery-guidance.ts +39 -22
- package/src/cli/terminal-recovery.ts +68 -16
- package/src/cli/upgrade-restore-state.ts +16 -7
- package/src/cli/upgrade-runtime.ts +67 -39
- package/src/cli/upgrade.ts +43 -27
- package/src/hub/budget.ts +55 -3
- package/src/hub/bus.ts +1 -1
- package/src/hub/conductor.ts +16 -10
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-reload.ts +11 -1
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +14 -5
- package/src/hub/daemon.ts +296 -125
- package/src/hub/execution-budget.ts +3 -3
- package/src/hub/hub-tools.ts +17 -6
- 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/permission-mode.ts +7 -7
- package/src/hub/roster.ts +7 -6
- package/src/hub/routing.ts +82 -18
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +84 -38
- package/src/hub/settings.ts +34 -6
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +7 -4
- package/src/hub/status-roster.ts +8 -0
- package/src/hub/tasks.ts +53 -23
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/workflow.ts +30 -52
- package/src/pi/launch.ts +4 -3
- package/src/ui/index.html +1 -1
- package/templates/AGENTS.block.md +2 -1
package/src/hub/routing.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
405
|
-
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);
|
|
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
|
-
|
|
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
|
/**
|
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,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 =
|
|
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
|
|
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 } : {}) };
|