@staix/agent-hub 0.13.0 → 0.15.0
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 +30 -0
- package/README.md +19 -7
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +18 -4
- package/docs/agent-notes/budget.md +5 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +19 -7
- package/docs/agent-notes/tasks.md +15 -7
- package/docs/agent-notes/tests.md +4 -4
- package/docs/dogfood.md +18 -10
- package/docs/events.md +21 -7
- package/docs/operations.md +131 -27
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +40 -2
- package/docs/security.md +45 -14
- package/docs/specs/2026-09-19-agent-hub-design.md +112 -10
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1078 -327
- package/src/adapters/acp.ts +43 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-review-isolation.ts +18 -7
- package/src/adapters/compat.ts +4 -0
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +15 -3
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/registry-types.ts +7 -3
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +64 -60
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +28 -1
- package/src/cli/console.ts +25 -1
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +6 -4
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +52 -9
- package/src/cli/output.ts +75 -2
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-enforce.ts +82 -0
- package/src/hub/action-policy.ts +22 -10
- package/src/hub/board.ts +48 -3
- package/src/hub/budget.ts +5 -0
- package/src/hub/bus.ts +5 -5
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/config-apply.ts +5 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +450 -71
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/events.ts +3 -1
- package/src/hub/hub-tools.ts +21 -4
- package/src/hub/instances.ts +19 -6
- package/src/hub/kimi-usage.ts +144 -0
- package/src/hub/manager.ts +3 -3
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc.ts +5 -3
- package/src/hub/settings.ts +1 -1
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/tasks.ts +374 -37
- package/src/hub/token-window.ts +33 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +74 -6
- package/src/hub/workflow.ts +16 -0
- package/src/ui/index.html +388 -3
package/src/hub/peers.ts
CHANGED
|
@@ -9,8 +9,15 @@ export interface DeliveryReceipt {
|
|
|
9
9
|
quota?: true;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** Host-owned print child authority, revoked before stop/turn settlement. No terminal ownership. */
|
|
13
|
+
export interface OwnedToolsLaunch {
|
|
14
|
+
launchId: string; generation: string; sessionId: string; signal: AbortSignal; task?: number; permissionHook: true;
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
export interface PeerAdapter {
|
|
13
18
|
readonly id: PeerId;
|
|
19
|
+
ownedTools?(launchId: unknown): OwnedToolsLaunch | undefined;
|
|
20
|
+
keepApprovalAlive?(): void;
|
|
14
21
|
readonly state: PeerState;
|
|
15
22
|
/** Host-observed native session identity. An absent port permits owner-token fallback; undefined means unknown. */
|
|
16
23
|
readonly sessionGeneration?: string | number;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { sanitize } from "./envelope.ts";
|
|
2
|
+
|
|
3
|
+
/** Public approval rendering, never the arguments eventually executed by the native child.
|
|
4
|
+
* Withhold the complete value if any private/secret/path policy matches, before truncation. */
|
|
5
|
+
export function permissionPayload(tool: unknown, input: unknown, publicText: (text: string) => boolean): { tool: string; title: string } {
|
|
6
|
+
const name = typeof tool === "string" && /^[A-Za-z][A-Za-z0-9_.:/-]{0,127}$/.test(tool) && publicText(tool) ? tool : "[withheld]";
|
|
7
|
+
let rendered = "[withheld]";
|
|
8
|
+
try {
|
|
9
|
+
const text = JSON.stringify(input);
|
|
10
|
+
if (typeof text === "string" && Buffer.byteLength(text) <= 64 * 1024 && publicText(text)) rendered = sanitize(text);
|
|
11
|
+
} catch { /* Unknown or unserializable arguments are never partially shown. */ }
|
|
12
|
+
const full = Array.from(`${name} ${rendered}`);
|
|
13
|
+
const marker = ` [cut, ${full.length} chars]`;
|
|
14
|
+
return { tool: name, title: full.length > 600 ? full.slice(0, 600 - marker.length).join("") + marker : full.join("") };
|
|
15
|
+
}
|
package/src/hub/routing.ts
CHANGED
|
@@ -309,6 +309,9 @@ export interface Assignment {
|
|
|
309
309
|
refusals?: RoutingRefusal[];
|
|
310
310
|
owner?: PeerId;
|
|
311
311
|
reviewer?: PeerId;
|
|
312
|
+
/** #525-3: the task's planner, from the planner walk. Read-only information until 525-2 records it; absent for a PII task or when nobody holds the role. */
|
|
313
|
+
planner?: PeerId;
|
|
314
|
+
plannerReason?: PlannerReason;
|
|
312
315
|
/** What local should ask for on this task: a hub route or retired non-hub selection, with its fixed fallback. */
|
|
313
316
|
route?: string;
|
|
314
317
|
fixedModel?: string;
|
|
@@ -318,6 +321,79 @@ export interface Assignment {
|
|
|
318
321
|
trace: string[];
|
|
319
322
|
}
|
|
320
323
|
|
|
324
|
+
/**
|
|
325
|
+
* The role a class's owner works in (#735): `plan` is the planner's, `review` the reviewer's, and every other class (implement, bulk_edit,
|
|
326
|
+
* test, summarize, triage, and a profile class that maps onto one) is done by an implementer, which needs write access. Derived, never a
|
|
327
|
+
* list of the write classes, so a class added later is a write class unless it says otherwise here.
|
|
328
|
+
*/
|
|
329
|
+
export function ownerRoleOfClass(cls: TaskClass): "planner" | "implementer" | "reviewer" {
|
|
330
|
+
return cls === "review" ? "reviewer" : cls === "plan" ? "planner" : "implementer";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Why a planner was chosen: the person named it, rotation reached it, the task's recorded planner is kept, or there is none (PII, no holder). */
|
|
334
|
+
export type PlannerReason = "rotation" | "named" | "kept" | "none";
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* I-2 (#525-3): the peers that implemented a task, read from its own history: any peer with an `accepted` or `done` entry. A reviewer is
|
|
338
|
+
* never one of them, so a multi-role holder (implementer and reviewer) never reviews a change it made, even after the task changed hands.
|
|
339
|
+
* Pure: history in, set out. A `done` by the person is not a peer's implementation and is left out.
|
|
340
|
+
*/
|
|
341
|
+
export function implementers(history: readonly { by: PeerId; event: string }[] | undefined): Set<PeerId> {
|
|
342
|
+
return new Set((history ?? []).filter(entry => (entry.event === "accepted" || entry.event === "done") && entry.by !== "user").map(entry => entry.by));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface PlannerWalkInput {
|
|
346
|
+
/** The settled planner holders, sorted by id. The walk sorts again, so an unsorted input cannot change the result. */
|
|
347
|
+
planners: readonly PeerId[];
|
|
348
|
+
/** The rotation seed: the task id. */
|
|
349
|
+
seed: number;
|
|
350
|
+
/** A person-named planner: exact, never replaced by rotation. */
|
|
351
|
+
named?: PeerId;
|
|
352
|
+
/** The planner already recorded on the task: kept while it is still a holder. */
|
|
353
|
+
kept?: PeerId;
|
|
354
|
+
/** Why a holder cannot take planning right now; undefined when it can. */
|
|
355
|
+
blocked(peer: PeerId): string | undefined;
|
|
356
|
+
/** A PII task has no planner: its planning stays with the person. */
|
|
357
|
+
pii: boolean;
|
|
358
|
+
}
|
|
359
|
+
export interface PlannerWalk { planner?: PeerId; reason: PlannerReason; walk: { peer: PeerId; note: string }[]; line: string }
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The planner walk (#525 R3, "Choice"): pure, so assignment and `ahub route explain` print the same lines.
|
|
363
|
+
* - a PII task has no planner;
|
|
364
|
+
* - a named planner is exact: a name that is not a holder or cannot plan is refused in the trace, never replaced by rotation;
|
|
365
|
+
* - otherwise the recorded planner is kept while it is still a holder;
|
|
366
|
+
* - otherwise rotation: holders sorted by id, starting at `seed mod n`, walking forward past unavailable holders. The denominator is every
|
|
367
|
+
* holder, availability never renumbers it (the #519 seed rule). When all are skipped, the seed holder is taken, "rotation (none available)".
|
|
368
|
+
*/
|
|
369
|
+
export function plannerWalk(input: PlannerWalkInput): PlannerWalk {
|
|
370
|
+
const holders = [...new Set(input.planners)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
371
|
+
const walk: { peer: PeerId; note: string }[] = [];
|
|
372
|
+
const finish = (reason: PlannerReason, planner: PeerId | undefined, detail: string): PlannerWalk => ({ ...(planner ? { planner } : {}), reason, walk, line: planner ? `planner: ${planner} (${detail})` : `planner: none (${detail})` });
|
|
373
|
+
if (input.pii) return finish("none", undefined, "pii: planning stays with the person");
|
|
374
|
+
if (!holders.length) return finish("none", undefined, "no planner holder");
|
|
375
|
+
if (input.named !== undefined) {
|
|
376
|
+
if (!holders.includes(input.named)) { walk.push({ peer: input.named, note: "skipped, not a planner holder" }); return finish("none", undefined, `named ${input.named} is not a planner holder`); }
|
|
377
|
+
const why = input.blocked(input.named);
|
|
378
|
+
walk.push({ peer: input.named, note: why ? `named, ${why}` : "named" });
|
|
379
|
+
return finish("named", input.named, why ? `named; ${why}` : "named");
|
|
380
|
+
}
|
|
381
|
+
if (input.kept !== undefined && holders.includes(input.kept)) {
|
|
382
|
+
walk.push({ peer: input.kept, note: "kept: already the task's planner" });
|
|
383
|
+
return finish("kept", input.kept, "kept");
|
|
384
|
+
}
|
|
385
|
+
const seed = Number.isSafeInteger(input.seed) ? input.seed : 0;
|
|
386
|
+
const start = ((seed % holders.length) + holders.length) % holders.length;
|
|
387
|
+
for (let step = 0; step < holders.length; step++) {
|
|
388
|
+
const peer = holders[(start + step) % holders.length]!;
|
|
389
|
+
const why = input.blocked(peer);
|
|
390
|
+
walk.push({ peer, note: why ? `skipped, ${why}` : "available" });
|
|
391
|
+
if (!why) return finish("rotation", peer, step === 0 ? "rotation" : "rotation, skipped unavailable holders");
|
|
392
|
+
}
|
|
393
|
+
const seeded = holders[start]!;
|
|
394
|
+
return finish("rotation", seeded, "rotation (none available)");
|
|
395
|
+
}
|
|
396
|
+
|
|
321
397
|
/**
|
|
322
398
|
* L1. A pure function of policy, task and peer states, so `ahub route explain` runs exactly what assignment runs and
|
|
323
399
|
* prints its trace. `states`: effective bus states of attached peers (a peer that is not in the map is not attached).
|
|
@@ -338,6 +414,11 @@ export function assign(
|
|
|
338
414
|
pullOnly?: PeerId[];
|
|
339
415
|
namedOwner?: PeerId;
|
|
340
416
|
namedReviewer?: PeerId;
|
|
417
|
+
/** #525-3: the settled planner holders (sorted ids) and a person-named planner. Absent `planners`: no planner walk, as before. */
|
|
418
|
+
planners?: readonly PeerId[];
|
|
419
|
+
namedPlanner?: PeerId;
|
|
420
|
+
/** The planner already recorded on the task (525-2): kept while it is still a holder. */
|
|
421
|
+
keptPlanner?: PeerId;
|
|
341
422
|
reviewPool?: { pool: readonly string[]; rerotate?: boolean; failed?: readonly string[]; required?: boolean };
|
|
342
423
|
candidates?: PeerId[];
|
|
343
424
|
notReviewer?: PeerId;
|
|
@@ -376,11 +457,14 @@ export function assign(
|
|
|
376
457
|
if (!pii) try { needsSpec = specRefs({ title: task.title ?? "", detail: task.detail ?? "", refs: task.refs ?? {}, external: task.external }, opts.repository ?? "", { bareTitle: isGitHubRepository(opts.repository) }).length > 0; }
|
|
377
458
|
catch { needsSpec = true; } // an invalid/foreign reference never grants a sandboxed peer spec access
|
|
378
459
|
const needsTests = ["implement", "test", "bulk_edit"].includes(task.class);
|
|
460
|
+
// #735: the owner needs write access whenever its role for this class is the implementer's, not only for the three classes that need tests.
|
|
461
|
+
const ownerWrites = ownerRoleOfClass(task.class) === "implementer";
|
|
379
462
|
const refusals = new Map<string, RoutingRefusal>();
|
|
463
|
+
const former = implementers(task.history);
|
|
380
464
|
const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
|
|
381
465
|
const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
|
|
382
466
|
const ceiling = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.allowedRoles;
|
|
383
|
-
const requiredRole = role === "reviewer"
|
|
467
|
+
const requiredRole = role === "reviewer" ? "reviewer" : ownerRoleOfClass(task.class);
|
|
384
468
|
if (ceiling && !ceiling.includes(requiredRole)) return deny("policy", "adapter role ceiling excludes this assignment");
|
|
385
469
|
const unavailable = role === "reviewer" ? (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.reviewer?.unavailable : undefined;
|
|
386
470
|
if (unavailable) return deny("policy", unavailable);
|
|
@@ -397,7 +481,7 @@ export function assign(
|
|
|
397
481
|
if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
|
|
398
482
|
const adapter = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances);
|
|
399
483
|
if (role === "owner" && adapter?.routing?.reviewOnly) return deny("owner-admission", "owner admission: a review-only instance owns no task");
|
|
400
|
-
if (role === "owner" &&
|
|
484
|
+
if (role === "owner" && ownerWrites && adapter && adapter.kind !== peer) {
|
|
401
485
|
if (!peerEntry(opts.roles, peer)?.includes("implementer")) return deny("owner-admission", "owner admission: declared write owner requires the current implementer role");
|
|
402
486
|
if (peerEntry(opts.ownerIsolation, peer) !== true) return deny("owner-admission", "owner admission: declared write owner requires a verified isolated execution root");
|
|
403
487
|
}
|
|
@@ -414,6 +498,10 @@ export function assign(
|
|
|
414
498
|
if (routing.signals.long_context_tokens > limit) return deny("policy", `${nativeRouting.backendLabel} ${backend} capability limit ${limit} tokens`);
|
|
415
499
|
}
|
|
416
500
|
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");
|
|
501
|
+
// I-2 (#525-3): a peer that implemented this task never reviews it, whoever holds it now, a named reviewer included. It is the LAST check,
|
|
502
|
+
// so it only ever removes a candidate that was otherwise eligible: a former implementer that is offline, paused, failing or off roster is
|
|
503
|
+
// refused for that real reason and leaves the outcome exactly as it was before I-2 (a task with no other reviewer still approves directly).
|
|
504
|
+
if (role === "reviewer" && former.has(peer)) return deny("policy", "implemented this task");
|
|
417
505
|
return undefined;
|
|
418
506
|
};
|
|
419
507
|
/** 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). */
|
|
@@ -479,6 +567,27 @@ export function assign(
|
|
|
479
567
|
const ownerHold = owner ? peerEntry(opts.held, owner) : undefined;
|
|
480
568
|
if (ownerHold) trace.push(` hold: ${owner}'s queue is held: ${ownerHold} (it receives the task once the hold is resolved)`);
|
|
481
569
|
|
|
570
|
+
// #525-3: the planner is chosen by its own walk over the settled planner holders, independent of the owner and the reviewer (planner and
|
|
571
|
+
// owner, or planner and reviewer, may be the same peer). Information only until 525-2 records it: nothing here moves the task.
|
|
572
|
+
let planner: PeerId | undefined, plannerReason: PlannerReason | undefined;
|
|
573
|
+
if (opts.planners !== undefined) {
|
|
574
|
+
const plannerBlocked = (peer: PeerId): string | undefined => {
|
|
575
|
+
if ((opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.allowedRoles?.includes("planner") === false) return "adapter role ceiling excludes planning";
|
|
576
|
+
if (opts.offRoster?.includes(peer)) return "off roster";
|
|
577
|
+
if (!Object.hasOwn(states, peer)) return "not attached";
|
|
578
|
+
if (states[peer] === "offline") return "offline";
|
|
579
|
+
if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return "paused";
|
|
580
|
+
const failing = peerEntry(opts.failing, peer);
|
|
581
|
+
return failing ? `failing: ${failing}` : undefined;
|
|
582
|
+
};
|
|
583
|
+
const walked = plannerWalk({ planners: opts.planners, seed: task.id ?? 0, ...(opts.namedPlanner !== undefined ? { named: opts.namedPlanner } : {}),
|
|
584
|
+
...(opts.keptPlanner !== undefined ? { kept: opts.keptPlanner } : {}), blocked: plannerBlocked, pii });
|
|
585
|
+
for (const row of walked.walk) trace.push(` planner candidate ${row.peer}: ${row.note}`);
|
|
586
|
+
trace.push(walked.line);
|
|
587
|
+
planner = walked.planner;
|
|
588
|
+
plannerReason = walked.reason;
|
|
589
|
+
}
|
|
590
|
+
|
|
482
591
|
let reviewer: PeerId | undefined;
|
|
483
592
|
let sameFamilyFallback = false;
|
|
484
593
|
let plannerFallback = false;
|
|
@@ -548,6 +657,12 @@ export function assign(
|
|
|
548
657
|
sameFamilyFallback = true;
|
|
549
658
|
trace.push(` same-family fallback: ${reviewer} and ${reviewOwner} share ${ownerFamily}; no eligible other family`);
|
|
550
659
|
}
|
|
660
|
+
// I-2 (#525-3): when the peers that implemented this task were the only candidates left, the person reviews. Never a fallback to an implementer.
|
|
661
|
+
if (!reviewer && [...refusals.values()].some(row => row.role === "reviewer" && row.code === "policy" && row.reason === "implemented this task")) {
|
|
662
|
+
reviewer = "user";
|
|
663
|
+
reviewBlocked = true;
|
|
664
|
+
trace.push(" every eligible reviewer implemented this task: the person reviews");
|
|
665
|
+
}
|
|
551
666
|
trace.push(reviewer ? `reviewer: ${reviewer}` : "reviewer: none, done will approve directly");
|
|
552
667
|
const reviewerHold = reviewer ? peerEntry(opts.held, reviewer) : undefined;
|
|
553
668
|
if (reviewerHold) trace.push(` hold: ${reviewer}'s queue is held: ${reviewerHold} (it receives the review once the hold is resolved)`);
|
|
@@ -558,7 +673,7 @@ export function assign(
|
|
|
558
673
|
const piBackend = policy?.pi_backend;
|
|
559
674
|
if (owner && (opts.adapterCatalog?.definitionOf ?? definitionOf)(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
|
|
560
675
|
if (owner && (opts.adapterCatalog?.definitionOf ?? definitionOf)(owner, opts.instances)?.routing?.trace === "backend") trace.push(`${(opts.adapterCatalog?.definitionOf ?? 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}` : ""}`);
|
|
561
|
-
return { ...(reviewSelection ? { reviewSelection } : {}), ...(reviewBlocked ? { reviewBlocked: true as const } : {}), ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(plannerFallback ? { plannerFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
|
|
676
|
+
return { ...(reviewSelection ? { reviewSelection } : {}), ...(reviewBlocked ? { reviewBlocked: true as const } : {}), ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(plannerFallback ? { plannerFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(planner ? { planner } : {}), ...(plannerReason ? { plannerReason } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
|
|
562
677
|
}
|
|
563
678
|
|
|
564
679
|
/**
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// #481 S7: SDLC metrics and deterministic control bands. Pure: board history in, numbers out. No I/O, no clock, no model.
|
|
2
|
+
// A metric that cannot be derived is `unknown` and counted as such, never 0.
|
|
3
|
+
// `first_pass_check` reads the hub's own completion check (`check passed` / `check failed` at hub_task_done), not PR CI: it is unknown where completion checks are off.
|
|
4
|
+
|
|
5
|
+
export const BAND_METRICS = ["acceptance_to_plan_ms", "acceptance_to_first_review_ms", "first_pass_approval", "rework_rounds", "first_pass_check", "stale_head_re_reviews"] as const;
|
|
6
|
+
export type BandMetric = typeof BAND_METRICS[number];
|
|
7
|
+
export const BAND_TIERS = ["ok", "log", "diagnose", "propose"] as const;
|
|
8
|
+
export type BandTier = typeof BAND_TIERS[number];
|
|
9
|
+
|
|
10
|
+
/** The slice of a board task the metrics read. History entries carry ids, times and event names only. */
|
|
11
|
+
export interface MetricTask {
|
|
12
|
+
id: number;
|
|
13
|
+
private?: boolean;
|
|
14
|
+
/** `requestHead` is the head a `done` asked review for (reviewCost.head); `verdictHead` the head a verdict was bound to (reviewFindings.head). Compared for equality only, never printed. */
|
|
15
|
+
history: { at: number; event: string; requestHead?: string; verdictHead?: string }[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Per-task values; `null` is unknown. */
|
|
19
|
+
export interface TaskSample {
|
|
20
|
+
id: number;
|
|
21
|
+
/** When the sample counts: its first verdict, else its acceptance, else its first event. */
|
|
22
|
+
at: number;
|
|
23
|
+
/** When the task was approved, else null: `rework_rounds` and `stale_head_re_reviews` are final only then, and sit at this time in their series. */
|
|
24
|
+
approved_at: number | null;
|
|
25
|
+
acceptance_to_plan_ms: number | null;
|
|
26
|
+
acceptance_to_first_review_ms: number | null;
|
|
27
|
+
acceptance_to_merge_ms: number | null;
|
|
28
|
+
first_pass_approval: 0 | 1 | null;
|
|
29
|
+
rework_rounds: number | null;
|
|
30
|
+
first_pass_check: 0 | 1 | null;
|
|
31
|
+
same_head_ci_reruns: number | null;
|
|
32
|
+
stale_head_re_reviews: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Summary { n: number; unknown: number; mean: number | null; median: number | null; p90: number | null; max: number | null }
|
|
36
|
+
export interface StallCounts { open: number; byKind: Record<string, number>; doneWithoutWrites: number }
|
|
37
|
+
export interface BandConfig { window: number; minSamples: number; logSigma: number; diagnoseSigma: number; proposeSigma: number; driftRun: number; minDelta: number }
|
|
38
|
+
export type BandsConfig = Partial<Record<BandMetric, BandConfig>>;
|
|
39
|
+
export interface BandResult {
|
|
40
|
+
metric: BandMetric;
|
|
41
|
+
tier: BandTier;
|
|
42
|
+
/** Why the tier is what it is, in fixed words and numbers only. */
|
|
43
|
+
reason: string;
|
|
44
|
+
baseline: number;
|
|
45
|
+
mean: number | null;
|
|
46
|
+
sd: number | null;
|
|
47
|
+
last: number | null;
|
|
48
|
+
z: number | null;
|
|
49
|
+
/** The task that produced the last value: the breach's evidence. */
|
|
50
|
+
lastTask: number | null;
|
|
51
|
+
/** The task whose value started the run of non-ok values this one belongs to, else null: one breach episode has one card. */
|
|
52
|
+
episode: number | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Which way is worse. A rate that falls and a time that grows are both breaches. */
|
|
56
|
+
export const BAD_DIRECTION: Record<BandMetric, "up" | "down"> = {
|
|
57
|
+
acceptance_to_plan_ms: "up", acceptance_to_first_review_ms: "up", first_pass_approval: "down", rework_rounds: "up", first_pass_check: "down", stale_head_re_reviews: "up",
|
|
58
|
+
};
|
|
59
|
+
const HOUR = 3_600_000;
|
|
60
|
+
/** Per-metric floor on the absolute deviation, so a flat baseline does not make every wobble a breach. */
|
|
61
|
+
export const DEFAULT_MIN_DELTA: Record<BandMetric, number> = {
|
|
62
|
+
acceptance_to_plan_ms: HOUR, acceptance_to_first_review_ms: HOUR, first_pass_approval: 0.5, rework_rounds: 1, first_pass_check: 0.5, stale_head_re_reviews: 1,
|
|
63
|
+
};
|
|
64
|
+
/** Per-metric floor on the baseline deviation: a flat baseline (every value the same) must not turn one step into thousands of deviations. */
|
|
65
|
+
export const SD_FLOOR: Record<BandMetric, number> = {
|
|
66
|
+
acceptance_to_plan_ms: HOUR / 2, acceptance_to_first_review_ms: HOUR / 2, first_pass_approval: 0.5, rework_rounds: 0.5, first_pass_check: 0.5, stale_head_re_reviews: 0.5,
|
|
67
|
+
};
|
|
68
|
+
export const DEFAULT_BAND: Omit<BandConfig, "minDelta"> = { window: 20, minSamples: 8, logSigma: 2, diagnoseSigma: 3, proposeSigma: 4, driftRun: 6 };
|
|
69
|
+
|
|
70
|
+
const first = (task: MetricTask, events: readonly string[], after = -Infinity) => task.history.find(entry => entry.at >= after && events.includes(entry.event));
|
|
71
|
+
const VERDICTS = ["approved", "changes_requested"];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Review rounds whose verdict was bound to a different head than the review was requested for: the head moved while the review ran.
|
|
75
|
+
* Known only for a round with both heads recorded; a task with no such round is unknown, never 0. (A `done` after an `approved` is not
|
|
76
|
+
* evidence: the board cannot produce one.)
|
|
77
|
+
* ponytail: the request head is `reviewCost.head` (the commit the done named first) while a verdict's head prefers the lifecycle receipt's
|
|
78
|
+
* `head`/`localHead` over `refs.commit` (`findingHead`), so a receipt head that differs from the commit can read as stale. Ceiling: a
|
|
79
|
+
* spurious stale round for a task whose receipt head and commit disagree; the upgrade is one shared head rule for both sides.
|
|
80
|
+
*/
|
|
81
|
+
function staleHeadRounds(history: MetricTask["history"]): number | null {
|
|
82
|
+
let pending: string | undefined, known = false, stale = 0;
|
|
83
|
+
for (const entry of history) {
|
|
84
|
+
if (entry.event === "done") pending = entry.requestHead;
|
|
85
|
+
else if (VERDICTS.includes(entry.event)) {
|
|
86
|
+
if (pending !== undefined && entry.verdictHead !== undefined) { known = true; if (entry.verdictHead !== pending) stale++; }
|
|
87
|
+
pending = undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return known ? stale : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One task's values from its own history. A private task never reaches here. */
|
|
94
|
+
export function taskSample(task: MetricTask, extra: { mergedAt?: number; ciReruns?: number | null } = {}): TaskSample {
|
|
95
|
+
const history = [...task.history].filter(entry => Number.isFinite(entry.at) && entry.at >= 0).sort((a, b) => a.at - b.at);
|
|
96
|
+
const view: MetricTask = { id: task.id, history };
|
|
97
|
+
const accepted = first(view, ["accepted"]);
|
|
98
|
+
const spec = first(view, ["spec accepted"]);
|
|
99
|
+
const plan = spec ? first(view, ["plan reviewed"], spec.at) : undefined;
|
|
100
|
+
const verdict = accepted ? first(view, VERDICTS, accepted.at) : first(view, VERDICTS);
|
|
101
|
+
const dones = history.filter(entry => entry.event === "done");
|
|
102
|
+
const ci = first(view, ["check passed", "check failed"]);
|
|
103
|
+
const approvedAt = history.find(entry => entry.event === "approved")?.at ?? null;
|
|
104
|
+
return {
|
|
105
|
+
id: task.id,
|
|
106
|
+
at: verdict?.at ?? accepted?.at ?? history[0]?.at ?? 0,
|
|
107
|
+
approved_at: approvedAt,
|
|
108
|
+
acceptance_to_plan_ms: spec && plan ? plan.at - spec.at : null,
|
|
109
|
+
acceptance_to_first_review_ms: accepted && verdict ? verdict.at - accepted.at : null,
|
|
110
|
+
acceptance_to_merge_ms: accepted && extra.mergedAt !== undefined && Number.isFinite(extra.mergedAt) && extra.mergedAt >= accepted.at ? extra.mergedAt - accepted.at : null,
|
|
111
|
+
first_pass_approval: verdict ? (verdict.event === "approved" ? 1 : 0) : null,
|
|
112
|
+
// Rework grows with every round, so it is a value only once the task is approved: a task still in flight has no final one, and a
|
|
113
|
+
// provisional point would move at each verdict and could start a second breach episode inside one shift.
|
|
114
|
+
rework_rounds: dones.length && approvedAt !== null ? history.filter(entry => entry.event === "changes_requested").length : null,
|
|
115
|
+
first_pass_check: ci ? (ci.event === "check passed" ? 1 : 0) : null,
|
|
116
|
+
same_head_ci_reruns: extra.ciReruns === undefined ? null : extra.ciReruns,
|
|
117
|
+
stale_head_re_reviews: approvedAt !== null ? staleHeadRounds(history) : null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const percentile = (sorted: number[], p: number) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1))]!;
|
|
122
|
+
export function summarize(values: readonly (number | null)[]): Summary {
|
|
123
|
+
const known = values.filter((value): value is number => value !== null && Number.isFinite(value)).sort((a, b) => a - b);
|
|
124
|
+
const unknown = values.length - known.length;
|
|
125
|
+
if (!known.length) return { n: 0, unknown, mean: null, median: null, p90: null, max: null };
|
|
126
|
+
const mean = known.reduce((sum, value) => sum + value, 0) / known.length;
|
|
127
|
+
return { n: known.length, unknown, mean: round(mean), median: round(percentile(known, 0.5)), p90: round(percentile(known, 0.9)), max: known.at(-1)! };
|
|
128
|
+
}
|
|
129
|
+
const round = (value: number) => Math.round(value * 1000) / 1000;
|
|
130
|
+
|
|
131
|
+
/** The values of one metric in the order they happened, unknown ones left out. */
|
|
132
|
+
export function series(samples: readonly TaskSample[], metric: BandMetric): { value: number; task: number }[] {
|
|
133
|
+
const final = metric === "rework_rounds" || metric === "stale_head_re_reviews";
|
|
134
|
+
const when = (sample: TaskSample) => final ? sample.approved_at ?? sample.at : sample.at;
|
|
135
|
+
return [...samples].sort((a, b) => when(a) - when(b) || a.id - b.id).flatMap(sample => sample[metric] === null ? [] : [{ value: sample[metric] as number, task: sample.id }]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The newest value against the `window` values before it: mean, population standard deviation, and a tier from how many
|
|
140
|
+
* deviations the newest value sits on the bad side. A run of `driftRun` newest values all more than one deviation on the bad
|
|
141
|
+
* side is a drift and is at least `diagnose`. With fewer than `minSamples` before it, nothing fires.
|
|
142
|
+
*/
|
|
143
|
+
export function controlBands(points: readonly { value: number; task: number }[], metric: BandMetric, config: BandConfig): BandResult {
|
|
144
|
+
const baselineCount = Math.max(0, Math.min(config.window, points.length - 1));
|
|
145
|
+
const none = (reason: string, extra: Partial<BandResult> = {}): BandResult => ({ metric, tier: "ok", reason, baseline: baselineCount, mean: null, sd: null, last: points.at(-1)?.value ?? null, z: null, lastTask: points.at(-1)?.task ?? null, episode: null, ...extra });
|
|
146
|
+
if (points.length < 2 || baselineCount < config.minSamples) return none(`baseline of ${baselineCount} is below the ${config.minSamples} needed`);
|
|
147
|
+
const last = points.at(-1)!, base = points.slice(-1 - baselineCount, -1).map(point => point.value);
|
|
148
|
+
const mean = base.reduce((sum, value) => sum + value, 0) / base.length;
|
|
149
|
+
const sd = Math.sqrt(base.reduce((sum, value) => sum + (value - mean) ** 2, 0) / base.length);
|
|
150
|
+
const sign = BAD_DIRECTION[metric] === "up" ? 1 : -1;
|
|
151
|
+
const deviation = sign * (last.value - mean);
|
|
152
|
+
const z = deviation <= 0 ? 0 : deviation / Math.max(sd, SD_FLOOR[metric]);
|
|
153
|
+
const result = { metric, baseline: base.length, mean: round(mean), sd: round(sd), last: last.value, z: round(Math.min(z, 1e6)), lastTask: last.task, episode: null as number | null };
|
|
154
|
+
const drifting = deviation >= config.minDelta && deviation > 0 && points.length - 1 >= config.driftRun && points.slice(-config.driftRun).every(point => sign * (point.value - mean) > sd);
|
|
155
|
+
let tier: BandTier = "ok", reason = `${round(Math.min(z, 1e6))} deviations on the bad side`;
|
|
156
|
+
if (deviation >= config.minDelta && deviation > 0) {
|
|
157
|
+
if (z >= config.proposeSigma) { tier = "propose"; reason = `${round(Math.min(z, 1e6))} deviations beyond the baseline (propose at ${config.proposeSigma})`; }
|
|
158
|
+
else if (z >= config.diagnoseSigma) { tier = "diagnose"; reason = `${round(z)} deviations beyond the baseline (diagnose at ${config.diagnoseSigma})`; }
|
|
159
|
+
else if (z >= config.logSigma) { tier = "log"; reason = `${round(z)} deviations beyond the baseline (log at ${config.logSigma})`; }
|
|
160
|
+
}
|
|
161
|
+
if (drifting && BAND_TIERS.indexOf(tier) < BAND_TIERS.indexOf("diagnose")) { tier = "diagnose"; reason = `the last ${config.driftRun} values are all more than one deviation on the bad side`; }
|
|
162
|
+
return { ...result, tier, reason };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** How far back an episode is searched. ponytail: a run longer than this restarts its key every sample, so the open-card rule in Tasks holds it to one card. */
|
|
166
|
+
export const EPISODE_LOOKBACK = 200;
|
|
167
|
+
/**
|
|
168
|
+
* `controlBands` for the newest value, plus the breach episode it belongs to: the trailing run of values whose own tier (computed from
|
|
169
|
+
* the values before each, with nothing after) was not `ok`. The episode is the task that started the run, so a sustained shift keeps one
|
|
170
|
+
* key for as long as it lasts, and it is derived from history alone, so it survives a restart.
|
|
171
|
+
*/
|
|
172
|
+
export function controlEpisode(points: readonly { value: number; task: number }[], metric: BandMetric, config: BandConfig): BandResult {
|
|
173
|
+
const now = controlBands(points, metric, config);
|
|
174
|
+
if (now.tier === "ok") return now;
|
|
175
|
+
let start = points.length - 1;
|
|
176
|
+
for (let end = points.length - 2; end >= 0 && points.length - 1 - end <= EPISODE_LOOKBACK; end--) {
|
|
177
|
+
if (controlBands(points.slice(0, end + 1), metric, config).tier === "ok") break;
|
|
178
|
+
start = end;
|
|
179
|
+
}
|
|
180
|
+
return { ...now, episode: points[start]!.task };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface MetricsReport {
|
|
184
|
+
since: number | null;
|
|
185
|
+
tasks: number;
|
|
186
|
+
withheldPrivate: number;
|
|
187
|
+
metrics: {
|
|
188
|
+
acceptance_to_plan_ms: Summary; acceptance_to_first_review_ms: Summary; acceptance_to_merge_ms: Summary;
|
|
189
|
+
first_pass_approval: { n: number; unknown: number; rate: number | null };
|
|
190
|
+
rework_rounds: Summary;
|
|
191
|
+
first_pass_check: { n: number; unknown: number; rate: number | null };
|
|
192
|
+
same_head_ci_reruns: Summary;
|
|
193
|
+
stale_head_re_reviews: Summary;
|
|
194
|
+
};
|
|
195
|
+
stalls: StallCounts;
|
|
196
|
+
bands: BandResult[] | "off";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rate = (values: readonly (number | null)[]) => {
|
|
200
|
+
const known = values.filter((value): value is number => value !== null);
|
|
201
|
+
return { n: known.length, unknown: values.length - known.length, rate: known.length ? round(known.reduce((sum, value) => sum + value, 0) / known.length) : null };
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The whole report from board tasks. `since` keeps samples that count at or after it; `mergedAt` and `ciReruns` come from bounded
|
|
206
|
+
* PR readbacks keyed by task id and are optional. Private tasks are counted as withheld and read for nothing else.
|
|
207
|
+
*/
|
|
208
|
+
export function sdlcMetrics(tasks: readonly MetricTask[], opts: { since?: number; bands?: BandsConfig; stalls?: StallCounts; mergedAt?: Readonly<Record<number, number>>; ciReruns?: Readonly<Record<number, number | null>> } = {}): MetricsReport {
|
|
209
|
+
const visible = tasks.filter(task => !task.private);
|
|
210
|
+
const all = visible.map(task => taskSample(task, { mergedAt: opts.mergedAt?.[task.id], ciReruns: opts.ciReruns?.[task.id] }));
|
|
211
|
+
const samples = opts.since === undefined ? all : all.filter(sample => sample.at >= opts.since!);
|
|
212
|
+
const bands: BandResult[] | "off" = opts.bands && Object.keys(opts.bands).length
|
|
213
|
+
? BAND_METRICS.flatMap(metric => opts.bands![metric] ? [controlEpisode(series(samples, metric), metric, opts.bands![metric]!)] : [])
|
|
214
|
+
: "off";
|
|
215
|
+
return {
|
|
216
|
+
since: opts.since ?? null, tasks: samples.length, withheldPrivate: tasks.length - visible.length,
|
|
217
|
+
metrics: {
|
|
218
|
+
acceptance_to_plan_ms: summarize(samples.map(s => s.acceptance_to_plan_ms)), acceptance_to_first_review_ms: summarize(samples.map(s => s.acceptance_to_first_review_ms)),
|
|
219
|
+
acceptance_to_merge_ms: summarize(samples.map(s => s.acceptance_to_merge_ms)),
|
|
220
|
+
first_pass_approval: rate(samples.map(s => s.first_pass_approval)), rework_rounds: summarize(samples.map(s => s.rework_rounds)),
|
|
221
|
+
first_pass_check: rate(samples.map(s => s.first_pass_check)), same_head_ci_reruns: summarize(samples.map(s => s.same_head_ci_reruns)),
|
|
222
|
+
stale_head_re_reviews: summarize(samples.map(s => s.stale_head_re_reviews)),
|
|
223
|
+
},
|
|
224
|
+
stalls: opts.stalls ?? { open: 0, byKind: {}, doneWithoutWrites: 0 }, bands,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const BAND_KEYS = ["window", "min_samples", "log_sigma", "diagnose_sigma", "propose_sigma", "drift_run", "min_delta"] as const;
|
|
229
|
+
/**
|
|
230
|
+
* `[bands.<metric>]` tables of sdlc.toml. Every field is optional and bounded; an unknown metric or field is refused with its
|
|
231
|
+
* path, and the tiers must be ordered log <= diagnose <= propose so a breach never skips a tier it is meant to pass through.
|
|
232
|
+
*/
|
|
233
|
+
export function bandsConfig(value: unknown): BandsConfig {
|
|
234
|
+
if (value === undefined) return {};
|
|
235
|
+
const fail = (path: string, why: string): never => { throw new Error(`sdlc.bands${path}: ${why}`); };
|
|
236
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return fail("", "expected a table");
|
|
237
|
+
const out: BandsConfig = {};
|
|
238
|
+
for (const [metric, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
239
|
+
if (!(BAND_METRICS as readonly string[]).includes(metric)) fail(`.${metric}`, `unknown metric (use ${BAND_METRICS.join(", ")})`);
|
|
240
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) fail(`.${metric}`, "expected a table");
|
|
241
|
+
const row = raw as Record<string, unknown>;
|
|
242
|
+
for (const key of Object.keys(row)) if (!(BAND_KEYS as readonly string[]).includes(key)) fail(`.${metric}.${key}`, "unknown field");
|
|
243
|
+
const number = (key: typeof BAND_KEYS[number], fallback: number, min: number, max: number, integer: boolean) => {
|
|
244
|
+
const given = row[key] ?? fallback;
|
|
245
|
+
if (typeof given !== "number" || !Number.isFinite(given) || given < min || given > max || integer && !Number.isSafeInteger(given)) fail(`.${metric}.${key}`, `expected ${integer ? "an integer" : "a number"} from ${min} to ${max}`);
|
|
246
|
+
return given as number;
|
|
247
|
+
};
|
|
248
|
+
const m = metric as BandMetric;
|
|
249
|
+
const config: BandConfig = {
|
|
250
|
+
window: number("window", DEFAULT_BAND.window, 4, 200, true), minSamples: number("min_samples", DEFAULT_BAND.minSamples, 3, 200, true),
|
|
251
|
+
logSigma: number("log_sigma", DEFAULT_BAND.logSigma, 0.5, 20, false), diagnoseSigma: number("diagnose_sigma", DEFAULT_BAND.diagnoseSigma, 0.5, 20, false),
|
|
252
|
+
proposeSigma: number("propose_sigma", DEFAULT_BAND.proposeSigma, 0.5, 20, false), driftRun: number("drift_run", DEFAULT_BAND.driftRun, 3, 50, true),
|
|
253
|
+
minDelta: number("min_delta", DEFAULT_MIN_DELTA[m], 0, 1e12, false),
|
|
254
|
+
};
|
|
255
|
+
if (config.minSamples > config.window) fail(`.${metric}.min_samples`, "must not exceed window");
|
|
256
|
+
if (!(config.logSigma <= config.diagnoseSigma && config.diagnoseSigma <= config.proposeSigma)) fail(`.${metric}`, "tiers must be ordered log_sigma <= diagnose_sigma <= propose_sigma");
|
|
257
|
+
out[m] = config;
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
package/src/hub/sdlc.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { parseIssueLabels, parseRedFirst, testIntegrityBlockers, type RedFirstRe
|
|
|
2
2
|
import { planConformanceBlockers, type PlanConformance } from "./plan-conformance.ts";
|
|
3
3
|
import { DEFAULT_MAX_NITS, findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
4
4
|
import type { CostPlan } from "./cost-governance.ts";
|
|
5
|
+
import { bandsConfig, type BandsConfig } from "./sdlc-metrics.ts";
|
|
5
6
|
import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
|
|
6
7
|
import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
7
8
|
import { join } from "node:path";
|
|
@@ -32,7 +33,7 @@ export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "criter
|
|
|
32
33
|
export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
|
|
33
34
|
export type SdlcMode = "off" | "advise" | "enforce";
|
|
34
35
|
/** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
|
|
35
|
-
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
36
|
+
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; /** #481 S7: optional control bands per metric; absent means none. */ bands?: BandsConfig; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
36
37
|
export interface PlanReviewReceipt {
|
|
37
38
|
planner: string;
|
|
38
39
|
planningRevision: number;
|
|
@@ -133,10 +134,11 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
|
133
134
|
if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
|
|
134
135
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
|
|
135
136
|
const raw = value as Record<string, unknown>;
|
|
136
|
-
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
137
|
+
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits", "bands"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
137
138
|
const maxNits = raw.max_nits === undefined ? DEFAULT_MAX_NITS : raw.max_nits;
|
|
138
139
|
if (typeof maxNits !== "number" || !Number.isSafeInteger(maxNits) || maxNits < 0 || maxNits > 64) throw new Error("sdlc.max_nits: expected an integer from 0 to 64");
|
|
139
|
-
const
|
|
140
|
+
const bands = bandsConfig(raw.bands), bandConfig = Object.keys(bands).length ? { bands } : {};
|
|
141
|
+
const findingConfig = { ...(raw.max_nits === undefined ? {} : { maxNits }), ...bandConfig };
|
|
140
142
|
const mode = raw.mode ?? "off";
|
|
141
143
|
if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
|
|
142
144
|
// ponytail: the #481 S8 window is a fixed version; delete `workflow`, the phase aliases and their tests once it passes.
|
package/src/hub/settings.ts
CHANGED
|
@@ -473,7 +473,7 @@ export function settingRows(input: {
|
|
|
473
473
|
const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
|
|
474
474
|
return projectSettings(input.instances, input.adapterCatalog).map((def): SettingRow => {
|
|
475
475
|
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 }),
|
|
476
|
-
...(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.key.startsWith("action_policy.") ? { hint: "
|
|
476
|
+
...(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.key.startsWith("action_policy.") ? { hint: "enforced for Claude (PreToolUse hook) and hub-owned ACP peers in ask mode; ahub status lists each peer that is not enforceable" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances, input.adapterCatalog) } : {}) };
|
|
477
477
|
if (def.locked) return { ...base, values: def.values, file: def.store === "runtime" ? "this hub run" : CONFIG_LOCAL,
|
|
478
478
|
source: "instance declaration", value: "read-only", fileValue: "read-only", locked: def.locked, note: def.locked };
|
|
479
479
|
if (def.store === "runtime") {
|
package/src/hub/spec-read.ts
CHANGED
|
@@ -48,6 +48,37 @@ export function specRefs(task: Pick<Task, "title" | "detail" | "refs"> & Partial
|
|
|
48
48
|
return [...issues];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* #723: issue numbers a title names as a sibling repository (`agent-mem#13`) when the task's `refs.repo` is that repository.
|
|
53
|
+
* The bare name must equal the repository part of `refs.repo`; nothing else is guessed, and nothing is read. A bare
|
|
54
|
+
* `#N` and an `owner/repo#N` form are specRefs' business, never this function's.
|
|
55
|
+
*/
|
|
56
|
+
export function siblingTitleRefs(title: string, repo: string | undefined): number[] {
|
|
57
|
+
if (!repo || !isGitHubRepository(repo)) return [];
|
|
58
|
+
const name = repo.split("/")[1]!.toLowerCase(), found = new Set<number>();
|
|
59
|
+
for (const match of title.matchAll(/(?<![A-Za-z0-9_./-])([A-Za-z0-9_.-]+)#([1-9][0-9]*)\b/g)) {
|
|
60
|
+
if (match[1]!.toLowerCase() === name) found.add(Number(match[2]));
|
|
61
|
+
}
|
|
62
|
+
return [...found].slice(0, 4);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Words that precede `#N` without naming a repository (`PR#12`); never worth a refs.repo suggestion. */
|
|
66
|
+
const NOT_A_REPOSITORY = new Set(["pr", "pull", "issue", "issues", "task", "tasks", "bug", "fix", "phase", "step", "item", "ticket", "gh", "id", "no", "nr", "p", "s", "t", "u"]);
|
|
67
|
+
/**
|
|
68
|
+
* #723: the qualified repository names a title carries (`owner/repo#N` as `owner/repo`, `agent-mem#13` as `agent-mem`), so
|
|
69
|
+
* propose can suggest `refs.repo`. Advice only: a hit is a hint, never an identity.
|
|
70
|
+
*/
|
|
71
|
+
export function qualifiedTitleRepos(title: string): string[] {
|
|
72
|
+
const found = new Set<string>();
|
|
73
|
+
for (const match of title.matchAll(/(?<![A-Za-z0-9_./-])(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)|([A-Za-z0-9_.-]*[A-Za-z][A-Za-z0-9_.-]*))#[1-9][0-9]*\b/g)) {
|
|
74
|
+
const name = match[1] ?? match[2]!;
|
|
75
|
+
if (match[1] ? !isGitHubRepository(name) : NOT_A_REPOSITORY.has(name.toLowerCase())) continue;
|
|
76
|
+
found.add(name);
|
|
77
|
+
if (found.size >= 4) break;
|
|
78
|
+
}
|
|
79
|
+
return [...found];
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
interface GhResult { code: number; stdout: string; }
|
|
52
83
|
/** One fixed-argv authenticated boundary, no shell, no retries and no retained stderr. */
|
|
53
84
|
function gh(args: string[], opts: SpecOptions): Promise<GhResult> {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { symbolsOverlap } from "./symbol-normalizer.ts";
|
|
2
|
+
|
|
3
|
+
// #684 2c: the dashboard task sheet's "Code" group, classified on the hub so the page never matches names itself. Pure data in, ids and
|
|
4
|
+
// names out. The observation (`codeSymbols`) is the receipt `Tasks.publicView` already re-screened; the plan names are `plan.symbols`, the same
|
|
5
|
+
// list the review brief reads as `planned`. Advisory only: ripwire does not report deleted symbols, so neither list proves a plan was followed.
|
|
6
|
+
|
|
7
|
+
export interface CodeGroupSymbol { id: string; name: string; path: string; kind: string; mark: "matching" | "unplanned" }
|
|
8
|
+
export interface CodeGroup {
|
|
9
|
+
state: "known" | "unknown";
|
|
10
|
+
complete: boolean;
|
|
11
|
+
head?: string;
|
|
12
|
+
symbols: CodeGroupSymbol[];
|
|
13
|
+
/** Plan names nothing changed under. */
|
|
14
|
+
untouched: string[];
|
|
15
|
+
/** How many more changed symbols exist than are listed, so a cap is never read as the whole. */
|
|
16
|
+
more: number;
|
|
17
|
+
/** Totals before the cap, so the sheet's counts are the whole and not the listed part. */
|
|
18
|
+
counts: { matching: number; unplanned: number; untouched: number };
|
|
19
|
+
/** Plan names the hub would not show (screened out or malformed). While any is withheld a symbol marked unplanned may be planned, and one marked untouched may not be. */
|
|
20
|
+
planWithheld: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const CODE_GROUP_MAX = 40;
|
|
24
|
+
/** The stored plan's own bounds (`cleanPlan`: 300 characters a name, 50 names), so no stored name is dropped for its length alone. */
|
|
25
|
+
export const PLAN_NAME_MAX = 300, PLAN_NAMES_MAX = 50;
|
|
26
|
+
interface Receipt { state?: unknown; complete?: unknown; head?: unknown; symbols?: unknown }
|
|
27
|
+
const word = (value: unknown, max: number): value is string => typeof value === "string" && value.length > 0 && value.length <= max && !/[\p{Cc}\p{Cf}]/u.test(value);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `receipt` is the public `codeSymbols` (never the stored one, which carries `repositoryKey`); `plan` the task's `plan.symbols`; `screen`
|
|
31
|
+
* the hub's `publicMetadataText`. Returns undefined for a private task: the sheet then shows no Code group at all.
|
|
32
|
+
*/
|
|
33
|
+
export function codeGroup(task: { id: number }, receipt: unknown, plan: unknown, screen: (text: string) => boolean, privateTask: boolean): CodeGroup | undefined {
|
|
34
|
+
if (privateTask) return undefined;
|
|
35
|
+
const r = (receipt && typeof receipt === "object" && !Array.isArray(receipt) ? receipt : undefined) as Receipt | undefined;
|
|
36
|
+
if (!r || r.state !== "known" || !Array.isArray(r.symbols) || Object.hasOwn(r, "repositoryKey")) return { state: "unknown", complete: false, symbols: [], untouched: [], more: 0, counts: { matching: 0, unplanned: 0, untouched: 0 }, planWithheld: 0 };
|
|
37
|
+
// Every stored plan name is read; one that is malformed or fails the screen is counted, never silently dropped, because dropping it would
|
|
38
|
+
// turn a symbol the plan names into an "unplanned" one and the sheet would show a departure that never happened.
|
|
39
|
+
const given = Array.isArray(plan) ? plan.slice(0, PLAN_NAMES_MAX + 1) : [];
|
|
40
|
+
const planned = given.slice(0, PLAN_NAMES_MAX).filter((name): name is string => word(name, PLAN_NAME_MAX)).filter(name => { try { return screen(name); } catch { return false; } });
|
|
41
|
+
const planWithheld = (given.length > PLAN_NAMES_MAX ? given.length - PLAN_NAMES_MAX : 0) + (Math.min(given.length, PLAN_NAMES_MAX) - planned.length);
|
|
42
|
+
const changed: CodeGroupSymbol[] = [];
|
|
43
|
+
for (const item of r.symbols as Record<string, unknown>[]) {
|
|
44
|
+
if (!item || !word(item.id, 512) || !word(item.name, 512) || !word(item.path, 512) || !word(item.kind, 64) || item.id !== `symbol:${item.name}@${item.path}`) continue;
|
|
45
|
+
changed.push({ id: item.id, name: item.name, path: item.path, kind: item.kind, mark: planned.some(name => symbolsOverlap(name, item.name as string)) ? "matching" : "unplanned" });
|
|
46
|
+
}
|
|
47
|
+
const untouched = planned.filter(name => !changed.some(symbol => symbolsOverlap(name, symbol.name)));
|
|
48
|
+
const shown = changed.slice(0, CODE_GROUP_MAX);
|
|
49
|
+
return { state: "known", complete: r.complete === true && changed.length === r.symbols.length && planWithheld === 0, ...(typeof r.head === "string" && /^[a-f0-9]{40}$/.test(r.head) ? { head: r.head.slice(0, 8) } : {}),
|
|
50
|
+
symbols: shown, untouched: untouched.slice(0, CODE_GROUP_MAX), more: Math.max(0, changed.length - shown.length) + Math.max(0, untouched.length - CODE_GROUP_MAX),
|
|
51
|
+
counts: { matching: changed.filter(s => s.mark === "matching").length, unplanned: changed.filter(s => s.mark === "unplanned").length, untouched: untouched.length }, planWithheld };
|
|
52
|
+
}
|