@teamscala/service-boot 0.23.321 → 0.23.323

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.
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.321",
9
+ "version": "0.23.323",
10
10
  "sideEffects": false,
11
11
  "files": [
12
12
  "src"
@@ -426,6 +426,14 @@
426
426
  "types": "./src/fn-routers/task-sync-projection.d.ts",
427
427
  "default": "./src/fn-routers/task-sync-projection.ts"
428
428
  },
429
+ "./fn-routers/task-sync-rename": {
430
+ "types": "./src/fn-routers/task-sync-rename.d.ts",
431
+ "default": "./src/fn-routers/task-sync-rename.ts"
432
+ },
433
+ "./fn-routers/task-sync-rename.ts": {
434
+ "types": "./src/fn-routers/task-sync-rename.d.ts",
435
+ "default": "./src/fn-routers/task-sync-rename.ts"
436
+ },
429
437
  "./fn-routers/task-sync-router": {
430
438
  "types": "./src/fn-routers/task-sync-router.d.ts",
431
439
  "default": "./src/fn-routers/task-sync-router.ts"
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Lifecycle round-trip for the Claude Code per-item task projection:
7
+ * `upsertFromTaskHookHandler` (the extracted body of the upsertFromTaskHook
8
+ * ORPC handler) must carry a task through created -> in_progress -> completed ->
9
+ * deleted against an in-memory mock prisma, so `work_items` sees every state a
10
+ * task reaches (`task-projection-converges`). A projection that cannot express
11
+ * completion — or deletion — is an append-only log, and the operator cannot see
12
+ * what a lane is doing. The matcher covering TaskCreate|TaskUpdate|TaskDelete is
13
+ * what routes all three tool events here; these tests assert the body applies
14
+ * each one. Same mock-prisma shape as claim-orchestrator-session.test.ts.
15
+ */
16
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
17
+
18
+ const recordEventMock = mock((_event: unknown) => {});
19
+ mock.module("@teamscala/event-log/record-event", () => ({ recordEvent: recordEventMock }));
20
+ mock.module("@teamscala/logger/app-loggers", () => ({
21
+ getAppLogger: () => ({
22
+ info: () => {},
23
+ warn: () => {},
24
+ error: () => {},
25
+ }),
26
+ }));
27
+
28
+ const { upsertFromTaskHookHandler } = await import("./task-sync-router.ts");
29
+
30
+ /** A lane's parent task, returned by the mocked resolveLaneTask findFirst. */
31
+ const LANE_TASK = {
32
+ id: "task-1",
33
+ kind: "task",
34
+ parent_id: null,
35
+ organisation_id: "org-1",
36
+ title: "Test Lane",
37
+ status: "active",
38
+ completed: false,
39
+ position: null,
40
+ kind_data: { lane: { session: "ses-1", pane: "tmux:%1" } },
41
+ };
42
+
43
+ function makePrisma() {
44
+ // In-memory subtask store, so the four states act on ONE evolving row.
45
+ const subtasks: Array<Record<string, unknown>> = [];
46
+ const findFirst = mock(async (args: { where: Record<string, unknown> }) => {
47
+ // resolveLaneTask asks for kind='task' + status active/archived; everything
48
+ // else (subtask lookups) returns null.
49
+ if (args.where?.kind === "task") return { ...LANE_TASK };
50
+ return null;
51
+ });
52
+ const findMany = mock(async (args: { where: Record<string, unknown> }) => {
53
+ const pid = args.where?.parent_id;
54
+ return subtasks.filter((s) => s.parent_id === pid);
55
+ });
56
+ const create = mock(async (args: { data: Record<string, unknown> }) => {
57
+ const row = { id: `s-${subtasks.length + 1}`, ...args.data };
58
+ subtasks.push(row);
59
+ return row;
60
+ });
61
+ const update = mock(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
62
+ const row = subtasks.find((s) => s.id === args.where.id);
63
+ if (row) Object.assign(row, args.data);
64
+ return row ?? null;
65
+ });
66
+ const deleteMany = mock(async (args: { where: { id: { in: string[] } } }) => {
67
+ // Real Prisma form: where.id.in (a bare array on the scalar id is a
68
+ // validation error). Mirrors the canonical deleteMany in reconcileTaskList.
69
+ const ids = args.where?.id?.in ?? [];
70
+ let count = 0;
71
+ for (let i = subtasks.length - 1; i >= 0; i--) {
72
+ if (ids.includes(String(subtasks[i]?.id))) {
73
+ subtasks.splice(i, 1);
74
+ count++;
75
+ }
76
+ }
77
+ return { count };
78
+ });
79
+ return {
80
+ store: subtasks,
81
+ prisma: { work_items: { findFirst, findMany, create, update, deleteMany } },
82
+ mocks: { findFirst, findMany, create, update, deleteMany },
83
+ };
84
+ }
85
+
86
+ /** A lane context bound to a mock prisma, so each call site passes only the event. */
87
+ function bindCtx(prisma: unknown, session = "ses-1") {
88
+ const headers = new Headers();
89
+ headers.set("x-lane-session", session);
90
+ headers.set("x-lane-pane", "tmux:%1");
91
+ return { headers, prisma, user: undefined };
92
+ }
93
+
94
+ /** makePrisma + a `hook(evt)` convenience bound to that prisma's session. */
95
+ function makeFixture() {
96
+ const fixture = makePrisma();
97
+ const hook = (evt: unknown) => upsertFromTaskHookHandler(evt, bindCtx(fixture.prisma));
98
+ return { ...fixture, hook };
99
+ }
100
+
101
+ describe("upsertFromTaskHookHandler — full lifecycle round-trip", () => {
102
+ beforeEach(() => recordEventMock.mockClear());
103
+
104
+ it("created -> in_progress -> completed -> deleted all write through to one subtask", async () => {
105
+ const { hook, store, mocks } = makeFixture();
106
+
107
+ // 1. CREATED — PostToolUse:TaskCreate. The harness assigns the task id in
108
+ // tool_response.task.id; the projection must stamp it as the mark so every
109
+ // later state is mark-addressable.
110
+ const created = await hook({
111
+ tool_name: "TaskCreate",
112
+ hook_event_name: "PostToolUse",
113
+ tool_input: { subject: "Ship the fix" },
114
+ tool_response: { task: { id: "42" } },
115
+ });
116
+ expect(created).toEqual({ applied: true, op: "create", taskId: "42" });
117
+ expect(mocks.create).toHaveBeenCalledTimes(1);
118
+ const row = store[0];
119
+ expect(row.title).toBe("Ship the fix");
120
+ expect(row.status).toBe("todo");
121
+ expect((row.kind_data as { task_sync: { mark: string } }).task_sync.mark).toBe("42");
122
+
123
+ // 2. IN_PROGRESS — PostToolUse:TaskUpdate with status in_progress. Matched
124
+ // by the mark stamped at creation; the projection expresses it, it does
125
+ // not append a new row.
126
+ mocks.create.mockClear();
127
+ const inProgress = await hook({
128
+ tool_name: "TaskUpdate",
129
+ hook_event_name: "PostToolUse",
130
+ tool_input: { taskId: "42", status: "in_progress" },
131
+ });
132
+ expect(inProgress).toEqual({ applied: true, op: "update", taskId: "42" });
133
+ expect(mocks.create).not.toHaveBeenCalled();
134
+ expect(mocks.update).toHaveBeenCalledTimes(1);
135
+ expect(row.status).toBe("in-progress");
136
+ expect(row.completed).toBe(false);
137
+ expect(store).toHaveLength(1);
138
+
139
+ // 3. COMPLETED — PostToolUse:TaskUpdate with status completed. A projection
140
+ // that cannot express completion is an append-only log.
141
+ mocks.update.mockClear();
142
+ const done = await hook({
143
+ tool_name: "TaskUpdate",
144
+ hook_event_name: "PostToolUse",
145
+ tool_input: { taskId: "42", status: "completed" },
146
+ });
147
+ expect(done).toEqual({ applied: true, op: "update", taskId: "42" });
148
+ expect(mocks.update).toHaveBeenCalledTimes(1);
149
+ expect(row.status).toBe("done");
150
+ expect(row.completed).toBe(true);
151
+ expect(store).toHaveLength(1);
152
+
153
+ // 4. DELETED — PostToolUse:TaskDelete. The row leaves the projection; the
154
+ // list empties, which is the half a create-only projection can never do.
155
+ const deleted = await hook({
156
+ tool_name: "TaskDelete",
157
+ hook_event_name: "PostToolUse",
158
+ tool_input: { taskId: "42" },
159
+ });
160
+ expect(deleted).toEqual({ applied: true, op: "delete", taskId: "42" });
161
+ expect(mocks.deleteMany).toHaveBeenCalledTimes(1);
162
+ expect(store).toHaveLength(0);
163
+ });
164
+
165
+ it("stamps the mark on a title-matched update so a later rename is mark-addressable", async () => {
166
+ // A row written with NO mark (pre-mark history, or a healed row) is only
167
+ // reachable by title. The first title-match must stamp the mark so the row
168
+ // never orphans on a later rename (the 2026-07-28 stranded-subtask class).
169
+ const { hook, store } = makeFixture();
170
+ store.push({
171
+ id: "legacy-1",
172
+ parent_id: "task-1",
173
+ title: "Old title",
174
+ status: "todo",
175
+ completed: false,
176
+ kind_data: {},
177
+ });
178
+
179
+ await hook({
180
+ tool_name: "TaskUpdate",
181
+ hook_event_name: "PostToolUse",
182
+ tool_input: { taskId: "99", subject: "Old title", status: "in_progress" },
183
+ });
184
+ expect((store[0].kind_data as { task_sync: { mark: string } }).task_sync.mark).toBe("99");
185
+ expect(store[0].status).toBe("in-progress");
186
+ });
187
+
188
+ it("is idempotent on a retried TaskCreate (no duplicate subtask)", async () => {
189
+ // The hook curls with --retry, so one TaskCreate can arrive twice. A
190
+ // projection that duplicates on retry is not a projection.
191
+ const { hook, store } = makeFixture();
192
+ const evt = {
193
+ tool_name: "TaskCreate",
194
+ hook_event_name: "PostToolUse",
195
+ tool_input: { subject: "Once only" },
196
+ tool_response: { task: { id: "7" } },
197
+ };
198
+ await hook(evt);
199
+ const second = await hook(evt);
200
+ // The retried create matches the stored mark and is discarded, not doubled.
201
+ expect(second.applied).toBe(false);
202
+ expect(store.filter((s) => s.title === "Once only")).toHaveLength(1);
203
+ });
204
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Lane RENAME back-write for task-sync: a tab rename EMITS to update the bound
7
+ * work_items task's title in the DB, by CLI session id. This is the DB half of
8
+ * `tab-rename-emits-back-to-task-title`: the operator's tab bar is how they see
9
+ * what is being worked on (`lane-labels-and-task-lists`), so the tmux window
10
+ * name and the task title must never diverge silently. Renaming a tab from
11
+ * `scala-tools session rename` (or the lane renaming itself through the same
12
+ * surface) updates the task title so the project board agrees with the tab bar.
13
+ *
14
+ * THE EMISSION, not a poll (`actions-are-rows-then-emissions`): a caller renames
15
+ * the window (a row/state change) and that change emits the title update — the
16
+ * task-sync owner reacts by updating the row. State lives in the row, never in a
17
+ * return value a caller may never receive.
18
+ *
19
+ * Bound by CLI SESSION ID, never pane id (`lane-identity-is-cli-session-id`): a
20
+ * pane OUTLIVES the session inside it, so matching on a pane would let a dead
21
+ * session's task answer for a live lane. The session is the stable identity a
22
+ * rename resolves to (the daemon resolves the pane's tab → its stored session id
23
+ * and passes that here), so the rename lands on the lane that owns the tab.
24
+ *
25
+ * Split out of task-sync-router.ts for the same reason task-sync-brief/lane/
26
+ * assign exist: the router sits at its 1000-line commit gate, so every cohesive
27
+ * task-sync unit that can live in a sibling does. This owns the rename handler +
28
+ * its pure outcome decision.
29
+ *
30
+ * CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
31
+ * candidate. Bound to the work_items schema and to task-sync's own
32
+ * kind_data.lane.session convention; no unrelated app could install this
33
+ * unmodified.
34
+ */
35
+ import * as v from "valibot";
36
+ import { type Context } from "./auth-router.ts";
37
+ /**
38
+ * renameLaneTask ORPC input. `lane_session` is the lane's CLI-native session id
39
+ * (kind_data.lane.session) — the stable identity a pane rename resolves to.
40
+ * `title` is the new task title (the renamed tab's label). Both required:
41
+ * oRPC rejects a call missing either at the signature
42
+ * (`prevention-over-detection`), so a malformed rename is a 400, never a silent
43
+ * no-op that leaves the title stale.
44
+ */
45
+ export declare const renameLaneTaskInput: v.ObjectSchema<{
46
+ readonly lane_session: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "lane_session is required">]>;
47
+ readonly title: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "title is required">]>;
48
+ }, undefined>;
49
+ /**
50
+ * The rename outcome from a resolved lane task — pure so the "no task bound ⇒
51
+ * not updated, never an error" decision is unit-testable with no DB. A rename
52
+ * whose lane has no task is best-effort (returns updated:false): every spawn
53
+ * now mints a task, so this is the rare case (a tab whose task was archived and
54
+ * pruned), not a path the operator hits. Failing loudly here would refuse a
55
+ * rename of the WINDOW for a DB miss, which couples two surfaces that must not
56
+ * be — the tmux rename already succeeded before this is called.
57
+ */
58
+ export declare function renameOutcome(task: {
59
+ id: string;
60
+ } | null): {
61
+ task_id: string | null;
62
+ updated: boolean;
63
+ };
64
+ /**
65
+ * renameLaneTask ORPC handler. The session-picker daemon calls this from
66
+ * `/rpc/rename` after it renames the tmux window: it resolves the pane's tab to
67
+ * its session id and posts it here. Same transport as bindLane/readTaskList:
68
+ * page-gate-exempt (fn.taskSync), localhost super-admin. Resolves the lane's
69
+ * task BY SESSION (never pane) and updates its title. Idempotent: renaming to
70
+ * the same title writes the same row.
71
+ *
72
+ * An archived parent is matched too (resolveLaneTask falls back to the archived
73
+ * lane task for the session), so a rename of a tab whose run closed still lands
74
+ * on its task — the title is the title regardless of run state.
75
+ */
76
+ export declare const renameLaneTaskHandler: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
77
+ headers: Headers;
78
+ resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
79
+ prisma: any;
80
+ user: import("./auth-router.ts").User;
81
+ orgId: string | null | undefined;
82
+ }>, v.ObjectSchema<{
83
+ readonly lane_session: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "lane_session is required">]>;
84
+ readonly title: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "title is required">]>;
85
+ }, undefined>, import("@orpc/contract").Schema<{
86
+ task_id: string | null;
87
+ updated: boolean;
88
+ }, {
89
+ task_id: string | null;
90
+ updated: boolean;
91
+ }>, Record<never, never>, Record<never, never>>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit directly
5
+ *
6
+ * Unit tests for the rename back-write leg: renameOutcome (the pure
7
+ * no-task ⇒ not-updated decision) + source-contract assertions that the handler
8
+ * resolves the lane task BY SESSION (never pane) and updates its title. Covers
9
+ * `what-must-have-a-test` for the new procedure + the `lane-identity-is-cli-
10
+ * session-id` invariant it rides on.
11
+ */
12
+ import { describe, it, expect } from "bun:test";
13
+ import { renameOutcome } from "./task-sync-rename.ts";
14
+
15
+ describe("renameOutcome (no verdict from no data)", () => {
16
+ it("returns not-updated with a null task_id when no task is bound to the session", () => {
17
+ // A rename of a tab whose lane has no resolvable task updates nothing —
18
+ // never an error, never a fake id. updated:false is the signal the caller
19
+ // treats as best-effort (the window was already renamed; this only missed
20
+ // the DB echo, which is the rare archived-and-pruned case).
21
+ expect(renameOutcome(null)).toEqual({ task_id: null, updated: false });
22
+ });
23
+
24
+ it("returns the bound task id + updated:true when a task resolves", () => {
25
+ expect(renameOutcome({ id: "task-9" })).toEqual({ task_id: "task-9", updated: true });
26
+ });
27
+ });
28
+
29
+ describe("renameLaneTask resolves BY SESSION and writes the title (source contract)", () => {
30
+ const renameSrc = Bun.file(new URL("./task-sync-rename.ts", import.meta.url).pathname);
31
+
32
+ it("resolves the lane task by lane_session (the CLI session id), never by pane", async () => {
33
+ // `lane-identity-is-cli-session-id`: binding is by session because a pane
34
+ // outlives the session inside it. A pane-keyed rename would let a dead
35
+ // session's task answer for a live lane.
36
+ const text = await renameSrc.text();
37
+ expect(text).toContain("resolveLaneTask(prisma, lane_session)");
38
+ // No pane matching anywhere in the resolution path.
39
+ expect(text).not.toContain("lane.pane");
40
+ });
41
+
42
+ it("updates the resolved task's title by id", async () => {
43
+ const text = await renameSrc.text();
44
+ expect(text).toContain('where: { id: task.id }');
45
+ expect(text).toContain("data: { title }");
46
+ });
47
+
48
+ it("requires lane_session + title at the signature (incomplete rename unconstructible)", async () => {
49
+ const text = await renameSrc.text();
50
+ expect(text).toContain('lane_session: v.pipe(v.string()');
51
+ expect(text).toContain('title: v.pipe(v.string()');
52
+ });
53
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Lane RENAME back-write for task-sync: a tab rename EMITS to update the bound
7
+ * work_items task's title in the DB, by CLI session id. This is the DB half of
8
+ * `tab-rename-emits-back-to-task-title`: the operator's tab bar is how they see
9
+ * what is being worked on (`lane-labels-and-task-lists`), so the tmux window
10
+ * name and the task title must never diverge silently. Renaming a tab from
11
+ * `scala-tools session rename` (or the lane renaming itself through the same
12
+ * surface) updates the task title so the project board agrees with the tab bar.
13
+ *
14
+ * THE EMISSION, not a poll (`actions-are-rows-then-emissions`): a caller renames
15
+ * the window (a row/state change) and that change emits the title update — the
16
+ * task-sync owner reacts by updating the row. State lives in the row, never in a
17
+ * return value a caller may never receive.
18
+ *
19
+ * Bound by CLI SESSION ID, never pane id (`lane-identity-is-cli-session-id`): a
20
+ * pane OUTLIVES the session inside it, so matching on a pane would let a dead
21
+ * session's task answer for a live lane. The session is the stable identity a
22
+ * rename resolves to (the daemon resolves the pane's tab → its stored session id
23
+ * and passes that here), so the rename lands on the lane that owns the tab.
24
+ *
25
+ * Split out of task-sync-router.ts for the same reason task-sync-brief/lane/
26
+ * assign exist: the router sits at its 1000-line commit gate, so every cohesive
27
+ * task-sync unit that can live in a sibling does. This owns the rename handler +
28
+ * its pure outcome decision.
29
+ *
30
+ * CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
31
+ * candidate. Bound to the work_items schema and to task-sync's own
32
+ * kind_data.lane.session convention; no unrelated app could install this
33
+ * unmodified.
34
+ */
35
+
36
+ import * as v from "valibot";
37
+ import type { Prisma } from "./task-sync-router.ts";
38
+ import { authed, type Context } from "./auth-router.ts";
39
+ import { resolveLaneTask } from "./task-sync-lane.ts";
40
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
41
+
42
+ /**
43
+ * renameLaneTask ORPC input. `lane_session` is the lane's CLI-native session id
44
+ * (kind_data.lane.session) — the stable identity a pane rename resolves to.
45
+ * `title` is the new task title (the renamed tab's label). Both required:
46
+ * oRPC rejects a call missing either at the signature
47
+ * (`prevention-over-detection`), so a malformed rename is a 400, never a silent
48
+ * no-op that leaves the title stale.
49
+ */
50
+ export const renameLaneTaskInput = v.object({
51
+ lane_session: v.pipe(v.string(), v.minLength(1, "lane_session is required")),
52
+ title: v.pipe(v.string(), v.minLength(1, "title is required")),
53
+ });
54
+
55
+ /**
56
+ * The rename outcome from a resolved lane task — pure so the "no task bound ⇒
57
+ * not updated, never an error" decision is unit-testable with no DB. A rename
58
+ * whose lane has no task is best-effort (returns updated:false): every spawn
59
+ * now mints a task, so this is the rare case (a tab whose task was archived and
60
+ * pruned), not a path the operator hits. Failing loudly here would refuse a
61
+ * rename of the WINDOW for a DB miss, which couples two surfaces that must not
62
+ * be — the tmux rename already succeeded before this is called.
63
+ */
64
+ export function renameOutcome(task: { id: string } | null): {
65
+ task_id: string | null;
66
+ updated: boolean;
67
+ } {
68
+ if (!task) return { task_id: null, updated: false };
69
+ return { task_id: task.id, updated: true };
70
+ }
71
+
72
+ /**
73
+ * renameLaneTask ORPC handler. The session-picker daemon calls this from
74
+ * `/rpc/rename` after it renames the tmux window: it resolves the pane's tab to
75
+ * its session id and posts it here. Same transport as bindLane/readTaskList:
76
+ * page-gate-exempt (fn.taskSync), localhost super-admin. Resolves the lane's
77
+ * task BY SESSION (never pane) and updates its title. Idempotent: renaming to
78
+ * the same title writes the same row.
79
+ *
80
+ * An archived parent is matched too (resolveLaneTask falls back to the archived
81
+ * lane task for the session), so a rename of a tab whose run closed still lands
82
+ * on its task — the title is the title regardless of run state.
83
+ */
84
+ export const renameLaneTaskHandler = authed
85
+ .input(renameLaneTaskInput)
86
+ .handler(async ({ input, context }) => {
87
+ const ctx = context as Context;
88
+ const prisma = ctx.prisma as Prisma;
89
+ const { lane_session, title } = input;
90
+ const task = await resolveLaneTask(prisma, lane_session);
91
+ const outcome = renameOutcome(task);
92
+ if (!task) return outcome;
93
+ try {
94
+ await prisma.work_items.update({
95
+ where: { id: task.id },
96
+ data: { title },
97
+ select: { id: true },
98
+ });
99
+ } catch (err) {
100
+ // The window was already renamed by the caller; a DB update failure must
101
+ // not surface as an error that implies the rename failed. Report
102
+ // not-updated + log, so the two surfaces stay decoupled
103
+ // (`a-component-may-not-report-a-state-it-has-not-verified`).
104
+ getAppLogger().warn("[task-sync] renameLaneTask: title update failed", {
105
+ taskId: task.id,
106
+ error: err instanceof Error ? err.message : String(err),
107
+ });
108
+ return { task_id: task.id, updated: false };
109
+ }
110
+ return outcome;
111
+ });
@@ -344,6 +344,28 @@ export declare const taskSyncRouter: {
344
344
  }, {
345
345
  work_item_id: string;
346
346
  }>, Record<never, never>, Record<never, never>>;
347
+ /**
348
+ * TAB→DB rename back-write: the session-picker `/rpc/rename` calls this after
349
+ * renaming a tmux window to update the bound task's title BY SESSION (never
350
+ * pane), so the tab bar and the project board never diverge
351
+ * (`tab-rename-emits-back-to-task-title`). Handler in task-sync-rename.ts.
352
+ */
353
+ renameLaneTask: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
354
+ headers: Headers;
355
+ resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
356
+ prisma: any;
357
+ user: import("./auth-router.ts").User;
358
+ orgId: string | null | undefined;
359
+ }>, v.ObjectSchema<{
360
+ readonly lane_session: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "lane_session is required">]>;
361
+ readonly title: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "title is required">]>;
362
+ }, undefined>, import("@orpc/contract").Schema<{
363
+ task_id: string | null;
364
+ updated: boolean;
365
+ }, {
366
+ task_id: string | null;
367
+ updated: boolean;
368
+ }>, Record<never, never>, Record<never, never>>;
347
369
  /**
348
370
  * DB→AGENT realtime leg (explicit dispatch path). Record an out-of-band
349
371
  * change to a subtask's status (operator toggles it in the projects UI,
@@ -375,3 +397,28 @@ export declare const taskSyncRouter: {
375
397
  notified: boolean;
376
398
  }>, Record<never, never>, Record<never, never>>;
377
399
  };
400
+ /**
401
+ * The Claude Code per-item task lifecycle applied to a lane's work_items
402
+ * subtasks — the AGENT→DB leg for the Task* tool family (TaskCreate /
403
+ * TaskUpdate / TaskDelete) and the dedicated TaskCreated / TaskCompleted events.
404
+ *
405
+ * Extracted verbatim from the upsertFromTaskHook ORPC handler so the FULL
406
+ * lifecycle — created, in_progress, completed, deleted — is unit-testable with a
407
+ * mock prisma (same testability split as claim-orchestrator-session, and the
408
+ * router-handlers-only-execute half of planReconcile). The ORPC handler is now a
409
+ * thin `authed` wrapper that delegates here; this function takes the exact
410
+ * `(input, context)` the handler would have bound, so behaviour is unchanged.
411
+ *
412
+ * `task-projection-converges`: every state a task reaches must write through, or
413
+ * the projection is an append-only log and the operator cannot see what a lane is
414
+ * doing. The matcher covering TaskCreate|TaskUpdate|TaskDelete is what makes all
415
+ * four states reach this handler; this body is what applies them.
416
+ */
417
+ export declare function upsertFromTaskHookHandler(input: unknown, context: Context): Promise<{
418
+ applied: boolean;
419
+ op: string;
420
+ taskId: string;
421
+ } | ({
422
+ applied: false;
423
+ reason: string;
424
+ } & Record<string, unknown>)>;
@@ -43,6 +43,7 @@ import { authed, type Context } from "./auth-router.ts";
43
43
  import { notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
44
44
  import { resolveLaneTask, resolveLaneWorkName } from "./task-sync-lane.ts";
45
45
  import { readAssignedBriefHandler } from "./task-sync-brief.ts";
46
+ import { renameLaneTaskHandler } from "./task-sync-rename.ts";
46
47
  import {
47
48
  adoptAssignmentTask,
48
49
  bindLaneInput,
@@ -721,265 +722,8 @@ export const taskSyncRouter = {
721
722
  * less: fn.taskSync is page-gate-exempt; localhost pane shell authenticates
722
723
  * as super-admin via resolveUser. Soft-returns on any no-op (never throws).
723
724
  */
724
- upsertFromTaskHook: authed.handler(async ({ input, context }) => {
725
- const raw = (input ?? {}) as {
726
- tool_name?: string;
727
- tool_input?: Record<string, unknown>;
728
- tool_response?: Record<string, unknown>;
729
- json?: { tool_name?: string; tool_input?: Record<string, unknown>; tool_response?: Record<string, unknown> };
730
- };
731
- const evt = raw.tool_name ? raw : (raw.json ?? raw);
732
- const toolName = typeof evt.tool_name === "string" ? evt.tool_name : "";
733
- const toolInput = (evt.tool_input ?? {}) as Record<string, unknown>;
734
- const toolResponse = (evt.tool_response ?? {}) as Record<string, unknown>;
735
- const headerSession = (context as Context).headers?.get("x-lane-session") ?? null;
736
- const headerPane = (context as Context).headers?.get("x-lane-pane") ?? null;
737
- const prisma = (context as Context).prisma as Prisma;
738
- // TaskCreated/TaskCompleted event payloads do NOT carry session_id (only
739
- // SessionStart does), so the task hooks cannot populate x-lane-session —
740
- // the extraction in the hook command yields an empty string. Resolve the
741
- // session from the lane's run row by pane instead: the bind (at
742
- // session_start) already stamped kind_data.lane.session, and the run row's
743
- // opencode_session_id is the same CLI-native id. This keeps matching
744
- // session-keyed (resolveLaneTask still filters kind_data.lane.session)
745
- // without requiring every task-event payload to carry the session id.
746
- const session = headerSession || (headerPane ? await resolveSessionFromPane(prisma, headerPane) : null);
747
- if (!session) return discardApplied("no-session", null);
748
- const log = getAppLogger();
749
- // Claude Code fires the dedicated `TaskCreated` EVENT for the Task* tools,
750
- // NOT PostToolUse — a PostToolUse:TaskCreate matcher never fired (the prior
751
- // hook shape silently produced zero subtasks, so Claude lanes showed an
752
- // empty task panel). The TaskCreated payload carries task_subject + task_id
753
- // on the event object (evt — already unwrapped from the {json:...} wire
754
- // envelope by the raw.json ?? raw fallback above). Handle it before the
755
- // toolName guard so the dedicated event path creates the subtask.
756
- const createdEvt = evt as {
757
- task_subject?: unknown;
758
- task_id?: unknown;
759
- hook_event_name?: unknown;
760
- // Created-already-done must not project OPEN. Absent -> old behaviour.
761
- status?: unknown;
762
- };
763
- // TaskCompleted BEFORE the TaskCreated branch. Both dedicated events carry
764
- // `task_subject`, so the subject-presence test below cannot tell them apart
765
- // — an unguarded TaskCompleted would fall into the create path and mint a
766
- // DUPLICATE subtask instead of completing the existing one, which reads as
767
- // "the list only ever grows and nothing is ever done"
768
- // (`task-projection-converges`). `hook_event_name` is a common input field
769
- // on every hook event, so it is the reliable discriminator.
770
- const leg = classifyTaskHookEvent(createdEvt);
771
- if (leg === "completed") {
772
- const task3 = await resolveLaneTask(prisma, session);
773
- if (!task3) return discardApplied("no-task-bound", session, { source: "TaskCompleted" });
774
- const completedId = String(createdEvt.task_id ?? "");
775
- const subject =
776
- typeof createdEvt.task_subject === "string"
777
- ? sanitizeTaskSubject(createdEvt.task_subject)
778
- : "";
779
- // Match on the mark stamped at creation; fall back to the title so a
780
- // subtask created before marks were recorded still closes.
781
- const siblings = await prisma.work_items.findMany({
782
- where: { parent_id: task3.id, kind: "subtask" },
783
- });
784
- const target =
785
- siblings.find(
786
- (s) =>
787
- completedId &&
788
- (s.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark ===
789
- completedId,
790
- ) ?? siblings.find((s) => subject && s.title === subject);
791
- if (!target) {
792
- return discardApplied("no-matching-subtask", session, {
793
- source: "TaskCompleted",
794
- taskId: completedId,
795
- });
796
- }
797
- await prisma.work_items.update({
798
- where: { id: target.id },
799
- data: { status: "done", completed: true },
800
- });
801
- log.info("[task-sync] TaskCompleted: closed subtask", {
802
- session,
803
- taskId: completedId,
804
- subject,
805
- });
806
- return { applied: true, op: "complete", taskId: completedId };
807
- }
808
- if (leg === "created") {
809
- // Sanitized on BOTH legs: the completion leg matches by title, so a
810
- // raw subject here and a sanitized one at creation would never match.
811
- const subject = sanitizeTaskSubject(String(createdEvt.task_subject ?? ""));
812
- if (!subject) return discardApplied("no-subject", session, { source: "TaskCreated" });
813
- // Bind on demand rather than discard: a lane must always be able to
814
- // record the task it was just told to create.
815
- const task2 = await ensureLaneTask(prisma, session, headerPane);
816
- if (!task2) return discardApplied("no-task-bound", session, { source: "TaskCreated" });
817
- const createdId = String(createdEvt.task_id ?? "");
818
- const createdStatus =
819
- STATUS_TO_WORK_ITEM[createdEvt.status as TaskListItem["status"]] ?? "todo";
820
- const existing = await prisma.work_items.findMany({ where: { parent_id: task2.id, kind: "subtask" } });
821
- // IDEMPOTENT on the CLI's own task id. The hook curls with `--retry 2
822
- // --retry-connrefused`, so one TaskCreate can arrive twice (observed
823
- // 2026-07-25: four tasks each duplicated ~4s apart), and this path used
824
- // `existing` only to compute a position — never to check whether the
825
- // subtask was already written. A projection that duplicates on retry is
826
- // not a projection (task-projection-converges); the operator sees a list
827
- // that only grows. Match on the stored mark, falling back to title for
828
- // rows written before marks were recorded.
829
- const already = existing.find((e) => {
830
- const mark = (e.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark;
831
- return createdId && mark ? mark === createdId : e.title === subject;
832
- });
833
- if (already) {
834
- return discardApplied("duplicate-create", session, {
835
- source: "TaskCreated",
836
- taskId: createdId,
837
- subtaskId: already.id,
838
- });
839
- }
840
- try {
841
- await prisma.work_items.create({
842
- data: {
843
- kind: "subtask",
844
- parent_id: task2.id,
845
- organisation_id: task2.organisation_id,
846
- title: subject,
847
- status: createdStatus,
848
- completed: createdStatus === "done",
849
- position: String(existing.length),
850
- kind_data: { task_sync: { mark: createdId || undefined, source: "claude-code" } },
851
- },
852
- });
853
- } catch (err) {
854
- // P2002 = unique violation on work_items_subtask_task_sync_mark_uniq.
855
- // The `already` check above is a READ-THEN-WRITE and therefore loses
856
- // a race by construction: two retries of one TaskCreate both read
857
- // `existing` before either writes, both see nothing, and both insert.
858
- // That is why cleaning duplicates by hand kept "fixing" a class that
859
- // came straight back (31 rows cleaned earlier on 2026-07-25, 13 more
860
- // by that evening). The index is what makes the second insert
861
- // impossible; this catch turns losing the race into the CORRECT
862
- // idempotent outcome instead of a 500 back to the hook.
863
- if ((err as { code?: string })?.code !== "P2002") throw err;
864
- return discardApplied("duplicate-create-race", session, {
865
- source: "TaskCreated",
866
- taskId: createdId,
867
- });
868
- }
869
- log.info("[task-sync] TaskCreated: created subtask", { session, taskId: createdId, subject });
870
- return { applied: true, op: "create", taskId: createdId };
871
- }
872
- if (toolName !== "TaskCreate" && toolName !== "TaskUpdate" && toolName !== "TaskDelete") {
873
- return discardApplied("unhandled-tool", session, { toolName });
874
- }
875
- const task = await ensureLaneTask(prisma, session, headerPane);
876
- if (!task) return discardApplied("no-task-bound", session, { toolName });
877
-
878
- if (toolName === "TaskCreate") {
879
- const subject = typeof toolInput.subject === "string" ? toolInput.subject.trim() : "";
880
- if (!subject) return discardApplied("no-subject", session, { toolName });
881
- const taskResp = toolResponse.task as Record<string, unknown> | undefined;
882
- const taskId = String(toolResponse.id ?? toolResponse.taskId ?? taskResp?.id ?? toolInput.taskId ?? "");
883
- const existing = await prisma.work_items.findMany({ where: { parent_id: task.id, kind: "subtask" } });
884
- await prisma.work_items.create({
885
- data: {
886
- kind: "subtask",
887
- parent_id: task.id,
888
- organisation_id: task.organisation_id,
889
- title: subject,
890
- status: "todo",
891
- completed: false,
892
- position: String(existing.length),
893
- kind_data: { task_sync: { mark: taskId || undefined, source: "claude-code" } },
894
- },
895
- });
896
- log.info("[task-sync] upsertFromTaskHook: created subtask", { session, taskId, subject });
897
- return { applied: true, op: "create", taskId };
898
- }
899
-
900
- const taskId = String(toolInput.taskId ?? toolInput.id ?? "");
901
- if (!taskId) return discardApplied("no-taskId", session, { toolName });
902
- // The handler only EXECUTES; the matching rule lives in the pure
903
- // resolveTaskUpdateTarget so it is unit-testable (same split as planReconcile).
904
- const siblings = await prisma.work_items.findMany({
905
- where: { parent_id: task.id, kind: "subtask" },
906
- });
907
- const subjectRaw = typeof toolInput.subject === "string" ? toolInput.subject : undefined;
908
- const decision = resolveTaskUpdateTarget(siblings, { taskId, subject: subjectRaw });
909
- if (decision.op === "heal" && toolName === "TaskUpdate") {
910
- await prisma.work_items.create({
911
- data: {
912
- kind: "subtask",
913
- parent_id: task.id,
914
- organisation_id: task.organisation_id,
915
- title: decision.title,
916
- status: STATUS_TO_WORK_ITEM[toolInput.status as TaskListItem["status"]] ?? "todo",
917
- completed: toolInput.status === "completed",
918
- position: String(siblings.length),
919
- kind_data: { task_sync: { mark: taskId || undefined, source: "claude-code" } },
920
- },
921
- });
922
- log.warn("[task-sync] TaskUpdate healed a missing subtask", {
923
- session,
924
- taskId,
925
- subject: decision.title,
926
- });
927
- return { applied: true, op: "create", taskId };
928
- }
929
- const subtask = decision.op === "update" ? siblings.find((s) => s.id === decision.id) : null;
930
- if (!subtask) return discardApplied("no-matching-subtask", session, { taskId });
931
- if (toolName === "TaskDelete") {
932
- await prisma.work_items.deleteMany({ where: { id: [subtask.id] } });
933
- return { applied: true, op: "delete", taskId };
934
- }
935
- // TaskUpdate
936
- const data: {
937
- status?: string;
938
- completed?: boolean;
939
- title?: string;
940
- kind_data?: Record<string, unknown>;
941
- } = {};
942
- const statusRaw = typeof toolInput.status === "string" ? toolInput.status : "";
943
- if (statusRaw) {
944
- data.status = STATUS_TO_WORK_ITEM[statusRaw as TaskListItem["status"]] ?? "todo";
945
- data.completed = statusRaw === "completed";
946
- }
947
- if (typeof toolInput.subject === "string") data.title = toolInput.subject;
948
- // STAMP THE MARK WHEN A ROW IS MATCHED BY TITLE.
949
- //
950
- // Matching is mark-first, then EXACT TITLE. A row that has never been
951
- // stamped can therefore only ever be found by its title — so the moment a
952
- // task is RENAMED, neither match succeeds, the event heals into a NEW row,
953
- // and the pre-rename row is orphaned at whatever status it held. Forever:
954
- // nothing else in the single-event path will ever look at it again.
955
- //
956
- // Observed 2026-07-28 on this lane — three subtasks stranded at `todo` and
957
- // `in-progress` under their OLD titles while the CLI's own list read empty,
958
- // which is what made the stop gate refuse a lane that had genuinely
959
- // finished. `task-projection-converges` requires the projection to express
960
- // every state a task reaches; a row it can no longer address expresses
961
- // nothing.
962
- //
963
- // Stamping here closes the class rather than the instance: the FIRST
964
- // title-match a row ever gets makes it mark-addressable, so every later
965
- // rename updates it in place. The heal path already stamps on create; this
966
- // is the half that was missing, and without it an unmarked row stays
967
- // unmarked no matter how many times it is touched.
968
- const alreadyMarked =
969
- (subtask.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark;
970
- if (taskId && !alreadyMarked) {
971
- const priorKindData = (subtask.kind_data ?? {}) as Record<string, unknown>;
972
- const priorSync = (priorKindData.task_sync ?? {}) as Record<string, unknown>;
973
- data.kind_data = {
974
- ...priorKindData,
975
- task_sync: { ...priorSync, mark: taskId, source: "claude-code" },
976
- };
977
- }
978
- if (Object.keys(data).length === 0) return discardApplied("no-change", session, { taskId });
979
- await prisma.work_items.update({ where: { id: subtask.id }, data });
980
- log.info("[task-sync] upsertFromTaskHook: updated subtask", { session, taskId, ...data });
981
- return { applied: true, op: "update", taskId };
982
- }),
725
+ upsertFromTaskHook: authed.handler(async ({ input, context }) =>
726
+ upsertFromTaskHookHandler(input, context)),
983
727
 
984
728
  /**
985
729
  * DB→AGENT backstop read. Returns the lane's current subtasks in the
@@ -1029,6 +773,14 @@ export const taskSyncRouter = {
1029
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. */
1030
774
  createBriefTask: createBriefTaskHandler,
1031
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
+
1032
784
  /**
1033
785
  * DB→AGENT realtime leg (explicit dispatch path). Record an out-of-band
1034
786
  * change to a subtask's status (operator toggles it in the projects UI,
@@ -1073,3 +825,282 @@ export const taskSyncRouter = {
1073
825
  return { task_id, lane_pane: lanePane ?? null, notified: !!lanePane };
1074
826
  }),
1075
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
+ }