@staix/agent-hub 0.20.0 → 0.22.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 (183) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +17 -8
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +39 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +610 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +133 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  37. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  38. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  39. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  40. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  41. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  42. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  43. package/docs/verification/claude-permission-transport-native.py +71 -14
  44. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  45. package/docs/verified.json +15 -15
  46. package/package.json +2 -2
  47. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  48. package/plugins/agent-hub/server.js +1016 -280
  49. package/src/adapters/acp.ts +79 -3
  50. package/src/adapters/channel-peer.ts +4 -0
  51. package/src/adapters/claude-channel.ts +13 -6
  52. package/src/adapters/claude-implementer.ts +60 -7
  53. package/src/adapters/codex-appserver.ts +77 -4
  54. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  55. package/src/adapters/conformance/kit.ts +3 -9
  56. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  57. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  58. package/src/adapters/definitions/native-config-policy.ts +3 -2
  59. package/src/adapters/definitions/native-context.ts +12 -0
  60. package/src/adapters/external-metadata.ts +48 -0
  61. package/src/adapters/external-modules.ts +63 -3
  62. package/src/adapters/jcode.ts +6 -2
  63. package/src/adapters/local-worker.ts +40 -25
  64. package/src/adapters/native-proof-ports.ts +10 -0
  65. package/src/adapters/registry-types.ts +4 -0
  66. package/src/broker/protocol.ts +203 -0
  67. package/src/broker/server.ts +314 -0
  68. package/src/broker/store.ts +548 -0
  69. package/src/cli/bench.ts +3 -2
  70. package/src/cli/broker.ts +58 -0
  71. package/src/cli/conductor-recovery.ts +142 -0
  72. package/src/cli/configured-acp-entry.ts +26 -6
  73. package/src/cli/console-state.ts +59 -17
  74. package/src/cli/console.ts +17 -2
  75. package/src/cli/facts-hook.ts +3 -1
  76. package/src/cli/help.ts +29 -16
  77. package/src/cli/identity.ts +25 -10
  78. package/src/cli/init.ts +34 -7
  79. package/src/cli/intake-output.ts +33 -0
  80. package/src/cli/learn-output.ts +38 -0
  81. package/src/cli/main.ts +334 -73
  82. package/src/cli/output.ts +41 -1
  83. package/src/cli/recovery-package.ts +18 -1
  84. package/src/cli/recovery-waits.ts +26 -0
  85. package/src/cli/role-holders.ts +35 -0
  86. package/src/cli/room.ts +304 -0
  87. package/src/cli/say-input.ts +16 -0
  88. package/src/cli/schedule-output.ts +49 -0
  89. package/src/cli/sdlc-output.ts +44 -6
  90. package/src/cli/smoke-pty-bridge.ts +82 -0
  91. package/src/cli/smoke-pty.ts +153 -0
  92. package/src/cli/smoke-run.ts +139 -0
  93. package/src/cli/smoke-target.ts +130 -0
  94. package/src/cli/source-output.ts +29 -0
  95. package/src/cli/task-output.ts +3 -2
  96. package/src/cli/upgrade-runtime.ts +112 -28
  97. package/src/cli/upgrade.ts +68 -8
  98. package/src/hub/ask.ts +76 -12
  99. package/src/hub/board.ts +132 -22
  100. package/src/hub/bus.ts +416 -65
  101. package/src/hub/child-process.ts +2 -0
  102. package/src/hub/ci-watch.ts +42 -20
  103. package/src/hub/claude-failure-stage.ts +85 -0
  104. package/src/hub/claude-shell-wrapper.ts +11 -5
  105. package/src/hub/conductor.ts +17 -4
  106. package/src/hub/config-apply.ts +34 -6
  107. package/src/hub/config-reload.ts +7 -5
  108. package/src/hub/config-trust.ts +7 -1
  109. package/src/hub/context-window.ts +33 -3
  110. package/src/hub/control-client.ts +14 -2
  111. package/src/hub/corpus-config.ts +34 -0
  112. package/src/hub/corpus-evidence.ts +184 -0
  113. package/src/hub/corpus.ts +373 -0
  114. package/src/hub/cost-governance.ts +26 -0
  115. package/src/hub/daemon.ts +1209 -183
  116. package/src/hub/deliverable-files.ts +8 -2
  117. package/src/hub/delivery-journal.ts +200 -73
  118. package/src/hub/dogfood-profile.ts +32 -5
  119. package/src/hub/dogfood-read.ts +6 -2
  120. package/src/hub/envelope.ts +11 -0
  121. package/src/hub/eval-gate.ts +131 -0
  122. package/src/hub/events.ts +1 -1
  123. package/src/hub/external-instance.ts +69 -0
  124. package/src/hub/facts.ts +15 -5
  125. package/src/hub/folder-trigger.ts +223 -0
  126. package/src/hub/hub-tools.ts +2 -1
  127. package/src/hub/inference.ts +20 -5
  128. package/src/hub/instance-config.ts +69 -3
  129. package/src/hub/instance-execution.ts +6 -2
  130. package/src/hub/instances.ts +32 -4
  131. package/src/hub/intake.ts +125 -0
  132. package/src/hub/learning.ts +117 -0
  133. package/src/hub/lifecycle.ts +7 -3
  134. package/src/hub/limits.ts +12 -5
  135. package/src/hub/native-permission-request.ts +77 -0
  136. package/src/hub/operational-authority.ts +63 -5
  137. package/src/hub/operational-settings.ts +154 -0
  138. package/src/hub/operational-view.ts +1 -1
  139. package/src/hub/packs.ts +132 -0
  140. package/src/hub/path-policy.ts +7 -0
  141. package/src/hub/peer-doing.ts +130 -0
  142. package/src/hub/peers.ts +12 -0
  143. package/src/hub/queue-backpressure.ts +58 -0
  144. package/src/hub/read-tools.ts +2 -0
  145. package/src/hub/recovery-authority.ts +151 -0
  146. package/src/hub/recovery-policy.ts +220 -0
  147. package/src/hub/research.ts +24 -14
  148. package/src/hub/restart.ts +7 -0
  149. package/src/hub/routing.ts +54 -11
  150. package/src/hub/schedules.ts +266 -0
  151. package/src/hub/sdlc-merge.ts +38 -14
  152. package/src/hub/sdlc-metrics.ts +32 -10
  153. package/src/hub/sdlc-queue.ts +7 -1
  154. package/src/hub/sdlc-release.ts +1 -1
  155. package/src/hub/sdlc.ts +37 -7
  156. package/src/hub/security-baseline.ts +38 -0
  157. package/src/hub/settings.ts +27 -2
  158. package/src/hub/shipped-packs.ts +8 -0
  159. package/src/hub/snapshots.ts +72 -6
  160. package/src/hub/source-read.ts +160 -0
  161. package/src/hub/sources.ts +311 -0
  162. package/src/hub/tasks.ts +987 -110
  163. package/src/hub/workflow.ts +1 -1
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/tools.ts +37 -3
  167. package/src/local/web-fetch-config.ts +43 -0
  168. package/src/local/web-fetch.ts +183 -0
  169. package/src/memory/brief.ts +85 -13
  170. package/src/memory/capture.ts +1 -1
  171. package/src/rooms/application.ts +114 -0
  172. package/src/rooms/boundary.ts +103 -0
  173. package/src/rooms/commands.ts +294 -0
  174. package/src/rooms/crypto.ts +371 -0
  175. package/src/rooms/link.ts +584 -0
  176. package/src/rooms/policy.ts +82 -0
  177. package/src/rooms/remote-peer.ts +80 -0
  178. package/src/rooms/service.ts +426 -0
  179. package/src/rooms/status.ts +146 -0
  180. package/src/rooms/store.ts +1210 -0
  181. package/src/rooms/wire.ts +465 -0
  182. package/src/ui/index.html +91 -4
  183. package/templates/packs/example.pack.json +13 -0
package/src/hub/board.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { HOST_CODE_SYMBOLS, parseTaskCodeSymbols, type TaskCodeSymbols } from "./task-code-symbols.ts";
2
+ import { createHash } from "node:crypto";
2
3
  import { planFingerprint } from "./plan-conformance.ts";
3
4
  import type { PlannerReason } from "./routing.ts";
4
5
  import type { ReviewerPoolReason } from "./reviewer-pool.ts";
@@ -20,10 +21,26 @@ export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "
20
21
  export const isClosedTaskState = (state: string): boolean => state === "approved" || state === "cancelled";
21
22
  export interface TaskOffer { envelopeId: string; owner: PeerId; assignmentFlowRevision: number; pullOnly?: boolean }
22
23
  export interface TaskCancellation { reason: string; supersededBy?: string; flowRevision: number; withheld?: true }
23
- export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "no-writes"] as const;
24
+ export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "no-writes", "queue-count", "queue-age", "queue-age-unknown"] as const;
24
25
  export type TaskStallCode = (typeof TASK_STALL_CODES)[number];
25
26
  export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency" | "no-writes"; peer?: string; dependency?: number; code: TaskStallCode; turns?: number; acceptance?: number; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
26
27
 
28
+ const QUEUE_PRESSURE_STALL = "CASE WHEN length(CAST(payload AS BLOB)) <= 65536 THEN CASE WHEN json_valid(payload) THEN json_extract(payload,'$.cause.kind')='no-eligible-peer' AND json_extract(payload,'$.cause.code') IN ('queue-count','queue-age','queue-age-unknown') ELSE 0 END ELSE 0 END";
29
+ export interface QueuePressureStallPage { rows: TaskStall[]; afterTask: number; done: boolean }
30
+ function parseStall(payload: string, task?: number): TaskStall | undefined {
31
+ try {
32
+ const value = JSON.parse(payload) as TaskStall, cause = value.cause;
33
+ const peer = (id: unknown) => typeof id === "string" && /^[a-z][a-z0-9_-]{0,63}$/i.test(id);
34
+ if (task !== undefined && value.task !== task || 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)
35
+ || !cause || !["reservation", "no-eligible-peer", "cancelled-dependency", "no-writes"].includes(cause.kind) || !TASK_STALL_CODES.includes(cause.code)
36
+ || cause.kind === "no-writes" && (cause.code !== "no-writes" || !peer(cause.peer) || !Number.isSafeInteger(cause.turns) || cause.turns! < 1 || !Number.isSafeInteger(cause.acceptance) || cause.acceptance! < 0)
37
+ || cause.peer !== undefined && !peer(cause.peer) || cause.dependency !== undefined && (!Number.isSafeInteger(cause.dependency) || cause.dependency < 1)
38
+ || 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)))
39
+ || Object.keys(cause).some(key => !["kind", "peer", "dependency", "code", "refusals", ...(cause.kind === "no-writes" ? ["turns", "acceptance"] : [])].includes(key)) || value.signature !== JSON.stringify(cause)) return undefined;
40
+ return value;
41
+ } catch { return undefined; }
42
+ }
43
+
27
44
  export interface TaskRefs {
28
45
  repo?: string;
29
46
  branch?: string;
@@ -58,6 +75,8 @@ export interface HistoryEntry {
58
75
  from?: PeerId;
59
76
  /** On a decline: the owner had no capacity right now (#531). Absent on older rows and on a plain refusal, which read as lasting. */
60
77
  capacity?: true;
78
+ /** Host-observed explicit conductor assignment/removal, retained independently of later role changes. */
79
+ conductorManual?: true;
61
80
  /** On a hand-over: the new owner's split profile then (#109); absent when it was unknown. */
62
81
  profile?: string;
63
82
  /** Structured cause recorded at the operation source, never inferred from note text. */
@@ -70,6 +89,12 @@ export interface HistoryEntry {
70
89
  noWrites?: true;
71
90
  /** Host-recorded expected cost and exact review head, never narrative. */
72
91
  reviewCost?: ReviewCost;
92
+ /** #901: on the hub's own `check passed` / `check failed`: the check key (task class or profile class alias) whose command ran. Set by the hub from its own selection, never from output or an owner's words. */
93
+ checkKey?: string;
94
+ /** #901: on such an entry, the number of unresolved citation keys the example citation check printed (`citations: N declared keys, M missing`); absent when the check printed no such line. */
95
+ missingKeys?: number;
96
+ /** #901: a person's verdict on one declared claim of a document plan. `slot` is the claim's 1-based position in the current plan; no claim text, no source and no path is stored. */
97
+ claim?: ClaimVerdict;
73
98
  /** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
74
99
  reviewContext?: "fresh" | "continuing";
75
100
  /** #852: a verdict by a `code`-scope reviewer says so; a `full` reviewer's verdict carries nothing, so existing rows are unchanged. */
@@ -94,21 +119,45 @@ export interface HistoryEntry {
94
119
  reviewerFamily?: string;
95
120
  reviewerReason?: ReviewerPoolReason;
96
121
  reviewBlocked?: true;
122
+ /** Host-only temporary automatic review hold caused by queue admission, never a reviewer failure. */
123
+ queueReviewHeld?: true;
124
+ /** Opaque artifact identity captured by the host at the queue-held role write. */
125
+ queueReviewBinding?: string;
97
126
  reviewerFailure?: { peer: string; head?: string; cause: "start_failed" | "quota" | "no_verdict" };
98
127
  planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
99
128
  }
129
+ /** #901: one person verdict on a plan claim, with the model label the person says produced the claim. All closed or bounded; nothing here is claim text. */
130
+ export const CLAIM_VERDICTS = ["supported", "unsupported", "unclear"] as const;
131
+ /** `modelVerdict` is what the model's own fact check said of the claim, as the person enters it: with it the scorecard can say whether person and model agree; without it agreement is unknown. */
132
+ export interface ClaimVerdict { slot: number; verdict: (typeof CLAIM_VERDICTS)[number]; model?: string; modelVerdict?: (typeof CLAIM_VERDICTS)[number]; /** `claimListId` of the plan's claims when the verdict was entered: the list it evaluated, as a hash. Set by the hub, never by the person. */ list?: string }
133
+ /** A short opaque identity of an ordered claim list (a sha-256 prefix of its canonical form): equal lists, in order, give equal ids; it reveals nothing of the claims. Absent list: the empty list's id. */
134
+ export function claimListId(claims: readonly string[] | undefined): string {
135
+ return createHash("sha256").update(planFingerprint({ claims: [...(claims ?? [])] })).digest("hex").slice(0, 16);
136
+ }
137
+ /** A claim slot is a position in the plan's claim list (at most 50, as the scorecard's own bound); a model label is a bounded token, never prose. */
138
+ export function parseClaimVerdict(value: unknown, slots: number): ClaimVerdict {
139
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("claim verdict must be an object");
140
+ const v = value as Record<string, unknown>;
141
+ if (Object.keys(v).some(key => !["slot", "verdict", "model", "modelVerdict"].includes(key))) throw new Error("claim verdict carries only slot, verdict, model and modelVerdict");
142
+ if (!Number.isSafeInteger(v.slot) || (v.slot as number) < 1 || (v.slot as number) > Math.min(50, slots)) throw new Error(`claim slot must name one of the task's ${slots} declared claim${slots === 1 ? "" : "s"}`);
143
+ if (typeof v.verdict !== "string" || !(CLAIM_VERDICTS as readonly string[]).includes(v.verdict)) throw new Error(`claim verdict must be one of ${CLAIM_VERDICTS.join(", ")}`);
144
+ if (v.model !== undefined && (typeof v.model !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,63}$/.test(v.model))) throw new Error("model label must be a short identifier (letters, digits and . _ : / @ + -)");
145
+ if (v.modelVerdict !== undefined && (typeof v.modelVerdict !== "string" || !(CLAIM_VERDICTS as readonly string[]).includes(v.modelVerdict))) throw new Error(`model verdict must be one of ${CLAIM_VERDICTS.join(", ")}`);
146
+ return { slot: v.slot as number, verdict: v.verdict as ClaimVerdict["verdict"], ...(v.model === undefined ? {} : { model: v.model }), ...(v.modelVerdict === undefined ? {} : { modelVerdict: v.modelVerdict as ClaimVerdict["verdict"] }) };
147
+ }
100
148
  /** The ids a stored verdict may carry (#507b): bounded criterion identifiers in the S8 checklist's own format, at most its 32, each once. */
101
149
  function criterionIdList(value: unknown): string[] {
102
150
  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");
103
151
  return [...value] as string[];
104
152
  }
105
- export type ReviewerHistory = Pick<HistoryEntry, "reviewerFamily" | "reviewerReason" | "reviewBlocked" | "reviewerFailure">;
153
+ export type ReviewerHistory = Pick<HistoryEntry, "reviewerFamily" | "reviewerReason" | "reviewBlocked" | "queueReviewHeld" | "queueReviewBinding" | "reviewerFailure">;
106
154
  function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
107
155
  const id = (value: unknown) => typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
108
- if (extra.reviewerFamily !== undefined && !id(extra.reviewerFamily) || extra.reviewerReason !== undefined && !["rotation", "implementer skip", "unavailable skip", "same-family fallback"].includes(extra.reviewerReason) || extra.reviewBlocked !== undefined && extra.reviewBlocked !== true) throw new Error("invalid reviewer metadata");
156
+ if (extra.reviewerFamily !== undefined && !id(extra.reviewerFamily) || extra.reviewerReason !== undefined && !["rotation", "implementer skip", "unavailable skip", "same-family fallback"].includes(extra.reviewerReason) || extra.reviewBlocked !== undefined && extra.reviewBlocked !== true || extra.queueReviewHeld !== undefined && extra.queueReviewHeld !== true) throw new Error("invalid reviewer metadata");
157
+ if (extra.queueReviewHeld && (typeof extra.queueReviewBinding !== "string" || !/^[0-9a-f]{64}$/.test(extra.queueReviewBinding)) || extra.queueReviewBinding !== undefined && extra.queueReviewHeld !== true) throw new Error("invalid queue-held review binding");
109
158
  const failure = extra.reviewerFailure;
110
159
  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");
111
- 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 } : {}) } } : {}) };
160
+ return { ...(extra.reviewerFamily ? { reviewerFamily: extra.reviewerFamily } : {}), ...(extra.reviewerReason ? { reviewerReason: extra.reviewerReason } : {}), ...(extra.reviewBlocked ? { reviewBlocked: true } : {}), ...(extra.queueReviewHeld ? { queueReviewHeld: true, ...(extra.queueReviewBinding !== undefined ? { queueReviewBinding: extra.queueReviewBinding } : {}) } : {}), ...(failure ? { reviewerFailure: { peer: failure.peer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } } : {}) };
112
161
  }
113
162
  export interface PlannerBinding { planner: PeerId | null; plannerReason: PlannerReason }
114
163
  function plannerValue(value: unknown): PeerId | null {
@@ -125,6 +174,8 @@ export interface Task extends PlanningFields {
125
174
  cancelled?: TaskCancellation;
126
175
  external?: GitHubSource;
127
176
  planningRevision?: number;
177
+ /** #334: an intake draft. Backlog work that is never offered or pulled until the person approves it; split children inherit it. */
178
+ draft?: true;
128
179
  id: number;
129
180
  title: string;
130
181
  detail: string;
@@ -261,8 +312,12 @@ export class Board {
261
312
  // And from before issue #207: existing rows reserve nobody.
262
313
  for (const col of ["offer", "cancelled"]) if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN ${col} TEXT`);
263
314
  this.db.run("CREATE TABLE IF NOT EXISTS task_stalls (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
315
+ this.db.run(`CREATE INDEX IF NOT EXISTS task_stalls_queue_pressure ON task_stalls(task) WHERE ${QUEUE_PRESSURE_STALL}`);
316
+ this.db.run("CREATE INDEX IF NOT EXISTS tasks_human_reviews ON tasks(id) WHERE state='in_review' AND reviewer='user'");
264
317
  // #481 S7: one row per log-tier band breach episode already announced, so a restart never repeats the notice.
265
318
  this.db.run("CREATE TABLE IF NOT EXISTS band_notices (marker TEXT PRIMARY KEY, at INTEGER NOT NULL)");
319
+ // #481 S7: one row per scheduled security scan. Counts only (no alert text); `total` is null when a named source could not be read completely.
320
+ this.db.run("CREATE TABLE IF NOT EXISTS security_scans (id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, repo TEXT NOT NULL, sources TEXT NOT NULL, counts TEXT NOT NULL, total INTEGER)");
266
321
  // #843 AC3: one row per (agent id, installed version, planner) already queued, so a restart never repeats it.
267
322
  this.db.run("CREATE TABLE IF NOT EXISTS agent_drift_notices (marker TEXT PRIMARY KEY, at INTEGER NOT NULL)");
268
323
  // Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
@@ -283,6 +338,7 @@ export class Board {
283
338
  if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN "${col}" INTEGER`);
284
339
  }
285
340
  if (!have.has("pinned")) this.db.run("ALTER TABLE tasks ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0");
341
+ if (!have.has("draft")) this.db.run("ALTER TABLE tasks ADD COLUMN draft INTEGER NOT NULL DEFAULT 0");
286
342
  if (!have.has("planning_revision")) this.db.run("ALTER TABLE tasks ADD COLUMN planning_revision INTEGER NOT NULL DEFAULT 0");
287
343
  this.db.run(`CREATE TABLE IF NOT EXISTS releases (
288
344
  id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, goal TEXT NOT NULL,
@@ -295,14 +351,14 @@ export class Board {
295
351
 
296
352
  }
297
353
 
298
- 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 {
354
+ 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; draft?: boolean; external?: GitHubSource }): Task {
299
355
  const now = Date.now(), planner = plannerValue(t.planner);
300
356
  const chosen = Object.hasOwn(t, "planner") || t.plannerReason !== undefined ? plannerMetadata(planner, t.plannerReason) : {};
301
357
  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) } } : {}) }];
302
358
  const { lastInsertRowid } = this.taskWrite(() => {
303
359
  const inserted = this.db
304
- .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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
305
- .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);
360
+ .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, draft) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
361
+ .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, t.draft && t.backlog ? 1 : 0);
306
362
  if (this.proposalPlanner && !Object.hasOwn(t, "planner") && t.plannerReason === undefined) {
307
363
  const selected = this.proposalPlanner(this.get(Number(inserted.lastInsertRowid))!);
308
364
  const binding = plannerMetadata(selected.planner, selected.plannerReason);
@@ -374,6 +430,16 @@ export class Board {
374
430
  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)));
375
431
  return true;
376
432
  }
433
+ /** #481 S7: the scan history of one repository and source set, oldest first. Counts and times only. */
434
+ /** `knownOnly` filters unknown totals in SQL, before the limit, so rows with no total never use up the rows a series needs. */
435
+ securityScans(repo: string, sources: string, limit = 200, knownOnly = false): { id: number; at: number; total: number | null }[] {
436
+ const rows = this.db.query(`SELECT id, at, total FROM security_scans WHERE repo=? AND sources=?${knownOnly ? " AND total IS NOT NULL" : ""} ORDER BY id DESC LIMIT ?`).all(repo.toLowerCase(), sources, Math.max(1, Math.min(1000, limit))) as { id: number; at: number; total: number | null }[];
437
+ return rows.reverse();
438
+ }
439
+ recordSecurityScan(row: { repo: string; sources: string; counts: Record<string, number | null>; total: number | null }): number {
440
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(row.repo) || !/^[a-z_,]{1,80}$/.test(row.sources) || (row.total !== null && (!Number.isSafeInteger(row.total) || row.total < 0))) throw new Error("invalid security scan row");
441
+ return Number(this.db.query("INSERT INTO security_scans(at, repo, sources, counts, total) VALUES(?,?,?,?,?)").run(Date.now(), row.repo.toLowerCase(), row.sources, JSON.stringify(row.counts), row.total).lastInsertRowid);
442
+ }
377
443
  /** True when the marker was new (and is now recorded), false when it was announced before. Ids, metric names and task numbers only. */
378
444
  recordBandNotice(marker: string): boolean {
379
445
  if (!/^[a-z_]{1,64}:#[1-9][0-9]{0,9}:log$/.test(marker)) throw new Error("invalid band notice marker");
@@ -386,21 +452,28 @@ export class Board {
386
452
  }
387
453
  forgetAgentDriftNotice(marker: string): void { this.db.query("DELETE FROM agent_drift_notices WHERE marker=?").run(marker); }
388
454
  clearStall(task: number): void { this.taskWrite(() => this.db.query("DELETE FROM task_stalls WHERE task=?").run(task)); }
455
+ /** One bounded primary-key metadata read for a retry's saved replacement cause. */
456
+ stall(task: number): TaskStall | undefined {
457
+ if (!Number.isSafeInteger(task) || task < 1) return undefined;
458
+ const saved = this.db.query<{ payload: string | null }, [number]>("SELECT CASE WHEN length(CAST(payload AS BLOB)) <= 65536 THEN payload END AS payload FROM task_stalls WHERE task=?").get(task);
459
+ return saved?.payload == null ? undefined : parseStall(saved.payload, task);
460
+ }
389
461
  stalls(): TaskStall[] {
390
- return (this.db.query("SELECT payload FROM task_stalls ORDER BY task").all() as { payload: string }[]).flatMap(row => {
391
- try {
392
- const value = JSON.parse(row.payload) as TaskStall, cause = value.cause;
393
- const peer = (id: unknown) => typeof id === "string" && /^[a-z][a-z0-9_-]{0,63}$/i.test(id);
394
- 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)
395
- || !cause || !["reservation", "no-eligible-peer", "cancelled-dependency", "no-writes"].includes(cause.kind) || !TASK_STALL_CODES.includes(cause.code)
396
- || cause.kind === "no-writes" && (cause.code !== "no-writes" || !peer(cause.peer) || !Number.isSafeInteger(cause.turns) || cause.turns! < 1 || !Number.isSafeInteger(cause.acceptance) || cause.acceptance! < 0)
397
- || cause.peer !== undefined && !peer(cause.peer) || cause.dependency !== undefined && (!Number.isSafeInteger(cause.dependency) || cause.dependency < 1)
398
- || 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)))
399
- || Object.keys(cause).some(key => !["kind", "peer", "dependency", "code", "refusals", ...(cause.kind === "no-writes" ? ["turns", "acceptance"] : [])].includes(key)) || value.signature !== JSON.stringify(cause)) return [];
400
- return [value];
401
- } catch { return []; }
462
+ return (this.db.query("SELECT task,payload FROM task_stalls ORDER BY task").all() as { task: number; payload: string }[]).flatMap(row => {
463
+ const value = parseStall(row.payload, row.task); return value ? [value] : [];
402
464
  });
403
465
  }
466
+ /** Bounded ids/hold metadata only; a corrupt skipped payload still advances the cursor. */
467
+ queuePressureStallPage(afterTask = 0, limit = 64): QueuePressureStallPage {
468
+ if (!Number.isSafeInteger(afterTask) || afterTask < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 64) throw new Error("invalid queue pressure stall page");
469
+ const saved = this.db.query<{ task: number; payload: string }, [number, number]>(`SELECT task,payload FROM task_stalls WHERE task>? AND ${QUEUE_PRESSURE_STALL} ORDER BY task LIMIT ?`).all(afterTask, limit);
470
+ return { rows: saved.flatMap(row => { const value = parseStall(row.payload, row.task); return value ? [value] : []; }), afterTask: saved.at(-1)?.task ?? afterTask, done: saved.length < limit };
471
+ }
472
+ /** Current human-review candidates, by indexed state/role/id metadata without opening task history. */
473
+ humanReviewPage(afterId = 0, limit = 64): number[] {
474
+ if (!Number.isSafeInteger(afterId) || afterId < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 64) throw new Error("invalid human review page");
475
+ return this.db.query<{ id: number }, [number, number]>("SELECT id FROM tasks WHERE id>? AND state='in_review' AND reviewer='user' ORDER BY id LIMIT ?").all(afterId, limit).map(row => row.id);
476
+ }
404
477
 
405
478
  /** Tasks per state, without parsing a single row: `status.json` is rewritten on every bus event and the board only grows. */
406
479
  counts(): Record<string, number> {
@@ -410,12 +483,13 @@ export class Board {
410
483
 
411
484
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
412
485
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
413
- 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" | "reviewScope" | "evidenceOnly" | "reviewFindings" | "unmetCriteria" | "deliverables" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
486
+ 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" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
414
487
  const task = this.get(id);
415
488
  if (!task) throw new Error(`no task #${id}`);
416
489
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
417
490
  const untouchedReturn = event === "backlog returned untouched" && patch.state === "backlog" && patch.owner === null && patch.reviewer === null
418
491
  && ["proposed", "in_progress"].includes(task.state) && expectedFlowRevision !== undefined && !!note?.trim() && !taskHasWorkEvidence(task);
492
+ if (task.draft && patch.state && patch.state !== "backlog" && patch.state !== "cancelled") throw new Error(`task #${id} is an intake draft; the person approves it first`);
419
493
  if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state) && !untouchedReturn) {
420
494
  throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
421
495
  }
@@ -423,6 +497,8 @@ export class Board {
423
497
  const plannerHistory = Object.hasOwn(patch, "planner") || extra.plannerReason !== undefined ? plannerMetadata(planner, extra.plannerReason) : {};
424
498
  const authorHistory = Object.hasOwn(extra, "planAuthor") ? { planAuthor: plannerValue(extra.planAuthor) } : {};
425
499
  if (extra.selfReview !== undefined && extra.selfReview !== true) throw new Error("invalid self-review metadata");
500
+ if (extra.conductorManual !== undefined && (extra.conductorManual !== true || by === "user" || by === "hub" || extra.reason !== "manual"
501
+ || !["assigned", "reassigned", "reserved", "unassigned", "reviewer changed"].includes(event))) throw new Error("invalid conductor manual metadata");
426
502
  const next = { ...task, ...patch, planner, refs: { ...task.refs, ...patch.refs } };
427
503
  const headOf = (row: Task) => row.refs.commit ?? row.sdlc?.head ?? row.sdlc?.localHead;
428
504
  const privateSymbols = next.signals.includes("pii") || next.signals.includes("pii_path");
@@ -466,7 +542,7 @@ export class Board {
466
542
  // Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
467
543
  const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
468
544
  const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
469
- 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.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
545
+ 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.conductorManual === true ? { conductorManual: 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.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
470
546
  this.taskWrite(() => {
471
547
  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 = ?")
472
548
  .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);
@@ -502,6 +578,7 @@ export class Board {
502
578
  pullPlanned(id: number, by: PeerId, reserved: PeerId, revision: number, note: string, withheld = false, binding?: PlannerBinding): Task {
503
579
  const task = this.get(id);
504
580
  if (!task || (task.planningRevision ?? 0) !== revision || task.state !== "backlog" || task.owner || !taskCanPlan(task)) throw new Error("planner pull changed or already started");
581
+ if (task.draft) throw new Error(`task #${id} is an intake draft; the person approves it first`);
505
582
  const selected = binding ? plannerMetadata(binding.planner, binding.plannerReason) : {};
506
583
  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 } : {}) };
507
584
  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")
@@ -535,15 +612,21 @@ export class Board {
535
612
  const updated = this.get(id)!; this.changed(updated, entry); return updated;
536
613
  }
537
614
 
615
+ /** #334: set by `Tasks` for exactly the next `splitBacklog` and cleared by it, so the hub's own intake split requires its parent to still be a draft. */
616
+ requireDraftForNextSplit = false;
538
617
  splitBacklog(id: number, by: PeerId, revision: number, parts: (Parameters<Board["propose"]>[1] & { fields?: PlanningFields; afterParts?: number[]; screeningNote?: string })[], note: string, withheld = false): { parent: Task; children: Task[] } {
618
+ const requireDraft = this.requireDraftForNextSplit; this.requireDraftForNextSplit = false;
539
619
  return this.atomic(() => {
540
620
  const parent = this.get(id);
541
621
  if (!parent || parent.state !== "backlog" || parent.owner || taskHasStarted(parent) || (parent.planningRevision ?? 0) !== revision || parts.length < 2 || parts.length > 8) throw new Error("split changed or invalid");
622
+ // #334: the hub's own intake split is only ever for a draft. An approval clears the mark without moving the planning revision, so the revision alone cannot tell the
623
+ // person approved the card while the model answered; checked here, in the same transaction as the write, nothing can slip between this and the children.
624
+ if (requireDraft && !parent.draft) throw new Error("split changed or invalid");
542
625
  const children: Task[] = [];
543
626
  for (const part of parts) {
544
627
  const inherited = part.afterParts?.length ? (part.deps ?? []).filter(dep => !parent.deps?.includes(dep)) : part.deps ?? [];
545
628
  const deps = [...new Set([...inherited, ...(part.afterParts ?? []).map(index => { const child = children[index]; if (!child) throw new Error("split dependencies must name an earlier part"); return child.id; })])];
546
- let child = this.propose(by, { ...part, deps, backlog: true });
629
+ let child = this.propose(by, { ...part, deps, backlog: true, ...(parent.draft ? { draft: true } : {}) });
547
630
  if (part.screeningNote !== undefined) child = this.update(child.id, HUB, "screened", {}, part.screeningNote);
548
631
  if (part.fields && Object.keys(part.fields).length) child = this.plan(child.id, by, part.fields, 0, note, withheld);
549
632
  children.push(child);
@@ -559,6 +642,23 @@ export class Board {
559
642
  });
560
643
  }
561
644
 
645
+ /**
646
+ * #334: approve an intake draft and its children in one transaction. Only the person's approval reaches here (Tasks.approveIntake);
647
+ * each card must still be an unstarted, ownerless backlog draft, or nothing changes.
648
+ */
649
+ approveDrafts(ids: readonly number[], by: PeerId, note: string): Task[] {
650
+ return this.atomic(() => {
651
+ const rows = ids.map(id => this.get(id));
652
+ for (const row of rows) if (!row || row.state !== "backlog" || !row.draft || row.owner || taskHasStarted(row)) throw new Error("draft changed, approved or started");
653
+ return rows.map(row => {
654
+ const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "draft approved", note };
655
+ const result = this.taskWrite(() => this.db.query("UPDATE tasks SET draft=0, history=?, updated=? WHERE id=? AND draft=1").run(JSON.stringify([...row!.history, entry]), now, row!.id));
656
+ if (result.changes !== 1) throw new Error("draft changed, approved or started");
657
+ const updated = this.get(row!.id)!; this.changed(updated, entry); return updated;
658
+ });
659
+ });
660
+ }
661
+
562
662
  undoBacklogSplit(id: number, by: PeerId, revision: number): Task {
563
663
  return this.atomic(() => {
564
664
  const parent = this.get(id), entry = parent ? planningUndoEntry(parent) : undefined, split = entry?.planning?.split;
@@ -638,6 +738,15 @@ export class Board {
638
738
  }
639
739
 
640
740
  /** A short-circuit SQL presence read; no task, receipt or history body is hydrated into JS. */
741
+ hasConductorManualState(): boolean {
742
+ const row = this.db.query(`SELECT EXISTS(
743
+ SELECT 1 FROM tasks, json_each(CASE WHEN json_valid(tasks.history) THEN tasks.history ELSE '[]' END) AS entry
744
+ WHERE tasks.state NOT IN ('approved','cancelled') AND CASE WHEN json_valid(entry.value)
745
+ THEN json_type(entry.value,'$.conductorManual') IS NOT NULL ELSE 0 END
746
+ ) AS present`).get() as { present: number };
747
+ return row.present === 1;
748
+ }
749
+
641
750
  hasPlannerState(): boolean {
642
751
  const row = this.db.query(`SELECT
643
752
  EXISTS(SELECT 1 FROM tasks WHERE planner IS NOT NULL) OR
@@ -691,6 +800,7 @@ function parse(row: Record<string, unknown>): Task {
691
800
  if (out.planning_revision) out.planningRevision = out.planning_revision;
692
801
  delete out.planning_revision;
693
802
  if (out.pinned) out.pinned = true; else delete out.pinned;
803
+ if (out.draft) out.draft = true; else delete out.draft;
694
804
  for (const key of ["rank", "priority", "release", "due"]) if (out[key] == null) delete out[key];
695
805
  out.planner = row.planner ?? null;
696
806
  return out as unknown as Task;