@teamscala/service-boot 0.23.350 → 0.23.352

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 (39) hide show
  1. package/package.json +479 -567
  2. package/src/agreement-gate.ts +1 -1
  3. package/src/auth-gate.ts +0 -11
  4. package/src/fn-routers/index.ts +1 -12
  5. package/src/fn-routers/public-agreement-router.ts +37 -49
  6. package/src/mount-orpc-router.test.ts +46 -0
  7. package/src/mount-orpc-router.ts +16 -3
  8. package/src/mutable-app-ref.ts +0 -20
  9. package/src/page-server-orpc/puck-pages-router.ts +0 -102
  10. package/src/register-page-server-client.ts +0 -4
  11. package/src/register-ws-routes.ts +10 -3
  12. package/src/two-phase-boot.ts +2 -1
  13. package/src/ui-service.ts +11 -18
  14. package/src/fn-routers/ai-health-router.ts +0 -64
  15. package/src/fn-routers/panel-data-router.ts +0 -93
  16. package/src/fn-routers/plans-router.ts +0 -141
  17. package/src/fn-routers/task-sync-assign.test.ts +0 -483
  18. package/src/fn-routers/task-sync-assign.ts +0 -485
  19. package/src/fn-routers/task-sync-bind.test.ts +0 -110
  20. package/src/fn-routers/task-sync-brief.test.ts +0 -120
  21. package/src/fn-routers/task-sync-brief.ts +0 -151
  22. package/src/fn-routers/task-sync-classify.test.ts +0 -109
  23. package/src/fn-routers/task-sync-close.test.ts +0 -44
  24. package/src/fn-routers/task-sync-close.ts +0 -82
  25. package/src/fn-routers/task-sync-created-status.test.ts +0 -58
  26. package/src/fn-routers/task-sync-dispatch.test.ts +0 -62
  27. package/src/fn-routers/task-sync-hook-lifecycle.test.ts +0 -204
  28. package/src/fn-routers/task-sync-lane.test.ts +0 -36
  29. package/src/fn-routers/task-sync-projection.test.ts +0 -172
  30. package/src/fn-routers/task-sync-projection.ts +0 -273
  31. package/src/fn-routers/task-sync-rename.test.ts +0 -53
  32. package/src/fn-routers/task-sync-rename.ts +0 -111
  33. package/src/fn-routers/tasks-router.ts +0 -77
  34. package/src/page-server-orpc/portal-diagnostics-router.ts +0 -170
  35. package/src/page-server-orpc/templates-router.ts +0 -116
  36. package/src/serve/serve-ws.test.ts +0 -135
  37. package/src/serve/serve.ts +0 -148
  38. package/src/serve/stamp-transport-identity.test.ts +0 -24
  39. package/src/serve/stamp-transport-identity.ts +0 -109
@@ -1,204 +0,0 @@
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
- });
@@ -1,36 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Behavioral tests for canonical CLI-session lane resolution.
7
- */
8
- import { describe, expect, it } from "bun:test";
9
- import { resolveLaneWorkName } from "./task-sync-lane.ts";
10
-
11
- describe("resolveLaneWorkName", () => {
12
- it("reads the visible label from the active cli_sessions route", async () => {
13
- let query: unknown;
14
- const name = await resolveLaneWorkName(
15
- {
16
- cli_sessions: {
17
- findFirst: async (args: unknown) => {
18
- query = args;
19
- return { id: "session-1", label: "ZeroStack" };
20
- },
21
- },
22
- } as never,
23
- "tmux:%7",
24
- );
25
- expect(name).toBe("ZeroStack");
26
- expect(query).toMatchObject({ where: { host_route: { path: ["pane_id"], equals: "tmux:%7" } } });
27
- });
28
-
29
- it("returns no unverified title when no active session owns the pane", async () => {
30
- const name = await resolveLaneWorkName(
31
- { cli_sessions: { findFirst: async () => null } } as never,
32
- "tmux:%8",
33
- );
34
- expect(name).toBeNull();
35
- });
36
- });
@@ -1,172 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Co-located tests for the task-sync PROJECTION pure helpers. The module was
7
- * split out of task-sync-router.ts precisely so this half — the WHAT-changes
8
- * decisions — is unit-testable with no database (the router only EXECUTES the
9
- * plan). Covers status mapping, the blocked-row preservation rule, key
10
- * resolution, update-target resolution + full reconciliation planning.
11
- */
12
- import { describe, expect, it } from "bun:test";
13
- import {
14
- STATUS_TO_WORK_ITEM,
15
- deriveEvidenceProgress,
16
- normalizeStatus,
17
- planReconcile,
18
- resolveProjectedStatus,
19
- resolveTaskUpdateTarget,
20
- subtaskKey,
21
- type ReconcilePlan,
22
- } from "./task-sync-projection.ts";
23
- import type { TaskListItem } from "./task-sync-router.ts";
24
-
25
- describe("STATUS_TO_WORK_ITEM", () => {
26
- it("maps the three CLI statuses to work_items status enum values", () => {
27
- expect(STATUS_TO_WORK_ITEM.pending).toBe("todo");
28
- expect(STATUS_TO_WORK_ITEM.in_progress).toBe("in-progress");
29
- expect(STATUS_TO_WORK_ITEM.completed).toBe("done");
30
- });
31
- });
32
-
33
- describe("evidence-derived task progress", () => {
34
- it("counts only required subtasks with independent Orchestrator acceptance", () => {
35
- const out = deriveEvidenceProgress(
36
- [
37
- { id: "a", status: "done" },
38
- { id: "b", status: "done" },
39
- { id: "c", status: "todo", kind_data: { pm_progress: { required: false } } },
40
- ],
41
- [
42
- { work_item_id: "a", sender_role: "orchestrator", system_key: "acceptance:a" },
43
- { work_item_id: "b", sender_role: "worker", system_key: "acceptance:b" },
44
- ],
45
- );
46
- expect(out).toEqual({ total: 2, accepted: 1, percentage: 50 });
47
- });
48
-
49
- it("does not treat prose, context, or done alone as evidence", () => {
50
- expect(
51
- deriveEvidenceProgress([{ id: "a", status: "done" }], [
52
- { work_item_id: "a", sender_role: "orchestrator", system_key: "progress:100" },
53
- ]),
54
- ).toEqual({ total: 1, accepted: 0, percentage: 0 });
55
- });
56
- });
57
-
58
- describe("status translation is a single non-drifting source", () => {
59
- // The forward map (CLI → work_items) is canonical; the reverse is DERIVED from
60
- // it (+ a closed synonym set). This locks the structural fix: the two
61
- // directions can never disagree, so adding a CLI status is one entry, not two
62
- // maps to keep in sync — deletes the drift class (`prevention-over-detection`).
63
-
64
- it("round-trips every CLI status through work_items and back unchanged", () => {
65
- for (const cli of ["pending", "in_progress", "completed"] as TaskListItem["status"][]) {
66
- expect(normalizeStatus(STATUS_TO_WORK_ITEM[cli])).toBe(cli);
67
- }
68
- });
69
-
70
- it("the reverse map is the derived inverse of the forward map", () => {
71
- // Every forward mapping is present reversed (the reverse may ADD synonyms
72
- // like active/completed, but must never CONTRADICT the forward map).
73
- for (const [cli, workItem] of Object.entries(STATUS_TO_WORK_ITEM) as Array<
74
- [TaskListItem["status"], string]
75
- >) {
76
- expect(normalizeStatus(workItem)).toBe(cli);
77
- }
78
- });
79
- });
80
-
81
- describe("normalizeStatus", () => {
82
- it("maps work_items statuses back to the normalized CLI form", () => {
83
- expect(normalizeStatus("todo")).toBe("pending");
84
- expect(normalizeStatus("in-progress")).toBe("in_progress");
85
- expect(normalizeStatus("done")).toBe("completed");
86
- });
87
-
88
- it("collapses unknown / null / internal statuses to pending", () => {
89
- expect(normalizeStatus(null)).toBe("pending");
90
- expect(normalizeStatus("blocked")).toBe("pending");
91
- expect(normalizeStatus("some-internal-state")).toBe("pending");
92
- });
93
- });
94
-
95
- describe("resolveProjectedStatus", () => {
96
- it("preserves a blocked row when the CLI list can only express it as todo", () => {
97
- expect(resolveProjectedStatus("blocked", "todo")).toBe("blocked");
98
- });
99
-
100
- it("lets real CLI transitions (in-progress, done) win normally", () => {
101
- expect(resolveProjectedStatus("blocked", "in-progress")).toBe("in-progress");
102
- expect(resolveProjectedStatus("todo", "done")).toBe("done");
103
- });
104
- });
105
-
106
- describe("subtaskKey", () => {
107
- it("prefers mark, then content, then title", () => {
108
- expect(subtaskKey({ mark: "1", content: "c", title: "t" })).toBe("1");
109
- expect(subtaskKey({ content: "c", title: "t" })).toBe("c");
110
- expect(subtaskKey({ content: "", title: "t" })).toBe("t");
111
- });
112
- });
113
-
114
- describe("resolveTaskUpdateTarget", () => {
115
- const existing = [
116
- { id: "a", title: "Alpha", kind_data: { task_sync: { mark: "1" } } },
117
- { id: "b", title: "Beta", kind_data: null },
118
- ];
119
- it("updates by stamped mark", () => {
120
- expect(resolveTaskUpdateTarget(existing, { taskId: "1", subject: "anything" })).toEqual({
121
- op: "update",
122
- id: "a",
123
- });
124
- });
125
- it("updates by exact title when no mark matches", () => {
126
- expect(resolveTaskUpdateTarget(existing, { taskId: "9", subject: "Beta" })).toEqual({
127
- op: "update",
128
- id: "b",
129
- });
130
- });
131
- it("heals when nothing matches but a subject can rebuild the row", () => {
132
- expect(resolveTaskUpdateTarget(existing, { taskId: "9", subject: "Gamma" })).toEqual({
133
- op: "heal",
134
- title: "Gamma",
135
- });
136
- });
137
- it("discards when nothing matches and there is no subject", () => {
138
- expect(resolveTaskUpdateTarget(existing, { taskId: "9" })).toEqual({ op: "discard" });
139
- });
140
- });
141
-
142
- describe("planReconcile", () => {
143
- it("creates new items, updates changed ones, deletes unseen ones", () => {
144
- const existing = [
145
- { id: "1", kind: "subtask", parent_id: null, organisation_id: "org", title: "Keep", status: "todo", completed: false, position: "0", kind_data: {} },
146
- { id: "2", kind: "subtask", parent_id: null, organisation_id: "org", title: "Gone", status: "todo", completed: false, position: "1", kind_data: {} },
147
- ];
148
- const incoming: TaskListItem[] = [
149
- { content: "Keep", status: "completed", mark: undefined },
150
- { content: "New", status: "pending", mark: undefined },
151
- ];
152
- const plan: ReconcilePlan = planReconcile(existing, incoming);
153
- // "Keep" changed (todo→done) → update; "Gone" absent → delete; "New" → create.
154
- expect(plan.toUpdate.map((u) => u.id)).toEqual(["1"]);
155
- expect(plan.toUpdate[0]?.status).toBe("done");
156
- expect(plan.toCreate.map((c) => c.content)).toEqual(["New"]);
157
- expect(plan.toDelete).toEqual(["2"]);
158
- });
159
-
160
- it("preserves a blocked existing row against an incoming todo", () => {
161
- const existing = [
162
- { id: "1", kind: "subtask", parent_id: null, organisation_id: "org", title: "X", status: "blocked", completed: false, position: "5", kind_data: {} },
163
- ];
164
- // Incoming pending (-> "todo") at a DIFFERENT position so an update fires;
165
- // without the preservation rule the row would be rewritten to "todo",
166
- // silently destroying the block.
167
- const incoming: TaskListItem[] = [{ content: "X", status: "pending", mark: undefined }];
168
- const plan = planReconcile(existing, incoming, "src");
169
- expect(plan.toUpdate.length).toBe(1);
170
- expect(plan.toUpdate[0]?.status).toBe("blocked");
171
- });
172
- });
@@ -1,273 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The task-sync PROJECTION: how a CLI's task list maps onto `work_items` rows,
7
- * and what reconciling one against the other should do.
8
- *
9
- * Split out of `task-sync-router.ts`, which had grown past the 1000-line budget
10
- * and so could not be committed by anyone touching it. The seam is real rather
11
- * than arbitrary: everything here is PURE and decides WHAT should change, while
12
- * the router owns the ORPC surface and performs the writes. That is exactly why
13
- * this half is directly testable with no database.
14
- *
15
- * CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
16
- * candidate. Checked against the 14 platform singletons and the package catalog
17
- * — nothing generic to fold into, and the doctrine test fails: no unrelated app
18
- * could install this unmodified, because every export is bound to the
19
- * `work_items` schema and task-sync's own contract (STATUS_TO_WORK_ITEM encodes
20
- * the work_items status enum, subtaskKey is this projection's key convention,
21
- * planReconcile diffs against work_items subtask rows).
22
- *
23
- * The near-miss is worth naming so the next reader does not re-derive it: the
24
- * SHAPE — diff a desired list against existing rows into create/update/delete —
25
- * is generic, and `document-signing` is precedent for lifting a pure domain half
26
- * out of service-boot into a package. It stays here because it has exactly one
27
- * consumer; extracting it now would be speculative packaging, which
28
- * platform-capabilities treats as drift. Revisit if a SECOND surface needs
29
- * list-against-rows reconciliation — then the generic diff core (not the
30
- * work_items-specific mapping) is what earns a package.
31
- */
32
-
33
- import type { TaskListItem, WorkItem } from "./task-sync-router.ts";
34
-
35
- // The status translation between a CLI's task list (pending/in_progress/completed)
36
- // and work_items.status (todo/in-progress/done/active/blocked/…). ONE source of
37
- // truth: the CLI→work_items map is canonical (each CLI state maps to exactly one
38
- // work_items status); the reverse direction is DERIVED from it, plus a closed set
39
- // of work_items-internal synonyms (active, completed) that have no distinct CLI
40
- // state. The two directions therefore cannot drift — adding a CLI state is one
41
- // entry in CLI_STATUS_TO_WORK_ITEM and the inverse updates with it — which deletes
42
- // the "two hand-maintained inverse maps" drift class (`prevention-over-detection`).
43
- // The DB enum uses a hyphen for the active state ("in-progress"); see
44
- // COLUMN_ENUMES["work_items.status"] in db-validation.
45
- const CLI_STATUS_TO_WORK_ITEM: Record<TaskListItem["status"], string> = {
46
- pending: "todo",
47
- in_progress: "in-progress",
48
- completed: "done",
49
- };
50
-
51
- // work_items-internal statuses that are SYNONYMS for a CLI state (no distinct CLI
52
- // representation). A closed, additive set — the only hand-maintained second source.
53
- const WORK_ITEM_STATUS_SYNONYMS: Record<string, TaskListItem["status"]> = {
54
- active: "in_progress",
55
- completed: "completed",
56
- };
57
-
58
- // The reverse map, DERIVED: the canonical inverse of CLI_STATUS_TO_WORK_ITEM, plus
59
- // the synonyms. Generated at module load so it can never disagree with the forward
60
- // map. Unknown/internal statuses collapse to pending via normalizeStatus.
61
- const WORK_ITEM_TO_STATUS: Record<string, TaskListItem["status"]> = {
62
- ...Object.fromEntries(
63
- (Object.entries(CLI_STATUS_TO_WORK_ITEM) as Array<[TaskListItem["status"], string]>).map(
64
- ([cli, workItem]) => [workItem, cli],
65
- ),
66
- ),
67
- ...WORK_ITEM_STATUS_SYNONYMS,
68
- };
69
-
70
- /** CLI task-list status → work_items.status. The canonical forward direction. */
71
- export const STATUS_TO_WORK_ITEM = CLI_STATUS_TO_WORK_ITEM;
72
-
73
- export function normalizeStatus(s: string | null): TaskListItem["status"] {
74
- return (s && WORK_ITEM_TO_STATUS[s]) || "pending";
75
- }
76
-
77
- export interface ProgressSubtask {
78
- id: string;
79
- status: string | null;
80
- kind_data?: Record<string, unknown> | null;
81
- }
82
-
83
- export interface ProgressAcceptanceEvidence {
84
- work_item_id: string | null;
85
- sender_role: string | null;
86
- system_key: string | null;
87
- }
88
-
89
- export interface EvidenceProgress {
90
- total: number;
91
- accepted: number;
92
- percentage: number;
93
- }
94
-
95
- /**
96
- * Derive progress from the canonical checklist and independent acceptance
97
- * comments. A done status without an Orchestrator acceptance comment is not
98
- * evidence of completion; prose percentages and context gauges never enter
99
- * this calculation. Subtasks are required by default, with an explicit
100
- * `kind_data.pm_progress.required: false` opt-out for non-gating notes.
101
- */
102
- export function deriveEvidenceProgress(
103
- subtasks: ProgressSubtask[],
104
- evidence: ProgressAcceptanceEvidence[],
105
- ): EvidenceProgress {
106
- const required = subtasks.filter(
107
- (s) => (s.kind_data?.pm_progress as { required?: boolean } | undefined)?.required !== false,
108
- );
109
- const accepted = new Set(
110
- evidence
111
- .filter(
112
- (e) =>
113
- e.sender_role === "orchestrator" &&
114
- typeof e.system_key === "string" &&
115
- e.system_key.startsWith("acceptance:") &&
116
- e.work_item_id,
117
- )
118
- .map((e) => e.work_item_id as string),
119
- );
120
- const acceptedCount = required.filter((s) => s.status === "done" && accepted.has(s.id)).length;
121
- return {
122
- total: required.length,
123
- accepted: acceptedCount,
124
- percentage: required.length === 0 ? 0 : Math.round((acceptedCount / required.length) * 100),
125
- };
126
- }
127
-
128
- /**
129
- * Decide the status to WRITE for an existing row, given what the CLI list says.
130
- *
131
- * The CLI task list has no `blocked` state, so a blocked row round-trips as
132
- * `pending` (via `normalizeStatus`, which collapses unknown/internal statuses)
133
- * and then maps back to `todo` — silently destroying the block. That is not a
134
- * cosmetic drift: the stop gate reads these rows, so un-blocking one re-blocks
135
- * the LANE from stopping, and the lane's only apparent fix is to touch the list
136
- * again, which destroys it again.
137
- *
138
- * A `todo` arriving from the CLI therefore carries NO information: it is what
139
- * the projection emits for every status it cannot represent, not an assertion
140
- * that the work became un-blocked. `in-progress` and `done` are different —
141
- * those are real transitions the CLI CAN express, so they win normally.
142
- *
143
- * Measured 2026-07-30: a lane set a row to `blocked` with a named blocker, the
144
- * next TaskUpdate reset it to `todo`, and the stop gate reported the lane
145
- * unfinished again — three turns of ping-pong before the cause was found.
146
- */
147
- export function resolveProjectedStatus(
148
- currentStatus: string | null | undefined,
149
- incomingStatus: string,
150
- ): string {
151
- if (currentStatus === "blocked" && incomingStatus === "todo") return "blocked";
152
- return incomingStatus;
153
- }
154
-
155
- export function subtaskKey(item: { mark?: string; content: string; title: string }): string {
156
- return item.mark || item.content || item.title;
157
- }
158
-
159
- /**
160
- * Pure decision for a TaskUpdate/TaskDelete: which existing subtask does this
161
- * event address, and what should happen when none does. Extracted for the same
162
- * reason planReconcile was — the handler should only EXECUTE, so the matching
163
- * rule is unit-testable.
164
- *
165
- * Three resolutions:
166
- * - `update` — matched by mark, or (mark absent/unstamped) by exact title.
167
- * - `heal` — nothing matched but the event carries a subject, so the row can
168
- * be reconstructed. Covers a TaskCreate that silently produced no
169
- * subtask, which previously orphaned the task forever.
170
- * - `discard` — nothing matched and there is no subject to rebuild a title from.
171
- *
172
- * Measured 2026-07-28: marks "1" and "3" were stamped for one lane while mark
173
- * "2" was never written at all, and every later update for it was dropped as
174
- * `no-matching-subtask` — 26 of 30 tasksync discards in six hours, across seven
175
- * distinct lane sessions, were that one reason.
176
- */
177
- export function resolveTaskUpdateTarget(
178
- existing: Array<{ id: string; title: string; kind_data?: Record<string, unknown> | null }>,
179
- evt: { taskId: string; subject?: string },
180
- ): { op: "update"; id: string } | { op: "heal"; title: string } | { op: "discard" } {
181
- const byMark = existing.find(
182
- (s) =>
183
- evt.taskId &&
184
- (s.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark === evt.taskId,
185
- );
186
- if (byMark) return { op: "update", id: byMark.id };
187
- const subject = (evt.subject ?? "").trim();
188
- if (subject) {
189
- const byTitle = existing.find((s) => s.title === subject);
190
- if (byTitle) return { op: "update", id: byTitle.id };
191
- return { op: "heal", title: subject };
192
- }
193
- return { op: "discard" };
194
- }
195
-
196
- /**
197
- * Pure reconciliation plan: given the lane's EXISTING subtasks and its INCOMING
198
- * (current) task list, compute the create/update/delete ops that make the
199
- * subtask set match the list — WITHOUT touching prisma. Extracted so the
200
- * matching/status/positioning logic is unit-testable (the handler only executes
201
- * the plan). Matching is by stable mark when the CLI provides one, else by
202
- * content. Position follows incoming index, so a reorder is an update.
203
- */
204
- export interface ReconcilePlan {
205
- toCreate: Array<{
206
- content: string;
207
- status: string;
208
- completed: boolean;
209
- position: string;
210
- kind_data: Record<string, unknown>;
211
- }>;
212
- toUpdate: Array<{
213
- id: string;
214
- content: string;
215
- status: string;
216
- completed: boolean;
217
- position: string;
218
- kind_data: Record<string, unknown>;
219
- }>;
220
- toDelete: string[];
221
- }
222
-
223
- export function planReconcile(
224
- existing: WorkItem[],
225
- incoming: TaskListItem[],
226
- source?: string,
227
- ): ReconcilePlan {
228
- const byKey = new Map<string, WorkItem>();
229
- for (const s of existing) {
230
- const mark = (s.kind_data?.task_sync as { mark?: string } | undefined)?.mark;
231
- byKey.set(subtaskKey({ mark, content: "", title: s.title }), s);
232
- }
233
- const toCreate: ReconcilePlan["toCreate"] = [];
234
- const toUpdate: ReconcilePlan["toUpdate"] = [];
235
- const seen = new Set<string>();
236
- for (let i = 0; i < incoming.length; i++) {
237
- const item = incoming[i];
238
- const content = (item.content ?? "").trim();
239
- if (!content) continue;
240
- const key = subtaskKey({ mark: item.mark, content, title: content });
241
- const incomingStatus = STATUS_TO_WORK_ITEM[item.status] ?? "todo";
242
- const completed = item.status === "completed";
243
- const position = String(i);
244
- const taskSync = { task_sync: { mark: item.mark, source } };
245
- const match = byKey.get(key);
246
- if (match) {
247
- seen.add(match.id);
248
- // A blocked row must survive a list the CLI cannot express it in.
249
- const status = resolveProjectedStatus(match.status, incomingStatus);
250
- const changed =
251
- match.title !== content ||
252
- match.status !== status ||
253
- (match.completed ?? false) !== completed ||
254
- match.position !== position;
255
- if (changed) {
256
- toUpdate.push({
257
- id: match.id,
258
- content,
259
- status,
260
- completed,
261
- position,
262
- kind_data: { ...match.kind_data, ...taskSync },
263
- });
264
- }
265
- } else {
266
- // A row being created has no prior status to preserve, so the incoming
267
- // one stands unmodified.
268
- toCreate.push({ content, status: incomingStatus, completed, position, kind_data: taskSync });
269
- }
270
- }
271
- const toDelete = existing.filter((s) => !seen.has(s.id)).map((s) => s.id);
272
- return { toCreate, toUpdate, toDelete };
273
- }