@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +19 -7
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +18 -4
  5. package/docs/agent-notes/budget.md +5 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +19 -7
  8. package/docs/agent-notes/tasks.md +15 -7
  9. package/docs/agent-notes/tests.md +4 -4
  10. package/docs/dogfood.md +18 -10
  11. package/docs/events.md +21 -7
  12. package/docs/operations.md +131 -27
  13. package/docs/profiles.md +1 -0
  14. package/docs/quickstart.md +13 -3
  15. package/docs/sdlc.md +40 -2
  16. package/docs/security.md +45 -14
  17. package/docs/specs/2026-09-19-agent-hub-design.md +112 -10
  18. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
  19. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  20. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  21. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  22. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  23. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  24. package/docs/verified.json +34 -17
  25. package/package.json +1 -1
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +1078 -327
  28. package/src/adapters/acp.ts +43 -7
  29. package/src/adapters/claude-channel.ts +26 -4
  30. package/src/adapters/claude-implementer.ts +243 -0
  31. package/src/adapters/claude-print-child.ts +90 -0
  32. package/src/adapters/claude-task-sessions.ts +80 -0
  33. package/src/adapters/codex-review-isolation.ts +18 -7
  34. package/src/adapters/compat.ts +4 -0
  35. package/src/adapters/conformance/enrollment.ts +2 -1
  36. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  37. package/src/adapters/definitions/builtin-metadata.ts +15 -3
  38. package/src/adapters/definitions/native-channel-state.ts +1 -1
  39. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  40. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  41. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  42. package/src/adapters/definitions/native-factories.ts +8 -0
  43. package/src/adapters/kimi-wire-usage.ts +303 -0
  44. package/src/adapters/registry-types.ts +7 -3
  45. package/src/adapters/registry.ts +14 -2
  46. package/src/adapters/reviewer-print.ts +64 -60
  47. package/src/cli/console-config.ts +123 -0
  48. package/src/cli/console-state.ts +28 -1
  49. package/src/cli/console.ts +25 -1
  50. package/src/cli/facts-hook.ts +9 -5
  51. package/src/cli/help.ts +6 -4
  52. package/src/cli/launch.ts +10 -4
  53. package/src/cli/main.ts +52 -9
  54. package/src/cli/output.ts +75 -2
  55. package/src/cli/reset.ts +1 -1
  56. package/src/cli/settings-edit.ts +89 -0
  57. package/src/cli/status-lines.ts +1 -1
  58. package/src/cli/terminal-recovery.ts +1 -6
  59. package/src/hub/action-enforce.ts +82 -0
  60. package/src/hub/action-policy.ts +22 -10
  61. package/src/hub/board.ts +48 -3
  62. package/src/hub/budget.ts +5 -0
  63. package/src/hub/bus.ts +5 -5
  64. package/src/hub/claude-launch-proof.ts +33 -0
  65. package/src/hub/config-apply.ts +5 -0
  66. package/src/hub/config-edit-host.ts +173 -0
  67. package/src/hub/config-edit.ts +282 -0
  68. package/src/hub/config-tier.ts +89 -0
  69. package/src/hub/control-client.ts +24 -3
  70. package/src/hub/daemon.ts +450 -71
  71. package/src/hub/deliverable-files.ts +131 -0
  72. package/src/hub/deliverables.ts +125 -0
  73. package/src/hub/delivery-journal.ts +18 -2
  74. package/src/hub/events.ts +3 -1
  75. package/src/hub/hub-tools.ts +21 -4
  76. package/src/hub/instances.ts +19 -6
  77. package/src/hub/kimi-usage.ts +144 -0
  78. package/src/hub/manager.ts +3 -3
  79. package/src/hub/peers.ts +7 -0
  80. package/src/hub/permission-payload.ts +15 -0
  81. package/src/hub/routing.ts +118 -3
  82. package/src/hub/sdlc-metrics.ts +260 -0
  83. package/src/hub/sdlc.ts +5 -3
  84. package/src/hub/settings.ts +1 -1
  85. package/src/hub/spec-read.ts +31 -0
  86. package/src/hub/task-code-group.ts +52 -0
  87. package/src/hub/tasks.ts +374 -37
  88. package/src/hub/token-window.ts +33 -0
  89. package/src/hub/ui.ts +3 -0
  90. package/src/hub/usage-rollup.ts +74 -6
  91. package/src/hub/workflow.ts +16 -0
  92. 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
+ }
@@ -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" || task.class === "review" ? "reviewer" : task.class === "plan" ? "planner" : "implementer";
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" && needsTests && adapter && adapter.kind !== peer) {
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 findingConfig = raw.max_nits === undefined ? {} : { maxNits };
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.
@@ -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: "not enforced yet: adapter integration pending" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances, input.adapterCatalog) } : {}) };
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") {
@@ -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
+ }