@juzser/blacksmith 0.1.0 → 0.1.1

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 (61) hide show
  1. package/.claude/agents/coder.md +9 -3
  2. package/.claude/agents/grader.md +13 -5
  3. package/.claude/agents/merger.md +2 -0
  4. package/.claude/agents/planner.md +1 -1
  5. package/.claude/agents/researcher.md +3 -1
  6. package/.claude/agents/reviewer.md +9 -2
  7. package/.claude/agents/security-reviewer.md +15 -7
  8. package/.claude/agents/spec-reviewer.md +12 -3
  9. package/.claude/agents/tester.md +5 -2
  10. package/.claude/agents/uiux.md +10 -3
  11. package/.claude/agents/verifier.md +8 -3
  12. package/.claude/skills/bs/SKILL.md +103 -0
  13. package/.claude/skills/bs/audit.md +250 -0
  14. package/.claude/skills/bs/dispatch.md +394 -0
  15. package/.claude/skills/bs/lessons.md +73 -0
  16. package/.claude/skills/bs/mcp.md +29 -0
  17. package/.claude/skills/bs/new.md +33 -0
  18. package/.claude/skills/bs/plan.md +155 -0
  19. package/.claude/skills/bs/report.md +129 -0
  20. package/.claude/skills/bs/run.md +325 -0
  21. package/.claude/skills/bs/status.md +54 -0
  22. package/.claude/skills/bs/ui.md +12 -0
  23. package/.claude/skills/bs/waivers.md +13 -0
  24. package/.claude/skills/bs/wave.md +261 -0
  25. package/README.md +119 -65
  26. package/factory/orchestrator/dist/actors.js +4 -3
  27. package/factory/orchestrator/dist/agents-registry.js +96 -19
  28. package/factory/orchestrator/dist/artifacts.js +36 -4
  29. package/factory/orchestrator/dist/budgets.js +7 -5
  30. package/factory/orchestrator/dist/cli.js +107 -7
  31. package/factory/orchestrator/dist/crosscheck.js +11 -0
  32. package/factory/orchestrator/dist/daemon.js +14 -0
  33. package/factory/orchestrator/dist/db/projector.js +272 -90
  34. package/factory/orchestrator/dist/db/queries.js +83 -14
  35. package/factory/orchestrator/dist/db/schema.js +7 -0
  36. package/factory/orchestrator/dist/events.js +38 -4
  37. package/factory/orchestrator/dist/harness.js +367 -0
  38. package/factory/orchestrator/dist/judges.js +49 -8
  39. package/factory/orchestrator/dist/lessons.js +4 -4
  40. package/factory/orchestrator/dist/mcp.js +4 -3
  41. package/factory/orchestrator/dist/paths.js +167 -15
  42. package/factory/orchestrator/dist/plan.js +1 -1
  43. package/factory/orchestrator/dist/planQuorum.js +36 -8
  44. package/factory/orchestrator/dist/providers/api-transport.js +62 -5
  45. package/factory/orchestrator/dist/providers/index.js +1 -0
  46. package/factory/orchestrator/dist/providers/schema-validate.js +58 -7
  47. package/factory/orchestrator/dist/providers/types.js +5 -0
  48. package/factory/orchestrator/dist/roadmap.js +3 -3
  49. package/factory/orchestrator/dist/scaffold.js +3 -2
  50. package/factory/orchestrator/dist/stack.js +10 -2
  51. package/factory/orchestrator/dist/templateFragments.js +178 -0
  52. package/factory/orchestrator/dist/usage.js +24 -5
  53. package/factory/orchestrator/dist/workroot.js +113 -0
  54. package/factory/orchestrator/drizzle/0011_findings_spec_ref.sql +3 -0
  55. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  56. package/factory/policies/budgets.yml +6 -3
  57. package/factory/policies/crosscheck.yml +10 -8
  58. package/factory/policies/lessons.md +122 -0
  59. package/factory/policies/taxonomy.yml +6 -2
  60. package/factory/specs/roadmap.md +154 -0
  61. package/package.json +8 -6
@@ -40,8 +40,8 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
40
40
  import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
41
41
  import { detectStale as detectStaleAgents, foldAgents, } from '../agents-registry.js';
42
42
  import { eventTaskId, listSessionIds, mergeSessionLogs, readEvents, } from '../events.js';
43
- import { foldFindingsDetailed, missingProjectionFields, } from '../findings.js';
44
- import { DB_MIGRATIONS_DIR, ROADMAP_PATH, STATE_DB_PATH, STATE_EVENTS_DIR } from '../paths.js';
43
+ import { findingScope, foldFindingsDetailed, missingProjectionFields, } from '../findings.js';
44
+ import { DB_MIGRATIONS_DIR, roadmapReadPath, STATE_DB_PATH, STATE_EVENTS_DIR } from '../paths.js';
45
45
  import { isPlanRefTaskId, latestPlanVersion, loadPlan } from '../plan.js';
46
46
  import { loadRoadmap } from '../roadmap.js';
47
47
  import { assertRuntimeSupported } from '../runtime.js';
@@ -92,16 +92,16 @@ function clearSession(db, sessionId) {
92
92
  db.delete(schema.prompts).where(eq(schema.prompts.sessionId, sessionId)).run();
93
93
  db.delete(schema.dispatches).where(eq(schema.dispatches.sessionId, sessionId)).run();
94
94
  db.delete(schema.agents).where(eq(schema.agents.sessionId, sessionId)).run();
95
- db.delete(schema.tasks).where(eq(schema.tasks.sessionId, sessionId)).run();
96
95
  db.delete(schema.epics).where(eq(schema.epics.sessionId, sessionId)).run();
97
96
  db.delete(schema.edges).where(eq(schema.edges.sessionId, sessionId)).run();
98
97
  db.delete(schema.errors).where(eq(schema.errors.sessionId, sessionId)).run();
99
98
  db.delete(schema.waivers).where(eq(schema.waivers.sessionId, sessionId)).run();
100
99
  db.delete(schema.artifacts).where(eq(schema.artifacts.sessionId, sessionId)).run();
101
- // `lessons` and `findings` are deliberately absent, like `milestones` —
102
- // projectLessons() and projectFindings() own those tables whole (D-199,
103
- // D-200). Deleting by session_id here would delete a row this session
104
- // raised and another session has since approved or closed.
100
+ // `lessons`, `findings` and `tasks` are deliberately absent, like
101
+ // `milestones` — projectLessons(), projectFindings() and projectTasks() own
102
+ // those tables whole (D-199, D-200, and the continuation case projectTasks()
103
+ // describes). Deleting by session_id here would delete a row this session
104
+ // raised and another session has since approved, closed or worked on.
105
105
  }
106
106
  /**
107
107
  * Fully replace the milestones table from factory/specs/roadmap.md (not
@@ -118,7 +118,7 @@ function clearSession(db, sessionId) {
118
118
  * than the whole write erroring out; logged to stderr so it is visible.
119
119
  */
120
120
  function projectMilestones(handle, opts) {
121
- const roadmapPath = opts.roadmapPath ?? ROADMAP_PATH;
121
+ const roadmapPath = opts.roadmapPath ?? roadmapReadPath();
122
122
  if (!existsSync(roadmapPath))
123
123
  return;
124
124
  let defs;
@@ -150,6 +150,13 @@ function projectMilestones(handle, opts) {
150
150
  }
151
151
  });
152
152
  }
153
+ function describeShape(value) {
154
+ if (value === null)
155
+ return 'null';
156
+ if (Array.isArray(value))
157
+ return 'an array';
158
+ return typeof value === 'object' ? 'an object' : `a ${typeof value}`;
159
+ }
153
160
  /** Splits an `error.class` string into its group/class parts; tolerant of malformed values. */
154
161
  function splitErrorClass(value) {
155
162
  const [group, cls] = (value ?? '').split('.');
@@ -162,6 +169,11 @@ const TERMINAL_TASK_STATUSES = new Set([
162
169
  'escalated',
163
170
  'waived',
164
171
  ]);
172
+ /**
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.
175
+ */
176
+ const NOTE_ONLY_SEVERITIES = new Set(['S3-minor', 'S4-nit']);
165
177
  /**
166
178
  * Fallback for a `task-added` that declares no `branch` — every event logged
167
179
  * before D-23/P9-12 added the field, and any hand-appended one. The
@@ -262,29 +274,37 @@ function knownEpicIds(events) {
262
274
  */
263
275
  function planRosterAliases(epics, specsDir) {
264
276
  const candidates = new Map();
277
+ for (const id of planRosterTaskIds(epics, specsDir)) {
278
+ if (!isQualifiedTaskId(id))
279
+ continue;
280
+ const bare = bareTaskId(id);
281
+ const set = candidates.get(bare) ?? new Set();
282
+ set.add(id);
283
+ candidates.set(bare, set);
284
+ }
285
+ return candidates;
286
+ }
287
+ /**
288
+ * Every task id the latest plan on disk lists, for every epic the log names.
289
+ * The one read behind both `planRosterAliases` and foldTasks()'s "is this id
290
+ * a task at all" check, so the two can never disagree about what a plan says.
291
+ */
292
+ function planRosterTaskIds(epics, specsDir) {
293
+ const ids = [];
265
294
  for (const epicId of epics) {
266
- let roster = [];
267
295
  try {
268
296
  const version = latestPlanVersion(epicId, { specsDir });
269
297
  if (version !== null) {
270
- roster = loadPlan(epicId, version, { specsDir }).tasks.map((t) => t.task_id);
298
+ ids.push(...loadPlan(epicId, version, { specsDir }).tasks.map((t) => t.task_id));
271
299
  }
272
300
  }
273
301
  catch (err) {
274
- console.error(`db/projector.ts planRosterAliases(): plan file for "${epicId}" ` +
302
+ console.error(`db/projector.ts planRosterTaskIds(): plan file for "${epicId}" ` +
275
303
  `failed to load, leaving its task ids unresolved: ` +
276
304
  `${err instanceof Error ? err.message : String(err)}`);
277
305
  }
278
- for (const id of roster) {
279
- if (!isQualifiedTaskId(id))
280
- continue;
281
- const bare = bareTaskId(id);
282
- const set = candidates.get(bare) ?? new Set();
283
- set.add(id);
284
- candidates.set(bare, set);
285
- }
286
306
  }
287
- return candidates;
307
+ return ids;
288
308
  }
289
309
  /**
290
310
  * Bare id -> the one qualified id it must mean, built from every epic the log
@@ -367,12 +387,55 @@ function waveTaskIds(record) {
367
387
  const taskId = eventTaskId(record);
368
388
  return taskId ? [taskId] : [];
369
389
  }
390
+ /**
391
+ * The task ids one event asserts something about, spelled the way foldTasks()'s
392
+ * switch reads them: the envelope or payload `task_id` (D-245) for the
393
+ * 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.
397
+ */
398
+ function assertedTaskIds(record) {
399
+ switch (record.event_type) {
400
+ case 'wave-admitted':
401
+ case 'wave-merged':
402
+ return waveTaskIds(record);
403
+ case 'error-logged': {
404
+ const id = eventTaskId(record) ?? record.payload.task_ref;
405
+ return id ? [id] : [];
406
+ }
407
+ case 'task-added':
408
+ case 'gate-outcome':
409
+ case 'task-superseded': {
410
+ const id = eventTaskId(record);
411
+ return id ? [id] : [];
412
+ }
413
+ default:
414
+ return [];
415
+ }
416
+ }
370
417
  export function foldTasks(events, opts = {}) {
371
418
  const byId = new Map();
372
419
  // Normalisation happens at this boundary, once, before any row exists —
373
420
  // so no later code has to ask which of two ids for one task it is holding.
374
421
  const canonical = taskIdCanonicalizer(events, opts);
375
422
  const epics = knownEpicIds(events);
423
+ // The ids the log asserts a task under: every id a task-level event names
424
+ // (`task-added`, a wave admission or merge, a gate outcome, a supersession,
425
+ // an error) plus what the plan roster on disk lists (D-250's task whose
426
+ // every event was bare and whose `task-added` never reached the log). A
427
+ // `dispatch_decision` is deliberately not on that list — see touch() for
428
+ // the twenty-two cards that taught it. Read over the whole log before the
429
+ // walk so the answer does not depend on which event reached the log first.
430
+ const declared = new Set();
431
+ for (const { record } of events) {
432
+ if (record.event_type === 'dispatch_decision')
433
+ continue;
434
+ for (const id of assertedTaskIds(record))
435
+ declared.add(canonical(id));
436
+ }
437
+ for (const id of planRosterTaskIds(epics, opts.specsDir))
438
+ declared.add(canonical(id));
376
439
  function touch(rawTaskId, ts, sessionId) {
377
440
  const taskId = canonical(rawTaskId);
378
441
  let row = byId.get(taskId);
@@ -396,8 +459,9 @@ export function foldTasks(events, opts = {}) {
396
459
  updatedAt: ts,
397
460
  project: null,
398
461
  };
399
- // Three ref shapes are NOT tasks and must never surface as kanban
400
- // cards. The epic's own bare id, which `error-logged` writes into
462
+ // Not every id an event carries is a task, and a ref that is not a
463
+ // task must never surface as a kanban card. Three shapes are refused by
464
+ // name: the epic's own bare id, which `error-logged` writes into
401
465
  // `payload.task_ref` when the failure belongs to no single task (D-251)
402
466
  // — the phantom foldEpics() keeps itself off this fold to avoid; the
403
467
  // errors table takes that row straight from the event, so refusing the
@@ -409,18 +473,33 @@ export function foldTasks(events, opts = {}) {
409
473
  // dispatch_decision/judge-verdict/quorum-decision events their quorum
410
474
  // case emits, so provider-agreement analytics can group by it like any
411
475
  // other dispatch (db/queries.ts's providerAgreement() reads eventsRaw
412
- // directly, never joins tasks, so it's unaffected either way). Every
413
- // event type that carries a task_id routes through this one touch()
414
- // choke point (task-added, wave-admitted, dispatch_decision,
476
+ // directly, never joins tasks, so it's unaffected either way).
477
+ //
478
+ // The fourth refusal is the general one: an id nothing but a dispatch
479
+ // ever named. A session that ran its planning rounds by hand, before
480
+ // planRefTaskId() existed, stamped each round's dispatch with an id it
481
+ // made up on the spot — `<epic>/plan-r12`, `<epic>/spec-review-r15` —
482
+ // and twenty-two of those sat in the board's "In progress" column for
483
+ // a week, one card each, no objective, nothing ever moving them; the
484
+ // column was nothing BUT them. A dispatch is a fact about an agent and
485
+ // its task_id is where the agent was pointed; a task is a fact the log
486
+ // asserts elsewhere (`declared`, above), and a dispatch moves one that
487
+ // exists rather than minting one. An orphan the log DOES assert — a
488
+ // bare id no roster claims (D-250), an error against a ref nobody
489
+ // declared — keeps its row on purpose: a producer's mistake stays
490
+ // visible. Refusing by what the log asserts rather than by spelling
491
+ // means the next hand-minted ref never gets a card either.
492
+ //
493
+ // Every event type that carries a task_id routes through this one
494
+ // touch() choke point (task-added, wave-admitted, dispatch_decision,
415
495
  // gate-outcome, wave-merged, task-superseded, error-logged), so one
416
496
  // guard here is enough: build the row (callers below still mutate it
417
- // freely) but never register a reserved ref in byId, so it never
418
- // reaches foldTasks()'s returned rows or projectSession()'s tasks-table
419
- // insert loop.
497
+ // freely) but never register a refused id in byId, so it never
498
+ // reaches foldTasks()'s returned rows or projectTasks()'s insert loop.
420
499
  const isReservedRef = taskId.split('/').pop() === RESERVED_TASK_ID ||
421
500
  isPlanRefTaskId(taskId) ||
422
501
  epics.has(taskId);
423
- if (!isReservedRef)
502
+ if (!isReservedRef && declared.has(taskId))
424
503
  byId.set(taskId, row);
425
504
  }
426
505
  row.updatedAt = ts;
@@ -451,7 +530,8 @@ export function foldTasks(events, opts = {}) {
451
530
  // the amended task carries the new plan_version) would otherwise revert
452
531
  // a merged task to `todo`. A terminal status is a fact the log earned
453
532
  // from `wave-merged`; a static field in a plan file does not overrule
454
- // it. Same guard `dispatch_decision` and `error-logged` already apply.
533
+ // it. `wave-admitted`, `dispatch_decision`, `gate-outcome` and
534
+ // `error-logged` carry the same guard.
455
535
  if (!TERMINAL_TASK_STATUSES.has(row.taskStatus))
456
536
  row.taskStatus = p.task_status ?? row.taskStatus;
457
537
  row.planVersion = p.plan_version ?? row.planVersion;
@@ -466,7 +546,13 @@ export function foldTasks(events, opts = {}) {
466
546
  const p = record.payload;
467
547
  for (const taskId of waveTaskIds(record)) {
468
548
  const row = touch(taskId, record.ts, record.session_id);
469
- row.taskStatus = 'ready';
549
+ // A re-planned wave admits the new plan's task ids, and one of them
550
+ // can be an id that already merged under the previous version. The
551
+ // admission is a fact about the wave, not a reopening of the task:
552
+ // the board showed a shipped task as `ready` for the rest of the
553
+ // epic (D-249). Same guard as `task-added` and `dispatch_decision`.
554
+ if (!TERMINAL_TASK_STATUSES.has(row.taskStatus))
555
+ row.taskStatus = 'ready';
470
556
  row.epicId = epicOfTaskId(row.taskId) ?? p.epic_id ?? row.epicId;
471
557
  }
472
558
  break;
@@ -485,6 +571,10 @@ export function foldTasks(events, opts = {}) {
485
571
  break;
486
572
  const p = record.payload;
487
573
  const row = touch(eventTask, record.ts, record.session_id);
574
+ // A judge whose evidence lands late is gated after `wave-merged`; its
575
+ // verdict is on the record, but the task it grades already shipped.
576
+ if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
577
+ break;
488
578
  if (p.outcome === 'blocked')
489
579
  row.taskStatus = 'blocked';
490
580
  else if (p.outcome === 'pass-with-waivers-pending')
@@ -516,6 +606,16 @@ export function foldTasks(events, opts = {}) {
516
606
  row.project = record.project ?? row.project;
517
607
  if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
518
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;
519
619
  row.taskStatus = p.error?.startsWith('coordination.') ? 'escalated' : 'blocked';
520
620
  break;
521
621
  }
@@ -754,26 +854,30 @@ function planProjectResolver(specsDir) {
754
854
  /**
755
855
  * foldTasks() with D-246's plan-file backfill applied.
756
856
  *
757
- * Both folds of the task list go through here, because both answer the same
758
- * question and must not answer it differently: projectSession() derives the
759
- * tasks, epics, dispatches, artifacts and errors rows from one, and
760
- * projectFindings() folds the same events again for findings.project. Split
761
- * the backfill across only one of them and a single task reads as demo-rpg's
762
- * on the board and black-smith's on the errors page.
857
+ * Every fold of the task list goes through here, because all of them answer
858
+ * the same question and must not answer it differently: projectSession()
859
+ * derives the epics, dispatches, artifacts and errors rows from one,
860
+ * projectTasks() writes the tasks table from another over every session's
861
+ * log, and projectFindings() folds the same events again for
862
+ * findings.project. Split the backfill across only one of them and a single
863
+ * task reads as demo-rpg's on the board and black-smith's on the errors page.
763
864
  */
764
865
  function foldTasksWithPlanProject(events, opts) {
765
866
  const projectFromPlan = planProjectResolver(opts.specsDir);
766
867
  return foldTasks([...events], { specsDir: opts.specsDir }).map((t) => t.project === null ? { ...t, project: projectFromPlan(t.epicId) } : t);
767
868
  }
768
869
  export function projectSession(handle, sessionId, events, opts = {}) {
870
+ const skippedArtifacts = [];
769
871
  handle.db.transaction((txDb) => {
770
872
  clearSession(txDb, sessionId);
771
873
  if (events.length === 0)
772
874
  return;
773
- // Folded once, up here: the rows below inherit their project from it, and
774
- // the tasks table is written from the very same array further down. The
775
- // plan-file backfill lands here for that reason -- one insertion point,
776
- // and the tasks table, the epics table and every child row come with it.
875
+ // Folded once, up here: the rows below inherit their project from it. The
876
+ // tasks table itself is NOT written from this array -- projectTasks() folds
877
+ // every session's log for that, because a task planned in one session and
878
+ // worked in its continuation is one row -- but the epics table and every
879
+ // child row inherit their project from this per-session fold, and the
880
+ // plan-file backfill lands here so they agree with the global one.
777
881
  const taskRows = foldTasksWithPlanProject(events, opts);
778
882
  const projectForTask = projectResolver(taskRows);
779
883
  const projectForEpic = new Map(taskRows.flatMap((t) => (t.project === null ? [] : [[t.epicId, t.project]])));
@@ -916,8 +1020,21 @@ export function projectSession(handle, sessionId, events, opts = {}) {
916
1020
  const taskId = eventTask;
917
1021
  if (!taskId)
918
1022
  continue;
1023
+ // The whole session runs inside this transaction: a throw here used
1024
+ // to roll back every row above, so an epic three waves deep drew as
1025
+ // nothing. Hold back the one list that cannot be read, name it, and
1026
+ // keep going (D-141: a loud undercount beats a crash).
1027
+ if (p.artifacts !== undefined && !Array.isArray(p.artifacts)) {
1028
+ skippedArtifacts.push({
1029
+ event_id,
1030
+ session_id: record.session_id,
1031
+ task_id: taskId,
1032
+ reason: `payload.artifacts is ${describeShape(p.artifacts)}, not an array`,
1033
+ });
1034
+ continue;
1035
+ }
919
1036
  (p.artifacts ?? []).forEach((artifact, index) => {
920
- if (!artifact.type || !artifact.path)
1037
+ if (!artifact || typeof artifact !== 'object' || !artifact.type || !artifact.path)
921
1038
  return;
922
1039
  txDb
923
1040
  .insert(schema.artifacts)
@@ -952,27 +1069,6 @@ export function projectSession(handle, sessionId, events, opts = {}) {
952
1069
  .run();
953
1070
  }
954
1071
  }
955
- for (const task of taskRows) {
956
- txDb
957
- .insert(schema.tasks)
958
- .values({
959
- taskId: task.taskId,
960
- sessionId: task.sessionId,
961
- epicId: task.epicId,
962
- caseTag: task.caseTag,
963
- origin: task.origin,
964
- taskStatus: task.taskStatus,
965
- planVersion: task.planVersion,
966
- objective: task.objective,
967
- claims: task.claims ? JSON.stringify(task.claims) : null,
968
- budgetTokens: task.budgetTokens,
969
- branch: task.branch,
970
- createdAt: task.createdAt,
971
- updatedAt: task.updatedAt,
972
- project: task.project,
973
- })
974
- .run();
975
- }
976
1072
  for (const epic of foldEpics(events)) {
977
1073
  txDb
978
1074
  .insert(schema.epics)
@@ -1017,6 +1113,62 @@ export function projectSession(handle, sessionId, events, opts = {}) {
1017
1113
  }
1018
1114
  // `lessons` is not written here — see projectLessons() (D-199).
1019
1115
  });
1116
+ return { skippedArtifacts };
1117
+ }
1118
+ /**
1119
+ * Fully replace the tasks table from EVERY session's log at once, in one
1120
+ * causal order (mergeSessionLogs). Not session-scoped, the same shape as
1121
+ * projectLessons() and projectFindings() below, and for the same reason.
1122
+ *
1123
+ * A task is planned in one session and worked in another BY DESIGN: an epic
1124
+ * outlives an orchestrator's context window, and `smith session start
1125
+ * --continues` (P9-7) is how the next session picks the epic up. Every event
1126
+ * in the continuation names the same task_id the parent's `task-added` did,
1127
+ * and `tasks.task_id` is the table's whole primary key. Folding one session
1128
+ * at a time inserted the row twice -- once from the parent's fold, once from
1129
+ * the continuation's -- and the second insert aborted the whole rebuild on
1130
+ * `UNIQUE constraint failed: tasks.task_id`. The first real continuation in
1131
+ * this factory's own logs could not be projected at all; the dashboard showed
1132
+ * the epic without its working session.
1133
+ *
1134
+ * A per-session upsert would not do either. Whichever session is projected
1135
+ * last would own the row, and listSessionIds() sorts by filename, which is
1136
+ * nothing causal: re-applying the parent after the continuation would put the
1137
+ * task back to `todo`. foldTasks() already knows how to fold a task's whole
1138
+ * history in order -- terminal statuses stick, a late gate-outcome does not
1139
+ * reopen a merged task -- so it is handed the whole history: one global key,
1140
+ * one global fold.
1141
+ *
1142
+ * The row still carries the session that FIRST touched the task -- the one
1143
+ * whose plan added it -- so `kanban({ sessionId })` keeps meaning "tasks born
1144
+ * here", and the lineage width (D-264) is what shows the continuation's
1145
+ * progress on them.
1146
+ */
1147
+ export function projectTasks(handle, events, opts = {}) {
1148
+ handle.db.transaction((txDb) => {
1149
+ txDb.delete(schema.tasks).run();
1150
+ for (const task of foldTasksWithPlanProject(events, opts)) {
1151
+ txDb
1152
+ .insert(schema.tasks)
1153
+ .values({
1154
+ taskId: task.taskId,
1155
+ sessionId: task.sessionId,
1156
+ epicId: task.epicId,
1157
+ caseTag: task.caseTag,
1158
+ origin: task.origin,
1159
+ taskStatus: task.taskStatus,
1160
+ planVersion: task.planVersion,
1161
+ objective: task.objective,
1162
+ claims: task.claims ? JSON.stringify(task.claims) : null,
1163
+ budgetTokens: task.budgetTokens,
1164
+ branch: task.branch,
1165
+ createdAt: task.createdAt,
1166
+ updatedAt: task.updatedAt,
1167
+ project: task.project,
1168
+ })
1169
+ .run();
1170
+ }
1171
+ });
1020
1172
  }
1021
1173
  /**
1022
1174
  * Fully replace the lessons table from EVERY session's log at once, in one
@@ -1149,9 +1301,10 @@ function projectFindings(handle, events, opts) {
1149
1301
  const stamps = findingTimestamps(events);
1150
1302
  // A finding has no `project` of its own on the wire (findings.ts's raiseFinding()
1151
1303
  // predates Phase 6b) — derive it from its owning task's project, the same fold
1152
- // projectSession() computes, so the value always matches tasks.project exactly.
1153
- // Same helper as projectSession() for exactly that reason: the plan-file
1154
- // backfill (D-246) has to reach both folds or the invariant is a lie.
1304
+ // projectTasks() writes the tasks table from, over the same merged events, so
1305
+ // the value always matches tasks.project exactly. Same helper for exactly that
1306
+ // reason: the plan-file backfill (D-246) has to reach both folds or the
1307
+ // invariant is a lie.
1155
1308
  const taskRows = foldTasksWithPlanProject(events, opts);
1156
1309
  // projectResolver and not a Map lookup, for the reason its own docblock
1157
1310
  // gives: the log spells one task both `epic/task-1` and `task-1`, and a
@@ -1216,6 +1369,10 @@ function projectFindings(handle, events, opts) {
1216
1369
  waiverId: finding.waiver_id ?? null,
1217
1370
  raisedAt: stamp.raisedAt,
1218
1371
  updatedAt: stamp.updatedAt,
1372
+ // Through findingScope(), not the raw field: absence means diff.
1373
+ findingScope: findingScope(finding),
1374
+ specPlanVersion: finding.spec_ref?.plan_version ?? null,
1375
+ criterionRef: finding.spec_ref?.criterion_ref ?? null,
1219
1376
  });
1220
1377
  }
1221
1378
  handle.db.transaction((txDb) => {
@@ -1226,22 +1383,25 @@ function projectFindings(handle, events, opts) {
1226
1383
  return skipped;
1227
1384
  }
1228
1385
  /**
1229
- * Every named session's log, read once (D-199 — projectLessons), oldest
1230
- * session first.
1231
- *
1232
- * The order is part of the contract, not tidiness. mergeSessionLogs breaks a
1233
- * tie on `ts` by array position — for a lineage that is right, because
1234
- * walkLineage hands it the chain root-first, but a SET of sessions has no
1235
- * such order, and two events written in the same millisecond in two sessions
1236
- * are exactly the case a global fold has to settle the same way every time.
1237
- * "The session that started earlier" is the one causal reading available;
1238
- * session id settles two logs that start in the same millisecond, so the
1239
- * order is total and independent of how the caller listed them.
1386
+ * Reads every named log. `tolerate` names the sessions whose read may fail
1387
+ * without failing the call: those come back in `unreadable` instead of as
1388
+ * rows. Any other session's failure is thrown as it always was.
1240
1389
  */
1241
- async function readAllSessionLogs(sessionIds, stateDir) {
1390
+ async function readAllSessionLogs(sessionIds, stateDir, tolerate = new Set()) {
1242
1391
  const logs = [];
1392
+ const unreadable = [];
1243
1393
  for (const sessionId of sessionIds) {
1244
- logs.push({ sessionId, events: await readEvents(sessionId, { stateDir }) });
1394
+ try {
1395
+ logs.push({ sessionId, events: await readEvents(sessionId, { stateDir }) });
1396
+ }
1397
+ catch (err) {
1398
+ if (!tolerate.has(sessionId))
1399
+ throw err;
1400
+ unreadable.push({
1401
+ session_id: sessionId,
1402
+ reason: err instanceof Error ? err.message : String(err),
1403
+ });
1404
+ }
1245
1405
  }
1246
1406
  logs.sort((a, b) => {
1247
1407
  const aStart = a.events[0]?.record.ts ?? '';
@@ -1250,7 +1410,7 @@ async function readAllSessionLogs(sessionIds, stateDir) {
1250
1410
  return aStart < bStart ? -1 : 1;
1251
1411
  return a.sessionId.localeCompare(b.sessionId);
1252
1412
  });
1253
- return logs;
1413
+ return { logs, unreadable };
1254
1414
  }
1255
1415
  /**
1256
1416
  * Full rebuild: drop every table's rows, then replay every session (or the
@@ -1265,18 +1425,28 @@ export async function rebuild(dbPath = STATE_DB_PATH, sessions = 'all', opts = {
1265
1425
  const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
1266
1426
  const sessionIds = sessions === 'all' ? listSessionIds(stateDir) : [...sessions];
1267
1427
  // Read each log once: the per-session projection consumes them one at a
1268
- // time, projectLessons() and projectFindings() need them all at once.
1269
- const logs = await readAllSessionLogs(sessionIds, stateDir);
1428
+ // time; projectTasks(), projectFindings() and projectLessons() need them
1429
+ // all at once.
1430
+ const { logs } = await readAllSessionLogs(sessionIds, stateDir);
1270
1431
  let eventsApplied = 0;
1432
+ const skippedArtifacts = [];
1271
1433
  for (const { sessionId, events } of logs) {
1272
- projectSession(handle, sessionId, events, opts);
1434
+ const report = projectSession(handle, sessionId, events, opts);
1435
+ skippedArtifacts.push(...report.skippedArtifacts);
1273
1436
  eventsApplied += events.length;
1274
1437
  }
1275
1438
  const merged = mergeSessionLogs(logs);
1439
+ projectTasks(handle, merged, opts);
1276
1440
  const skippedFindings = projectFindings(handle, merged, opts);
1277
1441
  projectLessons(handle, merged);
1278
1442
  projectMilestones(handle, opts);
1279
- return { sessionsProcessed: sessionIds.length, eventsApplied, skippedFindings };
1443
+ return {
1444
+ sessionsProcessed: sessionIds.length,
1445
+ eventsApplied,
1446
+ skippedFindings,
1447
+ skippedArtifacts,
1448
+ unreadableSessions: [],
1449
+ };
1280
1450
  }
1281
1451
  finally {
1282
1452
  handle.sqlite.close();
@@ -1287,10 +1457,10 @@ export async function rebuild(dbPath = STATE_DB_PATH, sessions = 'all', opts = {
1287
1457
  * replaces only its rows, leaving every other session's projection intact.
1288
1458
  * Safe to call repeatedly while a session is still running (tailing).
1289
1459
  *
1290
- * The three tables that are not session-scoped are rewritten whole on every
1460
+ * The four tables that are not session-scoped are rewritten whole on every
1291
1461
  * call: `milestones` from roadmap.md, and — from every session's log, which is
1292
- * why this reads more than the one session it re-folds — `lessons` (D-199) and
1293
- * `findings` (D-200).
1462
+ * why this reads more than the one session it re-folds — `tasks`
1463
+ * (projectTasks()), `lessons` (D-199) and `findings` (D-200).
1294
1464
  */
1295
1465
  export async function apply(dbPath = STATE_DB_PATH, sessionId, opts = {}) {
1296
1466
  const handle = openDb(dbPath, opts);
@@ -1301,14 +1471,26 @@ export async function apply(dbPath = STATE_DB_PATH, sessionId, opts = {}) {
1301
1471
  // naming it anyway costs an empty read and keeps it in the global folds
1302
1472
  // below, which would otherwise drop the very session being applied.
1303
1473
  const listed = listSessionIds(stateDir);
1304
- const logs = await readAllSessionLogs(listed.includes(sessionId) ? listed : [...listed, sessionId], stateDir);
1474
+ // Every other session's log is read for the global folds only, so one of
1475
+ // them being unreadable is reported, not thrown: this session's rows do
1476
+ // not depend on it. The applied session's own log still throws -- that
1477
+ // failure is the caller's to name, and there is nothing to write for it.
1478
+ const others = new Set(listed.filter((id) => id !== sessionId));
1479
+ const { logs, unreadable } = await readAllSessionLogs(listed.includes(sessionId) ? listed : [...listed, sessionId], stateDir, others);
1305
1480
  const events = logs.find((l) => l.sessionId === sessionId)?.events ?? [];
1306
- projectSession(handle, sessionId, events, opts);
1481
+ const { skippedArtifacts } = projectSession(handle, sessionId, events, opts);
1307
1482
  const merged = mergeSessionLogs(logs);
1483
+ projectTasks(handle, merged, opts);
1308
1484
  const skippedFindings = projectFindings(handle, merged, opts);
1309
1485
  projectLessons(handle, merged);
1310
1486
  projectMilestones(handle, opts);
1311
- return { sessionsProcessed: 1, eventsApplied: events.length, skippedFindings };
1487
+ return {
1488
+ sessionsProcessed: 1,
1489
+ eventsApplied: events.length,
1490
+ skippedFindings,
1491
+ skippedArtifacts,
1492
+ unreadableSessions: unreadable,
1493
+ };
1312
1494
  }
1313
1495
  finally {
1314
1496
  handle.sqlite.close();