@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,1015 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { appendFile, mkdir, open, readFile, rm, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { SmithError } from './errors.js';
5
+ import { STATE_EVENTS_DIR } from './paths.js';
6
+ import { compileSchemas, validateRecord } from './schemas.js';
7
+ import { taskIdsMatch } from './taskId.js';
8
+ import { loadTaxonomy, validateRequiredDimensions, validateTag, } from './taxonomy.js';
9
+ export class EventError extends SmithError {
10
+ }
11
+ // Root events (session_id's first event) are the only ones allowed a null
12
+ // causal_parent (architecture §7).
13
+ /**
14
+ * The one event type a log may open with, and the only one allowed to name a
15
+ * `causal_parent` in another session's log. Exported since D-263 because
16
+ * db/queries.ts walks the same edge off the projection and a second copy of
17
+ * the literal is a second thing to keep in step.
18
+ */
19
+ export const ROOT_EVENT_TYPE = 'session-start';
20
+ /**
21
+ * Split an event id into the session that owns it and its line index.
22
+ *
23
+ * Event ids have always been `<session-id>#<index>` — that shape is what makes
24
+ * a cross-session reference resolvable at all (P9-7): given only the id, a
25
+ * reader knows which log file to open. Splitting on the LAST `#` keeps a
26
+ * session id that happens to contain one from silently resolving to the wrong
27
+ * session.
28
+ */
29
+ export function parseEventId(eventId) {
30
+ // Typed as a string and reached with whatever the writer sent. `smith event
31
+ // append` JSON.parses its argument and hands it straight over, and
32
+ // readEvents() takes a `causal_parent` off a log line that nothing retypes
33
+ // on the way in. A number is the likeliest of those to be written on
34
+ // purpose -- an event id is `<session>#<index>` and the index is the half
35
+ // that is memorable -- and it used to reach `.lastIndexOf` and come back as
36
+ // a bare TypeError with no `code`, the D-135 shape.
37
+ if (typeof eventId !== 'string') {
38
+ throw new EventError('events.malformed-event-id', `${JSON.stringify(eventId) ?? String(eventId)} (${typeof eventId}) is not an event id. The form is <session-id>#<index>, e.g. dogfood-envkit-1#42.`, { event_id: eventId });
39
+ }
40
+ const cut = eventId.lastIndexOf('#');
41
+ const sessionId = cut === -1 ? '' : eventId.slice(0, cut);
42
+ const rawIndex = eventId.slice(cut + 1);
43
+ // Number.parseInt would accept "3abc"; an event id is exact or it is a typo.
44
+ const index = /^\d+$/.test(rawIndex) ? Number(rawIndex) : Number.NaN;
45
+ if (cut === -1 || sessionId.length === 0 || Number.isNaN(index)) {
46
+ throw new EventError('events.malformed-event-id', `"${eventId}" is not an event id. The form is <session-id>#<index>, e.g. dogfood-envkit-1#42.`, { event_id: eventId });
47
+ }
48
+ return { sessionId, index };
49
+ }
50
+ /**
51
+ * Where an event sits in the order the log actually wrote it.
52
+ *
53
+ * An event id is `<session-id>#<index>` and the index is the event's line in
54
+ * its session's log, so within a session this *is* the order rather than a
55
+ * proxy for it. Across sessions the logs are separate files that nothing
56
+ * interleaves, so the session id is here to make the answer the same on every
57
+ * call, not because one session precedes another.
58
+ *
59
+ * Total where parseEventId throws, and the fallback is unreachable by
60
+ * construction — every id this sees came out of readEvents() or a projection
61
+ * of it, and readEvents builds each one as `<session>#<index>`. It is here
62
+ * because the readers on top of this are a dashboard `/api/pulse` polls every
63
+ * 5s and two audits the operator runs on a whole log, and an id that somehow
64
+ * would not parse should sort somewhere rather than take the caller down with
65
+ * it. Where it lands is deliberately modest: an event whose place in the log
66
+ * is unreadable does not get to win a tie on it.
67
+ */
68
+ function logOrderOf(eventId) {
69
+ try {
70
+ return parseEventId(eventId);
71
+ }
72
+ catch {
73
+ return { sessionId: eventId, index: -1 };
74
+ }
75
+ }
76
+ /**
77
+ * The order the log wrote two events in: negative when `a` came first,
78
+ * positive when `b` did, zero only for the same event.
79
+ *
80
+ * `ts` is stamped at millisecond resolution (appendEvent, just below), so a
81
+ * burst of appends routinely shares one: the test fixture's own last two
82
+ * events tie in roughly one build in three, and a gate outcome and the retry
83
+ * dispatched in answer to it are written back to back. Nothing else in the
84
+ * record carries the sequence — `events_raw` has no such column and the JSONL
85
+ * line has no such field — so on a tie `ts` has nothing left to say, and
86
+ * nothing downstream of it does either. A `>` between two tied rows is not a
87
+ * decision, it is whichever the scan reached first; `ORDER BY ts` is the same
88
+ * non-answer spelled in SQL, since SQLite promises nothing about tied rows and
89
+ * hands them back in physical order, which changes the moment a row is
90
+ * rewritten; and a JS `.sort()` whose comparator returns 0 throughout is
91
+ * stable, so it keeps that same scan order and passes it off as chronology.
92
+ *
93
+ * The log index behind the event id is what actually decides, and it has to be
94
+ * read as a number: ordered as text — which is what `ORDER BY ts, event_id`
95
+ * does — `#9` sorts after `#10`, so the tiebreaker inverts as soon as a
96
+ * session's log passes ten events.
97
+ *
98
+ * It lives beside parseEventId rather than in any one reader because more than
99
+ * one of them asks this question: the dashboard queries fold and sort rows,
100
+ * escalation.ts walks a task's rounds looking for the dispatch on either side
101
+ * of one. Every ordering a reader would call chronological routes through
102
+ * here, so that no two of them can answer the same question about the same two
103
+ * events differently — which is exactly what happened when the callers spelled
104
+ * the comparison themselves, with opposite operators.
105
+ */
106
+ export function compareLogOrder(a, b) {
107
+ if (a.ts !== b.ts)
108
+ return a.ts < b.ts ? -1 : 1;
109
+ const left = logOrderOf(a.eventId);
110
+ const right = logOrderOf(b.eventId);
111
+ if (left.sessionId !== right.sessionId)
112
+ return left.sessionId < right.sessionId ? -1 : 1;
113
+ return left.index - right.index;
114
+ }
115
+ /**
116
+ * Whether `a` is the later of two events — the one a reader means by "what
117
+ * just happened".
118
+ */
119
+ export function isLaterEvent(a, b) {
120
+ return compareLogOrder(a, b) > 0;
121
+ }
122
+ /**
123
+ * The task an event names, read from both places one can be written.
124
+ *
125
+ * D-245. The envelope's `task_id` is the field every projector keys on, and
126
+ * every machine producer stamps it. The payload copy is what a hand-written
127
+ * dispatch carries: `.claude/skills/bs/dispatch.md` lists the dispatch payload's
128
+ * four required fields and never says where the task id goes, so the operator
129
+ * writes it in beside them. 15 of 19 dispatches in dogfood-envkit-1 and 14 of
130
+ * 82 in dogfood-demo-rpg-1 name their task only there.
131
+ *
132
+ * Reading one level is not a partial answer, it is a wrong one. An agent whose
133
+ * dispatch names no task cannot be reached by that task's terminal event, so
134
+ * it reads "no task assigned" until the epic verdict sweeps it `abandoned`,
135
+ * and the task itself never leaves `todo` on the board. budgetAlarm.ts (D-172)
136
+ * and dispatchAudit.ts each found this and fixed it behind a private copy of
137
+ * this function; the rule belongs to the module that owns the record shape, so
138
+ * every reader of that shape can reach it -- the move D-143 made for
139
+ * `taskIdsMatch`.
140
+ *
141
+ * The envelope wins when both are set: it is the indexed column, and a payload
142
+ * that disagrees with it is a producer bug, not a second opinion. An empty
143
+ * string names no task at either level (D-244), and neither does a non-string
144
+ * -- every field here has been through a JSONL round-trip, where TypeScript
145
+ * stopped watching (D-135).
146
+ */
147
+ export function eventTaskId(record) {
148
+ if (typeof record.task_id === 'string' && record.task_id.length > 0)
149
+ return record.task_id;
150
+ const fromPayload = record.payload?.task_id;
151
+ return typeof fromPayload === 'string' && fromPayload.length > 0 ? fromPayload : null;
152
+ }
153
+ let cachedTaxonomy;
154
+ let cachedSchemas;
155
+ function resolveTaxonomyAndSchemas(opts) {
156
+ if (cachedTaxonomy === undefined)
157
+ cachedTaxonomy = loadTaxonomy();
158
+ if (cachedSchemas === undefined)
159
+ cachedSchemas = compileSchemas(cachedTaxonomy);
160
+ const taxonomy = opts.taxonomy ?? cachedTaxonomy;
161
+ const schemas = opts.schemas ?? cachedSchemas;
162
+ return { taxonomy, schemas };
163
+ }
164
+ /**
165
+ * D-197: a session id is a file name before it is anything else.
166
+ *
167
+ * `logPath` interpolates the id straight into a path and `appendEvent` mints
168
+ * `<id>#<index>` from it, so an id that is not one path segment breaks both
169
+ * ends at once and neither end said so. `''` produced the id `#0`, which
170
+ * parseEventId refuses — the session's own second event was impossible,
171
+ * because causal_parent could not name its first. `'a/b/c'` and `'../x'`
172
+ * appended happily (appendEventLocked mkdirs the parent, recursive), and read
173
+ * back through the same id, so nothing local looked wrong: what breaks is
174
+ * every reader that enumerates the directory instead of naming the id.
175
+ * projector.ts's listSessionIds and the UI server's refresher both readdir
176
+ * `state/events` non-recursively and keep entries ending in `.jsonl`, so a log
177
+ * one directory down is not a session as far as the database and the
178
+ * dashboard are concerned, and no row, no warning and no count records that a
179
+ * log was skipped.
180
+ *
181
+ * The check is structural, not a charset: exactly the ids that would land
182
+ * outside `<dir>/<id>.jsonl` are refused, and nothing else. `#` in particular
183
+ * stays legal — parseEventId splits on the LAST one deliberately, and a `#`
184
+ * cannot move a file.
185
+ */
186
+ function requireSessionIdShape(sessionId) {
187
+ if (sessionId.length > 0 && sessionId !== '.' && sessionId !== '..') {
188
+ if (path.basename(sessionId) === sessionId)
189
+ return;
190
+ }
191
+ throw new EventError('events.malformed-session-id', `"${sessionId}" is not a session id. A session id names one event log file, so it must be a single non-empty path segment — no "/", and not "." or "..".`, { session_id: sessionId });
192
+ }
193
+ function logPath(sessionId, opts) {
194
+ requireSessionIdShape(sessionId);
195
+ const dir = opts.stateDir ?? STATE_EVENTS_DIR;
196
+ return path.join(dir, `${sessionId}.jsonl`);
197
+ }
198
+ async function readEventsAtPath(filePath, sessionId) {
199
+ if (!existsSync(filePath))
200
+ return [];
201
+ const text = await readFile(filePath, 'utf8');
202
+ const lines = text
203
+ .trimEnd()
204
+ .split('\n')
205
+ .filter((line) => line.length > 0);
206
+ return lines.map((line, index) => ({
207
+ event_id: `${sessionId}#${index}`,
208
+ record: JSON.parse(line),
209
+ }));
210
+ }
211
+ /**
212
+ * Per-log serialization *inside one process*: concurrent appendEvent calls on
213
+ * the same session log must not derive the same line-count index or read a
214
+ * stale event list for causal_parent lookup (TOCTOU). One promise chain per
215
+ * resolved file path.
216
+ *
217
+ * This is the fast path, not the guarantee — see `withLogLock` below for the
218
+ * part that holds across processes. Keeping both matters: the queue means the
219
+ * common case of two appends in one process never touches the filesystem lock
220
+ * at all, so nothing polls when nothing is contended.
221
+ */
222
+ const fileQueues = new Map();
223
+ function enqueue(key, task) {
224
+ const previous = fileQueues.get(key) ?? Promise.resolve();
225
+ const next = previous.then(task, task);
226
+ // Store a rejection-swallowed continuation so one failed append never
227
+ // permanently jams the queue for the next caller; the real outcome for
228
+ // *this* call is still `next`, returned below.
229
+ fileQueues.set(key, next.then(() => undefined, () => undefined));
230
+ return next;
231
+ }
232
+ /**
233
+ * Cross-process serialization for one session log.
234
+ *
235
+ * `fileQueues` orders appends within a Node process, and for a long time that
236
+ * was the whole story: queue, gates and CLI ran as one process per factory
237
+ * session, so one promise chain was the only ordering anyone needed. Running a
238
+ * wave in parallel ends that assumption. An orchestrator that dispatches five
239
+ * tasks and records five events in five `smith` invocations has five
240
+ * processes, five queues, and five independent readers of `existing.length` —
241
+ * and they all read the same number.
242
+ *
243
+ * What that costs is subtle enough to be worth naming. The log survives: ids
244
+ * are derived from line position at read time (`readEventsAtPath`), so five
245
+ * concurrent appends leave five well-formed lines and the file reads back
246
+ * correctly. It is the id handed *back* to each caller that is wrong, and the
247
+ * caller feeds that id to the next command as `--causal-parent`. The wrong
248
+ * parent then names an event that genuinely exists, so `validateCausalParent`
249
+ * accepts it and the lineage is quietly mis-shaped. A wrong parent that
250
+ * validates is worse than one that fails.
251
+ *
252
+ * Hence a lock file beside the log. `open(..., 'wx')` is atomic on every
253
+ * filesystem this runs on: exactly one process creates it, everyone else gets
254
+ * EEXIST and waits. The whole read-validate-append sequence happens inside it,
255
+ * which is what makes the returned index true — it counts the lines another
256
+ * process wrote before us, because it is taken after that process let go.
257
+ *
258
+ * The lock is named `<session>.jsonl.lock`, which `listSessionIds` does not
259
+ * see: it selects on `.jsonl` as the suffix, and this is not one.
260
+ */
261
+ const LOCK_POLL_MS = 12;
262
+ /**
263
+ * A lock older than this belonged to a process that died holding it. The
264
+ * window is generous on purpose: breaking a live lock reintroduces exactly the
265
+ * race the lock exists to remove, while waiting too long only ever costs a
266
+ * stalled append that an operator can see and retry.
267
+ */
268
+ const LOCK_STALE_MS = 30_000;
269
+ /** How long to wait for a peer before calling the log jammed rather than busy. */
270
+ const LOCK_TIMEOUT_MS = 60_000;
271
+ function sleep(ms) {
272
+ return new Promise((resolve) => setTimeout(resolve, ms));
273
+ }
274
+ /**
275
+ * Age of the lock file in milliseconds, or `undefined` if it went away while
276
+ * we were asking — which is not an error but the good outcome: the holder
277
+ * finished, and the next acquire attempt is the one that succeeds.
278
+ */
279
+ async function lockAgeMs(lockPath) {
280
+ try {
281
+ return Date.now() - (await stat(lockPath)).mtimeMs;
282
+ }
283
+ catch {
284
+ return undefined;
285
+ }
286
+ }
287
+ async function withLogLock(filePath, task) {
288
+ const lockPath = `${filePath}.lock`;
289
+ await mkdir(path.dirname(lockPath), { recursive: true });
290
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
291
+ for (;;) {
292
+ let handle;
293
+ try {
294
+ handle = await open(lockPath, 'wx');
295
+ }
296
+ catch (error) {
297
+ if (error.code !== 'EEXIST')
298
+ throw error;
299
+ const age = await lockAgeMs(lockPath);
300
+ if (age !== undefined && age > LOCK_STALE_MS) {
301
+ // Best-effort: if a peer breaks it first, our next `wx` simply wins or
302
+ // waits again. `force` keeps that harmless.
303
+ await rm(lockPath, { force: true });
304
+ continue;
305
+ }
306
+ if (Date.now() > deadline) {
307
+ throw new EventError('events.log-busy', `Another process has held the log for ${filePath} longer than ${LOCK_TIMEOUT_MS}ms. ` +
308
+ 'Nothing was written. If no `smith` process is running, delete the stale lock file.', { lock_path: lockPath });
309
+ }
310
+ await sleep(LOCK_POLL_MS);
311
+ continue;
312
+ }
313
+ try {
314
+ // Written for a person reading a stuck lock, never read back by code.
315
+ await handle.writeFile(`${process.pid}\n`, 'utf8');
316
+ return await task();
317
+ }
318
+ finally {
319
+ await handle.close();
320
+ await rm(lockPath, { force: true });
321
+ }
322
+ }
323
+ }
324
+ // event_type -> which taxonomy record type its payload must satisfy, and how
325
+ // payload field names map onto that record type's required dimension names
326
+ // (architecture §7 payload shapes; taxonomy.yml rules.required_dimensions).
327
+ // Extend this map if/when another payload-typed event kind is emitted;
328
+ // edge-recorded is already covered separately via event.schema.json's
329
+ // x-taxonomy on the `edge` sub-object.
330
+ const PAYLOAD_DIMENSION_MAP = {
331
+ dispatch_decision: {
332
+ recordType: 'dispatch',
333
+ fieldMap: {
334
+ agent: 'agent_role',
335
+ provider: 'provider',
336
+ model_tier: 'model_tier',
337
+ // P9-23: the concrete model, not just its tier. Required, because an
338
+ // optional field would be dropped exactly where it matters and the
339
+ // absence would read as compliance; presence-only in taxonomy.ts,
340
+ // because model ids are not a closed vocabulary.
341
+ model: 'model',
342
+ },
343
+ },
344
+ // Phase 8 addition (architecture §6/§7 note, providers/quorum.ts): one
345
+ // per external judge run, `agent`/`provider`/`model_tier` payload fields
346
+ // already match the taxonomy dimension names 1:1 (no renaming needed,
347
+ // unlike dispatch_decision's agent_role -> agent), so fieldMap is an
348
+ // identity map.
349
+ 'judge-verdict': {
350
+ recordType: 'dispatch',
351
+ fieldMap: { agent: 'agent', provider: 'provider', model_tier: 'model_tier', model: 'model' },
352
+ },
353
+ 'error-logged': {
354
+ recordType: 'error',
355
+ fieldMap: { error: 'error', severity: 'severity', task_ref: 'task_ref' },
356
+ },
357
+ // Phase 5 addition (deviation, see db/projector.ts header comment): the
358
+ // full lesson record — every dimension taxonomy.yml's rules.
359
+ // required_dimensions.lesson demands is present on THIS event's payload.
360
+ 'lesson-candidate-raised': {
361
+ recordType: 'lesson',
362
+ fieldMap: {
363
+ lesson_type: 'lesson_type',
364
+ lesson_level: 'lesson_level',
365
+ lesson_status: 'lesson_status',
366
+ lesson_scope: 'lesson_scope',
367
+ provenance_event_ids: 'provenance_event_ids',
368
+ },
369
+ },
370
+ };
371
+ // event_type -> a single taxonomy-valued payload field to check, for event
372
+ // kinds that carry only ONE dimension-tagged field rather than a full
373
+ // record (mirrors finding-transitioned's shape: a status change, not a new
374
+ // record — PAYLOAD_DIMENSION_MAP's required_dimensions check would
375
+ // wrongly demand the OTHER lesson fields that only the raise event carries).
376
+ const PAYLOAD_TAG_MAP = {
377
+ 'lesson-status-changed': { dimension: 'lesson_status', payloadField: 'to_status' },
378
+ // P9-11: the terminal half of a judge dispatch (judges.ts). It names a role
379
+ // and nothing else dimension-valued — the provider and tier were settled by
380
+ // the `dispatch_decision` this closes, and repeating them here would be a
381
+ // second place for them to disagree.
382
+ 'judge-reported': { dimension: 'agent', payloadField: 'agent_role' },
383
+ };
384
+ // event_type -> several OPTIONAL taxonomy-valued payload fields, each
385
+ // validated only when present. Mirrors PAYLOAD_TAG_MAP, but for an event
386
+ // that carries a PARTIAL update over more than one dimension at once rather
387
+ // than exactly one field — lesson-edited's Edit action lets the operator
388
+ // change just the statement, just the type, just the scope, just the
389
+ // selector the scope names, or any combination (ui/server/src/app.ts's edit
390
+ // route), so neither
391
+ // PAYLOAD_DIMENSION_MAP (demands every field) nor PAYLOAD_TAG_MAP (checks
392
+ // exactly one) fits. Without this, `lessonType: "NOT-A-REAL-TYPE"` would
393
+ // persist silently — db/projector.ts's foldLessons() writes whatever
394
+ // lesson_type/lesson_scope string it is given, unchecked.
395
+ const PAYLOAD_PARTIAL_TAG_MAP = {
396
+ 'lesson-edited': [
397
+ { dimension: 'lesson_type', payloadField: 'lesson_type' },
398
+ { dimension: 'lesson_scope', payloadField: 'lesson_scope' },
399
+ // D-129 added these two so that re-scoping a lesson to a selector scope
400
+ // can name the selector in the same edit. They are taxonomy-valued like
401
+ // the two above and lessons.ts's transitionLesson checks them -- but a
402
+ // writer's guard is not the log's, and everything the paragraph above
403
+ // says of an unchecked lesson_type is true of these: foldLessons writes
404
+ // the string it is given onto the row, and a lesson scoped to an agent
405
+ // role no agent has is a lesson that reaches nobody, silently.
406
+ //
407
+ // The dimension names are the taxonomy's ('agent', 'case'), not the
408
+ // payload field's -- same rename dispatch_decision's fieldMap makes.
409
+ { dimension: 'agent', payloadField: 'agent_role' },
410
+ { dimension: 'case', payloadField: 'case_type' },
411
+ ],
412
+ // D-215. taxonomy.yml's rules.required_dimensions names six record types;
413
+ // `dispatch`, `error` and `lesson` are checked above and `edge` goes
414
+ // through validateRequiredDimensions in plan.ts -- `task` was the one with
415
+ // no write-time check anywhere, and `task-added` is the only event that
416
+ // carries one. It matters more than the others, not less: db/projector.ts's
417
+ // `row.taskStatus = p.task_status ?? row.taskStatus` is the ONLY place a
418
+ // payload string becomes a task's status (every other assignment there is a
419
+ // literal from this vocabulary), and ui/src/lib/kanban.ts folds that status
420
+ // through a lookup with no bucket for a value it does not recognise -- so a
421
+ // typo does not render oddly, it removes the task from the board, in "All"
422
+ // mode too. `smith plan ingest` never calls validatePlan(), so the schema's
423
+ // x-taxonomy on task-spec.schema.json is opt-in and this is the only stop.
424
+ //
425
+ // Partial rather than PAYLOAD_DIMENSION_MAP: presence of the three is
426
+ // task-spec.schema.json's business and `plan validate`'s, and demanding
427
+ // them here would reject the sparse `task-added` payloads that legitimately
428
+ // carry only an epic and an objective. This rule is about the value of a
429
+ // field that is present.
430
+ 'task-added': [
431
+ { dimension: 'task_status', payloadField: 'task_status' },
432
+ { dimension: 'case', payloadField: 'case' },
433
+ { dimension: 'origin', payloadField: 'origin' },
434
+ ],
435
+ };
436
+ /**
437
+ * Every event_type the three maps above key on — a third hand-kept list of
438
+ * event types, after the taxonomy's and queries.ts's. Exported so the P9-37
439
+ * lint (test/eventTypes.test.ts) can hold it against the other two: a key here
440
+ * that names no event anyone writes is a payload rule that never fires, and a
441
+ * typo in the key is exactly that, silently.
442
+ */
443
+ export const PAYLOAD_TAGGED_EVENT_TYPES = [
444
+ ...Object.keys(PAYLOAD_DIMENSION_MAP),
445
+ ...Object.keys(PAYLOAD_TAG_MAP),
446
+ ...Object.keys(PAYLOAD_PARTIAL_TAG_MAP),
447
+ ];
448
+ function checkTag(taxonomy, eventType, dimension, value) {
449
+ try {
450
+ validateTag(taxonomy, dimension, String(value));
451
+ }
452
+ catch (err) {
453
+ throw new EventError('events.invalid-payload-dimensions', `Event payload for "${eventType}" failed taxonomy validation: ${err instanceof Error ? err.message : String(err)}`, { event_type: eventType });
454
+ }
455
+ }
456
+ function validatePayloadDimensions(taxonomy, record) {
457
+ const mapping = PAYLOAD_DIMENSION_MAP[record.event_type];
458
+ if (mapping) {
459
+ const payload = record.payload;
460
+ const mapped = {};
461
+ for (const [dimensionField, payloadField] of Object.entries(mapping.fieldMap)) {
462
+ mapped[dimensionField] = payload[payloadField];
463
+ }
464
+ try {
465
+ validateRequiredDimensions(taxonomy, mapping.recordType, mapped);
466
+ }
467
+ catch (err) {
468
+ throw new EventError('events.invalid-payload-dimensions', `Event payload for "${record.event_type}" failed taxonomy validation: ${err instanceof Error ? err.message : String(err)}`, { event_type: record.event_type });
469
+ }
470
+ return;
471
+ }
472
+ const tagMapping = PAYLOAD_TAG_MAP[record.event_type];
473
+ if (tagMapping) {
474
+ const value = record.payload[tagMapping.payloadField];
475
+ if (value === undefined)
476
+ return; // presence is the caller's job, not ours
477
+ checkTag(taxonomy, record.event_type, tagMapping.dimension, value);
478
+ return;
479
+ }
480
+ const partialTagMapping = PAYLOAD_PARTIAL_TAG_MAP[record.event_type];
481
+ if (partialTagMapping) {
482
+ for (const { dimension, payloadField } of partialTagMapping) {
483
+ const value = record.payload[payloadField];
484
+ if (value === undefined)
485
+ continue; // this partial update did not touch this field
486
+ checkTag(taxonomy, record.event_type, dimension, value);
487
+ }
488
+ }
489
+ }
490
+ /**
491
+ * Event types whose payload IS a schema-defined record, not free-form JSON.
492
+ *
493
+ * D-135. The event schema types `payload` as an open object, so the envelope
494
+ * check passes on any payload at all. That is right for most event types —
495
+ * the payload is a note for a human reader. It is wrong for the few whose
496
+ * payload is later rehydrated into a typed object by a reader: `foldFindings`
497
+ * rebuilds each Finding from `record.payload` alone, so a payload missing a
498
+ * required field produces a Finding with `task_id: undefined`, and the crash
499
+ * lands in whichever reader touches it first — three frames and possibly
500
+ * three weeks from the actor that wrote it. Validating here puts the error
501
+ * where the bad record is still attributable, and keeps the log's invariant
502
+ * true for readers rather than merely hoped for.
503
+ */
504
+ const TYPED_PAYLOAD_SCHEMAS = {
505
+ 'finding-raised': 'finding',
506
+ };
507
+ function validateTypedPayload(schemas, taxonomy, record) {
508
+ const schemaName = TYPED_PAYLOAD_SCHEMAS[record.event_type];
509
+ if (schemaName === undefined)
510
+ return;
511
+ const result = validateRecord(schemas, taxonomy, schemaName, record.payload);
512
+ if (!result.valid) {
513
+ throw new EventError('events.invalid-typed-payload', `Event payload for "${record.event_type}" failed "${schemaName}" schema validation. Readers rehydrate this payload into a typed record, so an incomplete one becomes a crash in a later reader rather than an error here.`, { event_type: record.event_type, schema: schemaName, errors: result.errors });
514
+ }
515
+ }
516
+ /**
517
+ * P9-7: causal_parent may point into ANOTHER session's log, but only from a
518
+ * session root.
519
+ *
520
+ * The concurrency ceiling on an epic is the orchestrator's context window —
521
+ * fan-out is bounded by the claim graph, not by a cap — so the documented way
522
+ * to run a large epic is to split it across operator sessions. That was
523
+ * unexpressible: this check read only the appending session's own log, so a
524
+ * second session's first event could never name the first session's timeline.
525
+ *
526
+ * The edge is restricted to `session-start` deliberately. One entry edge per
527
+ * session makes the log a tree of sessions rather than a general graph: a
528
+ * reader can answer "where did this session come from" by looking at one
529
+ * event, and `sessionLineage` terminates. Letting any event point anywhere
530
+ * would buy nothing the root edge does not already express.
531
+ */
532
+ async function validateCausalParent(input, opts, existing) {
533
+ const parent = parseEventId(input.causal_parent);
534
+ if (parent.sessionId === input.session_id) {
535
+ if (!existing.some((e) => e.event_id === input.causal_parent)) {
536
+ throw new EventError('events.unknown-causal-parent', `causal_parent "${input.causal_parent}" does not reference an existing event in session "${input.session_id}".`, { causal_parent: input.causal_parent, session_id: input.session_id });
537
+ }
538
+ return;
539
+ }
540
+ if (input.event_type !== ROOT_EVENT_TYPE) {
541
+ throw new EventError('events.cross-session-parent-not-root', `Only a "${ROOT_EVENT_TYPE}" event may name a parent in another session; "${input.event_type}" in session "${input.session_id}" named "${input.causal_parent}". Chain the session root to the other session, then chain this event locally.`, {
542
+ causal_parent: input.causal_parent,
543
+ session_id: input.session_id,
544
+ event_type: input.event_type,
545
+ });
546
+ }
547
+ // Read outside the parent log's append queue. Safe because logs are
548
+ // append-only: a concurrent append there can add events, never remove the
549
+ // one we are about to point at.
550
+ const parentPath = logPath(parent.sessionId, opts);
551
+ if (!existsSync(parentPath)) {
552
+ throw new EventError('events.unknown-causal-session', `causal_parent "${input.causal_parent}" names session "${parent.sessionId}", which has no event log (expected ${parentPath}).`, { causal_parent: input.causal_parent, session_id: parent.sessionId, path: parentPath });
553
+ }
554
+ const parentEvents = await readEventsAtPath(parentPath, parent.sessionId);
555
+ if (!parentEvents.some((e) => e.event_id === input.causal_parent)) {
556
+ throw new EventError('events.unknown-causal-parent', `causal_parent "${input.causal_parent}" does not reference an existing event in session "${parent.sessionId}" (it holds ${parentEvents.length}).`, { causal_parent: input.causal_parent, session_id: parent.sessionId });
557
+ }
558
+ }
559
+ async function appendEventLocked(input, opts, filePath) {
560
+ // EventInput requires causal_parent, so inside the orchestrator undefined is
561
+ // unreachable. It is reachable from outside: every event appended through the
562
+ // CLI crosses a JSON boundary where the type guarantees nothing, and omitting
563
+ // a field is the likeliest way a hand-written event is malformed. Both guards
564
+ // below used to compare against `null` alone, so an omitted parent fell
565
+ // through them into parseEventId(undefined) and reached the caller as a raw
566
+ // TypeError with no `code` — the one shape produced by accident was the one
567
+ // shape with nothing to branch on. Normalise once so both guards read it.
568
+ const causalParent = input.causal_parent ?? null;
569
+ if (causalParent === null && input.event_type !== ROOT_EVENT_TYPE) {
570
+ throw new EventError('events.missing-causal-parent', `causal_parent may only be null for "${ROOT_EVENT_TYPE}" events (got event_type "${input.event_type}").`, { event_type: input.event_type });
571
+ }
572
+ await mkdir(path.dirname(filePath), { recursive: true });
573
+ const existing = await readEventsAtPath(filePath, input.session_id);
574
+ if (causalParent !== null) {
575
+ await validateCausalParent(input, opts, existing);
576
+ }
577
+ const record = { ...input, ts: new Date().toISOString() };
578
+ const { taxonomy, schemas } = resolveTaxonomyAndSchemas(opts);
579
+ const result = validateRecord(schemas, taxonomy, 'event', record);
580
+ if (!result.valid) {
581
+ throw new EventError('events.invalid-record', 'Event failed schema/taxonomy validation.', {
582
+ errors: result.errors,
583
+ });
584
+ }
585
+ validatePayloadDimensions(taxonomy, record);
586
+ validateTypedPayload(schemas, taxonomy, record);
587
+ const index = existing.length;
588
+ await appendFile(filePath, `${JSON.stringify(record)}\n`, 'utf8');
589
+ return { event_id: `${input.session_id}#${index}`, record };
590
+ }
591
+ /**
592
+ * Append one event. Stamps ts, validates via schemas.ts + taxonomy.ts
593
+ * (including payload-level taxonomy dimensions for dispatch/error events),
594
+ * enforces the causal_parent invariant (must reference a real prior event
595
+ * in this session, null only for session-start), and serializes concurrent
596
+ * appends to the same log — a rejected event never touches the log file.
597
+ *
598
+ * Serialized twice, deliberately: `enqueue` orders callers inside this
599
+ * process, `withLogLock` orders this process against every other `smith`
600
+ * running against the same log. A wave dispatched in parallel needs the
601
+ * second one; see its docblock for what a missing lock actually costs.
602
+ */
603
+ export async function appendEvent(input, opts = {}) {
604
+ const filePath = logPath(input.session_id, opts);
605
+ return enqueue(filePath, () => withLogLock(filePath, () => appendEventLocked(input, opts, filePath)));
606
+ }
607
+ /**
608
+ * Open a session: write the one `session-start` its log is allowed to hold.
609
+ *
610
+ * This exists because `appendEvent` cannot be the thing that enforces "one
611
+ * root per log", and should not become it. That writer is open on purpose
612
+ * (D-163): `event_type` is a free string so a type nobody has declared yet is
613
+ * still recorded rather than lost, and the same openness reaches the root --
614
+ * a second `session-start` with a null `causal_parent`, into a log that
615
+ * already has one, satisfies every rule the writer has and is receipted as
616
+ * `#1` with exit 0.
617
+ *
618
+ * Nothing downstream reads that second line. `sessionLineage` takes the FIRST
619
+ * root, and the tree-of-sessions reading the whole cross-session edge is built
620
+ * on assumes there is exactly one entry point per session. So the second root
621
+ * is not a second beginning; it is a line no reader will ever look at, written
622
+ * by someone who believed they were starting fresh and was told they had.
623
+ *
624
+ * A dedicated verb can be closed where the writer has to stay open, and that
625
+ * is the whole design: the rule lives in the command whose only job is the
626
+ * root, so `appendEvent` keeps the property that makes it safe to hand an
627
+ * unknown record to.
628
+ *
629
+ * The emptiness check runs inside the same lock as the append, for the reason
630
+ * `withLogLock` gives above: checking outside it would read a length another
631
+ * process is in the middle of changing, and a guard that passes because it
632
+ * looked a moment too early is worse than no guard, since it reports success.
633
+ */
634
+ export async function startSession(sessionId, opts = {}) {
635
+ const filePath = logPath(sessionId, opts);
636
+ const input = {
637
+ session_id: sessionId,
638
+ actor: opts.actor ?? 'operator',
639
+ event_type: ROOT_EVENT_TYPE,
640
+ plan_version: 1,
641
+ causal_parent: opts.continues ?? null,
642
+ payload: {},
643
+ };
644
+ return enqueue(filePath, () => withLogLock(filePath, async () => {
645
+ const existing = await readEventsAtPath(filePath, sessionId);
646
+ const last = existing[existing.length - 1];
647
+ if (last !== undefined) {
648
+ // Names the anchor rather than only the refusal. Someone who reaches
649
+ // for this verb on a live session wants the event id their next
650
+ // command needs as `--causal-parent`, and it is right here.
651
+ throw new EventError('events.session-already-started', `Session "${sessionId}" is already open — its log holds ${existing.length} event(s), the last being "${last.event_id}". A session log has one root, so there is nothing here to start. Chain your next command off "${last.event_id}", or pick a session id nothing has used.`, { session_id: sessionId, event_id: last.event_id, events: existing.length });
652
+ }
653
+ return appendEventLocked(input, opts, filePath);
654
+ }));
655
+ }
656
+ /** Convenience wrapper for edge-recorded events (edge_type + edge_provenance required). */
657
+ export async function appendEdge(input, edge, opts = {}) {
658
+ return appendEvent({ ...input, event_type: input.event_type ?? 'edge-recorded', edge }, opts);
659
+ }
660
+ /**
661
+ * ONE session's log.
662
+ *
663
+ * Since D-119 this is the narrow reader, and the narrow scope is the thing a
664
+ * caller has to ask for: anything that decides something about an epic wants
665
+ * `readLineageEvents`, because an epic is allowed to span sessions and a fold
666
+ * that stops at the session boundary answers `go` for the half it cannot see.
667
+ * The remaining callers here want exactly one session and say so — the
668
+ * projector (it writes rows keyed by session_id), `closeEpic`'s "does THIS
669
+ * session have a log" guard, `walkLineage` itself, and the plain `event tail`.
670
+ */
671
+ export async function readEvents(sessionId, opts = {}) {
672
+ return readEventsAtPath(logPath(sessionId, opts), sessionId);
673
+ }
674
+ /**
675
+ * Assert that this session has a log at all.
676
+ *
677
+ * P9-28: `readEvents` answers `[]` for a log that does not exist, and it has
678
+ * to — `appendEvent` reads the log to derive the next line index and to check
679
+ * `causal_parent`, so a session's very first event depends on "absent" meaning
680
+ * "empty". The cost lands on the reading side, which then cannot tell *no such
681
+ * session* from *this session did nothing*, and every reader reported the
682
+ * second: `smith event tail typo-in-the-id` printed `[]` and exited 0.
683
+ *
684
+ * So the check belongs here rather than inside readEvents, and readers opt
685
+ * into it — precisely so writers keep the behaviour they need. It says nothing
686
+ * about an existing-but-empty log; that one really is an empty session.
687
+ */
688
+ export function requireSession(sessionId, opts = {}) {
689
+ const filePath = logPath(sessionId, opts);
690
+ if (!existsSync(filePath)) {
691
+ throw new EventError('events.unknown-session', `No event log for session '${sessionId}' (expected ${filePath}). Check the id, or create the session with a session-start event.`, { session_id: sessionId, path: filePath });
692
+ }
693
+ }
694
+ /**
695
+ * The chain of sessions this one continues, plus the sessions that continue
696
+ * it, as separate lists.
697
+ *
698
+ * D-266's read side, and the shape `smith event lineage` prints from. Callers
699
+ * that only want the union take `sessionLineage`.
700
+ */
701
+ export async function sessionTree(sessionId, opts = {}) {
702
+ // `require` — a caller naming a session by hand gets told when the name is
703
+ // wrong. readLineageEvents does not, and its comment says why.
704
+ const { ancestors, continuations } = await walkLineage(sessionId, opts, true);
705
+ const ancestry = ancestors.map((s) => s.sessionId);
706
+ const continued = continuations.map((s) => s.sessionId);
707
+ return { sessions: [...ancestry, ...continued], ancestry, continuations: continued };
708
+ }
709
+ /**
710
+ * Every session in this one's lineage: what it continues, itself, and what
711
+ * continued it.
712
+ *
713
+ * P9-7 shipped the upward half. A session's entry edge is the `causal_parent`
714
+ * of its `session-start` event; when that names another session, the walk
715
+ * follows it back. A session with no cross-session parent and nothing
716
+ * continuing it answers `[sessionId]` — a lineage of one, not an empty list,
717
+ * because the session is always part of its own lineage.
718
+ *
719
+ * D-266 added the downward half. `--continues` was built for a chain: an epic
720
+ * that outlives one operator window, continued in a fresh session and read
721
+ * from the newest end. The tier split the factory needs is not a chain but a
722
+ * fan-out — an epic session dispatching a disposable session per wave, each of
723
+ * which continues it — and every verb that decides something about an epic
724
+ * (`epic close`, `wave audit`, `findings list`, `stats overview`) is run from
725
+ * the epic end. Walking up only, that end saw a lineage of one and said so in
726
+ * exactly the words it uses for an epic that never fanned out at all. Which is
727
+ * D-119's failure mode, arrived at from the other direction.
728
+ */
729
+ export async function sessionLineage(sessionId, opts = {}) {
730
+ return (await sessionTree(sessionId, opts)).sessions;
731
+ }
732
+ /** The `causal_parent` of a log's `session-start`, however far into the log it sits.
733
+ *
734
+ * The FIRST such event, for the reason `startSession`'s comment gives: a log
735
+ * has one beginning, and a second `session-start` is a line no reader looks at.
736
+ */
737
+ function entryEdge(events) {
738
+ return events.find((e) => e.record.event_type === ROOT_EVENT_TYPE)?.record.causal_parent ?? null;
739
+ }
740
+ /**
741
+ * How much of a log to read when all that is wanted is its entry edge.
742
+ *
743
+ * One `session-start` record is a few hundred bytes; 8KB is slack enough that
744
+ * the full-read fallback below is for malformed logs rather than for ordinary
745
+ * large ones.
746
+ */
747
+ const ENTRY_EDGE_PROBE_BYTES = 8192;
748
+ /**
749
+ * The entry edge of one log, without reading the whole log.
750
+ *
751
+ * The downward walk asks this of every log in the state directory, and every
752
+ * log's answer lives on its first line — so this reads the head of the file
753
+ * and stops. `undefined` means the log is gone (a delete racing the readdir
754
+ * that produced the id), which is the one absence that is not an answer about
755
+ * lineage.
756
+ *
757
+ * The full-read fallback is not defensive padding. `startSession` enforces
758
+ * "the root is line 0", but `appendEvent` deliberately does not (D-163), so a
759
+ * log written event-by-event may carry its root anywhere and the upward walk
760
+ * finds it with a scan. A probe that only ever read line 0 would make the two
761
+ * directions disagree, and a session reachable upward but not downward is the
762
+ * exact asymmetry this walk exists to remove.
763
+ *
764
+ * A first line that is not JSON throws rather than being skipped. Skipping it
765
+ * would drop that session out of its parent's lineage silently, and a fold
766
+ * that cannot tell "no session continues you" from "I could not read whether
767
+ * one does" is the thing D-119 is about. Loud, naming the file, is the answer.
768
+ */
769
+ async function readEntryEdge(sessionId, opts) {
770
+ const filePath = logPath(sessionId, opts);
771
+ let head;
772
+ let bytesRead;
773
+ try {
774
+ const handle = await open(filePath, 'r');
775
+ try {
776
+ const buf = Buffer.alloc(ENTRY_EDGE_PROBE_BYTES);
777
+ ({ bytesRead } = await handle.read(buf, 0, ENTRY_EDGE_PROBE_BYTES, 0));
778
+ head = buf.subarray(0, bytesRead).toString('utf8');
779
+ }
780
+ finally {
781
+ await handle.close();
782
+ }
783
+ }
784
+ catch (err) {
785
+ if (err.code === 'ENOENT')
786
+ return undefined;
787
+ throw err;
788
+ }
789
+ const newline = head.indexOf('\n');
790
+ // No newline inside the probe means the first line is longer than the probe.
791
+ // The cheap read cannot answer, so pay for the whole one.
792
+ if (newline === -1 && bytesRead === ENTRY_EDGE_PROBE_BYTES) {
793
+ return entryEdge(await readEventsAtPath(filePath, sessionId));
794
+ }
795
+ const firstLine = (newline === -1 ? head : head.slice(0, newline)).trim();
796
+ // An empty log continues nothing, exactly as `readEvents` reports it.
797
+ if (firstLine.length === 0)
798
+ return null;
799
+ let record;
800
+ try {
801
+ record = JSON.parse(firstLine);
802
+ }
803
+ catch (err) {
804
+ throw new EventError('events.unreadable-session-log', `The first line of ${filePath} is not JSON, so whether session "${sessionId}" continues another cannot be read: ${errorText(err)}. Every lineage read scans every log in the state directory, so one unparseable log stops all of them — deliberately, because "cannot tell" must not be reported as "continues nothing".`, { session_id: sessionId, path: filePath });
805
+ }
806
+ if (record.event_type === ROOT_EVENT_TYPE)
807
+ return record.causal_parent ?? null;
808
+ return entryEdge(await readEventsAtPath(filePath, sessionId));
809
+ }
810
+ /** Whatever a thrown value has to say for itself, without assuming it is an Error. */
811
+ function errorText(err) {
812
+ return err instanceof Error ? err.message : String(err);
813
+ }
814
+ /**
815
+ * Every session in the state directory that continues another, keyed by the
816
+ * session it continues.
817
+ *
818
+ * Built by probe rather than by full read: the alternative is reading every
819
+ * log on every lineage fold, and `runTick` — the one place that already does
820
+ * that — pays for it because it needs the events, not just the edges. Children
821
+ * come out id-sorted for free, since `listSessionIds` sorts.
822
+ *
823
+ * The cost is one readdir plus one bounded read per log, per lineage fold. It
824
+ * is accepted rather than cached because a cache would have to be invalidated
825
+ * by a writer this module deliberately lets run concurrently in other
826
+ * processes, and a lineage index that is stale is a lineage index that is
827
+ * blind — which is the whole finding.
828
+ */
829
+ async function continuationIndex(opts) {
830
+ const index = new Map();
831
+ for (const sessionId of listSessionIds(opts.stateDir ?? STATE_EVENTS_DIR)) {
832
+ const edge = await readEntryEdge(sessionId, opts);
833
+ if (edge === undefined || edge === null)
834
+ continue;
835
+ const parent = parseEventId(edge).sessionId;
836
+ // A root naming its own log is not a continuation; the upward walk stops
837
+ // on the same condition.
838
+ if (parent === sessionId)
839
+ continue;
840
+ const siblings = index.get(parent);
841
+ if (siblings === undefined)
842
+ index.set(parent, [sessionId]);
843
+ else
844
+ siblings.push(sessionId);
845
+ }
846
+ return index;
847
+ }
848
+ /**
849
+ * One walk up the causal_parent chain and one walk back down it, carrying each
850
+ * session's log along.
851
+ *
852
+ * Both readers need the same walk and the same cycle guard, and the walk has
853
+ * to read every ancestor's log anyway to find its parent — so the events come
854
+ * back with the ids rather than being read a second time by whoever wanted
855
+ * them.
856
+ *
857
+ * Downward from `sessionId` and not from the root, on purpose: a wave session
858
+ * folds in its own ancestry and its own continuations, never its siblings.
859
+ * Folding in siblings would re-couple exactly what dispatching a session per
860
+ * wave decouples, and would make a disposable session's scope grow with every
861
+ * other wave the epic ever ran.
862
+ */
863
+ async function walkLineage(sessionId, opts, require) {
864
+ const ancestors = [];
865
+ const seen = new Set();
866
+ let current = sessionId;
867
+ while (current !== null) {
868
+ if (seen.has(current)) {
869
+ throw new EventError('events.session-lineage-cycle', `Session lineage for "${sessionId}" revisits "${current}". A session-start's causal_parent chain must terminate; this log has been edited by hand.`, { session_id: sessionId, revisited: current });
870
+ }
871
+ seen.add(current);
872
+ if (require)
873
+ requireSession(current, opts);
874
+ const events = await readEvents(current, opts);
875
+ ancestors.unshift({ sessionId: current, events });
876
+ const parent = entryEdge(events);
877
+ const parentSession = parent === null ? null : parseEventId(parent).sessionId;
878
+ current = parentSession === current ? null : parentSession;
879
+ }
880
+ const index = await continuationIndex(opts);
881
+ const continuations = [];
882
+ // Breadth-first: a wave's own continuation sorts after every wave of the
883
+ // round it belongs to, which is the order they were dispatched in.
884
+ let frontier = index.get(sessionId) ?? [];
885
+ while (frontier.length > 0) {
886
+ const next = [];
887
+ for (const child of frontier) {
888
+ // A revisit here is a hand-edited cycle, and the upward walk from the
889
+ // session inside it already throws. Down here it is enough to visit once
890
+ // — throwing would take out every reader of an unrelated lineage that
891
+ // merely shares a state directory with the edited log.
892
+ if (seen.has(child))
893
+ continue;
894
+ seen.add(child);
895
+ continuations.push({ sessionId: child, events: await readEvents(child, opts) });
896
+ next.push(...(index.get(child) ?? []));
897
+ }
898
+ frontier = next;
899
+ }
900
+ return { ancestors, continuations };
901
+ }
902
+ /**
903
+ * Every event in this session's lineage, in one causal order.
904
+ *
905
+ * D-119, the S1 this exists for: `sessionLineage` shipped with P9-7 and was
906
+ * then read by exactly two call sites, both read-only display verbs. Every
907
+ * fold that *decided* something — which findings are open, whether an epic may
908
+ * close, whether a task result is a duplicate — read `readEvents(sessionId)`,
909
+ * one session, and reported its answer in the same words it would have used
910
+ * had it read them all. Splitting the real `dogfood-mcp-1` log in two, the
911
+ * findings in one file and everything else in the other, turned `hold` with
912
+ * eleven open findings into `go` with none. A gate that reads a narrower scope
913
+ * than the thing it guards is off for everything outside that scope, and
914
+ * nothing in its output says so.
915
+ *
916
+ * Order: by `ts` ACROSS sessions, by append position WITHIN one, never by `ts`
917
+ * within one. findings.ts's staleFindings is "strictly ordering-based … a
918
+ * timestamp comparison across separately-clocked producers would not" answer
919
+ * correctly, and that caution holds — a session's own log is already the order
920
+ * its writer appended in, and a clock that steps backwards mid-session must
921
+ * not be able to hand a last-write-wins fold a different winner. Between two
922
+ * sessions the wall clock is the only shared reference there is, and the
923
+ * alternative — concatenating whole logs root-first — is strictly worse the
924
+ * moment a parent keeps working after a continuation chains onto it. Ties go
925
+ * to the older session, which is the direction causality runs.
926
+ *
927
+ * A session with no log answers `[]`, exactly as `readEvents` does and for the
928
+ * same reason (P9-28): this is a drop-in at the fold sites, and making them
929
+ * throw where they used to return would change what a session's first write
930
+ * does. `requireSession` stays the opt-in it already was.
931
+ */
932
+ export async function readLineageEvents(sessionId, opts = {}) {
933
+ const { ancestors, continuations } = await walkLineage(sessionId, opts, false);
934
+ return mergeSessionLogs([...ancestors, ...continuations]);
935
+ }
936
+ /**
937
+ * Every session that has a log in `stateDir`, sorted, with no log read.
938
+ *
939
+ * A session id is only ever a `<id>.jsonl` filename here, so this is the one
940
+ * place that knows it — the projector kept a private copy and the daemon
941
+ * wanted a third, and three answers to "what sessions exist" is how a reader
942
+ * comes to inspect a set the writer never wrote.
943
+ */
944
+ export function listSessionIds(stateDir = STATE_EVENTS_DIR) {
945
+ if (!existsSync(stateDir))
946
+ return [];
947
+ return readdirSync(stateDir)
948
+ .filter((f) => f.endsWith('.jsonl'))
949
+ .map((f) => f.slice(0, -'.jsonl'.length))
950
+ .sort();
951
+ }
952
+ /**
953
+ * Several sessions' logs in one causal order, under `readLineageEvents`'s
954
+ * ordering rule above: by `ts` ACROSS sessions, by append position WITHIN
955
+ * one, ties to whichever log comes first in `logs`.
956
+ *
957
+ * Extracted from `readLineageEvents` for D-199, which needs the same order
958
+ * over a set of sessions that is not a lineage — the projector's lessons
959
+ * table is keyed globally, so it folds every session, not one chain. Two
960
+ * copies of a k-way merge are two chances to answer a last-write-wins fold
961
+ * with different winners, which is the failure this ordering exists to
962
+ * prevent; callers pass their own set and share the one implementation.
963
+ */
964
+ export function mergeSessionLogs(logs) {
965
+ // The overwhelmingly common shape, and the one every pre-D-119 caller had:
966
+ // returned as-is so a single session's order is its own log's, untouched.
967
+ const only = logs.length === 1 ? logs[0] : undefined;
968
+ if (only)
969
+ return only.events;
970
+ // A k-way merge over the per-session cursors. Each session's events are
971
+ // consumed in append order, so the merge can only interleave sessions — it
972
+ // can never reorder one.
973
+ const cursors = logs.map((s) => ({ events: s.events, next: 0 }));
974
+ const total = cursors.reduce((n, c) => n + c.events.length, 0);
975
+ const merged = [];
976
+ while (merged.length < total) {
977
+ let pick;
978
+ let head;
979
+ for (const cursor of cursors) {
980
+ const candidate = cursor.events[cursor.next];
981
+ if (candidate === undefined)
982
+ continue; // this session is exhausted
983
+ // Strict `<`: an equal timestamp leaves `pick` on the older session.
984
+ if (head === undefined || candidate.record.ts < head.record.ts) {
985
+ pick = cursor;
986
+ head = candidate;
987
+ }
988
+ }
989
+ // Unreachable — `total` counts exactly what the cursors hold — but a
990
+ // silent infinite loop is the wrong way to be wrong about that.
991
+ if (pick === undefined || head === undefined)
992
+ break;
993
+ merged.push(head);
994
+ pick.next += 1;
995
+ }
996
+ return merged;
997
+ }
998
+ export async function tailEvents(sessionId, n, opts = {}) {
999
+ const all = await readEvents(sessionId, opts);
1000
+ return all.slice(Math.max(0, all.length - n));
1001
+ }
1002
+ export function filterEvents(events, filter) {
1003
+ return events.filter(({ record }) => {
1004
+ // D-130's two-spelling rule now lives in taskId.ts, where the findings
1005
+ // fold can reach it too (D-143).
1006
+ // D-245: from either level, or a `--task` scope drops the events whose
1007
+ // producer wrote the id in the payload.
1008
+ if (filter.taskId !== undefined &&
1009
+ !taskIdsMatch(eventTaskId(record) ?? undefined, filter.taskId))
1010
+ return false;
1011
+ if (filter.planVersion !== undefined && record.plan_version !== filter.planVersion)
1012
+ return false;
1013
+ return true;
1014
+ });
1015
+ }