@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,1317 @@
1
+ // Rebuild state/smith.db (SQLite projections) from state/events/*.jsonl.
2
+ //
3
+ // Events are the source of truth (architecture §7); this module is the
4
+ // ONLY writer of the tables in db/schema.ts. Two entry points:
5
+ // - rebuild(): drop every table's rows and replay every session's full
6
+ // event log from scratch. Idempotent — running it twice yields the
7
+ // same rows.
8
+ // - apply(): re-fold ONE session's current event log and refresh only
9
+ // that session's rows, leaving every other session's projection
10
+ // untouched ("incremental" in the sense that it never re-touches
11
+ // unrelated sessions, not in the sense of appending a single event —
12
+ // see the design note above projectSession below).
13
+ //
14
+ // Deviation (noted for the human, per the phase-5 task brief): the
15
+ // architecture doc describes dispatch_decision's terminal counterpart only
16
+ // in prose ("task result recorded"); no event type or payload shape for it
17
+ // exists anywhere in the codebase yet. This module introduces
18
+ // `task-result-recorded` (payload = the full Result object, i.e.
19
+ // result.schema.json's shape) as that terminal event, consumed here by
20
+ // agents-registry.ts's fold and by tasks/analytics below. No taxonomy.yml
21
+ // change was needed — event_type is a free string in event.schema.json
22
+ // (dispatch_decision, error-logged, session-start, edge-recorded, and
23
+ // every gate_event/graph_event value are already free strings by the same
24
+ // precedent) — but a short note was added to docs/specs/
25
+ // black-smith-architecture.md §7 documenting the new event kind, in the
26
+ // same place dispatch_decision/user_prompt are documented.
27
+ //
28
+ // A second, smaller deviation: `lesson-candidate-raised` /
29
+ // `lesson-status-changed` events (consumed by foldLessons below) are
30
+ // likewise new — Phase 7 owns the real lessons loop (novelty gate,
31
+ // operator approval UI) and will define these for real; this module's
32
+ // fold is forward-compatible with whatever Phase 7 emits, since it only
33
+ // depends on the taxonomy's already-existing lesson_type/lesson_level/
34
+ // lesson_status/lesson_scope vocabulary, not on any new taxonomy value.
35
+ import { existsSync, mkdirSync } from 'node:fs';
36
+ import path from 'node:path';
37
+ import Database from 'better-sqlite3';
38
+ import { eq } from 'drizzle-orm';
39
+ import { drizzle } from 'drizzle-orm/better-sqlite3';
40
+ import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
41
+ import { detectStale as detectStaleAgents, foldAgents, } from '../agents-registry.js';
42
+ import { eventTaskId, listSessionIds, mergeSessionLogs, readEvents, } from '../events.js';
43
+ import { foldFindingsDetailed, missingProjectionFields, } from '../findings.js';
44
+ import { DB_MIGRATIONS_DIR, ROADMAP_PATH, STATE_DB_PATH, STATE_EVENTS_DIR } from '../paths.js';
45
+ import { isPlanRefTaskId, latestPlanVersion, loadPlan } from '../plan.js';
46
+ import { loadRoadmap } from '../roadmap.js';
47
+ import { assertRuntimeSupported } from '../runtime.js';
48
+ import { bareTaskId, epicOfTaskId, isQualifiedTaskId, qualifyTaskId, taskIdsMatch, } from '../taskId.js';
49
+ import { RESERVED_TASK_ID, taskBranchName } from '../worktree.js';
50
+ import * as schema from './schema.js';
51
+ /** Open (creating if needed) the projection DB and ensure its schema is current. */
52
+ export function openDb(dbPath = STATE_DB_PATH, opts = {}) {
53
+ // The line below is where an unsupported runtime dies: the prebuilt binding
54
+ // imports fine on Node 20 and then segfaults here, with both streams empty
55
+ // (D-47). Assert immediately before it so every in-process consumer — not
56
+ // just the CLI — gets a sentence instead of a signal.
57
+ assertRuntimeSupported();
58
+ mkdirSync(path.dirname(dbPath), { recursive: true });
59
+ const sqlite = new Database(dbPath);
60
+ sqlite.pragma('journal_mode = WAL');
61
+ const db = drizzle(sqlite, { schema });
62
+ migrate(db, { migrationsFolder: opts.migrationsDir ?? DB_MIGRATIONS_DIR });
63
+ return { sqlite, db };
64
+ }
65
+ const ALL_TABLES = [
66
+ schema.eventsRaw,
67
+ schema.sessions,
68
+ schema.prompts,
69
+ schema.dispatches,
70
+ schema.agents,
71
+ schema.tasks,
72
+ schema.epics,
73
+ schema.edges,
74
+ schema.errors,
75
+ schema.findings,
76
+ schema.waivers,
77
+ schema.lessons,
78
+ schema.artifacts,
79
+ schema.milestones,
80
+ ];
81
+ /** Delete every row in every projection table (full "drop" for rebuild()). */
82
+ function clearAll(handle) {
83
+ handle.db.transaction((txDb) => {
84
+ for (const table of ALL_TABLES)
85
+ txDb.delete(table).run();
86
+ });
87
+ }
88
+ /** Delete one session's rows from every table (used before re-projecting it). */
89
+ function clearSession(db, sessionId) {
90
+ db.delete(schema.eventsRaw).where(eq(schema.eventsRaw.sessionId, sessionId)).run();
91
+ db.delete(schema.sessions).where(eq(schema.sessions.sessionId, sessionId)).run();
92
+ db.delete(schema.prompts).where(eq(schema.prompts.sessionId, sessionId)).run();
93
+ db.delete(schema.dispatches).where(eq(schema.dispatches.sessionId, sessionId)).run();
94
+ db.delete(schema.agents).where(eq(schema.agents.sessionId, sessionId)).run();
95
+ db.delete(schema.tasks).where(eq(schema.tasks.sessionId, sessionId)).run();
96
+ db.delete(schema.epics).where(eq(schema.epics.sessionId, sessionId)).run();
97
+ db.delete(schema.edges).where(eq(schema.edges.sessionId, sessionId)).run();
98
+ db.delete(schema.errors).where(eq(schema.errors.sessionId, sessionId)).run();
99
+ db.delete(schema.waivers).where(eq(schema.waivers.sessionId, sessionId)).run();
100
+ db.delete(schema.artifacts).where(eq(schema.artifacts.sessionId, sessionId)).run();
101
+ // `lessons` and `findings` are deliberately absent, like `milestones` —
102
+ // projectLessons() and projectFindings() own those tables whole (D-199,
103
+ // D-200). Deleting by session_id here would delete a row this session
104
+ // raised and another session has since approved or closed.
105
+ }
106
+ /**
107
+ * Fully replace the milestones table from factory/specs/roadmap.md (not
108
+ * session-scoped — see schema.ts's milestones comment). Missing roadmap.md
109
+ * is tolerated (leaves milestones empty) rather than fatal, so `db rebuild`
110
+ * still works in a repo/state-dir that hasn't adopted a roadmap yet.
111
+ *
112
+ * A roadmap.md that fails to PARSE (malformed status, duplicate id, ...) is
113
+ * likewise tolerated, deliberately: this runs after every session write
114
+ * (rebuild()/apply(), the latter called by ui/server after every waiver/
115
+ * lesson event) — a markdown typo must degrade the Roadmap/Overview
116
+ * milestone data only, not the write path it happens to share a call site
117
+ * with. Existing milestones rows are left as-is (stale, not wiped) rather
118
+ * than the whole write erroring out; logged to stderr so it is visible.
119
+ */
120
+ function projectMilestones(handle, opts) {
121
+ const roadmapPath = opts.roadmapPath ?? ROADMAP_PATH;
122
+ if (!existsSync(roadmapPath))
123
+ return;
124
+ let defs;
125
+ try {
126
+ defs = loadRoadmap(roadmapPath);
127
+ }
128
+ catch (err) {
129
+ console.error(`db/projector.ts projectMilestones(): roadmap.md failed to parse, ` +
130
+ `skipping the milestones refresh (existing rows left as-is): ` +
131
+ `${err instanceof Error ? err.message : String(err)}`);
132
+ return;
133
+ }
134
+ handle.db.transaction((txDb) => {
135
+ txDb.delete(schema.milestones).run();
136
+ for (const m of defs) {
137
+ txDb
138
+ .insert(schema.milestones)
139
+ .values({
140
+ milestoneId: m.milestoneId,
141
+ name: m.name,
142
+ status: m.status,
143
+ sequence: m.sequence,
144
+ goal: m.goal,
145
+ epicIds: JSON.stringify(m.epicIds),
146
+ project: m.project,
147
+ kind: m.kind,
148
+ })
149
+ .run();
150
+ }
151
+ });
152
+ }
153
+ /** Splits an `error.class` string into its group/class parts; tolerant of malformed values. */
154
+ function splitErrorClass(value) {
155
+ const [group, cls] = (value ?? '').split('.');
156
+ return { group: group ?? 'unknown', cls: cls ?? 'unknown' };
157
+ }
158
+ const TERMINAL_TASK_STATUSES = new Set([
159
+ 'completed',
160
+ 'superseded',
161
+ 'failed',
162
+ 'escalated',
163
+ 'waived',
164
+ ]);
165
+ /**
166
+ * Fallback for a `task-added` that declares no `branch` — every event logged
167
+ * before D-23/P9-12 added the field, and any hand-appended one. The
168
+ * convention itself lives in worktree.ts, which is what actually creates the
169
+ * branch; deriving it here from a second copy of the string is how the two
170
+ * would drift.
171
+ */
172
+ /**
173
+ * The paths a `task-added` says its task owns, or undefined when the payload
174
+ * does not say it in a shape a reader can use.
175
+ *
176
+ * event.schema.json validates `payload` as `type: object` and stops there —
177
+ * the body is free-form on purpose, so that a new event type is never
178
+ * rejected at write time — which is why taskEvents.ts types the same field
179
+ * `unknown`. The cast above says `string[]` and nothing checks it, so
180
+ * whatever was written lands in tasks.claims and comes back out of
181
+ * taskDetail() still cast to `string[]`: a claim set written as one bare path
182
+ * reaches the board as a string and renders one chip per character.
183
+ * waveTaskIds already learned that a hand-appended payload fills in the
184
+ * singular field ("four times during the dogfood run"), and a single claim is
185
+ * the same keystroke.
186
+ *
187
+ * Unreadable leaves the row's claims alone rather than becoming `[]`: the
188
+ * column starts null meaning "no task-added told us what this owns", and that
189
+ * is still true here, whereas an empty list would assert the task declares
190
+ * nothing. A half-readable list is the same hole as no list — filtering it
191
+ * would hand every reader a shorter set than the task declared and call that
192
+ * what it owns — and gate.ts's loggedClaims() draws the line in the same
193
+ * place on the same payload.
194
+ */
195
+ function readClaims(raw) {
196
+ if (!Array.isArray(raw))
197
+ return undefined;
198
+ return raw.every((c) => typeof c === 'string') ? raw : undefined;
199
+ }
200
+ function branchFor(epicId, taskId) {
201
+ if (!epicId)
202
+ return null;
203
+ return taskBranchName(epicId, taskId);
204
+ }
205
+ /**
206
+ * Every (task id, epic) pair an event asserts: from the id itself when it is
207
+ * qualified, from the payload's `epic_id` when it is not. `wave-admitted`
208
+ * carries the epic beside a list of ids; `task-added`/`task-superseded` carry
209
+ * it beside the one on the record.
210
+ */
211
+ function epicAssertions(record) {
212
+ const payload = record.payload;
213
+ const epicId = typeof payload?.epic_id === 'string' ? payload.epic_id : undefined;
214
+ const taskId = eventTaskId(record);
215
+ const ids = [
216
+ ...(taskId ? [taskId] : []),
217
+ ...(Array.isArray(payload?.task_ids)
218
+ ? payload.task_ids.filter((v) => typeof v === 'string')
219
+ : []),
220
+ ];
221
+ return ids.map((id) => [id, epicId]);
222
+ }
223
+ /**
224
+ * Every epic id the log names: from any event's `epic_id`, and from the epic
225
+ * half of every qualified task id it mentions.
226
+ *
227
+ * An epic id is not a task id — it never contains a `/` — so a set of them
228
+ * can never shadow a qualified task. Two things below need it: the guard that
229
+ * keeps an epic-shaped ref off the board (D-251), and the roster lookup that
230
+ * only visits epics this log actually ran (D-250).
231
+ */
232
+ function knownEpicIds(events) {
233
+ const epics = new Set();
234
+ for (const { record } of events) {
235
+ const payload = record.payload;
236
+ if (typeof payload?.epic_id === 'string' && payload.epic_id.length > 0) {
237
+ epics.add(payload.epic_id);
238
+ }
239
+ for (const [id] of epicAssertions(record)) {
240
+ const epic = epicOfTaskId(id);
241
+ if (epic !== null)
242
+ epics.add(epic);
243
+ }
244
+ }
245
+ return epics;
246
+ }
247
+ /**
248
+ * Bare id -> qualified id, read from the plan roster of every epic the log
249
+ * names (D-250).
250
+ *
251
+ * `buildTaskIdAliases` can only match a bare id against a qualified spelling
252
+ * some OTHER event supplied. A task whose every event is bare and carries no
253
+ * `epic_id` — no `task-added`, just gate results, as `task-4-api` was through
254
+ * the whole envkit dogfood run — has no such spelling, so it folds to its own
255
+ * epic-less row and the project-scoped board never draws it. The plan roster
256
+ * this projector already opens for `planProjectResolver` names it in full.
257
+ *
258
+ * Same tolerance as that resolver: a missing dir, a missing file, or one that
259
+ * will not parse all mean "no answer", never a failed rebuild. The full
260
+ * `plan.tasks` roster is read rather than `livePlanTasks` — a superseded task
261
+ * is still that task, and its id still has to fold onto the right row.
262
+ */
263
+ function planRosterAliases(epics, specsDir) {
264
+ const candidates = new Map();
265
+ for (const epicId of epics) {
266
+ let roster = [];
267
+ try {
268
+ const version = latestPlanVersion(epicId, { specsDir });
269
+ if (version !== null) {
270
+ roster = loadPlan(epicId, version, { specsDir }).tasks.map((t) => t.task_id);
271
+ }
272
+ }
273
+ catch (err) {
274
+ console.error(`db/projector.ts planRosterAliases(): plan file for "${epicId}" ` +
275
+ `failed to load, leaving its task ids unresolved: ` +
276
+ `${err instanceof Error ? err.message : String(err)}`);
277
+ }
278
+ for (const id of roster) {
279
+ if (!isQualifiedTaskId(id))
280
+ continue;
281
+ const bare = bareTaskId(id);
282
+ const set = candidates.get(bare) ?? new Set();
283
+ set.add(id);
284
+ candidates.set(bare, set);
285
+ }
286
+ }
287
+ return candidates;
288
+ }
289
+ /**
290
+ * Bare id -> the one qualified id it must mean, built from every epic the log
291
+ * asserts for it (D-49/P9-10), and from the plan rosters on disk for the ids
292
+ * the log never qualifies anywhere (D-250).
293
+ *
294
+ * Producers disagree about the form: `task-added` writes `envkit/task-0`
295
+ * while `wave-admitted`/`wave-merged` write `task-0` and put the epic in the
296
+ * payload. Folded literally that is two rows for one task — one stuck at
297
+ * `todo` with no epic, one `completed` with no objective — and every consumer
298
+ * that counts tasks counts it twice.
299
+ *
300
+ * A bare id claimed by two different epics gets NO entry: merging it into
301
+ * either would silently complete the wrong task, so it stays its own row and
302
+ * stays visible as the ambiguity it is.
303
+ */
304
+ function buildTaskIdAliases(events, opts) {
305
+ const candidates = new Map();
306
+ const register = (bare, qualified) => {
307
+ const set = candidates.get(bare) ?? new Set();
308
+ set.add(qualified);
309
+ candidates.set(bare, set);
310
+ };
311
+ for (const { record } of events) {
312
+ for (const [id, epicId] of epicAssertions(record)) {
313
+ if (isQualifiedTaskId(id))
314
+ register(bareTaskId(id), id);
315
+ else if (epicId !== undefined && id.length > 0)
316
+ register(id, qualifyTaskId(epicId, id));
317
+ }
318
+ }
319
+ // The roster is consulted only for a bare id NO event qualified. It is a
320
+ // fallback and never an override — the same precedence D-246 gives the
321
+ // plan's `project` — so a log that already answered keeps its answer, and a
322
+ // roster entry can never turn a clean event-derived alias into an
323
+ // ambiguity. An id the LOG left ambiguous stays ambiguous too: the plan
324
+ // breaking that tie would quietly overrule two events that disagree.
325
+ const fromPlan = planRosterAliases(knownEpicIds(events), opts.specsDir);
326
+ const aliases = new Map();
327
+ for (const bare of new Set([...candidates.keys(), ...fromPlan.keys()])) {
328
+ const qualified = candidates.get(bare) ?? fromPlan.get(bare) ?? new Set();
329
+ const [only] = [...qualified];
330
+ if (qualified.size === 1 && only !== undefined && only !== bare)
331
+ aliases.set(bare, only);
332
+ }
333
+ return aliases;
334
+ }
335
+ /**
336
+ * The exact normalisation `foldTasks` applies to every id it folds.
337
+ *
338
+ * Exported because a consumer that keys its own map on raw log ids — the ids
339
+ * as producers wrote them — must resolve them the same way, or its lookups
340
+ * miss the rows `foldTasks` returns and silently fall back to a default
341
+ * (D-182). One resolver, one answer.
342
+ *
343
+ * `opts.specsDir` is threaded through for the same reason: a caller that
344
+ * resolves ids without it would miss D-250's roster-derived aliases and go
345
+ * back to disagreeing with the rows in the db.
346
+ */
347
+ export function taskIdCanonicalizer(events, opts = {}) {
348
+ const aliases = buildTaskIdAliases(events, opts);
349
+ return (taskId) => (isQualifiedTaskId(taskId) ? taskId : (aliases.get(taskId) ?? taskId));
350
+ }
351
+ /**
352
+ * The tasks a wave-scoped event (`wave-admitted`, `wave-merged`) speaks for.
353
+ *
354
+ * D-23/P9-12: the payload's `task_ids` is authoritative — that is what the
355
+ * producers in taskEvents.ts write, and a wave is a set. But an event
356
+ * appended by hand naturally fills in the envelope's singular `task_id` and
357
+ * leaves `task_ids` empty; that happened four times during the dogfood run
358
+ * and the fold read nothing at all, so the board silently stayed wrong.
359
+ * Reading the record-level id as a one-task wave makes those keystrokes mean
360
+ * what they look like they mean, and costs the producers nothing: they emit
361
+ * both (one `wave-merged` per merged task, carrying its own id).
362
+ */
363
+ function waveTaskIds(record) {
364
+ const ids = record.payload.task_ids;
365
+ if (Array.isArray(ids) && ids.length > 0)
366
+ return ids;
367
+ const taskId = eventTaskId(record);
368
+ return taskId ? [taskId] : [];
369
+ }
370
+ export function foldTasks(events, opts = {}) {
371
+ const byId = new Map();
372
+ // Normalisation happens at this boundary, once, before any row exists —
373
+ // so no later code has to ask which of two ids for one task it is holding.
374
+ const canonical = taskIdCanonicalizer(events, opts);
375
+ const epics = knownEpicIds(events);
376
+ function touch(rawTaskId, ts, sessionId) {
377
+ const taskId = canonical(rawTaskId);
378
+ let row = byId.get(taskId);
379
+ if (!row) {
380
+ row = {
381
+ taskId,
382
+ sessionId,
383
+ // A qualified id IS an epic assertion; reading it here means every
384
+ // consumer can filter on the field instead of re-deriving it, and a
385
+ // task whose only event is a dispatch still knows its epic.
386
+ epicId: epicOfTaskId(taskId),
387
+ caseTag: null,
388
+ origin: null,
389
+ taskStatus: 'todo',
390
+ planVersion: null,
391
+ objective: null,
392
+ claims: null,
393
+ budgetTokens: null,
394
+ branch: null,
395
+ createdAt: ts,
396
+ updatedAt: ts,
397
+ project: null,
398
+ };
399
+ // Three ref shapes are NOT tasks and must never surface as kanban
400
+ // cards. The epic's own bare id, which `error-logged` writes into
401
+ // `payload.task_ref` when the failure belongs to no single task (D-251)
402
+ // — the phantom foldEpics() keeps itself off this fold to avoid; the
403
+ // errors table takes that row straight from the event, so refusing the
404
+ // card costs nothing. And two qualified-looking refs:
405
+ // <epic>/integration (worktree.ts's RESERVED_TASK_ID, the epic's
406
+ // integration branch — epic.ts's runEpicVerdict()) and <epic>/plan-v<n>
407
+ // (plan.ts's planRefTaskId(), a plan version — planQuorum.ts's
408
+ // runPlanQuorum()). Both hosts stamp their ref onto the
409
+ // dispatch_decision/judge-verdict/quorum-decision events their quorum
410
+ // case emits, so provider-agreement analytics can group by it like any
411
+ // other dispatch (db/queries.ts's providerAgreement() reads eventsRaw
412
+ // directly, never joins tasks, so it's unaffected either way). Every
413
+ // event type that carries a task_id routes through this one touch()
414
+ // choke point (task-added, wave-admitted, dispatch_decision,
415
+ // gate-outcome, wave-merged, task-superseded, error-logged), so one
416
+ // guard here is enough: build the row (callers below still mutate it
417
+ // freely) but never register a reserved ref in byId, so it never
418
+ // reaches foldTasks()'s returned rows or projectSession()'s tasks-table
419
+ // insert loop.
420
+ const isReservedRef = taskId.split('/').pop() === RESERVED_TASK_ID ||
421
+ isPlanRefTaskId(taskId) ||
422
+ epics.has(taskId);
423
+ if (!isReservedRef)
424
+ byId.set(taskId, row);
425
+ }
426
+ row.updatedAt = ts;
427
+ return row;
428
+ }
429
+ for (const { record } of events) {
430
+ // Resolved once, above the switch, for the same reason `touch()` is the
431
+ // one place a row is born: a hand-written event names its task in the
432
+ // payload and leaves the envelope null (D-245), and reading the envelope
433
+ // alone dropped every such event on the floor — a dispatched task stayed
434
+ // `todo` on the board and its `project` was never stamped.
435
+ const eventTask = eventTaskId(record);
436
+ switch (record.event_type) {
437
+ case 'task-added': {
438
+ if (!eventTask)
439
+ break;
440
+ const p = record.payload;
441
+ const row = touch(eventTask, record.ts, record.session_id);
442
+ // The id wins when it carries an epic: it is what every branch name
443
+ // and finding is keyed on, and a payload that disagrees with it is a
444
+ // producer bug, not a second opinion worth honouring.
445
+ row.epicId = epicOfTaskId(row.taskId) ?? p.epic_id ?? row.epicId;
446
+ row.caseTag = p.case ?? row.caseTag;
447
+ row.origin = p.origin ?? row.origin;
448
+ // D-18b. `task_status` in a plan file is only ever the task's INITIAL
449
+ // status — nothing writes completion back into the document — so a
450
+ // second `task-added` for the same id (which an amendment now emits, so
451
+ // the amended task carries the new plan_version) would otherwise revert
452
+ // a merged task to `todo`. A terminal status is a fact the log earned
453
+ // from `wave-merged`; a static field in a plan file does not overrule
454
+ // it. Same guard `dispatch_decision` and `error-logged` already apply.
455
+ if (!TERMINAL_TASK_STATUSES.has(row.taskStatus))
456
+ row.taskStatus = p.task_status ?? row.taskStatus;
457
+ row.planVersion = p.plan_version ?? row.planVersion;
458
+ row.objective = p.objective ?? row.objective;
459
+ row.claims = readClaims(p.claims) ?? row.claims;
460
+ row.budgetTokens = p.budget_tokens ?? row.budgetTokens;
461
+ row.branch = p.branch ?? branchFor(row.epicId, row.taskId);
462
+ row.project = record.project ?? row.project;
463
+ break;
464
+ }
465
+ case 'wave-admitted': {
466
+ const p = record.payload;
467
+ for (const taskId of waveTaskIds(record)) {
468
+ const row = touch(taskId, record.ts, record.session_id);
469
+ row.taskStatus = 'ready';
470
+ row.epicId = epicOfTaskId(row.taskId) ?? p.epic_id ?? row.epicId;
471
+ }
472
+ break;
473
+ }
474
+ case 'dispatch_decision': {
475
+ if (!eventTask)
476
+ break;
477
+ const row = touch(eventTask, record.ts, record.session_id);
478
+ if (!TERMINAL_TASK_STATUSES.has(row.taskStatus))
479
+ row.taskStatus = 'in-progress';
480
+ row.project = record.project ?? row.project;
481
+ break;
482
+ }
483
+ case 'gate-outcome': {
484
+ if (!eventTask)
485
+ break;
486
+ const p = record.payload;
487
+ const row = touch(eventTask, record.ts, record.session_id);
488
+ if (p.outcome === 'blocked')
489
+ row.taskStatus = 'blocked';
490
+ else if (p.outcome === 'pass-with-waivers-pending')
491
+ row.taskStatus = 'reviewing';
492
+ else if (p.outcome === 'pass')
493
+ row.taskStatus = 'merging';
494
+ break;
495
+ }
496
+ case 'wave-merged': {
497
+ for (const taskId of waveTaskIds(record)) {
498
+ touch(taskId, record.ts, record.session_id).taskStatus = 'completed';
499
+ }
500
+ break;
501
+ }
502
+ case 'task-superseded': {
503
+ if (!eventTask)
504
+ break;
505
+ touch(eventTask, record.ts, record.session_id).taskStatus = 'superseded';
506
+ break;
507
+ }
508
+ case 'error-logged': {
509
+ const p = record.payload;
510
+ // `task_ref` is this event's own spelling; eventTaskId covers the two
511
+ // the rest of the log uses (D-245).
512
+ const taskId = eventTask ?? p.task_ref;
513
+ if (!taskId)
514
+ break;
515
+ const row = touch(taskId, record.ts, record.session_id);
516
+ row.project = record.project ?? row.project;
517
+ if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
518
+ break;
519
+ row.taskStatus = p.error?.startsWith('coordination.') ? 'escalated' : 'blocked';
520
+ break;
521
+ }
522
+ default:
523
+ break;
524
+ }
525
+ }
526
+ return [...byId.values()];
527
+ }
528
+ export function foldLessons(events) {
529
+ const byId = new Map();
530
+ const prevented = new Map();
531
+ for (const { record } of events) {
532
+ if (record.event_type === 'lesson-candidate-raised') {
533
+ const p = record.payload;
534
+ if (!p.lesson_id || !p.lesson_type || !p.lesson_level || !p.lesson_scope || !p.statement) {
535
+ continue;
536
+ }
537
+ byId.set(p.lesson_id, {
538
+ lessonId: p.lesson_id,
539
+ sessionId: record.session_id,
540
+ lessonType: p.lesson_type,
541
+ lessonLevel: p.lesson_level,
542
+ lessonStatus: p.lesson_status ?? 'candidate',
543
+ lessonScope: p.lesson_scope,
544
+ statement: p.statement,
545
+ validFrom: p.valid_from ?? record.ts,
546
+ supersededBy: p.superseded_by ?? null,
547
+ invalidatedByEventId: p.invalidated_by_event_id ?? null,
548
+ provenanceEventIds: p.provenance_event_ids ?? [],
549
+ evidence: p.evidence ?? null,
550
+ timesPrevented: 0,
551
+ findingCategory: p.finding_category ?? null,
552
+ claimPath: p.claim_path ?? null,
553
+ agentRole: p.agent_role ?? null,
554
+ caseType: p.case_type ?? null,
555
+ });
556
+ }
557
+ else if (record.event_type === 'lesson-status-changed') {
558
+ const p = record.payload;
559
+ if (!p.lesson_id || !p.to_status)
560
+ continue;
561
+ const row = byId.get(p.lesson_id);
562
+ if (!row)
563
+ continue;
564
+ row.lessonStatus = p.to_status;
565
+ if (p.superseded_by)
566
+ row.supersededBy = p.superseded_by;
567
+ if (p.invalidated_by_event_id)
568
+ row.invalidatedByEventId = p.invalidated_by_event_id;
569
+ }
570
+ else if (record.event_type === 'lesson-edited') {
571
+ const p = record.payload;
572
+ if (!p.lesson_id)
573
+ continue;
574
+ const row = byId.get(p.lesson_id);
575
+ if (!row)
576
+ continue;
577
+ // Trimmed, and a whitespace-only statement ignored rather than
578
+ // applied: transitionLesson trims the edit and then refuses an empty
579
+ // result outright, so this is the writer's own rule read back. A fold
580
+ // cannot refuse -- it is replaying what already happened -- so the
581
+ // nearest true answer is to leave the statement the edit failed to say
582
+ // anything better than, instead of blanking a real one to spaces.
583
+ const statement = p.statement?.trim();
584
+ if (statement)
585
+ row.statement = statement;
586
+ if (p.lesson_type)
587
+ row.lessonType = p.lesson_type;
588
+ if (p.lesson_scope)
589
+ row.lessonScope = p.lesson_scope;
590
+ if (p.agent_role)
591
+ row.agentRole = p.agent_role;
592
+ if (p.case_type)
593
+ row.caseType = p.case_type;
594
+ }
595
+ else if (record.event_type === 'severity-decisions') {
596
+ const p = record.payload;
597
+ for (const d of p.decisions ?? []) {
598
+ if (d.same_mistake && d.matched_lesson_id) {
599
+ prevented.set(d.matched_lesson_id, (prevented.get(d.matched_lesson_id) ?? 0) + 1);
600
+ }
601
+ }
602
+ }
603
+ }
604
+ for (const [lessonId, count] of prevented) {
605
+ const row = byId.get(lessonId);
606
+ if (row)
607
+ row.timesPrevented = count;
608
+ }
609
+ return [...byId.values()];
610
+ }
611
+ /**
612
+ * D-44/P9-27. Appending the close event is half the fix. projectSession()
613
+ * writes every event to events_raw, so a hand-written `epic-closed` was
614
+ * queryable there — but foldTasks()'s switch knows seven event types and ends
615
+ * `default: break;`, so nothing a human ever looks at changed. The log and the
616
+ * projection disagreed, and the projection is what every surface reads.
617
+ *
618
+ * Deliberately its own fold, keyed on `payload.epic_id` and never on task_id:
619
+ * the real dogfood close used `<epic>/epic`, an unreserved suffix that slips
620
+ * past foldTasks()'s isReservedRef guard, so anything routed through the task
621
+ * fold would mint a phantom task card named after the epic. Reading the id
622
+ * from the payload makes that structurally impossible.
623
+ *
624
+ * Last close wins — re-closing an epic is a correction, not a second epic.
625
+ */
626
+ export function foldEpics(events) {
627
+ const byId = new Map();
628
+ for (const { event_id, record } of events) {
629
+ if (record.event_type !== 'epic-closed')
630
+ continue;
631
+ const p = record.payload;
632
+ // No epic_id means there is no epic to attribute this to. Guessing one
633
+ // out of task_id is exactly the inference this fold exists to avoid.
634
+ if (!p.epic_id)
635
+ continue;
636
+ byId.set(p.epic_id, {
637
+ epicId: p.epic_id,
638
+ sessionId: record.session_id,
639
+ epicStatus: 'closed',
640
+ // An unattributed close still closed the epic — dropping the row would
641
+ // leave it reading as in flight forever, which is the D-43 bug again.
642
+ closedBy: p.closed_by ?? 'unspecified',
643
+ machineVerdict: p.machine_verdict ?? null,
644
+ machineReason: p.machine_reason ?? null,
645
+ overrideRationale: p.override_rationale ?? null,
646
+ blockers: Array.isArray(p.blockers) ? p.blockers : [],
647
+ closedAt: record.ts,
648
+ eventId: event_id,
649
+ project: record.project ?? null,
650
+ });
651
+ }
652
+ return [...byId.values()];
653
+ }
654
+ // ---------------------------------------------------------------------------
655
+ // Per-session projection
656
+ // ---------------------------------------------------------------------------
657
+ /**
658
+ * Re-fold one session's CURRENT full event log into every table's rows for
659
+ * that session. Both rebuild() and apply() call this — there is one fold
660
+ * path, not two: "incremental" apply() re-derives the session's rows from
661
+ * its complete history (readEvents() always returns the full log; findings.
662
+ * ts's foldFindings() already works this way too) rather than patching a
663
+ * single new event into partially-mutable state. That keeps this file the
664
+ * single owner of every table's shape and makes rebuild/apply equivalence
665
+ * trivial to prove (same function, same input) instead of two fold
666
+ * implementations that can drift.
667
+ */
668
+ /**
669
+ * Where a row belongs when its own envelope does not say.
670
+ *
671
+ * D-233. Only the writers holding the plan stamp a project (taskEvents.ts,
672
+ * D-232): a task's `task-added` carries one, and the dispatch, the result and
673
+ * the error that follow it carry nothing, because the code that appends them
674
+ * never saw the plan file. That is not a gap in the log -- events.ts is
675
+ * deliberate that an absent project stays absent on the wire -- but it IS a
676
+ * gap in these tables, because db/queries.ts's projectOf() resolves a NULL
677
+ * column to the DEFAULT project. Left alone, every gate outcome and every
678
+ * failure of a demo-rpg run reads back as black-smith's.
679
+ *
680
+ * A child row's project is its task's, which is exactly the rule the Scope
681
+ * docblock in db/queries.ts already states (D-170). Derived from the same
682
+ * foldTasks() the tasks table is written from, so the two can never disagree.
683
+ *
684
+ * This docblock used to claim projectFindings() below already applied that
685
+ * same rule. It did not -- it looked its tasks up in a plain exact-key Map,
686
+ * and 41 of the 56 findings in the shipped logs fell through the difference
687
+ * (D-247). It calls this resolver now, so the claim is true.
688
+ *
689
+ * Membership asks taskIdsMatch and not Map.has: the log spells the same task
690
+ * both qualified ("epic/task-1") and bare ("task-1"), and a spelling
691
+ * difference is not a different task (D-130/D-143).
692
+ */
693
+ function projectResolver(taskRows) {
694
+ const exact = new Map(taskRows.map((t) => [t.taskId, t.project]));
695
+ return (taskId) => {
696
+ if (typeof taskId !== 'string')
697
+ return null;
698
+ const hit = exact.get(taskId);
699
+ if (hit !== undefined)
700
+ return hit;
701
+ return taskRows.find((t) => taskIdsMatch(taskId, t.taskId))?.project ?? null;
702
+ };
703
+ }
704
+ /**
705
+ * What an epic's plan file says it belongs to, for the tasks whose own events
706
+ * never said.
707
+ *
708
+ * D-246. taskEvents.ts's planScoped() stamps the project onto `task-added` at
709
+ * write time (D-232), which repairs every run started after it and nothing
710
+ * that came before -- and the real logs are mostly "before": not one of
711
+ * dogfood-demo-rpg-1's thirteen task-added events carries a project. Those
712
+ * rows fold to project NULL, projectResolver() hands the NULL down to every
713
+ * dispatch and error under them, and db/queries.ts's projectOf() resolves the
714
+ * lot to the DEFAULT project: the whole demo-rpg epic filed under
715
+ * black-smith, its own board empty. That history cannot be repaired from the
716
+ * log, because the log never held the answer. The plan file did, all along.
717
+ *
718
+ * Same source and same precedence as the write side -- what the envelope
719
+ * declared, else what the plan does -- so a rebuild and a fresh run agree.
720
+ * The plan is a fallback and never an override: an epic re-homed after its
721
+ * plan was written keeps where its events put it.
722
+ *
723
+ * Tolerant in exactly the way projectMilestones() is tolerant of roadmap.md:
724
+ * a missing dir, a missing file, a plan with no project, or one that will not
725
+ * parse all mean "no answer", which leaves the row as it was. An unreadable
726
+ * spec must not cost the operator the rest of the projection.
727
+ */
728
+ function planProjectResolver(specsDir) {
729
+ const cache = new Map();
730
+ return (epicId) => {
731
+ // A task with no epic has no plan file to ask. Both spellings of that
732
+ // reach here: NULL from a `task-added` that named no epic, and '' from
733
+ // the phantom rows a bare task id folds into.
734
+ if (epicId === null || epicId === '')
735
+ return null;
736
+ const cached = cache.get(epicId);
737
+ if (cached !== undefined)
738
+ return cached;
739
+ let project = null;
740
+ try {
741
+ const version = latestPlanVersion(epicId, { specsDir });
742
+ if (version !== null)
743
+ project = loadPlan(epicId, version, { specsDir }).project ?? null;
744
+ }
745
+ catch (err) {
746
+ console.error(`db/projector.ts planProjectResolver(): plan file for "${epicId}" ` +
747
+ `failed to load, leaving its project unresolved: ` +
748
+ `${err instanceof Error ? err.message : String(err)}`);
749
+ }
750
+ cache.set(epicId, project);
751
+ return project;
752
+ };
753
+ }
754
+ /**
755
+ * foldTasks() with D-246's plan-file backfill applied.
756
+ *
757
+ * Both folds of the task list go through here, because both answer the same
758
+ * question and must not answer it differently: projectSession() derives the
759
+ * tasks, epics, dispatches, artifacts and errors rows from one, and
760
+ * projectFindings() folds the same events again for findings.project. Split
761
+ * the backfill across only one of them and a single task reads as demo-rpg's
762
+ * on the board and black-smith's on the errors page.
763
+ */
764
+ function foldTasksWithPlanProject(events, opts) {
765
+ const projectFromPlan = planProjectResolver(opts.specsDir);
766
+ return foldTasks([...events], { specsDir: opts.specsDir }).map((t) => t.project === null ? { ...t, project: projectFromPlan(t.epicId) } : t);
767
+ }
768
+ export function projectSession(handle, sessionId, events, opts = {}) {
769
+ handle.db.transaction((txDb) => {
770
+ clearSession(txDb, sessionId);
771
+ if (events.length === 0)
772
+ return;
773
+ // Folded once, up here: the rows below inherit their project from it, and
774
+ // the tasks table is written from the very same array further down. The
775
+ // plan-file backfill lands here for that reason -- one insertion point,
776
+ // and the tasks table, the epics table and every child row come with it.
777
+ const taskRows = foldTasksWithPlanProject(events, opts);
778
+ const projectForTask = projectResolver(taskRows);
779
+ const projectForEpic = new Map(taskRows.flatMap((t) => (t.project === null ? [] : [[t.epicId, t.project]])));
780
+ /**
781
+ * The project a row inherits from whatever its ref names, task or epic.
782
+ *
783
+ * Half the refs a real run writes name an epic and no task at all --
784
+ * `<epic>/integration`, `<epic>/plan-v2`, `<epic>/epic`, or the bare epic
785
+ * id -- and foldTasks() deliberately refuses to mint a task row for any of
786
+ * them (D-250), so the task leg alone can never answer. Resolving through
787
+ * it alone left those rows NULL, and queries.ts's projectOf() reads a NULL
788
+ * back as the DEFAULT project, not as "unscoped": every one of them was
789
+ * filed under black-smith, including the one stop-the-line error in the
790
+ * shipped logs, which demo-rpg had raised (D-252).
791
+ *
792
+ * epicOfTaskId() answers for a qualified ref; `?? ref` covers the bare
793
+ * spelling. It cannot mis-file: the map is keyed by epic id, so a bare
794
+ * TASK id that names no epic simply misses and stays null.
795
+ */
796
+ const projectForRef = (ref) => typeof ref !== 'string'
797
+ ? null
798
+ : (projectForTask(ref) ?? projectForEpic.get(epicOfTaskId(ref) ?? ref) ?? null);
799
+ const first = events[0];
800
+ const last = events[events.length - 1];
801
+ txDb
802
+ .insert(schema.sessions)
803
+ .values({
804
+ sessionId,
805
+ startedAt: first.record.ts,
806
+ lastEventAt: last.record.ts,
807
+ eventCount: events.length,
808
+ })
809
+ .run();
810
+ for (const { event_id, record } of events) {
811
+ // The JSONL is the archive; every table below is a projection of it, and
812
+ // `project` here is already derived rather than copied. The task id gets
813
+ // the same treatment: read from both levels (D-245), or a hand-written
814
+ // dispatch lands in events_raw and dispatches with a null task and
815
+ // answers no task-scoped query the operator can type.
816
+ const eventTask = eventTaskId(record);
817
+ txDb
818
+ .insert(schema.eventsRaw)
819
+ .values({
820
+ eventId: event_id,
821
+ sessionId: record.session_id,
822
+ ts: record.ts,
823
+ eventType: record.event_type,
824
+ taskId: eventTask,
825
+ agentId: record.agent_id ?? null,
826
+ planVersion: record.plan_version,
827
+ causalParent: record.causal_parent,
828
+ payload: JSON.stringify(record.payload),
829
+ project: record.project ?? projectForRef(eventTask),
830
+ actor: record.actor,
831
+ })
832
+ .run();
833
+ if (record.event_type === 'user_prompt') {
834
+ const p = record.payload;
835
+ txDb
836
+ .insert(schema.prompts)
837
+ .values({
838
+ eventId: event_id,
839
+ sessionId: record.session_id,
840
+ ts: record.ts,
841
+ prompt: p.prompt ?? p.text ?? '',
842
+ causalParent: record.causal_parent,
843
+ })
844
+ .run();
845
+ continue;
846
+ }
847
+ if (record.event_type === 'dispatch_decision') {
848
+ const p = record.payload;
849
+ if (!p.agent_role || !p.provider || !p.model_tier)
850
+ continue;
851
+ txDb
852
+ .insert(schema.dispatches)
853
+ .values({
854
+ eventId: event_id,
855
+ sessionId: record.session_id,
856
+ ts: record.ts,
857
+ taskId: eventTask,
858
+ agentId: record.agent_id ?? null,
859
+ agentRole: p.agent_role,
860
+ provider: p.provider,
861
+ modelTier: p.model_tier,
862
+ specRef: p.spec_ref ?? null,
863
+ reason: p.reason ?? null,
864
+ parentPromptId: p.parent_prompt_id ?? null,
865
+ causalParent: record.causal_parent,
866
+ project: record.project ?? projectForRef(eventTask),
867
+ })
868
+ .run();
869
+ continue;
870
+ }
871
+ if (record.event_type === 'edge-recorded' && record.edge && eventTask) {
872
+ const p = record.payload;
873
+ if (!p.depends_on)
874
+ continue;
875
+ txDb
876
+ .insert(schema.edges)
877
+ .values({
878
+ eventId: event_id,
879
+ sessionId: record.session_id,
880
+ ts: record.ts,
881
+ taskId: eventTask,
882
+ dependsOn: p.depends_on,
883
+ edgeType: record.edge.edge_type,
884
+ edgeProvenance: record.edge.edge_provenance,
885
+ })
886
+ .run();
887
+ continue;
888
+ }
889
+ if (record.event_type === 'error-logged') {
890
+ const p = record.payload;
891
+ if (!p.error || !p.severity)
892
+ continue;
893
+ const { group, cls } = splitErrorClass(p.error);
894
+ txDb
895
+ .insert(schema.errors)
896
+ .values({
897
+ eventId: event_id,
898
+ sessionId: record.session_id,
899
+ ts: record.ts,
900
+ taskRef: p.task_ref ?? eventTask,
901
+ agentId: record.agent_id ?? null,
902
+ errorGroup: group,
903
+ errorClass: cls,
904
+ severity: p.severity,
905
+ detail: p.detail ?? null,
906
+ project: record.project ?? projectForRef(p.task_ref ?? eventTask),
907
+ })
908
+ .run();
909
+ continue;
910
+ }
911
+ if (record.event_type === 'task-result-recorded') {
912
+ const p = record.payload;
913
+ // Envelope first, like every other reader — this line used to prefer
914
+ // the payload, the one place in the repo that resolved the pair the
915
+ // other way round (D-245).
916
+ const taskId = eventTask;
917
+ if (!taskId)
918
+ continue;
919
+ (p.artifacts ?? []).forEach((artifact, index) => {
920
+ if (!artifact.type || !artifact.path)
921
+ return;
922
+ txDb
923
+ .insert(schema.artifacts)
924
+ .values({
925
+ id: `${event_id}#${index}`,
926
+ sessionId: record.session_id,
927
+ taskId,
928
+ eventId: event_id,
929
+ ts: record.ts,
930
+ type: artifact.type,
931
+ path: artifact.path,
932
+ description: artifact.description ?? null,
933
+ })
934
+ .run();
935
+ });
936
+ continue;
937
+ }
938
+ if (record.event_type === 'waiver-granted' || record.event_type === 'waiver-denied') {
939
+ const p = record.payload;
940
+ if (!p.fingerprint)
941
+ continue;
942
+ txDb
943
+ .insert(schema.waivers)
944
+ .values({
945
+ eventId: event_id,
946
+ sessionId: record.session_id,
947
+ ts: record.ts,
948
+ fingerprint: p.fingerprint,
949
+ decision: record.event_type === 'waiver-granted' ? 'granted' : 'denied',
950
+ operatorNote: p.operator_note ?? null,
951
+ })
952
+ .run();
953
+ }
954
+ }
955
+ for (const task of taskRows) {
956
+ txDb
957
+ .insert(schema.tasks)
958
+ .values({
959
+ taskId: task.taskId,
960
+ sessionId: task.sessionId,
961
+ epicId: task.epicId,
962
+ caseTag: task.caseTag,
963
+ origin: task.origin,
964
+ taskStatus: task.taskStatus,
965
+ planVersion: task.planVersion,
966
+ objective: task.objective,
967
+ claims: task.claims ? JSON.stringify(task.claims) : null,
968
+ budgetTokens: task.budgetTokens,
969
+ branch: task.branch,
970
+ createdAt: task.createdAt,
971
+ updatedAt: task.updatedAt,
972
+ project: task.project,
973
+ })
974
+ .run();
975
+ }
976
+ for (const epic of foldEpics(events)) {
977
+ txDb
978
+ .insert(schema.epics)
979
+ .values({
980
+ epicId: epic.epicId,
981
+ sessionId: epic.sessionId,
982
+ epicStatus: epic.epicStatus,
983
+ closedBy: epic.closedBy,
984
+ machineVerdict: epic.machineVerdict,
985
+ machineReason: epic.machineReason,
986
+ overrideRationale: epic.overrideRationale,
987
+ blockers: JSON.stringify(epic.blockers),
988
+ closedAt: epic.closedAt,
989
+ eventId: epic.eventId,
990
+ // An epic-closed names an epic and no task, and epic.ts writes it
991
+ // without a plan in hand -- its own tasks are the only thing that
992
+ // can place it (D-233).
993
+ project: epic.project ?? projectForEpic.get(epic.epicId) ?? null,
994
+ })
995
+ .run();
996
+ }
997
+ for (const agent of foldAgents(events)) {
998
+ txDb
999
+ .insert(schema.agents)
1000
+ .values({
1001
+ id: agent.id,
1002
+ sessionId: agent.sessionId,
1003
+ taskId: agent.taskId,
1004
+ epicId: agent.epicId,
1005
+ agentId: agent.agentId,
1006
+ agentRole: agent.agentRole,
1007
+ round: agent.round,
1008
+ provider: agent.provider,
1009
+ modelTier: agent.modelTier,
1010
+ dispatchedAt: agent.dispatchedAt,
1011
+ terminalEventId: agent.terminalEventId,
1012
+ terminalAt: agent.terminalAt,
1013
+ terminalType: agent.terminalType,
1014
+ status: agent.status,
1015
+ })
1016
+ .run();
1017
+ }
1018
+ // `lessons` is not written here — see projectLessons() (D-199).
1019
+ });
1020
+ }
1021
+ /**
1022
+ * Fully replace the lessons table from EVERY session's log at once, in one
1023
+ * causal order (mergeSessionLogs). Not session-scoped, the same shape as
1024
+ * projectMilestones() above, and for a reason the rest of this file's
1025
+ * per-session partition cannot express.
1026
+ *
1027
+ * D-199. A lesson is raised in one session and approved in another BY DESIGN:
1028
+ * lessons.ts's transitionLesson() reads the lineage on purpose, because
1029
+ * "approving from a continuation the candidate the parent session raised is
1030
+ * the ordinary shape of a long epic". Folding one session at a time handed
1031
+ * foldLessons() a `lesson-status-changed` for a lesson_id it had no row for,
1032
+ * and the fold correctly continued — the truncation happened before it, in
1033
+ * what it was given. The projection kept saying `candidate`, which is what
1034
+ * lessonsPage() buckets on and what `smith lessons compile` writes
1035
+ * factory/policies/lessons.md from, so the approval reached neither the
1036
+ * operator's screen nor an agent's prompt block.
1037
+ *
1038
+ * The lineage is not enough here. Session B's lineage reaches back to A, but
1039
+ * A's never reaches forward to B, so a fix that folds each session's lineage
1040
+ * still loses the approval the moment A is projected last — and
1041
+ * listSessionIds() sorts by filename, which is nothing causal. The table's
1042
+ * primary key is the lesson id, not (session, lesson): one global key, one
1043
+ * global fold.
1044
+ *
1045
+ * The row still carries the RAISING session's id, so `lessonsPage({ sessionId })`
1046
+ * keeps meaning "lessons raised here" and an approval never moves a lesson.
1047
+ *
1048
+ * One deliberate consequence: two sessions raising the same lesson_id used to
1049
+ * abort a rebuild on the table's UNIQUE constraint and now resolve last-raise-
1050
+ * wins, because that is already what foldLessons() does with two raises inside
1051
+ * one session. The rule is now the same on both sides of a session boundary
1052
+ * instead of quiet within one and fatal across.
1053
+ */
1054
+ function projectLessons(handle, events) {
1055
+ handle.db.transaction((txDb) => {
1056
+ txDb.delete(schema.lessons).run();
1057
+ for (const lesson of foldLessons(events)) {
1058
+ txDb
1059
+ .insert(schema.lessons)
1060
+ .values({
1061
+ lessonId: lesson.lessonId,
1062
+ sessionId: lesson.sessionId,
1063
+ lessonType: lesson.lessonType,
1064
+ lessonLevel: lesson.lessonLevel,
1065
+ lessonStatus: lesson.lessonStatus,
1066
+ lessonScope: lesson.lessonScope,
1067
+ statement: lesson.statement,
1068
+ validFrom: lesson.validFrom,
1069
+ supersededBy: lesson.supersededBy,
1070
+ invalidatedByEventId: lesson.invalidatedByEventId,
1071
+ provenanceEventIds: JSON.stringify(lesson.provenanceEventIds),
1072
+ evidence: lesson.evidence,
1073
+ timesPrevented: lesson.timesPrevented,
1074
+ findingCategory: lesson.findingCategory,
1075
+ claimPath: lesson.claimPath,
1076
+ agentRole: lesson.agentRole,
1077
+ caseType: lesson.caseType,
1078
+ })
1079
+ .run();
1080
+ }
1081
+ });
1082
+ }
1083
+ function findingTimestamps(events) {
1084
+ const stamps = new Map();
1085
+ for (const { event_id, record } of events) {
1086
+ if (record.event_type === 'finding-raised') {
1087
+ const findingId = record.payload.finding_id;
1088
+ if (findingId)
1089
+ stamps.set(findingId, {
1090
+ raisedAt: record.ts,
1091
+ updatedAt: record.ts,
1092
+ raisedByEventId: event_id,
1093
+ sessionId: record.session_id,
1094
+ });
1095
+ }
1096
+ else if (record.event_type === 'finding-transitioned') {
1097
+ const findingId = record.payload.finding_id;
1098
+ const existing = findingId ? stamps.get(findingId) : undefined;
1099
+ if (findingId && existing)
1100
+ stamps.set(findingId, { ...existing, updatedAt: record.ts });
1101
+ }
1102
+ }
1103
+ return stamps;
1104
+ }
1105
+ /**
1106
+ * Fully replace the findings table from EVERY session's log at once, in one
1107
+ * causal order (mergeSessionLogs) — the same shape as projectLessons() above,
1108
+ * and for the same reason.
1109
+ *
1110
+ * D-200. A finding is raised in one session and closed in another BY DESIGN:
1111
+ * findings.ts's transition() reads the lineage on purpose, because "a finding
1112
+ * raised in the first session of a cross-session epic is otherwise not FOUND
1113
+ * from the second" (D-119), and it appends the `finding-transitioned` event to
1114
+ * the session it was called in. Folding one session at a time handed
1115
+ * foldFindingsDetailed() a transition for a finding_id it had no row for, and
1116
+ * the fold correctly continued — the truncation happened before it, in what it
1117
+ * was given. The projection kept the pre-transition status, which is what the
1118
+ * Kanban's worst-open-severity chip reads, so a refuted S2 kept flagging a task
1119
+ * nobody needed to look at and the board contradicted `smith findings list`.
1120
+ *
1121
+ * The lineage is not enough here, exactly as in projectLessons(): the
1122
+ * continuation's lineage reaches back to its parent, but the parent's never
1123
+ * reaches forward, so folding each session's lineage still loses the
1124
+ * transition whenever the parent is projected last — and listSessionIds()
1125
+ * sorts by filename, which is nothing causal. The table's primary key is the
1126
+ * finding id, not (session, finding): one global key, one global fold.
1127
+ *
1128
+ * The row still carries the RAISING session's id (the stamp's, no longer the
1129
+ * caller's), so a findings query scoped to a session keeps meaning "findings
1130
+ * raised here" and closing one never moves it.
1131
+ *
1132
+ * One deliberate consequence, again as in projectLessons(): two sessions
1133
+ * raising the same finding_id used to abort a rebuild on the table's primary
1134
+ * key and now resolve last-raise-wins, because that is already what
1135
+ * foldFindingsDetailed() does with two raises inside one session.
1136
+ *
1137
+ * Findings are folded via findings.ts's own fold — reused, not duplicated.
1138
+ *
1139
+ * Returns what it could not store. Three disjoint reasons, reported the same
1140
+ * way (D-141): the fold's own quarantine (D-135 — a payload the fold cannot
1141
+ * even dereference), records the fold returns happily but that cannot fill one
1142
+ * of the table's `notNull()` columns, and (since the fold is now global) a
1143
+ * finding whose raise is not in the projected logs at all, which has no
1144
+ * session to be owned by. The second kind used to abort the entire rebuild on
1145
+ * the first offender.
1146
+ */
1147
+ function projectFindings(handle, events, opts) {
1148
+ const { findings, skipped } = foldFindingsDetailed([...events]);
1149
+ const stamps = findingTimestamps(events);
1150
+ // A finding has no `project` of its own on the wire (findings.ts's raiseFinding()
1151
+ // predates Phase 6b) — derive it from its owning task's project, the same fold
1152
+ // projectSession() computes, so the value always matches tasks.project exactly.
1153
+ // Same helper as projectSession() for exactly that reason: the plan-file
1154
+ // backfill (D-246) has to reach both folds or the invariant is a lie.
1155
+ const taskRows = foldTasksWithPlanProject(events, opts);
1156
+ // projectResolver and not a Map lookup, for the reason its own docblock
1157
+ // gives: the log spells one task both `epic/task-1` and `task-1`, and a
1158
+ // spelling is not an identity (D-130/D-143). D-233 already routed every
1159
+ // other child row through it and named findings as following the same rule
1160
+ // — findings were the one table that did not (D-247).
1161
+ const projectForTask = projectResolver(taskRows);
1162
+ // And an epic-level fallback, because the merge queue raises findings
1163
+ // against `<epic>/integration`, a pseudo-task that never gets a task row:
1164
+ // no task lookup can ever answer for it, its epic always can, and the row
1165
+ // already stores that epic. On the shipped logs this is 37 of 56 findings.
1166
+ const projectForEpic = new Map(taskRows.flatMap((t) => (t.project === null ? [] : [[t.epicId, t.project]])));
1167
+ const projectFromPlan = planProjectResolver(opts.specsDir);
1168
+ const rows = [];
1169
+ for (const finding of findings) {
1170
+ const stamp = stamps.get(finding.finding_id);
1171
+ const missing = missingProjectionFields(finding);
1172
+ if (missing.length > 0) {
1173
+ // Held back rather than inserted-with-nulls: a row that lies about what
1174
+ // it knows is worse than a row that is missing, and worse than the crash
1175
+ // this replaces only in that it is quiet. So it is named instead.
1176
+ skipped.push({
1177
+ event_id: stamp?.raisedByEventId ?? '?',
1178
+ finding_id: finding.finding_id,
1179
+ reason: `finding cannot be projected into the findings table: missing required string field(s): ${missing.join(', ')}`,
1180
+ });
1181
+ continue;
1182
+ }
1183
+ if (stamp === undefined) {
1184
+ // Unreachable through the fold, which only returns findings it saw a
1185
+ // `finding-raised` for — named rather than defaulted because the row's
1186
+ // owning session would otherwise be a guess, and `session_id` is the
1187
+ // column every scoped findings query filters on.
1188
+ skipped.push({
1189
+ event_id: '?',
1190
+ finding_id: finding.finding_id,
1191
+ reason: 'finding cannot be projected into the findings table: no finding-raised event in the projected logs',
1192
+ });
1193
+ continue;
1194
+ }
1195
+ // The carried field first; the task id's prefix only as back-compat
1196
+ // for records raised before the field existed (D-49/P9-10).
1197
+ const findingEpicId = finding.epic_id ?? epicOfTaskId(finding.task_id);
1198
+ rows.push({
1199
+ findingId: finding.finding_id,
1200
+ sessionId: stamp.sessionId,
1201
+ taskId: finding.task_id,
1202
+ epicId: findingEpicId,
1203
+ project: projectForTask(finding.task_id) ??
1204
+ projectForEpic.get(findingEpicId) ??
1205
+ projectFromPlan(findingEpicId),
1206
+ fingerprint: finding.fingerprint,
1207
+ findingCategory: finding.finding_category,
1208
+ severity: finding.severity,
1209
+ findingStatus: finding.finding_status,
1210
+ summary: finding.summary,
1211
+ foundBy: finding.found_by,
1212
+ foundByProvider: finding.found_by_provider ?? null,
1213
+ verifiedBy: finding.verified_by ?? null,
1214
+ verifiedByProvider: finding.verified_by_provider ?? null,
1215
+ sameMistakeOfLessonId: finding.same_mistake_of_lesson_id ?? null,
1216
+ waiverId: finding.waiver_id ?? null,
1217
+ raisedAt: stamp.raisedAt,
1218
+ updatedAt: stamp.updatedAt,
1219
+ });
1220
+ }
1221
+ handle.db.transaction((txDb) => {
1222
+ txDb.delete(schema.findings).run();
1223
+ for (const row of rows)
1224
+ txDb.insert(schema.findings).values(row).run();
1225
+ });
1226
+ return skipped;
1227
+ }
1228
+ /**
1229
+ * Every named session's log, read once (D-199 — projectLessons), oldest
1230
+ * session first.
1231
+ *
1232
+ * The order is part of the contract, not tidiness. mergeSessionLogs breaks a
1233
+ * tie on `ts` by array position — for a lineage that is right, because
1234
+ * walkLineage hands it the chain root-first, but a SET of sessions has no
1235
+ * such order, and two events written in the same millisecond in two sessions
1236
+ * are exactly the case a global fold has to settle the same way every time.
1237
+ * "The session that started earlier" is the one causal reading available;
1238
+ * session id settles two logs that start in the same millisecond, so the
1239
+ * order is total and independent of how the caller listed them.
1240
+ */
1241
+ async function readAllSessionLogs(sessionIds, stateDir) {
1242
+ const logs = [];
1243
+ for (const sessionId of sessionIds) {
1244
+ logs.push({ sessionId, events: await readEvents(sessionId, { stateDir }) });
1245
+ }
1246
+ logs.sort((a, b) => {
1247
+ const aStart = a.events[0]?.record.ts ?? '';
1248
+ const bStart = b.events[0]?.record.ts ?? '';
1249
+ if (aStart !== bStart)
1250
+ return aStart < bStart ? -1 : 1;
1251
+ return a.sessionId.localeCompare(b.sessionId);
1252
+ });
1253
+ return logs;
1254
+ }
1255
+ /**
1256
+ * Full rebuild: drop every table's rows, then replay every session (or the
1257
+ * given subset) from its complete event log. Idempotent by construction —
1258
+ * clearAll() + a deterministic fold of the same log always yields the same
1259
+ * rows.
1260
+ */
1261
+ export async function rebuild(dbPath = STATE_DB_PATH, sessions = 'all', opts = {}) {
1262
+ const handle = openDb(dbPath, opts);
1263
+ try {
1264
+ clearAll(handle);
1265
+ const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
1266
+ const sessionIds = sessions === 'all' ? listSessionIds(stateDir) : [...sessions];
1267
+ // Read each log once: the per-session projection consumes them one at a
1268
+ // time, projectLessons() and projectFindings() need them all at once.
1269
+ const logs = await readAllSessionLogs(sessionIds, stateDir);
1270
+ let eventsApplied = 0;
1271
+ for (const { sessionId, events } of logs) {
1272
+ projectSession(handle, sessionId, events, opts);
1273
+ eventsApplied += events.length;
1274
+ }
1275
+ const merged = mergeSessionLogs(logs);
1276
+ const skippedFindings = projectFindings(handle, merged, opts);
1277
+ projectLessons(handle, merged);
1278
+ projectMilestones(handle, opts);
1279
+ return { sessionsProcessed: sessionIds.length, eventsApplied, skippedFindings };
1280
+ }
1281
+ finally {
1282
+ handle.sqlite.close();
1283
+ }
1284
+ }
1285
+ /**
1286
+ * Incremental refresh for one session: re-folds ITS current event log and
1287
+ * replaces only its rows, leaving every other session's projection intact.
1288
+ * Safe to call repeatedly while a session is still running (tailing).
1289
+ *
1290
+ * The three tables that are not session-scoped are rewritten whole on every
1291
+ * call: `milestones` from roadmap.md, and — from every session's log, which is
1292
+ * why this reads more than the one session it re-folds — `lessons` (D-199) and
1293
+ * `findings` (D-200).
1294
+ */
1295
+ export async function apply(dbPath = STATE_DB_PATH, sessionId, opts = {}) {
1296
+ const handle = openDb(dbPath, opts);
1297
+ try {
1298
+ const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
1299
+ // A session whose first event has not landed yet has no file to list, and
1300
+ // readEvents answers `[]` for a log that does not exist (P9-28) — so
1301
+ // naming it anyway costs an empty read and keeps it in the global folds
1302
+ // below, which would otherwise drop the very session being applied.
1303
+ const listed = listSessionIds(stateDir);
1304
+ const logs = await readAllSessionLogs(listed.includes(sessionId) ? listed : [...listed, sessionId], stateDir);
1305
+ const events = logs.find((l) => l.sessionId === sessionId)?.events ?? [];
1306
+ projectSession(handle, sessionId, events, opts);
1307
+ const merged = mergeSessionLogs(logs);
1308
+ const skippedFindings = projectFindings(handle, merged, opts);
1309
+ projectLessons(handle, merged);
1310
+ projectMilestones(handle, opts);
1311
+ return { sessionsProcessed: 1, eventsApplied: events.length, skippedFindings };
1312
+ }
1313
+ finally {
1314
+ handle.sqlite.close();
1315
+ }
1316
+ }
1317
+ export { detectStaleAgents as detectStale };