@staix/agent-hub 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +12 -8
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +24 -8
  5. package/docs/agent-notes/budget.md +4 -2
  6. package/docs/agent-notes/daemon.md +11 -4
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +15 -3
  9. package/docs/agent-notes/tests.md +8 -3
  10. package/docs/dogfood.md +18 -6
  11. package/docs/events.md +1 -1
  12. package/docs/memory.md +78 -10
  13. package/docs/operations.md +160 -47
  14. package/docs/quickstart.md +13 -5
  15. package/docs/roles.md +1 -0
  16. package/docs/sdlc.md +11 -11
  17. package/docs/security.md +94 -22
  18. package/docs/smoke.md +58 -6
  19. package/docs/specs/2026-09-19-agent-hub-design.md +124 -12
  20. package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
  21. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  22. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  23. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  24. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  25. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  26. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  27. package/docs/verification/2026-10-25-0.17.0.md +74 -0
  28. package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
  29. package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
  30. package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
  31. package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
  32. package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
  33. package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
  34. package/docs/verified.json +30 -18
  35. package/package.json +2 -2
  36. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  37. package/plugins/agent-hub/server.js +3421 -2176
  38. package/src/adapters/acp.ts +5 -2
  39. package/src/adapters/codex-appserver.ts +69 -1
  40. package/src/adapters/codex-review-isolation.ts +10 -1
  41. package/src/adapters/compat.ts +9 -4
  42. package/src/adapters/conformance/enrollment.ts +18 -4
  43. package/src/adapters/conformance/factories.ts +4 -1
  44. package/src/adapters/conformance/kit.ts +11 -0
  45. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  46. package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
  47. package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
  48. package/src/adapters/definitions/native-factories.ts +8 -0
  49. package/src/adapters/definitions/native-recovery.ts +8 -0
  50. package/src/adapters/definitions/qwen.ts +7 -4
  51. package/src/adapters/local-worker.ts +5 -4
  52. package/src/adapters/qwen-dual.ts +578 -0
  53. package/src/adapters/registry-types.ts +6 -2
  54. package/src/adapters/registry.ts +9 -1
  55. package/src/adapters/reviewer-mcp.ts +12 -6
  56. package/src/adapters/reviewer-print.ts +100 -17
  57. package/src/cli/console-state.ts +20 -2
  58. package/src/cli/console-symbols.ts +3 -2
  59. package/src/cli/console-usage.ts +5 -2
  60. package/src/cli/console.ts +5 -1
  61. package/src/cli/help.ts +7 -5
  62. package/src/cli/identity.ts +3 -1
  63. package/src/cli/main.ts +44 -10
  64. package/src/cli/memory.ts +119 -7
  65. package/src/cli/output.ts +12 -4
  66. package/src/cli/upgrade.ts +4 -3
  67. package/src/hub/action-class.ts +211 -17
  68. package/src/hub/action-enforce.ts +13 -5
  69. package/src/hub/action-policy.ts +19 -8
  70. package/src/hub/ask.ts +16 -4
  71. package/src/hub/board.ts +86 -31
  72. package/src/hub/budget-notes.ts +65 -0
  73. package/src/hub/budget.ts +42 -10
  74. package/src/hub/bus.ts +1 -1
  75. package/src/hub/code-facts.ts +56 -16
  76. package/src/hub/conductor.ts +5 -2
  77. package/src/hub/config-apply.ts +7 -0
  78. package/src/hub/control-client.ts +1 -1
  79. package/src/hub/daemon.ts +384 -71
  80. package/src/hub/envelope.ts +1 -1
  81. package/src/hub/events.ts +2 -2
  82. package/src/hub/hub-tools.ts +3 -3
  83. package/src/hub/kimi-usage.ts +12 -0
  84. package/src/hub/lifecycle.ts +10 -5
  85. package/src/hub/memory-code-head.ts +44 -0
  86. package/src/hub/memory-code-overlay.ts +119 -0
  87. package/src/hub/memory-plan-guard.ts +11 -27
  88. package/src/hub/memory-stale-anchors.ts +159 -0
  89. package/src/hub/memory-symbols.ts +74 -0
  90. package/src/hub/planning.ts +10 -4
  91. package/src/hub/read-tools.ts +21 -3
  92. package/src/hub/sdlc.ts +34 -0
  93. package/src/hub/settings.ts +17 -2
  94. package/src/hub/settled-roles.ts +15 -3
  95. package/src/hub/task-sweep.ts +25 -3
  96. package/src/hub/tasks.ts +378 -76
  97. package/src/hub/token-window.ts +15 -2
  98. package/src/hub/window-kind.ts +13 -0
  99. package/src/local/tools.ts +5 -5
  100. package/src/memory/brief.ts +66 -16
  101. package/src/memory/capture.ts +26 -40
  102. package/src/memory/client.ts +137 -65
  103. package/src/memory/recall.ts +13 -22
  104. package/src/ui/index.html +100 -18
@@ -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
- * Before adapter enforcement (#312/#313), bind weakened startup policy to person
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
- /** Adapter integrations must pass the host's publicView and current command/private-turn screens. */
111
- export function actionApprovalText<T>(classes: readonly ActionClass[], command: string, task: T | undefined,
112
- ports: { publicView(task: T): Record<string, unknown>; private: boolean; commandView(command: string): string | undefined }): string {
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 line = shown === undefined ? "[private command withheld]" : sanitize(shown).replace(/[\p{Cc}\p{Cf}]/gu, " ").slice(0, 512);
117
- return `Action approval (${ACTION_CLASSES.filter(key => classes.includes(key)).join(", ")})${title ? `; task ${title}` : ""}: ${line}`;
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 that carries PII is not sent to the memory worker, whose observer is a cloud model.
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 e of [...taskRows, ...native, ...memory, ...logRows]) {
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 history: HistoryEntry[] = [{ at: now, by, event: t.backlog ? "backlogged" : "proposed", ...(this.trackPlanRevisions?.() && t.plan && Object.keys(t.plan).length ? { planRevision: { revision: 1, fingerprint: planFingerprint(t.plan) } } : {}) }];
270
- const { lastInsertRowid } = this.taskWrite(() => this.db
271
- .query("INSERT INTO tasks (title, detail, class, state, refs, plan, deps, reserved, signals, history, created, updated, project_profile, task_role, external_repo, external_issue) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
272
- .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));
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 next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
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
- const next = { ...task, ...patch };
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 now = Date.now(), entry: HistoryEntry = { at: now, by, event: "backlog pulled", planning: { revision: revision + 1, fields: ["reserved"], previous: { reserved: task.reserved ?? null }, pull: true }, note, ...(withheld ? { withheld: true as const } : {}) };
437
- 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=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
438
- .run(reserved, JSON.stringify([...task.history, entry]), now, id, revision));
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(?,?,?,?,?)").run(name, goal, order, now, now);
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) mine.set(w.id, { ...w, used: Math.max(0, Math.min(1, w.used)), at });
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))?.delete(id);
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(w.id);
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(w.id, { used, ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}), at });
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.fresh(peer);
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.fresh(peer);
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
- return [peer, { windows: [...(this.readings.get(peer)?.values() ?? [])].map((r) => ({ ...r, stale: this.now() - r.at > staleMs })), ...(open ? { paused: { reason: open.reason, resetsAt: open.resetsAt, since: open.since } } : {}) }];
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 claude-mem. */
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" }));