@staix/agent-hub 0.14.0 → 0.16.0

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