@juzser/blacksmith 0.3.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 (46) 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 +12 -5
  8. package/.claude/skills/bs/dispatch.md +40 -4
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +11 -5
  11. package/.claude/skills/bs/wave.md +40 -3
  12. package/README.md +1 -1
  13. package/factory/orchestrator/dist/agentsSync.js +23 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +6 -2
  16. package/factory/orchestrator/dist/cli.js +309 -28
  17. package/factory/orchestrator/dist/crossFinding.js +20 -0
  18. package/factory/orchestrator/dist/daemon.js +198 -31
  19. package/factory/orchestrator/dist/db/projector.js +51 -27
  20. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  21. package/factory/orchestrator/dist/epic.js +179 -12
  22. package/factory/orchestrator/dist/errorIssues.js +175 -10
  23. package/factory/orchestrator/dist/events.js +59 -11
  24. package/factory/orchestrator/dist/findings.js +25 -2
  25. package/factory/orchestrator/dist/gh.js +19 -2
  26. package/factory/orchestrator/dist/git.js +20 -2
  27. package/factory/orchestrator/dist/hookDecision.js +320 -37
  28. package/factory/orchestrator/dist/integration.js +11 -1
  29. package/factory/orchestrator/dist/issueReporter.js +81 -16
  30. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  31. package/factory/orchestrator/dist/logCache.js +291 -0
  32. package/factory/orchestrator/dist/paths.js +10 -0
  33. package/factory/orchestrator/dist/plan.js +130 -0
  34. package/factory/orchestrator/dist/projects.js +12 -1
  35. package/factory/orchestrator/dist/queue.js +469 -28
  36. package/factory/orchestrator/dist/scheduler.js +3 -3
  37. package/factory/orchestrator/dist/spec.js +70 -0
  38. package/factory/orchestrator/dist/taskEvents.js +13 -4
  39. package/factory/orchestrator/dist/taxonomy.js +11 -0
  40. package/factory/orchestrator/dist/testgate.js +4 -3
  41. package/factory/orchestrator/dist/usage.js +18 -10
  42. package/factory/orchestrator/dist/waivers.js +60 -7
  43. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  44. package/factory/orchestrator/dist/worktree.js +19 -6
  45. package/factory/specs/roadmap.md +24 -0
  46. package/package.json +1 -1
@@ -22,8 +22,9 @@ 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';
29
30
  import { FACTORY_PROJECT } from './roadmap.js';
@@ -63,6 +64,20 @@ function admitFor(proposals, events, lens) {
63
64
  function staleSubject(agent) {
64
65
  return agent.taskId ?? agent.epicId ?? agent.agentId ?? agent.id;
65
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
+ }
66
81
  /**
67
82
  * Everything one session's lineage says about itself: spend against the epic
68
83
  * cap, dispatches nobody can be billed for, agents that never came back, and
@@ -119,10 +134,24 @@ export function inspectSession(sessionId, events, opts = {}) {
119
134
  });
120
135
  }
121
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;
122
147
  findings.push({
123
148
  kind: 'stale-agent',
124
149
  severity: 'attention',
125
- 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,
126
155
  subject: staleSubject(agent),
127
156
  detail: `${agent.agentRole} (${agent.provider}/${agent.modelTier}) has been live for ` +
128
157
  `${agent.liveHours.toFixed(1)}h with no result, error or supersession — past the ` +
@@ -142,7 +171,10 @@ export function inspectSession(sessionId, events, opts = {}) {
142
171
  findings.push({
143
172
  kind: 'spec-change',
144
173
  severity: proposal.blocking ? 'attention' : 'info',
145
- 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,
146
178
  subject: proposal.taskId,
147
179
  detail: `${proposal.proposedBy} proposes amending ${proposal.criterionRef}: ${proposal.assumption} ` +
148
180
  `(${proposal.severity}, ${proposal.blocking ? 'blocking' : 'non-blocking'}, ` +
@@ -150,15 +182,29 @@ export function inspectSession(sessionId, events, opts = {}) {
150
182
  `${proposal.proposalId}\` or \`smith plan reject ${proposal.proposalId}\`.`,
151
183
  });
152
184
  }
153
- 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
+ });
154
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]));
155
201
  for (const [index, proposal] of proposals.entries()) {
156
202
  const admission = admissions[index];
157
203
  if (proposal.kind === 'recheck') {
158
204
  findings.push({
159
205
  kind: 'recheck',
160
206
  severity: 'info',
161
- sessionId,
207
+ sessionId: taskSession.get(proposal.taskId) ?? sessionId,
162
208
  subject: proposal.taskId,
163
209
  detail: `Recheck due (${proposal.reasons.join(', ')}): ${proposal.mergeCount} later overlapping ` +
164
210
  `merge(s), ${proposal.daysElapsed} day(s) elapsed, confidence ${proposal.confidence}.`,
@@ -172,13 +218,20 @@ export function inspectSession(sessionId, events, opts = {}) {
172
218
  // the discharge command below clears. Reported here and not in
173
219
  // inspectFactory because `issue-reported` lands in the error's own
174
220
  // session, so this lineage is the one that can answer it.
175
- const project = proposal.project === FACTORY_PROJECT ? '' : ` (${proposal.project})`;
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}`;
176
227
  findings.push({
177
228
  kind: 'unreported-error',
178
229
  severity: 'info',
179
- sessionId,
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,
180
233
  subject: `error ${proposal.fingerprint}${project}`,
181
- detail: `Error ${proposal.fingerprint} in project ${proposal.project} ` +
234
+ detail: `Error ${proposal.fingerprint} in ${projectPhrase} ` +
182
235
  `(${proposal.errorClass}, task ${proposal.taskRef}, ${proposal.occurrences} occurrence(s)) ` +
183
236
  'has no issue-reported event. Report it with ' +
184
237
  `\`smith issues report --session ${proposal.sessionId}\`; ` +
@@ -208,6 +261,12 @@ export function inspectFactory(events, opts = {}) {
208
261
  policy,
209
262
  ...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
210
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 }),
211
270
  });
212
271
  const admissions = admitFor(proposals, events, opts.admission);
213
272
  for (const [index, proposal] of proposals.entries()) {
@@ -343,21 +402,29 @@ function errorMessage(err) {
343
402
  /**
344
403
  * One pass over the state directory.
345
404
  *
346
- * Two rules hold it together. It reads every log ONCE and folds lineages in
347
- * memory, so a five-session lineage reports one budget alarm rather than five
348
- * (D-119's argument, read from the other end). And no single unreadable log or
349
- * failed projection ends the tick: both become findings, because a watchdog
350
- * that dies on the first corrupt line is a watchdog that is silent exactly
351
- * 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.
352
413
  */
353
414
  export async function runTick(opts = {}) {
354
415
  const now = opts.now ?? new Date();
355
416
  const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
356
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();
357
422
  const logs = new Map();
423
+ const liveSessionIds = new Set();
358
424
  for (const sessionId of listSessionIds(stateDir)) {
425
+ liveSessionIds.add(sessionId);
359
426
  try {
360
- logs.set(sessionId, await readEvents(sessionId, { stateDir }));
427
+ logs.set(sessionId, await cache.read(sessionId, { stateDir }));
361
428
  }
362
429
  catch (err) {
363
430
  findings.push({
@@ -369,6 +436,13 @@ export async function runTick(opts = {}) {
369
436
  });
370
437
  }
371
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);
372
446
  const parentOf = new Map();
373
447
  for (const [sessionId, events] of logs) {
374
448
  const root = events.find((e) => e.record.event_type === ROOT_EVENT_TYPE);
@@ -390,17 +464,59 @@ export async function runTick(opts = {}) {
390
464
  ancestors.add(parent);
391
465
  }
392
466
  const leaves = [...logs.keys()].filter((id) => !ancestors.has(id)).sort();
393
- const lineageOf = (leaf) => {
394
- 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;
395
483
  const seen = new Set();
396
- let current = leaf;
397
- 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;
398
488
  seen.add(current);
399
- chain.unshift(current);
400
- current = parentOf.get(current) ?? null;
489
+ current = parent;
401
490
  }
402
- return chain.map((sessionId) => ({ sessionId, events: logs.get(sessionId) ?? [] }));
403
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
+ }
506
+ }
507
+ return all;
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
+ }
404
520
  // A tick reports the admission line by default, where `inspectSession` and
405
521
  // `inspectFactory` stay silent unless asked. The asymmetry is deliberate: a
406
522
  // caller who reaches for one function is answering their own question and
@@ -422,6 +538,12 @@ export async function runTick(opts = {}) {
422
538
  ...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
423
539
  ...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
424
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 }),
425
547
  };
426
548
  const projectDb = opts.projectDb ?? true;
427
549
  const dbPath = opts.dbPath ?? STATE_DB_PATH;
@@ -437,16 +559,55 @@ export async function runTick(opts = {}) {
437
559
  detail: `The read-model refresh failed: ${errorMessage(err)}. The event log is unaffected — ` +
438
560
  'it is the UI and `smith status` that are now stale.',
439
561
  });
440
- for (const leaf of leaves) {
441
- findings.push(...inspectSession(leaf, mergeSessionLogs(lineageOf(leaf)), inspectOpts));
442
- if (!projectDb)
443
- continue;
444
- try {
445
- await apply(dbPath, leaf, dbOpts);
446
- 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;
447
587
  }
448
- catch (err) {
449
- 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
+ }
450
611
  }
451
612
  }
452
613
  // The read-model is not made of sessions alone. `milestones` is a full
@@ -683,6 +844,10 @@ export async function runDaemon(opts) {
683
844
  const tick = opts.tick ?? runTick;
684
845
  const sleep = opts.sleep ?? defaultSleep;
685
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();
686
851
  acquireLock(opts.dir, { pid, startedAt, intervalSeconds }, opts.isAlive === undefined ? {} : { isAlive: opts.isAlive });
687
852
  const tickOptions = {
688
853
  ...(opts.now === undefined ? {} : { now: opts.now }),
@@ -695,6 +860,8 @@ export async function runDaemon(opts) {
695
860
  ...(opts.projectDb === undefined ? {} : { projectDb: opts.projectDb }),
696
861
  ...(opts.dbPath === undefined ? {} : { dbPath: opts.dbPath }),
697
862
  ...(opts.dbOpts === undefined ? {} : { dbOpts: opts.dbOpts }),
863
+ ...(opts.applyFn === undefined ? {} : { applyFn: opts.applyFn }),
864
+ logCache,
698
865
  };
699
866
  // Read from disk rather than held in a variable, so a daemon restarted by
700
867
  // cron or by the operator picks up where the last process stopped. A tick
@@ -396,23 +396,46 @@ function waveTaskIds(record) {
396
396
  const taskId = eventTaskId(record);
397
397
  return taskId ? [taskId] : [];
398
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
+ }
399
424
  /**
400
425
  * The task ids one event asserts something about, spelled the way foldTasks()'s
401
426
  * switch reads them: the envelope or payload `task_id` (D-245) for the
402
427
  * single-task events, `payload.task_ids` for a wave's admission or merge, and
403
- * `error-logged`'s own `payload.task_ref`. Kept beside the switch on purpose —
404
- * the two must agree on what names a task, or the walk touches an id the
405
- * 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.
406
431
  */
407
432
  function assertedTaskIds(record) {
408
433
  switch (record.event_type) {
409
434
  case 'wave-admitted':
410
435
  case 'wave-merged':
411
436
  return waveTaskIds(record);
412
- case 'error-logged': {
413
- const id = eventTaskId(record) ?? record.payload.task_ref;
414
- return id ? [id] : [];
415
- }
437
+ case 'error-logged':
438
+ return errorTaskRefIds(record);
416
439
  case 'task-added':
417
440
  case 'gate-outcome':
418
441
  case 'task-superseded': {
@@ -606,26 +629,27 @@ export function foldTasks(events, opts = {}) {
606
629
  }
607
630
  case 'error-logged': {
608
631
  const p = record.payload;
609
- // `task_ref` is this event's own spelling; eventTaskId covers the two
610
- // the rest of the log uses (D-245).
611
- const taskId = eventTask ?? p.task_ref;
612
- if (!taskId)
613
- break;
614
- const row = touch(taskId, record.ts, record.session_id);
615
- row.project = record.project ?? row.project;
616
- if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
617
- break;
618
- // Severity decides whether the task moves; the error class decides
619
- // where. taxonomy.yml: S3 is "real but waivable; batched to operator
620
- // at epic end", S4 is "logged, never asked" — a budget note or a
621
- // tool hiccup at that level is on the record but the task carries on,
622
- // and showing it as blocked is what the board did for 43 of the 55
623
- // errors logged so far. A record with no severity is treated as
624
- // major: the write path requires the field, so its absence means a
625
- // log this reader does not own.
626
- if (NOTE_ONLY_SEVERITIES.has(p.severity ?? ''))
627
- break;
628
- 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
+ }
629
653
  break;
630
654
  }
631
655
  default:
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `smith dispatch lint` — check a dispatch prompt against two things a
3
+ * dispatcher can get wrong without any signal until the agent runs:
4
+ *
5
+ * - the turn budget stated in the prompt vs the role template's
6
+ * `maxTurns:`, read fresh off disk (dispatch.md "Carry into the
7
+ * prompt" — a prompt that promises more than the template is a fiction
8
+ * the agent plans against and gets cut in the middle of).
9
+ * - a judge prompt's declared-artifact line vs what the ledger recorded
10
+ * for its `judge dispatch` (dispatch.md "Declare each judge's
11
+ * artifact" — a judge prompt missing that line is undetectable until
12
+ * the report never lands).
13
+ *
14
+ * Both parsers and both comparisons live here so the two checks stay one
15
+ * source of truth for "what does a compliant dispatch prompt say", rather
16
+ * than every dispatcher re-deriving it.
17
+ */
18
+ import path from 'node:path';
19
+ import { readTemplateMaxTurns } from './agentsSync.js';
20
+ import { readJudgeTurns } from './judges.js';
21
+ import { AGENTS_DIR } from './paths.js';
22
+ /**
23
+ * The epic criterion's six judge roles (task 5 and task 6 objectives both
24
+ * name this exact set: spec-reviewer, reviewer, verifier, grader,
25
+ * security-reviewer, auditor) -- the roles whose dispatch declares an
26
+ * artifact and whose SubagentStop is guarded by judge-stop.sh. Kept as its
27
+ * own list rather than read out of judges.ts: `foldJudgeTurns` folds any
28
+ * role with a `declared_artifact` on its dispatch into a turn, but this
29
+ * module is stricter on purpose — only these six get an artifact check, so
30
+ * a coder's dispatch (never carries one) reads `not-applicable` rather than
31
+ * a false `undeclared`.
32
+ *
33
+ * Not the same six as dispatch.md's "Fingerprint the worktree around every
34
+ * judge" list, which swaps `auditor` for `uiux`: that section is about
35
+ * which judges run inside a task's worktree (auditor runs over a whole
36
+ * project at HEAD, never per-task, so a worktree fingerprint does not apply
37
+ * to it; uiux does run per-task). This list is about which roles owe a
38
+ * declared artifact and a blocked Stop, and `auditor` is one of them.
39
+ */
40
+ export const JUDGE_ROLES = [
41
+ 'reviewer',
42
+ 'verifier',
43
+ 'grader',
44
+ 'spec-reviewer',
45
+ 'security-reviewer',
46
+ 'auditor',
47
+ ];
48
+ const JUDGE_ROLE_SET = new Set(JUDGE_ROLES);
49
+ const TURN_BUDGET_LINE = /Turn budget:[ \t]*(\S*)/g;
50
+ /**
51
+ * `Turn budget: <n>` in a dispatch prompt — mid-sentence or on its own
52
+ * line, since a dispatch's leading line names the role and the budget in
53
+ * one breath ("Role: coder. Turn budget: 40 ..."). Null for no occurrence,
54
+ * more than one (two numbers is no number), or a value that is not a bare
55
+ * non-negative integer — every one of those is "cannot state a turn count",
56
+ * not "states zero".
57
+ */
58
+ export function parseStatedTurns(prompt) {
59
+ const matches = [...prompt.matchAll(TURN_BUDGET_LINE)];
60
+ if (matches.length !== 1)
61
+ return null;
62
+ const raw = matches[0]?.[1] ?? '';
63
+ return /^[0-9]+$/.test(raw) ? Number(raw) : null;
64
+ }
65
+ const DECLARED_ARTIFACT_LINE = /^Declared artifact:[ \t]*(\S+)[ \t]*$/m;
66
+ /** `Declared artifact: <path>` in a dispatch prompt, or null when the line is not there. */
67
+ export function parseDeclaredArtifactLine(prompt) {
68
+ const match = DECLARED_ARTIFACT_LINE.exec(prompt);
69
+ return match?.[1] ?? null;
70
+ }
71
+ /** The line a dispatch is expected to carry verbatim, given the ledger's declared path. */
72
+ export function formatDeclaredArtifactLine(artifactPath) {
73
+ return `Declared artifact: ${artifactPath}`;
74
+ }
75
+ function checkTurns(prompt, role, agentsDir) {
76
+ const stated = parseStatedTurns(prompt);
77
+ const template = readTemplateMaxTurns(agentsDir, role) ?? null;
78
+ if (template === null)
79
+ return { status: 'unverifiable', stated, template: null };
80
+ if (stated === null)
81
+ return { status: 'missing', stated: null, template };
82
+ if (stated === template)
83
+ return { status: 'ok', stated, template };
84
+ return { status: stated < template ? 'under' : 'over', stated, template };
85
+ }
86
+ async function checkArtifact(prompt, role, taskId, sessionId, eventOpts) {
87
+ if (!JUDGE_ROLE_SET.has(role)) {
88
+ return { status: 'not-applicable', expected_line: null, declared_line: null };
89
+ }
90
+ const declaredLine = parseDeclaredArtifactLine(prompt);
91
+ const turns = await readJudgeTurns(taskId, { sessionId }, eventOpts);
92
+ const turn = turns.find((t) => t.role === role);
93
+ if (turn === undefined) {
94
+ return { status: 'undeclared', expected_line: null, declared_line: declaredLine };
95
+ }
96
+ const expectedLine = formatDeclaredArtifactLine(turn.declaredArtifact);
97
+ if (declaredLine === null) {
98
+ return { status: 'missing', expected_line: expectedLine, declared_line: null };
99
+ }
100
+ if (declaredLine === turn.declaredArtifact) {
101
+ return { status: 'ok', expected_line: expectedLine, declared_line: declaredLine };
102
+ }
103
+ if (!path.isAbsolute(declaredLine)) {
104
+ return { status: 'relative', expected_line: expectedLine, declared_line: declaredLine };
105
+ }
106
+ return { status: 'mismatch', expected_line: expectedLine, declared_line: declaredLine };
107
+ }
108
+ // Fail closed: every status but "ok" exits non-zero, including "under" (a
109
+ // prompt that understates the template's budget still disagrees with it) and
110
+ // "unverifiable" (an unreadable template is not a pass, exactly like `dispatch
111
+ // check`, `tester check`, `delegation check`, and `escalation check` already
112
+ // treat "cannot tell" as a failure, never a maybe).
113
+ const TURNS_FAIL = new Set(['under', 'over', 'missing', 'unverifiable']);
114
+ const ARTIFACT_FAIL = new Set([
115
+ 'missing',
116
+ 'relative',
117
+ 'mismatch',
118
+ 'undeclared',
119
+ ]);
120
+ /**
121
+ * Lint one dispatch prompt. Never throws on a bad prompt — a bad prompt is
122
+ * exactly the finding this reports — only on a missing task/session (the
123
+ * caller's own mistake, same as every other verb that reads the ledger).
124
+ */
125
+ export async function lintDispatchPrompt(input) {
126
+ const agentsDir = input.agentsDir ?? AGENTS_DIR;
127
+ const eventOpts = input.eventOpts ?? {};
128
+ const turns = checkTurns(input.prompt, input.role, agentsDir);
129
+ const artifact = await checkArtifact(input.prompt, input.role, input.taskId, input.sessionId, eventOpts);
130
+ const exitCode = TURNS_FAIL.has(turns.status) || ARTIFACT_FAIL.has(artifact.status) ? 1 : 0;
131
+ return { turns, artifact, exitCode };
132
+ }