@staix/agent-hub 0.14.0 → 0.16.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 +42 -0
- package/README.md +23 -9
- package/docs/agent-notes/README.md +8 -0
- package/docs/agent-notes/adapters.md +21 -4
- package/docs/agent-notes/budget.md +6 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +20 -9
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +21 -4
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +19 -5
- package/docs/events.md +21 -7
- package/docs/memory.md +78 -10
- package/docs/operations.md +197 -39
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +16 -6
- package/docs/sdlc.md +17 -0
- package/docs/security.md +110 -21
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
- 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/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verified.json +40 -19
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3109 -2046
- package/src/adapters/acp.ts +46 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/compat.ts +6 -2
- 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 +17 -5
- 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/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +8 -4
- 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 +48 -3
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +30 -2
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +10 -7
- package/src/cli/identity.ts +3 -1
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +91 -19
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +40 -4
- 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-class.ts +211 -17
- package/src/hub/action-enforce.ts +90 -0
- package/src/hub/action-policy.ts +40 -17
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +110 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +44 -7
- package/src/hub/bus.ts +6 -6
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +12 -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 +614 -104
- 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/envelope.ts +1 -1
- package/src/hub/events.ts +5 -3
- package/src/hub/hub-tools.ts +22 -6
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +156 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-code-head.ts +44 -0
- package/src/hub/memory-code-overlay.ts +119 -0
- package/src/hub/memory-plan-guard.ts +10 -1
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +18 -3
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +551 -75
- package/src/hub/token-window.ts +46 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/window-kind.ts +13 -0
- package/src/hub/workflow.ts +16 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +22 -12
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +215 -19
package/src/hub/board.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { HOST_CODE_SYMBOLS, parseTaskCodeSymbols, type TaskCodeSymbols } from "./task-code-symbols.ts";
|
|
2
2
|
import { planFingerprint } from "./plan-conformance.ts";
|
|
3
|
+
import type { PlannerReason } from "./routing.ts";
|
|
3
4
|
import type { ReviewerPoolReason } from "./reviewer-pool.ts";
|
|
4
5
|
import type { GitHubSource } from "./github-import.ts";
|
|
5
6
|
import { Database } from "bun:sqlite";
|
|
@@ -9,6 +10,7 @@ import type { SweepRecord } from "./task-sweep.ts";
|
|
|
9
10
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
10
11
|
import type { SdlcReceipt } from "./sdlc.ts";
|
|
11
12
|
import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
|
|
13
|
+
import { parseDeliverableRecords, type DeliverableRecord } from "./deliverables.ts";
|
|
12
14
|
import type { CostPlan, ReviewCost } from "./cost-governance.ts";
|
|
13
15
|
|
|
14
16
|
export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
|
|
@@ -46,6 +48,12 @@ export interface HistoryEntry {
|
|
|
46
48
|
supersededBy?: string;
|
|
47
49
|
/** The owner an event that set it left the task with (#67); absent in rows written before 0.11. */
|
|
48
50
|
owner?: PeerId | null;
|
|
51
|
+
/** Host-selected planner at this transition; absent in legacy history. */
|
|
52
|
+
planner?: PeerId | null;
|
|
53
|
+
plannerReason?: PlannerReason;
|
|
54
|
+
/** Saved plan-review author/self-review evidence, retained if the current receipt changes. */
|
|
55
|
+
planAuthor?: PeerId | null;
|
|
56
|
+
selfReview?: true;
|
|
49
57
|
/** On a decline or an escalation: the owner it was declined for or taken from (#207); later reroutes leave it out. */
|
|
50
58
|
from?: PeerId;
|
|
51
59
|
/** On a decline: the owner had no capacity right now (#531). Absent on older rows and on a plain refusal, which read as lasting. */
|
|
@@ -66,6 +74,14 @@ export interface HistoryEntry {
|
|
|
66
74
|
reviewContext?: "fresh" | "continuing";
|
|
67
75
|
/** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
|
|
68
76
|
reviewFindings?: ReviewFindingSet;
|
|
77
|
+
/**
|
|
78
|
+
* #509a: the files a `done`, a lifecycle record or an approval bound, with the content hashes the hub computed. An approval carries the hashes
|
|
79
|
+
* it was given at review time, so a later edit reads as "deliverable changed since review". Full paths live here; every view goes through
|
|
80
|
+
* `publicDeliverables`.
|
|
81
|
+
*/
|
|
82
|
+
deliverables?: DeliverableRecord[];
|
|
83
|
+
/** #507b: the recorded acceptance criteria (S8 checklist ids) a verdict's unmet items named; derived by the hub from the checklist, never free text. */
|
|
84
|
+
unmetCriteria?: string[];
|
|
69
85
|
/** Host-authored plan content revision, separate from backlog planningRevision. */
|
|
70
86
|
planRevision?: { revision: number; fingerprint: string };
|
|
71
87
|
planObservation?: { repo: string; pr: number; head: string; revision: number; fingerprint: string };
|
|
@@ -77,6 +93,11 @@ export interface HistoryEntry {
|
|
|
77
93
|
reviewerFailure?: { peer: string; head?: string; cause: "start_failed" | "quota" | "no_verdict" };
|
|
78
94
|
planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
|
|
79
95
|
}
|
|
96
|
+
/** The ids a stored verdict may carry (#507b): bounded criterion identifiers in the S8 checklist's own format, at most its 32, each once. */
|
|
97
|
+
function criterionIdList(value: unknown): string[] {
|
|
98
|
+
if (!Array.isArray(value) || value.length > 32 || value.some(id => typeof id !== "string" || !/^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(id)) || new Set(value).size !== value.length) throw new Error("unmet criteria: expected up to 32 distinct criterion ids");
|
|
99
|
+
return [...value] as string[];
|
|
100
|
+
}
|
|
80
101
|
export type ReviewerHistory = Pick<HistoryEntry, "reviewerFamily" | "reviewerReason" | "reviewBlocked" | "reviewerFailure">;
|
|
81
102
|
function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
|
|
82
103
|
const id = (value: unknown) => typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
|
|
@@ -85,6 +106,16 @@ function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
|
|
|
85
106
|
if (failure && (!id(failure.peer) || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || failure.head !== undefined && (typeof failure.head !== "string" || !failure.head.trim() || failure.head.length > 300 || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head)))) throw new Error("invalid reviewer failure metadata");
|
|
86
107
|
return { ...(extra.reviewerFamily ? { reviewerFamily: extra.reviewerFamily } : {}), ...(extra.reviewerReason ? { reviewerReason: extra.reviewerReason } : {}), ...(extra.reviewBlocked ? { reviewBlocked: true } : {}), ...(failure ? { reviewerFailure: { peer: failure.peer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } } : {}) };
|
|
87
108
|
}
|
|
109
|
+
export interface PlannerBinding { planner: PeerId | null; plannerReason: PlannerReason }
|
|
110
|
+
function plannerValue(value: unknown): PeerId | null {
|
|
111
|
+
if (value == null) return null;
|
|
112
|
+
if (typeof value !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(value)) throw new Error("invalid planner id");
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
function plannerMetadata(planner: unknown, reason?: PlannerReason): Pick<HistoryEntry, "planner" | "plannerReason"> {
|
|
116
|
+
if (reason !== undefined && !["rotation", "named", "kept", "none"].includes(reason)) throw new Error("invalid planner reason");
|
|
117
|
+
return { planner: plannerValue(planner), ...(reason === undefined ? {} : { plannerReason: reason }) };
|
|
118
|
+
}
|
|
88
119
|
export interface Task extends PlanningFields {
|
|
89
120
|
offer?: TaskOffer;
|
|
90
121
|
cancelled?: TaskCancellation;
|
|
@@ -99,6 +130,8 @@ export interface Task extends PlanningFields {
|
|
|
99
130
|
/** Catalog guidance id only, never an authority role (#295). */
|
|
100
131
|
role?: string;
|
|
101
132
|
owner: PeerId | null;
|
|
133
|
+
/** Null for legacy rows; only Tasks chooses or changes a planner. */
|
|
134
|
+
planner?: PeerId | null;
|
|
102
135
|
reviewer: PeerId | null;
|
|
103
136
|
state: TaskState;
|
|
104
137
|
refs: TaskRefs;
|
|
@@ -169,6 +202,8 @@ export class Board {
|
|
|
169
202
|
onChange?: (task: Task, entry: HistoryEntry) => void;
|
|
170
203
|
/** Host lifecycle policy, installed by Tasks; off projects keep their original history shape. */
|
|
171
204
|
trackPlanRevisions?: () => boolean;
|
|
205
|
+
/** Fixed host-owned pure selection; no model, network or asynchronous work is admitted inside the insert. */
|
|
206
|
+
proposalPlanner?: (task: Task) => PlannerBinding;
|
|
172
207
|
private changed(task: Task, entry: HistoryEntry): void {
|
|
173
208
|
if (this.pendingChanges) { this.pendingChanges.push({ task, entry }); return; }
|
|
174
209
|
try { this.onChange?.(task, entry); } catch { /* never throw after a board write */ }
|
|
@@ -227,6 +262,8 @@ export class Board {
|
|
|
227
262
|
// Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
|
|
228
263
|
this.db.run("CREATE TABLE IF NOT EXISTS board_status_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL)");
|
|
229
264
|
this.db.run("INSERT OR IGNORE INTO board_status_revision VALUES (1,0)");
|
|
265
|
+
if (!have.has("planner")) this.db.run("ALTER TABLE tasks ADD COLUMN planner TEXT");
|
|
266
|
+
this.db.run("CREATE INDEX IF NOT EXISTS tasks_planner_present ON tasks(planner) WHERE planner IS NOT NULL");
|
|
230
267
|
if (!have.has("reserved")) this.db.run("ALTER TABLE tasks ADD COLUMN reserved TEXT");
|
|
231
268
|
if (!have.has("external_repo")) this.db.run("ALTER TABLE tasks ADD COLUMN external_repo TEXT");
|
|
232
269
|
if (!have.has("external_issue")) this.db.run("ALTER TABLE tasks ADD COLUMN external_issue INTEGER");
|
|
@@ -246,16 +283,28 @@ export class Board {
|
|
|
246
283
|
release_order INTEGER NOT NULL, state TEXT NOT NULL DEFAULT 'planning', planned_start INTEGER, cut INTEGER, ship INTEGER,
|
|
247
284
|
activated_at INTEGER, frozen_at INTEGER, shipped_at INTEGER, created INTEGER NOT NULL, updated INTEGER NOT NULL,
|
|
248
285
|
revision INTEGER NOT NULL DEFAULT 0)`);
|
|
286
|
+
if (!(this.db.query("PRAGMA table_info(releases)").all() as { name: string }[]).some(row => row.name === "planner")) this.db.run("ALTER TABLE releases ADD COLUMN planner TEXT");
|
|
287
|
+
this.db.run("CREATE INDEX IF NOT EXISTS releases_planner_present ON releases(planner) WHERE planner IS NOT NULL");
|
|
249
288
|
if (!(this.db.query("PRAGMA table_info(releases)").all() as { name: string }[]).some(row => row.name === "train")) this.db.run("ALTER TABLE releases ADD COLUMN train TEXT");
|
|
250
289
|
|
|
251
290
|
}
|
|
252
291
|
|
|
253
|
-
propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; profile?: import("./profiles.ts").ProfileName; profileClass?: string; customProfile?: true; role?: string; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; reserved?: PeerId; signals?: string[]; backlog?: boolean; external?: GitHubSource }): Task {
|
|
254
|
-
const now = Date.now();
|
|
255
|
-
const
|
|
256
|
-
const {
|
|
257
|
-
|
|
258
|
-
|
|
292
|
+
propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; planner?: PeerId | null; plannerReason?: PlannerReason; profile?: import("./profiles.ts").ProfileName; profileClass?: string; customProfile?: true; role?: string; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; reserved?: PeerId; signals?: string[]; backlog?: boolean; external?: GitHubSource }): Task {
|
|
293
|
+
const now = Date.now(), planner = plannerValue(t.planner);
|
|
294
|
+
const chosen = Object.hasOwn(t, "planner") || t.plannerReason !== undefined ? plannerMetadata(planner, t.plannerReason) : {};
|
|
295
|
+
const history: HistoryEntry[] = [{ at: now, by, ...chosen, event: t.backlog ? "backlogged" : "proposed", ...(this.trackPlanRevisions?.() && t.plan && Object.keys(t.plan).length ? { planRevision: { revision: 1, fingerprint: planFingerprint(t.plan) } } : {}) }];
|
|
296
|
+
const { lastInsertRowid } = this.taskWrite(() => {
|
|
297
|
+
const inserted = this.db
|
|
298
|
+
.query("INSERT INTO tasks (title, detail, class, state, refs, plan, deps, reserved, signals, history, created, updated, project_profile, task_role, external_repo, external_issue, planner) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
299
|
+
.run(t.title, t.detail ?? "", t.class, t.backlog ? "backlog" : "proposed", JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), t.reserved ?? null, JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now, t.profile && (t.profile !== "dev" || t.customProfile || t.profileClass !== undefined && t.profileClass !== t.class) ? JSON.stringify({ profile: t.profile, profileClass: t.profileClass ?? t.class }) : null, t.role ?? null, t.external?.repo.toLowerCase() ?? null, t.external?.issue ?? null, planner);
|
|
300
|
+
if (this.proposalPlanner && !Object.hasOwn(t, "planner") && t.plannerReason === undefined) {
|
|
301
|
+
const selected = this.proposalPlanner(this.get(Number(inserted.lastInsertRowid))!);
|
|
302
|
+
const binding = plannerMetadata(selected.planner, selected.plannerReason);
|
|
303
|
+
Object.assign(history[0]!, binding);
|
|
304
|
+
this.db.query("UPDATE tasks SET planner=?,history=? WHERE id=?").run(binding.planner ?? null, JSON.stringify(history), inserted.lastInsertRowid);
|
|
305
|
+
}
|
|
306
|
+
return inserted;
|
|
307
|
+
});
|
|
259
308
|
const task = this.get(Number(lastInsertRowid))!;
|
|
260
309
|
this.changed(task, history[0]!);
|
|
261
310
|
return task;
|
|
@@ -332,7 +381,7 @@ export class Board {
|
|
|
332
381
|
|
|
333
382
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
334
383
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
335
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
384
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "unmetCriteria" | "deliverables" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
336
385
|
const task = this.get(id);
|
|
337
386
|
if (!task) throw new Error(`no task #${id}`);
|
|
338
387
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -341,7 +390,11 @@ export class Board {
|
|
|
341
390
|
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state) && !untouchedReturn) {
|
|
342
391
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
343
392
|
}
|
|
344
|
-
const
|
|
393
|
+
const planner = Object.hasOwn(patch, "planner") ? plannerValue(patch.planner) : task.planner ?? null;
|
|
394
|
+
const plannerHistory = Object.hasOwn(patch, "planner") || extra.plannerReason !== undefined ? plannerMetadata(planner, extra.plannerReason) : {};
|
|
395
|
+
const authorHistory = Object.hasOwn(extra, "planAuthor") ? { planAuthor: plannerValue(extra.planAuthor) } : {};
|
|
396
|
+
if (extra.selfReview !== undefined && extra.selfReview !== true) throw new Error("invalid self-review metadata");
|
|
397
|
+
const next = { ...task, ...patch, planner, refs: { ...task.refs, ...patch.refs } };
|
|
345
398
|
const headOf = (row: Task) => row.refs.commit ?? row.sdlc?.head ?? row.sdlc?.localHead;
|
|
346
399
|
const privateSymbols = next.signals.includes("pii") || next.signals.includes("pii_path");
|
|
347
400
|
if (Object.hasOwn(patch, "codeSymbols")) {
|
|
@@ -366,6 +419,16 @@ export class Board {
|
|
|
366
419
|
if (head !== undefined && !/^[a-f0-9]{40}$/.test(head)) throw new Error("finding metadata requires a full reviewed head");
|
|
367
420
|
reviewFindings = { ...(head ? { head } : {}), items: parseReviewFindings(extra.reviewFindings.items, { person: by === "user", maxNits: 64 }) };
|
|
368
421
|
}
|
|
422
|
+
let deliverables: DeliverableRecord[] | undefined;
|
|
423
|
+
if (extra.deliverables !== undefined) {
|
|
424
|
+
if (!["done", "done (checking)", "lifecycle", "approved"].includes(event)) throw new Error("deliverable records require a done, a lifecycle record or an approval");
|
|
425
|
+
deliverables = parseDeliverableRecords(extra.deliverables);
|
|
426
|
+
}
|
|
427
|
+
let unmetCriteria: string[] | undefined;
|
|
428
|
+
if (extra.unmetCriteria !== undefined) {
|
|
429
|
+
if (!["approved", "changes_requested"].includes(event) || patch.state !== event) throw new Error("unmet criteria require a saved review verdict");
|
|
430
|
+
unmetCriteria = criterionIdList(extra.unmetCriteria);
|
|
431
|
+
}
|
|
369
432
|
if (extra.planDeparture && (by !== "user" || event !== "plan departure accepted")) throw new Error("plan departure acceptance is person-only");
|
|
370
433
|
const trackPlan = this.trackPlanRevisions?.() === true, hasDeparture = task.history.some(h => h.planDeparture);
|
|
371
434
|
const fingerprint = patch.plan && (trackPlan || hasDeparture) ? planFingerprint(next.plan ?? {}) : undefined;
|
|
@@ -374,10 +437,10 @@ export class Board {
|
|
|
374
437
|
// Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
|
|
375
438
|
const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
|
|
376
439
|
const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
|
|
377
|
-
const history = [...task.history, { at: Date.now(), by, event, ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
|
|
440
|
+
const history = [...task.history, { at: Date.now(), by, event, ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
|
|
378
441
|
this.taskWrite(() => {
|
|
379
|
-
const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
|
|
380
|
-
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id);
|
|
442
|
+
const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
|
|
443
|
+
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, id);
|
|
381
444
|
if (result.changes) {
|
|
382
445
|
if (next.codeSymbols) this.db.query("INSERT INTO task_code_symbols(task,payload) VALUES(?,?) ON CONFLICT(task) DO UPDATE SET payload=excluded.payload").run(id, JSON.stringify(next.codeSymbols));
|
|
383
446
|
else this.db.query("DELETE FROM task_code_symbols WHERE task=?").run(id);
|
|
@@ -390,40 +453,42 @@ export class Board {
|
|
|
390
453
|
}
|
|
391
454
|
|
|
392
455
|
/** Internal persistence; caller authority and frozen scope are owned by Tasks. */
|
|
393
|
-
plan(id: number, by: PeerId, input: PlanningFields, revision: number, note?: string, withheld = false): Task {
|
|
456
|
+
plan(id: number, by: PeerId, input: PlanningFields, revision: number, note?: string, withheld = false, receipt?: SdlcReceipt): Task {
|
|
394
457
|
const patch = planningPatch(input);
|
|
395
458
|
const task = this.get(id);
|
|
396
459
|
if (!task || (task.planningRevision ?? 0) !== revision) throw new Error("planning revision changed");
|
|
397
|
-
|
|
460
|
+
if ((patch.sdlc !== undefined) !== (receipt !== undefined)) throw new Error("planning issue identity requires a normalized host receipt");
|
|
461
|
+
const next = { ...task, ...patch, sdlc: receipt ?? task.sdlc };
|
|
398
462
|
if (next.plannedStart != null && next.due != null && next.plannedStart > next.due) throw new Error("planned start must not follow due");
|
|
399
463
|
if (next.release != null && !this.release(next.release)) throw new Error("no such release");
|
|
400
|
-
const fields = Object.keys(patch) as (keyof PlanningFields)[], previous = Object.fromEntries(fields.map(key => [key, task[key] ?? (key === "pinned" ? false : key === "deps" ? [] : null)])) as PlanningFields;
|
|
464
|
+
const fields = Object.keys(patch) as (keyof PlanningFields)[], previous = Object.fromEntries(fields.map(key => [key, key === "sdlc" ? { repo: task.sdlc?.repo ?? null, issue: task.sdlc?.issue ?? null } : task[key] ?? (key === "pinned" ? false : key === "deps" ? [] : null)])) as PlanningFields;
|
|
401
465
|
const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "planned", planning: { revision: revision + 1, fields, previous }, ...(note ? { note } : {}), ...(withheld && note ? { withheld: true as const } : {}) };
|
|
402
|
-
const result = this.taskWrite(() => this.db.query(`UPDATE tasks SET rank=?, priority=?, "release"=?, planned_start=?, due=?, pinned=?, deps=?, reserved=?, planning_revision=planning_revision+1, history=?, updated=? WHERE id=? AND planning_revision=?`)
|
|
403
|
-
.run(next.rank ?? null, next.priority ?? null, next.release ?? null, next.plannedStart ?? null, next.due ?? null, next.pinned ? 1 : 0, JSON.stringify(next.deps ?? []), next.reserved ?? null, JSON.stringify([...task.history, entry]), now, id, revision));
|
|
466
|
+
const result = this.taskWrite(() => this.db.query(`UPDATE tasks SET rank=?, priority=?, "release"=?, planned_start=?, due=?, pinned=?, deps=?, reserved=?, planning_revision=planning_revision+1, history=?, updated=?, sdlc=? WHERE id=? AND planning_revision=?`)
|
|
467
|
+
.run(next.rank ?? null, next.priority ?? null, next.release ?? null, next.plannedStart ?? null, next.due ?? null, next.pinned ? 1 : 0, JSON.stringify(next.deps ?? []), next.reserved ?? null, JSON.stringify([...task.history, entry]), now, next.sdlc === undefined ? null : JSON.stringify(next.sdlc), id, revision));
|
|
404
468
|
if (result.changes !== 1) throw new Error("planning revision changed");
|
|
405
469
|
const updated = this.get(id)!; this.changed(updated, entry); return updated;
|
|
406
470
|
}
|
|
407
471
|
|
|
408
472
|
/** A planner reserves a routing slot without claiming or dispatching the work. */
|
|
409
|
-
pullPlanned(id: number, by: PeerId, reserved: PeerId, revision: number, note: string, withheld = false): Task {
|
|
473
|
+
pullPlanned(id: number, by: PeerId, reserved: PeerId, revision: number, note: string, withheld = false, binding?: PlannerBinding): Task {
|
|
410
474
|
const task = this.get(id);
|
|
411
475
|
if (!task || (task.planningRevision ?? 0) !== revision || task.state !== "backlog" || task.owner || !taskCanPlan(task)) throw new Error("planner pull changed or already started");
|
|
412
|
-
const
|
|
413
|
-
const
|
|
414
|
-
|
|
476
|
+
const selected = binding ? plannerMetadata(binding.planner, binding.plannerReason) : {};
|
|
477
|
+
const now = Date.now(), entry: HistoryEntry = { at: now, by, ...selected, event: "backlog pulled", planning: { revision: revision + 1, fields: ["reserved"], previous: { reserved: task.reserved ?? null }, pull: true }, note, ...(withheld ? { withheld: true as const } : {}) };
|
|
478
|
+
const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,offer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=?,planner=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
|
|
479
|
+
.run(reserved, JSON.stringify([...task.history, entry]), now, binding ? selected.planner ?? null : task.planner ?? null, id, revision));
|
|
415
480
|
if (result.changes !== 1) throw new Error("planner pull changed");
|
|
416
481
|
const updated = this.get(id)!; this.changed(updated, entry); return updated;
|
|
417
482
|
}
|
|
418
483
|
|
|
419
484
|
/** Publish no intermediate ownerless pull: Tasks has already admitted the exact reserved peer. */
|
|
420
485
|
pullAssigned(id: number, by: PeerId, owner: PeerId, revision: number, note: string, withheld: boolean,
|
|
421
|
-
reviewer: PeerId | null, assignmentNote: string, extra: Parameters<Board["update"]>[5], expectedFlowRevision: number): Task {
|
|
486
|
+
reviewer: PeerId | null, assignmentNote: string, extra: Parameters<Board["update"]>[5], expectedFlowRevision: number, binding?: PlannerBinding): Task {
|
|
422
487
|
return this.atomic(() => {
|
|
423
488
|
const current = this.get(id);
|
|
424
489
|
if (!current || current.history.length !== expectedFlowRevision) throw new Error("task flow revision changed");
|
|
425
|
-
const pulled = this.pullPlanned(id, by, owner, revision, note, withheld);
|
|
426
|
-
const assigned = this.update(id, by, "assigned", { owner, reviewer }, assignmentNote, extra, pulled.history.length);
|
|
490
|
+
const pulled = this.pullPlanned(id, by, owner, revision, note, withheld, binding);
|
|
491
|
+
const assigned = this.update(id, by, "assigned", { owner, reviewer, ...(binding ? { planner: binding.planner } : {}) }, assignmentNote, { ...extra, ...(binding ? { plannerReason: binding.plannerReason } : {}) }, pulled.history.length);
|
|
427
492
|
// Both deferred events describe the committed handoff, including the callback payload.
|
|
428
493
|
for (const change of this.pendingChanges ?? []) if (change.task.id === id) change.task = assigned;
|
|
429
494
|
return assigned;
|
|
@@ -484,9 +549,9 @@ export class Board {
|
|
|
484
549
|
});
|
|
485
550
|
}
|
|
486
551
|
|
|
487
|
-
newRelease(name: string, goal: string, order: number): Release {
|
|
488
|
-
const now = Date.now();
|
|
489
|
-
const result = this.db.query("INSERT INTO releases(name,goal,release_order,created,updated) VALUES(
|
|
552
|
+
newRelease(name: string, goal: string, order: number, planner?: PeerId | null): Release {
|
|
553
|
+
const now = Date.now(), selected = plannerValue(planner);
|
|
554
|
+
const result = this.db.query("INSERT INTO releases(name,goal,release_order,created,updated,planner) VALUES(?,?,?,?,?,?)").run(name, goal, order, now, now, selected);
|
|
490
555
|
return this.release(Number(result.lastInsertRowid))!;
|
|
491
556
|
}
|
|
492
557
|
release(id: number): Release | undefined {
|
|
@@ -496,10 +561,10 @@ export class Board {
|
|
|
496
561
|
releases(): Release[] {
|
|
497
562
|
return (this.db.query("SELECT * FROM releases ORDER BY release_order,id").all() as Record<string, unknown>[]).map(parseRelease);
|
|
498
563
|
}
|
|
499
|
-
updateRelease(id: number, patch: Partial<Pick<Release, "state" | "plannedStart" | "cut" | "ship" | "train">>, revision: number): Release {
|
|
564
|
+
updateRelease(id: number, patch: Partial<Pick<Release, "state" | "planner" | "plannedStart" | "cut" | "ship" | "train">>, revision: number): Release {
|
|
500
565
|
const release = this.release(id);
|
|
501
566
|
if (!release || release.revision !== revision) throw new Error("release revision changed");
|
|
502
|
-
const next = { ...release, ...patch }, now = Date.now();
|
|
567
|
+
const next = { ...release, ...patch, planner: Object.hasOwn(patch, "planner") ? plannerValue(patch.planner) : release.planner ?? null }, now = Date.now();
|
|
503
568
|
const moves: Record<ReleaseState, ReleaseState[]> = { planning: ["active", "dropped"], active: ["frozen", "dropped"], frozen: ["shipped", "dropped"], shipped: [], dropped: [] };
|
|
504
569
|
if (next.state !== release.state && !moves[release.state].includes(next.state)) throw new Error("invalid release transition");
|
|
505
570
|
if (patch.train && next.state !== release.state && ["frozen", "shipped"].includes(next.state)) {
|
|
@@ -508,8 +573,8 @@ export class Board {
|
|
|
508
573
|
}
|
|
509
574
|
const dates = [next.plannedStart, next.cut, next.ship].filter((date): date is number => date !== null);
|
|
510
575
|
if (dates.some(date => !Number.isSafeInteger(date) || date <= 0 || date > 8_640_000_000_000_000) || dates.some((date, i) => i > 0 && dates[i-1]! > date)) throw new Error("invalid release planned dates");
|
|
511
|
-
const result = this.db.query("UPDATE releases SET state=?,planned_start=?,cut=?,ship=?,activated_at=?,frozen_at=?,shipped_at=?,updated=?,train=?,revision=revision+1 WHERE id=? AND revision=?")
|
|
512
|
-
.run(next.state, next.plannedStart, next.cut, next.ship, release.activatedAt ?? (next.state === "active" ? now : null), release.frozenAt ?? (next.state === "frozen" ? now : null), release.shippedAt ?? (next.state === "shipped" ? now : null), now, next.train === undefined ? null : JSON.stringify(next.train), id, revision);
|
|
576
|
+
const result = this.db.query("UPDATE releases SET state=?,planned_start=?,cut=?,ship=?,activated_at=?,frozen_at=?,shipped_at=?,updated=?,train=?,planner=?,revision=revision+1 WHERE id=? AND revision=?")
|
|
577
|
+
.run(next.state, next.plannedStart, next.cut, next.ship, release.activatedAt ?? (next.state === "active" ? now : null), release.frozenAt ?? (next.state === "frozen" ? now : null), release.shippedAt ?? (next.state === "shipped" ? now : null), now, next.train === undefined ? null : JSON.stringify(next.train), next.planner, id, revision);
|
|
513
578
|
if (result.changes !== 1) throw new Error("release revision changed");
|
|
514
579
|
return this.release(id)!;
|
|
515
580
|
}
|
|
@@ -543,6 +608,19 @@ export class Board {
|
|
|
543
608
|
: this.db.query("SELECT * FROM reviews WHERE class = ? ORDER BY at").all(where.class)) as ReviewOutcome[];
|
|
544
609
|
}
|
|
545
610
|
|
|
611
|
+
/** A short-circuit SQL presence read; no task, receipt or history body is hydrated into JS. */
|
|
612
|
+
hasPlannerState(): boolean {
|
|
613
|
+
const row = this.db.query(`SELECT
|
|
614
|
+
EXISTS(SELECT 1 FROM tasks WHERE planner IS NOT NULL) OR
|
|
615
|
+
EXISTS(SELECT 1 FROM releases WHERE planner IS NOT NULL) OR
|
|
616
|
+
EXISTS(SELECT 1 FROM tasks WHERE CASE WHEN json_valid(sdlc) THEN
|
|
617
|
+
json_type(sdlc,'$.planReview.planAuthor') IS NOT NULL OR json_type(sdlc,'$.planReview.selfReview') IS NOT NULL ELSE 0 END) OR
|
|
618
|
+
EXISTS(SELECT 1 FROM tasks, json_each(CASE WHEN json_valid(tasks.history) THEN tasks.history ELSE '[]' END) AS entry
|
|
619
|
+
WHERE CASE WHEN json_valid(entry.value) THEN json_type(entry.value,'$.planAuthor') IS NOT NULL OR json_type(entry.value,'$.selfReview') IS NOT NULL ELSE 0 END)
|
|
620
|
+
AS present`).get() as { present: number };
|
|
621
|
+
return row.present === 1;
|
|
622
|
+
}
|
|
623
|
+
|
|
546
624
|
close(): void {
|
|
547
625
|
this.db.close();
|
|
548
626
|
}
|
|
@@ -585,11 +663,12 @@ function parse(row: Record<string, unknown>): Task {
|
|
|
585
663
|
delete out.planning_revision;
|
|
586
664
|
if (out.pinned) out.pinned = true; else delete out.pinned;
|
|
587
665
|
for (const key of ["rank", "priority", "release", "due"]) if (out[key] == null) delete out[key];
|
|
666
|
+
out.planner = row.planner ?? null;
|
|
588
667
|
return out as unknown as Task;
|
|
589
668
|
}
|
|
590
669
|
|
|
591
670
|
function parseRelease(row: Record<string, unknown>): Release {
|
|
592
|
-
return { ...(row.train == null ? {} : { train: JSON.parse(String(row.train)) }), id: Number(row.id), name: String(row.name), goal: String(row.goal), order: Number(row.release_order), state: row.state as ReleaseState,
|
|
671
|
+
return { ...(row.train == null ? {} : { train: JSON.parse(String(row.train)) }), planner: row.planner == null ? null : String(row.planner), id: Number(row.id), name: String(row.name), goal: String(row.goal), order: Number(row.release_order), state: row.state as ReleaseState,
|
|
593
672
|
plannedStart: row.planned_start as number | null, cut: row.cut as number | null, ship: row.ship as number | null,
|
|
594
673
|
activatedAt: row.activated_at as number | null, frozenAt: row.frozen_at as number | null, shippedAt: row.shipped_at as number | null,
|
|
595
674
|
created: Number(row.created), updated: Number(row.updated), revision: Number(row.revision) };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AdapterDefinition } from "../adapters/registry.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Why a peer has no quota number, for the `ahub budget` block, the console detail and the dashboard (#285 AC6, AC7). A note never carries a path,
|
|
5
|
+
* a session id or text from a peer's log: only the rule that applies and a count.
|
|
6
|
+
*/
|
|
7
|
+
export interface BudgetPeerNote {
|
|
8
|
+
peer: string;
|
|
9
|
+
/** `no-quota`: the peer has no provider quota at all. `no-reading`: it has one (or a ceiling) and nothing has been read. */
|
|
10
|
+
kind: "no-quota" | "no-reading";
|
|
11
|
+
note: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** What the Kimi count can say now (`KimiUsage.status`): a reason code at most. */
|
|
15
|
+
export type KimiSourceState = { state: "off" | "waiting" | "counting" } | { state: "unreadable"; reason: string };
|
|
16
|
+
|
|
17
|
+
export interface BudgetNoteInput {
|
|
18
|
+
/** The peers to describe: the attached ones, and any with a reading. */
|
|
19
|
+
peers: readonly string[];
|
|
20
|
+
/** How many windows the account of this peer has now (any kind, stale ones included: they are shown as stale, not hidden). */
|
|
21
|
+
windows(peer: string): number;
|
|
22
|
+
definition(peer: string): AdapterDefinition | undefined;
|
|
23
|
+
/** The count of a peer whose definition keeps host-owned soft token accounting (`budget.resetSoftTokensOnResume`); today that is Kimi's wire log. */
|
|
24
|
+
softTokens(): KimiSourceState | undefined;
|
|
25
|
+
/** Tokens the hub counted for the peer in the last 5 h, or undefined when it has no counter. */
|
|
26
|
+
hubTokens5h(peer: string): number | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `1.2M`, `340k`, `812`: a count a person reads at a glance. */
|
|
30
|
+
export function compactTokens(n: number): string {
|
|
31
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1).replace(/\.0$/, "")}M`;
|
|
32
|
+
if (n >= 10_000) return `${Math.round(n / 1000)}k`;
|
|
33
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
34
|
+
return String(Math.round(n));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The line for a peer that has no windows, or undefined when its windows already say everything. */
|
|
38
|
+
export function budgetNote(peer: string, input: BudgetNoteInput): BudgetPeerNote | undefined {
|
|
39
|
+
const definition = input.definition(peer), budget = definition?.budget;
|
|
40
|
+
if (!budget) return undefined;
|
|
41
|
+
const tokens = input.hubTokens5h(peer);
|
|
42
|
+
const counted = tokens && tokens > 0 ? `; ${compactTokens(tokens)} tokens in 5h (hub count)` : "";
|
|
43
|
+
if (budget.noQuotaReason) return { peer, kind: "no-quota", note: `no quota: ${budget.noQuotaReason}${counted}` };
|
|
44
|
+
if (input.windows(peer) > 0) return undefined;
|
|
45
|
+
if (budget.resetSoftTokensOnResume) {
|
|
46
|
+
const kimi = input.softTokens();
|
|
47
|
+
if (!kimi || kimi.state === "off") return { peer, kind: "no-reading", note: "no reading: kimi reports context occupancy only; set budget.kimi_tokens_5h to count tokens, or ahub budget set kimi <0..1>" };
|
|
48
|
+
if (kimi.state === "unreadable") return { peer, kind: "no-reading", note: `no reading: kimi wire log unreadable (${kimi.reason}); the hub counts nothing it cannot read cleanly` };
|
|
49
|
+
return { peer, kind: "no-reading", note: kimi.state === "waiting" ? "no reading yet: waiting for a Kimi session to count" : "no reading yet: the count starts with the next Kimi turn" };
|
|
50
|
+
}
|
|
51
|
+
if (budget.reader) return { peer, kind: "no-reading", note: `no reading yet: ${budget.reader.source} has not reported` };
|
|
52
|
+
return { peer, kind: "no-reading", note: `no reading: ${peer} declares no quota source; ahub budget set ${peer} <0..1> feeds one by hand` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function budgetNotes(input: BudgetNoteInput): BudgetPeerNote[] {
|
|
56
|
+
return input.peers.flatMap(peer => budgetNote(peer, input) ?? []);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The `ahub doctor` row for the Kimi usage source: `ok` undefined is unknown (nothing to read yet), false is a log the hub cannot trust. */
|
|
60
|
+
export function kimiSourceRow(state: KimiSourceState | undefined): { ok: boolean | undefined; detail: string } | undefined {
|
|
61
|
+
if (!state) return undefined;
|
|
62
|
+
if (state.state === "off") return { ok: undefined, detail: "no count: budget.kimi_tokens_5h is 0; Kimi shows context occupancy only" };
|
|
63
|
+
if (state.state === "unreadable") return { ok: false, detail: `wire log unreadable (${state.reason}); no Kimi tokens are counted until it reads cleanly` };
|
|
64
|
+
return { ok: true, detail: state.state === "waiting" ? "counting from Kimi's wire log; no Kimi session is bound yet" : "counting from Kimi's wire log (hub count, never the provider's quota)" };
|
|
65
|
+
}
|
package/src/hub/budget.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { definitionOf, type AdapterDefinition } from "../adapters/registry.ts";
|
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
3
|
import type { PeerId } from "./envelope.ts";
|
|
4
4
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
5
|
+
import { windowKind, type WindowKind } from "./window-kind.ts";
|
|
6
|
+
export { windowKind, type WindowKind };
|
|
5
7
|
|
|
6
8
|
export interface UsageWindow {
|
|
7
9
|
id: "5h" | "week" | "tokens";
|
|
@@ -12,6 +14,8 @@ export interface UsageWindow {
|
|
|
12
14
|
/** length of the window, for a fallback reset time */
|
|
13
15
|
windowMins?: number;
|
|
14
16
|
source: string;
|
|
17
|
+
/** Optional: a window without one is classified by `windowKind`, so a source that predates it keeps working. */
|
|
18
|
+
kind?: WindowKind;
|
|
15
19
|
}
|
|
16
20
|
/** No declared source is unknown, not a synthetic empty provider reading. */
|
|
17
21
|
export function readNativeQuota(definition: AdapterDefinition | undefined, value: unknown): UsageWindow[] | undefined {
|
|
@@ -19,7 +23,10 @@ export function readNativeQuota(definition: AdapterDefinition | undefined, value
|
|
|
19
23
|
}
|
|
20
24
|
interface Reading extends UsageWindow {
|
|
21
25
|
at: number;
|
|
26
|
+
kind: WindowKind;
|
|
22
27
|
}
|
|
28
|
+
/** Readings are kept per window id and kind, so a person's number and the provider's for one window never overwrite each other. */
|
|
29
|
+
const readingKey = (id: string, kind: WindowKind): string => `${id}|${kind}`;
|
|
23
30
|
|
|
24
31
|
export interface Moved {
|
|
25
32
|
id: number;
|
|
@@ -243,11 +250,23 @@ export class Budget {
|
|
|
243
250
|
this.deriveResets(peer, windows, at);
|
|
244
251
|
const mine = this.readings.get(peer) ?? new Map<string, Reading>();
|
|
245
252
|
this.readings.set(peer, mine);
|
|
246
|
-
for (const w of windows)
|
|
253
|
+
for (const w of windows) {
|
|
254
|
+
const kind = windowKind(w);
|
|
255
|
+
// A provider that has refused another turn outranks a person's number for the same window.
|
|
256
|
+
if (hard && kind !== "manual") mine.delete(readingKey(w.id, "manual"));
|
|
257
|
+
mine.set(readingKey(w.id, kind), { ...w, kind, used: Math.max(0, Math.min(1, w.used)), at });
|
|
258
|
+
}
|
|
247
259
|
if (this.recoveryHeld) { this.deferred = true; if (hard) this.deferredHard.add(peer); return; }
|
|
248
260
|
this.evaluate(peer, hard).catch((e: Error) => this.closed || this.hooks.notify(`budget: evaluating ${peer} failed: ${e.message}`));
|
|
249
261
|
}
|
|
250
262
|
|
|
263
|
+
/** A source that can no longer be trusted drops its reading of one window: the window shows unknown, never the last number. `kind` limits it to that source's own reading. */
|
|
264
|
+
forget(peer: PeerId, id: UsageWindow["id"], kind?: WindowKind): void {
|
|
265
|
+
const mine = this.readings.get(this.account(peer));
|
|
266
|
+
if (!mine) return;
|
|
267
|
+
for (const [key, reading] of mine) if (reading.id === id && (!kind || reading.kind === kind)) mine.delete(key);
|
|
268
|
+
}
|
|
269
|
+
|
|
251
270
|
/** Readings that still say something: not stale, and not about a window that has reset since they were taken. */
|
|
252
271
|
private fresh(peer: PeerId): Reading[] {
|
|
253
272
|
peer = this.account(peer);
|
|
@@ -255,6 +274,18 @@ export class Budget {
|
|
|
255
274
|
return [...(this.readings.get(peer)?.values() ?? [])].filter((r) => this.now() - r.at <= staleMs && !(r.resetsAt && r.resetsAt <= this.now()));
|
|
256
275
|
}
|
|
257
276
|
|
|
277
|
+
/**
|
|
278
|
+
* The readings the gate and routing act on (#285 AC8). A person's number for a window replaces the provider's and the hub's for that window; and while
|
|
279
|
+
* a native or manual reading is fresh, the hub count is shown but never gates, because it is an estimate against a ceiling and the others are measurements.
|
|
280
|
+
* A stale or reset reading is out of `fresh`, so the count gates again when nothing better is left.
|
|
281
|
+
*/
|
|
282
|
+
private gating(peer: PeerId): Reading[] {
|
|
283
|
+
const fresh = this.fresh(peer);
|
|
284
|
+
const fed = new Set(fresh.filter((r) => r.kind === "manual").map((r) => r.id));
|
|
285
|
+
const decided = fresh.filter((r) => r.kind === "manual" || !fed.has(r.id));
|
|
286
|
+
return decided.some((r) => r.kind !== "count") ? decided.filter((r) => r.kind !== "count") : decided;
|
|
287
|
+
}
|
|
288
|
+
|
|
258
289
|
/**
|
|
259
290
|
* `quota_reset` (#683 U1), derived from two readings of one window id of the account: the window's `resetsAt` moved forward (a new
|
|
260
291
|
* window began, by more than RESET_JITTER_MS), or `used` fell by RESET_DROP or more inside one window id (the provider emptied it before its stated reset). The
|
|
@@ -266,7 +297,7 @@ export class Budget {
|
|
|
266
297
|
const last = this.lastSeen.get(peer) ?? new Map<string, { used: number; resetsAt?: number; at: number }>();
|
|
267
298
|
this.lastSeen.set(peer, last);
|
|
268
299
|
for (const w of windows) {
|
|
269
|
-
const used = Math.max(0, Math.min(1, w.used)), before = last.get(
|
|
300
|
+
const used = Math.max(0, Math.min(1, w.used)), slot = readingKey(w.id, windowKind(w)), before = last.get(slot);
|
|
270
301
|
if (before && at >= before.at) {
|
|
271
302
|
const moved = w.resetsAt !== undefined && before.resetsAt !== undefined && w.resetsAt - before.resetsAt > RESET_JITTER_MS;
|
|
272
303
|
const sameWindow = w.resetsAt === undefined || before.resetsAt === undefined || Math.abs(w.resetsAt - before.resetsAt) <= RESET_JITTER_MS;
|
|
@@ -280,7 +311,7 @@ export class Budget {
|
|
|
280
311
|
}
|
|
281
312
|
}
|
|
282
313
|
}
|
|
283
|
-
if (!before || at >= before.at) last.set(
|
|
314
|
+
if (!before || at >= before.at) last.set(slot, { used, ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}), at });
|
|
284
315
|
}
|
|
285
316
|
}
|
|
286
317
|
|
|
@@ -310,7 +341,7 @@ export class Budget {
|
|
|
310
341
|
private async evaluate(peer: PeerId, hard: boolean): Promise<void> {
|
|
311
342
|
peer = this.account(peer);
|
|
312
343
|
if (this.recoveryHeld) { this.deferred = true; if (hard) this.deferredHard.add(peer); return; }
|
|
313
|
-
const fresh = this.
|
|
344
|
+
const fresh = this.gating(peer);
|
|
314
345
|
const over = fresh.filter((r) => r.used >= this.cfg.gate);
|
|
315
346
|
const open = this.record(peer);
|
|
316
347
|
// An expired restored record may be waiting for attachment on an unpaused bus. A new hard refusal
|
|
@@ -460,7 +491,7 @@ export class Budget {
|
|
|
460
491
|
*/
|
|
461
492
|
headroom(): Record<PeerId, { headroom: number; resetsAt?: number }> {
|
|
462
493
|
return Object.fromEntries([...this.readings.keys()].flatMap((peer) => {
|
|
463
|
-
const fresh = this.
|
|
494
|
+
const fresh = this.gating(peer);
|
|
464
495
|
if (!fresh.length) return [];
|
|
465
496
|
const used = Math.max(...fresh.map((r) => r.used));
|
|
466
497
|
const binding = fresh.filter((r) => r.used === used);
|
|
@@ -469,13 +500,19 @@ export class Budget {
|
|
|
469
500
|
}));
|
|
470
501
|
}
|
|
471
502
|
|
|
472
|
-
status(): Record<PeerId, { windows: (Reading & { stale: boolean })[]; paused?: { reason: string; resetsAt: number; since: number } }> {
|
|
503
|
+
status(): Record<PeerId, { windows: (Reading & { stale: boolean; superseded: boolean })[]; paused?: { reason: string; resetsAt: number; since: number } }> {
|
|
473
504
|
const staleMs = this.cfg.stale_min * 60_000;
|
|
474
505
|
const peers = new Set([...this.readings.keys(), ...this.records().map((r) => r.peer)]);
|
|
475
506
|
return Object.fromEntries(
|
|
476
507
|
[...peers].map((peer) => {
|
|
477
508
|
const open = this.record(peer);
|
|
478
|
-
|
|
509
|
+
const fresh = new Set(this.fresh(peer)), used = new Set(this.gating(peer));
|
|
510
|
+
// `superseded`: fresh, but information only: a measurement of the same account outranks it (the hub count under a native or manual reading).
|
|
511
|
+
// The reading the gate acts on is listed first (then the information-only ones, then the stale), so a client that looks a window up by id, as every
|
|
512
|
+
// client did before kinds existed, lands on the effective number and not on the one it replaced.
|
|
513
|
+
const rank = (r: Reading) => (used.has(r) ? 0 : fresh.has(r) ? 1 : 2);
|
|
514
|
+
const ordered = [...(this.readings.get(peer)?.values() ?? [])].sort((a, b) => rank(a) - rank(b));
|
|
515
|
+
return [peer, { windows: ordered.map((r) => ({ ...r, stale: this.now() - r.at > staleMs, superseded: fresh.has(r) && !used.has(r) })), ...(open ? { paused: { reason: open.reason, resetsAt: open.resetsAt, since: open.since } } : {}) }];
|
|
479
516
|
}),
|
|
480
517
|
);
|
|
481
518
|
}
|
package/src/hub/bus.ts
CHANGED
|
@@ -429,14 +429,14 @@ export class Bus {
|
|
|
429
429
|
}
|
|
430
430
|
|
|
431
431
|
knownPeers(): string[] {
|
|
432
|
-
const pending = this.storageError ? [] : this.journal?.
|
|
432
|
+
const pending = this.storageError ? [] : this.journal?.pendingPeers() ?? [];
|
|
433
433
|
return [...new Set([...this.peers.keys(), ...[...this.queues].filter(([, queue]) => queue.length).map(([id]) => id), ...pending])].sort();
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
-
queueList(peer?: string): JournalDelivery[] {
|
|
436
|
+
queueList(peer?: string, unresolvedOnly = false): JournalDelivery[] {
|
|
437
437
|
if (this.journal) {
|
|
438
|
-
const rows = this.journal.list(peer).filter((row) => !row.reason?.startsWith("grouped into delivery "));
|
|
439
|
-
const revision = this.journal.
|
|
438
|
+
const rows = (unresolvedOnly ? this.journal.unresolved(peer) : this.journal.list(peer)).filter((row) => !row.reason?.startsWith("grouped into delivery "));
|
|
439
|
+
const revision = this.journal.revision();
|
|
440
440
|
const recorded = new Set(rows.filter((r) => ["queued", "dispatching", "accepted", "needs_review"].includes(r.state)).flatMap((r) => (r.originals as Envelope[]).map((e) => `${r.peer}:${e.id}`)));
|
|
441
441
|
const ids = peer ? [peer] : [...this.queues.keys()];
|
|
442
442
|
for (const p of ids) for (const e of this.queues.get(p) ?? []) if (!recorded.has(`${p}:${e.id}`)) rows.push({ id: `q:${p}:${e.id}`, peer: p, state: "queued", revision, createdAt: e.ts, updatedAt: e.ts, originals: [e], out: [e] });
|
|
@@ -525,7 +525,7 @@ export class Bus {
|
|
|
525
525
|
}
|
|
526
526
|
|
|
527
527
|
queueSummary(peer: string): { needsReview: number; heldBy?: string; liveAccepted?: string[]; oldestQueuedAt?: number } {
|
|
528
|
-
const rows = this.storageError ? [] : this.queueList(peer);
|
|
528
|
+
const rows = this.storageError ? [] : this.queueList(peer, true);
|
|
529
529
|
const queued = this.queues.get(peer) ?? [];
|
|
530
530
|
const review = rows.filter((r) => r.state === "needs_review");
|
|
531
531
|
const needsReview = review.length;
|
|
@@ -667,7 +667,7 @@ export class Bus {
|
|
|
667
667
|
this.persist();
|
|
668
668
|
}
|
|
669
669
|
|
|
670
|
-
/** Another peer's shared note: rides in the preface like recall. The newest NOTES_KEPT stay; older ones remain in
|
|
670
|
+
/** Another peer's shared note: rides in the preface like recall. The newest NOTES_KEPT stay; older ones remain in amem. */
|
|
671
671
|
note(id: PeerId, line: string): void {
|
|
672
672
|
const existing = this.prefaces.get(id);
|
|
673
673
|
this.prefaces.set(id, existing ? { ...existing, body: keepNotes(uniqueMemoryLines(appendNote(existing.body, line)), NOTES_KEPT) } : newEnvelope(HUB, uniqueMemoryLines(line), { to: [id], kind: "presence" }));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync, constants } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { claudeStateFile } from "./claude-state.ts";
|
|
4
|
+
|
|
5
|
+
export interface ClaudeLaunchProof {
|
|
6
|
+
launchId: string;
|
|
7
|
+
executionCwd?: string;
|
|
8
|
+
privateRecord: boolean;
|
|
9
|
+
permissionHook: boolean;
|
|
10
|
+
unattended: boolean;
|
|
11
|
+
hookPurpose?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One bounded reader for current managed launch metadata; no native identity or payload escapes. */
|
|
15
|
+
export function readClaudeLaunchProof(stateDir: string, peer: string, instanceId: string): ClaudeLaunchProof | undefined {
|
|
16
|
+
let fd: number | undefined;
|
|
17
|
+
try {
|
|
18
|
+
fd = openSync(join(stateDir, claudeStateFile("claude-launch", peer)), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
19
|
+
const stat = fstatSync(fd);
|
|
20
|
+
// ponytail: launch metadata is capped at 16 KiB; add a versioned projection if it ever grows.
|
|
21
|
+
if (!stat.isFile() || stat.size > 16 * 1024) return;
|
|
22
|
+
const bytes = Buffer.alloc(16 * 1024 + 1);
|
|
23
|
+
const count = readSync(fd, bytes, 0, bytes.length, 0);
|
|
24
|
+
if (count > 16 * 1024) return;
|
|
25
|
+
const row = JSON.parse(bytes.subarray(0, count).toString("utf8"));
|
|
26
|
+
if (row?.instanceId !== instanceId || typeof row.launchId !== "string" || !row.launchId || row.launchId.length > 128) return;
|
|
27
|
+
return { launchId: row.launchId, privateRecord: stat.nlink === 1 && (stat.mode & 0o777) === 0o600 && !!process.getuid && stat.uid === process.getuid(),
|
|
28
|
+
...(typeof row.executionCwd === "string" && row.executionCwd.length <= 4096 && isAbsolute(row.executionCwd) ? { executionCwd: row.executionCwd } : {}),
|
|
29
|
+
permissionHook: row.permissionHook === true, unattended: row.unattended === true,
|
|
30
|
+
...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
|
|
31
|
+
} catch { return; }
|
|
32
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
33
|
+
}
|