@staix/agent-hub 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +16 -6
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +17 -3
- package/docs/agent-notes/budget.md +4 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +14 -9
- package/docs/agent-notes/tasks.md +9 -4
- package/docs/agent-notes/tests.md +3 -3
- package/docs/dogfood.md +9 -4
- package/docs/events.md +21 -7
- package/docs/operations.md +90 -18
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +9 -3
- package/docs/sdlc.md +17 -0
- package/docs/security.md +37 -10
- package/docs/specs/2026-09-19-agent-hub-design.md +74 -10
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verified.json +29 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +992 -312
- package/src/adapters/acp.ts +43 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/compat.ts +4 -0
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +14 -2
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/registry-types.ts +7 -3
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +30 -53
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +28 -1
- package/src/cli/console.ts +25 -1
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +4 -3
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +47 -9
- package/src/cli/output.ts +28 -0
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-enforce.ts +82 -0
- package/src/hub/action-policy.ts +22 -10
- package/src/hub/board.ts +26 -2
- package/src/hub/budget.ts +5 -0
- package/src/hub/bus.ts +5 -5
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/config-apply.ts +5 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +368 -59
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/events.ts +3 -1
- package/src/hub/hub-tools.ts +19 -3
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +144 -0
- package/src/hub/manager.ts +1 -1
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/routing.ts +118 -3
- package/src/hub/settings.ts +1 -1
- package/src/hub/tasks.ts +202 -15
- package/src/hub/token-window.ts +33 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/workflow.ts +16 -0
- package/src/ui/index.html +115 -1
package/src/hub/tasks.ts
CHANGED
|
@@ -21,7 +21,9 @@ import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
|
|
|
21
21
|
import type { MemoryScreen } from "./read-tools.ts";
|
|
22
22
|
import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
23
23
|
import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
|
|
24
|
-
import { criteriaView, workflowHash } from "./workflow.ts";
|
|
24
|
+
import { criteriaView, unmetCriteriaIds, workflowHash } from "./workflow.ts";
|
|
25
|
+
import { approvedDeliverables, latestDeliverables, parseDeliverableClaims, publicDeliverables, type DeliverableRecord, type DeliverableStatus } from "./deliverables.ts";
|
|
26
|
+
import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverables, type DeliverablePorts } from "./deliverable-files.ts";
|
|
25
27
|
import { isAbsolute, relative } from "node:path";
|
|
26
28
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
27
29
|
import type { MemoryClient } from "../memory/client.ts";
|
|
@@ -66,6 +68,13 @@ export interface TasksDeps {
|
|
|
66
68
|
profile?: ProfileName;
|
|
67
69
|
profileData?: ProjectProfile;
|
|
68
70
|
piiPaths?: string[];
|
|
71
|
+
/** The project's secrets denylist (`local.deny`), for the deliverable file reader (#509a). */
|
|
72
|
+
deny?: string[];
|
|
73
|
+
/**
|
|
74
|
+
* #509a: the root a peer's files live under: its verified execution root (the daemon's `ownerWriteRoot`), which for a declared worktree
|
|
75
|
+
* instance is not the project checkout. `undefined` means the root is not proven right now. Absent (library use): the project root.
|
|
76
|
+
*/
|
|
77
|
+
executionRoot?: (peer: PeerId) => string | undefined;
|
|
69
78
|
catalog?: RoleCatalog;
|
|
70
79
|
/** Role ids whose contract was prepared for the current native session. */
|
|
71
80
|
sessionRoles?: (peer: PeerId) => string[];
|
|
@@ -92,6 +101,8 @@ export interface TasksDeps {
|
|
|
92
101
|
reviewOnly?: (peer: PeerId) => boolean;
|
|
93
102
|
/** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
|
|
94
103
|
reviewAuthority?: (by: PeerId, task: number) => (() => string | undefined) | undefined;
|
|
104
|
+
/** Host-captured current-child authority, rechecked before an admitted async tool first commits effects. */
|
|
105
|
+
requestAuthority?: (by: PeerId) => (() => string | undefined) | undefined;
|
|
95
106
|
memory?: MemoryClient;
|
|
96
107
|
/** Captured native profile fence at our memory operation boundaries; no board mutation. */
|
|
97
108
|
memoryCheck?: () => void;
|
|
@@ -183,6 +194,11 @@ const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
|
183
194
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
|
|
184
195
|
QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
|
|
185
196
|
QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
|
|
197
|
+
/** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
|
|
198
|
+
const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
|
|
199
|
+
const checking = task.history.findLastIndex(entry => entry.event === "done (checking)");
|
|
200
|
+
return checking >= 0 && !task.history.slice(checking + 1).some(entry => entry.event === "done") ? task.history[checking]!.deliverables : undefined;
|
|
201
|
+
};
|
|
186
202
|
|
|
187
203
|
/**
|
|
188
204
|
* Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
|
|
@@ -358,6 +374,12 @@ export class Tasks {
|
|
|
358
374
|
};
|
|
359
375
|
|
|
360
376
|
/** One bounded screen call. A screener that fails is no verdict, handled as PII (fail closed). */
|
|
377
|
+
private requestCheck(by: PeerId): () => void {
|
|
378
|
+
const authority = this.d.requestAuthority?.(by);
|
|
379
|
+
const check = () => { const reason = authority?.(); if (reason) throw new PreEffectToolRefusal(reason); };
|
|
380
|
+
check(); return check;
|
|
381
|
+
}
|
|
382
|
+
|
|
361
383
|
private async screenCall(text: string): Promise<PiiVerdict> {
|
|
362
384
|
return (await this.d.piiScreen?.(text).catch(() => undefined)) ?? { label: "unknown", miss: "failed", ms: 0 };
|
|
363
385
|
}
|
|
@@ -559,6 +581,31 @@ export class Tasks {
|
|
|
559
581
|
}
|
|
560
582
|
|
|
561
583
|
/** Host-authored proposals contain only cleared IDs, integer task references and fixed instruction targets. */
|
|
584
|
+
/**
|
|
585
|
+
* #736: how many separate pieces of work the tasks are. Tasks that share a PR, a branch in one repository, or a
|
|
586
|
+
* supersession (a cancelled task names its replacement) are one piece of work, as an approve-as-step follow-up is: a
|
|
587
|
+
* finding carried between them is the same finding, not a recurrence.
|
|
588
|
+
*/
|
|
589
|
+
private workUnits(rows: readonly Task[]): number {
|
|
590
|
+
const parent = new Map<number, number>();
|
|
591
|
+
const find = (id: number): number => { let at = id; while (parent.has(at)) at = parent.get(at)!; return at; };
|
|
592
|
+
const join = (a: number, b: number) => { const x = find(a), y = find(b); if (x !== y) parent.set(x, y); };
|
|
593
|
+
const first = new Map<string, number>(), ids = new Set(rows.map(row => row.id));
|
|
594
|
+
for (const row of rows) {
|
|
595
|
+
// Repository identity comes from the board's own resolver, so aliases match and an outside path never falls back to this project.
|
|
596
|
+
const prRepo = row.sdlc?.pr ? this.repositories.scope(row.sdlc.repo ? { repo: row.sdlc.repo, paths: row.refs.paths } : row.refs).key : "";
|
|
597
|
+
const branchRepo = row.refs.branch ? this.repositories.scope(row.refs).key : "";
|
|
598
|
+
for (const key of [prRepo ? `pr ${prRepo}#${row.sdlc!.pr}` : "", branchRepo ? `branch ${branchRepo} ${row.refs.branch}` : ""]) {
|
|
599
|
+
if (!key) continue;
|
|
600
|
+
const seen = first.get(key);
|
|
601
|
+
if (seen === undefined) first.set(key, row.id); else join(row.id, seen);
|
|
602
|
+
}
|
|
603
|
+
// superseded_by is free text ("task #73", "#73" or "73"); the first task number in it names the replacement.
|
|
604
|
+
const next = Number(/#?(\d+)/.exec(row.cancelled?.supersededBy ?? "")?.[1]);
|
|
605
|
+
if (ids.has(next)) join(row.id, next);
|
|
606
|
+
}
|
|
607
|
+
return new Set(rows.map(row => find(row.id))).size;
|
|
608
|
+
}
|
|
562
609
|
private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
|
|
563
610
|
if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
|
|
564
611
|
const rows = this.d.board.list();
|
|
@@ -574,7 +621,7 @@ export class Tasks {
|
|
|
574
621
|
const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
|
|
575
622
|
return !last?.withheld && finding && same(finding) && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
|
|
576
623
|
});
|
|
577
|
-
if (
|
|
624
|
+
if (this.workUnits(holders) < 2) continue;
|
|
578
625
|
const key = `Review finding key: ${item.pass}:${item.id}`;
|
|
579
626
|
if (keys.has(key)) continue;
|
|
580
627
|
const title = `Review policy amendment for recurring finding ${item.pass}:${item.id}`;
|
|
@@ -608,6 +655,8 @@ export class Tasks {
|
|
|
608
655
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
609
656
|
...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
|
|
610
657
|
...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
|
|
658
|
+
...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
|
|
659
|
+
...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
|
|
611
660
|
...(h.reviewBlocked ? { reviewBlocked: true } : {}),
|
|
612
661
|
...(h.reviewerFailure ? { reviewerFailure: { peer: screenHistoryText(h.reviewerFailure.peer, "reviewer"), cause: h.reviewerFailure.cause, ...(h.reviewerFailure.head ? { head: screenHistoryText(h.reviewerFailure.head, "review head") } : {}) } } : {}),
|
|
613
662
|
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
@@ -745,10 +794,19 @@ export class Tasks {
|
|
|
745
794
|
}
|
|
746
795
|
|
|
747
796
|
/** What assignment weighs besides states and policy: quota, demotion and the review record. */
|
|
797
|
+
/**
|
|
798
|
+
* #525-3: the settled planner holders, sorted: the one input of the planner walk. It comes from the same settled role map as planner
|
|
799
|
+
* authority (`d.roles`), restricted to rostered peers, so assignment, planner notices and `route explain` agree on who the holders are.
|
|
800
|
+
* Read-only for now: the walk's result is shown and recorded nowhere until 525-2.
|
|
801
|
+
*/
|
|
802
|
+
private plannerHolders(): PeerId[] {
|
|
803
|
+
return Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => Array.isArray(roles) && roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer).sort();
|
|
804
|
+
}
|
|
805
|
+
|
|
748
806
|
private weights(cls: TaskClass, task?: Task) {
|
|
749
807
|
const now = Date.now();
|
|
750
808
|
const quota = this.d.quota?.();
|
|
751
|
-
return { ...this.health(), ...(task ? { reviewPool: this.reviewPoolOptions(task) } : {}), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
809
|
+
return { ...this.health(), planners: this.plannerHolders(), ...(task ? { reviewPool: this.reviewPoolOptions(task) } : {}), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
752
810
|
}
|
|
753
811
|
|
|
754
812
|
/**
|
|
@@ -1382,6 +1440,7 @@ export class Tasks {
|
|
|
1382
1440
|
}
|
|
1383
1441
|
|
|
1384
1442
|
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; backlog?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void): Promise<Task> {
|
|
1443
|
+
const requestCheck = this.requestCheck(by);
|
|
1385
1444
|
external = external ? { ...external } : undefined;
|
|
1386
1445
|
if (external && (by !== USER || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
|
|
1387
1446
|
if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
|
|
@@ -1426,6 +1485,7 @@ export class Tasks {
|
|
|
1426
1485
|
// board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
|
|
1427
1486
|
const screening = this.screenOn();
|
|
1428
1487
|
const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, detail: screenDetail, plan })) : undefined;
|
|
1488
|
+
requestCheck();
|
|
1429
1489
|
if (screened && screened.label !== "clear") signals.push("pii");
|
|
1430
1490
|
let cls = baseGiven;
|
|
1431
1491
|
let triaged = false;
|
|
@@ -1433,8 +1493,11 @@ export class Tasks {
|
|
|
1433
1493
|
if (!cls && this.d.triage) {
|
|
1434
1494
|
// Signals first: a PII task's text may only go to a model that is reached without leaving the campus network.
|
|
1435
1495
|
const pii = this.isPii({ signals });
|
|
1436
|
-
|
|
1496
|
+
const reached = !pii || await this.d.triage.onCampus().catch(() => false);
|
|
1497
|
+
requestCheck();
|
|
1498
|
+
if (reached) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
|
|
1437
1499
|
else fenced = true;
|
|
1500
|
+
requestCheck();
|
|
1438
1501
|
triaged = !!cls;
|
|
1439
1502
|
}
|
|
1440
1503
|
// A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
|
|
@@ -1450,6 +1513,7 @@ export class Tasks {
|
|
|
1450
1513
|
if (reserved) this.requireCapabilities({ ...draft, signals, ...(external ? { external } : {}) }, reserved);
|
|
1451
1514
|
this.warmRepository({ refs: draft.refs, plan });
|
|
1452
1515
|
const repositoryMismatch = !this.isPii({ signals }) && this.repositories.mismatch(draft.refs, plan);
|
|
1516
|
+
requestCheck();
|
|
1453
1517
|
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
|
|
1454
1518
|
if (repositoryMismatch) this.releaseNotice(`task #${task.id}: refs and plan name different repositories; check refs.repo before editing`);
|
|
1455
1519
|
if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
|
|
@@ -2741,7 +2805,23 @@ export class Tasks {
|
|
|
2741
2805
|
}
|
|
2742
2806
|
|
|
2743
2807
|
/** Artifact links and claimed local gates stay on the board; no outside text is accepted. */
|
|
2744
|
-
|
|
2808
|
+
/**
|
|
2809
|
+
* `hub_sdlc_record` (#509a): the receipt may also name `deliverables`. They are split off here, claims vetted and the files hashed by the hub,
|
|
2810
|
+
* and only then is the rest recorded as before. A receipt with deliverables and nothing else records just the deliverables.
|
|
2811
|
+
*/
|
|
2812
|
+
async recordSdlc(by: PeerId, id: unknown, input: unknown): Promise<Task> {
|
|
2813
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || !("deliverables" in input)) return this.recordLifecycle(by, id, input);
|
|
2814
|
+
const { deliverables, ...receipt } = input as Record<string, unknown>;
|
|
2815
|
+
const task = this.need(id, false, true);
|
|
2816
|
+
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
2817
|
+
const claims = this.deliverableClaims(deliverables, task);
|
|
2818
|
+
const records = await this.hashClaims(claims, task, true);
|
|
2819
|
+
const current = this.need(id, false, true);
|
|
2820
|
+
if (current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed while its deliverables were hashed; record them again");
|
|
2821
|
+
return this.recordLifecycle(by, id, receipt, records);
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
recordLifecycle(by: PeerId, id: unknown, input: unknown, deliverables?: DeliverableRecord[]): Task {
|
|
2745
2825
|
const task = this.need(id, false, true);
|
|
2746
2826
|
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
2747
2827
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
@@ -2771,7 +2851,7 @@ export class Tasks {
|
|
|
2771
2851
|
if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
|
|
2772
2852
|
const departure = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
|
|
2773
2853
|
const invalidatesDeparture = !!departure && (artifactChanged || next.localHead !== task.sdlc?.localHead && next.localHead !== undefined && next.localHead !== departure.head || next.reviewedHead !== task.sdlc?.reviewedHead && next.reviewedHead !== undefined && next.reviewedHead !== departure.head);
|
|
2774
|
-
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}) });
|
|
2854
|
+
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}), ...(deliverables ? { deliverables } : {}) });
|
|
2775
2855
|
}
|
|
2776
2856
|
|
|
2777
2857
|
async deliveryQueue(repo: string) {
|
|
@@ -2981,7 +3061,9 @@ export class Tasks {
|
|
|
2981
3061
|
const task = this.need(id, false, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2982
3062
|
if (config.mode === "off" || by === USER || !input || typeof input !== "object" || !("stage" in input)) return;
|
|
2983
3063
|
let next: ReturnType<typeof sdlcReceipt>;
|
|
2984
|
-
|
|
3064
|
+
// #509a: `deliverables` ride in the receipt but are not a lifecycle field: recordSdlc splits them off, so the gate reads the same receipt it will record.
|
|
3065
|
+
const { deliverables: _carried, ...lifecycleInput } = input as Record<string, unknown>;
|
|
3066
|
+
try { next = sdlcReceipt(lifecycleInput, task.sdlc, config, VERSION); } catch { return; } // recordLifecycle reports an invalid receipt
|
|
2985
3067
|
const definition = stageDefinition(config, next.stage);
|
|
2986
3068
|
if (next.stage === task.sdlc?.stage || !definition || canonicalStage(definition.base) !== "build") return;
|
|
2987
3069
|
const accepted = next.acceptance;
|
|
@@ -3059,6 +3141,7 @@ export class Tasks {
|
|
|
3059
3141
|
return base === "build" || base === "implement";
|
|
3060
3142
|
}
|
|
3061
3143
|
async acceptReviewed(by: PeerId, id: unknown, plan?: unknown, refs?: unknown): Promise<Task> {
|
|
3144
|
+
const requestCheck = this.requestCheck(by);
|
|
3062
3145
|
const metadata = acceptRefs(refs);
|
|
3063
3146
|
const task = this.need(id, false, true);
|
|
3064
3147
|
this.mine(task, by, "owner", true);
|
|
@@ -3069,6 +3152,7 @@ export class Tasks {
|
|
|
3069
3152
|
if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "off" || !this.isBuildTask(task)) return this.accept(by, id, plan, metadata);
|
|
3070
3153
|
await this.specGate(by, id);
|
|
3071
3154
|
await this.planEntryGate(by, draft);
|
|
3155
|
+
requestCheck();
|
|
3072
3156
|
const current = this.need(id, false, true);
|
|
3073
3157
|
this.mine(current, by, "owner", true);
|
|
3074
3158
|
if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during build admission");
|
|
@@ -3225,6 +3309,59 @@ export class Tasks {
|
|
|
3225
3309
|
return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
|
|
3226
3310
|
}
|
|
3227
3311
|
|
|
3312
|
+
/**
|
|
3313
|
+
* #509a: the task's deliverables as they are now against what was delivered and what was approved. `changedSinceReview` is true only for a file
|
|
3314
|
+
* measured to differ from the bytes a reviewer approved (or, before approval, from the bytes delivered); a file the hub cannot read is
|
|
3315
|
+
* `unknown`, never `unchanged`. Ids, hashes and sizes only for a private record. Undefined when the task delivered nothing.
|
|
3316
|
+
*/
|
|
3317
|
+
async deliverableStatus(task: Task): Promise<{ state: "none" | "delivered" | "approved"; changedSinceReview: boolean; files: DeliverableStatus[] } | undefined> {
|
|
3318
|
+
const delivered = latestDeliverables(task.history);
|
|
3319
|
+
if (!delivered) return undefined;
|
|
3320
|
+
const approved = task.state === "approved" ? approvedDeliverables(task.history) : undefined;
|
|
3321
|
+
const basis = approved ?? delivered;
|
|
3322
|
+
const checks = await this.checkRecords(basis, task);
|
|
3323
|
+
const shown = publicDeliverables(basis, this.nameable)!;
|
|
3324
|
+
const files = checks.map((check, i): DeliverableStatus => {
|
|
3325
|
+
const { sha256, id, format, version, path, private: hidden } = shown[i]!;
|
|
3326
|
+
return { id, sha256, state: check.state, ...(path ? { path } : {}), format, ...(version ? { version } : {}), ...(hidden ? { private: true as const } : {}) };
|
|
3327
|
+
});
|
|
3328
|
+
return { state: approved ? "approved" : "delivered", changedSinceReview: files.some(file => file.state === "changed"), files };
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
/** Check records against the owner's root; with that root unproven every file is `unknown` (never `unchanged`, never an exception). */
|
|
3332
|
+
private async checkRecords(records: DeliverableRecord[], task: Task) {
|
|
3333
|
+
try { return await checkDeliverables(records, this.deliverablePorts(task)); }
|
|
3334
|
+
catch (error) { return records.map(record => ({ record, state: "unknown" as const, why: error instanceof Error ? error.message : "unreadable" })); }
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
/** What the deliverable reader needs from the host: the project root, the secrets denylist, and which files make a record private (#509a). */
|
|
3338
|
+
private deliverablePorts(task: Task): DeliverablePorts {
|
|
3339
|
+
const taskPrivate = this.isPii(task);
|
|
3340
|
+
// The owner's own root: a declared worktree instance writes under its worktree, so its deliverables are hashed there, never in the canonical
|
|
3341
|
+
// checkout (which holds a different file or none). With the root unproven there is nothing safe to hash: refuse rather than guess.
|
|
3342
|
+
const root = !this.d.executionRoot || !task.owner || task.owner === USER ? this.d.cwd : this.d.executionRoot(task.owner);
|
|
3343
|
+
if (root === undefined) throw new DeliverableRefusal(`deliverables: ${task.owner}'s execution root is not verified right now, so its files cannot be hashed`);
|
|
3344
|
+
return { cwd: root, deny: this.d.deny ?? [], private: path => taskPrivate || matchesPolicy(root, path, this.d.piiPaths ?? [], true) };
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
/**
|
|
3348
|
+
* #509a: parse and vet a call's deliverable claims before any effect. A claimed hash is a refusal (the hub hashes the files itself), and so is a
|
|
3349
|
+
* path outside the project, on the denylist, inside the hub's own state, not a regular file, missing or too large. Nothing is hashed or stored here.
|
|
3350
|
+
*/
|
|
3351
|
+
private deliverableClaims(input: unknown, task: Task, preEffect = true) {
|
|
3352
|
+
try {
|
|
3353
|
+
const claims = parseDeliverableClaims(input);
|
|
3354
|
+
vetDeliverables(claims, this.deliverablePorts(task));
|
|
3355
|
+
return claims;
|
|
3356
|
+
} catch (error) { throw new (preEffect ? PreEffectToolRefusal : Error)(error instanceof Error ? error.message : "invalid deliverables"); }
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
/** Hash vetted claims now. A refusal during the read (the file changed while it was read) is still before any write. */
|
|
3360
|
+
private async hashClaims(claims: ReturnType<Tasks["deliverableClaims"]>, task: Task, preEffect = true): Promise<DeliverableRecord[]> {
|
|
3361
|
+
try { return await hashDeliverables(claims, this.deliverablePorts(task)); }
|
|
3362
|
+
catch (error) { throw new (preEffect ? PreEffectToolRefusal : Error)(error instanceof DeliverableRefusal || error instanceof Error ? error.message : "deliverables could not be hashed"); }
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3228
3365
|
/** Tasks whose check is queued or running, and the owner it was started for. */
|
|
3229
3366
|
private readonly checking = new Map<number, PeerId | null>();
|
|
3230
3367
|
private checkQueue: Promise<void> = Promise.resolve();
|
|
@@ -3252,11 +3389,17 @@ export class Tasks {
|
|
|
3252
3389
|
return task;
|
|
3253
3390
|
}
|
|
3254
3391
|
|
|
3255
|
-
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
|
|
3256
|
-
const
|
|
3392
|
+
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown): Promise<Task> {
|
|
3393
|
+
const requestCheck = this.requestCheck(by);
|
|
3394
|
+
const valid = () => { requestCheck(); return this.completionCurrent(id, by, true); };
|
|
3395
|
+
// #509a: a bad claim (a claimed hash, an outside or denied path, a missing file) is refused before any effect, with nothing recorded.
|
|
3396
|
+
const claims = deliverables === undefined ? undefined : this.deliverableClaims(deliverables, valid());
|
|
3257
3397
|
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
3258
3398
|
const withheld = await this.withholds("summary", summary, valid());
|
|
3259
3399
|
let task = valid();
|
|
3400
|
+
// The hub hashes the files itself, once, here: what the owner delivered is what this read, whatever the file becomes afterwards.
|
|
3401
|
+
const records = claims ? await this.hashClaims(claims, task, true) : undefined;
|
|
3402
|
+
task = valid();
|
|
3260
3403
|
// #481 S1: done without a separate accept starts build work too: the same live spec check as accept, before any write.
|
|
3261
3404
|
if (task.state === "proposed" || task.state === "changes_requested") {
|
|
3262
3405
|
const admission = task;
|
|
@@ -3302,12 +3445,12 @@ export class Tasks {
|
|
|
3302
3445
|
}
|
|
3303
3446
|
if (task.state === "proposed" || task.state === "changes_requested") task = this.withLink(this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE), link, refs); // done without a separate accept
|
|
3304
3447
|
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
3305
|
-
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link);
|
|
3448
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck);
|
|
3306
3449
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
3307
3450
|
this.checking.set(task.id, task.owner);
|
|
3308
3451
|
const checkedRefs = cleanRefs(refs);
|
|
3309
3452
|
this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
|
|
3310
|
-
const pending = this.d.board.update(task.id, by, "done (checking)", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...checkedRefs } }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined }, task.history.length, HOST_CODE_SYMBOLS);
|
|
3453
|
+
const pending = this.d.board.update(task.id, by, "done (checking)", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...checkedRefs } }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined, ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
3311
3454
|
this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
3312
3455
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
3313
3456
|
this.pendingChecks++;
|
|
@@ -3438,11 +3581,15 @@ export class Tasks {
|
|
|
3438
3581
|
}
|
|
3439
3582
|
|
|
3440
3583
|
/** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
|
|
3441
|
-
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink): Promise<Task> {
|
|
3584
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink, records?: DeliverableRecord[], requestCheck: () => void = () => {}): Promise<Task> {
|
|
3585
|
+
requestCheck();
|
|
3586
|
+
// A check's completion carries the records its `done (checking)` bound: the same bytes the owner delivered, not a later read.
|
|
3587
|
+
records ??= latestPendingDeliverables(task);
|
|
3442
3588
|
let selection = this.poolReview(task, refs);
|
|
3443
3589
|
let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
|
|
3444
3590
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
3445
3591
|
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
3592
|
+
requestCheck();
|
|
3446
3593
|
task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
|
|
3447
3594
|
selection = this.poolReview(task, refs);
|
|
3448
3595
|
reviewer = selection?.reviewer ?? task.reviewer;
|
|
@@ -3450,6 +3597,7 @@ export class Tasks {
|
|
|
3450
3597
|
// A delayed local check needs a new readback; a completion-selected reviewer also needs optional facts in off mode.
|
|
3451
3598
|
if (!admission && ((reviewer && (this.d.sdlc?.config().mode ?? "off") !== "off") || this.d.prepareReviewBrief)) {
|
|
3452
3599
|
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !reviewer);
|
|
3600
|
+
requestCheck();
|
|
3453
3601
|
task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
|
|
3454
3602
|
}
|
|
3455
3603
|
selection = this.poolReview(task, refs);
|
|
@@ -3460,7 +3608,7 @@ export class Tasks {
|
|
|
3460
3608
|
const completedRefs = cleanRefs(refs);
|
|
3461
3609
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
3462
3610
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
3463
|
-
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
3611
|
+
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
3464
3612
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
3465
3613
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
3466
3614
|
this.tellCompleted(next, summary, withheld);
|
|
@@ -3650,7 +3798,19 @@ export class Tasks {
|
|
|
3650
3798
|
const checklistLines = checklistTemplates.map(template =>
|
|
3651
3799
|
template.replaceAll("{target}", target).replaceAll("{check}", checkResult)
|
|
3652
3800
|
);
|
|
3653
|
-
|
|
3801
|
+
// #507b: the task's recorded acceptance criteria (the #481 S8 checklist, ids and all). A PII task shows none: its checklist is private.
|
|
3802
|
+
const recorded = this.isPii(task) ? undefined : checklistOf(task.sdlc);
|
|
3803
|
+
const criteriaBlock = recorded?.criteria.length
|
|
3804
|
+
? ["Acceptance criteria (name an id at the start of an unmet item, such as \"AC1: what is missing\"):",
|
|
3805
|
+
...recorded.criteria.map(c => `- ${c.id}${c.required ? "" : " (optional)"}: ${c.result}`)].join("\n") + "\n"
|
|
3806
|
+
: "";
|
|
3807
|
+
// #509a: what was delivered, by id, hash and size. A private record (a PII task, or a path under pii.paths) shows no path.
|
|
3808
|
+
const delivered = publicDeliverables(latestDeliverables(task.history), this.nameable);
|
|
3809
|
+
const deliverablesBlock = delivered?.length
|
|
3810
|
+
? ["Deliverables (the hub hashed these files when the task was done; approval binds these hashes):",
|
|
3811
|
+
...delivered.map(d => `- ${d.id}: ${d.path ?? "(path withheld)"} [${d.format}${d.version ? ` ${d.version}` : ""}] sha256 ${d.sha256.slice(0, 16)}, ${d.bytes} bytes`)].join("\n") + "\n"
|
|
3812
|
+
: "";
|
|
3813
|
+
const checklist = deliverablesBlock + criteriaBlock + [
|
|
3654
3814
|
"Checklist:",
|
|
3655
3815
|
...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
|
|
3656
3816
|
...checklistLines.map(line => `- ${line}`),
|
|
@@ -3829,6 +3989,28 @@ export class Tasks {
|
|
|
3829
3989
|
const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
|
|
3830
3990
|
reviewedHead = checked.reviewedHead;
|
|
3831
3991
|
}
|
|
3992
|
+
// #509a: an approval binds the bytes that were delivered. The files are read again now: unchanged, their hashes go on the approval; changed,
|
|
3993
|
+
// missing or unreadable, a reviewer's approval is refused before any write, and the person may approve the files as they are, saying so.
|
|
3994
|
+
let boundDeliverables: DeliverableRecord[] | undefined;
|
|
3995
|
+
if (verdict === "approved") {
|
|
3996
|
+
const delivered = latestDeliverables(task.history);
|
|
3997
|
+
if (delivered) {
|
|
3998
|
+
const identity = JSON.stringify(delivered);
|
|
3999
|
+
const checks = await this.checkRecords(delivered, task);
|
|
4000
|
+
task = valid();
|
|
4001
|
+
if (JSON.stringify(latestDeliverables(task.history)) !== identity) throw new PreEffectToolRefusal("the task's deliverables changed during the verdict; review the current work again");
|
|
4002
|
+
const drift = checks.filter(check => check.state !== "unchanged");
|
|
4003
|
+
if (!drift.length) boundDeliverables = delivered;
|
|
4004
|
+
else {
|
|
4005
|
+
const text = drift.map(check => `${check.record.id} (${check.state})`).join(", ");
|
|
4006
|
+
if (by !== USER) throw new PreEffectToolRefusal(`deliverable changed since it was delivered: ${text}; ask its owner to call hub_task_done again so the current files are reviewed, or a person may approve them as they are`);
|
|
4007
|
+
// The person approves what is on disk now: those bytes are measured and bound, and the verdict says the files had moved.
|
|
4008
|
+
boundDeliverables = await this.hashClaims(delivered.map(record => ({ path: record.path!, format: record.format, ...(record.version !== undefined ? { version: record.version } : {}) })), task, true);
|
|
4009
|
+
task = valid();
|
|
4010
|
+
note = `${note ?? ""}\nApproved with the deliverable files changed since delivery: ${text}`.trim();
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
3832
4014
|
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
|
|
3833
4015
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
3834
4016
|
if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
@@ -3847,7 +4029,10 @@ export class Tasks {
|
|
|
3847
4029
|
const family = by === USER ? undefined : (this.d.adapterCatalog?.familyOf ?? familyOf)(by, this.d.instances?.());
|
|
3848
4030
|
const reviewerFamily = poolRequest?.reviewerFamily === family ? family : undefined;
|
|
3849
4031
|
const reviewerReason = reviewerFamily ? poolRequest?.reviewerReason : undefined;
|
|
3850
|
-
|
|
4032
|
+
// #507b: unmet items that start with a recorded criterion id name that criterion. The ids come from the checklist on the board as it is at this
|
|
4033
|
+
// write (the `task` just rechecked by valid()), never from the caller; a PII task carries none, its checklist being private.
|
|
4034
|
+
const unmetCriteria = pii ? [] : unmetCriteriaIds(items, checklistOf(task.sdlc));
|
|
4035
|
+
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
3851
4036
|
if (verdict === "approved") {
|
|
3852
4037
|
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
|
|
3853
4038
|
if (next.owner) {
|
|
@@ -4147,6 +4332,7 @@ export class Tasks {
|
|
|
4147
4332
|
}
|
|
4148
4333
|
|
|
4149
4334
|
async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown; horizon?: unknown; scope?: unknown; approach?: unknown; symptom?: unknown; cause?: unknown; evidence?: unknown; retry_when?: unknown }): Promise<string> {
|
|
4335
|
+
const requestCheck = this.requestCheck(by);
|
|
4150
4336
|
const text = composeRememberText(input);
|
|
4151
4337
|
const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
|
|
4152
4338
|
if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved: claude-mem processes what it stores with a cloud model");
|
|
@@ -4160,6 +4346,7 @@ export class Tasks {
|
|
|
4160
4346
|
if (looksSecret(`${title}\n${text}`)) throw new Error("this note looks like it holds a secret (a key, token or password) and is not saved");
|
|
4161
4347
|
if (this.aboutPrivatePath(title, text, task)) throw new Error("this note is about a path the project keeps private (pii.paths) and is not saved");
|
|
4162
4348
|
if (await this.withholds("note", `${title}\n${text}`, task)) throw new Error("the PII screen did not clear this note, so it is not saved: claude-mem processes what it stores with a cloud model");
|
|
4349
|
+
requestCheck();
|
|
4163
4350
|
const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
|
|
4164
4351
|
const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
|
|
4165
4352
|
if (task && this.d.memory) this.markMemoryReference(task, by);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A rolling window of counted tokens, by the time each was used (not the time it was read), so a row read late still ages out
|
|
3
|
+
* on schedule. The hub's own count against a ceiling a person configured, never the provider's quota (#285).
|
|
4
|
+
*/
|
|
5
|
+
export class TokenWindow {
|
|
6
|
+
private rows: { at: number; n: number }[] = [];
|
|
7
|
+
constructor(private readonly spanMs: number) {}
|
|
8
|
+
|
|
9
|
+
add(at: number, n: number): void {
|
|
10
|
+
if (!Number.isFinite(at) || !Number.isSafeInteger(n) || n <= 0) return;
|
|
11
|
+
const index = this.rows.findIndex(row => row.at > at);
|
|
12
|
+
if (index < 0) this.rows.push({ at, n }); else this.rows.splice(index, 0, { at, n });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
private prune(now: number): void {
|
|
16
|
+
let drop = 0;
|
|
17
|
+
while (drop < this.rows.length && now - this.rows[drop]!.at > this.spanMs) drop++;
|
|
18
|
+
if (drop) this.rows.splice(0, drop);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
sum(now: number): number {
|
|
22
|
+
this.prune(now);
|
|
23
|
+
return this.rows.reduce((total, row) => total + row.n, 0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** When the oldest counted token was used: the window frees its first share at this time plus its span. */
|
|
27
|
+
oldest(now: number): number | undefined {
|
|
28
|
+
this.prune(now);
|
|
29
|
+
return this.rows[0]?.at;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
reset(): void { this.rows = []; }
|
|
33
|
+
}
|
package/src/hub/usage-rollup.ts
CHANGED
|
@@ -101,7 +101,8 @@ export class UsageRollup {
|
|
|
101
101
|
* Fold the next slice of the log. Whole lines only: a trailing partial line (a crash, or a write in flight) waits for the next pass.
|
|
102
102
|
* A log that is shorter than the stored offset was replaced or reset, so the roll-up starts over from byte 0.
|
|
103
103
|
*/
|
|
104
|
-
poll(): { bytes: number; done: boolean } {
|
|
104
|
+
poll(maxMs = 20): { bytes: number; done: boolean } {
|
|
105
|
+
const deadline = performance.now() + Math.max(0, Math.min(20, maxMs));
|
|
105
106
|
if (this.closed) return { bytes: 0, done: true };
|
|
106
107
|
if (!existsSync(this.eventsFile)) return { bytes: 0, done: true };
|
|
107
108
|
const fd = openSync(this.eventsFile, "r");
|
|
@@ -127,18 +128,27 @@ export class UsageRollup {
|
|
|
127
128
|
want = Math.min(size - offset, want * 2);
|
|
128
129
|
}
|
|
129
130
|
if (end < 0) return { bytes: 0, done: true }; // the last line is still being written: it waits for the next pass
|
|
130
|
-
|
|
131
|
+
let consumed = 0;
|
|
131
132
|
// The offset was read before the slice was; another roll-up on this hub.db (a restart that overlaps the old process) may have folded the same
|
|
132
133
|
// bytes since. The transaction takes the write lock first and checks the stored offset again: a pass whose start is stale is abandoned whole,
|
|
133
134
|
// so a byte range is folded exactly once however many writers there are (found by a forced interleave: the counts doubled).
|
|
134
135
|
const applied = this.db.transaction(() => {
|
|
135
136
|
if (this.state().offset !== offset) return false;
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
// Commit only complete processed records with their offset. Yield between records;
|
|
138
|
+
// ponytail: one whole record and the existing corrupt-line skip are indivisible;
|
|
139
|
+
// a streaming parser would be needed to preempt within either operation.
|
|
140
|
+
for (let start = 0; start <= end;) {
|
|
141
|
+
const newline = buffer.indexOf(0x0a, start);
|
|
142
|
+
if (newline < 0 || newline > end) break;
|
|
143
|
+
this.fold(buffer.subarray(start, newline).toString("utf8"));
|
|
144
|
+
consumed = newline + 1; start = consumed;
|
|
145
|
+
if (performance.now() >= deadline) break;
|
|
146
|
+
}
|
|
147
|
+
this.db.query("UPDATE rollup_state SET byte_offset = ? WHERE id = 1").run(offset + consumed);
|
|
138
148
|
return true;
|
|
139
149
|
}).immediate();
|
|
140
150
|
if (!applied) return { bytes: 0, done: false }; // stale: the next pass starts from the stored offset
|
|
141
|
-
return { bytes:
|
|
151
|
+
return { bytes: consumed, done: offset + consumed >= size };
|
|
142
152
|
} finally { closeSync(fd); }
|
|
143
153
|
}
|
|
144
154
|
|
|
@@ -160,7 +170,7 @@ export class UsageRollup {
|
|
|
160
170
|
*/
|
|
161
171
|
catchUp(maxMs?: number): boolean {
|
|
162
172
|
const until = maxMs === undefined ? Infinity : Date.now() + maxMs;
|
|
163
|
-
for (let i = 0; i < 100_000; i++) { if (this.poll().done) return true; if (Date.now() >= until) return false; }
|
|
173
|
+
for (let i = 0; i < 100_000; i++) { if (this.poll(Math.min(20, until - Date.now())).done) return true; if (Date.now() >= until) return false; }
|
|
164
174
|
return true;
|
|
165
175
|
}
|
|
166
176
|
|
package/src/hub/workflow.ts
CHANGED
|
@@ -46,4 +46,20 @@ export function criteriaView(config: SdlcConfig, task: Task, isPrivate: (t: Task
|
|
|
46
46
|
for (const criterion of checklist.criteria) if (criterion.required && criterion.result !== "verified") blockers.push(`criterion ${criterion.id}: ${criterion.result}`);
|
|
47
47
|
return { id: checklist.id, ready: blockers.length === 0, blockers, criteria: checklist.criteria.map(c => ({ ...c, refs: [...c.refs] })), ...("tasks" in checklist && (checklist as LegacyPhaseReceipt).tasks.length ? { note: "stored checklist task list ignored: task import was retired (#481)" } : {}) };
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* #507b: which of a task's recorded acceptance criteria (the #481 S8 checklist, the only criteria model) a reviewer's unmet items name.
|
|
51
|
+
* An item names a criterion when it STARTS with that criterion's id, exactly (case and all), followed by the end of the item or a delimiter:
|
|
52
|
+
* `AC3: the overwrite case has no test`, or just `AC3`. An id the checklist does not hold is plain text, never a criterion, and an id in the
|
|
53
|
+
* middle of a sentence is not a claim. Pure: items in, ids out (in the order first named, each once).
|
|
54
|
+
*/
|
|
55
|
+
export function unmetCriteriaIds(items: readonly string[], checklist: Pick<CriteriaReceipt, "criteria"> | undefined): string[] {
|
|
56
|
+
if (!checklist) return [];
|
|
57
|
+
const known = new Set(checklist.criteria.map(c => c.id));
|
|
58
|
+
const named: string[] = [];
|
|
59
|
+
for (const item of items) {
|
|
60
|
+
const id = /^\s*([A-Za-z][A-Za-z0-9-]{0,47})(?=$|[\s:.,;)\]])/.exec(item)?.[1];
|
|
61
|
+
if (id !== undefined && known.has(id) && !named.includes(id)) named.push(id);
|
|
62
|
+
}
|
|
63
|
+
return named;
|
|
64
|
+
}
|
|
49
65
|
export function workflowHash(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
|