@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.
- package/CHANGELOG.md +13 -0
- package/README.md +6 -4
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/daemon.md +8 -1
- package/docs/agent-notes/tasks.md +7 -4
- package/docs/agent-notes/tests.md +1 -1
- package/docs/dogfood.md +10 -7
- package/docs/operations.md +45 -13
- package/docs/quickstart.md +6 -2
- package/docs/sdlc.md +23 -2
- package/docs/security.md +9 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +38 -0
- 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/verified.json +20 -15
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +91 -20
- package/src/adapters/codex-review-isolation.ts +18 -7
- package/src/adapters/definitions/builtin-metadata.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/reviewer-print.ts +34 -7
- package/src/cli/help.ts +2 -1
- package/src/cli/main.ts +5 -0
- package/src/cli/output.ts +47 -2
- package/src/cli/status-lines.ts +1 -1
- package/src/hub/board.ts +22 -1
- package/src/hub/daemon.ts +84 -14
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/instances.ts +14 -4
- package/src/hub/manager.ts +2 -2
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc.ts +5 -3
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/tasks.ts +172 -22
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +58 -0
- 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,
|
|
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
|
-
|
|
925
|
-
|
|
926
|
-
if (
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
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
|
-
|
|
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
|
|
package/src/hub/usage-rollup.ts
CHANGED
|
@@ -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();
|