@juzser/blacksmith 0.1.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 (172) hide show
  1. package/.claude/agents/auditor.md +146 -0
  2. package/.claude/agents/coder.md +164 -0
  3. package/.claude/agents/grader.md +110 -0
  4. package/.claude/agents/merger.md +107 -0
  5. package/.claude/agents/planner.md +92 -0
  6. package/.claude/agents/researcher.md +105 -0
  7. package/.claude/agents/reviewer.md +171 -0
  8. package/.claude/agents/scribe.md +72 -0
  9. package/.claude/agents/security-reviewer.md +130 -0
  10. package/.claude/agents/spec-reviewer.md +215 -0
  11. package/.claude/agents/tester.md +121 -0
  12. package/.claude/agents/uiux.md +126 -0
  13. package/.claude/agents/verifier.md +103 -0
  14. package/.claude/agents/wave-runner.md +74 -0
  15. package/LICENSE +21 -0
  16. package/README.md +389 -0
  17. package/factory/orchestrator/dist/actors.js +46 -0
  18. package/factory/orchestrator/dist/agents-registry.js +308 -0
  19. package/factory/orchestrator/dist/args.js +84 -0
  20. package/factory/orchestrator/dist/artifacts.js +111 -0
  21. package/factory/orchestrator/dist/attribution.js +160 -0
  22. package/factory/orchestrator/dist/audit.js +812 -0
  23. package/factory/orchestrator/dist/autonomy.js +151 -0
  24. package/factory/orchestrator/dist/budgetAlarm.js +366 -0
  25. package/factory/orchestrator/dist/budgets.js +196 -0
  26. package/factory/orchestrator/dist/claims.js +633 -0
  27. package/factory/orchestrator/dist/cli.js +3185 -0
  28. package/factory/orchestrator/dist/commit.js +152 -0
  29. package/factory/orchestrator/dist/coverage.js +188 -0
  30. package/factory/orchestrator/dist/crossFinding.js +479 -0
  31. package/factory/orchestrator/dist/crosscheck.js +396 -0
  32. package/factory/orchestrator/dist/daemon.js +707 -0
  33. package/factory/orchestrator/dist/db/projector.js +1317 -0
  34. package/factory/orchestrator/dist/db/queries.js +1714 -0
  35. package/factory/orchestrator/dist/db/schema.js +305 -0
  36. package/factory/orchestrator/dist/delegation.js +400 -0
  37. package/factory/orchestrator/dist/diffstat.js +164 -0
  38. package/factory/orchestrator/dist/dispatchAudit.js +275 -0
  39. package/factory/orchestrator/dist/dotenv.js +72 -0
  40. package/factory/orchestrator/dist/effort.js +202 -0
  41. package/factory/orchestrator/dist/epic.js +1018 -0
  42. package/factory/orchestrator/dist/epicWidth.js +238 -0
  43. package/factory/orchestrator/dist/errorIssues.js +249 -0
  44. package/factory/orchestrator/dist/errors.js +11 -0
  45. package/factory/orchestrator/dist/escalation.js +500 -0
  46. package/factory/orchestrator/dist/events.js +1015 -0
  47. package/factory/orchestrator/dist/findingAge.js +91 -0
  48. package/factory/orchestrator/dist/findingContext.js +103 -0
  49. package/factory/orchestrator/dist/findings.js +1156 -0
  50. package/factory/orchestrator/dist/gate.js +911 -0
  51. package/factory/orchestrator/dist/gh.js +146 -0
  52. package/factory/orchestrator/dist/git.js +87 -0
  53. package/factory/orchestrator/dist/goalCheck.js +380 -0
  54. package/factory/orchestrator/dist/graph.js +61 -0
  55. package/factory/orchestrator/dist/hookDecision.js +106 -0
  56. package/factory/orchestrator/dist/immutability.js +171 -0
  57. package/factory/orchestrator/dist/impact.js +280 -0
  58. package/factory/orchestrator/dist/integration.js +141 -0
  59. package/factory/orchestrator/dist/judgePreflight.js +155 -0
  60. package/factory/orchestrator/dist/judges.js +310 -0
  61. package/factory/orchestrator/dist/lessonAudit.js +399 -0
  62. package/factory/orchestrator/dist/lessons.js +1329 -0
  63. package/factory/orchestrator/dist/mcp.js +555 -0
  64. package/factory/orchestrator/dist/paths.js +82 -0
  65. package/factory/orchestrator/dist/plan.js +502 -0
  66. package/factory/orchestrator/dist/planQuorum.js +421 -0
  67. package/factory/orchestrator/dist/policy.js +1308 -0
  68. package/factory/orchestrator/dist/policyHook.js +28 -0
  69. package/factory/orchestrator/dist/preconditions.js +47 -0
  70. package/factory/orchestrator/dist/projects.js +134 -0
  71. package/factory/orchestrator/dist/prompts.js +57 -0
  72. package/factory/orchestrator/dist/provenance.js +201 -0
  73. package/factory/orchestrator/dist/providers/api-transport.js +117 -0
  74. package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
  75. package/factory/orchestrator/dist/providers/index.js +54 -0
  76. package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
  77. package/factory/orchestrator/dist/providers/types.js +49 -0
  78. package/factory/orchestrator/dist/queue.js +263 -0
  79. package/factory/orchestrator/dist/quorum.js +347 -0
  80. package/factory/orchestrator/dist/quorumEscalations.js +132 -0
  81. package/factory/orchestrator/dist/results.js +65 -0
  82. package/factory/orchestrator/dist/roadmap.js +437 -0
  83. package/factory/orchestrator/dist/runtime.js +86 -0
  84. package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
  85. package/factory/orchestrator/dist/sandbox.js +165 -0
  86. package/factory/orchestrator/dist/scaffold.js +373 -0
  87. package/factory/orchestrator/dist/scheduler.js +408 -0
  88. package/factory/orchestrator/dist/schemas.js +195 -0
  89. package/factory/orchestrator/dist/security.js +222 -0
  90. package/factory/orchestrator/dist/severity.js +293 -0
  91. package/factory/orchestrator/dist/spec.js +376 -0
  92. package/factory/orchestrator/dist/specChange.js +497 -0
  93. package/factory/orchestrator/dist/stack.js +324 -0
  94. package/factory/orchestrator/dist/symbols.js +966 -0
  95. package/factory/orchestrator/dist/taskEvents.js +399 -0
  96. package/factory/orchestrator/dist/taskId.js +126 -0
  97. package/factory/orchestrator/dist/taxonomy.js +113 -0
  98. package/factory/orchestrator/dist/testSelect.js +129 -0
  99. package/factory/orchestrator/dist/testerAudit.js +264 -0
  100. package/factory/orchestrator/dist/testgate.js +171 -0
  101. package/factory/orchestrator/dist/usage.js +874 -0
  102. package/factory/orchestrator/dist/waivers.js +228 -0
  103. package/factory/orchestrator/dist/waveBudget.js +223 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +250 -0
  105. package/factory/orchestrator/dist/waveNext.js +210 -0
  106. package/factory/orchestrator/dist/waveSchedule.js +146 -0
  107. package/factory/orchestrator/dist/worktree.js +207 -0
  108. package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
  109. package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
  110. package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
  111. package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
  112. package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
  113. package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
  114. package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
  115. package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
  116. package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
  117. package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
  118. package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
  119. package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
  120. package/factory/policies/budgets.yml +262 -0
  121. package/factory/policies/crosscheck.yml +308 -0
  122. package/factory/policies/delegation.yml +44 -0
  123. package/factory/policies/effort.yml +142 -0
  124. package/factory/policies/guardrails.yml +306 -0
  125. package/factory/policies/lessons.md +239 -0
  126. package/factory/policies/scheduler.yml +90 -0
  127. package/factory/policies/sensitive-paths.yml +148 -0
  128. package/factory/policies/severity.yml +83 -0
  129. package/factory/policies/stack.yml +60 -0
  130. package/factory/policies/taxonomy.yml +340 -0
  131. package/factory/policies/worktree.yml +95 -0
  132. package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
  133. package/factory/scaffold/base/.gitignore.tmpl +11 -0
  134. package/factory/scaffold/base/AGENTS.md +33 -0
  135. package/factory/scaffold/base/README.md +25 -0
  136. package/factory/scaffold/base/biome.json.tmpl +30 -0
  137. package/factory/scaffold/base/package.json +25 -0
  138. package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
  139. package/factory/scaffold/base/src/index.ts +4 -0
  140. package/factory/scaffold/base/test/index.test.ts +8 -0
  141. package/factory/scaffold/base/tsconfig.json +20 -0
  142. package/factory/scaffold/base/tsconfig.test.json +9 -0
  143. package/factory/scaffold/base/vitest.config.ts +29 -0
  144. package/factory/scaffold/mcp/mcp.manifest.json +22 -0
  145. package/factory/scaffold/mcp/package.fragment.json +9 -0
  146. package/factory/scaffold/mcp/src/mcp/README.md +63 -0
  147. package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
  148. package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
  149. package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
  150. package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
  151. package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
  152. package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
  153. package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
  154. package/factory/scaffold/ui/index.html +12 -0
  155. package/factory/scaffold/ui/package.fragment.json +13 -0
  156. package/factory/scaffold/ui/src/App.vue +10 -0
  157. package/factory/scaffold/ui/src/main.ts +5 -0
  158. package/factory/scaffold/ui/src/shims.d.ts +11 -0
  159. package/factory/scaffold/ui/vite.config.ts +20 -0
  160. package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
  161. package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
  162. package/factory/specs/schema/event.schema.json +82 -0
  163. package/factory/specs/schema/finding-evidence.schema.json +45 -0
  164. package/factory/specs/schema/finding.schema.json +115 -0
  165. package/factory/specs/schema/grader-verdict.schema.json +50 -0
  166. package/factory/specs/schema/judge-verdict.schema.json +19 -0
  167. package/factory/specs/schema/lesson.schema.json +85 -0
  168. package/factory/specs/schema/mcp-manifest.schema.json +230 -0
  169. package/factory/specs/schema/result.schema.json +81 -0
  170. package/factory/specs/schema/spec-change-request.schema.json +46 -0
  171. package/factory/specs/schema/task-spec.schema.json +134 -0
  172. package/package.json +98 -0
@@ -0,0 +1,1714 @@
1
+ // The §10 dashboard page queries, each a typed function over the
2
+ // projection tables in db/schema.ts. Every function accepts a `SmithDb`
3
+ // (see db/projector.ts's openDb()) plus an optional session scope — when
4
+ // omitted, a query spans every projected session (a single Blacksmith
5
+ // instance is one continuously-running factory, so "no session filter"
6
+ // is the normal case; a session filter is for debugging one run).
7
+ import { and, eq, gte, inArray, lte } from 'drizzle-orm';
8
+ import { isOperatorActor } from '../actors.js';
9
+ import { foldAgents, liveAgents as foldLiveAgents, REGISTRY_EVENT_TYPES, } from '../agents-registry.js';
10
+ import { compareLogOrder, isLaterEvent, parseEventId, ROOT_EVENT_TYPE } from '../events.js';
11
+ import { waveLayers } from '../graph.js';
12
+ import { judgeFailureKind } from '../providers/types.js';
13
+ import { severityRank } from '../severity.js';
14
+ import { epicOfTaskId, taskIdsMatch } from '../taskId.js';
15
+ import { loadTaxonomy } from '../taxonomy.js';
16
+ import { agents, artifacts, dispatches, edges, epics, errors, eventsRaw, findings, lessons, milestones, prompts, sessions, tasks, waivers, } from './schema.js';
17
+ /** Phase 6b default: every event/task/etc. logged before the project dimension existed. */
18
+ export const DEFAULT_PROJECT = 'black-smith';
19
+ /**
20
+ * `null` (never-tagged, pre-Phase-6b) reads as DEFAULT_PROJECT, same as the
21
+ * schema.ts columns' doc comment promises. Project scoping is applied
22
+ * JS-side, after a (session-scoped) row fetch, everywhere in this file —
23
+ * dataset sizes here are "one local factory's projection tables", not a
24
+ * multi-tenant SaaS table, so this is the §-ladder "reuse the existing
25
+ * pattern" choice over hand-writing a COALESCE into every one of this
26
+ * file's ~15 query shapes (and it composes for free with every function
27
+ * below that already builds its own JS-side Maps/aggregations from a rows
28
+ * array — one filter step ahead of the aggregation, not a SQL rewrite).
29
+ */
30
+ function projectOf(value) {
31
+ return value ?? DEFAULT_PROJECT;
32
+ }
33
+ /**
34
+ * The session narrowing for one `session_id` column: `IN (lineage)` when the
35
+ * scope carries one, `= sessionId` when it carries a single session, and
36
+ * `undefined` -- meaning no narrowing at all -- when it carries neither.
37
+ *
38
+ * Every session-scoped read in this file goes through here rather than
39
+ * writing its own `eq()`, so widening the scope is one change instead of
40
+ * twenty, and a reader added later cannot quietly opt out of the lineage by
41
+ * forgetting it exists.
42
+ */
43
+ function scopedToSessions(column, scope) {
44
+ if (scope.sessionIds !== undefined) {
45
+ if (scope.sessionIds.length === 0) {
46
+ // `inArray(col, [])` is a clause with no rows to compare against, and
47
+ // what a driver makes of it -- match nothing, match everything, refuse
48
+ // to compile -- is not something this file should be relying on either
49
+ // way. Neither reading answers "scope this to no sessions", and
50
+ // `projectedLineage` cannot produce an empty array, so an empty one is
51
+ // a caller bug and gets told so rather than an arbitrary row set.
52
+ throw new RangeError('Scope.sessionIds was empty: a lineage always contains at least the session it was resolved from. Pass sessionId for a single session, or omit both to span every session.');
53
+ }
54
+ return inArray(column, [...scope.sessionIds]);
55
+ }
56
+ if (scope.sessionId !== undefined)
57
+ return eq(column, scope.sessionId);
58
+ return undefined;
59
+ }
60
+ /**
61
+ * The lineage of `sessionId`, root-first and always containing `sessionId`
62
+ * itself -- the projection's answer to the question `sessionLineage()` in
63
+ * events.ts answers from the raw logs.
64
+ *
65
+ * It is resolved here, and not by calling that one, because everything this
66
+ * file serves is a pure reader over `state/smith.db`: `smith stats` opens the
67
+ * db and nothing else, and the UI server has no access to the log directory
68
+ * at all. A lineage that could only be read off the filesystem would be a
69
+ * lineage the dashboard could never draw.
70
+ *
71
+ * Ancestors, self, then continuations -- exactly as `sessionLineage()` and
72
+ * `smith event tail --lineage` define it, because D-264's whole point was that
73
+ * the dashboard and the CLI must not scope an epic differently. D-266 added
74
+ * the downward half on both sides at once: an epic session that dispatches a
75
+ * session per wave is read from the epic end, and a walk that only went up saw
76
+ * none of the work. Continuations of `sessionId` and not of the root, for the
77
+ * reason walkLineage() gives -- a wave folds in its ancestry and its own
78
+ * continuations, never its siblings.
79
+ *
80
+ * The walk is over `session-start` rows, because that is the only event type
81
+ * allowed a cross-session `causal_parent`
82
+ * (`events.cross-session-parent-not-root`), and it takes the FIRST root of
83
+ * each log for the reason startSession()'s comment gives -- a log has one
84
+ * beginning, and a second `session-start` is a line no reader looks at.
85
+ */
86
+ export function projectedLineage(db, sessionId) {
87
+ return [...projectedAncestry(db, sessionId), ...projectedContinuations(db, sessionId)];
88
+ }
89
+ /** The chain `sessionId` continues, root first, ending with `sessionId`. */
90
+ function projectedAncestry(db, sessionId) {
91
+ const chain = [sessionId];
92
+ const seen = new Set([sessionId]);
93
+ let current = sessionId;
94
+ for (;;) {
95
+ const roots = db
96
+ .select({
97
+ eventId: eventsRaw.eventId,
98
+ ts: eventsRaw.ts,
99
+ causalParent: eventsRaw.causalParent,
100
+ })
101
+ .from(eventsRaw)
102
+ .where(and(eq(eventsRaw.sessionId, current), eq(eventsRaw.eventType, ROOT_EVENT_TYPE)))
103
+ .all();
104
+ // inLogOrder, not `limit 1`: rows come back in whatever order the table
105
+ // holds them, and "the first root" has to mean first in the log.
106
+ const root = inLogOrder(roots)[0];
107
+ const parentId = root?.causalParent ?? null;
108
+ if (parentId === null)
109
+ return chain;
110
+ const parent = db
111
+ .select({ sessionId: eventsRaw.sessionId })
112
+ .from(eventsRaw)
113
+ .where(eq(eventsRaw.eventId, parentId))
114
+ .all()[0];
115
+ // A parent the projection has not folded is where the walk stops, not
116
+ // where it throws: `smith stats` runs against whatever has been projected
117
+ // so far, and a partial rebuild is a normal state, not a corrupt one. The
118
+ // scope is then the part of the lineage that exists, which is the most
119
+ // this reader can honestly offer.
120
+ if (parent === undefined)
121
+ return chain;
122
+ if (seen.has(parent.sessionId)) {
123
+ throw new RangeError(`Session lineage for "${sessionId}" is a cycle: "${parent.sessionId}" appears twice. A session log's root may name a parent in another log, and the result has to stay a tree.`);
124
+ }
125
+ seen.add(parent.sessionId);
126
+ chain.unshift(parent.sessionId);
127
+ current = parent.sessionId;
128
+ }
129
+ }
130
+ /**
131
+ * The sessions that continue `sessionId`, breadth-first, ids sorted at each
132
+ * level -- the projection's copy of walkLineage()'s downward half.
133
+ *
134
+ * Every root row in the projection is fetched once and indexed by the session
135
+ * its `causal_parent` names, rather than issuing a query per frontier session:
136
+ * there is one root per log, so the set is small, and the alternative is a
137
+ * query per level per session.
138
+ *
139
+ * The parent session is read off the event id rather than by joining back to
140
+ * the parent row, and the asymmetry with the upward walk is deliberate. Up
141
+ * there, an unresolvable parent is where the walk stops, because a partial
142
+ * rebuild is a normal state and the ancestors are what the reader was
143
+ * promised. Down here the CHILD is the row that was projected, so it belongs
144
+ * in the scope whether or not its parent's log has been folded yet -- and
145
+ * dropping it would be the very blindness this walk exists to remove.
146
+ */
147
+ function projectedContinuations(db, sessionId) {
148
+ const roots = db
149
+ .select({
150
+ eventId: eventsRaw.eventId,
151
+ sessionId: eventsRaw.sessionId,
152
+ ts: eventsRaw.ts,
153
+ causalParent: eventsRaw.causalParent,
154
+ })
155
+ .from(eventsRaw)
156
+ .where(eq(eventsRaw.eventType, ROOT_EVENT_TYPE))
157
+ .all();
158
+ const childrenOf = new Map();
159
+ const firstRootSeen = new Set();
160
+ for (const root of inLogOrder(roots)) {
161
+ // One entry edge per log: the first root wins, later ones are lines no
162
+ // reader looks at.
163
+ if (firstRootSeen.has(root.sessionId))
164
+ continue;
165
+ firstRootSeen.add(root.sessionId);
166
+ if (root.causalParent === null)
167
+ continue;
168
+ let parentSession;
169
+ try {
170
+ parentSession = parseEventId(root.causalParent).sessionId;
171
+ }
172
+ catch {
173
+ // A malformed parent id is not an edge. The raw-log reader throws on
174
+ // this; here the row is already projected and the projection is a
175
+ // derived artefact, so the honest answer is that this session continues
176
+ // nothing that can be named.
177
+ continue;
178
+ }
179
+ if (parentSession === root.sessionId)
180
+ continue;
181
+ const siblings = childrenOf.get(parentSession);
182
+ if (siblings === undefined)
183
+ childrenOf.set(parentSession, [root.sessionId]);
184
+ else
185
+ siblings.push(root.sessionId);
186
+ }
187
+ for (const siblings of childrenOf.values())
188
+ siblings.sort();
189
+ const out = [];
190
+ const seen = new Set([sessionId]);
191
+ let frontier = childrenOf.get(sessionId) ?? [];
192
+ while (frontier.length > 0) {
193
+ const next = [];
194
+ for (const child of frontier) {
195
+ if (seen.has(child))
196
+ continue;
197
+ seen.add(child);
198
+ out.push(child);
199
+ next.push(...(childrenOf.get(child) ?? []));
200
+ }
201
+ frontier = next;
202
+ }
203
+ return out;
204
+ }
205
+ /** Filters an already-fetched row array to `scope.project`; a no-op in global mode (project omitted). */
206
+ function filterByProject(rows, scope) {
207
+ return scope.project !== undefined
208
+ ? rows.filter((r) => projectOf(r.project) === scope.project)
209
+ : rows;
210
+ }
211
+ /** Rows in the order the log wrote them, oldest first. Never mutates the input. */
212
+ function inLogOrder(rows) {
213
+ return rows.slice().sort(compareLogOrder);
214
+ }
215
+ /** Every distinct project value present across the row sets, DEFAULT_PROJECT-normalized, sorted. */
216
+ function distinctProjects(...rowSets) {
217
+ return [...new Set(rowSets.flat().map((r) => projectOf(r.project)))].sort();
218
+ }
219
+ const NON_TERMINAL_TASK_STATUSES = [
220
+ 'todo',
221
+ 'ready',
222
+ 'in-progress',
223
+ 'grading',
224
+ 'reviewing',
225
+ 'merging',
226
+ 'blocked',
227
+ ];
228
+ /** Every `tasks` row for `scope`, session-filtered in SQL and project-filtered in JS. */
229
+ function allTasksForScope(db, scope) {
230
+ const sessionCond = scopedToSessions(tasks.sessionId, scope);
231
+ const rows = sessionCond
232
+ ? db.select().from(tasks).where(sessionCond).all()
233
+ : db.select().from(tasks).all();
234
+ return filterByProject(rows, scope);
235
+ }
236
+ /**
237
+ * A predicate for "this event belongs to `scope.project`", answered through
238
+ * the event's TASK rather than through its own `project` column.
239
+ *
240
+ * That route is the rule the `Scope.project` doc comment states and D-170
241
+ * paid for: a `task-result-recorded` carries no project of its own on any row
242
+ * logged before Phase 6b, so normalizing its null to `default` files real
243
+ * spend under a project that never incurred it. The task row is the thing the
244
+ * projector actually tagged, and `allTasksForScope` has already scoped it.
245
+ *
246
+ * Membership asks `taskIdsMatch`, not `Set.has` (D-130/D-143): the log spells
247
+ * the same task both qualified and bare, and a raw comparison drops the bare
248
+ * rows into no project at all — a partition that silently loses rows reads as
249
+ * a smaller number, not as an error. The Set is only the exact-match fast
250
+ * path in front of it.
251
+ *
252
+ * Global mode (no project) admits everything, including rows that name no
253
+ * task — unchanged behaviour, and the honest one: an untasked row still
254
+ * belongs to the whole.
255
+ */
256
+ function taskInScope(db, scope) {
257
+ if (scope.project === undefined)
258
+ return () => true;
259
+ const scopedIds = allTasksForScope(db, scope).map((t) => t.taskId);
260
+ const exact = new Set(scopedIds);
261
+ return (taskId) => typeof taskId === 'string' &&
262
+ (exact.has(taskId) || scopedIds.some((id) => taskIdsMatch(taskId, id)));
263
+ }
264
+ function taskResultRows(db, scope) {
265
+ const cols = { payload: eventsRaw.payload, envelopeTaskId: eventsRaw.taskId };
266
+ const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
267
+ const rows = sessionCond
268
+ ? db
269
+ .select(cols)
270
+ .from(eventsRaw)
271
+ .where(and(eq(eventsRaw.eventType, 'task-result-recorded'), sessionCond))
272
+ .all()
273
+ : db.select(cols).from(eventsRaw).where(eq(eventsRaw.eventType, 'task-result-recorded')).all();
274
+ return rows.map((r) => ({
275
+ payload: JSON.parse(r.payload),
276
+ envelopeTaskId: r.envelopeTaskId,
277
+ }));
278
+ }
279
+ /** The task a Result names, payload first, envelope second. */
280
+ function resultTaskId(row) {
281
+ return row.payload.task_id ?? row.envelopeTaskId ?? undefined;
282
+ }
283
+ /**
284
+ * Resolves the task a Result names to the epic that owns it, against a map
285
+ * keyed by the tasks table's ids.
286
+ *
287
+ * Exact first, then `taskIdsMatch` (D-130/D-143), for the same reason
288
+ * `taskInScope` above folds: the log spells one task both qualified and bare,
289
+ * and today's `state/events` has two `task-result-recorded` rows spelled bare
290
+ * against a tasks table that knows them qualified. An exact `Map.get` drops
291
+ * those rows out of per-epic spend entirely, and a partition that silently
292
+ * loses rows reads as a smaller number, not as an error.
293
+ *
294
+ * Where this deliberately differs from `taskInScope`: an ambiguous bare id —
295
+ * one that folds onto tasks in **two** epics — resolves to neither. Folding is
296
+ * free for a membership predicate, which can only say yes once; a sum counted
297
+ * into both epics would invent spend on the epic that did not incur it and
298
+ * make the per-epic column add up to more than the run. Under-counting an
299
+ * ambiguity is recoverable; over-counting it is a number nobody can reconcile.
300
+ */
301
+ function epicResolver(epicByTask) {
302
+ const keys = [...epicByTask.keys()];
303
+ return (taskId) => {
304
+ if (taskId === undefined)
305
+ return undefined;
306
+ const exact = epicByTask.get(taskId);
307
+ if (exact !== undefined)
308
+ return exact;
309
+ const matched = new Set(keys.filter((id) => taskIdsMatch(taskId, id)).map((id) => epicByTask.get(id)));
310
+ return matched.size === 1 ? [...matched][0] : undefined;
311
+ };
312
+ }
313
+ /**
314
+ * Per-epic budget (sum of tasks.budgetTokens) and spend (sum of
315
+ * task-result-recorded token_usage for that epic's tasks) — shared by
316
+ * overview()'s tokensByEpic and roadmapPage()'s per-milestone token roll-up,
317
+ * so the two views never compute the same numbers two different ways.
318
+ *
319
+ * Attribution goes through `epicResolver` (D-207), not a raw `Map.get`: on
320
+ * today's real log that raw lookup dropped 1,585,899 tokens of
321
+ * `envkit-mcp-surface` spend on the floor, because two of its Results spell
322
+ * the task id bare and one omits it from the payload.
323
+ */
324
+ function epicTokenMaps(db, scope, taskRows) {
325
+ const budgetByEpic = new Map();
326
+ const epicByTask = new Map();
327
+ for (const t of taskRows) {
328
+ if (!t.epicId)
329
+ continue;
330
+ epicByTask.set(t.taskId, t.epicId);
331
+ if (t.budgetTokens !== null) {
332
+ budgetByEpic.set(t.epicId, (budgetByEpic.get(t.epicId) ?? 0) + t.budgetTokens);
333
+ }
334
+ }
335
+ const epicOf = epicResolver(epicByTask);
336
+ const spentByEpic = new Map();
337
+ for (const row of taskResultRows(db, scope)) {
338
+ const epicId = epicOf(resultTaskId(row));
339
+ if (!epicId)
340
+ continue;
341
+ const total = row.payload.token_usage?.total_tokens ?? 0;
342
+ spentByEpic.set(epicId, (spentByEpic.get(epicId) ?? 0) + total);
343
+ }
344
+ return { budgetByEpic, spentByEpic };
345
+ }
346
+ const MILESTONE_COMPLETE_TASK_STATUSES = new Set(['completed', 'waived']);
347
+ const MILESTONE_NEXT_EXCLUDED_STATUSES = new Set(['completed', 'waived', 'failed', 'superseded']);
348
+ const MINI_TIMELINE_LIMIT = 3;
349
+ /**
350
+ * Operator directive 4 (Phase 6b, ui/docs/design-spec.md addendum): up to 3
351
+ * most-recent DONE tasks (success tone) and 3 NEXT tasks (plan/dependency
352
+ * order) per milestone, for the Roadmap page's mini-timeline row under each
353
+ * progress bar. `dependencyReady` = every edge this task depends on
354
+ * (edges.dependsOn) already points at a completed/waived task.
355
+ */
356
+ function milestoneTaskRefs(milestoneTasks, allTaskRows, edgeRows) {
357
+ const statusById = new Map(allTaskRows.map((t) => [t.taskId, t.taskStatus]));
358
+ const dependsOnByTask = new Map();
359
+ for (const e of edgeRows) {
360
+ const list = dependsOnByTask.get(e.taskId) ?? [];
361
+ list.push(e.dependsOn);
362
+ dependsOnByTask.set(e.taskId, list);
363
+ }
364
+ const dependencyReady = (taskId) => (dependsOnByTask.get(taskId) ?? []).every((dep) => {
365
+ const status = statusById.get(dep);
366
+ return status !== undefined && MILESTONE_COMPLETE_TASK_STATUSES.has(status);
367
+ });
368
+ const recentDone = milestoneTasks
369
+ .filter((t) => MILESTONE_COMPLETE_TASK_STATUSES.has(t.taskStatus))
370
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
371
+ .slice(0, MINI_TIMELINE_LIMIT)
372
+ .map((t) => ({
373
+ taskId: t.taskId,
374
+ taskStatus: t.taskStatus,
375
+ title: t.objective,
376
+ updatedAt: t.updatedAt,
377
+ dependencyReady: true,
378
+ }));
379
+ const nextUp = milestoneTasks
380
+ .filter((t) => !MILESTONE_NEXT_EXCLUDED_STATUSES.has(t.taskStatus))
381
+ .map((row) => ({ row, ready: dependencyReady(row.taskId) }))
382
+ // Dependency-ready tasks first (plan/dependency order), then oldest-created
383
+ // first within each group -- `createdAt`, because db/projector.ts's touch()
384
+ // rewrites `updatedAt` on every event carrying the task id: ordering NEXT
385
+ // by it sank the first-planned task below a later one the moment it was
386
+ // dispatched, which is when the operator most expects to see it at the top.
387
+ .sort((a, b) => {
388
+ if (a.ready !== b.ready)
389
+ return a.ready ? -1 : 1;
390
+ return a.row.createdAt.localeCompare(b.row.createdAt);
391
+ })
392
+ .slice(0, MINI_TIMELINE_LIMIT)
393
+ .map(({ row, ready }) => ({
394
+ taskId: row.taskId,
395
+ taskStatus: row.taskStatus,
396
+ title: row.objective,
397
+ updatedAt: row.updatedAt,
398
+ dependencyReady: ready,
399
+ }));
400
+ return { recentDone, nextUp };
401
+ }
402
+ /**
403
+ * Join factory/specs/roadmap.md's milestones (db/schema.ts's `milestones`
404
+ * table) with each milestone's mapped epics' task/token stats. A milestone
405
+ * with no epics mapped yet (roadmap.md's `epics: []`) reports zero tasks —
406
+ * not an error, just "nothing tagged to this phase yet". `scope.project`
407
+ * filters to milestones whose OWN `- project:` tag matches (milestones are
408
+ * declared per-project, not derived from their epics' tasks).
409
+ */
410
+ function milestoneProgressRows(db, scope = {}, opts = {}) {
411
+ const allMilestoneRows = db.select().from(milestones).orderBy(milestones.sequence).all();
412
+ const milestoneRows = scope.project !== undefined
413
+ ? allMilestoneRows.filter((m) => m.project === scope.project)
414
+ : allMilestoneRows;
415
+ if (milestoneRows.length === 0)
416
+ return [];
417
+ const taskRows = allTasksForScope(db, scope);
418
+ const { budgetByEpic, spentByEpic } = epicTokenMaps(db, scope, taskRows);
419
+ const edgeSessionCond = scopedToSessions(edges.sessionId, scope);
420
+ const edgeRows = opts.includeTaskRefs
421
+ ? edgeSessionCond
422
+ ? db.select().from(edges).where(edgeSessionCond).all()
423
+ : db.select().from(edges).all()
424
+ : [];
425
+ return milestoneRows.map((m) => {
426
+ const epicIds = JSON.parse(m.epicIds);
427
+ const epicSet = new Set(epicIds);
428
+ const milestoneTasks = taskRows.filter((t) => t.epicId !== null && epicSet.has(t.epicId));
429
+ const tasksCompleted = milestoneTasks.filter((t) => MILESTONE_COMPLETE_TASK_STATUSES.has(t.taskStatus)).length;
430
+ let tokensSpent = 0;
431
+ let tokensBudget = 0;
432
+ let hasBudget = false;
433
+ for (const epicId of epicIds) {
434
+ tokensSpent += spentByEpic.get(epicId) ?? 0;
435
+ const budget = budgetByEpic.get(epicId);
436
+ if (budget !== undefined) {
437
+ tokensBudget += budget;
438
+ hasBudget = true;
439
+ }
440
+ }
441
+ const refs = opts.includeTaskRefs
442
+ ? milestoneTaskRefs(milestoneTasks, taskRows, edgeRows)
443
+ : undefined;
444
+ return {
445
+ milestoneId: m.milestoneId,
446
+ name: m.name,
447
+ status: m.status,
448
+ sequence: m.sequence,
449
+ goal: m.goal,
450
+ epicIds,
451
+ tasksTotal: milestoneTasks.length,
452
+ tasksCompleted,
453
+ tokensSpent,
454
+ tokensBudget: hasBudget ? tokensBudget : null,
455
+ project: m.project,
456
+ kind: m.kind,
457
+ ...(refs ? { recentDone: refs.recentDone, nextUp: refs.nextUp } : {}),
458
+ };
459
+ });
460
+ }
461
+ /** Roadmap page (§5.4): every milestone with its progress + mini-timeline, in roadmap.md order. */
462
+ export function roadmapPage(db, scope = {}) {
463
+ return milestoneProgressRows(db, scope, { includeTaskRefs: true });
464
+ }
465
+ const RECENT_DISPATCHES_LIMIT = 10;
466
+ function allFindingsForScope(db, scope) {
467
+ const sessionCond = scopedToSessions(findings.sessionId, scope);
468
+ const rows = sessionCond
469
+ ? db.select().from(findings).where(sessionCond).all()
470
+ : db.select().from(findings).all();
471
+ return filterByProject(rows, scope);
472
+ }
473
+ function allAgentsForScope(db, scope) {
474
+ // agents has no project column of its own (schema.ts) — scope it by
475
+ // membership in this scope's own task set (agents.taskId -> tasks.project),
476
+ // the same "derive via the owning task" approach projectFindings() uses.
477
+ const sessionCond = scopedToSessions(agents.sessionId, scope);
478
+ const liveRows = sessionCond
479
+ ? db
480
+ .select()
481
+ .from(agents)
482
+ .where(and(eq(agents.status, 'live'), sessionCond))
483
+ .all()
484
+ : db.select().from(agents).where(eq(agents.status, 'live')).all();
485
+ if (scope.project === undefined)
486
+ return liveRows;
487
+ const scopedTasks = allTasksForScope(db, scope);
488
+ const scopedEpics = new Set(scopedTasks.flatMap((t) => (t.epicId === null ? [] : [t.epicId])));
489
+ // A task OR an epic places a row. Half the dispatches in a real run name an
490
+ // epic and no task at all — a planner, a spec-reviewer, a scribe, the
491
+ // epic-close judges — and matching on the task alone dropped every one of
492
+ // them the moment a project was selected (D-234).
493
+ //
494
+ // taskIdsMatch, not Set.has: the log spells the same task both qualified
495
+ // and bare, and a spelling difference is not a different task (D-130/D-143).
496
+ return liveRows.filter((a) => {
497
+ if (a.epicId !== null && scopedEpics.has(a.epicId))
498
+ return true;
499
+ return a.taskId !== null && scopedTasks.some((t) => taskIdsMatch(a.taskId, t.taskId));
500
+ });
501
+ }
502
+ /**
503
+ * Every projected session, most recently active first.
504
+ *
505
+ * Why the Overview needs this at all (dogfood round 2): the "Now running"
506
+ * card was built from `liveAgentEntries`, and an `agents` row stays `live`
507
+ * until a terminal event closes it out — which never happens for a run that
508
+ * was killed, crashed, or simply ended without one. In the real
509
+ * state/smith.db that left the card permanently filled with rows dispatched
510
+ * days earlier, so it never changed no matter what the factory was doing.
511
+ * Sessions are the honest unit: `lastEventAt` moves every time anything at
512
+ * all is appended.
513
+ *
514
+ * Ties on `lastEventAt` break on `sessionId` so the order is stable under the
515
+ * dashboard's 5s poll rather than shuffling between renders.
516
+ */
517
+ export function runningSessions(db, scope = {}) {
518
+ const sessionCond = scopedToSessions(sessions.sessionId, scope);
519
+ const rows = sessionCond
520
+ ? db.select().from(sessions).where(sessionCond).all()
521
+ : db.select().from(sessions).all();
522
+ const liveBySession = new Map();
523
+ for (const a of allAgentsForScope(db, scope)) {
524
+ liveBySession.set(a.sessionId, (liveBySession.get(a.sessionId) ?? 0) + 1);
525
+ }
526
+ const projectsBySession = new Map();
527
+ for (const t of allTasksForScope(db, scope)) {
528
+ const set = projectsBySession.get(t.sessionId) ?? new Set();
529
+ set.add(projectOf(t.project));
530
+ projectsBySession.set(t.sessionId, set);
531
+ }
532
+ // What each session did most recently. events_raw is not guaranteed to come
533
+ // back in ts order, so the latest row is chosen by comparison rather than by
534
+ // trusting scan order — and isLaterEvent settles a tie on ts the same way
535
+ // pulse() does, which is what lets the two agree about one session.
536
+ const lastEvent = new Map();
537
+ const eventQuery = db
538
+ .select({
539
+ sessionId: eventsRaw.sessionId,
540
+ ts: eventsRaw.ts,
541
+ eventType: eventsRaw.eventType,
542
+ eventId: eventsRaw.eventId,
543
+ })
544
+ .from(eventsRaw);
545
+ const eventSessionCond = scopedToSessions(eventsRaw.sessionId, scope);
546
+ for (const e of eventSessionCond ? eventQuery.where(eventSessionCond).all() : eventQuery.all()) {
547
+ const seen = lastEvent.get(e.sessionId);
548
+ if (seen === undefined || isLaterEvent(e, seen))
549
+ lastEvent.set(e.sessionId, e);
550
+ }
551
+ return (rows
552
+ .map((s) => ({
553
+ sessionId: s.sessionId,
554
+ startedAt: s.startedAt,
555
+ lastEventAt: s.lastEventAt,
556
+ eventCount: s.eventCount,
557
+ liveAgentCount: liveBySession.get(s.sessionId) ?? 0,
558
+ lastEventType: lastEvent.get(s.sessionId)?.eventType ?? null,
559
+ projects: [...(projectsBySession.get(s.sessionId) ?? [])].sort(),
560
+ }))
561
+ // Under a project scope, a session belongs to the project only through
562
+ // its tasks — one with none (a run that has not planned anything yet) is
563
+ // not this project's business, and would otherwise show up in every
564
+ // project's Overview at once.
565
+ .filter((s) => scope.project === undefined || s.projects.length > 0)
566
+ .sort((a, b) => b.lastEventAt.localeCompare(a.lastEventAt) || a.sessionId.localeCompare(b.sessionId)));
567
+ }
568
+ function groupLiveAgents(rows) {
569
+ const grouped = new Map();
570
+ for (const row of rows) {
571
+ const key = `${row.agentRole}|${row.provider}|${row.modelTier}`;
572
+ const existing = grouped.get(key);
573
+ if (existing)
574
+ existing.count += 1;
575
+ else {
576
+ grouped.set(key, {
577
+ agentRole: row.agentRole,
578
+ provider: row.provider,
579
+ modelTier: row.modelTier,
580
+ count: 1,
581
+ });
582
+ }
583
+ }
584
+ return [...grouped.values()];
585
+ }
586
+ /**
587
+ * Every closed epic in scope, newest close first (D-43/P9-27). One reader for
588
+ * both overview() and projectSummary(), so "in flight" can never mean two
589
+ * different things depending on which card you're looking at.
590
+ */
591
+ function closedEpicsForScope(db, scope) {
592
+ const sessionCond = scopedToSessions(epics.sessionId, scope);
593
+ const rows = sessionCond
594
+ ? db.select().from(epics).where(sessionCond).all()
595
+ : db.select().from(epics).all();
596
+ return filterByProject(rows, scope)
597
+ .map((e) => ({
598
+ epicId: e.epicId,
599
+ closedBy: e.closedBy,
600
+ machineVerdict: e.machineVerdict,
601
+ machineReason: e.machineReason,
602
+ overrideRationale: e.overrideRationale,
603
+ blockers: e.blockers ? JSON.parse(e.blockers) : [],
604
+ closedAt: e.closedAt,
605
+ }))
606
+ .sort((a, b) => b.closedAt.localeCompare(a.closedAt));
607
+ }
608
+ /**
609
+ * D-43/P9-27: "in flight" used to mean "has a non-terminal task", full stop —
610
+ * so an epic closed by an operator overriding a hold (exactly the case where a
611
+ * task stays non-terminal) read as in flight forever. A close on the log ends
612
+ * the flight, whatever the task rows still say.
613
+ */
614
+ function inFlightEpics(taskRows, closed) {
615
+ const closedIds = new Set(closed.map((e) => e.epicId));
616
+ return [
617
+ ...new Set(taskRows
618
+ .filter((t) => t.epicId &&
619
+ NON_TERMINAL_TASK_STATUSES.includes(t.taskStatus) &&
620
+ !closedIds.has(t.epicId))
621
+ .map((t) => t.epicId)),
622
+ ].sort();
623
+ }
624
+ /** One project's overview slice, computed by the same logic overview() itself uses (no drift). */
625
+ function projectSummary(db, project, baseScope) {
626
+ const scope = { ...baseScope, project };
627
+ const liveRows = allAgentsForScope(db, scope);
628
+ const taskRows = allTasksForScope(db, scope);
629
+ const epicsInFlight = inFlightEpics(taskRows, closedEpicsForScope(db, scope));
630
+ const { budgetByEpic, spentByEpic } = epicTokenMaps(db, scope, taskRows);
631
+ const tokensSpent = [...spentByEpic.values()].reduce((s, v) => s + v, 0);
632
+ const tokensBudget = budgetByEpic.size > 0 ? [...budgetByEpic.values()].reduce((s, v) => s + v, 0) : null;
633
+ const escalations = taskRows.filter((t) => t.taskStatus === 'escalated').length;
634
+ const findingRows = allFindingsForScope(db, scope).filter((f) => (f.severity === 'S3-minor' || f.severity === 'S4-nit') &&
635
+ (f.findingStatus === 'raised' || f.findingStatus === 'confirmed'));
636
+ const pendingWaivers = findingRows.filter((f) => f.waiverId === null).length;
637
+ return {
638
+ project,
639
+ liveAgentCount: liveRows.length,
640
+ epicsInFlight,
641
+ tokensSpent,
642
+ tokensBudget,
643
+ alerts: { escalations, pendingWaivers },
644
+ };
645
+ }
646
+ // D-161: the fold's own alphabet, not a hand-copied subset of it. The list
647
+ // here used to omit `judge-reported`, so the slice fed to foldAgents() below
648
+ // contained judges being dispatched and never reporting — every judge that
649
+ // ever ran stayed live in the historical count, and the delta reported a drop
650
+ // that never happened.
651
+ const SNAPSHOT_EVENT_TYPES = REGISTRY_EVENT_TYPES;
652
+ /**
653
+ * Phase 6b StatCard deltas (6a DESIGN.md deviation closed): live-agent count
654
+ * as of `cutoffIso`, reconstructed by re-running agents-registry.ts's own
655
+ * fold over a `ts <=` slice of events_raw — a real point-in-time scan, not
656
+ * a fabricated trend (the fold logic is REUSED, not re-implemented, so a
657
+ * snapshot can never disagree with the live agents table's own semantics).
658
+ *
659
+ * Project scope is applied to the folded agents, not to the events going in
660
+ * — the same "derive via the owning task" route `allAgentsForScope()` takes
661
+ * for the live half of the delta. Filtering the events by their own
662
+ * `project` column instead (D-170) counted a different population at each
663
+ * end of the subtraction: a dispatch logged before Phase 6b carries no
664
+ * project, so its agent stayed in the live count and vanished from the
665
+ * historical one, and the StatCard reported an arrival that never happened.
666
+ */
667
+ function liveAgentCountAt(db, scope, cutoffIso) {
668
+ const conds = [lte(eventsRaw.ts, cutoffIso), inArray(eventsRaw.eventType, SNAPSHOT_EVENT_TYPES)];
669
+ const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
670
+ if (sessionCond)
671
+ conds.push(sessionCond);
672
+ const rows = inLogOrder(db
673
+ .select()
674
+ .from(eventsRaw)
675
+ .where(and(...conds))
676
+ .all());
677
+ const storedEvents = rows.map((r) => ({
678
+ event_id: r.eventId,
679
+ record: {
680
+ session_id: r.sessionId,
681
+ actor: r.actor ?? 'system',
682
+ event_type: r.eventType,
683
+ task_id: r.taskId ?? undefined,
684
+ agent_id: r.agentId ?? undefined,
685
+ plan_version: r.planVersion,
686
+ causal_parent: r.causalParent,
687
+ payload: JSON.parse(r.payload),
688
+ ts: r.ts,
689
+ },
690
+ }));
691
+ const live = foldLiveAgents(foldAgents(storedEvents));
692
+ if (scope.project === undefined)
693
+ return live.length;
694
+ const scopedTaskIds = new Set(allTasksForScope(db, scope).map((t) => t.taskId));
695
+ return live.filter((a) => a.taskId !== null && scopedTaskIds.has(a.taskId)).length;
696
+ }
697
+ /**
698
+ * Total tokens spent (task-result-recorded token_usage) across `epicByTask`'s tasks, as of `cutoffIso`.
699
+ *
700
+ * Project scope arrives through `epicByTask`, which is built from rows
701
+ * `allTasksForScope()` already filtered — the same route `epicTokenMaps()`
702
+ * takes for the "now" end of the delta. Do NOT also filter these events on
703
+ * their own `project` column (D-170): `task-result-recorded` rows logged
704
+ * before Phase 6b carry no project, so the extra filter dropped spend here
705
+ * that the "now" total still counted, and the subtraction invented a rise
706
+ * that never happened — visible only when a project was selected.
707
+ *
708
+ * For the same reason it resolves the task id through `epicResolver`, exactly
709
+ * as `epicTokenMaps()` does (D-207): the two functions are the two ends of one
710
+ * subtraction, and teaching only the "now" end to read a bare id would turn
711
+ * hours-old spend into a rise that never happened — the identical bug in a new
712
+ * disguise.
713
+ */
714
+ function tokensSpentAt(db, scope, cutoffIso, epicByTask) {
715
+ const conds = [eq(eventsRaw.eventType, 'task-result-recorded'), lte(eventsRaw.ts, cutoffIso)];
716
+ const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
717
+ if (sessionCond)
718
+ conds.push(sessionCond);
719
+ const rows = db
720
+ .select()
721
+ .from(eventsRaw)
722
+ .where(and(...conds))
723
+ .all();
724
+ const epicOf = epicResolver(epicByTask);
725
+ let total = 0;
726
+ for (const r of rows) {
727
+ const payload = JSON.parse(r.payload);
728
+ if (!epicOf(resultTaskId({ payload, envelopeTaskId: r.taskId })))
729
+ continue;
730
+ total += payload.token_usage?.total_tokens ?? 0;
731
+ }
732
+ return total;
733
+ }
734
+ /**
735
+ * Total budget across `epicByTask`'s tasks, as of `cutoffIso` — the other
736
+ * term of the 1h budget-used delta, read as of the same instant as the spend.
737
+ *
738
+ * `tasks.budgetTokens` has no history, but it needs none: exactly one event
739
+ * type ever writes it (projector.ts's `task-added` case, `p.budget_tokens ??
740
+ * row.budgetTokens`, keyed on the envelope's task id), so folding those rows
741
+ * up to the cutoff in the same order reproduces the column as it stood then.
742
+ *
743
+ * Scope and epic attribution arrive through `epicByTask`, exactly as in
744
+ * `tokensSpentAt` and for the reasons D-170 and D-207 record there: the two
745
+ * ends of one subtraction have to count the same population, and a term
746
+ * computed as-of-now against a term computed as-of-then reports a change that
747
+ * never happened. Here it was the denominator: a budget that grew during the
748
+ * hour retroactively shrank the historical percentage, so an epic that had
749
+ * burned 90% of its budget and then had a large task planned into it read as
750
+ * a one-point rise while the gauge in fact fell eighty.
751
+ */
752
+ function tokensBudgetedAt(db, scope, cutoffIso, epicByTask) {
753
+ const conds = [eq(eventsRaw.eventType, 'task-added'), lte(eventsRaw.ts, cutoffIso)];
754
+ const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
755
+ if (sessionCond)
756
+ conds.push(sessionCond);
757
+ const rows = inLogOrder(db
758
+ .select()
759
+ .from(eventsRaw)
760
+ .where(and(...conds))
761
+ .all());
762
+ const epicOf = epicResolver(epicByTask);
763
+ // Last write per task wins, as the projector's fold does — a re-plan that
764
+ // re-states a task carries the budget it has from then on. Which write is
765
+ // last is the log's answer, not `ts`'s: this read used to break the tie on
766
+ // `event_id` as text, so within one re-plan's burst the eleventh task-added
767
+ // sorted before the ninth and a task could keep the budget it had been
768
+ // re-planned out of.
769
+ const budgetByTask = new Map();
770
+ for (const r of rows) {
771
+ if (!r.taskId || !epicOf(r.taskId))
772
+ continue;
773
+ const payload = JSON.parse(r.payload);
774
+ if (payload.budget_tokens === undefined)
775
+ continue;
776
+ budgetByTask.set(r.taskId, payload.budget_tokens);
777
+ }
778
+ let total = 0;
779
+ for (const v of budgetByTask.values())
780
+ total += v;
781
+ return total;
782
+ }
783
+ export function overview(db, scope = {}, opts = {}) {
784
+ const liveRows = allAgentsForScope(db, scope);
785
+ const taskRows = allTasksForScope(db, scope);
786
+ const closedEpics = closedEpicsForScope(db, scope);
787
+ const epicsInFlight = inFlightEpics(taskRows, closedEpics);
788
+ const { budgetByEpic, spentByEpic } = epicTokenMaps(db, scope, taskRows);
789
+ const epicIds = new Set([...budgetByEpic.keys(), ...spentByEpic.keys()]);
790
+ const tokensByEpic = [...epicIds].sort().map((epicId) => ({
791
+ epicId,
792
+ tokensSpent: spentByEpic.get(epicId) ?? 0,
793
+ tokensBudget: budgetByEpic.get(epicId) ?? null,
794
+ }));
795
+ const escalations = taskRows.filter((t) => t.taskStatus === 'escalated').length;
796
+ const pendingWaiverFindings = allFindingsForScope(db, scope).filter((f) => (f.severity === 'S3-minor' || f.severity === 'S4-nit') &&
797
+ (f.findingStatus === 'raised' || f.findingStatus === 'confirmed'));
798
+ const pendingWaivers = pendingWaiverFindings.filter((f) => f.waiverId === null).length;
799
+ const dispatchSessionCond = scopedToSessions(dispatches.sessionId, scope);
800
+ const dispatchRows = dispatchSessionCond
801
+ ? db.select().from(dispatches).where(dispatchSessionCond).all()
802
+ : db.select().from(dispatches).all();
803
+ const scopedDispatches = filterByProject(dispatchRows, scope);
804
+ // Newest first, then the top ten — so the tie has to be broken before the
805
+ // slice, not left to the sort's stability. A wave admits its whole cohort
806
+ // inside one millisecond, and a comparison that returned 0 across all of
807
+ // them left the rows in the ascending order they arrived in: the ten this
808
+ // then kept were the *oldest* ten of the burst, under a heading that says
809
+ // recent.
810
+ const recentDispatches = inLogOrder(scopedDispatches)
811
+ .reverse()
812
+ .slice(0, RECENT_DISPATCHES_LIMIT)
813
+ .map((d) => ({
814
+ eventId: d.eventId,
815
+ ts: d.ts,
816
+ agentRole: d.agentRole,
817
+ provider: d.provider,
818
+ modelTier: d.modelTier,
819
+ taskId: d.taskId,
820
+ reason: d.reason,
821
+ }));
822
+ let projects;
823
+ if (scope.project === undefined) {
824
+ const taskSessionCond = scopedToSessions(tasks.sessionId, scope);
825
+ const allTaskRowsUnfiltered = taskSessionCond
826
+ ? db.select().from(tasks).where(taskSessionCond).all()
827
+ : db.select().from(tasks).all();
828
+ // Milestones too, not just tasks: roadmap.md's `- project:` bullet
829
+ // declares a project before any of its work is planned, and
830
+ // milestoneProgressRows() filters phases by that same bullet. This list
831
+ // is the whole of GET /api/projects, which is the whole of the UI's
832
+ // project switcher -- deriving it from `tasks` alone left a declared but
833
+ // unstarted project unselectable, so its phases showed only in "All
834
+ // projects" and the scope built to serve them could not be reached.
835
+ // Milestones carry no session_id and milestoneProgressRows() never
836
+ // filters them by one, so they join the list under a session scope too.
837
+ const declaredRows = db.select({ project: milestones.project }).from(milestones).all();
838
+ projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope));
839
+ }
840
+ const now = opts.nowIso ? new Date(opts.nowIso) : new Date();
841
+ const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
842
+ const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000).toISOString();
843
+ const liveAgentCountDelta5m = liveRows.length - liveAgentCountAt(db, scope, fiveMinAgo);
844
+ const epicByTask = new Map(taskRows.filter((t) => t.epicId !== null).map((t) => [t.taskId, t.epicId]));
845
+ const totalBudgetNow = [...budgetByEpic.values()].reduce((s, v) => s + v, 0);
846
+ const totalSpentNow = [...spentByEpic.values()].reduce((s, v) => s + v, 0);
847
+ let budgetUsedPctPointDelta1h = null;
848
+ if (totalBudgetNow > 0) {
849
+ const currentPct = (totalSpentNow / totalBudgetNow) * 100;
850
+ const spentAgo = tokensSpentAt(db, scope, oneHourAgo, epicByTask);
851
+ const budgetAgo = tokensBudgetedAt(db, scope, oneHourAgo, epicByTask);
852
+ // Nothing was budgeted an hour ago, so nothing was used: the card read
853
+ // "No budget set" then, and the honest reading of the move since is the
854
+ // whole of today's percentage. Dividing by today's budget instead would
855
+ // be the bug this guard exists to avoid, in its smallest form.
856
+ const pctAgo = budgetAgo > 0 ? (spentAgo / budgetAgo) * 100 : 0;
857
+ budgetUsedPctPointDelta1h = currentPct - pctAgo;
858
+ }
859
+ return {
860
+ liveAgents: groupLiveAgents(liveRows),
861
+ // Newest dispatch first — a live-agents card that's about to show many
862
+ // concurrent agents should surface the most recently-started ones up top.
863
+ liveAgentEntries: [...liveRows]
864
+ .sort((a, b) => b.dispatchedAt.localeCompare(a.dispatchedAt))
865
+ .map((a) => ({
866
+ id: a.id,
867
+ sessionId: a.sessionId,
868
+ agentRole: a.agentRole,
869
+ provider: a.provider,
870
+ modelTier: a.modelTier,
871
+ taskId: a.taskId,
872
+ epicId: a.epicId,
873
+ dispatchedAt: a.dispatchedAt,
874
+ })),
875
+ liveAgentCount: liveRows.length,
876
+ runningSessions: runningSessions(db, scope),
877
+ epicsInFlight,
878
+ closedEpics,
879
+ tokensByEpic,
880
+ alerts: { escalations, pendingWaivers },
881
+ milestoneProgress: milestoneProgressRows(db, scope),
882
+ recentDispatches,
883
+ liveAgentCountDelta5m,
884
+ budgetUsedPctPointDelta1h,
885
+ ...(projects ? { projects } : {}),
886
+ };
887
+ }
888
+ // ---------------------------------------------------------------------------
889
+ // timeline()
890
+ // ---------------------------------------------------------------------------
891
+ /**
892
+ * The timeline's event types that are NOT taxonomy values: `event_type` is a
893
+ * free string (event.schema.json), and these predate — or sit outside — the
894
+ * gate/graph dimensions.
895
+ *
896
+ * `lesson-status-changed` is here because of a fix-round finding (code review
897
+ * #11): the "Decisions" lens's DECISION_EVENT_TYPES includes it, but
898
+ * timeline()'s eventType filter runs BEFORE the lens, so a lesson decision
899
+ * could never reach the lens — or the plain Timeline page — at all.
900
+ *
901
+ * The seven below it were found the same way, one layer further out: the
902
+ * event-type lint (P9-37) knows which free types `src` writes, and comparing
903
+ * that set against this one showed seven written correctly, folded correctly,
904
+ * and dropped here — including `epic-closed`, the most consequential event in
905
+ * an epic's life. Over the factory's own logs that filter was hiding 117 of
906
+ * 461 recorded events. The list is now guarded in both directions by
907
+ * factory/orchestrator/test/eventTypes.test.ts, so it cannot fall behind the
908
+ * writers again without a red test naming the type.
909
+ *
910
+ * `operator-note` was the exception that guard cannot catch on its own, and it
911
+ * cost the most: the scan reads `src`, and this type is only ever written from
912
+ * outside it (`smith event append`), so nothing pointed at the gap while it
913
+ * grew into the third most common event in the factory's own logs. It carries
914
+ * the operator's reasoning in their own words — the one thing on the timeline
915
+ * a machine did not write — and none of it reached the screen. The guard now
916
+ * covers it because a human named it in FREE_EVENT_TYPES; that human step is
917
+ * load-bearing for every `writtenBy: 'cli'` type, which is why each one says so.
918
+ */
919
+ export const FREE_TIMELINE_EVENT_TYPES = [
920
+ 'user_prompt',
921
+ 'operator-note',
922
+ 'dispatch_decision',
923
+ 'error-logged',
924
+ 'lesson-status-changed',
925
+ 'session-start',
926
+ 'task-result-recorded',
927
+ 'judge-verdict',
928
+ 'judge-reported',
929
+ 'epic-closed',
930
+ 'lesson-candidate-raised',
931
+ 'lesson-edited',
932
+ // D-21 Part 4: the whole point of this event is that a reader must not see
933
+ // a clean amendment discharge without learning its obligation was repaired
934
+ // -- hiding the repair itself from the timeline would be the same failure
935
+ // one layer out.
936
+ 'finding-obligation-repaired',
937
+ // The independent finder's reconciliation. It is the one event that says a
938
+ // second, differently-vendored eye read this diff at all, and in shadow mode
939
+ // it is the ONLY thing the run produces -- gating nothing by definition. A
940
+ // shadow verdict nobody can see is a shadow deployment nobody can evaluate,
941
+ // which is the whole reason the mode exists.
942
+ 'cross-finding-reconciled',
943
+ // The scheduler's three proposals. Architecture §12 has the scheduler
944
+ // propose and the operator dispose, so the timeline is the only place the
945
+ // offer is ever made -- drop the row and the proposal is never put to
946
+ // anyone. They arrive through scheduler.ts eventTypeFor(), a helper return
947
+ // rather than a literal at the `event_type:` position, which is how all
948
+ // three stayed off this list without failing the P9-37 lint (Rule D).
949
+ 'recheck-proposed',
950
+ 'maintenance-proposed',
951
+ 'growth-review-due',
952
+ // The audit verbs (audit.ts, docs/specs/audit-command-scope.md §6). An
953
+ // audit runs against a project outside this factory and carries no task_id,
954
+ // so nothing else on the timeline would show that it happened; each verb's
955
+ // event is stamped with the project's name for the multi-project view.
956
+ 'audit-opened',
957
+ 'audit-finding-raised',
958
+ 'audit-decision',
959
+ 'audit-cut',
960
+ 'audit-resolved',
961
+ 'audit-closed',
962
+ ];
963
+ let cachedTaxonomy;
964
+ /**
965
+ * event_types rendered on the timeline: user prompts + dispatch decisions +
966
+ * gate/graph events, interleaved (architecture §7 "a hard requirement";
967
+ * §7 also requires "errors and gate results attach to the same timeline").
968
+ *
969
+ * Read off the taxonomy rather than hand-copied (P9-37). The copy had fallen
970
+ * eight gate events behind — `deps-check-result` had an icon and a title in
971
+ * ui/src/lib/timelineDisplay.ts that could never fire, because this filter
972
+ * dropped the row before the renderer saw it. A gate result the operator
973
+ * cannot see is one the factory may as well not have logged, and a list that
974
+ * has to be updated twice is a list that will be updated once.
975
+ *
976
+ * Exported since D-163: `smith event append` reads it to tell the operator, on
977
+ * the receipt, that the type they just wrote lands outside it. The write side
978
+ * is open on purpose and this side is closed on purpose; the export is what
979
+ * keeps the two from being two different lists.
980
+ */
981
+ export function timelineEventTypes() {
982
+ if (cachedTaxonomy === undefined)
983
+ cachedTaxonomy = loadTaxonomy();
984
+ return [
985
+ ...FREE_TIMELINE_EVENT_TYPES,
986
+ ...(cachedTaxonomy.dimensions.gate_event ?? []),
987
+ ...(cachedTaxonomy.dimensions.graph_event ?? []),
988
+ ];
989
+ }
990
+ function toEntry(row) {
991
+ return {
992
+ eventId: row.eventId,
993
+ ts: row.ts,
994
+ eventType: row.eventType,
995
+ taskId: row.taskId,
996
+ agentId: row.agentId,
997
+ planVersion: row.planVersion,
998
+ causalParent: row.causalParent,
999
+ payload: JSON.parse(row.payload),
1000
+ project: row.project,
1001
+ actor: row.actor,
1002
+ };
1003
+ }
1004
+ /**
1005
+ * Operator-actor decision event kinds (Timeline "Decisions" lens). Limited
1006
+ * to kinds this codebase's write paths attribute to the operator; the actor
1007
+ * is still checked below rather than trusted from the type alone, so a
1008
+ * system-authored event of one of these kinds is correctly excluded rather
1009
+ * than silently miscounted as a decision. `plan-version-created`/
1010
+ * `-superseded` ("plan sign-offs" per the operator's own phrasing) are
1011
+ * deliberately NOT included yet — this codebase has no dedicated event
1012
+ * distinguishing an operator's plan sign-off from the planner's own
1013
+ * automatic version cut, and inventing that distinction was out of this
1014
+ * task's scope; flagged in ui/docs/DESIGN.md rather than guessed.
1015
+ *
1016
+ * D-164: that actor check used to read `entry.actor === 'user'`. The default
1017
+ * is `'user'` only when a caller passes no actor at all — true of the UI, and
1018
+ * of nothing else. The CLI paths the operator guide documents pass
1019
+ * `--actor operator`, the operator's console passes `operator-skill`, and so
1020
+ * across all 668 events the factory has ever recorded the string `'user'`
1021
+ * never appears: 26 waiver and lesson decisions were on the timeline and the
1022
+ * lens returned 0 of them, on every session. `isOperatorActor` is now the one
1023
+ * place that answers this, shared with lessons.ts, which had answered it
1024
+ * differently.
1025
+ *
1026
+ * D-213: `operator-note` is the fourth kind, and it was the one worth the most.
1027
+ * D-153 taught the Prompts chip that "a person said this" means `user_prompt`
1028
+ * OR `operator-note`, because this factory's logs hold 0 of the first and 57 of
1029
+ * the second. This lens asks the stronger form of the same question and knew
1030
+ * only `user_prompt`, so over the real store it answered with 27 of the 103
1031
+ * rows it exists to show: every note the operator wrote gone, and the 19
1032
+ * dispatches those notes caused gone with them — a lens whose subject is the
1033
+ * operator's decisions, returning almost none of them. The type belongs here
1034
+ * rather than beside `user_prompt` above precisely because it needs the actor
1035
+ * check: `smith event append` is open on the write side, so an agent can write
1036
+ * one, and `operator-note` should be read as a decision only when the operator
1037
+ * is who wrote it.
1038
+ */
1039
+ const DECISION_EVENT_TYPES = new Set([
1040
+ 'waiver-granted',
1041
+ 'waiver-denied',
1042
+ 'lesson-status-changed',
1043
+ 'operator-note',
1044
+ ]);
1045
+ function isDecisionEntry(entry) {
1046
+ if (entry.eventType === 'user_prompt')
1047
+ return true;
1048
+ return DECISION_EVENT_TYPES.has(entry.eventType) && isOperatorActor(entry.actor);
1049
+ }
1050
+ function applyDecisionsLens(entries) {
1051
+ const decisionIds = new Set(entries.filter(isDecisionEntry).map((e) => e.eventId));
1052
+ return entries.filter((e) => {
1053
+ if (isDecisionEntry(e))
1054
+ return true;
1055
+ if (e.eventType !== 'dispatch_decision')
1056
+ return false;
1057
+ const parentPromptId = e.payload.parent_prompt_id;
1058
+ return ((parentPromptId !== undefined && decisionIds.has(parentPromptId)) ||
1059
+ (e.causalParent !== null && decisionIds.has(e.causalParent)));
1060
+ });
1061
+ }
1062
+ /**
1063
+ * Walk causal_parent pointers back to the root, oldest-first.
1064
+ *
1065
+ * P9-7: the walk crosses session boundaries. Event ids are globally unique
1066
+ * (`<session-id>#<index>`), so a hop needs the id alone; constraining every
1067
+ * lookup to `sessionId` used to stop the chain dead at the session root of a
1068
+ * continued epic — the projection reported a two-event history for a chain
1069
+ * that ran back through three sessions. `sessionId` still gates the FIRST
1070
+ * lookup, so asking for a chain that does not start in the named session
1071
+ * remains an empty answer rather than a silent redirect.
1072
+ */
1073
+ function causalChain(db, sessionId, eventId) {
1074
+ const chain = [];
1075
+ let currentId = eventId;
1076
+ const seen = new Set();
1077
+ let first = true;
1078
+ while (currentId && !seen.has(currentId)) {
1079
+ seen.add(currentId);
1080
+ const idMatch = eq(eventsRaw.eventId, currentId);
1081
+ const row = db
1082
+ .select()
1083
+ .from(eventsRaw)
1084
+ .where(first ? and(idMatch, eq(eventsRaw.sessionId, sessionId)) : idMatch)
1085
+ .get();
1086
+ if (!row)
1087
+ break;
1088
+ first = false;
1089
+ chain.unshift(toEntry(row));
1090
+ currentId = row.causalParent;
1091
+ }
1092
+ return chain;
1093
+ }
1094
+ /**
1095
+ * Which epic an entry belongs to, or null when it names none.
1096
+ *
1097
+ * The task id decides it whenever it qualifies one, because `<epic>/<task>` is
1098
+ * the row's own identity and a payload is only ever a claim about it. Asked
1099
+ * through `epicOfTaskId`, not `split('/')[0]`: this filter was one of the call
1100
+ * sites D-49 wrote that module for, and it still spelled the derivation by
1101
+ * hand, so a bare `task-2-path-guard` answered "epic task-2-path-guard".
1102
+ *
1103
+ * The fallback is the actual defect (D-206). The events an operator opens an
1104
+ * epic timeline *for* -- `plan-version-created`, `wave-admitted`,
1105
+ * `wave-merged` -- belong to the epic rather than to any one task, so they
1106
+ * carry no task id and were filtered out of the one lens built to show them.
1107
+ * Those name their epic in the payload instead. An entry with neither drops
1108
+ * out, which is the honest answer for a legacy row that recorded neither.
1109
+ */
1110
+ function epicOfEntry(entry) {
1111
+ const fromTaskId = entry.taskId === null ? null : epicOfTaskId(entry.taskId);
1112
+ if (fromTaskId !== null)
1113
+ return fromTaskId;
1114
+ const fromPayload = entry.payload.epic_id;
1115
+ return typeof fromPayload === 'string' ? fromPayload : null;
1116
+ }
1117
+ export function timeline(db, filter = {}) {
1118
+ if (filter.causalChainFor) {
1119
+ if (!filter.sessionId) {
1120
+ throw new RangeError('timeline(): causalChainFor requires sessionId to be set.');
1121
+ }
1122
+ return causalChain(db, filter.sessionId, filter.causalChainFor);
1123
+ }
1124
+ const eventTypes = filter.eventTypes ?? timelineEventTypes();
1125
+ const conditions = [inArray(eventsRaw.eventType, eventTypes)];
1126
+ const sessionCond = scopedToSessions(eventsRaw.sessionId, filter);
1127
+ if (sessionCond)
1128
+ conditions.push(sessionCond);
1129
+ if (filter.taskId)
1130
+ conditions.push(eq(eventsRaw.taskId, filter.taskId));
1131
+ const rows = inLogOrder(db
1132
+ .select()
1133
+ .from(eventsRaw)
1134
+ .where(and(...conditions))
1135
+ .all());
1136
+ let entries = rows.map(toEntry);
1137
+ entries = filterByProject(entries, filter);
1138
+ if (filter.epicId)
1139
+ entries = entries.filter((e) => epicOfEntry(e) === filter.epicId);
1140
+ if (filter.decisionsOnly)
1141
+ entries = applyDecisionsLens(entries);
1142
+ return entries;
1143
+ }
1144
+ // D-127: `amend-pending` is open. The amendment has been written but the tasks
1145
+ // it obligates have not landed, so nothing is discharged yet. This query has no
1146
+ // obligation data — that lives in epic.ts's summary, which checks each named
1147
+ // task id — so it treats the status as unconditionally open: correct for a
1148
+ // per-task severity chip, and it fails closed.
1149
+ const OPEN_FINDING_STATUSES = new Set([
1150
+ 'raised',
1151
+ 'confirmed',
1152
+ 'fix-pending',
1153
+ 'fix-landed',
1154
+ 'amend-pending',
1155
+ ]);
1156
+ function worstSeverity(severities) {
1157
+ let worst = null;
1158
+ let worstIndex = Number.POSITIVE_INFINITY;
1159
+ for (const s of severities) {
1160
+ // Not worseSeverity(): this list comes out of SQL and may hold a severity a
1161
+ // taxonomy edit has since retired. A chip on a Kanban card degrades to
1162
+ // "no severity" for one; a reconciliation must not, so that one throws.
1163
+ const index = severityRank(s);
1164
+ if (index !== null && index < worstIndex) {
1165
+ worstIndex = index;
1166
+ worst = s;
1167
+ }
1168
+ }
1169
+ return worst;
1170
+ }
1171
+ /**
1172
+ * Tasks for one epic (or, Phase 6b, every epic when `epicId` is omitted —
1173
+ * closes the 6a DESIGN.md "no all-epics mode" deviation), grouped by
1174
+ * task_status, tagged with case/origin/worst-open-finding severity plus
1175
+ * (Phase 6b) title/agent-role/milestone.
1176
+ */
1177
+ export function kanban(db, epicId, scope = {}) {
1178
+ const epicCond = epicId !== undefined ? eq(tasks.epicId, epicId) : undefined;
1179
+ const sessionCond = scopedToSessions(tasks.sessionId, scope);
1180
+ const taskConds = [epicCond, sessionCond].filter((c) => c !== undefined);
1181
+ const taskRows = filterByProject(taskConds.length > 0
1182
+ ? db
1183
+ .select()
1184
+ .from(tasks)
1185
+ .where(and(...taskConds))
1186
+ .all()
1187
+ : db.select().from(tasks).all(), scope);
1188
+ const epicIdsInScope = new Set(taskRows.map((t) => t.epicId).filter((e) => e !== null));
1189
+ const findingCond = epicId !== undefined ? eq(findings.epicId, epicId) : undefined;
1190
+ const findingRows = findingCond
1191
+ ? db.select().from(findings).where(findingCond).all()
1192
+ : db.select().from(findings).all();
1193
+ const openSeverityByTask = new Map();
1194
+ for (const f of findingRows) {
1195
+ if (f.epicId !== null && !epicIdsInScope.has(f.epicId))
1196
+ continue;
1197
+ if (!OPEN_FINDING_STATUSES.has(f.findingStatus))
1198
+ continue;
1199
+ const list = openSeverityByTask.get(f.taskId) ?? [];
1200
+ list.push(f.severity);
1201
+ openSeverityByTask.set(f.taskId, list);
1202
+ }
1203
+ // Latest dispatch per task_id (agentRole + modelTier fields — operator
1204
+ // directive 2, Phase 6b round 3: Kanban's "subagent + model" chip. Same
1205
+ // minimal-widening pattern as the earlier agentRole/title/milestoneId
1206
+ // additions in this function, disclosed the same way.
1207
+ const dispatchSessionCond = scopedToSessions(dispatches.sessionId, scope);
1208
+ const dispatchRows = dispatchSessionCond
1209
+ ? db.select().from(dispatches).where(dispatchSessionCond).all()
1210
+ : db.select().from(dispatches).all();
1211
+ const latestAgentRoleByTask = new Map();
1212
+ for (const d of dispatchRows) {
1213
+ if (!d.taskId)
1214
+ continue;
1215
+ const existing = latestAgentRoleByTask.get(d.taskId);
1216
+ if (!existing || isLaterEvent(d, existing))
1217
+ latestAgentRoleByTask.set(d.taskId, {
1218
+ ts: d.ts,
1219
+ eventId: d.eventId,
1220
+ agentRole: d.agentRole,
1221
+ modelTier: d.modelTier,
1222
+ });
1223
+ }
1224
+ // epicId -> milestoneId, from roadmap.md's milestones table.
1225
+ const milestoneRows = db.select().from(milestones).all();
1226
+ const milestoneByEpic = new Map();
1227
+ for (const m of milestoneRows) {
1228
+ for (const e of JSON.parse(m.epicIds))
1229
+ milestoneByEpic.set(e, m.milestoneId);
1230
+ }
1231
+ const columns = new Map();
1232
+ for (const t of taskRows) {
1233
+ const column = columns.get(t.taskStatus) ?? [];
1234
+ column.push({
1235
+ taskId: t.taskId,
1236
+ taskStatus: t.taskStatus,
1237
+ title: t.objective,
1238
+ agentRole: latestAgentRoleByTask.get(t.taskId)?.agentRole ?? null,
1239
+ agentModelTier: latestAgentRoleByTask.get(t.taskId)?.modelTier ?? null,
1240
+ milestoneId: t.epicId ? (milestoneByEpic.get(t.epicId) ?? null) : null,
1241
+ tags: {
1242
+ case: t.caseTag,
1243
+ origin: t.origin,
1244
+ severity: worstSeverity(openSeverityByTask.get(t.taskId) ?? []),
1245
+ },
1246
+ });
1247
+ columns.set(t.taskStatus, column);
1248
+ }
1249
+ return [...columns.entries()]
1250
+ .sort(([a], [b]) => a.localeCompare(b))
1251
+ .map(([taskStatus, tasksInColumn]) => ({ taskStatus, tasks: tasksInColumn }));
1252
+ }
1253
+ export function taskDetail(db, taskId) {
1254
+ const task = db.select().from(tasks).where(eq(tasks.taskId, taskId)).get();
1255
+ if (!task)
1256
+ return null;
1257
+ const dispatchRows = inLogOrder(db.select().from(dispatches).where(eq(dispatches.taskId, taskId)).all());
1258
+ const agentRows = db.select().from(agents).where(eq(agents.taskId, taskId)).all();
1259
+ const agentByEventId = new Map(agentRows.map((a) => [a.id, a]));
1260
+ const attempts = dispatchRows.map((d) => {
1261
+ const agent = agentByEventId.get(d.eventId);
1262
+ return {
1263
+ eventId: d.eventId,
1264
+ ts: d.ts,
1265
+ agentRole: d.agentRole,
1266
+ provider: d.provider,
1267
+ modelTier: d.modelTier,
1268
+ agentStatus: agent?.status ?? null,
1269
+ terminalAt: agent?.terminalAt ?? null,
1270
+ };
1271
+ });
1272
+ const findingRows = db.select().from(findings).where(eq(findings.taskId, taskId)).all();
1273
+ const artifactRows = db.select().from(artifacts).where(eq(artifacts.taskId, taskId)).all();
1274
+ return {
1275
+ task,
1276
+ claims: task.claims ? JSON.parse(task.claims) : [],
1277
+ attempts,
1278
+ agents: agentRows,
1279
+ findings: findingRows,
1280
+ artifacts: artifactRows,
1281
+ branch: task.branch,
1282
+ };
1283
+ }
1284
+ /**
1285
+ * Every `lesson_status` the taxonomy declares, mapped to the bucket the
1286
+ * Lessons page renders it in. Total on purpose (D-220).
1287
+ *
1288
+ * Buckets used to be two filters over three of the six statuses, so
1289
+ * `novelty-rejected`, `superseded`, and `invalidated` rows reached no surface
1290
+ * at all — not even the filter labelled "All". That silently undid two
1291
+ * contracts: lessons.ts raises a near-duplicate and transitions it to
1292
+ * `novelty-rejected` precisely so it is "never silently dropped"
1293
+ * (architecture §9.3), and §9.6 calls an invalidated lesson a "traceable
1294
+ * rollback, never silent deletion". It also made the page's own Reject button
1295
+ * look broken — the row it wrote vanished instead of closing.
1296
+ *
1297
+ * factory/orchestrator/test/db/queries.test.ts asserts these keys equal
1298
+ * factory/policies/taxonomy.yml's `lesson_status`, so a seventh status cannot
1299
+ * be declared without landing here.
1300
+ */
1301
+ export const LESSON_BUCKET_FOR_STATUS = {
1302
+ candidate: 'pending',
1303
+ 'pending-approval': 'pending',
1304
+ approved: 'approved',
1305
+ 'novelty-rejected': 'closed',
1306
+ superseded: 'closed',
1307
+ invalidated: 'closed',
1308
+ };
1309
+ export function lessonsPage(db, scope = {}) {
1310
+ const sessionCond = scopedToSessions(lessons.sessionId, scope);
1311
+ const rows = sessionCond
1312
+ ? db.select().from(lessons).where(sessionCond).all()
1313
+ : db.select().from(lessons).all();
1314
+ const result = { pending: [], approved: [], closed: [] };
1315
+ for (const row of rows) {
1316
+ // An unrecognised status lands in `closed` rather than nowhere: being
1317
+ // invisible is the defect this map exists to close, and a row the
1318
+ // operator can see and question beats a row that does not exist.
1319
+ result[LESSON_BUCKET_FOR_STATUS[row.lessonStatus] ?? 'closed'].push(row);
1320
+ }
1321
+ return result;
1322
+ }
1323
+ export function errorsPage(db, scope = {}) {
1324
+ const sessionCond = scopedToSessions(errors.sessionId, scope);
1325
+ const allRows = sessionCond
1326
+ ? db.select().from(errors).where(sessionCond).all()
1327
+ : db.select().from(errors).all();
1328
+ const rows = filterByProject(allRows, scope);
1329
+ const byClass = new Map();
1330
+ const byDay = new Map();
1331
+ for (const row of rows) {
1332
+ const key = `${row.errorGroup}.${row.errorClass}|${row.severity}`;
1333
+ const existing = byClass.get(key);
1334
+ if (existing)
1335
+ existing.count += 1;
1336
+ else {
1337
+ byClass.set(key, {
1338
+ id: key,
1339
+ errorGroup: row.errorGroup,
1340
+ errorClass: row.errorClass,
1341
+ severity: row.severity,
1342
+ count: 1,
1343
+ });
1344
+ }
1345
+ const day = row.ts.slice(0, 10);
1346
+ byDay.set(day, (byDay.get(day) ?? 0) + 1);
1347
+ }
1348
+ return {
1349
+ byClass: [...byClass.values()].sort((a, b) => b.count - a.count),
1350
+ byDay: [...byDay.entries()]
1351
+ .sort(([a], [b]) => a.localeCompare(b))
1352
+ .map(([day, count]) => ({ day, count })),
1353
+ };
1354
+ }
1355
+ export function analytics(db, scope = {}) {
1356
+ const taskRows = allTasksForScope(db, scope);
1357
+ const inScope = taskInScope(db, scope);
1358
+ const throughputByDay = new Map();
1359
+ for (const t of taskRows) {
1360
+ if (t.taskStatus !== 'completed')
1361
+ continue;
1362
+ const day = t.updatedAt.slice(0, 10);
1363
+ throughputByDay.set(day, (throughputByDay.get(day) ?? 0) + 1);
1364
+ }
1365
+ const throughput = [...throughputByDay.entries()]
1366
+ .sort(([a], [b]) => a.localeCompare(b))
1367
+ .map(([day, completed]) => ({ day, completed }));
1368
+ const eventSessionCond = scopedToSessions(eventsRaw.sessionId, scope);
1369
+ const resultRows = eventSessionCond
1370
+ ? db
1371
+ .select({ taskId: eventsRaw.taskId, payload: eventsRaw.payload })
1372
+ .from(eventsRaw)
1373
+ .where(and(eq(eventsRaw.eventType, 'task-result-recorded'), eventSessionCond))
1374
+ .all()
1375
+ : db
1376
+ .select({ taskId: eventsRaw.taskId, payload: eventsRaw.payload })
1377
+ .from(eventsRaw)
1378
+ .where(eq(eventsRaw.eventType, 'task-result-recorded'))
1379
+ .all();
1380
+ const costBuckets = new Map();
1381
+ for (const row of resultRows) {
1382
+ const p = JSON.parse(row.payload);
1383
+ // Column first, payload second: both spellings occur, and one real row
1384
+ // omits `payload.task_id` while the column carries it.
1385
+ if (!inScope(row.taskId ?? p.task_id))
1386
+ continue;
1387
+ if (!p.model_tier || !p.provider)
1388
+ continue;
1389
+ const key = `${p.model_tier}|${p.provider}`;
1390
+ const bucket = costBuckets.get(key) ?? { taskCount: 0, totalTokens: 0 };
1391
+ bucket.taskCount += 1;
1392
+ bucket.totalTokens += p.token_usage?.total_tokens ?? 0;
1393
+ costBuckets.set(key, bucket);
1394
+ }
1395
+ const costByModelTierAndProvider = [...costBuckets.entries()].map(([key, v]) => {
1396
+ const [modelTier, provider] = key.split('|');
1397
+ return {
1398
+ modelTier,
1399
+ provider,
1400
+ taskCount: v.taskCount,
1401
+ totalTokens: v.totalTokens,
1402
+ avgTokensPerTask: v.taskCount > 0 ? v.totalTokens / v.taskCount : 0,
1403
+ };
1404
+ });
1405
+ const decisionRows = eventSessionCond
1406
+ ? db
1407
+ .select({ ts: eventsRaw.ts, taskId: eventsRaw.taskId, payload: eventsRaw.payload })
1408
+ .from(eventsRaw)
1409
+ .where(and(eq(eventsRaw.eventType, 'severity-decisions'), eventSessionCond))
1410
+ .all()
1411
+ : db
1412
+ .select({ ts: eventsRaw.ts, taskId: eventsRaw.taskId, payload: eventsRaw.payload })
1413
+ .from(eventsRaw)
1414
+ .where(eq(eventsRaw.eventType, 'severity-decisions'))
1415
+ .all();
1416
+ const sameMistakeByDay = new Map();
1417
+ for (const row of decisionRows) {
1418
+ // The payload names no task at all — gate.ts puts the id in the column.
1419
+ if (!inScope(row.taskId))
1420
+ continue;
1421
+ const p = JSON.parse(row.payload);
1422
+ const day = row.ts.slice(0, 10);
1423
+ const bucket = sameMistakeByDay.get(day) ?? { decisions: 0, sameMistake: 0 };
1424
+ for (const d of p.decisions ?? []) {
1425
+ bucket.decisions += 1;
1426
+ if (d.same_mistake)
1427
+ bucket.sameMistake += 1;
1428
+ }
1429
+ sameMistakeByDay.set(day, bucket);
1430
+ }
1431
+ const sameMistakeRateByDay = [...sameMistakeByDay.entries()]
1432
+ .sort(([a], [b]) => a.localeCompare(b))
1433
+ .map(([day, v]) => ({
1434
+ day,
1435
+ decisions: v.decisions,
1436
+ sameMistake: v.sameMistake,
1437
+ rate: v.decisions > 0 ? v.sameMistake / v.decisions : null,
1438
+ }));
1439
+ const recheckByStatus = new Map();
1440
+ for (const t of taskRows) {
1441
+ if (t.origin !== 'recheck')
1442
+ continue;
1443
+ recheckByStatus.set(t.taskStatus, (recheckByStatus.get(t.taskStatus) ?? 0) + 1);
1444
+ }
1445
+ const recheckOutcomes = [...recheckByStatus.entries()]
1446
+ .sort(([a], [b]) => a.localeCompare(b))
1447
+ .map(([taskStatus, count]) => ({ taskStatus, count }));
1448
+ return {
1449
+ throughput,
1450
+ costByModelTierAndProvider,
1451
+ sameMistakeRateByDay,
1452
+ recheckOutcomes,
1453
+ // Same db, same scope: a project-scoped page gets project-scoped judge
1454
+ // stats, and no second round trip is needed to render the card.
1455
+ providerAgreement: providerAgreement(db, scope),
1456
+ };
1457
+ }
1458
+ /**
1459
+ * The active plan's task DAG (Flow page): nodes = tasks (+ live
1460
+ * agent, if one is currently dispatched on that task), edges = dependency
1461
+ * edges, waves = graph.ts's own longest-path layering (docs/standards/
1462
+ * stack.md: "no separate layout library" — this IS that orchestrator
1463
+ * utility, exposed via this query/endpoint per the same doc's instruction).
1464
+ */
1465
+ export function flowGraph(db, filter = {}) {
1466
+ let taskRows = allTasksForScope(db, filter);
1467
+ if (filter.epicId)
1468
+ taskRows = taskRows.filter((t) => t.epicId === filter.epicId);
1469
+ // The picker's options are read off the scoped tasks BEFORE the filter
1470
+ // below narrows them — see FlowGraph.planVersions.
1471
+ const planVersions = [
1472
+ ...new Set(taskRows.map((t) => t.planVersion).filter((v) => v !== null)),
1473
+ ].sort((a, b) => b - a);
1474
+ // D-167: `tasks.plan_version` is stamped once, off the task's `task-added`,
1475
+ // so it records the version the task ENTERED at — not the one version it
1476
+ // belongs to. Every amendment carries its unfinished tasks forward
1477
+ // (`draftNextVersion`) and re-emits `task-added` only for the ones it adds,
1478
+ // so a task that entered at v1 is still in v2's plan file wearing a v1
1479
+ // stamp. Membership is therefore "entered at or before", and matching the
1480
+ // stamp exactly hid three of envkit-mcp-followup's four tasks the moment its
1481
+ // v2 added a fourth.
1482
+ //
1483
+ // What this cannot express is departure. `draftNextVersion` drops completed
1484
+ // tasks from the live backlog and a supersede that renames an id leaves the
1485
+ // old one dead, both recorded in the amendment's `diff` — and nothing
1486
+ // projects that diff, so a task that left at v3 still reads as present at
1487
+ // v3. That over-includes, which is the direction to err in: the plan file's
1488
+ // `edges` array carries every earlier version's edges forward, so a node set
1489
+ // narrower than "everything that ever entered" leaves arrows pointing at
1490
+ // tasks the page is not drawing.
1491
+ const belongsTo = (t, version) => t.planVersion !== null && t.planVersion <= version;
1492
+ if (filter.planVersion !== undefined) {
1493
+ const version = filter.planVersion;
1494
+ taskRows = taskRows.filter((t) => belongsTo(t, version));
1495
+ }
1496
+ else {
1497
+ // D-165: "the active plan" is a property of an epic, not of the store.
1498
+ // Taking one global max meant a single re-planned epic's v2 evicted every
1499
+ // other epic's v1 — on the factory's own log that left 1 of 15 tasks on
1500
+ // the page. Each epic now answers for its own latest plan.
1501
+ const latestByEpic = new Map();
1502
+ for (const t of taskRows) {
1503
+ if (t.planVersion === null)
1504
+ continue;
1505
+ const key = t.epicId ?? '';
1506
+ const max = latestByEpic.get(key);
1507
+ if (max === undefined || t.planVersion > max)
1508
+ latestByEpic.set(key, t.planVersion);
1509
+ }
1510
+ // A task whose `task-added` carried no plan_version was never assigned to
1511
+ // a plan, so no re-plan can have superseded it. Dropping it hid it at
1512
+ // every setting of the picker — the Flow page is the only view of the DAG,
1513
+ // so there is no other surface on which it would have been noticed.
1514
+ taskRows = taskRows.filter((t) => {
1515
+ if (t.planVersion === null)
1516
+ return true;
1517
+ const latest = latestByEpic.get(t.epicId ?? '');
1518
+ return latest !== undefined && belongsTo(t, latest);
1519
+ });
1520
+ }
1521
+ const taskIds = new Set(taskRows.map((t) => t.taskId));
1522
+ const edgeSessionCond = scopedToSessions(edges.sessionId, filter);
1523
+ const edgeRows = edgeSessionCond
1524
+ ? db.select().from(edges).where(edgeSessionCond).all()
1525
+ : db.select().from(edges).all();
1526
+ const scopedEdges = edgeRows.filter((e) => taskIds.has(e.taskId) && taskIds.has(e.dependsOn));
1527
+ const liveRows = allAgentsForScope(db, filter);
1528
+ const liveRoleByTask = new Map();
1529
+ for (const a of liveRows) {
1530
+ if (a.taskId)
1531
+ liveRoleByTask.set(a.taskId, a.agentRole);
1532
+ }
1533
+ const waveMap = waveLayers([...taskIds], scopedEdges.map((e) => ({ task: e.taskId, dependsOn: e.dependsOn })));
1534
+ const nodes = taskRows.map((t) => ({
1535
+ taskId: t.taskId,
1536
+ taskStatus: t.taskStatus,
1537
+ title: t.objective,
1538
+ liveAgentRole: liveRoleByTask.get(t.taskId) ?? null,
1539
+ planVersion: t.planVersion,
1540
+ wave: waveMap.get(t.taskId) ?? 0,
1541
+ }));
1542
+ const waveBuckets = new Map();
1543
+ for (const n of nodes) {
1544
+ const list = waveBuckets.get(n.wave) ?? [];
1545
+ list.push(n.taskId);
1546
+ waveBuckets.set(n.wave, list);
1547
+ }
1548
+ const waves = [...waveBuckets.entries()].sort(([a], [b]) => a - b).map(([, ids]) => ids.sort());
1549
+ return {
1550
+ nodes,
1551
+ edges: scopedEdges.map((e) => ({
1552
+ task: e.taskId,
1553
+ dependsOn: e.dependsOn,
1554
+ edgeType: e.edgeType,
1555
+ edgeProvenance: e.edgeProvenance,
1556
+ })),
1557
+ waves,
1558
+ planVersions,
1559
+ };
1560
+ }
1561
+ /** Per-provider judge-run calibration stats, read straight off `judge-verdict` events (quorum.ts's recordJudgeRun()) — no dedicated projection table (YAGNI: eventsRaw already carries every event untouched, same pattern analytics() uses for task-result-recorded). */
1562
+ export function providerAgreement(db, scope = {}, opts = {}) {
1563
+ const conds = [eq(eventsRaw.eventType, 'judge-verdict')];
1564
+ const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
1565
+ if (sessionCond)
1566
+ conds.push(sessionCond);
1567
+ if (opts.since)
1568
+ conds.push(gte(eventsRaw.ts, opts.since));
1569
+ const rows = db
1570
+ .select({ payload: eventsRaw.payload, project: eventsRaw.project })
1571
+ .from(eventsRaw)
1572
+ .where(and(...conds))
1573
+ .all();
1574
+ const scoped = filterByProject(rows, scope);
1575
+ const byProvider = new Map();
1576
+ for (const row of scoped) {
1577
+ const p = JSON.parse(row.payload);
1578
+ if (!p.provider)
1579
+ continue;
1580
+ const bucket = byProvider.get(p.provider) ?? {
1581
+ runs: 0,
1582
+ verdicts: 0,
1583
+ agreements: 0,
1584
+ latencySum: 0,
1585
+ latencyCount: 0,
1586
+ schemaFailures: 0,
1587
+ transportFailures: 0,
1588
+ failuresByCode: new Map(),
1589
+ };
1590
+ bucket.runs += 1;
1591
+ // D-168: `schema_failure` is the one predicate for "this run never came
1592
+ // back with a verdict", so it decides both counters. recordJudgeRun() now
1593
+ // writes `agreement_with_native: null` on a failed run — there was nothing
1594
+ // to compare — but every row written before that carries `false`, and those
1595
+ // are on the log for good. Reading either as a disagreement is the reader's
1596
+ // mistake to avoid, which is why the branch below never looks at the field
1597
+ // on a run that reached no verdict.
1598
+ if (p.schema_failure) {
1599
+ // D-253: which kind of failure is the error code's answer, not this
1600
+ // boolean's — it is true for an answer that would not parse and equally
1601
+ // true for a request that was never sent. A pre-D-253 row has no code,
1602
+ // so it is counted as a failure and named `unclassified` rather than
1603
+ // assigned to a rate on a guess.
1604
+ const code = p.error_code ?? null;
1605
+ bucket.failuresByCode.set(code ?? 'unclassified', (bucket.failuresByCode.get(code ?? 'unclassified') ?? 0) + 1);
1606
+ if (code !== null) {
1607
+ if (judgeFailureKind(code) === 'schema')
1608
+ bucket.schemaFailures += 1;
1609
+ else
1610
+ bucket.transportFailures += 1;
1611
+ }
1612
+ }
1613
+ else {
1614
+ bucket.verdicts += 1;
1615
+ if (p.agreement_with_native)
1616
+ bucket.agreements += 1;
1617
+ }
1618
+ if (typeof p.latency_ms === 'number') {
1619
+ bucket.latencySum += p.latency_ms;
1620
+ bucket.latencyCount += 1;
1621
+ }
1622
+ byProvider.set(p.provider, bucket);
1623
+ }
1624
+ return [...byProvider.entries()]
1625
+ .sort(([a], [b]) => a.localeCompare(b))
1626
+ .map(([provider, b]) => ({
1627
+ provider,
1628
+ runs: b.runs,
1629
+ verdicts: b.verdicts,
1630
+ // Each rate divides by the observations it actually has, and says `null`
1631
+ // when it has none. The old code divided both by `runs`, which turned
1632
+ // the factory's five all-failing deepseek runs into "agrees 0% of the
1633
+ // time, answers in 0ms" — a dissent and a speed record, both off zero
1634
+ // observations. The two failure rates keep `runs` as their denominator
1635
+ // because every run is evidence for them: a run either reached a verdict
1636
+ // or failed in one of two nameable ways, and all three are recorded.
1637
+ // They do not sum to 1 minus the answer rate when pre-D-253 rows are in
1638
+ // the window — those are in `failuresByCode` under `unclassified` and
1639
+ // nowhere else.
1640
+ agreementRate: b.verdicts > 0 ? b.agreements / b.verdicts : null,
1641
+ latencySamples: b.latencyCount,
1642
+ meanLatencyMs: b.latencyCount > 0 ? b.latencySum / b.latencyCount : null,
1643
+ schemaFailureRate: b.runs > 0 ? b.schemaFailures / b.runs : 0,
1644
+ transportFailureRate: b.runs > 0 ? b.transportFailures / b.runs : 0,
1645
+ failuresByCode: Object.fromEntries(b.failuresByCode),
1646
+ }));
1647
+ }
1648
+ /**
1649
+ * One cheap read the app shell polls from every page: "is the factory still
1650
+ * moving, and what has arrived since I looked?"
1651
+ *
1652
+ * It exists so liveness is a *shell* fact rather than an Overview fact. Every
1653
+ * page polls its own data, and on nine of them a frozen server was
1654
+ * indistinguishable from a quiet factory — which is precisely the confusion
1655
+ * ui/src/lib/liveness.ts was written to end, on the one page that had it.
1656
+ *
1657
+ * This changes nothing about the transport: design-spec.md §8 ("No
1658
+ * WebSockets") still holds, and this is the same polling contract asked once
1659
+ * for the frame instead of once more per page. It is deliberately not a push
1660
+ * and deliberately not a toast — an arrival the operator did not cause
1661
+ * belongs on a surface they can come back to, not in something that expires.
1662
+ *
1663
+ * Column-projected on purpose: `events_raw.payload` holds every event body,
1664
+ * and nothing here reads one, so a 5s shell poll never pulls the log's bodies
1665
+ * into memory just to count its rows.
1666
+ */
1667
+ export function pulse(db, scope = {}) {
1668
+ const eventCols = {
1669
+ ts: eventsRaw.ts,
1670
+ eventType: eventsRaw.eventType,
1671
+ project: eventsRaw.project,
1672
+ eventId: eventsRaw.eventId,
1673
+ };
1674
+ const eventSessionCond = scopedToSessions(eventsRaw.sessionId, scope);
1675
+ const allEvents = eventSessionCond
1676
+ ? db.select(eventCols).from(eventsRaw).where(eventSessionCond).all()
1677
+ : db.select(eventCols).from(eventsRaw).all();
1678
+ const events = filterByProject(allEvents, scope);
1679
+ const errorCols = { project: errors.project };
1680
+ const errorSessionCond = scopedToSessions(errors.sessionId, scope);
1681
+ const allErrors = errorSessionCond
1682
+ ? db.select(errorCols).from(errors).where(errorSessionCond).all()
1683
+ : db.select(errorCols).from(errors).all();
1684
+ const lessonCols = { lessonStatus: lessons.lessonStatus };
1685
+ const lessonSessionCond = scopedToSessions(lessons.sessionId, scope);
1686
+ const lessonRows = lessonSessionCond
1687
+ ? db.select(lessonCols).from(lessons).where(lessonSessionCond).all()
1688
+ : db.select(lessonCols).from(lessons).all();
1689
+ // Max over `ts`, not "the last row": rows land in the order the projector
1690
+ // folded them, and a rebuild folds one session to completion before it
1691
+ // starts the next — so the final row is the newest event of the last
1692
+ // session, which is not the newest event. Ties on `ts` are common enough to
1693
+ // need an answer of their own; isLaterEvent has it.
1694
+ let newest = null;
1695
+ for (const e of events) {
1696
+ if (newest === null || isLaterEvent(e, newest))
1697
+ newest = e;
1698
+ }
1699
+ return {
1700
+ lastEventAt: newest?.ts ?? null,
1701
+ lastEventType: newest?.eventType ?? null,
1702
+ counts: {
1703
+ events: events.length,
1704
+ errors: filterByProject(allErrors, scope).length,
1705
+ },
1706
+ // An unrecognised status is not pending — same reading as lessonsPage(),
1707
+ // which buckets it as `closed`.
1708
+ lessonsPending: lessonRows.filter((r) => LESSON_BUCKET_FOR_STATUS[r.lessonStatus] === 'pending')
1709
+ .length,
1710
+ };
1711
+ }
1712
+ // Re-exported for callers that only need the raw prompt/edge/waiver rows
1713
+ // (e.g. a future UI's simpler list views) without the composed pages above.
1714
+ export { edges, prompts, waivers };