@staix/agent-hub 0.12.29 → 0.12.31

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 (95) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +92 -218
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +3 -2
  5. package/docs/agent-notes/budget.md +1 -1
  6. package/docs/agent-notes/bus.md +7 -1
  7. package/docs/agent-notes/daemon.md +14 -1
  8. package/docs/agent-notes/local-worker.md +4 -2
  9. package/docs/agent-notes/tasks.md +14 -0
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/memory.md +70 -152
  12. package/docs/operations.md +317 -25
  13. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  14. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  15. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  16. package/docs/plans/amem-cases.json +482 -0
  17. package/docs/quickstart.md +24 -2
  18. package/docs/security.md +42 -2
  19. package/docs/smoke.md +11 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +142 -7
  21. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
  22. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  23. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  24. package/docs/verification/2026-10-17-418-probes.md +21 -0
  25. package/docs/verified.json +13 -13
  26. package/package.json +5 -2
  27. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  28. package/plugins/agent-hub/server.js +129 -62
  29. package/src/adapters/claude-channel.ts +29 -11
  30. package/src/adapters/local-worker.ts +2 -2
  31. package/src/adapters/pi.ts +5 -0
  32. package/src/cli/attention.ts +5 -1
  33. package/src/cli/console-state.ts +145 -29
  34. package/src/cli/console.ts +40 -10
  35. package/src/cli/help.ts +3 -2
  36. package/src/cli/identity.ts +4 -3
  37. package/src/cli/jcode-mcp.ts +1 -1
  38. package/src/cli/main.js +2 -2
  39. package/src/cli/main.ts +43 -14
  40. package/src/cli/memory.ts +87 -187
  41. package/src/cli/output.ts +19 -2
  42. package/src/cli/recovery-package.ts +59 -10
  43. package/src/cli/upgrade-interactive.ts +2 -1
  44. package/src/cli/upgrade-restore-state.ts +133 -0
  45. package/src/cli/upgrade-runtime.ts +204 -63
  46. package/src/cli/upgrade.ts +57 -24
  47. package/src/hub/board.ts +83 -24
  48. package/src/hub/bus.ts +108 -38
  49. package/src/hub/check-runner.ts +514 -0
  50. package/src/hub/child-process.ts +1 -0
  51. package/src/hub/control-client.ts +5 -1
  52. package/src/hub/daemon.ts +529 -89
  53. package/src/hub/delivery-hints.ts +30 -0
  54. package/src/hub/delivery-journal.ts +185 -12
  55. package/src/hub/delivery-superseded.ts +52 -0
  56. package/src/hub/envelope.ts +31 -1
  57. package/src/hub/git.ts +2 -38
  58. package/src/hub/host-executable.ts +11 -0
  59. package/src/hub/hub-tools.ts +14 -2
  60. package/src/hub/manager.ts +1 -1
  61. package/src/hub/memory-candidates.ts +103 -0
  62. package/src/hub/memory-fail.ts +25 -0
  63. package/src/hub/memory-observations.ts +47 -0
  64. package/src/hub/memory-plan-guard.ts +33 -0
  65. package/src/hub/memory-policy.ts +42 -0
  66. package/src/hub/memory-profile-check.ts +170 -0
  67. package/src/hub/memory-profile.ts +192 -0
  68. package/src/hub/memory-staleness.ts +55 -0
  69. package/src/hub/path-policy.ts +8 -0
  70. package/src/hub/peer-catalog.ts +3 -0
  71. package/src/hub/project.ts +4 -37
  72. package/src/hub/read-tools.ts +127 -0
  73. package/src/hub/routing.ts +35 -16
  74. package/src/hub/settings.ts +2 -1
  75. package/src/hub/spec-read.ts +120 -0
  76. package/src/hub/supervision.ts +21 -6
  77. package/src/hub/task-sweep.ts +7 -6
  78. package/src/hub/tasks.ts +370 -71
  79. package/src/local/deny.ts +4 -17
  80. package/src/memory/brief.ts +8 -5
  81. package/src/memory/capture.ts +3 -1
  82. package/src/memory/recall.ts +50 -110
  83. package/src/pi/process-signature.ts +2 -42
  84. package/src/ui/index.html +12 -9
  85. package/src/ui/needs.ts +7 -5
  86. package/src/ui/office-model.ts +4 -1
  87. package/src/ui/task-board.ts +1 -1
  88. package/src/ui/task-progress.ts +6 -2
  89. package/templates/AGENTS.block.md +1 -1
  90. package/templates/profiles/general.json +1 -1
  91. package/templates/profiles/research.json +1 -1
  92. package/templates/profiles/writing.json +1 -1
  93. package/src/memory/import.ts +0 -90
  94. package/src/memory/store.ts +0 -805
  95. package/src/memory/sync.ts +0 -145
@@ -1,3 +1,4 @@
1
+ import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
1
2
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
2
3
  import { Database } from "bun:sqlite";
3
4
  import { createHash, randomUUID } from "node:crypto";
@@ -53,6 +54,14 @@ export interface ProjectProgress {
53
54
  phase: "pending" | "prepared" | "stopped" | "started" | "peers-restored" | "verified";
54
55
  instanceId?: string;
55
56
  terminals: Record<string, unknown>;
57
+ /** #414: last bounded native-attachment timeout, metadata only; never terminal output or task text. */
58
+ attachmentTimeouts?: Record<string, {
59
+ at: number;
60
+ terminal: { handle: string; incarnationId: string; state: "unknown" | "idle" | "busy" | "exited"; connected?: boolean; orphaned?: boolean };
61
+ hub: { state: string; instanceId?: string; peerState?: string; sessionId?: string };
62
+ /** Client-side attempts are not recorded by the hub; an offline peer is not evidence of an attempt. */
63
+ lastHubConnectAttempt: "unknown";
64
+ }>;
56
65
  releaseRequested?: boolean;
57
66
  /** #215: peers the operator moved to a fresh session, with the session or thread id whose continuity is lost. */
58
67
  fresh?: Record<string, { lost: string; reason: string; at: number }>;
@@ -79,6 +88,8 @@ export interface RecoveryOperation {
79
88
  sourceRoot: string;
80
89
  targetRoot?: string;
81
90
  targetDigest?: string;
91
+ /** Verified capability evidence bound to targetRoot/targetDigest; absence is legacy unknown, not an empty list. */
92
+ targetFeatures?: readonly string[];
82
93
  projects: ProjectProgress[];
83
94
  pluginInstalled?: boolean;
84
95
  globalInstalled?: boolean;
@@ -119,7 +130,7 @@ export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
119
130
 
120
131
  /**
121
132
  * #225: record the restart of a target that stopped after it started, before the new one starts: the dead instance and
122
- * the time stay for status and audit. Each done `restored:<peer>` binding of the dead instance is retired
133
+ * the time stay for status and audit. Each done or waiting `restored:<peer>` binding of the dead instance is retired
123
134
  * (`retired:<peer>`): restore closes its terminal (or finds it absent) before that peer is relaunched, and never
124
135
  * revalidates it against the new instance. `closed:<peer>`, `commitSent`, `fresh`, waivers and the install flags stay.
125
136
  * The project goes back to `stopped`, whose path starts the target from this operation's snapshot.
@@ -127,9 +138,11 @@ export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
127
138
  export function recordRestart(progress: ProjectProgress, at: number): void {
128
139
  (progress.restarts ??= []).push({ instanceId: progress.instanceId ?? "unknown", at });
129
140
  for (const [key, value] of Object.entries(progress.terminals)) {
130
- if (!key.startsWith("restored:") || !value || typeof value !== "object") continue;
141
+ if (!key.startsWith("restored:")) continue;
142
+ const receipt = decodeRestoreReceipt(value);
143
+ if (receipt?.state !== "done" && receipt?.state !== "waiting") continue;
131
144
  const peer = key.slice("restored:".length);
132
- progress.terminals[`retired:${peer}`] = value;
145
+ progress.terminals[`retired:${peer}`] = receipt.binding;
133
146
  delete progress.terminals[`closedRetired:${peer}`]; // a newer binding to close: its own receipt starts over
134
147
  delete progress.terminals[key];
135
148
  }
@@ -272,7 +285,7 @@ export async function liveProjects(op: RecoveryOperation, inspect: (project: Pro
272
285
  */
273
286
  function failedPeers(op: RecoveryOperation, progress: ProjectProgress, live: Record<string, Inspection | undefined> = {}): string[] {
274
287
  const planned = op.plan.projects.find((p) => p.project.id === progress.id);
275
- return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && value === "failed").map(([key]) => key.slice("restored:".length))
288
+ return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && canChooseFresh(value)).map(([key]) => key.slice("restored:".length))
276
289
  .filter((peer) => peer !== "pi" && !progress.fresh?.[peer] && !planned?.freshStart?.includes(peer) && !plannedAttached(op, progress, peer, live));
277
290
  }
278
291
 
@@ -333,17 +346,18 @@ function abandoned(op: RecoveryOperation): boolean {
333
346
 
334
347
  function validateReceipt(id: string, op: RecoveryOperation): void {
335
348
  if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
349
+ if (op.targetFeatures !== undefined && (!Array.isArray(op.targetFeatures) || !op.targetFeatures.every(feature => typeof feature === "string" && feature.length > 0) || !op.targetRoot || !op.targetDigest)) throw new Error("invalid target features");
336
350
  const { fingerprint, ...reviewed } = op.plan;
337
351
  if (fingerprint !== planFingerprint(reviewed) || op.projects.length !== op.plan.projects.length ||
338
352
  op.projects.some((p, i) => p.id !== op.plan.projects[i]?.project.id)) throw new Error("reviewed operation plan or project scope changed");
339
353
  }
340
354
 
341
355
  export interface RecoveryDriver {
342
- stage(op: RecoveryOperation): Promise<{ root: string; digest: string }>;
343
- inspect(project: Project): Promise<Inspection>;
344
- prepare(project: Project, operation: string, instance: string): Promise<void>;
356
+ stage(op: RecoveryOperation): Promise<{ root: string; digest: string; targetFeatures?: readonly string[] }>;
357
+ inspect(project: Project, targetFeatures?: readonly string[]): Promise<Inspection>;
358
+ prepare(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
345
359
  abort(project: Project, operation: string, instance: string): Promise<void>;
346
- commit(project: Project, operation: string, instance: string): Promise<void>;
360
+ commit(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
347
361
  closeTerminals(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, save: () => void): Promise<void>;
348
362
  start(project: Project, op: RecoveryOperation): Promise<void>;
349
363
  restore(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, group: "native" | "claude", save: () => void): Promise<void>;
@@ -375,6 +389,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
375
389
  return op;
376
390
  }
377
391
  } catch (error) { releaseRunner(); throw error; }
392
+ let targetFeatures: readonly string[] = [];
378
393
  const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
379
394
  const step = (value: string) => { op.step = value; save(); };
380
395
  // #225: one restart per project per resume, so a target that keeps dying never spins unattended.
@@ -414,7 +429,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
414
429
  // Prepare, or after an expired lease re-prepare, the planned source and wait until it is quiet.
415
430
  const prepareSource = async (planned: PlannedProject, progress: ProjectProgress, again = false) => {
416
431
  const project = planned.project, instance = planned.source.instanceId!;
417
- await driver.prepare(project, id, instance);
432
+ await driver.prepare(project, id, instance, targetFeatures);
418
433
  const deadline = driver.now() + idleTimeoutMs;
419
434
  for (;;) {
420
435
  const live = await driver.inspect(project);
@@ -444,13 +459,16 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
444
459
  step("stage");
445
460
  const target = await driver.stage(op);
446
461
  if (op.targetDigest && (target.root !== op.targetRoot || target.digest !== op.targetDigest)) throw new FinalRefusal("staged target changed since operation started");
447
- op.targetRoot = target.root; op.targetDigest = target.digest; save();
462
+ targetFeatures = target.targetFeatures ?? op.targetFeatures ?? [];
463
+ op.targetRoot = target.root; op.targetDigest = target.digest;
464
+ if (target.targetFeatures !== undefined) op.targetFeatures = [...target.targetFeatures];
465
+ save();
448
466
 
449
- // Check every untouched source before stopping even the first project.
467
+ // Pending sources receive the verified target features before holds or terminal/runtime effects.
450
468
  for (let i = 0; i < op.projects.length; i++) {
451
469
  const progress = op.projects[i]!, planned = op.plan.projects[i]!;
452
470
  if (progress.phase !== "pending") continue;
453
- const live = await driver.inspect(planned.project);
471
+ const live = await driver.inspect(planned.project, targetFeatures);
454
472
  if (!["running", "stopped", "missing"].includes(live.state)) throw new Error(`${planned.project.id}: the source reads as ${live.state}; wait until it answers`);
455
473
  if (live.state !== "running" || live.instanceId !== planned.source.instanceId || live.version !== planned.source.version) {
456
474
  throw new Error(`${planned.project.id}: source runtime changed`);
@@ -478,7 +496,14 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
478
496
  }
479
497
  if (progress.phase === "prepared") {
480
498
  step(`commit:${project.id}`);
481
- let live = await driver.inspect(project);
499
+ let live: Inspection;
500
+ try { live = await driver.inspect(project); }
501
+ catch (error) {
502
+ if (!progress.commitSent || !isDatabaseBusy(error)) throw error;
503
+ live = { state: "unavailable", peers: [], blockers: ["database_busy"] };
504
+ }
505
+ const resumedShutdown = progress.commitSent && live.state === "unavailable";
506
+ let awaitShutdown = resumedShutdown;
482
507
  if (live.state === "running" && live.instanceId === planned.source.instanceId) {
483
508
  if (live.recovery?.operationId !== id || !live.recovery.ready) {
484
509
  const other = live.recovery?.operationId;
@@ -495,8 +520,22 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
495
520
  await driver.closeTerminals(planned, progress, op, save);
496
521
  // Durable, unlike `step`: from here the source may have committed, which status and abort must not forget.
497
522
  progress.commitSent = true; save();
498
- await driver.commit(project, id, planned.source.instanceId!);
523
+ await driver.commit(project, id, planned.source.instanceId!, targetFeatures);
524
+ awaitShutdown = true;
525
+ } else if (live.state === "running") {
526
+ throw new Error(`${project.id}: the source daemon was replaced by instance ${live.instanceId ?? "unknown"}; refusing to prepare, close or stop it (ending this operation leaves that daemon running)`);
527
+ } else if (live.state === "stopped" && !progress.commitSent && (!op.sourceRoot || coordinatorCurrent(op.sourceRoot))) {
528
+ // #215: this coordinator records a commit request before sending it, so a stopped source with none crashed while
529
+ // prepared: no snapshot was committed and there is nothing to start from. The phase stays `prepared`.
530
+ throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
531
+ }
532
+ if (awaitShutdown) {
499
533
  const deadline = driver.now() + 30_000;
534
+ // An already-sent commit can be between its final reply and the source closing its control server.
535
+ if (resumedShutdown) {
536
+ if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
537
+ await driver.sleep(100);
538
+ }
500
539
  do {
501
540
  try { live = await driver.inspect(project); }
502
541
  catch (error) {
@@ -509,12 +548,6 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
509
548
  if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
510
549
  await driver.sleep(100);
511
550
  } while (driver.now() < deadline);
512
- } else if (live.state === "running") {
513
- throw new Error(`${project.id}: the source daemon was replaced by instance ${live.instanceId ?? "unknown"}; refusing to prepare, close or stop it (ending this operation leaves that daemon running)`);
514
- } else if (live.state === "stopped" && !progress.commitSent && (!op.sourceRoot || coordinatorCurrent(op.sourceRoot))) {
515
- // #215: this coordinator records a commit request before sending it, so a stopped source with none crashed while
516
- // prepared: no snapshot was committed and there is nothing to start from. The phase stays `prepared`.
517
- throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
518
551
  }
519
552
  if (live.state === "missing") throw new Error(`${project.id}: the project directory is missing`);
520
553
  if (live.state !== "stopped") throw new Error(`${project.id}: old shutdown is not verified (the source reads as ${live.state}); not starting a second daemon; check ${recoveryCommand(op, "status")} once it answers`);
@@ -576,12 +609,12 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
576
609
 
577
610
  /** Receipts and runner state only (#215): ids, phases and effects, never task or message text. */
578
611
  export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unknown", live: Record<string, Inspection | undefined> = {}) {
579
- const effect = (value: unknown) => value === "pending" || value === "failed" ? value : "done";
612
+ const effect = (key: string, value: unknown) => key.startsWith("restored:") ? restoreReceiptStatus(value) : value === "pending" || value === "failed" ? value : "done";
580
613
  const next = nextActions(op, runnerPid, live);
581
614
  return { id: op.id, phase: op.phase, step: op.step, version: op.plan.version, updatedAt: op.updatedAt,
582
615
  runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
583
616
  ...(op.phase === "running" && !runnerPid ? { stale: "the receipt says running but no runner holds the operation: its last runner stopped mid-step" } : {}),
584
- projects: op.projects.map((p) => ({ id: p.id, phase: p.phase, ...(Object.keys(p.terminals).length ? { effects: Object.fromEntries(Object.entries(p.terminals).map(([key, value]) => [key, effect(value)])) } : {}),
617
+ projects: op.projects.map((p) => ({ id: p.id, phase: p.phase, ...(Object.keys(p.terminals).length ? { effects: Object.fromEntries(Object.entries(p.terminals).map(([key, value]) => [key, effect(key, value)])) } : {}),
585
618
  ...(p.fresh ? { lostContinuity: Object.fromEntries(Object.entries(p.fresh).map(([peer, f]) => [peer, f.lost])) } : {}),
586
619
  ...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
587
620
  pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
@@ -661,10 +694,10 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
661
694
  const peer = choice.fresh;
662
695
  // Per project: a planned fresh start has nothing to lose, so only the other failed projects take the choice.
663
696
  const failed = op.projects.filter((p) => failedPeers(op, p, live).includes(peer));
664
- if (!failed.length && op.projects.some((p) => p.terminals[`restored:${peer}`] === "failed" && op.plan.projects.find((planned) => planned.project.id === p.id)?.freshStart?.includes(peer))) {
697
+ if (!failed.length && op.projects.some((p) => canChooseFresh(p.terminals[`restored:${peer}`]) && op.plan.projects.find((planned) => planned.project.id === p.id)?.freshStart?.includes(peer))) {
665
698
  throw new Error(`${peer}: its plan already restarts it as a new session (no rollout and no turn, nothing to lose), so there is no conversation to record as lost; ${next}`);
666
699
  }
667
- if (!failed.length && op.projects.some((p) => p.terminals[`restored:${peer}`] === "failed" && plannedAttached(op, p, peer, live))) {
700
+ if (!failed.length && op.projects.some((p) => canChooseFresh(p.terminals[`restored:${peer}`]) && plannedAttached(op, p, peer, live))) {
668
701
  throw new Error(`${peer}: its planned session is attached again, so nothing was lost; resume records it as restored; ${next}`);
669
702
  }
670
703
  if (!failed.length) throw new Error(`${peer}: no failed restoration of it is open to a fresh session (none failed, or one is already chosen); --fresh-session applies only then (${recoveryCommand(op, "status")})`);
package/src/hub/board.ts CHANGED
@@ -8,7 +8,14 @@ import type { SdlcReceipt } from "./sdlc.ts";
8
8
 
9
9
  export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
10
10
  export type TaskClass = (typeof CLASSES)[number];
11
- export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "approved" | "changes_requested";
11
+ export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "approved" | "changes_requested" | "cancelled";
12
+
13
+ export const isClosedTaskState = (state: string): boolean => state === "approved" || state === "cancelled";
14
+ export interface TaskOffer { envelopeId: string; owner: PeerId; assignmentFlowRevision: number; pullOnly?: boolean }
15
+ export interface TaskCancellation { reason: string; supersededBy?: string; flowRevision: number; withheld?: true }
16
+ export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable"] as const;
17
+ export type TaskStallCode = (typeof TASK_STALL_CODES)[number];
18
+ export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency"; peer?: string; dependency?: number; code: TaskStallCode; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
12
19
 
13
20
  export interface TaskRefs {
14
21
  repo?: string;
@@ -31,6 +38,7 @@ export interface HistoryEntry {
31
38
  by: PeerId;
32
39
  event: string; // proposed | assigned | accepted | declined | done | approved | changes_requested | escalated | reassigned
33
40
  note?: string;
41
+ supersededBy?: string;
34
42
  /** The owner an event that set it left the task with (#67); absent in rows written before 0.11. */
35
43
  owner?: PeerId | null;
36
44
  /** On a decline or an escalation: the owner it was declined for or taken from (#207); later reroutes leave it out. */
@@ -46,6 +54,8 @@ export interface HistoryEntry {
46
54
  planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
47
55
  }
48
56
  export interface Task extends PlanningFields {
57
+ offer?: TaskOffer;
58
+ cancelled?: TaskCancellation;
49
59
  external?: GitHubSource;
50
60
  planningRevision?: number;
51
61
  id: number;
@@ -91,12 +101,13 @@ export function planningUndoEntry(task: Pick<Task, "history">): HistoryEntry | u
91
101
 
92
102
  /** The moves the state machine allows. `accept` and `done` are transitions, not states: they carried no behaviour of their own. */
93
103
  const MOVES: Record<TaskState, TaskState[]> = {
94
- backlog: ["proposed"],
95
- proposed: ["in_progress", "backlog"],
96
- in_progress: ["in_review", "approved", "proposed"], // approved: class without a reviewer; proposed: owner declined or was taken off
97
- in_review: ["approved", "changes_requested"],
98
- changes_requested: ["in_progress", "proposed"], // proposed: declined redo waits for reassignment
104
+ backlog: ["proposed", "cancelled"],
105
+ proposed: ["in_progress", "backlog", "cancelled"],
106
+ in_progress: ["in_review", "approved", "proposed", "cancelled"], // approved: class without a reviewer; proposed: owner declined or was taken off
107
+ in_review: ["approved", "changes_requested", "cancelled"],
108
+ changes_requested: ["in_progress", "proposed", "cancelled"], // proposed: declined redo waits for reassignment
99
109
  approved: [],
110
+ cancelled: [],
100
111
  };
101
112
 
102
113
  const JSON_COLS = ["refs", "plan", "deps", "signals", "history", "sdlc"] as const;
@@ -123,6 +134,19 @@ export class Board {
123
134
  return result;
124
135
  }
125
136
 
137
+ /** Keep the generation and business write atomic without changing Bun's reported row count. */
138
+ private taskWrite<T extends { changes: number }>(write: () => T): T {
139
+ return this.db.transaction(() => {
140
+ const result = { ...write() };
141
+ if (result.changes) this.db.run("UPDATE board_status_revision SET revision=revision+1 WHERE id=1");
142
+ return result;
143
+ })();
144
+ }
145
+
146
+ statusRevision(): number {
147
+ return (this.db.query("SELECT revision FROM board_status_revision WHERE id=1").get() as { revision: number }).revision;
148
+ }
149
+
126
150
  constructor(path: string) {
127
151
  this.db = new Database(path, { create: true });
128
152
  this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
@@ -145,6 +169,11 @@ export class Board {
145
169
  if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN ${col} TEXT NOT NULL DEFAULT '${empty}'`);
146
170
  }
147
171
  // And from before issue #207: existing rows reserve nobody.
172
+ for (const col of ["offer", "cancelled"]) if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN ${col} TEXT`);
173
+ this.db.run("CREATE TABLE IF NOT EXISTS task_stalls (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
174
+ // Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
175
+ this.db.run("CREATE TABLE IF NOT EXISTS board_status_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL)");
176
+ this.db.run("INSERT OR IGNORE INTO board_status_revision VALUES (1,0)");
148
177
  if (!have.has("reserved")) this.db.run("ALTER TABLE tasks ADD COLUMN reserved TEXT");
149
178
  if (!have.has("external_repo")) this.db.run("ALTER TABLE tasks ADD COLUMN external_repo TEXT");
150
179
  if (!have.has("external_issue")) this.db.run("ALTER TABLE tasks ADD COLUMN external_issue INTEGER");
@@ -168,9 +197,9 @@ export class Board {
168
197
  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 {
169
198
  const now = Date.now();
170
199
  const history: HistoryEntry[] = [{ at: now, by, event: t.backlog ? "backlogged" : "proposed" }];
171
- const { lastInsertRowid } = this.db
200
+ const { lastInsertRowid } = this.taskWrite(() => this.db
172
201
  .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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
173
- .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);
202
+ .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));
174
203
  const task = this.get(Number(lastInsertRowid))!;
175
204
  this.changed(task, history[0]!);
176
205
  return task;
@@ -193,18 +222,46 @@ export class Board {
193
222
 
194
223
  /** Structural status facts only; avoid decoding text, plans and growing history on each status write. */
195
224
  statusTasks(): Pick<Task, "id" | "state" | "owner" | "reviewer">[] {
196
- return this.db.query("SELECT id, state, owner, reviewer FROM tasks WHERE state != 'approved' ORDER BY id").all() as Pick<Task, "id" | "state" | "owner" | "reviewer">[];
225
+ return this.db.query("SELECT id, state, owner, reviewer FROM tasks WHERE state NOT IN ('approved','cancelled') ORDER BY id").all() as Pick<Task, "id" | "state" | "owner" | "reviewer">[];
197
226
  }
198
227
  statusReservations(): string[] {
199
- return (this.db.query("SELECT DISTINCT reserved FROM tasks WHERE state NOT IN ('approved', 'backlog') AND reserved IS NOT NULL ORDER BY reserved").all() as { reserved: string }[]).map(row => row.reserved);
228
+ return (this.db.query("SELECT DISTINCT reserved FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND reserved IS NOT NULL ORDER BY reserved").all() as { reserved: string }[]).map(row => row.reserved);
200
229
  }
201
230
  plannerWip(peer: string, except?: number): number {
202
- const row = this.db.query(`SELECT COUNT(*) AS n FROM tasks WHERE state NOT IN ('approved', 'backlog') AND (? IS NULL OR id != ?) AND
231
+ const row = this.db.query(`SELECT COUNT(*) AS n FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND (? IS NULL OR id != ?) AND
203
232
  (owner = ? OR (owner IS NULL AND state = 'proposed' AND reserved = ? AND
204
233
  json_extract((SELECT value FROM json_each(tasks.history) WHERE json_extract(value, '$.event') IN ('backlog pulled', 'backlog returned') ORDER BY CAST(key AS INTEGER) DESC LIMIT 1), '$.planning.pull') = 1))`).get(except ?? null, except ?? null, peer, peer) as { n: number };
205
234
  return row.n;
206
235
  }
207
236
 
237
+ recordTaskOffer(id: number, offer: TaskOffer, expectedFlowRevision: number): void {
238
+ const task = this.get(id);
239
+ if (!task || task.owner !== offer.owner || isClosedTaskState(task.state) || task.history.length !== expectedFlowRevision || offer.assignmentFlowRevision !== expectedFlowRevision) throw new Error("task offer changed");
240
+ const changed = this.taskWrite(() => this.db.query("UPDATE tasks SET offer=? WHERE id=? AND owner=? AND history=?").run(JSON.stringify(offer), id, offer.owner, JSON.stringify(task.history)));
241
+ if (!changed.changes) throw new Error("task offer changed");
242
+ }
243
+ recordStall(stall: TaskStall): boolean {
244
+ const old = this.stalls().find(row => row.task === stall.task);
245
+ if (old?.signature === stall.signature && old.replanRevision === stall.replanRevision) return false;
246
+ this.taskWrite(() => this.db.query("INSERT INTO task_stalls(task,payload) VALUES(?,?) ON CONFLICT(task) DO UPDATE SET payload=excluded.payload").run(stall.task, JSON.stringify(stall)));
247
+ return true;
248
+ }
249
+ clearStall(task: number): void { this.taskWrite(() => this.db.query("DELETE FROM task_stalls WHERE task=?").run(task)); }
250
+ stalls(): TaskStall[] {
251
+ return (this.db.query("SELECT payload FROM task_stalls ORDER BY task").all() as { payload: string }[]).flatMap(row => {
252
+ try {
253
+ const value = JSON.parse(row.payload) as TaskStall, cause = value.cause;
254
+ const peer = (id: unknown) => typeof id === "string" && /^[a-z][a-z0-9_-]{0,63}$/i.test(id);
255
+ if (Object.keys(value).some(key => !["task", "signature", "replanRevision", "cause", "at"].includes(key)) || !Number.isSafeInteger(value.task) || value.task < 1 || !Number.isSafeInteger(value.replanRevision) || value.replanRevision < 0 || !Number.isFinite(value.at)
256
+ || !cause || !["reservation", "no-eligible-peer", "cancelled-dependency"].includes(cause.kind) || !TASK_STALL_CODES.includes(cause.code)
257
+ || cause.peer !== undefined && !peer(cause.peer) || cause.dependency !== undefined && (!Number.isSafeInteger(cause.dependency) || cause.dependency < 1)
258
+ || cause.refusals !== undefined && (!Array.isArray(cause.refusals) || cause.refusals.length > 64 || cause.refusals.some(row => Object.keys(row).some(key => !["peer", "code"].includes(key)) || !peer(row.peer) || !TASK_STALL_CODES.includes(row.code)))
259
+ || Object.keys(cause).some(key => !["kind", "peer", "dependency", "code", "refusals"].includes(key)) || value.signature !== JSON.stringify(cause)) return [];
260
+ return [value];
261
+ } catch { return []; }
262
+ });
263
+ }
264
+
208
265
  /** Tasks per state, without parsing a single row: `status.json` is rewritten on every bus event and the board only grows. */
209
266
  counts(): Record<string, number> {
210
267
  const rows = this.db.query("SELECT state, COUNT(*) AS n FROM tasks GROUP BY state").all() as { state: string; n: number }[];
@@ -213,9 +270,10 @@ export class Board {
213
270
 
214
271
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
215
272
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
216
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld"> = {}): Task {
273
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld"> = {}, expectedFlowRevision?: number): Task {
217
274
  const task = this.get(id);
218
275
  if (!task) throw new Error(`no task #${id}`);
276
+ if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
219
277
  if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state)) {
220
278
  throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
221
279
  }
@@ -223,10 +281,10 @@ export class Board {
223
281
  if (next.state === "backlog" && (next.owner || next.reviewer)) throw new Error(`task #${id}: backlog cannot have an owner or reviewer`);
224
282
  if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
225
283
  if (patch.state === "backlog" && taskHasStarted(task)) throw new Error(`task #${id}: started work cannot return to backlog`);
226
- const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("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.withheld && note ? { withheld: true as const } : {}) }];
227
- this.db
228
- .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, updated = ? WHERE id = ?")
229
- .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, Date.now(), id);
284
+ const history = [...task.history, { at: Date.now(), by, event, ...(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.withheld && note ? { withheld: true as const } : {}) }];
285
+ this.taskWrite(() => this.db
286
+ .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
287
+ .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));
230
288
  const updated = this.get(id)!;
231
289
  this.changed(updated, history.at(-1)!);
232
290
  return updated;
@@ -242,8 +300,8 @@ export class Board {
242
300
  if (next.release != null && !this.release(next.release)) throw new Error("no such release");
243
301
  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;
244
302
  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 } : {}) };
245
- const result = 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=?`)
246
- .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);
303
+ 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=?`)
304
+ .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));
247
305
  if (result.changes !== 1) throw new Error("planning revision changed");
248
306
  const updated = this.get(id)!; this.changed(updated, entry); return updated;
249
307
  }
@@ -253,8 +311,8 @@ export class Board {
253
311
  const task = this.get(id);
254
312
  if (!task || (task.planningRevision ?? 0) !== revision || task.state !== "backlog" || task.owner || taskHasStarted(task)) throw new Error("planner pull changed or already started");
255
313
  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 } : {}) };
256
- const result = this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
257
- .run(reserved, JSON.stringify([...task.history, entry]), now, id, revision);
314
+ const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
315
+ .run(reserved, JSON.stringify([...task.history, entry]), now, id, revision));
258
316
  if (result.changes !== 1) throw new Error("planner pull changed");
259
317
  const updated = this.get(id)!; this.changed(updated, entry); return updated;
260
318
  }
@@ -264,8 +322,8 @@ export class Board {
264
322
  const task = this.get(id), pull = task ? planningUndoEntry(task)?.planning : undefined;
265
323
  if (!task || task.state !== "proposed" || task.owner || taskHasStarted(task) || (task.planningRevision ?? 0) !== revision || !pull?.pull || pull.revision !== revision) throw new Error("planner pull undo changed or already offered");
266
324
  const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "backlog returned", note: `undo planner pull revision ${revision}` };
267
- const result = this.db.query("UPDATE tasks SET state='backlog',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='proposed' AND owner IS NULL")
268
- .run(pull.previous.reserved ?? null, JSON.stringify([...task.history, entry]), now, id, revision);
325
+ const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='backlog',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='proposed' AND owner IS NULL")
326
+ .run(pull.previous.reserved ?? null, JSON.stringify([...task.history, entry]), now, id, revision));
269
327
  if (result.changes !== 1) throw new Error("planner pull undo changed");
270
328
  const updated = this.get(id)!; this.changed(updated, entry); return updated;
271
329
  }
@@ -287,7 +345,7 @@ export class Board {
287
345
  const updated = this.plan(id, by, { deps }, revision, note, withheld);
288
346
  const entry = updated.history.at(-1)!;
289
347
  entry.planning!.split = { children: children.map(child => ({ id: child.id, revision: child.planningRevision ?? 0, history: child.history.length })) };
290
- this.db.query("UPDATE tasks SET history=? WHERE id=?").run(JSON.stringify(updated.history), id);
348
+ this.taskWrite(() => this.db.query("UPDATE tasks SET history=? WHERE id=?").run(JSON.stringify(updated.history), id));
291
349
  const notification = this.pendingChanges?.findLast(change => change.task.id === id && change.entry.planning?.revision === revision + 1);
292
350
  if (notification) { notification.task = updated; notification.entry = entry; }
293
351
  return { parent: this.get(id)!, children };
@@ -305,7 +363,7 @@ export class Board {
305
363
  }
306
364
  if (this.list().some(task => task.id !== id && !ids.has(task.id) && (task.deps?.some(dep => ids.has(dep)) || task.sdlc?.phase?.tasks.some(ref => ids.has(ref.id) || ref.after.some(dep => ids.has(dep)))))) throw new Error("split children are referenced by other work");
307
365
  const restored = this.plan(id, by, { deps: entry!.planning!.previous.deps ?? [] }, revision, `undo split revision ${revision}`);
308
- for (const child of ids) this.db.query("DELETE FROM tasks WHERE id=?").run(child);
366
+ for (const child of ids) this.taskWrite(() => this.db.query("DELETE FROM tasks WHERE id=?").run(child));
309
367
  return restored;
310
368
  });
311
369
  }
@@ -373,6 +431,7 @@ export interface ReviewOutcome {
373
431
 
374
432
  function parse(row: Record<string, unknown>): Task {
375
433
  const out = { ...row } as Record<string, unknown>;
434
+ for (const key of ["offer", "cancelled"]) if (row[key] == null) delete out[key]; else out[key] = JSON.parse(String(row[key]));
376
435
  for (const col of JSON_COLS) {
377
436
  if (col === "sdlc" && row[col] == null) delete out[col];
378
437
  else out[col] = JSON.parse(String(row[col]));