@juzser/blacksmith 0.2.0 → 0.4.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 (71) hide show
  1. package/.claude/agents/auditor.md +3 -0
  2. package/.claude/agents/grader.md +12 -5
  3. package/.claude/agents/reviewer.md +3 -0
  4. package/.claude/agents/security-reviewer.md +3 -0
  5. package/.claude/agents/spec-reviewer.md +3 -0
  6. package/.claude/agents/verifier.md +3 -0
  7. package/.claude/skills/bs/audit.md +25 -9
  8. package/.claude/skills/bs/dispatch.md +60 -7
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +18 -13
  11. package/.claude/skills/bs/wave.md +68 -10
  12. package/README.md +4 -4
  13. package/factory/orchestrator/dist/agentsSync.js +178 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +25 -0
  16. package/factory/orchestrator/dist/budgets.js +91 -2
  17. package/factory/orchestrator/dist/claims.js +8 -4
  18. package/factory/orchestrator/dist/cli.js +394 -26
  19. package/factory/orchestrator/dist/crossFinding.js +20 -0
  20. package/factory/orchestrator/dist/daemon.js +230 -41
  21. package/factory/orchestrator/dist/db/projector.js +93 -36
  22. package/factory/orchestrator/dist/db/queries.js +58 -35
  23. package/factory/orchestrator/dist/db/schema.js +24 -0
  24. package/factory/orchestrator/dist/dispatchAudit.js +10 -2
  25. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  26. package/factory/orchestrator/dist/epic.js +192 -39
  27. package/factory/orchestrator/dist/epicWidth.js +25 -20
  28. package/factory/orchestrator/dist/errorIssues.js +209 -24
  29. package/factory/orchestrator/dist/eventOrder.js +102 -0
  30. package/factory/orchestrator/dist/events.js +73 -91
  31. package/factory/orchestrator/dist/findingContext.js +1 -13
  32. package/factory/orchestrator/dist/findings.js +132 -11
  33. package/factory/orchestrator/dist/gh.js +29 -6
  34. package/factory/orchestrator/dist/git.js +20 -2
  35. package/factory/orchestrator/dist/harness.js +92 -10
  36. package/factory/orchestrator/dist/hookDecision.js +320 -37
  37. package/factory/orchestrator/dist/impact.js +24 -2
  38. package/factory/orchestrator/dist/integration.js +11 -1
  39. package/factory/orchestrator/dist/issueReporter.js +415 -0
  40. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  41. package/factory/orchestrator/dist/judges.js +1 -1
  42. package/factory/orchestrator/dist/lessons.js +46 -18
  43. package/factory/orchestrator/dist/logCache.js +291 -0
  44. package/factory/orchestrator/dist/paths.js +10 -0
  45. package/factory/orchestrator/dist/plan.js +138 -4
  46. package/factory/orchestrator/dist/planQuorum.js +1 -1
  47. package/factory/orchestrator/dist/policy.js +57 -11
  48. package/factory/orchestrator/dist/projects.js +12 -1
  49. package/factory/orchestrator/dist/queue.js +477 -29
  50. package/factory/orchestrator/dist/scheduler.js +51 -0
  51. package/factory/orchestrator/dist/severity.js +40 -8
  52. package/factory/orchestrator/dist/spec.js +70 -0
  53. package/factory/orchestrator/dist/taskEvents.js +13 -4
  54. package/factory/orchestrator/dist/taskStatus.js +74 -0
  55. package/factory/orchestrator/dist/taxonomy.js +11 -0
  56. package/factory/orchestrator/dist/testgate.js +32 -3
  57. package/factory/orchestrator/dist/usage.js +42 -9
  58. package/factory/orchestrator/dist/waivers.js +60 -9
  59. package/factory/orchestrator/dist/waveConcurrency.js +29 -0
  60. package/factory/orchestrator/dist/waveNext.js +12 -10
  61. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  62. package/factory/orchestrator/dist/worktree.js +19 -6
  63. package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
  64. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  65. package/factory/policies/budgets.yml +9 -2
  66. package/factory/policies/crosscheck.yml +2 -2
  67. package/factory/policies/lessons.md +298 -0
  68. package/factory/policies/scheduler.yml +27 -2
  69. package/factory/policies/taxonomy.yml +10 -3
  70. package/factory/specs/roadmap.md +70 -23
  71. package/package.json +8 -8
@@ -22,10 +22,12 @@ import { loadCrosscheckPolicy } from './crosscheck.js';
22
22
  import { apply, foldTasks, rebuild } from './db/projector.js';
23
23
  import { summariseEpicWidth, UNMEASURED_HINT } from './epicWidth.js';
24
24
  import { SmithError } from './errors.js';
25
- import { listSessionIds, mergeSessionLogs, parseEventId, readEvents, } from './events.js';
25
+ import { listSessionIds, mergeSessionLogs, parseEventId } from './events.js';
26
26
  import { ageFindings, memoryOf } from './findingAge.js';
27
+ import { LogCache } from './logCache.js';
27
28
  import { STATE_DAEMON_DIR, STATE_DB_PATH, STATE_EVENTS_DIR } from './paths.js';
28
29
  import { unwatchedProjects } from './projects.js';
30
+ import { FACTORY_PROJECT } from './roadmap.js';
29
31
  import { computeProposals, loadSchedulerPolicy, } from './scheduler.js';
30
32
  import { foldSpecChanges } from './specChange.js';
31
33
  export class DaemonError extends SmithError {
@@ -62,6 +64,20 @@ function admitFor(proposals, events, lens) {
62
64
  function staleSubject(agent) {
63
65
  return agent.taskId ?? agent.epicId ?? agent.agentId ?? agent.id;
64
66
  }
67
+ /**
68
+ * The session an event id names, for a finding that has nothing better to key
69
+ * on than the id of the event that raised it. `null` on anything that is not
70
+ * a well-formed event id, rather than throwing — a proposal's origin is a
71
+ * nicety, not a fact `inspectSession` should die over.
72
+ */
73
+ function originSessionId(eventId) {
74
+ try {
75
+ return parseEventId(eventId).sessionId;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
65
81
  /**
66
82
  * Everything one session's lineage says about itself: spend against the epic
67
83
  * cap, dispatches nobody can be billed for, agents that never came back, and
@@ -118,10 +134,24 @@ export function inspectSession(sessionId, events, opts = {}) {
118
134
  });
119
135
  }
120
136
  for (const agent of detectStale(foldAgents(events), now.toISOString(), staleHours)) {
137
+ // Same rule as the budget check above, and the same reason: a closed
138
+ // epic's dispatch cannot still be live, so flagging it would raise the
139
+ // same line every tick until the log is archived. `foldAgents`'s own
140
+ // epic-closed handling only closes an entry against the session that
141
+ // closed it (D-234) — a dispatch and the epic's close can sit in
142
+ // different sessions of one lineage, which is exactly the shape this
143
+ // tick's merged log hands here, so the same-session fold alone would
144
+ // miss it.
145
+ if (agent.epicId !== null && closedEpics.has(agent.epicId))
146
+ continue;
121
147
  findings.push({
122
148
  kind: 'stale-agent',
123
149
  severity: 'attention',
124
- sessionId,
150
+ // The dispatch's own session, not the tree's primary leaf: a fork
151
+ // asks inspectSession once for the whole tree, and the agent that
152
+ // never came back belongs to whichever session actually dispatched
153
+ // it.
154
+ sessionId: agent.sessionId,
125
155
  subject: staleSubject(agent),
126
156
  detail: `${agent.agentRole} (${agent.provider}/${agent.modelTier}) has been live for ` +
127
157
  `${agent.liveHours.toFixed(1)}h with no result, error or supersession — past the ` +
@@ -141,7 +171,10 @@ export function inspectSession(sessionId, events, opts = {}) {
141
171
  findings.push({
142
172
  kind: 'spec-change',
143
173
  severity: proposal.blocking ? 'attention' : 'info',
144
- sessionId,
174
+ // `proposalId` is the `spec-change-proposed` event's own id, so this is
175
+ // the session that actually raised the proposal — not the tree's
176
+ // primary leaf, which a fork may have picked for an unrelated sibling.
177
+ sessionId: originSessionId(proposal.proposalId) ?? sessionId,
145
178
  subject: proposal.taskId,
146
179
  detail: `${proposal.proposedBy} proposes amending ${proposal.criterionRef}: ${proposal.assumption} ` +
147
180
  `(${proposal.severity}, ${proposal.blocking ? 'blocking' : 'non-blocking'}, ` +
@@ -149,21 +182,63 @@ export function inspectSession(sessionId, events, opts = {}) {
149
182
  `${proposal.proposalId}\` or \`smith plan reject ${proposal.proposalId}\`.`,
150
183
  });
151
184
  }
152
- const proposals = computeProposals({ events, now, policy: schedulerPolicy });
185
+ const proposals = computeProposals({
186
+ events,
187
+ now,
188
+ policy: schedulerPolicy,
189
+ ...(opts.resolveProjectForTaskRef === undefined
190
+ ? {}
191
+ : { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
192
+ ...(opts.selfFallbackForTaskRef === undefined
193
+ ? {}
194
+ : { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
195
+ });
153
196
  const admissions = admitFor(proposals, events, opts.admission);
197
+ // `TaskFoldRow.sessionId` is stamped once, the first time `touch()` builds
198
+ // the row, and never overwritten by a later touch — the task's origin
199
+ // session, not whichever one most recently merged or dispatched against it.
200
+ const taskSession = new Map(foldTasks(events).map((row) => [row.taskId, row.sessionId]));
154
201
  for (const [index, proposal] of proposals.entries()) {
155
- if (proposal.kind !== 'recheck')
156
- continue;
157
202
  const admission = admissions[index];
158
- findings.push({
159
- kind: 'recheck',
160
- severity: 'info',
161
- sessionId,
162
- subject: proposal.taskId,
163
- detail: `Recheck due (${proposal.reasons.join(', ')}): ${proposal.mergeCount} later overlapping ` +
164
- `merge(s), ${proposal.daysElapsed} day(s) elapsed, confidence ${proposal.confidence}.`,
165
- ...(admission === undefined ? {} : { admission }),
166
- });
203
+ if (proposal.kind === 'recheck') {
204
+ findings.push({
205
+ kind: 'recheck',
206
+ severity: 'info',
207
+ sessionId: taskSession.get(proposal.taskId) ?? sessionId,
208
+ subject: proposal.taskId,
209
+ detail: `Recheck due (${proposal.reasons.join(', ')}): ${proposal.mergeCount} later overlapping ` +
210
+ `merge(s), ${proposal.daysElapsed} day(s) elapsed, confidence ${proposal.confidence}.`,
211
+ ...(admission === undefined ? {} : { admission }),
212
+ });
213
+ }
214
+ else if (proposal.kind === 'error-report') {
215
+ // An error the log holds that nobody has reported — the session died
216
+ // before its run could. `info`, not `attention`: the error itself was
217
+ // raised when it happened, and what is left is work to schedule, which
218
+ // the discharge command below clears. Reported here and not in
219
+ // inspectFactory because `issue-reported` lands in the error's own
220
+ // session, so this lineage is the one that can answer it.
221
+ const project = proposal.project === null
222
+ ? ' (unresolved project)'
223
+ : proposal.project === FACTORY_PROJECT
224
+ ? ''
225
+ : ` (${proposal.project})`;
226
+ const projectPhrase = proposal.project === null ? 'an unresolved project' : `project ${proposal.project}`;
227
+ findings.push({
228
+ kind: 'unreported-error',
229
+ severity: 'info',
230
+ // `report.session_id` — the session the error actually lives in, and
231
+ // the same one the detail text below already sends the operator to.
232
+ sessionId: proposal.sessionId,
233
+ subject: `error ${proposal.fingerprint}${project}`,
234
+ detail: `Error ${proposal.fingerprint} in ${projectPhrase} ` +
235
+ `(${proposal.errorClass}, task ${proposal.taskRef}, ${proposal.occurrences} occurrence(s)) ` +
236
+ 'has no issue-reported event. Report it with ' +
237
+ `\`smith issues report --session ${proposal.sessionId}\`; ` +
238
+ `\`smith issues preview --session ${proposal.sessionId}\` is the dry run.`,
239
+ ...(admission === undefined ? {} : { admission }),
240
+ });
241
+ }
167
242
  }
168
243
  return findings;
169
244
  }
@@ -186,6 +261,12 @@ export function inspectFactory(events, opts = {}) {
186
261
  policy,
187
262
  ...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
188
263
  ...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
264
+ ...(opts.resolveProjectForTaskRef === undefined
265
+ ? {}
266
+ : { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
267
+ ...(opts.selfFallbackForTaskRef === undefined
268
+ ? {}
269
+ : { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
189
270
  });
190
271
  const admissions = admitFor(proposals, events, opts.admission);
191
272
  for (const [index, proposal] of proposals.entries()) {
@@ -221,9 +302,9 @@ export function inspectFactory(events, opts = {}) {
221
302
  ...(admission === undefined ? {} : { admission }),
222
303
  });
223
304
  }
224
- // Rechecks are deliberately dropped here: inspectSession already reports
225
- // each against the session that owns it, and a second copy with no session
226
- // is a duplicate an operator cannot act on.
305
+ // Rechecks and unreported errors are deliberately dropped here: inspectSession
306
+ // already reports each against the session that owns it, and a second copy
307
+ // with no session is a duplicate an operator cannot act on.
227
308
  }
228
309
  // The claim this repo rests on, watched instead of waited on. `smith epic
229
310
  // width` answers it over all of history the moment somebody types it — and
@@ -321,21 +402,29 @@ function errorMessage(err) {
321
402
  /**
322
403
  * One pass over the state directory.
323
404
  *
324
- * Two rules hold it together. It reads every log ONCE and folds lineages in
325
- * memory, so a five-session lineage reports one budget alarm rather than five
326
- * (D-119's argument, read from the other end). And no single unreadable log or
327
- * failed projection ends the tick: both become findings, because a watchdog
328
- * that dies on the first corrupt line is a watchdog that is silent exactly
329
- * when something is wrong.
405
+ * Two rules hold it together. It reads every log ONCE and folds each lineage
406
+ * TREE in memory — not once per leaf — so a five-session chain reports one
407
+ * budget alarm rather than five (D-119's argument, read from the other end),
408
+ * and a base session that forked into several wave children reports it once
409
+ * rather than once per sibling. And no single unreadable log or failed
410
+ * projection ends the tick: both become findings, because a watchdog that
411
+ * dies on the first corrupt line is a watchdog that is silent exactly when
412
+ * something is wrong.
330
413
  */
331
414
  export async function runTick(opts = {}) {
332
415
  const now = opts.now ?? new Date();
333
416
  const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
334
417
  const findings = [];
418
+ // No shared cache passed in: a fresh one is cold for every session, so this
419
+ // reads identically to the old `readEvents`-per-session loop — one full
420
+ // read per log, this call only.
421
+ const cache = opts.logCache ?? new LogCache();
335
422
  const logs = new Map();
423
+ const liveSessionIds = new Set();
336
424
  for (const sessionId of listSessionIds(stateDir)) {
425
+ liveSessionIds.add(sessionId);
337
426
  try {
338
- logs.set(sessionId, await readEvents(sessionId, { stateDir }));
427
+ logs.set(sessionId, await cache.read(sessionId, { stateDir }));
339
428
  }
340
429
  catch (err) {
341
430
  findings.push({
@@ -347,6 +436,13 @@ export async function runTick(opts = {}) {
347
436
  });
348
437
  }
349
438
  }
439
+ // Sessions that disappear from `listSessionIds` are evicted here, on the
440
+ // next tick, so the cache's memory is bounded by live logs, not by
441
+ // history. Driven by the LISTED set, not the read-succeeded set: a session
442
+ // that is listed but unreadable this tick keeps its entry. On a fresh,
443
+ // unshared cache (`opts.logCache` undefined) this is a no-op -- nothing
444
+ // was cached to evict.
445
+ cache.retainOnly(liveSessionIds);
350
446
  const parentOf = new Map();
351
447
  for (const [sessionId, events] of logs) {
352
448
  const root = events.find((e) => e.record.event_type === ROOT_EVENT_TYPE);
@@ -368,17 +464,59 @@ export async function runTick(opts = {}) {
368
464
  ancestors.add(parent);
369
465
  }
370
466
  const leaves = [...logs.keys()].filter((id) => !ancestors.has(id)).sort();
371
- const lineageOf = (leaf) => {
372
- const chain = [];
467
+ // parentOf inverted: a session's immediate children among the sessions this
468
+ // tick loaded. Lets a shared ancestor's descendants be walked forward once,
469
+ // instead of re-walked backward once per leaf below it.
470
+ const childrenOf = new Map();
471
+ for (const [childId, parent] of parentOf) {
472
+ if (parent === null || !logs.has(parent))
473
+ continue;
474
+ const siblings = childrenOf.get(parent) ?? [];
475
+ siblings.push(childId);
476
+ childrenOf.set(parent, siblings);
477
+ }
478
+ // The furthest ancestor of `id` that this tick still holds a log for — the
479
+ // root of the tree `id` belongs to, and the same root for every session in
480
+ // that tree regardless of which leaf asks.
481
+ const rootOf = (id) => {
482
+ let current = id;
373
483
  const seen = new Set();
374
- let current = leaf;
375
- while (current !== null && logs.has(current) && !seen.has(current)) {
484
+ while (true) {
485
+ const parent = parentOf.get(current) ?? null;
486
+ if (parent === null || !logs.has(parent) || seen.has(current))
487
+ return current;
376
488
  seen.add(current);
377
- chain.unshift(current);
378
- current = parentOf.get(current) ?? null;
489
+ current = parent;
490
+ }
491
+ };
492
+ // Every session reachable from `root` by walking children — the whole tree
493
+ // a lineage forks into, not just the one leaf that happened to ask for it.
494
+ // Guarded like rootOf: a cycle in causal parents must cost a tick nothing,
495
+ // not grow this list until the process dies.
496
+ const treeSessions = (root) => {
497
+ const all = [root];
498
+ const seen = new Set(all);
499
+ for (let i = 0; i < all.length; i += 1) {
500
+ for (const child of childrenOf.get(all[i]) ?? []) {
501
+ if (seen.has(child))
502
+ continue;
503
+ seen.add(child);
504
+ all.push(child);
505
+ }
379
506
  }
380
- return chain.map((sessionId) => ({ sessionId, events: logs.get(sessionId) ?? [] }));
507
+ return all;
381
508
  };
509
+ // Leaves grouped by the tree they fork from. A base session with several
510
+ // wave children is one tree with several leaves — inspected once, not once
511
+ // per sibling, so the shared ancestor's findings are not re-emitted once
512
+ // per fork (the D-119 argument, which a single chain never forks to break).
513
+ const leavesByRoot = new Map();
514
+ for (const leaf of leaves) {
515
+ const root = rootOf(leaf);
516
+ const group = leavesByRoot.get(root) ?? [];
517
+ group.push(leaf);
518
+ leavesByRoot.set(root, group);
519
+ }
382
520
  // A tick reports the admission line by default, where `inspectSession` and
383
521
  // `inspectFactory` stay silent unless asked. The asymmetry is deliberate: a
384
522
  // caller who reaches for one function is answering their own question and
@@ -400,6 +538,12 @@ export async function runTick(opts = {}) {
400
538
  ...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
401
539
  ...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
402
540
  ...(opts.readProjects === undefined ? {} : { readProjects: opts.readProjects }),
541
+ ...(opts.resolveProjectForTaskRef === undefined
542
+ ? {}
543
+ : { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
544
+ ...(opts.selfFallbackForTaskRef === undefined
545
+ ? {}
546
+ : { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
403
547
  };
404
548
  const projectDb = opts.projectDb ?? true;
405
549
  const dbPath = opts.dbPath ?? STATE_DB_PATH;
@@ -415,16 +559,55 @@ export async function runTick(opts = {}) {
415
559
  detail: `The read-model refresh failed: ${errorMessage(err)}. The event log is unaffected — ` +
416
560
  'it is the UI and `smith status` that are now stale.',
417
561
  });
418
- for (const leaf of leaves) {
419
- findings.push(...inspectSession(leaf, mergeSessionLogs(lineageOf(leaf)), inspectOpts));
420
- if (!projectDb)
421
- continue;
422
- try {
423
- await apply(dbPath, leaf, dbOpts);
424
- projected += 1;
562
+ for (const [root, group] of leavesByRoot) {
563
+ // Sorted, so a forked tree attributes its shared findings to the same
564
+ // leaf on every tick, rather than whichever one Map iteration hands back
565
+ // first — the same determinism `leaves` itself already sorts for.
566
+ const primary = [...group].sort()[0];
567
+ const merged = mergeSessionLogs(treeSessions(root).map((sessionId) => ({ sessionId, events: logs.get(sessionId) ?? [] })));
568
+ findings.push(...inspectSession(primary, merged, inspectOpts));
569
+ }
570
+ // The signature a leaf's projection was last applied against: every
571
+ // session in its own upward chain, keyed by the fingerprint the cache read
572
+ // it at. Two ticks that see the same fingerprints for the same chain saw
573
+ // no change anywhere that leaf's own projection or lineage-scoped folds
574
+ // depend on, so re-applying would write the same rows a second time.
575
+ // `opts.logCache === undefined` opts a caller all the way out: with no
576
+ // cache to remember a signature against, every leaf is applied every tick,
577
+ // unchanged from before this cache existed.
578
+ const leafSignature = (leaf) => {
579
+ const chain = [];
580
+ const seen = new Set();
581
+ let current = leaf;
582
+ while (current !== null && !seen.has(current)) {
583
+ seen.add(current);
584
+ chain.push(current);
585
+ const parent = parentOf.get(current) ?? null;
586
+ current = parent !== null && logs.has(parent) ? parent : null;
425
587
  }
426
- catch (err) {
427
- findings.push(projectionFailed(leaf, err));
588
+ return chain
589
+ .map((id) => {
590
+ const fp = cache.fingerprintOf(id);
591
+ return fp === undefined ? `${id}:?` : `${id}:${fp.dev}.${fp.ino}.${fp.size}.${fp.mtimeMs}`;
592
+ })
593
+ .join('|');
594
+ };
595
+ const applyFn = opts.applyFn ?? apply;
596
+ if (projectDb) {
597
+ for (const leaf of leaves) {
598
+ const signature = opts.logCache === undefined ? undefined : leafSignature(leaf);
599
+ if (signature !== undefined && opts.logCache?.getAppliedSignature(leaf) === signature) {
600
+ continue;
601
+ }
602
+ try {
603
+ await applyFn(dbPath, leaf, dbOpts);
604
+ projected += 1;
605
+ if (signature !== undefined)
606
+ opts.logCache?.setAppliedSignature(leaf, signature);
607
+ }
608
+ catch (err) {
609
+ findings.push(projectionFailed(leaf, err));
610
+ }
428
611
  }
429
612
  }
430
613
  // The read-model is not made of sessions alone. `milestones` is a full
@@ -661,6 +844,10 @@ export async function runDaemon(opts) {
661
844
  const tick = opts.tick ?? runTick;
662
845
  const sleep = opts.sleep ?? defaultSleep;
663
846
  const shouldContinue = opts.shouldContinue ?? (() => true);
847
+ // One cache for the whole run: this is the loop finding 50acc356 is about
848
+ // — the process that survives long enough for "every tick re-reads every
849
+ // byte ever written" to matter.
850
+ const logCache = opts.logCache ?? new LogCache();
664
851
  acquireLock(opts.dir, { pid, startedAt, intervalSeconds }, opts.isAlive === undefined ? {} : { isAlive: opts.isAlive });
665
852
  const tickOptions = {
666
853
  ...(opts.now === undefined ? {} : { now: opts.now }),
@@ -673,6 +860,8 @@ export async function runDaemon(opts) {
673
860
  ...(opts.projectDb === undefined ? {} : { projectDb: opts.projectDb }),
674
861
  ...(opts.dbPath === undefined ? {} : { dbPath: opts.dbPath }),
675
862
  ...(opts.dbOpts === undefined ? {} : { dbOpts: opts.dbOpts }),
863
+ ...(opts.applyFn === undefined ? {} : { applyFn: opts.applyFn }),
864
+ logCache,
676
865
  };
677
866
  // Read from disk rather than held in a variable, so a daemon restarted by
678
867
  // cron or by the operator picks up where the last process stopped. A tick
@@ -46,6 +46,7 @@ import { isPlanRefTaskId, latestPlanVersion, loadPlan } from '../plan.js';
46
46
  import { loadRoadmap } from '../roadmap.js';
47
47
  import { assertRuntimeSupported } from '../runtime.js';
48
48
  import { bareTaskId, epicOfTaskId, isQualifiedTaskId, qualifyTaskId, taskIdsMatch, } from '../taskId.js';
49
+ import { TERMINAL_TASK_STATUSES } from '../taskStatus.js';
49
50
  import { RESERVED_TASK_ID, taskBranchName } from '../worktree.js';
50
51
  import * as schema from './schema.js';
51
52
  /** Open (creating if needed) the projection DB and ensure its schema is current. */
@@ -77,6 +78,7 @@ const ALL_TABLES = [
77
78
  schema.lessons,
78
79
  schema.artifacts,
79
80
  schema.milestones,
81
+ schema.issue_reports,
80
82
  ];
81
83
  /** Delete every row in every projection table (full "drop" for rebuild()). */
82
84
  function clearAll(handle) {
@@ -97,6 +99,7 @@ function clearSession(db, sessionId) {
97
99
  db.delete(schema.errors).where(eq(schema.errors.sessionId, sessionId)).run();
98
100
  db.delete(schema.waivers).where(eq(schema.waivers.sessionId, sessionId)).run();
99
101
  db.delete(schema.artifacts).where(eq(schema.artifacts.sessionId, sessionId)).run();
102
+ db.delete(schema.issue_reports).where(eq(schema.issue_reports.sessionId, sessionId)).run();
100
103
  // `lessons`, `findings` and `tasks` are deliberately absent, like
101
104
  // `milestones` — projectLessons(), projectFindings() and projectTasks() own
102
105
  // those tables whole (D-199, D-200, and the continuation case projectTasks()
@@ -145,6 +148,7 @@ function projectMilestones(handle, opts) {
145
148
  epicIds: JSON.stringify(m.epicIds),
146
149
  project: m.project,
147
150
  kind: m.kind,
151
+ errorIssues: m.errorIssuesEnabled,
148
152
  })
149
153
  .run();
150
154
  }
@@ -162,16 +166,21 @@ function splitErrorClass(value) {
162
166
  const [group, cls] = (value ?? '').split('.');
163
167
  return { group: group ?? 'unknown', cls: cls ?? 'unknown' };
164
168
  }
165
- const TERMINAL_TASK_STATUSES = new Set([
166
- 'completed',
167
- 'superseded',
168
- 'failed',
169
- 'escalated',
170
- 'waived',
171
- ]);
172
169
  /**
173
- * Severities that record an error without stopping the task (taxonomy.yml
174
- * `severity`). An `error-logged` at one of these leaves `task_status` alone.
170
+ * Severities that record an error without stopping the task: the
171
+ * `blocks_merge: false` half of `factory/policies/severity.yml`. An
172
+ * `error-logged` at one of these leaves `task_status` alone.
173
+ *
174
+ * It is a literal rather than a read of that file on purpose. `apply()` and
175
+ * `rebuild()` are a deterministic fold over the event log, and a fold that
176
+ * consulted today's policy would replay a months-old log under a ruling
177
+ * nobody had made when it was written — the board would change because a
178
+ * policy did, with no event to point at. So the copy stays, and a test holds
179
+ * it to the policy instead: `test/db/projector.test.ts`, "an error-logged
180
+ * moves a task only when its severity says so", drives this fold once per
181
+ * severity severity.yml declares. Declare a non-blocking one without adding
182
+ * it here and that test fails rather than the board quietly showing its
183
+ * tasks as blocked.
175
184
  */
176
185
  const NOTE_ONLY_SEVERITIES = new Set(['S3-minor', 'S4-nit']);
177
186
  /**
@@ -387,23 +396,46 @@ function waveTaskIds(record) {
387
396
  const taskId = eventTaskId(record);
388
397
  return taskId ? [taskId] : [];
389
398
  }
399
+ /**
400
+ * The tasks one error-logged event names: the envelope/payload `task_id`
401
+ * (D-245) when the producer set one, else `payload.task_ref` split on `,`
402
+ * and each part trimmed. A comma joins several real ids into one string
403
+ * when a producer logged a single check's failure against more than one
404
+ * task at once — events.ts's append-time guard now refuses to write that
405
+ * shape, but a log written before the guard existed still carries it, and
406
+ * foldTasks() replays every log ever written. Splitting here, not only in
407
+ * assertedTaskIds()'s pre-pass, is what keeps the two agreeing on what the
408
+ * event names (see that function's own comment) — a fix that touched one
409
+ * and not the other used to mint a card whose id was the literal
410
+ * "epic-1/task-1,epic-1/task-2" string.
411
+ */
412
+ function errorTaskRefIds(record) {
413
+ const envelopeId = eventTaskId(record);
414
+ if (envelopeId)
415
+ return [envelopeId];
416
+ const ref = record.payload.task_ref;
417
+ if (!ref)
418
+ return [];
419
+ return ref
420
+ .split(',')
421
+ .map((id) => id.trim())
422
+ .filter(Boolean);
423
+ }
390
424
  /**
391
425
  * The task ids one event asserts something about, spelled the way foldTasks()'s
392
426
  * switch reads them: the envelope or payload `task_id` (D-245) for the
393
427
  * single-task events, `payload.task_ids` for a wave's admission or merge, and
394
- * `error-logged`'s own `payload.task_ref`. Kept beside the switch on purpose —
395
- * the two must agree on what names a task, or the walk touches an id the
396
- * pre-pass never saw.
428
+ * `error-logged`'s own `payload.task_ref` (or its comma-joined list — see
429
+ * errorTaskRefIds()). Kept beside the switch on purpose — the two must agree
430
+ * on what names a task, or the walk touches an id the pre-pass never saw.
397
431
  */
398
432
  function assertedTaskIds(record) {
399
433
  switch (record.event_type) {
400
434
  case 'wave-admitted':
401
435
  case 'wave-merged':
402
436
  return waveTaskIds(record);
403
- case 'error-logged': {
404
- const id = eventTaskId(record) ?? record.payload.task_ref;
405
- return id ? [id] : [];
406
- }
437
+ case 'error-logged':
438
+ return errorTaskRefIds(record);
407
439
  case 'task-added':
408
440
  case 'gate-outcome':
409
441
  case 'task-superseded': {
@@ -597,26 +629,27 @@ export function foldTasks(events, opts = {}) {
597
629
  }
598
630
  case 'error-logged': {
599
631
  const p = record.payload;
600
- // `task_ref` is this event's own spelling; eventTaskId covers the two
601
- // the rest of the log uses (D-245).
602
- const taskId = eventTask ?? p.task_ref;
603
- if (!taskId)
604
- break;
605
- const row = touch(taskId, record.ts, record.session_id);
606
- row.project = record.project ?? row.project;
607
- if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
608
- break;
609
- // Severity decides whether the task moves; the error class decides
610
- // where. taxonomy.yml: S3 is "real but waivable; batched to operator
611
- // at epic end", S4 is "logged, never asked" — a budget note or a
612
- // tool hiccup at that level is on the record but the task carries on,
613
- // and showing it as blocked is what the board did for 43 of the 55
614
- // errors logged so far. A record with no severity is treated as
615
- // major: the write path requires the field, so its absence means a
616
- // log this reader does not own.
617
- if (NOTE_ONLY_SEVERITIES.has(p.severity ?? ''))
618
- break;
619
- row.taskStatus = p.error?.startsWith('coordination.') ? 'escalated' : 'blocked';
632
+ // errorTaskRefIds() is this event's own spelling (D-245's envelope
633
+ // reader, or a comma-joined `task_ref` split into its real ids) —
634
+ // shared with assertedTaskIds()'s pre-pass so the two agree.
635
+ const taskIds = errorTaskRefIds(record);
636
+ for (const taskId of taskIds) {
637
+ const row = touch(taskId, record.ts, record.session_id);
638
+ row.project = record.project ?? row.project;
639
+ if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
640
+ continue;
641
+ // Severity decides whether the task moves; the error class decides
642
+ // where. taxonomy.yml: S3 is "real but waivable; batched to operator
643
+ // at epic end", S4 is "logged, never asked" — a budget note or a
644
+ // tool hiccup at that level is on the record but the task carries on,
645
+ // and showing it as blocked is what the board did for 43 of the 55
646
+ // errors logged so far. A record with no severity is treated as
647
+ // major: the write path requires the field, so its absence means a
648
+ // log this reader does not own.
649
+ if (NOTE_ONLY_SEVERITIES.has(p.severity ?? ''))
650
+ continue;
651
+ row.taskStatus = p.error?.startsWith('coordination.') ? 'escalated' : 'blocked';
652
+ }
620
653
  break;
621
654
  }
622
655
  default:
@@ -1012,6 +1045,30 @@ export function projectSession(handle, sessionId, events, opts = {}) {
1012
1045
  .run();
1013
1046
  continue;
1014
1047
  }
1048
+ if (record.event_type === 'issue-reported') {
1049
+ const p = record.payload;
1050
+ if (!p.fingerprint || !p.outcome)
1051
+ continue;
1052
+ txDb
1053
+ .insert(schema.issue_reports)
1054
+ .values({
1055
+ eventId: event_id,
1056
+ sessionId: record.session_id,
1057
+ ts: record.ts,
1058
+ taskRef: p.task_ref ?? eventTask,
1059
+ errorClass: p.error_class ?? '',
1060
+ fingerprint: p.fingerprint,
1061
+ issueUrl: p.issue_url ?? null,
1062
+ latestEventId: p.latest_event_id ?? '',
1063
+ outcome: p.outcome,
1064
+ reason: p.reason ?? null,
1065
+ repoSlug: p.repo_slug ?? null,
1066
+ source: p.source ?? '',
1067
+ project: record.project ?? projectForRef(p.task_ref ?? eventTask),
1068
+ })
1069
+ .run();
1070
+ continue;
1071
+ }
1015
1072
  if (record.event_type === 'task-result-recorded') {
1016
1073
  const p = record.payload;
1017
1074
  // Envelope first, like every other reader — this line used to prefer