@staix/agent-hub 0.15.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 +25 -0
- package/README.md +12 -8
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +6 -3
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/daemon.md +9 -3
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +13 -1
- package/docs/agent-notes/tests.md +3 -0
- package/docs/dogfood.md +13 -4
- package/docs/events.md +1 -1
- package/docs/memory.md +78 -10
- package/docs/operations.md +116 -30
- package/docs/quickstart.md +8 -4
- package/docs/security.md +80 -18
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
- 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 +26 -17
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2085 -1702
- package/src/adapters/acp.ts +5 -2
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/compat.ts +2 -2
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +1 -1
- package/src/cli/console-state.ts +20 -2
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +5 -1
- package/src/cli/help.ts +7 -5
- package/src/cli/identity.ts +3 -1
- package/src/cli/main.ts +44 -10
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +12 -4
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +13 -5
- package/src/hub/action-policy.ts +19 -8
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +86 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +42 -10
- package/src/hub/bus.ts +1 -1
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +7 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +250 -49
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/kimi-usage.ts +12 -0
- package/src/hub/lifecycle.ts +10 -5
- 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/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +17 -2
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +353 -64
- package/src/hub/token-window.ts +15 -2
- package/src/hub/window-kind.ts +13 -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 +100 -18
package/src/hub/action-policy.ts
CHANGED
|
@@ -5,8 +5,7 @@ export interface ActionPolicyConfig { enabled: boolean; always_ask: ActionClass[
|
|
|
5
5
|
const DEFAULT_BUDGETS: Record<ActionClass, number> = { publish: 1, destroy: 3, "external-write": 5, install: 2 };
|
|
6
6
|
/** Defaults are host policy; overrides belong only in untracked config.local.json.
|
|
7
7
|
* ponytail: startup adopts that local policy without a retained person confirmation.
|
|
8
|
-
*
|
|
9
|
-
* confirmation or surface it as attention; slice 1 never enforces these grants.
|
|
8
|
+
* Status surfaces every weakening as attention; upgrade: retain a person-bound startup confirmation.
|
|
10
9
|
*/
|
|
11
10
|
export function actionPolicyConfig(value?: unknown): ActionPolicyConfig {
|
|
12
11
|
const fail = (): never => { throw new Error("action_policy: expected enabled, always_ask and class budgets (integers 0..1000)"); };
|
|
@@ -102,17 +101,29 @@ export class ActionBudgets {
|
|
|
102
101
|
status(peer: string, enforced: boolean, reason = "adapter integration pending") {
|
|
103
102
|
const config = actionPolicyConfig(this.config()), state = this.sessions.get(peer);
|
|
104
103
|
const bound = !!state && this.currentSession(peer) === state.handle.session && state.policy === JSON.stringify(config);
|
|
104
|
+
const weakened = !config.enabled || ACTION_CLASSES.some(key => !config.always_ask.includes(key) || config.budgets[key] > DEFAULT_BUDGETS[key]);
|
|
105
|
+
const attention = weakened ? "; attention: machine-local policy weakens defaults; no retained startup confirmation" : "";
|
|
105
106
|
return { enabled: config.enabled, alwaysAsk: [...config.always_ask], limits: { ...config.budgets }, remaining: bound ? { ...state.remaining } : empty(), sessionBound: bound,
|
|
106
|
-
enforceable: enforced, note: !enforced ? `always-ask not enforceable: ${reason}${config.enabled ? "" : "; policy disabled"}` : config.enabled ? "always-ask active" : "always-ask disabled" };
|
|
107
|
+
enforceable: enforced, note: (!enforced ? `always-ask not enforceable: ${reason}${config.enabled ? "" : "; policy disabled"}` : config.enabled ? "always-ask active" : "always-ask disabled") + attention };
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
type ActionApprovalPorts<T> = { publicView(task: T): Record<string, unknown>; private: boolean; commandView(command: string): string | undefined };
|
|
112
|
+
|
|
113
|
+
/** Public/native presentation: incomplete command views grant no approval, not even once. */
|
|
114
|
+
export function actionApprovalView<T>(classes: readonly ActionClass[], command: string, task: T | undefined,
|
|
115
|
+
ports: ActionApprovalPorts<T>): { title: string; complete: boolean } {
|
|
113
116
|
const view = task === undefined ? undefined : ports.publicView(task);
|
|
114
117
|
const title = typeof view?.title === "string" ? sanitize(view.title).replace(/[\p{Cc}\p{Cf}]/gu, " ").slice(0, 160) : undefined;
|
|
115
118
|
const shown = ports.private ? undefined : ports.commandView(command);
|
|
116
|
-
const
|
|
117
|
-
|
|
119
|
+
const full = shown === undefined ? undefined : Array.from(sanitize(shown).replace(/[\p{Cc}\p{Cf}]/gu, " "));
|
|
120
|
+
const marker = ` [cut, ${full?.length ?? 0} chars]`;
|
|
121
|
+
const line = full === undefined ? "[private command withheld]" : full.length > 512 ? full.slice(0, 512 - marker.length).join("") + marker : full.join("");
|
|
122
|
+
return { title: `Action approval (${ACTION_CLASSES.filter(key => classes.includes(key)).join(", ")})${title ? `; task ${title}` : ""}: ${line}`,
|
|
123
|
+
complete: shown === command && full !== undefined && full.length <= 512 && full.join("") === command };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Adapter integrations must pass the host's publicView and current command/private-turn screens. */
|
|
127
|
+
export function actionApprovalText<T>(classes: readonly ActionClass[], command: string, task: T | undefined, ports: ActionApprovalPorts<T>): string {
|
|
128
|
+
return actionApprovalView(classes, command, task, ports).title;
|
|
118
129
|
}
|
package/src/hub/ask.ts
CHANGED
|
@@ -104,7 +104,7 @@ export async function gather(question: string, d: AskDeps): Promise<{ evidence:
|
|
|
104
104
|
return { id: `task #${t.id}`, kind: "task", text: `[${t.class}] ${t.state}, owner ${t.owner ?? "none"}, reviewer ${t.reviewer ?? "none"}: ${text}`.slice(0, ITEM_CHARS) };
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
// A question
|
|
107
|
+
// A PII question never queries the shared memory view; local storage does not relax the hub privacy fence.
|
|
108
108
|
const memoryRows = d.memory && !piiQuestion ? related(d.memory, d.project, question, 2).catch(() => []) : Promise.resolve([]);
|
|
109
109
|
// The log carries message bodies and is written by agents too: a line with PII in it is treated like a PII task row.
|
|
110
110
|
const matching = thisRunsLog(d.logFile).filter((l) => hit(l) > 0).slice(-12);
|
|
@@ -119,12 +119,24 @@ export async function gather(question: string, d: AskDeps): Promise<{ evidence:
|
|
|
119
119
|
try { native = piiQuestion ? [] : (d.native?.(keys) ?? []).map((r) => ({ id: r.id, kind: "memory" as const, text: r.text.slice(0, ITEM_CHARS) })); } catch { /* fail-open */ }
|
|
120
120
|
const memory: Evidence[] = (await memoryRows)
|
|
121
121
|
.filter((r) => !r.title.startsWith(ASK_NOTE_TITLE))
|
|
122
|
-
.map((r) => ({ id: `#${r.id}`, kind: "memory" as const, text: `${r.time} ${r.type} ${r.title}`.slice(0, ITEM_CHARS) }));
|
|
122
|
+
.map((r) => ({ id: typeof r.id === "string" ? r.id : `#${r.id}`, kind: "memory" as const, text: `${r.time} ${r.type} ${r.title}`.slice(0, ITEM_CHARS) }));
|
|
123
123
|
|
|
124
|
+
// Native briefs keep short ids; the client supplies full ids for the same rows. Resolve only a unique
|
|
125
|
+
// prefix before dedupe/budget accounting. An ambiguous short id stays separate: it identifies neither full row.
|
|
126
|
+
const fullMemoryIds = new Map<string, Set<string>>();
|
|
127
|
+
for (const row of [...native, ...memory]) if (/^mem:[0-9a-f]{32}$/.test(row.id)) {
|
|
128
|
+
const prefix = row.id.slice(0, 12), ids = fullMemoryIds.get(prefix) ?? new Set<string>();
|
|
129
|
+
ids.add(row.id); fullMemoryIds.set(prefix, ids);
|
|
130
|
+
}
|
|
131
|
+
const memoryId = (row: Evidence): string => {
|
|
132
|
+
const full = row.kind === "memory" && /^mem:[0-9a-f]{8}$/.test(row.id) ? fullMemoryIds.get(row.id) : undefined;
|
|
133
|
+
return full?.size === 1 ? full.values().next().value! : row.id;
|
|
134
|
+
};
|
|
124
135
|
// Every item is capped, so one long row cannot empty the list; what does not fit is skipped, not everything after it.
|
|
125
136
|
const evidence: Evidence[] = [];
|
|
126
137
|
let size = 0;
|
|
127
|
-
for (const
|
|
138
|
+
for (const row of [...taskRows, ...native, ...memory, ...logRows]) {
|
|
139
|
+
const e = { ...row, id: memoryId(row) };
|
|
128
140
|
if (evidence.some((x) => x.id === e.id)) continue;
|
|
129
141
|
if (size + e.text.length + e.id.length > EVIDENCE_CHARS) continue;
|
|
130
142
|
size += e.text.length + e.id.length;
|
|
@@ -133,7 +145,7 @@ export async function gather(question: string, d: AskDeps): Promise<{ evidence:
|
|
|
133
145
|
return { evidence, pii: piiQuestion || piiShown, onCampus: campus };
|
|
134
146
|
}
|
|
135
147
|
|
|
136
|
-
const ID = /^(task #\d+|#\d+|mem:[0-9a-f]{8}|log \d\d-\d\d \d\d:\d\d:\d\d\.\d{3})$/;
|
|
148
|
+
const ID = /^(task #\d+|#\d+|mem:(?:[0-9a-f]{8}|[0-9a-f]{32})|log \d\d-\d\d \d\d:\d\d:\d\d\.\d{3})$/;
|
|
137
149
|
|
|
138
150
|
/** Every id the answer cites, grouped brackets included: `[#1, #2, task #3]`. */
|
|
139
151
|
export function citedIds(answer: string): string[] {
|
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";
|
|
@@ -47,6 +48,12 @@ export interface HistoryEntry {
|
|
|
47
48
|
supersededBy?: string;
|
|
48
49
|
/** The owner an event that set it left the task with (#67); absent in rows written before 0.11. */
|
|
49
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;
|
|
50
57
|
/** On a decline or an escalation: the owner it was declined for or taken from (#207); later reroutes leave it out. */
|
|
51
58
|
from?: PeerId;
|
|
52
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. */
|
|
@@ -99,6 +106,16 @@ function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
|
|
|
99
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");
|
|
100
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 } : {}) } } : {}) };
|
|
101
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
|
+
}
|
|
102
119
|
export interface Task extends PlanningFields {
|
|
103
120
|
offer?: TaskOffer;
|
|
104
121
|
cancelled?: TaskCancellation;
|
|
@@ -113,6 +130,8 @@ export interface Task extends PlanningFields {
|
|
|
113
130
|
/** Catalog guidance id only, never an authority role (#295). */
|
|
114
131
|
role?: string;
|
|
115
132
|
owner: PeerId | null;
|
|
133
|
+
/** Null for legacy rows; only Tasks chooses or changes a planner. */
|
|
134
|
+
planner?: PeerId | null;
|
|
116
135
|
reviewer: PeerId | null;
|
|
117
136
|
state: TaskState;
|
|
118
137
|
refs: TaskRefs;
|
|
@@ -183,6 +202,8 @@ export class Board {
|
|
|
183
202
|
onChange?: (task: Task, entry: HistoryEntry) => void;
|
|
184
203
|
/** Host lifecycle policy, installed by Tasks; off projects keep their original history shape. */
|
|
185
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;
|
|
186
207
|
private changed(task: Task, entry: HistoryEntry): void {
|
|
187
208
|
if (this.pendingChanges) { this.pendingChanges.push({ task, entry }); return; }
|
|
188
209
|
try { this.onChange?.(task, entry); } catch { /* never throw after a board write */ }
|
|
@@ -241,6 +262,8 @@ export class Board {
|
|
|
241
262
|
// Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
|
|
242
263
|
this.db.run("CREATE TABLE IF NOT EXISTS board_status_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL)");
|
|
243
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");
|
|
244
267
|
if (!have.has("reserved")) this.db.run("ALTER TABLE tasks ADD COLUMN reserved TEXT");
|
|
245
268
|
if (!have.has("external_repo")) this.db.run("ALTER TABLE tasks ADD COLUMN external_repo TEXT");
|
|
246
269
|
if (!have.has("external_issue")) this.db.run("ALTER TABLE tasks ADD COLUMN external_issue INTEGER");
|
|
@@ -260,16 +283,28 @@ export class Board {
|
|
|
260
283
|
release_order INTEGER NOT NULL, state TEXT NOT NULL DEFAULT 'planning', planned_start INTEGER, cut INTEGER, ship INTEGER,
|
|
261
284
|
activated_at INTEGER, frozen_at INTEGER, shipped_at INTEGER, created INTEGER NOT NULL, updated INTEGER NOT NULL,
|
|
262
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");
|
|
263
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");
|
|
264
289
|
|
|
265
290
|
}
|
|
266
291
|
|
|
267
|
-
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 {
|
|
268
|
-
const now = Date.now();
|
|
269
|
-
const
|
|
270
|
-
const {
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
});
|
|
273
308
|
const task = this.get(Number(lastInsertRowid))!;
|
|
274
309
|
this.changed(task, history[0]!);
|
|
275
310
|
return task;
|
|
@@ -346,7 +381,7 @@ export class Board {
|
|
|
346
381
|
|
|
347
382
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
348
383
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
349
|
-
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" | "unmetCriteria" | "deliverables" | "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 {
|
|
350
385
|
const task = this.get(id);
|
|
351
386
|
if (!task) throw new Error(`no task #${id}`);
|
|
352
387
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -355,7 +390,11 @@ export class Board {
|
|
|
355
390
|
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state) && !untouchedReturn) {
|
|
356
391
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
357
392
|
}
|
|
358
|
-
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 } };
|
|
359
398
|
const headOf = (row: Task) => row.refs.commit ?? row.sdlc?.head ?? row.sdlc?.localHead;
|
|
360
399
|
const privateSymbols = next.signals.includes("pii") || next.signals.includes("pii_path");
|
|
361
400
|
if (Object.hasOwn(patch, "codeSymbols")) {
|
|
@@ -398,10 +437,10 @@ export class Board {
|
|
|
398
437
|
// Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
|
|
399
438
|
const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
|
|
400
439
|
const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
|
|
401
|
-
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 } : {}), ...(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 } : {}) }];
|
|
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 } : {}) }];
|
|
402
441
|
this.taskWrite(() => {
|
|
403
|
-
const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
|
|
404
|
-
.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);
|
|
405
444
|
if (result.changes) {
|
|
406
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));
|
|
407
446
|
else this.db.query("DELETE FROM task_code_symbols WHERE task=?").run(id);
|
|
@@ -414,40 +453,42 @@ export class Board {
|
|
|
414
453
|
}
|
|
415
454
|
|
|
416
455
|
/** Internal persistence; caller authority and frozen scope are owned by Tasks. */
|
|
417
|
-
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 {
|
|
418
457
|
const patch = planningPatch(input);
|
|
419
458
|
const task = this.get(id);
|
|
420
459
|
if (!task || (task.planningRevision ?? 0) !== revision) throw new Error("planning revision changed");
|
|
421
|
-
|
|
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 };
|
|
422
462
|
if (next.plannedStart != null && next.due != null && next.plannedStart > next.due) throw new Error("planned start must not follow due");
|
|
423
463
|
if (next.release != null && !this.release(next.release)) throw new Error("no such release");
|
|
424
|
-
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;
|
|
425
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 } : {}) };
|
|
426
|
-
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=?`)
|
|
427
|
-
.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));
|
|
428
468
|
if (result.changes !== 1) throw new Error("planning revision changed");
|
|
429
469
|
const updated = this.get(id)!; this.changed(updated, entry); return updated;
|
|
430
470
|
}
|
|
431
471
|
|
|
432
472
|
/** A planner reserves a routing slot without claiming or dispatching the work. */
|
|
433
|
-
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 {
|
|
434
474
|
const task = this.get(id);
|
|
435
475
|
if (!task || (task.planningRevision ?? 0) !== revision || task.state !== "backlog" || task.owner || !taskCanPlan(task)) throw new Error("planner pull changed or already started");
|
|
436
|
-
const
|
|
437
|
-
const
|
|
438
|
-
|
|
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));
|
|
439
480
|
if (result.changes !== 1) throw new Error("planner pull changed");
|
|
440
481
|
const updated = this.get(id)!; this.changed(updated, entry); return updated;
|
|
441
482
|
}
|
|
442
483
|
|
|
443
484
|
/** Publish no intermediate ownerless pull: Tasks has already admitted the exact reserved peer. */
|
|
444
485
|
pullAssigned(id: number, by: PeerId, owner: PeerId, revision: number, note: string, withheld: boolean,
|
|
445
|
-
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 {
|
|
446
487
|
return this.atomic(() => {
|
|
447
488
|
const current = this.get(id);
|
|
448
489
|
if (!current || current.history.length !== expectedFlowRevision) throw new Error("task flow revision changed");
|
|
449
|
-
const pulled = this.pullPlanned(id, by, owner, revision, note, withheld);
|
|
450
|
-
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);
|
|
451
492
|
// Both deferred events describe the committed handoff, including the callback payload.
|
|
452
493
|
for (const change of this.pendingChanges ?? []) if (change.task.id === id) change.task = assigned;
|
|
453
494
|
return assigned;
|
|
@@ -508,9 +549,9 @@ export class Board {
|
|
|
508
549
|
});
|
|
509
550
|
}
|
|
510
551
|
|
|
511
|
-
newRelease(name: string, goal: string, order: number): Release {
|
|
512
|
-
const now = Date.now();
|
|
513
|
-
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);
|
|
514
555
|
return this.release(Number(result.lastInsertRowid))!;
|
|
515
556
|
}
|
|
516
557
|
release(id: number): Release | undefined {
|
|
@@ -520,10 +561,10 @@ export class Board {
|
|
|
520
561
|
releases(): Release[] {
|
|
521
562
|
return (this.db.query("SELECT * FROM releases ORDER BY release_order,id").all() as Record<string, unknown>[]).map(parseRelease);
|
|
522
563
|
}
|
|
523
|
-
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 {
|
|
524
565
|
const release = this.release(id);
|
|
525
566
|
if (!release || release.revision !== revision) throw new Error("release revision changed");
|
|
526
|
-
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();
|
|
527
568
|
const moves: Record<ReleaseState, ReleaseState[]> = { planning: ["active", "dropped"], active: ["frozen", "dropped"], frozen: ["shipped", "dropped"], shipped: [], dropped: [] };
|
|
528
569
|
if (next.state !== release.state && !moves[release.state].includes(next.state)) throw new Error("invalid release transition");
|
|
529
570
|
if (patch.train && next.state !== release.state && ["frozen", "shipped"].includes(next.state)) {
|
|
@@ -532,8 +573,8 @@ export class Board {
|
|
|
532
573
|
}
|
|
533
574
|
const dates = [next.plannedStart, next.cut, next.ship].filter((date): date is number => date !== null);
|
|
534
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");
|
|
535
|
-
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=?")
|
|
536
|
-
.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);
|
|
537
578
|
if (result.changes !== 1) throw new Error("release revision changed");
|
|
538
579
|
return this.release(id)!;
|
|
539
580
|
}
|
|
@@ -567,6 +608,19 @@ export class Board {
|
|
|
567
608
|
: this.db.query("SELECT * FROM reviews WHERE class = ? ORDER BY at").all(where.class)) as ReviewOutcome[];
|
|
568
609
|
}
|
|
569
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
|
+
|
|
570
624
|
close(): void {
|
|
571
625
|
this.db.close();
|
|
572
626
|
}
|
|
@@ -609,11 +663,12 @@ function parse(row: Record<string, unknown>): Task {
|
|
|
609
663
|
delete out.planning_revision;
|
|
610
664
|
if (out.pinned) out.pinned = true; else delete out.pinned;
|
|
611
665
|
for (const key of ["rank", "priority", "release", "due"]) if (out[key] == null) delete out[key];
|
|
666
|
+
out.planner = row.planner ?? null;
|
|
612
667
|
return out as unknown as Task;
|
|
613
668
|
}
|
|
614
669
|
|
|
615
670
|
function parseRelease(row: Record<string, unknown>): Release {
|
|
616
|
-
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,
|
|
617
672
|
plannedStart: row.planned_start as number | null, cut: row.cut as number | null, ship: row.ship as number | null,
|
|
618
673
|
activatedAt: row.activated_at as number | null, frozenAt: row.frozen_at as number | null, shippedAt: row.shipped_at as number | null,
|
|
619
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,14 +250,21 @@ 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
|
|
|
251
|
-
/** A source that can no longer be trusted drops its reading of one window: the window shows unknown, never the last number. */
|
|
252
|
-
forget(peer: PeerId, id: UsageWindow["id"]): void {
|
|
253
|
-
this.readings.get(this.account(peer))
|
|
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);
|
|
254
268
|
}
|
|
255
269
|
|
|
256
270
|
/** Readings that still say something: not stale, and not about a window that has reset since they were taken. */
|
|
@@ -260,6 +274,18 @@ export class Budget {
|
|
|
260
274
|
return [...(this.readings.get(peer)?.values() ?? [])].filter((r) => this.now() - r.at <= staleMs && !(r.resetsAt && r.resetsAt <= this.now()));
|
|
261
275
|
}
|
|
262
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
|
+
|
|
263
289
|
/**
|
|
264
290
|
* `quota_reset` (#683 U1), derived from two readings of one window id of the account: the window's `resetsAt` moved forward (a new
|
|
265
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
|
|
@@ -271,7 +297,7 @@ export class Budget {
|
|
|
271
297
|
const last = this.lastSeen.get(peer) ?? new Map<string, { used: number; resetsAt?: number; at: number }>();
|
|
272
298
|
this.lastSeen.set(peer, last);
|
|
273
299
|
for (const w of windows) {
|
|
274
|
-
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);
|
|
275
301
|
if (before && at >= before.at) {
|
|
276
302
|
const moved = w.resetsAt !== undefined && before.resetsAt !== undefined && w.resetsAt - before.resetsAt > RESET_JITTER_MS;
|
|
277
303
|
const sameWindow = w.resetsAt === undefined || before.resetsAt === undefined || Math.abs(w.resetsAt - before.resetsAt) <= RESET_JITTER_MS;
|
|
@@ -285,7 +311,7 @@ export class Budget {
|
|
|
285
311
|
}
|
|
286
312
|
}
|
|
287
313
|
}
|
|
288
|
-
if (!before || at >= before.at) last.set(
|
|
314
|
+
if (!before || at >= before.at) last.set(slot, { used, ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}), at });
|
|
289
315
|
}
|
|
290
316
|
}
|
|
291
317
|
|
|
@@ -315,7 +341,7 @@ export class Budget {
|
|
|
315
341
|
private async evaluate(peer: PeerId, hard: boolean): Promise<void> {
|
|
316
342
|
peer = this.account(peer);
|
|
317
343
|
if (this.recoveryHeld) { this.deferred = true; if (hard) this.deferredHard.add(peer); return; }
|
|
318
|
-
const fresh = this.
|
|
344
|
+
const fresh = this.gating(peer);
|
|
319
345
|
const over = fresh.filter((r) => r.used >= this.cfg.gate);
|
|
320
346
|
const open = this.record(peer);
|
|
321
347
|
// An expired restored record may be waiting for attachment on an unpaused bus. A new hard refusal
|
|
@@ -465,7 +491,7 @@ export class Budget {
|
|
|
465
491
|
*/
|
|
466
492
|
headroom(): Record<PeerId, { headroom: number; resetsAt?: number }> {
|
|
467
493
|
return Object.fromEntries([...this.readings.keys()].flatMap((peer) => {
|
|
468
|
-
const fresh = this.
|
|
494
|
+
const fresh = this.gating(peer);
|
|
469
495
|
if (!fresh.length) return [];
|
|
470
496
|
const used = Math.max(...fresh.map((r) => r.used));
|
|
471
497
|
const binding = fresh.filter((r) => r.used === used);
|
|
@@ -474,13 +500,19 @@ export class Budget {
|
|
|
474
500
|
}));
|
|
475
501
|
}
|
|
476
502
|
|
|
477
|
-
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 } }> {
|
|
478
504
|
const staleMs = this.cfg.stale_min * 60_000;
|
|
479
505
|
const peers = new Set([...this.readings.keys(), ...this.records().map((r) => r.peer)]);
|
|
480
506
|
return Object.fromEntries(
|
|
481
507
|
[...peers].map((peer) => {
|
|
482
508
|
const open = this.record(peer);
|
|
483
|
-
|
|
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 } } : {}) }];
|
|
484
516
|
}),
|
|
485
517
|
);
|
|
486
518
|
}
|
package/src/hub/bus.ts
CHANGED
|
@@ -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" }));
|