@staix/agent-hub 0.13.0 → 0.14.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 (40) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +6 -4
  3. package/docs/agent-notes/adapters.md +1 -1
  4. package/docs/agent-notes/budget.md +2 -1
  5. package/docs/agent-notes/daemon.md +8 -1
  6. package/docs/agent-notes/tasks.md +7 -4
  7. package/docs/agent-notes/tests.md +1 -1
  8. package/docs/dogfood.md +10 -7
  9. package/docs/operations.md +45 -13
  10. package/docs/quickstart.md +6 -2
  11. package/docs/sdlc.md +23 -2
  12. package/docs/security.md +9 -5
  13. package/docs/specs/2026-09-19-agent-hub-design.md +38 -0
  14. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
  15. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  16. package/docs/verified.json +20 -15
  17. package/package.json +1 -1
  18. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  19. package/plugins/agent-hub/server.js +91 -20
  20. package/src/adapters/codex-review-isolation.ts +18 -7
  21. package/src/adapters/definitions/builtin-metadata.ts +1 -1
  22. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  23. package/src/adapters/reviewer-print.ts +34 -7
  24. package/src/cli/help.ts +2 -1
  25. package/src/cli/main.ts +5 -0
  26. package/src/cli/output.ts +47 -2
  27. package/src/cli/status-lines.ts +1 -1
  28. package/src/hub/board.ts +22 -1
  29. package/src/hub/daemon.ts +84 -14
  30. package/src/hub/hub-tools.ts +2 -1
  31. package/src/hub/instances.ts +14 -4
  32. package/src/hub/manager.ts +2 -2
  33. package/src/hub/sdlc-metrics.ts +260 -0
  34. package/src/hub/sdlc.ts +5 -3
  35. package/src/hub/spec-read.ts +31 -0
  36. package/src/hub/task-code-group.ts +52 -0
  37. package/src/hub/tasks.ts +172 -22
  38. package/src/hub/ui.ts +3 -0
  39. package/src/hub/usage-rollup.ts +58 -0
  40. package/src/ui/index.html +273 -2
package/src/hub/tasks.ts CHANGED
@@ -4,7 +4,8 @@ import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type Tes
4
4
  import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
5
5
  import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
6
6
  import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
7
- import { specRefs } from "./spec-read.ts";
7
+ import { qualifiedTitleRepos, siblingTitleRefs, specRefs } from "./spec-read.ts";
8
+ import { sdlcMetrics, type BandResult, type MetricTask, type MetricsReport } from "./sdlc-metrics.ts";
8
9
  import type { InstancesConfig } from "./instances.ts";
9
10
  import { composeRememberText } from "./memory-fail.ts";
10
11
  import { VERSION } from "../version.ts";
@@ -892,11 +893,11 @@ export class Tasks {
892
893
  }
893
894
  return parts.length ? `no peer can take the planner pull: ${parts.join("; ")}` : "no peer has eligible planner WIP capacity";
894
895
  }
895
- private plannerAssignment(task: Task, reservedOnly = false): Assignment {
896
+ private plannerAssignment(task: Task, reservedOnly = false, routing: Routing = this.d.routing()): Assignment {
896
897
  const states = this.states();
897
898
  const full = this.plannerFull(task);
898
899
  // Preview the To Do transition without routing or offering the persisted backlog row.
899
- return assign({ ...task, state: "proposed" }, states, this.d.routing(), { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved && !this.excluded(task).includes(task.reserved) ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
900
+ return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
900
901
  }
901
902
 
902
903
  async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
@@ -921,14 +922,25 @@ export class Tasks {
921
922
  return !!competing && (competing.rank < order.rank || competing.rank === order.rank && competing.id < order.id);
922
923
  })) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
923
924
  }
924
- const assignment = this.plannerAssignment(task);
925
- if (!assignment.owner) throw new PreEffectToolRefusal(this.plannerRefusal(task));
926
- if (this.hasPersonReservation(task) && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
925
+ if (task.reserved && this.excluded(task).includes(task.reserved)) throw new PreEffectToolRefusal(this.plannerRefusal(task));
926
+ const assignment = this.plannerAssignment(task, !!task.reserved);
927
+ if (!assignment.owner) {
928
+ const refusal = task.reserved ? `reserved peer ${task.reserved} cannot take the planner pull: ${assignment.refusals?.find(row => row.role === "owner" && row.peer === task.reserved)?.reason ?? "not eligible"}; ${this.plannerRefusal(task)}` : this.plannerRefusal(task);
929
+ throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
930
+ }
927
931
  return { task, owner: assignment.owner };
928
932
  };
929
933
  const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
930
934
  authorized();
931
935
  const current = validate();
936
+ if (current.task.reserved) {
937
+ return this.assignOwner(current.task, by, { candidates: [current.owner], namedOwner: current.owner, planner: true, refuseNamed: "pre",
938
+ plannerPull: { revision: input.revision, note: input.reason.trim(), withheld, guard: () => {
939
+ authorized();
940
+ const checked = validate();
941
+ if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
942
+ } } });
943
+ }
932
944
  const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
933
945
  this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
934
946
  return pulled;
@@ -1443,6 +1455,7 @@ export class Tasks {
1443
1455
  if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
1444
1456
  created?.();
1445
1457
  if (screening) task = this.screenedTask(task, screened);
1458
+ task = this.linkOnWrite(task); // #723
1446
1459
  if (goal) this.linkNative(task, goal, by);
1447
1460
  if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
1448
1461
  if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
@@ -1855,7 +1868,9 @@ export class Tasks {
1855
1868
  const task = this.d.board.get(target);
1856
1869
  if (!task) throw new Error(`no task #${target}`);
1857
1870
  if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
1858
- const a = assign(task, this.states(), routing, { exclude: this.excluded(task), excludeOwners: this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
1871
+ const planned = task.state === "backlog" && !!task.reserved || this.plannerPullRecord(task)?.pull === true;
1872
+ const a = planned ? this.plannerAssignment(task, true, routing)
1873
+ : assign(task, this.states(), routing, { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
1859
1874
  // The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
1860
1875
  return [...workflow, `task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, ...(task.profileClass ? [`profile class ${profileTaskLabel(task)}`] : []), ...(task.role ? [`task role ${task.role} (guidance only)`] : []), "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
1861
1876
  }
@@ -1943,14 +1958,17 @@ export class Tasks {
1943
1958
  }
1944
1959
 
1945
1960
  /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
1946
- private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain" } = {}): Promise<Task> {
1947
- if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
1961
+ private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
1962
+ if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
1948
1963
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
1949
1964
  let stagedClaimGap: string | undefined;
1950
1965
  if (mode !== "off" && this.isBuildTask(task)) {
1951
1966
  const gap = task.sdlc?.planReview ? await this.planReviewGap(task) : "plan not reviewed since proposal";
1952
1967
  const current = this.d.board.get(task.id);
1953
- if (!current || !this.ownerAssignmentCurrent(task, current)) return current ?? task;
1968
+ if (!current || !this.ownerAssignmentCurrent(task, current)) {
1969
+ if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull changed during admission");
1970
+ return current ?? task;
1971
+ }
1954
1972
  task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
1955
1973
  if (gap) {
1956
1974
  this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
@@ -1967,9 +1985,10 @@ export class Tasks {
1967
1985
  const waits = this.waitsFor(task);
1968
1986
  const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
1969
1987
  const plannerFull = plannedMove ? this.plannerFull(task) : [];
1970
- const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
1988
+ const a = assign(opts.plannerPull ? { ...task, state: "proposed" } : task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
1971
1989
 
1972
1990
  if (waits.length) {
1991
+ if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
1973
1992
  this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
1974
1993
  return task;
1975
1994
  }
@@ -1995,7 +2014,13 @@ export class Tasks {
1995
2014
  // A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
1996
2015
  // out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
1997
2016
  const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
1998
- const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a), { ...this.reviewMetadata(a), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
2017
+ opts.plannerPull?.guard();
2018
+ const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
2019
+ const assignmentExtra = { ...this.reviewMetadata(a), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
2020
+ const next = opts.plannerPull
2021
+ ? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length)
2022
+ : this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
2023
+ if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
1999
2024
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
2000
2025
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
2001
2026
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -2009,6 +2034,11 @@ export class Tasks {
2009
2034
  // The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
2010
2035
  if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
2011
2036
  try {
2037
+ if (opts.plannerPull && task.offer) {
2038
+ const prior = { ...task, owner: task.offer.owner };
2039
+ this.withdrawRoleRequest(prior, next, "owner");
2040
+ this.releaseNotice(`task #${next.id}: stale offer withdrawal failed`, () => this.d.bus.withdraw(task.offer!.envelopeId));
2041
+ }
2012
2042
  if (by === USER && opts.event === "reassigned") this.withdrawRoleRequest(task, next, "owner");
2013
2043
  const hits = this.overlapHits(next);
2014
2044
  this.formCohort(next, hits);
@@ -2379,6 +2409,25 @@ export class Tasks {
2379
2409
  if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
2380
2410
  }
2381
2411
 
2412
+ /**
2413
+ * #723: link the issue a task names the moment it is proposed or accepted, in advise and enforce, so the lifecycle never
2414
+ * waits for a groom. It is a local parse with no GitHub read, and it never refuses or fails the write it follows.
2415
+ */
2416
+ private linkOnWrite(task: Task): Task {
2417
+ try { return (this.d.sdlc?.config().mode ?? "off") === "off" ? task : this.linkIssue(task); }
2418
+ catch { return task; }
2419
+ }
2420
+
2421
+ /**
2422
+ * #723: a title that qualifies another repository (`agent-mem#13`, `owner/repo#N`) while `refs.repo` is absent cannot link
2423
+ * (the identity needs the repository), so the proposer is told to set it. Advice only; a PII task gets none.
2424
+ */
2425
+ refsRepoHint(task: Task): string | undefined {
2426
+ if (this.isPii(task) || task.refs.repo || task.sdlc?.issue || (this.d.sdlc?.config().mode ?? "off") === "off") return undefined;
2427
+ const names = qualifiedTitleRepos(task.title);
2428
+ return names.length ? `task #${task.id}: the title names ${names.join(", ")}; set refs.repo to that repository (owner/name) so its issue links and the lifecycle shows it` : undefined;
2429
+ }
2430
+
2382
2431
  /** #694: write the one issue the task itself names (the plan-review source) into its lifecycle receipt, once; never relinks. */
2383
2432
  private linkIssue(task: Task): Task {
2384
2433
  if (this.isPii(task) || task.sdlc?.issue) return task;
@@ -2428,12 +2477,16 @@ export class Tasks {
2428
2477
  let issues: number[];
2429
2478
  try { issues = specRefs(task, repository); }
2430
2479
  catch { throw new PreEffectToolRefusal("plan review issue identity unavailable"); }
2480
+ // #723: a title that qualifies the sibling repository `refs.repo` names (`agent-mem#13`) is a local parse, never a read.
2481
+ // A sibling shorthand next to a bare #N is two identities, refused below, never the bare one alone.
2482
+ issues = [...new Set([...issues, ...siblingTitleRefs(task.title, task.refs.repo)])];
2431
2483
  if (issues.length > 1) throw new PreEffectToolRefusal("plan review needs one explicit issue identity");
2432
2484
  return issues[0] ? { repo: repository, issue: issues[0] } : undefined;
2433
2485
  }
2434
2486
 
2435
2487
  async groomingDigest(by: PeerId): Promise<Record<string, unknown>> {
2436
2488
  this.planningAuthority(by);
2489
+ this.backfillIssueLinks(by); // #723: first, bounded, before the slow reads
2437
2490
  const included = (task: Task) => !isClosedTaskState(task.state) || task.state === "cancelled" && task.history.some(entry => !!entry.supersededBy);
2438
2491
  const snapshot = this.d.board.list().filter(included), issues = new Map<string, PlanIssueMetadata>(), attempted = new Set<string>();
2439
2492
  const deadline = performance.now() + 4_000;
@@ -2473,17 +2526,114 @@ export class Tasks {
2473
2526
  const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
2474
2527
  limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
2475
2528
  states, issues, reviewedIssues, acceptedIssues, blockers });
2476
- // #694: backfill lifecycle issue links after every authority check, with their own bound (not the metadata-read
2477
- // budget): each call links up to 32 open tasks that still lack one, so later tasks are reached on later calls.
2478
- if ((this.d.sdlc?.config().mode ?? "off") !== "off") {
2479
- let linked = 0;
2480
- for (const task of this.d.board.list()) {
2481
- if (linked >= 32) break;
2482
- if (isClosedTaskState(task.state) || task.sdlc?.issue || this.isPii(task)) continue;
2483
- if (this.linkIssue(task) !== task) linked++;
2529
+ return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
2530
+ }
2531
+
2532
+ /**
2533
+ * #694, #723: backfill lifecycle issue links with their own bounds, run first and apart from the slow metadata reads and
2534
+ * the digest, so a groom that later times out has still linked. Each call links up to 32 open tasks that still lack one
2535
+ * within a one-second budget, so later tasks are reached on later calls. A local parse; a failure skips that task only.
2536
+ */
2537
+ private backfillCursor = 0;
2538
+ private backfillIssueLinks(by: PeerId): number {
2539
+ if ((this.d.sdlc?.config().mode ?? "off") === "off") return 0;
2540
+ // ponytail: 32 links and 1 s per call; raise both only with a measured groom time on a 400-task board.
2541
+ const deadline = performance.now() + 1_000;
2542
+ let linked = 0, i = 0;
2543
+ const list = this.d.board.list(), n = list.length;
2544
+ // The scan resumes where the last call stopped, so rows that never link cannot hold later ones back.
2545
+ for (; i < n; i++) {
2546
+ if (linked >= 32 || performance.now() >= deadline) break;
2547
+ const task = list[(this.backfillCursor + i) % n]!;
2548
+ if (isClosedTaskState(task.state) || task.sdlc?.issue || this.isPii(task)) continue;
2549
+ this.planningAuthority(by);
2550
+ try { if (this.linkIssue(task) !== task) linked++; } catch { /* one task never stops the rest */ }
2551
+ }
2552
+ this.backfillCursor = n ? (this.backfillCursor + i) % n : 0;
2553
+ return linked;
2554
+ }
2555
+
2556
+ /**
2557
+ * #481 S7: the SDLC metrics from board history, read-only. Private tasks are counted as withheld and read for nothing else; PR
2558
+ * readbacks are not made here (the cost report owns them), so merge time and same-head reruns read `unknown` unless given.
2559
+ * Detection never calls a model and the report writes nothing.
2560
+ */
2561
+ sdlcMetrics(by: PeerId, since?: unknown, merged?: { mergedAt?: Record<number, number>; ciReruns?: Record<number, number | null> }): MetricsReport {
2562
+ this.planningAuthority(by);
2563
+ if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("metrics unavailable during private work");
2564
+ let cutoff: number | undefined;
2565
+ if (since !== undefined && since !== null && since !== "") {
2566
+ cutoff = typeof since === "number" ? since : /^\d+$/.test(String(since)) ? Number(since) : Date.parse(String(since));
2567
+ if (!Number.isFinite(cutoff) || cutoff < 0) throw new PreEffectToolRefusal("since must be a time (YYYY-MM-DD or an ISO timestamp) or milliseconds");
2568
+ }
2569
+ const rows = this.d.board.list();
2570
+ const tasks: MetricTask[] = rows.map(task => ({ id: task.id, private: this.isPii(task), history: task.history.map(entry => ({ at: entry.at, event: entry.event,
2571
+ ...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}) })) }));
2572
+ const visibleIds = new Set(rows.filter(task => !this.isPii(task)).map(task => task.id));
2573
+ const byKind: Record<string, number> = {};
2574
+ for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
2575
+ const stalls = { open: Object.values(byKind).reduce((sum, n) => sum + n, 0), byKind, doneWithoutWrites: rows.filter(task => visibleIds.has(task.id) && task.history.findLast(entry => entry.event === "done")?.noWrites).length };
2576
+ return sdlcMetrics(tasks, { ...(cutoff !== undefined ? { since: cutoff } : {}), ...(this.d.sdlc?.config().bands ? { bands: this.d.sdlc.config().bands } : {}), stalls, ...(merged ?? {}) });
2577
+ }
2578
+
2579
+ /**
2580
+ * #481 S7: act on the band tiers, deterministically and once per breach episode. An episode is a run of non-ok values for one metric
2581
+ * (`BandResult.episode`, derived from history), so a sustained shift is one episode however many tasks finish inside it. `log` is one
2582
+ * hub notice, `diagnose` one read-only `triage` backlog task and `propose` one `plan` backlog card naming the metric and its evidence.
2583
+ * A metric with an open band card (a backlog or open task whose detail carries its key) writes nothing more, so a value that grows
2584
+ * from diagnose to propose inside one episode does not add a second card. Held while the hub is stopping, recovering or has a
2585
+ * storage error, like every host writer. The text is fixed words, numbers and task ids, never task text, and no GitHub issue is
2586
+ * created. Returns the task ids created.
2587
+ */
2588
+ /**
2589
+ * Whether `proposeFromBands` would run now, and a signature of what it would read. The daemon timer records a board revision only for a
2590
+ * pass that ran, and re-runs when the signature changes (a reload that enables bands or edits them on an idle board).
2591
+ */
2592
+ bandsState(): { ready: boolean; signature: string } {
2593
+ const config = this.d.sdlc?.config();
2594
+ const configured = !!config && config.mode !== "off" && !!config.bands && Object.keys(config.bands).length > 0;
2595
+ return { ready: configured && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError, signature: configured ? JSON.stringify([config!.mode, config!.bands]) : "" };
2596
+ }
2597
+
2598
+ proposeFromBands(by: PeerId = HUB): number[] {
2599
+ const config = this.d.sdlc?.config();
2600
+ if (!config || config.mode === "off" || !config.bands || !Object.keys(config.bands).length) return [];
2601
+ if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return [];
2602
+ if (by !== HUB && by !== USER) this.planningAuthority(by);
2603
+ const report = this.sdlcMetrics(by === HUB ? USER : by);
2604
+ if (report.bands === "off") return [];
2605
+ const rows = this.d.board.list();
2606
+ const bandRows = rows.filter(row => row.history[0]?.by === HUB && row.detail?.startsWith("Band breach key: "));
2607
+ const keyOf = (row: Task) => row.detail!.split("\n")[0]!.slice("Band breach key: ".length);
2608
+ const seen = new Set(bandRows.map(keyOf));
2609
+ // A card counts as open while its task is not closed: the planner has not yet decided on it.
2610
+ const open = new Set(bandRows.filter(row => !isClosedTaskState(row.state)).map(row => keyOf(row).split(":")[0]!));
2611
+ const created: number[] = [];
2612
+ for (const band of report.bands as BandResult[]) {
2613
+ if (band.tier === "ok" || band.lastTask === null || band.episode === null) continue;
2614
+ const key = `${band.metric}:#${band.episode}`;
2615
+ if (band.tier === "log") {
2616
+ const marker = `${key}:log`;
2617
+ if (!open.has(band.metric) && this.d.board.recordBandNotice(marker)) this.releaseNotice(`sdlc band: ${band.metric} is ${band.reason}; last value ${band.last} on task #${band.lastTask}`);
2618
+ continue;
2484
2619
  }
2620
+ if (seen.has(`${key}:propose`) || seen.has(`${key}:diagnose`) || open.has(band.metric)) continue;
2621
+ const tier = band.tier === "propose" ? "propose" : "diagnose";
2622
+ const full = `${key}:${tier}`;
2623
+ const evidence = `metric ${band.metric}; newest value ${band.last} on task #${band.lastTask}; episode began at task #${band.episode}; baseline mean ${band.mean}, deviation ${band.sd} over ${band.baseline} tasks; ${band.reason}`;
2624
+ const diagnose = tier === "diagnose";
2625
+ const title = diagnose ? `Diagnose SDLC metric ${band.metric}: read-only triage` : `Review SDLC metric ${band.metric}: control band breach`;
2626
+ const detail = `Band breach key: ${full}\n${evidence}\n${diagnose
2627
+ ? "Read-only triage for the planner: inspect the recent task histories behind this metric and report what changed. Do not edit files, branches or the board."
2628
+ : "Backlog card for the planner: decide whether this breach needs a process change. It names the metric and its evidence; it opens no GitHub issue."}`;
2629
+ const draft = { title, detail, refs: {} };
2630
+ const signals = this.signalsFor(draft);
2631
+ if (this.isPii({ signals }) || !this.candidateTextAllowed(`${title}\n${detail}`)) continue;
2632
+ const proposal = this.d.board.propose(HUB, { ...draft, class: diagnose ? "triage" : "plan", backlog: true, signals });
2633
+ this.incomingBacklog.add(proposal.id); seen.add(full); open.add(band.metric); created.push(proposal.id);
2485
2634
  }
2486
- return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
2635
+ for (const id of created) this.releaseNotice(`task #${id}: SDLC control band breach proposed in the backlog`);
2636
+ return created;
2487
2637
  }
2488
2638
 
2489
2639
  /** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
@@ -2997,7 +3147,7 @@ export class Tasks {
2997
3147
  this.warmRepository(draft);
2998
3148
  const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
2999
3149
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
3000
- const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) });
3150
+ const next = this.linkOnWrite(this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) })); // #723: refs.repo may have just arrived
3001
3151
  this.captureOwnerAcceptance(next);
3002
3152
  this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
3003
3153
  if (repositoryChanged) this.cohorts.leave(next.id);
package/src/hub/ui.ts CHANGED
@@ -25,6 +25,9 @@ const TICKET_MS = 60_000;
25
25
  const SESSION_MS = 60 * 60_000;
26
26
  /** How long a session opened by `ahub ui --settings` may change settings that raise authority; it stays an ordinary session after. */
27
27
  export const SETTINGS_MS = 15 * 60_000;
28
+ /** The page's view names (index.html DASHBOARD_VIEWS): the only values a snapshot request's `view` may carry to a project hub. */
29
+ export const DASHBOARD_VIEWS = ["board", "office", "stream", "agents", "message", "memory", "settings"] as const;
30
+ export const dashboardView = (value: unknown): typeof DASHBOARD_VIEWS[number] | undefined => (DASHBOARD_VIEWS as readonly unknown[]).includes(value) ? value as typeof DASHBOARD_VIEWS[number] : undefined;
28
31
  const LIMIT = 64;
29
32
  const secret = () => randomBytes(32).toString("hex");
30
33
 
@@ -65,6 +65,10 @@ export interface TotalRow { peer: string; tokens: Counter; withoutCache: Counter
65
65
  /** `unknownPeers`: the peers whose figure is unknown, which the overall figure then leaves out (so it is `partial`, a lower bound, never a sum that treats them as 0). */
66
66
  export interface TotalResult { since: string | null; peers: TotalRow[]; overall: { tokens: Counter; withoutCache: Counter; cacheRead: Counter }; unknownPeers: { tokens: string[]; withoutCache: string[]; cacheRead: string[] } }
67
67
 
68
+ /** `empty`: no record at all for that UTC day (a gap), shown as an empty column and never as a zero. */
69
+ export interface DailyRow { day: string; peers: Record<string, Counter>; total: Counter; empty?: true }
70
+ export interface DailyResult { days: DailyRow[]; peers: string[] }
71
+
68
72
  const sum = (a: number | null, b: number | null): number | null => a === null ? b : b === null ? a : a + b;
69
73
  const counter = (value: number | null, n: number, source: UsageSource = "native"): Counter => n > 0 && value !== null ? { value, source } : { value: null, source: "unknown" };
70
74
 
@@ -350,6 +354,60 @@ export class UsageRollup {
350
354
  unknownPeers: { tokens: unknownPeers(r => r.tokens), withoutCache: unknownPeers(r => r.withoutCache), cacheRead: unknownPeers(r => r.cacheRead) } };
351
355
  }
352
356
 
357
+ /**
358
+ * Daily tokens per peer for the last `days` calendar UTC days up to `now`, stacked for the dashboard Usage view (#683 U4).
359
+ * For each day and peer, tokens is native when reported, counted increments when available, otherwise unknown. A day inside
360
+ * the window with no record is an `empty` row (a gap the chart shows), and the window never starts before the first record.
361
+ */
362
+ daily(days = 14, now: number = Date.now()): DailyResult {
363
+ const raw = this.db.query(`SELECT day, peer, SUM(total) AS total, SUM(total_n) AS total_n FROM usage_rollup GROUP BY day, peer ORDER BY day ASC`).all() as any[];
364
+ const counted = this.db.query(`SELECT day, peer, SUM(tokens) AS tokens, SUM(increments) AS increments FROM token_rollup GROUP BY day, peer ORDER BY day ASC`).all() as any[];
365
+ const usageMap = new Map<string, { total: number; total_n: number }>();
366
+ for (const r of raw) usageMap.set(`${r.day}\0${r.peer}`, { total: r.total, total_n: r.total_n });
367
+ const countMap = new Map<string, { tokens: number; increments: number }>();
368
+ for (const r of counted) countMap.set(`${r.day}\0${r.peer}`, { tokens: r.tokens, increments: r.increments });
369
+
370
+ const allDays = [...new Set([...raw.map(r => r.day as string), ...counted.map(r => r.day as string)])].sort();
371
+ const allPeers = [...new Set([...raw.map(r => r.peer as string), ...counted.map(r => r.peer as string)])].sort();
372
+ // The last `days` calendar days ending today, clipped to the first day that has any record.
373
+ const dayMs = 86_400_000, today = new Date(now).toISOString().slice(0, 10);
374
+ const sliceDays: string[] = [];
375
+ if (allDays.length) for (let i = Math.max(1, Math.min(days, 366)) - 1; i >= 0; i--) {
376
+ const day = new Date(Date.parse(today + "T00:00:00Z") - i * dayMs).toISOString().slice(0, 10);
377
+ if (day >= allDays[0]!) sliceDays.push(day);
378
+ }
379
+
380
+ const resultDays: DailyRow[] = [];
381
+ for (const d of sliceDays) {
382
+ const peerCounters: Record<string, Counter> = {};
383
+ const knownCounters: Counter[] = [];
384
+ for (const p of allPeers) {
385
+ const u = usageMap.get(`${d}\0${p}`);
386
+ const c = countMap.get(`${d}\0${p}`);
387
+ let counterVal: Counter = { value: null, source: "unknown" };
388
+ if (u && u.total_n > 0) {
389
+ counterVal = counter(u.total, u.total_n);
390
+ } else if (c && c.increments > 0) {
391
+ counterVal = { value: c.tokens, source: "count" };
392
+ }
393
+ peerCounters[p] = counterVal;
394
+ if (counterVal.value !== null) knownCounters.push(counterVal);
395
+ }
396
+ let dayTotal: Counter = { value: null, source: "unknown" };
397
+ if (knownCounters.length > 0) {
398
+ const sumVal = knownCounters.reduce((acc, cur) => acc + cur.value!, 0);
399
+ const source: UsageSource = knownCounters.length !== allPeers.length
400
+ ? "partial"
401
+ : knownCounters.every(cur => cur.source === "native")
402
+ ? "native"
403
+ : "count";
404
+ dayTotal = { value: sumVal, source };
405
+ }
406
+ resultDays.push({ day: d, peers: peerCounters, total: dayTotal, ...(!allDays.includes(d) ? { empty: true as const } : {}) });
407
+ }
408
+ return { days: resultDays, peers: allPeers };
409
+ }
410
+
353
411
  /** Every roll-up row, in key order, for the rebuild-equals-incremental test. */
354
412
  dump(): unknown {
355
413
  const all = (table: string, order: string) => this.db.query(`SELECT * FROM ${table} ORDER BY ${order}`).all();