@felixdotgo/aidlc-kit 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/DEVELOPMENT.md +14 -13
  2. package/README.md +37 -43
  3. package/dist/assets/.agents/aidlc/docker/Dockerfile.claude +7 -0
  4. package/dist/assets/.agents/aidlc/docker/Dockerfile.codex +9 -0
  5. package/dist/assets/.agents/aidlc/docker/Dockerfile.opencode +8 -0
  6. package/dist/assets/.agents/aidlc/orchestrator.md +2 -2
  7. package/dist/assets/.agents/aidlc/phase-clarify.md +1 -1
  8. package/dist/assets/.agents/aidlc/phase-plan.md +2 -2
  9. package/dist/assets/.agents/aidlc/schemas/config.schema.json +2 -1
  10. package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
  12. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +261 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +206 -0
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +100 -14
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +105 -32
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +17 -1
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +24 -7
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +42 -16
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +20 -6
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +33 -7
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +116 -28
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +114 -12
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +117 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +149 -6
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +44 -6
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +68 -2
  38. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
  40. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +168 -73
  41. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
  42. package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
  43. package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
  44. package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
  45. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
  46. package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
  47. package/dist/assets/.agents/aidlc/templates/design.md +9 -0
  48. package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
  49. package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
  50. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
  51. package/dist/assets/ui/bulma.min.css +3 -0
  52. package/dist/assets/ui/changes.js +83 -0
  53. package/dist/assets/ui/common.js +664 -0
  54. package/dist/assets/ui/dashboard.css +636 -0
  55. package/dist/assets/ui/dashboard.js +77 -0
  56. package/dist/assets/ui/detail.js +159 -0
  57. package/dist/assets/ui/live-log.js +332 -0
  58. package/dist/services/aidlc-coordination/Dockerfile +11 -3
  59. package/dist/services/aidlc-coordination/compose.yaml +12 -0
  60. package/dist/services/aidlc-coordination/package-lock.json +3 -1
  61. package/dist/services/aidlc-coordination/package.json +9 -4
  62. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +68 -2
  63. package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
  64. package/dist/services/aidlc-coordination/src/server.mjs +9 -3
  65. package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
  66. package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
  67. package/dist/src/adapters.d.ts +0 -3
  68. package/dist/src/adapters.js +3 -5
  69. package/dist/src/cli/argv.d.ts +12 -0
  70. package/dist/src/cli/argv.js +68 -0
  71. package/dist/src/cli/commands.d.ts +8 -0
  72. package/dist/src/cli/commands.js +159 -0
  73. package/dist/src/cli/loop-launcher.d.ts +47 -0
  74. package/dist/src/cli/loop-launcher.js +322 -0
  75. package/dist/src/cli/options.d.ts +25 -0
  76. package/dist/src/cli/options.js +32 -0
  77. package/dist/src/cli/prompt.d.ts +9 -0
  78. package/dist/src/cli/prompt.js +66 -0
  79. package/dist/src/cli.js +28 -191
  80. package/dist/src/gate.js +3 -2
  81. package/dist/src/installer.d.ts +14 -7
  82. package/dist/src/installer.js +42 -143
  83. package/dist/src/legacy.js +1 -0
  84. package/dist/src/managed-content.d.ts +7 -0
  85. package/dist/src/managed-content.js +15 -4
  86. package/dist/src/managed-planning.d.ts +65 -0
  87. package/dist/src/managed-planning.js +219 -0
  88. package/dist/src/mcp-setup.js +3 -1
  89. package/dist/src/model/config-contracts.d.ts +65 -0
  90. package/dist/src/model/config-contracts.js +1 -0
  91. package/dist/src/model/install-contracts.d.ts +29 -0
  92. package/dist/src/model/install-contracts.js +1 -0
  93. package/dist/src/model/lifecycle-enums.d.ts +13 -0
  94. package/dist/src/model/lifecycle-enums.js +1 -0
  95. package/dist/src/model/loop.d.ts +20 -0
  96. package/dist/src/model/loop.js +1 -0
  97. package/dist/src/model/manifest.d.ts +16 -0
  98. package/dist/src/model/manifest.js +1 -0
  99. package/dist/src/model/records.d.ts +142 -0
  100. package/dist/src/model/records.js +1 -0
  101. package/dist/src/model.d.ts +11 -264
  102. package/dist/src/model.js +1 -1
  103. package/dist/src/profiles.d.ts +2 -0
  104. package/dist/src/profiles.js +53 -9
  105. package/dist/src/state/archive-record.d.ts +9 -0
  106. package/dist/src/state/archive-record.js +26 -0
  107. package/dist/src/state/commands.d.ts +18 -0
  108. package/dist/src/state/commands.js +27 -0
  109. package/dist/src/state/domain.d.ts +9 -0
  110. package/dist/src/state/domain.js +9 -0
  111. package/dist/src/state/guards.d.ts +7 -0
  112. package/dist/src/state/guards.js +17 -0
  113. package/dist/src/state/io.d.ts +10 -0
  114. package/dist/src/state/io.js +35 -0
  115. package/dist/src/state/items.d.ts +3 -4
  116. package/dist/src/state/items.js +58 -57
  117. package/dist/src/state/lessons.js +14 -7
  118. package/dist/src/state/lifecycle.js +2 -2
  119. package/dist/src/state/lock.js +7 -6
  120. package/dist/src/state/memory.js +24 -12
  121. package/dist/src/state/{archive.js → migrate.js} +7 -7
  122. package/dist/src/state/partition.d.ts +15 -0
  123. package/dist/src/state/partition.js +90 -0
  124. package/dist/src/state/paths.d.ts +11 -0
  125. package/dist/src/state/paths.js +12 -0
  126. package/dist/src/state/render.js +9 -6
  127. package/dist/src/state/store.js +9 -15
  128. package/dist/src/state/transitions.js +9 -7
  129. package/dist/src/state/validate.js +55 -7
  130. package/dist/src/state.d.ts +16 -11
  131. package/dist/src/state.js +16 -11
  132. package/dist/src/task-ui.d.ts +33 -0
  133. package/dist/src/task-ui.js +33 -0
  134. package/dist/src/ui/assets.d.ts +14 -0
  135. package/dist/src/ui/assets.js +45 -0
  136. package/dist/src/ui/changes.d.ts +83 -0
  137. package/dist/src/ui/changes.js +498 -0
  138. package/dist/src/ui/containers.d.ts +71 -0
  139. package/dist/src/ui/containers.js +214 -0
  140. package/dist/src/ui/daemon.d.ts +75 -0
  141. package/dist/src/ui/daemon.js +232 -0
  142. package/dist/src/ui/data.d.ts +42 -0
  143. package/dist/src/ui/data.js +89 -0
  144. package/dist/src/ui/docs.d.ts +13 -0
  145. package/dist/src/ui/docs.js +39 -0
  146. package/dist/src/ui/files.d.ts +3 -0
  147. package/dist/src/ui/files.js +20 -0
  148. package/dist/src/ui/http.d.ts +14 -0
  149. package/dist/src/ui/http.js +80 -0
  150. package/dist/src/ui/live.d.ts +5 -0
  151. package/dist/src/ui/live.js +43 -0
  152. package/dist/src/ui/markdown.d.ts +2 -0
  153. package/dist/src/ui/markdown.js +127 -0
  154. package/dist/src/ui/options.d.ts +48 -0
  155. package/dist/src/ui/options.js +81 -0
  156. package/dist/src/ui/security.d.ts +34 -0
  157. package/dist/src/ui/security.js +38 -0
  158. package/dist/src/ui/server.d.ts +39 -0
  159. package/dist/src/ui/server.js +347 -0
  160. package/dist/src/ui/sse.d.ts +14 -0
  161. package/dist/src/ui/sse.js +281 -0
  162. package/dist/src/ui/views/board.d.ts +7 -0
  163. package/dist/src/ui/views/board.js +131 -0
  164. package/dist/src/ui/views/changes.d.ts +19 -0
  165. package/dist/src/ui/views/changes.js +154 -0
  166. package/dist/src/ui/views/components.d.ts +56 -0
  167. package/dist/src/ui/views/components.js +113 -0
  168. package/dist/src/ui/views/docs.d.ts +15 -0
  169. package/dist/src/ui/views/docs.js +64 -0
  170. package/dist/src/ui/views/layout.d.ts +44 -0
  171. package/dist/src/ui/views/layout.js +233 -0
  172. package/dist/src/ui/views/task.d.ts +25 -0
  173. package/dist/src/ui/views/task.js +320 -0
  174. package/dist/src/upgrade-board.js +10 -10
  175. package/dist/src/upgrade.d.ts +0 -1
  176. package/dist/src/upgrade.js +24 -85
  177. package/dist/src/workflow.d.ts +4 -0
  178. package/dist/src/workflow.js +36 -4
  179. package/docs/README.md +41 -22
  180. package/docs/architecture.md +101 -37
  181. package/docs/command-reference.md +289 -52
  182. package/docs/configuration.md +70 -30
  183. package/docs/coordination-service.md +91 -30
  184. package/docs/design-decisions.md +87 -0
  185. package/docs/development.md +124 -49
  186. package/docs/loop-local-run.md +228 -0
  187. package/docs/loop-runbook.md +214 -0
  188. package/docs/operating-workflow.md +55 -42
  189. package/docs/testing-and-release.md +72 -49
  190. package/docs/troubleshooting.md +221 -0
  191. package/package.json +4 -1
  192. package/dist/src/ledger.d.ts +0 -22
  193. package/dist/src/ledger.js +0 -119
  194. package/dist/src/state/shared.d.ts +0 -29
  195. package/dist/src/state/shared.js +0 -71
  196. package/dist/src/tracker.d.ts +0 -36
  197. package/dist/src/tracker.js +0 -44
  198. /package/dist/src/state/{archive.d.ts → migrate.d.ts} +0 -0
@@ -0,0 +1,281 @@
1
+ /** `/api/events`: the loop event logs as a Server-Sent Events stream. */
2
+ import { lstatSync, statSync } from "node:fs";
3
+ import { readRange } from "./files.js";
4
+ import { sendJson } from "./http.js";
5
+ import { join } from "node:path";
6
+ import { eventsLogPath, legacyTaskEventsDir, legacyTaskEventsLogPath, loopDataDir, TASK_ID_PATTERN, taskEventsLogPath, taskRuntimeDir, tasksDataDir } from "./options.js";
7
+ /** On a fresh connection the SSE stream replays at most this many lines. */
8
+ export const SSE_REPLAY_LINES = 200;
9
+ const SSE_REPLAY_BYTES = 256 * 1024;
10
+ const SSE_CHUNK_BYTES = 1024 * 1024;
11
+ /**
12
+ * Calls `onLine(line, endOffset)` for each complete (newline-terminated) line of
13
+ * `path` after `cursor.position`, reading at most one chunk, and advances the
14
+ * cursor past them. A half-written last line waits for the next call; a line
15
+ * longer than a chunk is skipped.
16
+ */
17
+ const readLines = (path, size, cursor, noFollow, onLine) => {
18
+ if (size <= cursor.position)
19
+ return;
20
+ const end = Math.min(size, cursor.position + SSE_CHUNK_BYTES);
21
+ let buffer = readRange(path, cursor.position, end, noFollow);
22
+ if (cursor.discarding) {
23
+ // Drop the rest of an oversized line up to and including its newline.
24
+ const newline = buffer.indexOf(0x0a);
25
+ if (newline < 0) {
26
+ cursor.position = end;
27
+ return;
28
+ }
29
+ cursor.position += newline + 1;
30
+ buffer = buffer.subarray(newline + 1);
31
+ cursor.discarding = false;
32
+ }
33
+ const last = buffer.lastIndexOf(0x0a);
34
+ if (last < 0) {
35
+ if (buffer.length >= SSE_CHUNK_BYTES) {
36
+ cursor.position = end;
37
+ cursor.discarding = true;
38
+ }
39
+ return;
40
+ }
41
+ for (let start = 0; start <= last;) {
42
+ const newline = buffer.indexOf(0x0a, start);
43
+ if (newline > start)
44
+ onLine(buffer.subarray(start, newline).toString("utf8"), cursor.position + newline + 1);
45
+ start = newline + 1;
46
+ }
47
+ cursor.position += last + 1;
48
+ };
49
+ /** Complete lines in the last `budget` bytes of `path` (a cut-off first line is dropped) and the
50
+ * offset just after the last newline (the window start when there is none). */
51
+ const tailLines = (path, size, budget, noFollow) => {
52
+ const start = Math.max(0, size - budget);
53
+ const buffer = readRange(path, start, size, noFollow);
54
+ const last = buffer.lastIndexOf(0x0a);
55
+ if (last < 0)
56
+ return { lines: [], end: start, whole: start === 0 };
57
+ let lines = buffer.subarray(0, last).toString("utf8").split("\n");
58
+ if (start > 0)
59
+ lines = lines.slice(1);
60
+ return { lines: lines.filter((line) => line.length > 0), end: start + last + 1, whole: start === 0 };
61
+ };
62
+ /**
63
+ * The global sidecar (`ui-events.jsonl`), optionally filtered to one task id
64
+ * (logs written before per-task files existed). Event ids are `<inode>:<offset>`.
65
+ * A stale Last-Event-ID (rotated-away file) replays the tail like a fresh
66
+ * connection; a rotation seen while streaming follows the new file from 0.
67
+ */
68
+ const globalLogTicker = (root, taskFilter, lastEventId, send) => {
69
+ const path = eventsLogPath(root);
70
+ const resume = /^(\d+):(\d+)$/.exec(lastEventId);
71
+ let inode = resume ? Number(resume[1]) : null;
72
+ const cursor = { position: resume ? Number(resume[2]) : -1, discarding: false };
73
+ let firstTick = true;
74
+ const matches = (line) => {
75
+ if (!taskFilter)
76
+ return true;
77
+ try {
78
+ return JSON.parse(line).taskId === taskFilter;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ };
84
+ return () => {
85
+ let stat;
86
+ try {
87
+ stat = statSync(path);
88
+ }
89
+ catch {
90
+ return;
91
+ }
92
+ if (inode !== null && stat.ino !== inode) {
93
+ cursor.position = firstTick ? -1 : 0;
94
+ cursor.discarding = false;
95
+ }
96
+ firstTick = false;
97
+ inode = stat.ino;
98
+ const ino = stat.ino;
99
+ if (cursor.position < 0) {
100
+ const tail = tailLines(path, stat.size, SSE_REPLAY_BYTES, false);
101
+ cursor.position = tail.end;
102
+ for (const line of tail.lines.filter(matches).slice(-SSE_REPLAY_LINES))
103
+ send(line, `${ino}:${cursor.position}`);
104
+ return;
105
+ }
106
+ if (stat.size < cursor.position) {
107
+ cursor.position = 0;
108
+ cursor.discarding = false;
109
+ }
110
+ readLines(path, stat.size, cursor, false, (line, end) => { if (matches(line))
111
+ send(line, `${ino}:${end}`); });
112
+ };
113
+ };
114
+ /** lstat of a real directory / regular file; undefined when missing, a symlink, or another kind. */
115
+ const realDirectory = (path) => { try {
116
+ return lstatSync(path).isDirectory();
117
+ }
118
+ catch {
119
+ return false;
120
+ } };
121
+ const regularFile = (path) => { try {
122
+ const stat = lstatSync(path);
123
+ return stat.isFile() ? stat : undefined;
124
+ }
125
+ catch {
126
+ return undefined;
127
+ } };
128
+ /** The per-task log files, refusing symlinked directories or files (like the
129
+ * writer does). The task's `runtime/events.jsonl` wins when it (or its `.1`
130
+ * generation) exists; otherwise the pre-runtime `.agents/data/loop/events/`
131
+ * copy is read until `loop migrate-data` has moved it. */
132
+ const taskLogFiles = (root, taskId) => {
133
+ const runtime = taskRuntimeDir(root, taskId);
134
+ const runtimeDirs = () => realDirectory(tasksDataDir(root)) && realDirectory(join(tasksDataDir(root), taskId)) && realDirectory(runtime);
135
+ const legacyDirs = () => realDirectory(loopDataDir(root)) && realDirectory(legacyTaskEventsDir(root));
136
+ const current = taskEventsLogPath(root, taskId);
137
+ if (runtimeDirs() && (regularFile(current) || regularFile(`${current}.1`)))
138
+ return { path: current, rotated: `${current}.1`, stat: (file) => runtimeDirs() ? regularFile(file) : undefined };
139
+ const legacy = legacyTaskEventsLogPath(root, taskId);
140
+ if (legacyDirs() && (regularFile(legacy) || regularFile(`${legacy}.1`)))
141
+ return { path: legacy, rotated: `${legacy}.1`, stat: (file) => legacyDirs() ? regularFile(file) : undefined };
142
+ return { path: current, rotated: `${current}.1`, stat: (file) => runtimeDirs() ? regularFile(file) : undefined };
143
+ };
144
+ /**
145
+ * One task's own history (`events/<taskId>.jsonl`, one rotated `.1` generation).
146
+ * Event ids are `t:<inode>:<offset>` (never confused with global-log ids). A fresh
147
+ * connection, or a Last-Event-ID naming neither generation, sends a `reset` event
148
+ * (the client clears its lines) and replays the tail of `.1` then the current file.
149
+ * A Last-Event-ID inside `.1` (or a rotation seen while streaming) finishes the
150
+ * old generation first, then follows the new file from 0.
151
+ */
152
+ const taskLogTicker = (root, taskId, lastEventId, send, reset) => {
153
+ const { path, rotated, stat: fileStat } = taskLogFiles(root, taskId);
154
+ const id = (ino, offset) => `t:${ino}:${offset}`;
155
+ let inode = null;
156
+ const cursor = { position: -1, discarding: false };
157
+ let drain = null;
158
+ const resume = /^t:(\d+):(\d+)$/.exec(lastEventId);
159
+ if (resume) {
160
+ const ino = Number(resume[1]);
161
+ const offset = Number(resume[2]);
162
+ if (fileStat(path)?.ino === ino) {
163
+ inode = ino;
164
+ cursor.position = offset;
165
+ }
166
+ else if (fileStat(rotated)?.ino === ino) {
167
+ drain = { inode: ino, cursor: { position: offset, discarding: false } };
168
+ cursor.position = 0;
169
+ }
170
+ }
171
+ const replay = () => {
172
+ const current = fileStat(path);
173
+ let lines = [];
174
+ let used = 0;
175
+ let whole = true;
176
+ inode = current ? current.ino : null;
177
+ cursor.position = 0;
178
+ cursor.discarding = false;
179
+ if (current) {
180
+ const tail = tailLines(path, current.size, SSE_REPLAY_BYTES, true);
181
+ ({ lines, whole } = tail);
182
+ cursor.position = tail.end;
183
+ used = Math.min(current.size, SSE_REPLAY_BYTES);
184
+ }
185
+ if (whole && lines.length < SSE_REPLAY_LINES && used < SSE_REPLAY_BYTES) {
186
+ const old = fileStat(rotated);
187
+ if (old)
188
+ lines = [...tailLines(rotated, old.size, SSE_REPLAY_BYTES - used, true).lines, ...lines];
189
+ }
190
+ reset();
191
+ const at = id(inode ?? 0, cursor.position);
192
+ for (const line of lines.slice(-SSE_REPLAY_LINES))
193
+ send(line, at);
194
+ };
195
+ const tick = () => {
196
+ if (cursor.position < 0) {
197
+ replay();
198
+ return;
199
+ }
200
+ if (drain) {
201
+ const old = fileStat(rotated);
202
+ const before = drain.cursor.position;
203
+ if (old && old.ino === drain.inode && old.size > before) {
204
+ readLines(rotated, old.size, drain.cursor, true, (line, end) => send(line, id(old.ino, end)));
205
+ // More of the old generation next tick; a trailing partial line never completes.
206
+ if (drain.cursor.position < old.size && drain.cursor.position !== before)
207
+ return;
208
+ }
209
+ drain = null;
210
+ }
211
+ const current = fileStat(path);
212
+ if (!current)
213
+ return;
214
+ if (inode !== null && current.ino !== inode) {
215
+ // Rotated while streaming: finish the old generation (now `.1`) first, then the new file.
216
+ const previous = inode;
217
+ drain = fileStat(rotated)?.ino === previous ? { inode: previous, cursor: { ...cursor } } : null;
218
+ inode = null;
219
+ cursor.position = 0;
220
+ cursor.discarding = false;
221
+ if (drain) {
222
+ tick();
223
+ return;
224
+ }
225
+ }
226
+ inode = current.ino;
227
+ if (current.size < cursor.position) {
228
+ cursor.position = 0;
229
+ cursor.discarding = false;
230
+ }
231
+ readLines(path, current.size, cursor, true, (line, end) => send(line, id(current.ino, end)));
232
+ };
233
+ return tick;
234
+ };
235
+ /**
236
+ * Streams loop events as SSE. Tracks byte offsets (no full re-read per tick),
237
+ * emits only newline-terminated lines (a half-written line waits for the next
238
+ * tick), handles truncation and rotation (inode change), and replays only the
239
+ * last SSE_REPLAY_LINES on a fresh connection. `?task-id=<id>` (a canonical task
240
+ * id, else 400) reads that task's own history file when it exists, and falls
241
+ * back to filtering the global sidecar (older logs) when it does not. Event ids
242
+ * let EventSource reconnects (`Last-Event-ID`) resume exactly.
243
+ */
244
+ export const streamEvents = (options, res, req, url) => {
245
+ const taskId = url.searchParams.get("task-id") ?? "";
246
+ if (taskId && !TASK_ID_PATTERN.test(taskId))
247
+ return sendJson(res, 400, { ok: false, error: "task-id must be a canonical task id" });
248
+ res.writeHead(200, {
249
+ "content-type": "text/event-stream; charset=utf-8",
250
+ "cache-control": "no-store",
251
+ "connection": "keep-alive",
252
+ });
253
+ // Flush headers immediately (an empty log would otherwise leave the client
254
+ // waiting for the first event) and keep idle connections alive.
255
+ res.write(": connected\n\n");
256
+ const lastEventId = String(req.headers["last-event-id"] ?? "");
257
+ const send = (line, id) => { res.write(`id: ${id}\ndata: ${line}\n\n`); };
258
+ const files = taskId ? taskLogFiles(options.root, taskId) : undefined;
259
+ const tick = files && (files.stat(files.path) || files.stat(files.rotated))
260
+ ? taskLogTicker(options.root, taskId, lastEventId, send, () => res.write("event: reset\ndata: replay\n\n"))
261
+ : globalLogTicker(options.root, taskId, lastEventId, send);
262
+ const emit = () => {
263
+ try {
264
+ tick();
265
+ }
266
+ catch { /* transient read errors are ignored until the next poll */ }
267
+ };
268
+ let idleTicks = 0;
269
+ emit();
270
+ const timer = setInterval(() => {
271
+ emit();
272
+ idleTicks += 1;
273
+ if (idleTicks >= 30) {
274
+ idleTicks = 0;
275
+ res.write(": keep-alive\n\n");
276
+ }
277
+ }, 500);
278
+ const cleanup = () => clearInterval(timer);
279
+ req.on("close", cleanup);
280
+ res.on("close", cleanup);
281
+ };
@@ -0,0 +1,7 @@
1
+ import type { WorkflowState } from "../../model.js";
2
+ import { type UiOptions } from "../options.js";
3
+ /** The done column shows this many cards until "Show more" (or a filter) reveals the rest. */
4
+ export declare const DONE_COLUMN_LIMIT = 8;
5
+ /** Toolbar, kanban, and list (or the empty state): the dashboard's `board` live region. */
6
+ export declare const renderBoard: (state: WorkflowState) => string;
7
+ export declare const renderDashboard: (options: UiOptions) => Promise<string>;
@@ -0,0 +1,131 @@
1
+ import { listTaskSummaries } from "../../state.js";
2
+ import { pendingFeedback, readUiState } from "../data.js";
3
+ import { escapeHtml } from "../http.js";
4
+ import { uiMode } from "../options.js";
5
+ import { feedbackPill, gateLabel, gatePill, PHASES, phaseSteps, relativeTime, RISK_TONE, riskPill, STATUS_META, statusPill } from "./components.js";
6
+ import { liveRegion, pageHead, pageScripts, renderHeader, renderModals, reviewQueue } from "./layout.js";
7
+ /** Per-status action: a paused task can be re-activated for the daemon; a task
8
+ * waiting at a human gate links to the detail page that hosts gate approval. */
9
+ const taskAction = (task, handedOff = false) => {
10
+ if (handedOff)
11
+ return `<a class="button is-small is-danger ad-soft" href="/tasks/${encodeURIComponent(task.id)}" title="handed off: follow the handoff actions">handoff</a>`;
12
+ if (task.status === "paused")
13
+ return `<button class="button is-small is-link ad-soft" data-resume="${escapeHtml(task.id)}">resume</button>`;
14
+ if (task.status === "blocked_on_user")
15
+ return `<a class="button is-small is-warning" href="/tasks/${encodeURIComponent(task.id)}" title="open the review: send feedback or approve">review</a>`;
16
+ return "";
17
+ };
18
+ /** Upper bound on dashboard cards (matches the `listTaskSummaries` page cap). */
19
+ const DASHBOARD_LIMIT = 100;
20
+ /** Lower-cased haystack for client-side search (escaped when rendered). */
21
+ const searchText = (task) => [task.id, task.title, task.phase, task.gate, gateLabel(task.gate), task.status, STATUS_META[task.status]?.label ?? "", task.risk, ...task.areas].join(" ").toLowerCase();
22
+ const itemAttrs = (task, handedOff) => `data-task-item data-task-id="${escapeHtml(task.id)}" data-status="${escapeHtml(task.status)}" data-risk="${escapeHtml(task.risk)}"${handedOff ? ' data-handoff="true"' : ""} data-search="${escapeHtml(searchText(task))}"`;
23
+ /** Statuses a kanban column cannot express (the column already says the phase, and
24
+ * "needs review" is the card's attention ring plus its review button). */
25
+ const CARD_FLAG_STATUSES = new Set(["paused", "closed", "superseded"]);
26
+ const cardFlag = (status, handedOff) => handedOff || CARD_FLAG_STATUSES.has(status) ? statusPill(status, handedOff) : "";
27
+ /** Kanban card: the title leads; the column is the phase, so no id, phase bar, gate, or
28
+ * routine status badge. The whole card is a click target (`data-card-href`): a plain
29
+ * click opens the side panel, ctrl/cmd/middle-click a new tab, shift-click this tab. */
30
+ const renderTaskCard = (task, handedOff = false) => {
31
+ const href = `/tasks/${encodeURIComponent(task.id)}`;
32
+ const attention = task.status === "blocked_on_user" && !handedOff;
33
+ const meta = [cardFlag(task.status, handedOff), task.risk !== "normal" ? riskPill(task.risk) : "", feedbackPill(task.feedbackPending ?? 0)].join("");
34
+ return `<article class="ad-card${attention ? " is-attention" : ""}" data-key="card:${escapeHtml(task.id)}" data-card-href="${href}" ${itemAttrs(task, handedOff)}>
35
+ <div class="ad-card-title"><a href="${href}" data-task-link="${escapeHtml(task.id)}" title="${escapeHtml(task.id)}">${escapeHtml(task.title)}</a></div>
36
+ ${meta ? `<div class="ad-card-meta">${meta}</div>` : ""}
37
+ <div class="ad-card-foot"><span class="ad-areas" title="areas: ${escapeHtml(task.areas.join(", "))}">${escapeHtml(task.areas.join(", "))}</span>${task.updatedAt ? `<span>· ${relativeTime(task.updatedAt)}</span>` : ""}${taskAction(task, handedOff)}</div>
38
+ </article>`;
39
+ };
40
+ /** List row: the title is the link (id on hover); the id column is gone. */
41
+ const renderListRow = (task, handedOff) => `<tr data-key="row:${escapeHtml(task.id)}" data-card-href="/tasks/${encodeURIComponent(task.id)}" ${itemAttrs(task, handedOff)}>
42
+ <td class="has-text-weight-semibold ad-row-title"><a href="/tasks/${encodeURIComponent(task.id)}" data-task-link="${escapeHtml(task.id)}" title="${escapeHtml(task.id)}">${escapeHtml(task.title)}</a></td>
43
+ <td><div class="is-size-7 mb-1">${escapeHtml(task.phase)}</div>${phaseSteps(task.phase)}</td>
44
+ <td>${gatePill(task.gate) || '<span class="has-text-grey">—</span>'}</td>
45
+ <td>${statusPill(task.status, handedOff)}${feedbackPill(task.feedbackPending ?? 0)}</td>
46
+ <td>${riskPill(task.risk)}</td>
47
+ <td class="is-size-7">${escapeHtml(task.areas.join(", "))}</td>
48
+ <td class="is-size-7 has-text-grey">${relativeTime(task.updatedAt)}</td>
49
+ <td class="has-text-right">${taskAction(task, handedOff)}</td>
50
+ </tr>`;
51
+ const renderToolbar = () => `<div class="ad-toolbar">
52
+ <div class="ad-search">
53
+ <svg class="ad-search-icon" aria-hidden="true" viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="8.5" cy="8.5" r="5.5"/><path d="m13 13 4 4"/></svg>
54
+ <input id="task-search" class="input" type="search" placeholder="Search title, id, area…" aria-label="Search tasks" autocomplete="off">
55
+ <kbd>/</kbd>
56
+ </div>
57
+ <div class="select"><select id="filter-status" aria-label="Filter by status">
58
+ <option value="">All statuses</option>
59
+ ${Object.entries(STATUS_META).map(([value, meta]) => `<option value="${value}">${escapeHtml(meta.label)}</option>`).join("")}
60
+ </select></div>
61
+ <div class="select"><select id="filter-risk" aria-label="Filter by risk">
62
+ <option value="">Any risk</option>
63
+ ${Object.keys(RISK_TONE).map((risk) => `<option value="${risk}">${risk}</option>`).join("")}
64
+ </select></div>
65
+ <div class="ad-toolbar-end">
66
+ <span id="result-count" class="ad-result-count" aria-live="polite"></span>
67
+ <div class="ad-segmented" role="group" aria-label="View">
68
+ <button type="button" data-view-toggle="kanban" aria-pressed="true">▦ Kanban</button>
69
+ <button type="button" data-view-toggle="list" aria-pressed="false">☰ List</button>
70
+ </div>
71
+ </div>
72
+ </div>`;
73
+ /** The done column shows this many cards until "Show more" (or a filter) reveals the rest. */
74
+ export const DONE_COLUMN_LIMIT = 8;
75
+ /** Toolbar, kanban, and list (or the empty state): the dashboard's `board` live region. */
76
+ export const renderBoard = (state) => {
77
+ /* Terminal tasks (done/closed/superseded) are moved to the archive catalog on
78
+ * save; list them too so they survive a reload. Active tasks keep priority
79
+ * within the display limit; archived ones fill the remaining room. */
80
+ const active = listTaskSummaries(state, { limit: DASHBOARD_LIMIT }).items;
81
+ const archived = active.length < DASHBOARD_LIMIT
82
+ ? listTaskSummaries({ ...state, tasks: {} }, { includeArchive: true, limit: DASHBOARD_LIMIT - active.length }).items
83
+ : [];
84
+ const items = [...active, ...archived].map((task) => {
85
+ const full = state.tasks[task.id];
86
+ return full ? { ...task, feedbackPending: pendingFeedback(full).length } : task;
87
+ });
88
+ const handedOff = new Set(Object.values(state.tasks).filter((task) => task.handoff).map((task) => task.id));
89
+ if (!items.length)
90
+ return `<div class="ad-empty">
91
+ <div class="ad-empty-icon" aria-hidden="true">🌱</div>
92
+ <h2>No tasks yet</h2>
93
+ <p>Describe a requirement and the loop will create the task and drive it through clarify → plan → build → wrap.</p>
94
+ <button class="button is-primary" data-open="new-task-drawer">+ New task</button>
95
+ </div>`;
96
+ const kanban = PHASES.map((phase, index) => {
97
+ const tasks = items.filter((task) => task.phase === phase);
98
+ const cards = tasks.map((task) => renderTaskCard(task, handedOff.has(task.id))).join("\n");
99
+ // The client hides cards past the limit (unless expanded or filtering) and labels the button.
100
+ const collapse = phase === "done" ? ` data-collapse="${DONE_COLUMN_LIMIT}"` : "";
101
+ const more = phase === "done" ? `<button type="button" class="ad-column-more" data-column-more${tasks.length > DONE_COLUMN_LIMIT ? "" : " hidden"}>Show ${Math.max(0, tasks.length - DONE_COLUMN_LIMIT)} more</button>` : "";
102
+ return `<section class="ad-column" data-key="column:${phase}" data-phase-column="${phase}"${collapse}>
103
+ <header class="ad-column-head"><span class="ad-step-index">${index + 1}</span><span class="ad-column-title">${escapeHtml(phase)}</span><span class="ad-count" data-column-count>${tasks.length}</span></header>
104
+ <div class="ad-column-body">${cards}<p class="ad-column-empty" data-column-empty${tasks.length ? " hidden" : ""}>No tasks</p>${more}</div>
105
+ </section>`;
106
+ }).join("\n");
107
+ const rows = items.map((task) => renderListRow(task, handedOff.has(task.id))).join("\n");
108
+ return `${renderToolbar()}
109
+ <div id="kanban-view"><div class="ad-board">${kanban}</div></div>
110
+ <div id="list-view">
111
+ <div class="ad-table-wrap"><table class="table ad-table is-fullwidth">
112
+ <thead><tr><th>title</th><th>phase</th><th>gate</th><th>status</th><th>risk</th><th>areas</th><th>updated</th><th></th></tr></thead>
113
+ <tbody>${rows}</tbody>
114
+ </table></div>
115
+ </div>
116
+ <p class="ad-no-match" data-no-match>No tasks match the current filters. <button type="button" id="filter-clear" class="button is-small is-text">Clear filters</button></p>`;
117
+ };
118
+ export const renderDashboard = async (options) => {
119
+ const state = await readUiState(options);
120
+ return `${pageHead("Loop")}
121
+ ${renderHeader(options.root, uiMode(options), reviewQueue(state))}
122
+ <main class="section ad-main">
123
+ <div class="container is-fluid">
124
+ ${liveRegion("board", renderBoard(state))}
125
+ </div>
126
+ </main>
127
+ ${renderModals()}
128
+ ${pageScripts("common", "dashboard", "live-log")}
129
+ </body>
130
+ </html>`;
131
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `/tasks/:id/changes`: a task's code changes, rendered server-side (every diff line
3
+ * and file name escaped) so the page works without JavaScript; `changes.js` only
4
+ * filters items in place, scrolls to files, and applies the diff display settings
5
+ * (`diffWrap` toggles `is-wrapped` on the diff containers; `diffIgnoreWs` reloads
6
+ * with `?ws=ignore`, which the server maps to `git diff -w`).
7
+ */
8
+ import { type ChangesQuery } from "../changes.js";
9
+ import { type UiOptions } from "../options.js";
10
+ /** Link to a task's changes page (task detail, G2 gate panel, side panel). */
11
+ export declare const changesHref: (taskId: string) => string;
12
+ /** Splits a unified patch into per-file sections (each starts at `diff --git`). */
13
+ export declare const patchSections: (patch: string) => string[][];
14
+ /** Diff display styles the page needs beyond dashboard.css: `is-wrapped` is toggled by changes.js from the `diffWrap` setting. */
15
+ /** The full changes page and its HTTP status (400 malformed id or `ws` value, 404 unknown task/item). */
16
+ export declare const renderChangesPage: (options: UiOptions, taskId: string, itemId: string | null, query?: ChangesQuery) => Promise<{
17
+ status: number;
18
+ html: string;
19
+ }>;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `/tasks/:id/changes`: a task's code changes, rendered server-side (every diff line
3
+ * and file name escaped) so the page works without JavaScript; `changes.js` only
4
+ * filters items in place, scrolls to files, and applies the diff display settings
5
+ * (`diffWrap` toggles `is-wrapped` on the diff containers; `diffIgnoreWs` reloads
6
+ * with `?ws=ignore`, which the server maps to `git diff -w`).
7
+ */
8
+ import { ID_PATTERN, INTEGRATION_ITEM, MAX_FILES, MAX_PATCH_BYTES, MAX_TASK_PATCH_BYTES, TASK_RANGE_ITEM, taskChanges, WORKING_TREE_ITEM } from "../changes.js";
9
+ import { readUiState } from "../data.js";
10
+ import { escapeHtml } from "../http.js";
11
+ import { uiMode } from "../options.js";
12
+ import { gatePill, statusPill } from "./components.js";
13
+ import { pageHead, pageScripts, renderHeader, renderModals, reviewQueue } from "./layout.js";
14
+ /** Link to a task's changes page (task detail, G2 gate panel, side panel). */
15
+ export const changesHref = (taskId) => `/tasks/${encodeURIComponent(taskId)}/changes`;
16
+ const SOURCE_LABEL = {
17
+ assignments: "loop assignments",
18
+ fleet: "fleet review snapshot",
19
+ "task-range": "task range",
20
+ "working-tree": "working tree",
21
+ none: "unavailable",
22
+ };
23
+ const STATUS_TONE = { added: "is-success", deleted: "is-danger", modified: "is-info", renamed: "is-link", copied: "is-link", untracked: "is-warning" };
24
+ const statusBadge = (status) => `<span class="ad-pill ad-diff-status ${STATUS_TONE[status] ?? ""}" title="${escapeHtml(status)}">${escapeHtml(status)}</span>`;
25
+ const short = (sha) => sha.slice(0, 7);
26
+ /** `task` item: `base <short> → <short>` once the task is done, `base <short> → HEAD + working tree` while open. */
27
+ const itemTitle = (item) => item.itemId === WORKING_TREE_ITEM ? "Working tree (uncommitted, not task-scoped)"
28
+ : item.itemId === INTEGRATION_ITEM ? "Integration commit"
29
+ : item.itemId === TASK_RANGE_ITEM ? `Task range · base ${short(item.base ?? "")} → ${item.workingTree ? "HEAD + working tree" : short(item.commit)}`
30
+ : `Item · ${item.itemId}`;
31
+ const lineClass = (line) => line.startsWith("@@") ? "ad-diff-hunk"
32
+ : line.startsWith("+++ ") || line.startsWith("--- ") || line === "+++" || line === "---" ? "ad-diff-meta"
33
+ : line.startsWith("+") ? "ad-diff-add"
34
+ : line.startsWith("-") ? "ad-diff-del"
35
+ : line.startsWith(" ") || line === "" ? "ad-diff-ctx"
36
+ : "ad-diff-meta";
37
+ /** Splits a unified patch into per-file sections (each starts at `diff --git`). */
38
+ export const patchSections = (patch) => {
39
+ const sections = [];
40
+ const lines = patch.split("\n");
41
+ if (lines[lines.length - 1] === "")
42
+ lines.pop();
43
+ for (const line of lines) {
44
+ if (line.startsWith("diff --git ") || !sections.length)
45
+ sections.push([]);
46
+ sections[sections.length - 1].push(line);
47
+ }
48
+ return sections;
49
+ };
50
+ const renderLines = (lines) => `<div class="ad-diff" role="presentation">${lines.map((line) => `<div class="ad-diff-line ${lineClass(line)}">${escapeHtml(line)}</div>`).join("")}</div>`;
51
+ const fileBody = (file, section, truncated, omitted = false) => {
52
+ if (file.status === "untracked")
53
+ return '<p class="ad-diff-note">Untracked file: not in git yet, content not shown.</p>';
54
+ if (file.binary)
55
+ return '<p class="ad-diff-note">Binary file changed.</p>';
56
+ if (file.whitespaceOnly)
57
+ return '<p class="ad-diff-note">Whitespace-only changes (hidden while whitespace is ignored).</p>';
58
+ if (!section && omitted)
59
+ return '<p class="ad-diff-note">Diff not loaded in the whole-task view: select this item to see it.</p>';
60
+ if (!section)
61
+ return `<p class="ad-diff-note">${truncated ? "Diff not shown: the patch exceeds the display limit." : "No textual changes."}</p>`;
62
+ const changed = section.some((line) => line.startsWith("@@"));
63
+ return changed ? renderLines(section) : `${renderLines(section)}<p class="ad-diff-note">No textual changes (mode or metadata only).</p>`;
64
+ };
65
+ const renderItem = (item, index, hidden) => {
66
+ const sections = patchSections(item.patch);
67
+ const added = item.files.reduce((sum, file) => sum + file.added, 0);
68
+ const removed = item.files.reduce((sum, file) => sum + file.removed, 0);
69
+ const anchor = (fileIndex) => `c-${index}-${fileIndex}`;
70
+ const commit = item.commit ? `<code title="${escapeHtml(item.commit)}">${escapeHtml(item.commit.slice(0, 12))}</code>${item.workingTree ? " + working tree" : ""}` : "";
71
+ const base = item.base ? `<code title="${escapeHtml(item.base)}">${escapeHtml(item.base.slice(0, 12))}</code> → ` : "";
72
+ const worker = item.workerId ? `<span class="ad-pill is-ghost" title="worker">${escapeHtml(item.workerId)}</span>` : "";
73
+ const banner = item.patchOmitted
74
+ ? `<div class="ad-callout is-info ad-changes-banner" data-changes-omitted><span aria-hidden="true">ℹ</span><span>Patch omitted: the whole-task view shows at most ${MAX_TASK_PATCH_BYTES / (1024 * 1024)} MiB of diff. Select this item to see it.</span></div>`
75
+ : item.truncated
76
+ ? `<div class="ad-callout ad-changes-banner" data-changes-truncated><span aria-hidden="true">⚠</span><span>Diff truncated: this item exceeds the display limit (${MAX_FILES} files or ${MAX_PATCH_BYTES / (1024 * 1024)} MiB of patch). ${item.commit ? `Inspect it locally with <code>git show ${escapeHtml(item.commit)}</code>.` : "Inspect it locally with git."}</span></div>`
77
+ : "";
78
+ const note = item.note ? `<div class="ad-callout is-info ad-changes-banner" data-changes-note><span aria-hidden="true">ℹ</span><span>${escapeHtml(item.note)}</span></div>` : "";
79
+ const list = item.files.length
80
+ ? `<ul>${item.files.map((file, fileIndex) => `<li><a href="#${anchor(fileIndex)}" data-changes-file>${statusBadge(file.status)}<span class="ad-diff-path" title="${escapeHtml(file.oldPath ? `${file.oldPath} → ${file.path}` : file.path)}">${escapeHtml(file.path)}</span>${file.binary ? '<span class="ad-diff-stat">binary</span>' : file.whitespaceOnly ? '<span class="ad-diff-stat">whitespace</span>' : file.status === "untracked" ? "" : `<span class="ad-diff-stat"><span class="ad-diff-plus">+${file.added}</span> <span class="ad-diff-minus">−${file.removed}</span></span>`}</a></li>`).join("")}</ul>`
81
+ : '<p class="ad-empty-line">No changed files.</p>';
82
+ // Untracked and whitespace-only files never have a patch section; the others keep git's order.
83
+ let sectionIndex = 0;
84
+ const blocks = item.files.map((file, fileIndex) => {
85
+ const section = file.status === "untracked" || file.whitespaceOnly ? undefined : sections[sectionIndex++];
86
+ const head = `${statusBadge(file.status)}<code class="ad-diff-path">${file.oldPath ? `${escapeHtml(file.oldPath)} → ` : ""}${escapeHtml(file.path)}</code>`;
87
+ return `<article class="ad-diff-file" id="${anchor(fileIndex)}"><header class="ad-diff-file-head">${head}</header>${fileBody(file, section, item.truncated, item.patchOmitted)}</article>`;
88
+ }).join("");
89
+ return `<section class="ad-panel ad-changes-item" data-change-item="${escapeHtml(item.itemId)}"${item.patchOmitted ? " data-patch-omitted" : ""}${hidden ? " hidden" : ""}>
90
+ <header class="ad-panel-head"><h2>${escapeHtml(itemTitle(item))}</h2>${worker}<span class="ad-changes-commit">${base}${commit}</span><span class="ad-count ad-panel-tools">${item.files.length} file${item.files.length === 1 ? "" : "s"} · +${added} −${removed}</span></header>
91
+ <div class="ad-panel-body is-flush">
92
+ ${banner}${note}
93
+ <div class="ad-changes">
94
+ <nav class="ad-changes-files" aria-label="Changed files">${list}</nav>
95
+ <div class="ad-changes-diff is-wrapped">${blocks || '<p class="ad-empty-line">Nothing to show.</p>'}</div>
96
+ </div>
97
+ </div>
98
+ </section>`;
99
+ };
100
+ const errorPage = (options, title, message, reviews) => `${pageHead(`${title} · Loop`)}${renderHeader(options.root, uiMode(options), reviews)}<main class="section ad-main"><div class="container is-fluid"><div class="ad-empty"><div class="ad-empty-icon" aria-hidden="true">🔍</div><h2>${escapeHtml(title)}</h2><p>${escapeHtml(message)}</p><a class="button" href="/">← Back to dashboard</a></div></div></main>${renderModals()}${pageScripts("common")}</body></html>`;
101
+ /** Diff display styles the page needs beyond dashboard.css: `is-wrapped` is toggled by changes.js from the `diffWrap` setting. */
102
+ /** The full changes page and its HTTP status (400 malformed id or `ws` value, 404 unknown task/item). */
103
+ export const renderChangesPage = async (options, taskId, itemId, query = {}) => {
104
+ const state = await readUiState(options);
105
+ const reviews = reviewQueue(state);
106
+ if (itemId !== null && !ID_PATTERN.test(itemId))
107
+ return { status: 400, html: errorPage(options, "Invalid request", "Invalid item id", reviews) };
108
+ // The page always renders every item; `?item=` only selects which one is visible.
109
+ const result = await taskChanges(options, taskId, null, state, itemId, query);
110
+ if (result.status !== 200)
111
+ return { status: result.status, html: errorPage(options, result.status === 400 ? "Invalid request" : "Task not found", result.payload.error, reviews) };
112
+ const { payload, task } = result;
113
+ if (itemId !== null && !payload.items.some((item) => item.itemId === itemId))
114
+ return { status: 404, html: errorPage(options, "Item not found", `No changes recorded for item ${itemId}`, reviews) };
115
+ const taskHref = `/tasks/${encodeURIComponent(task.id)}`;
116
+ const itemOptions = payload.items.map((item) => `<option value="${escapeHtml(item.itemId)}"${item.itemId === itemId ? " selected" : ""}>${escapeHtml(itemTitle(item))}${item.workerId ? ` · ${escapeHtml(item.workerId)}` : ""}</option>`).join("");
117
+ const ignoreWs = query.ws === "ignore";
118
+ const selector = payload.items.length > 1 || itemId !== null
119
+ ? `<form class="ad-changes-form" method="get" action="${changesHref(task.id)}" data-changes-form>
120
+ <label class="label is-small mb-0" for="changes-item">Show</label>
121
+ <div class="select is-small"><select id="changes-item" name="item" data-changes-select><option value=""${itemId === null ? " selected" : ""}>Whole task</option>${itemOptions}</select></div>${ignoreWs ? '<input type="hidden" name="ws" value="ignore">' : ""}
122
+ <button type="submit" class="button is-small" data-changes-submit>Apply</button>
123
+ </form>`
124
+ : "";
125
+ // Display options need JavaScript (they live in localStorage); changes.js reveals them.
126
+ const displayOptions = payload.items.length
127
+ ? `<span class="ad-changes-options" data-changes-options hidden>
128
+ <label><input type="checkbox" data-changes-wrap checked> Wrap lines</label>
129
+ <label><input type="checkbox" data-changes-ws${ignoreWs ? " checked" : ""}> Ignore whitespace</label>
130
+ </span>`
131
+ : "";
132
+ const toolbar = selector || displayOptions ? `<div class="ad-changes-toolbar">${selector}${displayOptions}</div>` : "";
133
+ const note = payload.note ? `<div class="ad-callout is-info ad-changes-banner" data-changes-note><span aria-hidden="true">ℹ</span><span>${escapeHtml(payload.note)}</span></div>` : "";
134
+ const items = payload.items.length
135
+ ? payload.items.map((item, index) => renderItem(item, index, itemId !== null && item.itemId !== itemId)).join("\n")
136
+ : '<div class="ad-empty"><div class="ad-empty-icon" aria-hidden="true">∅</div><h2>No changes to show</h2><p>No commits are recorded for this task yet.</p></div>';
137
+ const html = `${pageHead(`Changes · ${task.title} · Loop`, task.id)}
138
+ ${renderHeader(options.root, uiMode(options), reviews, task.id)}
139
+ <main class="section ad-main">
140
+ <div class="container is-fluid">
141
+ <a class="ad-breadcrumb" href="${taskHref}">← Task</a>
142
+ <h1 class="ad-page-title">Changes · ${escapeHtml(task.title)}</h1>
143
+ <div class="ad-page-sub"><code>${escapeHtml(task.id)}</code>${statusPill(task.status, Boolean(task.handoff))}${gatePill(task.gate)}<span class="ad-pill is-ghost" data-changes-source="${payload.source}" title="where these changes come from">source · ${escapeHtml(SOURCE_LABEL[payload.source])}</span><a class="button is-small ml-auto" href="${taskHref}">Back to task</a></div>
144
+ ${note}
145
+ ${toolbar}
146
+ ${items}
147
+ </div>
148
+ </main>
149
+ ${renderModals()}
150
+ ${pageScripts("common", "changes", "live-log")}
151
+ </body>
152
+ </html>`;
153
+ return { status: 200, html };
154
+ };