@teamscala/service-boot 0.23.326 → 0.23.327

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 (162) hide show
  1. package/package.json +532 -524
  2. package/src/agreement-gate.d.ts +19 -0
  3. package/src/agreement-gate.ts +3 -12
  4. package/src/asset-cache-key.d.ts +11 -0
  5. package/src/asset-optimize.d.ts +17 -0
  6. package/src/asset-proxy.d.ts +11 -0
  7. package/src/asset-social-card.d.ts +17 -0
  8. package/src/attach-agreement-magic.d.ts +20 -0
  9. package/src/attach-portal-members.d.ts +23 -0
  10. package/src/attach-proposal-accept.d.ts +26 -0
  11. package/src/attach-proposal-magic.d.ts +18 -0
  12. package/src/attach-transfers-upload.d.ts +27 -0
  13. package/src/auth-gate.d.ts +28 -0
  14. package/src/auth-gate.ts +6 -68
  15. package/src/bind-page-handler.d.ts +18 -0
  16. package/src/build-client-assets/input-hashing.d.ts +25 -0
  17. package/src/build-client-assets/source-candidates.d.ts +27 -0
  18. package/src/build-client-assets.d.ts +50 -0
  19. package/src/build-client-assets.test.ts +1 -1
  20. package/src/build-client-assets.ts +1 -1
  21. package/src/compose-page-title.d.ts +41 -0
  22. package/src/content-gate/access.d.ts +15 -0
  23. package/src/content-gate/config-types.d.ts +99 -0
  24. package/src/content-gate/gate.d.ts +13 -0
  25. package/src/content-gate/parse-cookie.d.ts +9 -0
  26. package/src/content-gate/token.d.ts +48 -0
  27. package/src/content-page-floor.d.ts +39 -0
  28. package/src/discover-blocks.d.ts +28 -0
  29. package/src/discover-blocks.ts +1 -1
  30. package/src/expand-roster.d.ts +34 -0
  31. package/src/fn-routers/agreement-completion-email.d.ts +14 -0
  32. package/src/fn-routers/agreement-send-router.d.ts +82 -0
  33. package/src/fn-routers/ai-health-router.d.ts +35 -0
  34. package/src/fn-routers/auth-router.d.ts +47 -0
  35. package/src/fn-routers/cache-admin-router.d.ts +57 -0
  36. package/src/fn-routers/chat-session-router.d.ts +219 -0
  37. package/src/fn-routers/email-defaults.d.ts +1 -0
  38. package/src/fn-routers/email-link-ttl.d.ts +1 -0
  39. package/src/fn-routers/index.d.ts +18 -0
  40. package/src/fn-routers/index.ts +0 -2
  41. package/src/fn-routers/panel-data-router.d.ts +19 -0
  42. package/src/fn-routers/persist-chat-messages.d.ts +36 -0
  43. package/src/fn-routers/plans-router.d.ts +25 -0
  44. package/src/fn-routers/proposal-send-router.d.ts +58 -0
  45. package/src/fn-routers/public-agreement-router.d.ts +289 -0
  46. package/src/fn-routers/public-assessment-router.d.ts +68 -0
  47. package/src/fn-routers/public-gpt-session-router.d.ts +143 -0
  48. package/src/fn-routers/sign-in-flow-router.d.ts +107 -0
  49. package/src/fn-routers/task-sync-assign.d.ts +305 -0
  50. package/src/fn-routers/task-sync-assign.test.ts +1 -2
  51. package/src/fn-routers/task-sync-assign.ts +4 -4
  52. package/src/fn-routers/task-sync-bind.test.ts +18 -18
  53. package/src/fn-routers/task-sync-brief.d.ts +96 -0
  54. package/src/fn-routers/task-sync-brief.test.ts +17 -20
  55. package/src/fn-routers/task-sync-brief.ts +14 -15
  56. package/src/fn-routers/task-sync-dispatch.d.ts +61 -0
  57. package/src/fn-routers/task-sync-dispatch.test.ts +33 -51
  58. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  59. package/src/fn-routers/task-sync-lane.d.ts +49 -0
  60. package/src/fn-routers/task-sync-lane.ts +125 -0
  61. package/src/fn-routers/task-sync-projection.d.ts +121 -0
  62. package/src/fn-routers/task-sync-projection.test.ts +0 -26
  63. package/src/fn-routers/task-sync-projection.ts +0 -51
  64. package/src/fn-routers/task-sync-rename.d.ts +91 -0
  65. package/src/fn-routers/task-sync-router.d.ts +424 -0
  66. package/src/fn-routers/task-sync-router.test.ts +2 -14
  67. package/src/fn-routers/task-sync-router.ts +1106 -0
  68. package/src/fn-routers/tasks-router.d.ts +25 -0
  69. package/src/fn-routers/user-preferences-router.d.ts +77 -0
  70. package/src/fn-routers/work-items-router.d.ts +102 -0
  71. package/src/gate-ttls.d.ts +6 -0
  72. package/src/gpt-gate.d.ts +25 -0
  73. package/src/handlers/bun-stable-watch.d.ts +1 -0
  74. package/src/handlers/pane-outbox-drain.d.ts +1 -0
  75. package/src/handlers/projects-digest.d.ts +26 -0
  76. package/src/handlers/prune-stale-data.d.ts +30 -0
  77. package/src/handlers/registry-stale-inactive-sweep.d.ts +1 -0
  78. package/src/handlers/scala-tools-currency-watch.d.ts +1 -0
  79. package/src/handlers/singleton-tools-currency-watch.d.ts +1 -0
  80. package/src/handlers/singleton-tools-currency-watch.ts +11 -114
  81. package/src/handlers/sweep-event-log.d.ts +1 -0
  82. package/src/handlers/sweep-host-cache.d.ts +1 -0
  83. package/src/handlers/sweep-host-cache.ts +26 -143
  84. package/src/handlers/upstream-issue-sync.d.ts +1 -0
  85. package/src/handlers/whatsapp-liveness-logic.d.ts +34 -0
  86. package/src/handlers/whatsapp-liveness.d.ts +4 -0
  87. package/src/hono-error-handler.d.ts +25 -0
  88. package/src/install-harness.d.ts +45 -0
  89. package/src/install-logging.d.ts +34 -0
  90. package/src/install-logging.ts +0 -3
  91. package/src/load-service-install-row.d.ts +24 -0
  92. package/src/load-service-prisma-client.d.ts +18 -0
  93. package/src/mount-orpc-router.d.ts +15 -0
  94. package/src/mount-orpc-router.ts +1 -1
  95. package/src/mount-server-packages.d.ts +29 -0
  96. package/src/mount.d.ts +9 -0
  97. package/src/mutable-app-ref.d.ts +44 -0
  98. package/src/page-route-shell-types.d.ts +12 -0
  99. package/src/page-server-orpc/audit-revert-router.d.ts +37 -0
  100. package/src/page-server-orpc/hub-mcp-client.d.ts +12 -0
  101. package/src/page-server-orpc/portal-diagnostics-router.d.ts +49 -0
  102. package/src/page-server-orpc/puck-pages-router.d.ts +58 -0
  103. package/src/page-server-orpc/server-client.d.ts +16 -0
  104. package/src/page-server-orpc/templates-router.d.ts +34 -0
  105. package/src/page-server-orpc/user-preferences-router.d.ts +79 -0
  106. package/src/pg-boss-handler-registry.d.ts +27 -0
  107. package/src/pg-boss-mount.d.ts +42 -0
  108. package/src/proposal-authorized-emails.d.ts +28 -0
  109. package/src/proposal-chat-inject.d.ts +17 -0
  110. package/src/proposal-gate.d.ts +18 -0
  111. package/src/read-html-template.d.ts +38 -0
  112. package/src/read-viewport-from-request.d.ts +18 -0
  113. package/src/readiness-gate.d.ts +16 -0
  114. package/src/register-api-routes.d.ts +18 -0
  115. package/src/register-crash-handlers.d.ts +15 -0
  116. package/src/register-page-routes.d.ts +27 -0
  117. package/src/register-page-routes.ts +3 -0
  118. package/src/register-page-server-client.d.ts +11 -0
  119. package/src/register-registry-page-route/ai-chat-prefetch.d.ts +13 -0
  120. package/src/register-registry-page-route/graph-cache.d.ts +30 -0
  121. package/src/register-registry-page-route/meta-title.d.ts +2 -0
  122. package/src/register-registry-page-route/proposal-approval-email-token.d.ts +14 -0
  123. package/src/register-registry-page-route/proposal-brand-stamping.d.ts +28 -0
  124. package/src/register-registry-page-route/resolve-page-data.d.ts +1 -0
  125. package/src/register-registry-page-route.d.ts +27 -0
  126. package/src/register-static-assets.d.ts +13 -0
  127. package/src/register-ws-routes.d.ts +28 -0
  128. package/src/resolve-app-base-url.d.ts +13 -0
  129. package/src/resolve-brand-favicon.d.ts +28 -0
  130. package/src/resolve-brand-favicon.ts +1 -1
  131. package/src/resolve-from-loader-config.d.ts +32 -0
  132. package/src/resolve-service-context.d.ts +17 -0
  133. package/src/resolve-service-port.d.ts +75 -0
  134. package/src/resolve-theme-mode.d.ts +42 -0
  135. package/src/resolvers/fetch-org-brand.d.ts +2 -0
  136. package/src/resolvers/portal-route-paths.d.ts +39 -0
  137. package/src/resolvers/website-by-host-map.d.ts +12 -0
  138. package/src/resource-chat-inject.d.ts +46 -0
  139. package/src/resource-not-found.d.ts +60 -0
  140. package/src/resource-not-found.ts +0 -10
  141. package/src/run-boot-hook-list.d.ts +19 -0
  142. package/src/run-boot-hooks.d.ts +31 -0
  143. package/src/scala-tools-currency-watchdog.d.ts +10 -0
  144. package/src/security-headers.d.ts +12 -0
  145. package/src/serve/serve.d.ts +46 -0
  146. package/src/serve/stamp-transport-identity.d.ts +32 -0
  147. package/src/serve/stamp-transport-identity.ts +2 -14
  148. package/src/server-error-page.d.ts +29 -0
  149. package/src/start-scheduled-jobs.d.ts +18 -0
  150. package/src/two-phase-boot.d.ts +136 -0
  151. package/src/two-phase-boot.ts +17 -85
  152. package/src/two-phase-health-gate.test.ts +2 -13
  153. package/src/types.d.ts +46 -0
  154. package/src/ui-service.d.ts +46 -0
  155. package/src/ws-boot-self-test.d.ts +19 -0
  156. package/src/auth-gate.test.ts +0 -70
  157. package/src/fn-routers/task-sync-close.test.ts +0 -44
  158. package/src/fn-routers/task-sync-close.ts +0 -82
  159. package/src/fn-routers/task-sync-lane.test.ts +0 -36
  160. package/src/handlers/sweep-host-cache.test.ts +0 -133
  161. package/src/serve/stamp-transport-identity.test.ts +0 -24
  162. package/src/two-phase-health-gate-client-only.test.ts +0 -77
@@ -0,0 +1,1106 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ * @indivisible-unit
6
+ *
7
+ * CLI-AGNOSTIC task-sync CORE — the bridge between a lane's native task list
8
+ * (Claude Code TodoWrite, OpenCode todowrite, future CLIs) and the `work_items`
9
+ * table, which is the single source of truth for "what work does this lane
10
+ * have." Detailed contract + the per-CLI adapter split: apps/agent-fleet.md
11
+ * (task-sync section).
12
+ *
13
+ * Design (operator requirement): work_items is the source; each CLI's task list
14
+ * is a WRITE-THROUGH PROJECTION, never a peer. One source + write-through
15
+ * projection cannot diverge by construction — no two-way sync, no conflict
16
+ * resolution. The lane↔task binding is SYMMETRIC: a lane is a lane whether the
17
+ * pane is an orchestrator or a supervised agent (both have tasks).
18
+ *
19
+ * Mapping: an operator request = a kind='task' work_item; the lane's todo items
20
+ * = kind='subtask' rows under it via parent_id. The task carries the lane
21
+ * identity in kind_data.lane = { pane, session, cli_kind, role } so any surface
22
+ * (fleet UI, another lane) can resolve "who owns this work" and push changes
23
+ * back. Binding is by pane identity (the stable $TMUX_PANE), not by fleet run —
24
+ * so it works before/without the fleet_agent_runs.work_item_id join (that column
25
+ * is a UI convenience for the unified lane+task+subtask view, not a dependency).
26
+ *
27
+ * These procedures are the ONLY CLI-agnostic surface an adapter calls:
28
+ * bindLane — bind (create-or-confirm) a lane's task. SessionStart.
29
+ * upsertFromTaskList — write a lane's current task list through to subtasks (AGENT→DB leg, OpenCode whole-list).
30
+ * upsertFromTaskHook — apply a per-item Claude Code PostToolUse:TaskCreate/Update/Delete (AGENT→DB leg, Claude per-item).
31
+ * readTaskList — read a lane's current subtasks as a normalized list (DB→AGENT backstop).
32
+ * applyExternalChange — record an out-of-band subtask change + notify the owning lane (DB→AGENT realtime leg).
33
+ * A CLI with no native task surface (Codex today — upstream_issue openai-codex-2966)
34
+ * has NO adapter: it degrades to work_items-only. The work is still tracked and
35
+ * visible in the projects UI; the lane simply has no live projection. That is a
36
+ * legitimate end state, not a gap to paper over with a fake adapter.
37
+ */
38
+
39
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
40
+ import { recordEvent } from "@teamscala/event-log/record-event";
41
+ import { getPlatformOrgId } from "@teamscala/db/registry/platform";
42
+ import { authed, type Context } from "./auth-router.ts";
43
+ import { notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
44
+ import { resolveLaneTask, resolveLaneWorkName } from "./task-sync-lane.ts";
45
+ import { readAssignedBriefHandler } from "./task-sync-brief.ts";
46
+ import { renameLaneTaskHandler } from "./task-sync-rename.ts";
47
+ import {
48
+ adoptAssignmentTask,
49
+ bindLaneInput,
50
+ createBriefTaskHandler,
51
+ resolveBindAction,
52
+ } from "./task-sync-assign.ts";
53
+ import * as v from "valibot";
54
+ import {
55
+ STATUS_TO_WORK_ITEM,
56
+ normalizeStatus,
57
+ resolveProjectedStatus,
58
+ subtaskKey,
59
+ resolveTaskUpdateTarget,
60
+ planReconcile,
61
+ type ReconcilePlan,
62
+ } from "./task-sync-projection.ts";
63
+
64
+ /** A lane's native task-list item, normalized. Every CLI adapter maps to this. */
65
+ export interface TaskListItem {
66
+ content: string;
67
+ status: "pending" | "in_progress" | "completed";
68
+ /** CLI-native stable id when the CLI provides one (TodoWrite has none; opencode todo row id). */
69
+ mark?: string;
70
+ }
71
+
72
+ /**
73
+ * Input contract for upsertFromTaskList. oRPC does NOT parse the JSON body into
74
+ * `input` for a schema-less procedure (see the bindLane comment in this file) —
75
+ * so without an explicit `.input()` schema, `input.lane_session` is ALWAYS
76
+ * undefined and the procedure throws "requires lane_session" unconditionally.
77
+ * Measured 2026-07-29: every pi/opencode whole-list `task_list` publish 400'd
78
+ * for exactly this reason (the per-item upsertFromTaskHook path worked because
79
+ * it unwraps `raw.json ?? raw`). Declaring the schema is the documented remedy
80
+ * and the pattern every other `authed.input(...)` procedure in service-boot
81
+ * already follows. The pi adapter (scala-hooks.ts syncTaskList) already sends
82
+ * `{ lane_session, task_list, source }` — it was always valid; the server never
83
+ * read it.
84
+ */
85
+ export const upsertFromTaskListInput = v.object({
86
+ lane_session: v.string(),
87
+ task_list: v.array(
88
+ v.object({
89
+ content: v.string(),
90
+ status: v.picklist(["pending", "in_progress", "completed"]),
91
+ mark: v.optional(v.string()),
92
+ }),
93
+ ),
94
+ source: v.optional(v.string()),
95
+ });
96
+
97
+ /** Who a lane IS — symmetric across orchestrator/agent roles and across CLIs. */
98
+ export interface LaneIdentity {
99
+ /** "tmux:%42" — authoritative pane identity ($TMUX_PANE). */
100
+ pane: string;
101
+ session?: string;
102
+ /** pane-inventory inferCliKind: claude-code | opencode | codex | unknown. */
103
+ cli_kind?: string;
104
+ /** agent | orchestrator — both have tasks; the model does not assume a supervised agent. */
105
+ role?: string;
106
+ /**
107
+ * Optional. work_items.organisation_id is NOT NULL; when the caller omits it
108
+ * (auto-bind at SessionStart — an adopted or channel-dispatched lane has no
109
+ * client org), bindLane defaults to the platform org via resolveBindOrg.
110
+ */
111
+ organisation_id?: string;
112
+ }
113
+
114
+ export interface WorkItem {
115
+ id: string;
116
+ kind: string;
117
+ parent_id: string | null;
118
+ organisation_id: string;
119
+ title: string;
120
+ status: string | null;
121
+ position: string | null;
122
+ completed: boolean | null;
123
+ kind_data: Record<string, unknown>;
124
+ }
125
+
126
+ export type Prisma = {
127
+ work_items: {
128
+ findFirst: (a: unknown) => Promise<WorkItem | null>;
129
+ findMany: (a: unknown) => Promise<WorkItem[]>;
130
+ create: (a: unknown) => Promise<WorkItem>;
131
+ update: (a: unknown) => Promise<WorkItem>;
132
+ deleteMany: (a: unknown) => Promise<{ count: number }>;
133
+ };
134
+ // `comments` is a universal shared model (owned by @teamgbg/db), so every
135
+ // service with the db client carries it. Declared here rather than
136
+ // feature-detected because, unlike fleet_agent_runs (fleet-service-only), it
137
+ // is part of the shared client every consumer installs.
138
+ comments?: {
139
+ findFirst: (a: unknown) => Promise<CommentRow | null>;
140
+ };
141
+ };
142
+
143
+ /** A `comments` row — only the fields readAssignedBrief touches. */
144
+ export interface CommentRow {
145
+ content: string;
146
+ created_at: Date;
147
+ }
148
+
149
+ /**
150
+ * A dispatched run already points at its fleet_tasks row. Task-sync may fill
151
+ * the convenience join for an adopted lane, but must never replace that
152
+ * dispatch identity with the lane's work_items projection id.
153
+ */
154
+ export function untrackedRunForPane(pane: string) {
155
+ return {
156
+ pane,
157
+ state: { not: "closed" },
158
+ tracked_task_id: null,
159
+ };
160
+ }
161
+
162
+
163
+ /**
164
+ * Resolve the org a lane's task is created under. Explicit when the lane knows
165
+ * its org (a dispatched fleet lane carries one); defaults to the platform org
166
+ * otherwise — an adopted or channel-dispatched pane has no client org, and the
167
+ * platform org is the home for fleet/internal lane tasks (where the operator's
168
+ * PM view reads), so auto-bind at SessionStart seeds the task there.
169
+ */
170
+ export function resolveBindOrg(explicit?: string): string {
171
+ return explicit ?? getPlatformOrgId();
172
+ }
173
+
174
+ /**
175
+ * Record a task-sync hook DISCARD to event_log so it is observable + queryable,
176
+ * not silently swallowed by the hook command's `>/dev/null 2>&1 || true`. The
177
+ * endpoint records it server-side regardless of what the hook does with stdout.
178
+ * Fire-and-forget per event-log-is-the-only-durable-event-log. The headline
179
+ * discard is "no-task-bound" (lane has no bound parent task — exactly the gap
180
+ * that SessionStart auto-bind closes; once binding is automatic this becomes a
181
+ * rare safety signal rather than the default outcome).
182
+ */
183
+ export function recordTaskSyncDiscard(
184
+ reason: string,
185
+ pane: string | null,
186
+ extra: Record<string, unknown> = {},
187
+ ): void {
188
+ recordEvent({
189
+ kind: "tasksync.hook.discarded",
190
+ payload: { reason, pane: pane ?? null, ...extra },
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Record a discard AND return the soft {applied:false} body in one step — every
196
+ * no-op path in upsertFromTaskHook goes through here so none can silently drop
197
+ * an event. The returned shape matches the prior `{applied:false, reason,...}`.
198
+ */
199
+ export function discardApplied(
200
+ reason: string,
201
+ pane: string | null,
202
+ extra: Record<string, unknown> = {},
203
+ ): { applied: false; reason: string } & Record<string, unknown> {
204
+ recordTaskSyncDiscard(reason, pane, extra);
205
+ return { applied: false as const, reason, ...extra };
206
+ }
207
+
208
+ /**
209
+ * The lane's human work name, read from its live fleet run (slot_name carries the
210
+ * tmux window label per lanes-named-by-the-spawner). Returns null when no run
211
+ * carries this pane yet — adoption and bind race at SessionStart, and a missing
212
+ * name must not block the bind. Feature-detected like the run-link below, because
213
+ * fleet_agent_runs lives only in the fleet service's schema.
214
+ */
215
+
216
+ /**
217
+ * Resolve a lane's CLI-native session id from its live run row by pane. Used by
218
+ * the task-event handlers (upsertFromTaskHook) whose payloads lack session_id —
219
+ * the run row's opencode_session_id IS the same CLI-native id the bind stamped
220
+ * on kind_data.lane.session, so this recovers it for session-keyed matching.
221
+ * Feature-detected (fleet_agent_runs lives only in the fleet service's schema).
222
+ */
223
+ async function resolveSessionFromPane(prisma: Prisma, pane: string): Promise<string | null> {
224
+ try {
225
+ const runs = (
226
+ prisma as unknown as {
227
+ fleet_agent_runs?: {
228
+ findFirst: (a: unknown) => Promise<{ opencode_session_id?: string | null } | null>;
229
+ };
230
+ }
231
+ ).fleet_agent_runs;
232
+ if (!runs?.findFirst) return null;
233
+ const run = await runs.findFirst({
234
+ where: { pane, state: { not: "closed" } },
235
+ select: { opencode_session_id: true },
236
+ });
237
+ return run?.opencode_session_id ?? null;
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ /** Resolve the task a lane owns, by SESSION IDENTITY (kind_data.lane.session).
244
+ *
245
+ * The CLI-native session id is the durable, unique-per-session key (Claude
246
+ * Code's UUIDv7 = transcript filename; OpenCode's ses-prefixed id). A pane id
247
+ * is NOT the key — it outlives the CLI session, so a fresh session in the same
248
+ * pane would inherit the prior session's tasks (the exact bug this re-keys).
249
+ * The pane stays on kind_data.lane.pane as a diagnostic field only. */
250
+ /**
251
+ * The lane's parent task, CREATING the binding when it does not exist yet.
252
+ *
253
+ * Every lane must be able to comply with `lane-labels-and-task-lists` — operator
254
+ * ruling 2026-07-27: "ALL lanes can comply, there is no reason why a tab cant
255
+ * comply." The task hooks previously resolved the parent and, finding none,
256
+ * DISCARDED the lane's task ("no-task-bound"). A lane whose SessionStart bindLane
257
+ * never ran, or ran while the endpoint was failing, therefore lost every task it
258
+ * created, silently, forever — it was told to keep a task list and structurally
259
+ * could not. Measured 2026-07-27: live active lanes with no binding at all, each
260
+ * throwing away its own list.
261
+ *
262
+ * Binding on demand removes the failure mode instead of reporting it: the first
263
+ * task a lane records creates the parent it needs. SessionStart binding still
264
+ * runs and is still preferred (it names the task from the lane's work name); this
265
+ * is the backstop that makes the discard impossible.
266
+ *
267
+ * The create races SessionStart and a sibling hook exactly as bindLane does, so
268
+ * it loses the race the same way — re-read and carry on, never surface an error
269
+ * for a row that now exists.
270
+ */
271
+ async function ensureLaneTask(
272
+ prisma: Prisma,
273
+ session: string,
274
+ pane: string | null,
275
+ ): Promise<WorkItem | null> {
276
+ const existing = await resolveLaneTask(prisma, session);
277
+ if (existing) return existing;
278
+ // The lane's real work name — its fleet run's slot_name
279
+ // (lanes-named-by-the-spawner). resolveLaneWorkName reads the most recent run
280
+ // for the pane regardless of state, so a lane whose run closed still resolves.
281
+ // A task with NO resolvable name is NOT created: writing the generic "Lane"
282
+ // sentinel is exactly the unmakeable state this backstop must refuse (a row
283
+ // titled "Lane" tells the operator nothing and reads as spawn debris).
284
+ // Return null — reconcileTaskList throws, the TaskCreated hook discards
285
+ // (observably), so the lane never silently produces a "Lane" row.
286
+ const name = pane ? await resolveLaneWorkName(prisma, pane) : null;
287
+ if (!name || name.trim().toLowerCase() === "lane") return null;
288
+ const lane: LaneIdentity = { pane: pane ?? "", session, cli_kind: "claude-code", role: "agent" };
289
+ try {
290
+ return await prisma.work_items.create({
291
+ data: {
292
+ kind: "task",
293
+ parent_id: null,
294
+ organisation_id: resolveBindOrg(),
295
+ title: name,
296
+ status: "active",
297
+ // assignee_id on every bind path (see bindLane): without it a bound
298
+ // task reads as unowned to an orchestrator steering by assignee_id.
299
+ assignee_id: session,
300
+ kind_data: { lane },
301
+ },
302
+ });
303
+ } catch {
304
+ return resolveLaneTask(prisma, session);
305
+ }
306
+ }
307
+
308
+
309
+ /** Match key for a subtask: stable mark when the CLI provides one, else content, else title. Uses || (not ??) so an empty-string content (passed for existing rows that key by title) falls through. */
310
+ /**
311
+ * Which lifecycle leg a Claude task-hook payload represents.
312
+ *
313
+ * The dedicated events `TaskCreated` and `TaskCompleted` BOTH carry
314
+ * `task_subject`, so "has a subject" cannot distinguish them — a completion
315
+ * classified as a creation mints a duplicate subtask instead of closing one,
316
+ * which is precisely the "list only grows, nothing ever completes" symptom
317
+ * (`task-projection-converges`). `hook_event_name` is a common field on every
318
+ * hook event and is the only reliable discriminator, so the decision is made
319
+ * here, once, rather than by branch ordering in the handler.
320
+ */
321
+ /**
322
+ * Markers that can only appear in a task subject when the CLI's tool-call text
323
+ * leaked into the field. `</subject>` is the closing tag of the parameter
324
+ * itself, and `<parameter name=` is the START of the NEXT parameter — either
325
+ * one means the extraction ran past the end of the value it was reading.
326
+ */
327
+ const SUBJECT_LEAK_MARKERS = ["</subject>", "<parameter name=", "</antml"];
328
+
329
+ /**
330
+ * Trim a task subject at the first tool-call marker.
331
+ *
332
+ * A subject arriving as `Cut judge false positives</subject>\n<parameter
333
+ * name="description">Live gate blocks ~half...` is not a title with odd
334
+ * punctuation — it is the raw tool call, and it renders on the dashboard as a
335
+ * wall of XML where the operator expects a task name (observed live 2026-07-25,
336
+ * 3 rows). The malformation happens upstream in the CLI's hook payload, which
337
+ * this service does not control, so the defence belongs at the WRITE boundary:
338
+ * `work_items` is the single source of truth for every surface, and once a
339
+ * corrupted title lands there every reader shows it.
340
+ *
341
+ * Truncating rather than rejecting is deliberate: the text BEFORE the marker is
342
+ * the real subject and is worth keeping. Dropping the row instead would lose a
343
+ * task the lane genuinely created.
344
+ */
345
+ export function sanitizeTaskSubject(raw: string): string {
346
+ let out = raw;
347
+ for (const marker of SUBJECT_LEAK_MARKERS) {
348
+ const i = out.indexOf(marker);
349
+ if (i !== -1) out = out.slice(0, i);
350
+ }
351
+ return out.trim();
352
+ }
353
+
354
+ export function classifyTaskHookEvent(evt: {
355
+ hook_event_name?: unknown;
356
+ task_subject?: unknown;
357
+ }): "completed" | "created" | "tool" {
358
+ if (evt.hook_event_name === "TaskCompleted") return "completed";
359
+ if (typeof evt.task_subject === "string") return "created";
360
+ return "tool";
361
+ }
362
+
363
+
364
+ /**
365
+ * Execute a reconcile against prisma for a lane's task — the shared write path
366
+ * for upsertFromTaskList (normalized input) and upsertFromTodoWriteHook (raw
367
+ * Claude hook event). Throws an Error ("No task bound to pane …") when the lane
368
+ * has no task; callers map that to their preferred response (ORPC NOT_FOUND or a
369
+ * soft {synced:0}). Does NOT dispatch a change notification — the lane that
370
+ * authored the list already has it.
371
+ */
372
+ async function reconcileTaskList(
373
+ prisma: Prisma,
374
+ laneSession: string,
375
+ list: TaskListItem[],
376
+ source?: string,
377
+ pane?: string | null,
378
+ ): Promise<{ task_id: string; created: number; updated: number; deleted: number; total: number }> {
379
+ // BIND ON DEMAND — this path used to `resolveLaneTask` and THROW, which is the
380
+ // exact failure `ensureLaneTask` was written to make impossible (see its
381
+ // comment above, and the operator ruling it cites: "ALL lanes can comply,
382
+ // there is no reason why a tab cant comply"). The backstop existed and this
383
+ // caller did not use it, so a lane whose SessionStart bindLane never ran — or
384
+ // ran while the endpoint was failing — could never sync a task list again.
385
+ // Not degraded: permanently, with a 404 on every attempt and no path to
386
+ // self-recover.
387
+ //
388
+ // Measured 2026-07-30: a live lane reported ~9 consecutive failures over
389
+ // hours and correctly diagnosed its own lane-compliance flag as a false
390
+ // positive it was structurally unable to clear. The gate was telling it to
391
+ // keep a task list while the only endpoint that records one refused it.
392
+ //
393
+ // A lane calling this HAS everything the bind needs — its session, its pane,
394
+ // its list — so requiring a prior call is a sequencing demand with no
395
+ // informational content. `friction-is-a-stop-condition`: the compliant path
396
+ // must exist, or the rule is unenforceable against the lane it binds.
397
+ const task = await ensureLaneTask(prisma, laneSession, pane ?? null);
398
+ if (!task) {
399
+ throw new Error(
400
+ `Could not bind a task for session ${laneSession} — the create failed and no existing task was found.`,
401
+ );
402
+ }
403
+ const existing = await prisma.work_items.findMany({
404
+ where: { parent_id: task.id, kind: "subtask" },
405
+ });
406
+ const plan = planReconcile(existing, list, source);
407
+ for (const c of plan.toCreate) {
408
+ await prisma.work_items.create({
409
+ data: {
410
+ kind: "subtask",
411
+ parent_id: task.id,
412
+ organisation_id: task.organisation_id,
413
+ title: c.content,
414
+ status: c.status,
415
+ completed: c.completed,
416
+ position: c.position,
417
+ kind_data: c.kind_data,
418
+ },
419
+ });
420
+ }
421
+ for (const u of plan.toUpdate) {
422
+ await prisma.work_items.update({
423
+ where: { id: u.id },
424
+ data: { title: u.content, status: u.status, completed: u.completed, position: u.position, kind_data: u.kind_data },
425
+ });
426
+ }
427
+ let deleted = 0;
428
+ if (plan.toDelete.length > 0) {
429
+ const res = await prisma.work_items.deleteMany({ where: { id: { in: plan.toDelete } } });
430
+ deleted = res.count;
431
+ }
432
+ return { task_id: task.id, created: plan.toCreate.length, updated: plan.toUpdate.length, deleted, total: list.length };
433
+ }
434
+
435
+ export const taskSyncRouter = {
436
+ /**
437
+ * Bind (create-or-confirm) the task a lane owns. Called from a SessionStart
438
+ * adapter so the lane has a durable task row before its first todo write.
439
+ * Idempotent: re-binding updates the identity (role/cli_kind can change) but
440
+ * never orphans existing subtasks. After binding, links the lane's
441
+ * fleet_agent_runs row (matched by pane) to the task via tracked_task_id —
442
+ * the join the operator's lane-labels monitor + the unified fleet view read.
443
+ */
444
+ bindLane: authed.input(bindLaneInput).handler(async ({ input, context }) => {
445
+ const ctx = context as Context;
446
+ const prisma = ctx.prisma as Prisma;
447
+ const log = getAppLogger();
448
+ // The SessionStart hook sends the pane + the CLI's native session id.
449
+ // SESSION is the match key (kind_data.lane.session) — it is unique per
450
+ // CLI session and re-minted each session, so a fresh session cannot find
451
+ // the prior session's tasks. PANE stays as a diagnostic field only
452
+ // (kind_data.lane.pane) — it outlives the CLI session, so matching on it
453
+ // is exactly the inheritance bug this re-keys. Without an .input()
454
+ // valibot schema ORPC does not parse the JSON body into `input`, so the
455
+ // headers (x-lane-session, x-lane-pane) are the reliable transport.
456
+ const raw = (input ?? {}) as {
457
+ lane?: Partial<LaneIdentity>;
458
+ title?: string;
459
+ parent_id?: string;
460
+ };
461
+ const headerPane = ctx.headers?.get("x-lane-pane") ?? null;
462
+ const pane = raw.lane?.pane ?? headerPane;
463
+ const headerSession = ctx.headers?.get("x-lane-session") ?? null;
464
+ const session = raw.lane?.session ?? headerSession;
465
+ // parent_id arrives EITHER as a body field (parsed now that bindLane has an
466
+ // `.input()` schema — bindLaneInput) OR on the x-lane-work-item header. Both
467
+ // are the assignment task id this lane ADOPTS (`adoptAssignmentTask`); the
468
+ // header stays as the redundant transport a header-only caller (zerostack)
469
+ // already sends. Before the schema, the body path silently never parsed, so a
470
+ // body-only caller (pi) dropped parent_id, never adopted, and self-briefed to
471
+ // nulls — the data-not-serving gap `bindLaneInput` exists to close.
472
+ const headerWorkItem = ctx.headers?.get("x-lane-work-item") ?? null;
473
+ // cli_kind via header is the fallback for callers that transport it there; a
474
+ // caller that puts cli_kind in the body lane object (parsed by bindLaneInput)
475
+ // is read from raw.lane.cli_kind first.
476
+ const headerWorkKind = ctx.headers?.get("x-lane-cli-kind") ?? null;
477
+ if (!session) {
478
+ const { ORPCError } = await import("@orpc/server");
479
+ throw new ORPCError("BAD_REQUEST", {
480
+ message: "bindLane requires lane.session (the CLI's native session id)",
481
+ });
482
+ }
483
+ const lane: LaneIdentity = {
484
+ pane,
485
+ session,
486
+ cli_kind: (raw.lane?.cli_kind ?? headerWorkKind ?? "claude-code") as LaneIdentity["cli_kind"],
487
+ role: raw.lane?.role ?? "agent",
488
+ organisation_id: raw.lane?.organisation_id,
489
+ };
490
+ // organisation_id optional: defaults to the platform org (resolveBindOrg)
491
+ // so auto-bind at SessionStart works with no client org on the lane.
492
+ const organisation_id = resolveBindOrg(lane.organisation_id);
493
+ const existing = await resolveLaneTask(prisma, session);
494
+ // A human-readable title, resolved from the lane's OWN work name. NEVER a
495
+ // pane id in user-facing text (agent-not-identity): `%9` is a machine
496
+ // address, it renumbers, and it tells the operator nothing about what the
497
+ // lane is doing. The run's slot_name IS the work name
498
+ // (lanes-named-by-the-spawner); fall back to a bare "Lane" rather than
499
+ // leak the pane id when no run resolves a name.
500
+ // The lane's work name — NEVER the "Lane" sentinel (spawn debris an
501
+ // orchestrator cannot attribute). null when no run resolves a slot_name;
502
+ // the create path REFUSES rather than write "Lane" (lanes-named-by-the-
503
+ // spawner).
504
+ const laneTitle = raw.title ?? (await resolveLaneWorkName(prisma, pane)) ?? null;
505
+ let taskId: string;
506
+ let created: boolean;
507
+ if (existing) {
508
+ const kindData = { ...existing.kind_data, lane };
509
+ const updated = await prisma.work_items.update({
510
+ where: { id: existing.id },
511
+ // A supplied parent is an INSTRUCTION, not a default: the spawn named
512
+ // the project this lane is for, which is newer than whatever the row
513
+ // carried. Set only on create until now, so a re-bind — the common
514
+ // case, since this matches by session — silently dropped it.
515
+ // Omitted leaves the existing parent alone, so a re-bind without a
516
+ // project cannot orphan a lane that already has one.
517
+ data: {
518
+ kind_data: kindData,
519
+ // assignee_id on EVERY bind path, not only adopt: a re-bind that
520
+ // left assignee_id null read as "unowned" to an orchestrator
521
+ // steering by it (measured 2026-08-01: "Dev-mcp bus claim" — a
522
+ // live lane whose task had a session but null assignee_id).
523
+ assignee_id: session,
524
+ ...(raw.parent_id ? { parent_id: raw.parent_id } : {}),
525
+ },
526
+ });
527
+ taskId = updated.id;
528
+ created = false;
529
+ log.info("[task-sync] bindLane: re-bound existing task", { taskId, session });
530
+ } else {
531
+ // DISPATCH-BY-ASSIGNMENT adopt, then a write-back that NEVER orphans.
532
+ // The spawn may have carried a workItemId the lane should adopt; adopt
533
+ // attempts it, then resolveBindAction decides from the outcome:
534
+ // use — adopt succeeded.
535
+ // create — exploratory lane (no workItemId) or the id is a PROJECT
536
+ // the task is created under.
537
+ // refuse — the spawn named a task that does NOT exist. This is the
538
+ // `absent-input-reads-as-success` failure: silently creating
539
+ // a new task here would abandon the spawn's referenced brief
540
+ // and bind the lane to an empty task it was never meant to
541
+ // own, leaving an orphan an orchestrator cannot tell from
542
+ // real unowned work. Failing loudly surfaces the bad spawn.
543
+ const workItemId = raw.parent_id ?? headerWorkItem;
544
+ const adopted = await adoptAssignmentTask(prisma, workItemId, { ...lane, session });
545
+ let referencedKind: string | null = null;
546
+ if (!adopted && workItemId) {
547
+ const ref = await prisma.work_items.findFirst({
548
+ where: { id: workItemId },
549
+ select: { kind: true },
550
+ });
551
+ referencedKind = ref?.kind ?? null;
552
+ }
553
+ const action = resolveBindAction({ adopted, workItemId, referencedKind });
554
+ if (action.action === "refuse") {
555
+ const { ORPCError } = await import("@orpc/server");
556
+ throw new ORPCError("NOT_FOUND", {
557
+ message:
558
+ `bindLane: the spawn referenced work item ${action.workItemId} to adopt, but no task ` +
559
+ "exists with that id — refusing rather than creating an orphan task and abandoning the spawn's brief.",
560
+ });
561
+ }
562
+ if (action.action === "use") {
563
+ taskId = adopted!.id;
564
+ created = false;
565
+ log.info("[task-sync] bindLane: adopted assigned task", {
566
+ taskId: adopted!.id,
567
+ session,
568
+ });
569
+ } else {
570
+ // A task's title must carry the lane's REAL work name — never the
571
+ // generic "Lane" sentinel (spawn debris an orchestrator cannot
572
+ // attribute). No resolvable name = refuse, so a "Lane" row is
573
+ // unmakeable on this path (lanes-named-by-the-spawner).
574
+ if (!laneTitle || laneTitle.trim().toLowerCase() === "lane") {
575
+ const { ORPCError } = await import("@orpc/server");
576
+ throw new ORPCError("BAD_REQUEST", {
577
+ message:
578
+ "bindLane: no real work name resolved for this lane (its fleet run has no slot_name). " +
579
+ "A task titled 'Lane' is spawn debris — name the lane at spawn (windowName) or ensure its fleet run resolves a slot_name.",
580
+ });
581
+ }
582
+ // Read-then-create is a race: SessionStart and the lane's first
583
+ // TaskCreate arrive seconds apart, both read nothing, and both create.
584
+ // Measured 2026-07-25: 9 lanes with two active parents each, every twin
585
+ // 2-3 seconds behind its sibling. Migration 109's partial unique index
586
+ // makes the second write fail instead of succeed, and losing the race
587
+ // is not an error — the winner's row is exactly what this call wanted,
588
+ // so re-read it and carry on.
589
+ let row: WorkItem;
590
+ try {
591
+ row = await prisma.work_items.create({
592
+ data: {
593
+ kind: "task",
594
+ parent_id: raw.parent_id ?? null,
595
+ organisation_id,
596
+ title: laneTitle,
597
+ status: "active",
598
+ // assignee_id = the lane's session on EVERY bind path, not only
599
+ // adopt. Without it a bound task reads as unowned (assignee_id
600
+ // null) — the signal the fleet steers by. Measured 2026-08-01:
601
+ // 207 task rows with kind_data.lane.session set but assignee_id
602
+ // null, every one reading as unowned.
603
+ assignee_id: session,
604
+ kind_data: { lane },
605
+ },
606
+ });
607
+ log.info("[task-sync] bindLane: created task", { taskId: row.id, session });
608
+ } catch (err) {
609
+ const winner = await resolveLaneTask(prisma, session);
610
+ if (!winner) throw err;
611
+ row = winner;
612
+ log.info("[task-sync] bindLane: lost the create race, using existing task", {
613
+ taskId: row.id,
614
+ session,
615
+ });
616
+ }
617
+ taskId = row.id;
618
+ created = true;
619
+ }
620
+ }
621
+ // Link the fleet run carrying this pane to the bound task — sets
622
+ // fleet_agent_runs.tracked_task_id. Best-effort by design: fleet_agent_runs
623
+ // is only in the fleet service's schema, so feature-detect the model; and a
624
+ // run not yet carrying its pane (adoption race at SessionStart — adoption
625
+ // fires first, but its pane resolve can come up empty on a tmux hiccup) is
626
+ // simply not matched, converging on re-bind / next adoption. The work_items
627
+ // task is the source of truth; this column is the UI convenience join.
628
+ try {
629
+ const runs = (
630
+ prisma as unknown as {
631
+ fleet_agent_runs?: {
632
+ updateMany: (a: unknown) => Promise<{ count: number }>;
633
+ };
634
+ }
635
+ ).fleet_agent_runs;
636
+ if (runs?.updateMany) {
637
+ // STEAL before CLAIM. A task is tracked by at most one LIVE run — the
638
+ // partial unique index fleet_agent_runs_one_active_run_per_task
639
+ // (UNIQUE(tracked_task_id) WHERE state IN active/committing/waiting)
640
+ // enforces it. Assigning tracked_task_id without first releasing it
641
+ // from any OTHER run holding it leaves two runs sharing the task, and
642
+ // the next state→active transition on either one (upsertLaneRun's
643
+ // UPDATE) violates the index → P2002 → the lane-event 500s and the
644
+ // run-link join is lost. The common trigger is re-adoption: a lane
645
+ // moves to a new pane, bindLane claims the task for the new run while
646
+ // the OLD pane's run still holds it. A task is session-scoped, so every
647
+ // run holding this taskId is this lane's own (or a stale duplicate) —
648
+ // releasing from all non-closed runs is correct, then only this lane's
649
+ // untracked run re-claims it.
650
+ await runs.updateMany({
651
+ where: { tracked_task_id: taskId, state: { not: "closed" } },
652
+ data: { tracked_task_id: null },
653
+ });
654
+ await runs.updateMany({
655
+ where: untrackedRunForPane(pane),
656
+ data: { tracked_task_id: taskId },
657
+ });
658
+ }
659
+ } catch (err) {
660
+ log.warn("[task-sync] bindLane: run-link by pane failed", {
661
+ pane,
662
+ taskId,
663
+ error: err instanceof Error ? err.message : String(err),
664
+ });
665
+ }
666
+ return { task_id: taskId, created };
667
+ }),
668
+
669
+ /**
670
+ * AGENT→DB leg. Write a lane's CURRENT task list through to subtasks. The
671
+ * list is authoritative for this lane (it's what the CLI just rendered), so
672
+ * this is a full reconcile: create/update/delete to match. Matching is by
673
+ * stable mark when the CLI provides one, else by content. Does NOT dispatch
674
+ * a change notification — the lane that authored the list already has it.
675
+ */
676
+ upsertFromTaskList: authed.input(upsertFromTaskListInput).handler(async ({ input, context }) => {
677
+ const body = input as { lane_session: string; task_list: TaskListItem[]; source?: string };
678
+ const prisma = (context as Context).prisma as Prisma;
679
+ const log = getAppLogger();
680
+ const session = body?.lane_session;
681
+ const list = Array.isArray(body?.task_list) ? body.task_list : [];
682
+ if (!session) {
683
+ const { ORPCError } = await import("@orpc/server");
684
+ throw new ORPCError("BAD_REQUEST", { message: "upsertFromTaskList requires lane_session" });
685
+ }
686
+ // The pane rides in the header the callers already send (`x-lane-pane`),
687
+ // and it is what lets the on-demand bind link the new task to the lane's
688
+ // run. Absent, the bind still succeeds — it just cannot link the run — so
689
+ // this is passed opportunistically rather than required.
690
+ const headerPane = (context as Context).headers?.get("x-lane-pane") ?? null;
691
+ try {
692
+ const res = await reconcileTaskList(prisma, session, list, body.source, headerPane);
693
+ log.info("[task-sync] upsertFromTaskList reconciled", { session, ...res });
694
+ return res;
695
+ } catch (err) {
696
+ if (err instanceof Error && err.message.includes("No task bound")) {
697
+ const { ORPCError } = await import("@orpc/server");
698
+ throw new ORPCError("NOT_FOUND", { message: err.message });
699
+ }
700
+ // The 500 body is masked by oRPC (INTERNAL_SERVER_ERROR), so without
701
+ // this the cause is invisible — measured 2026-07-30:
702
+ // upsertFromTaskList 500ing consistently with no surfaced error,
703
+ // blocking task-list projection + the watchdog for every lane. Log
704
+ // the actual error before re-throwing (protective-layers-fail-
705
+ // explicitly: a failure with no observability is the worst shape).
706
+ log.error("[task-sync] upsertFromTaskList FAILED", { session, err: err instanceof Error ? `${err.name}: ${err.message}` : String(err) });
707
+ throw err;
708
+ }
709
+ }),
710
+ /**
711
+ * Claude Code PostToolUse:TaskCreate/TaskUpdate hook entry. Claude Code's
712
+ * todo tool is the per-item Task* family (TaskCreate/TaskUpdate) — TodoWrite
713
+ * was REMOVED in recent Claude Code; the hook matcher keys on
714
+ * TaskCreate/TaskUpdate, never TodoWrite. The hook command posts the RAW hook
715
+ * event (stdin) + an x-lane-pane header (tmux:$TMUX_PANE). Fired as a
716
+ * PostToolUse:TaskCreate/Update hook (NOT a "TaskCreated" event — that is
717
+ * not a real Claude Code hook event + never fired); this procedure
718
+ * applies the per-item change (TaskCreate → new subtask keyed by the harness
719
+ * task id; TaskUpdate → status/title flip on the matching subtask). Server-
720
+ * side extraction keeps the hook command a trivial jq|curl. Accepts the event
721
+ * raw (input.tool_name) OR oRPC-enveloped (input.json.tool_name). Session-
722
+ * less: fn.taskSync is page-gate-exempt; localhost pane shell authenticates
723
+ * as super-admin via resolveUser. Soft-returns on any no-op (never throws).
724
+ */
725
+ upsertFromTaskHook: authed.handler(async ({ input, context }) =>
726
+ upsertFromTaskHookHandler(input, context)),
727
+
728
+ /**
729
+ * DB→AGENT backstop read. Returns the lane's current subtasks in the
730
+ * normalized shape every CLI adapter maps to its native list. Called by the
731
+ * SessionStart/UserPromptSubmit adapters so a pane never opens or continues
732
+ * on stale tasks, and by a lane after it receives a "your tasks changed"
733
+ * channel notification.
734
+ */
735
+ readTaskList: authed.handler(async ({ input, context }) => {
736
+ const ctx = context as Context;
737
+ const prisma = ctx.prisma as Prisma;
738
+ const body = (input ?? {}) as { lane_session?: string };
739
+ // Same transport as bindLane/upsertFromTaskHook: the session id arrives
740
+ // on the x-lane-session header (and body.lane_session as fallback).
741
+ const session = body.lane_session ?? ctx.headers?.get("x-lane-session") ?? null;
742
+ if (!session) {
743
+ const { ORPCError } = await import("@orpc/server");
744
+ throw new ORPCError("BAD_REQUEST", { message: "readTaskList requires lane_session" });
745
+ }
746
+ const task = await resolveLaneTask(prisma, session);
747
+ if (!task) return { task_id: null, task_title: null, subtasks: [] };
748
+ const rows = await prisma.work_items.findMany({
749
+ where: { parent_id: task.id, kind: "subtask" },
750
+ });
751
+ rows.sort((a, b) => (a.position ?? "").localeCompare(b.position ?? "", undefined, { numeric: true }));
752
+ const subtasks: TaskListItem[] = rows.map((r) => ({
753
+ content: r.title,
754
+ status: normalizeStatus(r.status),
755
+ mark: (r.kind_data?.task_sync as { mark?: string } | undefined)?.mark,
756
+ }));
757
+ return { task_id: task.id, task_title: task.title, subtasks };
758
+ }),
759
+
760
+ /**
761
+ * DB→AGENT self-brief leg. A spawned lane PULLS its own brief — nothing is
762
+ * pasted, typed, passed as an argument or sent over a channel
763
+ * (`action-emission-architecture.md` "The lane PULLS its brief"). The lane's
764
+ * CLI extension reads its own session id on load, calls this procedure, and
765
+ * reads the brief from the task's LATEST comment. The comment — not the
766
+ * description, not a stale comment — is the brief for THIS run: every spawn
767
+ * writes a NEW comment, so "what was this lane told" always has exactly one
768
+ * answer (`assignment-as-dispatch`). Handler lives in task-sync-brief.ts
769
+ * (router is at its line budget); mounted here so fn.taskSync surfaces it.
770
+ */
771
+ readAssignedBrief: readAssignedBriefHandler,
772
+
773
+ /** SPAWNER→DB brief mint: `spawn_agent_tab` turns an initialPrompt into the task row (brief = first comment) the lane self-briefs from; returns the work_item_id that rides the spawn payload. Handler in task-sync-assign.ts. */
774
+ createBriefTask: createBriefTaskHandler,
775
+
776
+ /**
777
+ * TAB→DB rename back-write: the session-picker `/rpc/rename` calls this after
778
+ * renaming a tmux window to update the bound task's title BY SESSION (never
779
+ * pane), so the tab bar and the project board never diverge
780
+ * (`tab-rename-emits-back-to-task-title`). Handler in task-sync-rename.ts.
781
+ */
782
+ renameLaneTask: renameLaneTaskHandler,
783
+
784
+ /**
785
+ * DB→AGENT realtime leg (explicit dispatch path). Record an out-of-band
786
+ * change to a subtask's status (operator toggles it in the projects UI,
787
+ * another lane edits a shared task) and notify the OWNING lane's pane that
788
+ * its projection is stale. The pane's adapter re-reads (readTaskList) and
789
+ * updates its native list. Symmetric counterpart to upsertFromTaskList:
790
+ * there the lane is the originator (no self-echo); here the lane is the
791
+ * recipient.
792
+ *
793
+ * COMPLETION STEP (not MVP): this procedure is the explicit trigger. For
794
+ * FULL coverage — every work_items subtask write, including the projects
795
+ * UI's generated CRUD — a Postgres trigger + LISTEN subscriber on
796
+ * scala-channel fires the same publish, so an operator edit reaches the
797
+ * lane with no explicit call. Tracked in apps/agent-fleet.md task-sync.
798
+ */
799
+ applyExternalChange: authed.handler(async ({ input, context }) => {
800
+ const body = input as { task_id: string; subtask_id?: string; status?: TaskListItem["status"] };
801
+ const prisma = (context as Context).prisma as Prisma;
802
+ const log = getAppLogger();
803
+ const { task_id, subtask_id, status } = body ?? {};
804
+ if (!task_id) {
805
+ const { ORPCError } = await import("@orpc/server");
806
+ throw new ORPCError("BAD_REQUEST", { message: "applyExternalChange requires task_id" });
807
+ }
808
+ if (subtask_id && status) {
809
+ await prisma.work_items.update({
810
+ where: { id: subtask_id },
811
+ data: { status: STATUS_TO_WORK_ITEM[status], completed: status === "completed" },
812
+ });
813
+ }
814
+ const task = await prisma.work_items.findFirst({ where: { id: task_id } });
815
+ const lanePane = (task?.kind_data?.lane as { pane?: string } | undefined)?.pane;
816
+ // Fire-and-forget: a dispatch failure must never block the work_items write.
817
+ if (lanePane) {
818
+ notifyLaneOfTaskChange(lanePane, task_id).catch((err) => {
819
+ log.warn("[task-sync] applyExternalChange: lane notify failed", {
820
+ taskId: task_id,
821
+ error: err instanceof Error ? err.message : String(err),
822
+ });
823
+ });
824
+ }
825
+ return { task_id, lane_pane: lanePane ?? null, notified: !!lanePane };
826
+ }),
827
+ };
828
+
829
+ /**
830
+ * The Claude Code per-item task lifecycle applied to a lane's work_items
831
+ * subtasks — the AGENT→DB leg for the Task* tool family (TaskCreate /
832
+ * TaskUpdate / TaskDelete) and the dedicated TaskCreated / TaskCompleted events.
833
+ *
834
+ * Extracted verbatim from the upsertFromTaskHook ORPC handler so the FULL
835
+ * lifecycle — created, in_progress, completed, deleted — is unit-testable with a
836
+ * mock prisma (same testability split as claim-orchestrator-session, and the
837
+ * router-handlers-only-execute half of planReconcile). The ORPC handler is now a
838
+ * thin `authed` wrapper that delegates here; this function takes the exact
839
+ * `(input, context)` the handler would have bound, so behaviour is unchanged.
840
+ *
841
+ * `task-projection-converges`: every state a task reaches must write through, or
842
+ * the projection is an append-only log and the operator cannot see what a lane is
843
+ * doing. The matcher covering TaskCreate|TaskUpdate|TaskDelete is what makes all
844
+ * four states reach this handler; this body is what applies them.
845
+ */
846
+ export async function upsertFromTaskHookHandler(input: unknown, context: Context) {
847
+ const raw = (input ?? {}) as {
848
+ tool_name?: string;
849
+ tool_input?: Record<string, unknown>;
850
+ tool_response?: Record<string, unknown>;
851
+ json?: { tool_name?: string; tool_input?: Record<string, unknown>; tool_response?: Record<string, unknown> };
852
+ };
853
+ const evt = raw.tool_name ? raw : (raw.json ?? raw);
854
+ const toolName = typeof evt.tool_name === "string" ? evt.tool_name : "";
855
+ const toolInput = (evt.tool_input ?? {}) as Record<string, unknown>;
856
+ const toolResponse = (evt.tool_response ?? {}) as Record<string, unknown>;
857
+ const headerSession = (context as Context).headers?.get("x-lane-session") ?? null;
858
+ const headerPane = (context as Context).headers?.get("x-lane-pane") ?? null;
859
+ const prisma = (context as Context).prisma as Prisma;
860
+ // TaskCreated/TaskCompleted event payloads do NOT carry session_id (only
861
+ // SessionStart does), so the task hooks cannot populate x-lane-session —
862
+ // the extraction in the hook command yields an empty string. Resolve the
863
+ // session from the lane's run row by pane instead: the bind (at
864
+ // session_start) already stamped kind_data.lane.session, and the run row's
865
+ // opencode_session_id is the same CLI-native id. This keeps matching
866
+ // session-keyed (resolveLaneTask still filters kind_data.lane.session)
867
+ // without requiring every task-event payload to carry the session id.
868
+ const session = headerSession || (headerPane ? await resolveSessionFromPane(prisma, headerPane) : null);
869
+ if (!session) return discardApplied("no-session", null);
870
+ const log = getAppLogger();
871
+ // PostToolUse is what Claude Code fires for the Task* tools (verified live:
872
+ // subtasks are created from PostToolUse:TaskCreate events). The dedicated
873
+ // TaskCreated/TaskCompleted EVENT legs below are kept as a defensive path —
874
+ // classifyTaskHookEvent discriminates them by hook_event_name — but the live
875
+ // matcher is PostToolUse:TaskCreate|TaskUpdate|TaskDelete, which lands in the
876
+ // `tool` leg. Handle the dedicated events first so a TaskCompleted cannot fall
877
+ // through to create a duplicate (task-projection-converges).
878
+ const createdEvt = evt as {
879
+ task_subject?: unknown;
880
+ task_id?: unknown;
881
+ hook_event_name?: unknown;
882
+ // Created-already-done must not project OPEN. Absent -> old behaviour.
883
+ status?: unknown;
884
+ };
885
+ const leg = classifyTaskHookEvent(createdEvt);
886
+ if (leg === "completed") {
887
+ const task3 = await resolveLaneTask(prisma, session);
888
+ if (!task3) return discardApplied("no-task-bound", session, { source: "TaskCompleted" });
889
+ const completedId = String(createdEvt.task_id ?? "");
890
+ const subject =
891
+ typeof createdEvt.task_subject === "string"
892
+ ? sanitizeTaskSubject(createdEvt.task_subject)
893
+ : "";
894
+ // Match on the mark stamped at creation; fall back to the title so a
895
+ // subtask created before marks were recorded still closes.
896
+ const siblings = await prisma.work_items.findMany({
897
+ where: { parent_id: task3.id, kind: "subtask" },
898
+ });
899
+ const target =
900
+ siblings.find(
901
+ (s) =>
902
+ completedId &&
903
+ (s.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark ===
904
+ completedId,
905
+ ) ?? siblings.find((s) => subject && s.title === subject);
906
+ if (!target) {
907
+ return discardApplied("no-matching-subtask", session, {
908
+ source: "TaskCompleted",
909
+ taskId: completedId,
910
+ });
911
+ }
912
+ await prisma.work_items.update({
913
+ where: { id: target.id },
914
+ data: { status: "done", completed: true },
915
+ });
916
+ log.info("[task-sync] TaskCompleted: closed subtask", {
917
+ session,
918
+ taskId: completedId,
919
+ subject,
920
+ });
921
+ return { applied: true, op: "complete", taskId: completedId };
922
+ }
923
+ if (leg === "created") {
924
+ // Sanitized on BOTH legs: the completion leg matches by title, so a
925
+ // raw subject here and a sanitized one at creation would never match.
926
+ const subject = sanitizeTaskSubject(String(createdEvt.task_subject ?? ""));
927
+ if (!subject) return discardApplied("no-subject", session, { source: "TaskCreated" });
928
+ // Bind on demand rather than discard: a lane must always be able to
929
+ // record the task it was just told to create.
930
+ const task2 = await ensureLaneTask(prisma, session, headerPane);
931
+ if (!task2) return discardApplied("no-task-bound", session, { source: "TaskCreated" });
932
+ const createdId = String(createdEvt.task_id ?? "");
933
+ const createdStatus =
934
+ STATUS_TO_WORK_ITEM[createdEvt.status as TaskListItem["status"]] ?? "todo";
935
+ const existing = await prisma.work_items.findMany({ where: { parent_id: task2.id, kind: "subtask" } });
936
+ // IDEMPOTENT on the CLI's own task id. The hook curls with `--retry 2
937
+ // --retry-connrefused`, so one TaskCreate can arrive twice (observed
938
+ // 2026-07-25: four tasks each duplicated ~4s apart), and this path used
939
+ // `existing` only to compute a position — never to check whether the
940
+ // subtask was already written. A projection that duplicates on retry is
941
+ // not a projection (task-projection-converges); the operator sees a list
942
+ // that only grows. Match on the stored mark, falling back to title for
943
+ // rows written before marks were recorded.
944
+ const already = existing.find((e) => {
945
+ const mark = (e.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark;
946
+ return createdId && mark ? mark === createdId : e.title === subject;
947
+ });
948
+ if (already) {
949
+ return discardApplied("duplicate-create", session, {
950
+ source: "TaskCreated",
951
+ taskId: createdId,
952
+ subtaskId: already.id,
953
+ });
954
+ }
955
+ try {
956
+ await prisma.work_items.create({
957
+ data: {
958
+ kind: "subtask",
959
+ parent_id: task2.id,
960
+ organisation_id: task2.organisation_id,
961
+ title: subject,
962
+ status: createdStatus,
963
+ completed: createdStatus === "done",
964
+ position: String(existing.length),
965
+ kind_data: { task_sync: { mark: createdId || undefined, source: "claude-code" } },
966
+ },
967
+ });
968
+ } catch (err) {
969
+ // P2002 = unique violation on work_items_subtask_task_sync_mark_uniq.
970
+ // The `already` check above is a READ-THEN-WRITE and therefore loses
971
+ // a race by construction: two retries of one TaskCreate both read
972
+ // `existing` before either writes, both see nothing, and both insert.
973
+ // That is why cleaning duplicates by hand kept "fixing" a class that
974
+ // came straight back (31 rows cleaned earlier on 2026-07-25, 13 more
975
+ // by that evening). The index is what makes the second insert
976
+ // impossible; this catch turns losing the race into the CORRECT
977
+ // idempotent outcome instead of a 500 back to the hook.
978
+ if ((err as { code?: string })?.code !== "P2002") throw err;
979
+ return discardApplied("duplicate-create-race", session, {
980
+ source: "TaskCreated",
981
+ taskId: createdId,
982
+ });
983
+ }
984
+ log.info("[task-sync] TaskCreated: created subtask", { session, taskId: createdId, subject });
985
+ return { applied: true, op: "create", taskId: createdId };
986
+ }
987
+ if (toolName !== "TaskCreate" && toolName !== "TaskUpdate" && toolName !== "TaskDelete") {
988
+ return discardApplied("unhandled-tool", session, { toolName });
989
+ }
990
+ const task = await ensureLaneTask(prisma, session, headerPane);
991
+ if (!task) return discardApplied("no-task-bound", session, { toolName });
992
+
993
+ if (toolName === "TaskCreate") {
994
+ const subject = typeof toolInput.subject === "string" ? toolInput.subject.trim() : "";
995
+ if (!subject) return discardApplied("no-subject", session, { toolName });
996
+ const taskResp = toolResponse.task as Record<string, unknown> | undefined;
997
+ const taskId = String(toolResponse.id ?? toolResponse.taskId ?? taskResp?.id ?? toolInput.taskId ?? "");
998
+ const existing = await prisma.work_items.findMany({ where: { parent_id: task.id, kind: "subtask" } });
999
+ // IDEMPOTENT on the CLI's own task id. The hook curls with --retry, so one
1000
+ // TaskCreate can arrive twice, and this (the LIVE PostToolUse path) used to
1001
+ // fetch `existing` only for position — never to check whether the subtask
1002
+ // was already written — so every retry minted a duplicate
1003
+ // (task-projection-converges: a projection that duplicates is not a
1004
+ // projection). Same mark-then-title match as the TaskCreated event leg.
1005
+ const already = existing.find((e) => {
1006
+ const mark = (e.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark;
1007
+ return taskId && mark ? mark === taskId : e.title === subject;
1008
+ });
1009
+ if (already) {
1010
+ return discardApplied("duplicate-create", session, { toolName, taskId, subtaskId: already.id });
1011
+ }
1012
+ try {
1013
+ await prisma.work_items.create({
1014
+ data: {
1015
+ kind: "subtask",
1016
+ parent_id: task.id,
1017
+ organisation_id: task.organisation_id,
1018
+ title: subject,
1019
+ status: "todo",
1020
+ completed: false,
1021
+ position: String(existing.length),
1022
+ kind_data: { task_sync: { mark: taskId || undefined, source: "claude-code" } },
1023
+ },
1024
+ });
1025
+ } catch (err) {
1026
+ // P2002 = unique violation on work_items_subtask_task_sync_mark_uniq
1027
+ // (read-then-write race: two retries both read `existing` before either
1028
+ // writes). Losing the race is the correct idempotent outcome, not a 500.
1029
+ if ((err as { code?: string })?.code !== "P2002") throw err;
1030
+ return discardApplied("duplicate-create-race", session, { toolName, taskId });
1031
+ }
1032
+ log.info("[task-sync] upsertFromTaskHook: created subtask", { session, taskId, subject });
1033
+ return { applied: true, op: "create", taskId };
1034
+ }
1035
+
1036
+ const taskId = String(toolInput.taskId ?? toolInput.id ?? "");
1037
+ if (!taskId) return discardApplied("no-taskId", session, { toolName });
1038
+ // The handler only EXECUTES; the matching rule lives in the pure
1039
+ // resolveTaskUpdateTarget so it is unit-testable (same split as planReconcile).
1040
+ const siblings = await prisma.work_items.findMany({
1041
+ where: { parent_id: task.id, kind: "subtask" },
1042
+ });
1043
+ const subjectRaw = typeof toolInput.subject === "string" ? toolInput.subject : undefined;
1044
+ const decision = resolveTaskUpdateTarget(siblings, { taskId, subject: subjectRaw });
1045
+ if (decision.op === "heal" && toolName === "TaskUpdate") {
1046
+ await prisma.work_items.create({
1047
+ data: {
1048
+ kind: "subtask",
1049
+ parent_id: task.id,
1050
+ organisation_id: task.organisation_id,
1051
+ title: decision.title,
1052
+ status: STATUS_TO_WORK_ITEM[toolInput.status as TaskListItem["status"]] ?? "todo",
1053
+ completed: toolInput.status === "completed",
1054
+ position: String(siblings.length),
1055
+ kind_data: { task_sync: { mark: taskId || undefined, source: "claude-code" } },
1056
+ },
1057
+ });
1058
+ log.warn("[task-sync] TaskUpdate healed a missing subtask", {
1059
+ session,
1060
+ taskId,
1061
+ subject: decision.title,
1062
+ });
1063
+ return { applied: true, op: "create", taskId };
1064
+ }
1065
+ const subtask = decision.op === "update" ? siblings.find((s) => s.id === decision.id) : null;
1066
+ if (!subtask) return discardApplied("no-matching-subtask", session, { taskId });
1067
+ if (toolName === "TaskDelete") {
1068
+ // Canonical Prisma form (the same as reconcileTaskList's deleteMany):
1069
+ // `id: { in: [...] }`, NOT `id: [...]` — a bare array on the scalar id is a
1070
+ // Prisma validation error that 500'd every TaskDelete, so deletion never
1071
+ // projected even once the hook was wired.
1072
+ await prisma.work_items.deleteMany({ where: { id: { in: [subtask.id] } } });
1073
+ return { applied: true, op: "delete", taskId };
1074
+ }
1075
+ // TaskUpdate
1076
+ const data: {
1077
+ status?: string;
1078
+ completed?: boolean;
1079
+ title?: string;
1080
+ kind_data?: Record<string, unknown>;
1081
+ } = {};
1082
+ const statusRaw = typeof toolInput.status === "string" ? toolInput.status : "";
1083
+ if (statusRaw) {
1084
+ data.status = STATUS_TO_WORK_ITEM[statusRaw as TaskListItem["status"]] ?? "todo";
1085
+ data.completed = statusRaw === "completed";
1086
+ }
1087
+ if (typeof toolInput.subject === "string") data.title = toolInput.subject;
1088
+ // STAMP THE MARK WHEN A ROW IS MATCHED BY TITLE. The first title-match a row
1089
+ // ever gets makes it mark-addressable, so every later rename updates it in
1090
+ // place (task-projection-converges — a row it can no longer address expresses
1091
+ // nothing). The heal path stamps on create; this is the update half.
1092
+ const alreadyMarked =
1093
+ (subtask.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark;
1094
+ if (taskId && !alreadyMarked) {
1095
+ const priorKindData = (subtask.kind_data ?? {}) as Record<string, unknown>;
1096
+ const priorSync = (priorKindData.task_sync ?? {}) as Record<string, unknown>;
1097
+ data.kind_data = {
1098
+ ...priorKindData,
1099
+ task_sync: { ...priorSync, mark: taskId, source: "claude-code" },
1100
+ };
1101
+ }
1102
+ if (Object.keys(data).length === 0) return discardApplied("no-change", session, { taskId });
1103
+ await prisma.work_items.update({ where: { id: subtask.id }, data });
1104
+ log.info("[task-sync] upsertFromTaskHook: updated subtask", { session, taskId, ...data });
1105
+ return { applied: true, op: "update", taskId };
1106
+ }