@juzser/blacksmith 0.3.0 → 0.5.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 (141) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +16 -9
  4. package/.claude/agents/{merger.md → bs-merger.md} +4 -4
  5. package/.claude/agents/{planner.md → bs-planner.md} +20 -6
  6. package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
  7. package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
  11. package/.claude/agents/{tester.md → bs-tester.md} +40 -12
  12. package/.claude/agents/bs-uiux.md +257 -0
  13. package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
  14. package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
  15. package/.claude/skills/bs/SKILL.md +99 -21
  16. package/.claude/skills/bs/audit.md +45 -31
  17. package/.claude/skills/bs/dispatch.md +133 -47
  18. package/.claude/skills/bs/lessons.md +28 -14
  19. package/.claude/skills/bs/mcp.md +4 -4
  20. package/.claude/skills/bs/new.md +3 -3
  21. package/.claude/skills/bs/plan.md +57 -16
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +190 -52
  24. package/.claude/skills/bs/status.md +14 -7
  25. package/.claude/skills/bs/ui.md +21 -5
  26. package/.claude/skills/bs/waivers.md +78 -2
  27. package/.claude/skills/bs/wave.md +143 -50
  28. package/README.md +53 -32
  29. package/factory/orchestrator/dist/agentNames.js +43 -0
  30. package/factory/orchestrator/dist/agents-registry.js +11 -4
  31. package/factory/orchestrator/dist/agentsSync.js +70 -13
  32. package/factory/orchestrator/dist/artifacts.js +31 -0
  33. package/factory/orchestrator/dist/attribution.js +4 -0
  34. package/factory/orchestrator/dist/audit.js +245 -20
  35. package/factory/orchestrator/dist/autonomy.js +6 -2
  36. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  37. package/factory/orchestrator/dist/budgets.js +302 -84
  38. package/factory/orchestrator/dist/claims.js +9 -19
  39. package/factory/orchestrator/dist/cli.js +1087 -116
  40. package/factory/orchestrator/dist/cliName.js +27 -0
  41. package/factory/orchestrator/dist/crossFinding.js +20 -0
  42. package/factory/orchestrator/dist/crosscheck.js +136 -8
  43. package/factory/orchestrator/dist/daemon.js +352 -67
  44. package/factory/orchestrator/dist/db/projector.js +327 -54
  45. package/factory/orchestrator/dist/db/queries.js +1831 -94
  46. package/factory/orchestrator/dist/db/schema.js +57 -4
  47. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  48. package/factory/orchestrator/dist/delegation.js +86 -19
  49. package/factory/orchestrator/dist/diffstat.js +15 -8
  50. package/factory/orchestrator/dist/dispatchLint.js +108 -0
  51. package/factory/orchestrator/dist/effort.js +14 -2
  52. package/factory/orchestrator/dist/effortTiers.js +13 -0
  53. package/factory/orchestrator/dist/env.js +26 -0
  54. package/factory/orchestrator/dist/epic.js +494 -42
  55. package/factory/orchestrator/dist/epicBudget.js +93 -0
  56. package/factory/orchestrator/dist/epicWidth.js +22 -5
  57. package/factory/orchestrator/dist/errorIssues.js +182 -11
  58. package/factory/orchestrator/dist/escalation.js +2 -2
  59. package/factory/orchestrator/dist/events.js +164 -14
  60. package/factory/orchestrator/dist/feedback.js +169 -0
  61. package/factory/orchestrator/dist/findingContext.js +26 -0
  62. package/factory/orchestrator/dist/findings.js +108 -14
  63. package/factory/orchestrator/dist/gate.js +120 -10
  64. package/factory/orchestrator/dist/gh.js +19 -2
  65. package/factory/orchestrator/dist/git.js +20 -2
  66. package/factory/orchestrator/dist/goalCheck.js +2 -2
  67. package/factory/orchestrator/dist/harness.js +25 -17
  68. package/factory/orchestrator/dist/hookDecision.js +411 -39
  69. package/factory/orchestrator/dist/integration.js +20 -8
  70. package/factory/orchestrator/dist/issueReporter.js +81 -16
  71. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  72. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  73. package/factory/orchestrator/dist/judgeStopHook.js +239 -0
  74. package/factory/orchestrator/dist/judges.js +419 -21
  75. package/factory/orchestrator/dist/lessons.js +190 -11
  76. package/factory/orchestrator/dist/logCache.js +291 -0
  77. package/factory/orchestrator/dist/mcp.js +6 -6
  78. package/factory/orchestrator/dist/paths.js +20 -7
  79. package/factory/orchestrator/dist/plan.js +399 -33
  80. package/factory/orchestrator/dist/planQuorum.js +5 -2
  81. package/factory/orchestrator/dist/policy.js +1275 -30
  82. package/factory/orchestrator/dist/projectName.js +25 -0
  83. package/factory/orchestrator/dist/projects.js +12 -1
  84. package/factory/orchestrator/dist/promptCapture.js +306 -0
  85. package/factory/orchestrator/dist/promptHook.js +33 -0
  86. package/factory/orchestrator/dist/promptImport.js +281 -0
  87. package/factory/orchestrator/dist/prompts.js +13 -0
  88. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  89. package/factory/orchestrator/dist/providers/types.js +1 -1
  90. package/factory/orchestrator/dist/queue.js +471 -30
  91. package/factory/orchestrator/dist/quorum.js +18 -5
  92. package/factory/orchestrator/dist/results.js +25 -7
  93. package/factory/orchestrator/dist/roadmap.js +10 -5
  94. package/factory/orchestrator/dist/run-cli.js +15 -10
  95. package/factory/orchestrator/dist/runner.js +2 -2
  96. package/factory/orchestrator/dist/scheduler.js +3 -3
  97. package/factory/orchestrator/dist/schemas.js +56 -8
  98. package/factory/orchestrator/dist/severity.js +8 -4
  99. package/factory/orchestrator/dist/spec.js +246 -12
  100. package/factory/orchestrator/dist/specChange.js +13 -9
  101. package/factory/orchestrator/dist/stack.js +6 -6
  102. package/factory/orchestrator/dist/storeRoot.js +90 -0
  103. package/factory/orchestrator/dist/symbols.js +261 -6
  104. package/factory/orchestrator/dist/taskEvents.js +192 -31
  105. package/factory/orchestrator/dist/taskStatus.js +14 -0
  106. package/factory/orchestrator/dist/taxonomy.js +11 -0
  107. package/factory/orchestrator/dist/testerAudit.js +31 -12
  108. package/factory/orchestrator/dist/testgate.js +20 -16
  109. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  110. package/factory/orchestrator/dist/usage.js +113 -48
  111. package/factory/orchestrator/dist/waivers.js +99 -12
  112. package/factory/orchestrator/dist/waveBudget.js +2 -0
  113. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  114. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  115. package/factory/orchestrator/dist/workroot.js +1 -1
  116. package/factory/orchestrator/dist/worktree.js +76 -21
  117. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  118. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  119. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  120. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  121. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  122. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  123. package/factory/policies/budgets.yml +123 -33
  124. package/factory/policies/crosscheck.yml +51 -13
  125. package/factory/policies/delegation.yml +3 -3
  126. package/factory/policies/effort.yml +18 -10
  127. package/factory/policies/guardrails.yml +8 -6
  128. package/factory/policies/harness.yml +5 -5
  129. package/factory/policies/lessons.md +53 -2
  130. package/factory/policies/scheduler.yml +4 -4
  131. package/factory/policies/sensitive-paths.yml +1 -1
  132. package/factory/policies/severity.yml +13 -1
  133. package/factory/policies/stack.yml +6 -6
  134. package/factory/policies/taxonomy.yml +9 -3
  135. package/factory/policies/worktree.yml +15 -9
  136. package/factory/specs/roadmap.md +27 -3
  137. package/factory/specs/schema/event.schema.json +8 -1
  138. package/factory/specs/schema/result.schema.json +21 -8
  139. package/factory/specs/schema/task-spec.schema.json +12 -0
  140. package/package.json +16 -12
  141. package/.claude/agents/uiux.md +0 -133
@@ -35,19 +35,21 @@
35
35
  import { existsSync, mkdirSync } from 'node:fs';
36
36
  import path from 'node:path';
37
37
  import Database from 'better-sqlite3';
38
- import { eq } from 'drizzle-orm';
38
+ import { and, eq, isNull } from 'drizzle-orm';
39
39
  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 { foldOperatorFeedback } from '../feedback.js';
43
44
  import { findingScope, foldFindingsDetailed, missingProjectionFields, } from '../findings.js';
44
45
  import { DB_MIGRATIONS_DIR, roadmapReadPath, STATE_DB_PATH, STATE_EVENTS_DIR } from '../paths.js';
45
46
  import { isPlanRefTaskId, latestPlanVersion, loadPlan } from '../plan.js';
47
+ import { normalizeProjectName } from '../projectName.js';
46
48
  import { loadRoadmap } from '../roadmap.js';
47
49
  import { assertRuntimeSupported } from '../runtime.js';
48
50
  import { bareTaskId, epicOfTaskId, isQualifiedTaskId, qualifyTaskId, taskIdsMatch, } from '../taskId.js';
49
51
  import { TERMINAL_TASK_STATUSES } from '../taskStatus.js';
50
- import { RESERVED_TASK_ID, taskBranchName } from '../worktree.js';
52
+ import { LEGACY_BRANCH_PREFIX, RESERVED_TASK_ID, taskBranchName } from '../worktree.js';
51
53
  import * as schema from './schema.js';
52
54
  /** Open (creating if needed) the projection DB and ensure its schema is current. */
53
55
  export function openDb(dbPath = STATE_DB_PATH, opts = {}) {
@@ -79,6 +81,7 @@ const ALL_TABLES = [
79
81
  schema.artifacts,
80
82
  schema.milestones,
81
83
  schema.issue_reports,
84
+ schema.operatorFeedback,
82
85
  ];
83
86
  /** Delete every row in every projection table (full "drop" for rebuild()). */
84
87
  function clearAll(handle) {
@@ -105,6 +108,9 @@ function clearSession(db, sessionId) {
105
108
  // those tables whole (D-199, D-200, and the continuation case projectTasks()
106
109
  // describes). Deleting by session_id here would delete a row this session
107
110
  // raised and another session has since approved, closed or worked on.
111
+ // `operator_feedback` is absent for the same reason: a comment recorded here
112
+ // can be resolved from a later, continuing session, so
113
+ // projectOperatorFeedback() owns that table whole too.
108
114
  }
109
115
  /**
110
116
  * Fully replace the milestones table from factory/specs/roadmap.md (not
@@ -154,6 +160,19 @@ function projectMilestones(handle, opts) {
154
160
  }
155
161
  });
156
162
  }
163
+ /** Task 3 (dispatch reason fallback): writers put the reason under other
164
+ * keys than `reason` — the dashboard's "Recent dispatch decisions" showed
165
+ * "no reason given" on almost every row because of it. Same chain as the
166
+ * UI's own fallback (ui/src/lib/timelineDisplay.ts's dispatchReasonText):
167
+ * no shared lib between the two runtimes, so it's kept in step by hand. */
168
+ function dispatchReasonText(p) {
169
+ for (const key of ['reason', 'rationale', 'note', 'why']) {
170
+ const v = p[key];
171
+ if (typeof v === 'string' && v.trim() !== '')
172
+ return v.trim();
173
+ }
174
+ return null;
175
+ }
157
176
  function describeShape(value) {
158
177
  if (value === null)
159
178
  return 'null';
@@ -161,6 +180,26 @@ function describeShape(value) {
161
180
  return 'an array';
162
181
  return typeof value === 'object' ? 'an object' : `a ${typeof value}`;
163
182
  }
183
+ /**
184
+ * A hand-written `task-result-recorded` from before `validateResultArtifactsShape`
185
+ * (events.ts) started refusing non-array artifacts at append time keyed them
186
+ * by name instead of listing them, e.g. `{"claude_half":
187
+ * "scratchpad/r23/claude-half.json"}`. The event log is append-only history,
188
+ * so that line can never be rewritten into the array shape the guard now
189
+ * requires — read it as the legacy shape it plainly is, rather than holding
190
+ * it back forever behind a banner an operator can never clear.
191
+ *
192
+ * Only a non-empty plain object whose every value is a non-empty string
193
+ * counts: an empty object, one bad value, or any other non-array shape goes
194
+ * through the loud skip below (D-141 — a loud undercount beats a crash).
195
+ */
196
+ function readLegacyArtifactsMap(value) {
197
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
198
+ return null;
199
+ const entries = Object.entries(value);
200
+ const allPaths = entries.length > 0 && entries.every(([, v]) => typeof v === 'string' && v.length > 0);
201
+ return allPaths ? entries : null;
202
+ }
164
203
  /** Splits an `error.class` string into its group/class parts; tolerant of malformed values. */
165
204
  function splitErrorClass(value) {
166
205
  const [group, cls] = (value ?? '').split('.');
@@ -218,10 +257,16 @@ function readClaims(raw) {
218
257
  return undefined;
219
258
  return raw.every((c) => typeof c === 'string') ? raw : undefined;
220
259
  }
260
+ // Fills in `branch` for a `task-added` written before taskEvents.ts's
261
+ // `addedPayload` carried that field at all — every such event predates the
262
+ // field, so it was written by a build that only ever knew `smith/`, never
263
+ // `bs/`. Defaulting to `BRANCH_PREFIX` here would assert a branch
264
+ // `worktree create` never cut for that task; `LEGACY_BRANCH_PREFIX` is the
265
+ // one prefix that build could have used.
221
266
  function branchFor(epicId, taskId) {
222
267
  if (!epicId)
223
268
  return null;
224
- return taskBranchName(epicId, taskId);
269
+ return taskBranchName(epicId, taskId, LEGACY_BRANCH_PREFIX);
225
270
  }
226
271
  /**
227
272
  * Every (task id, epic) pair an event asserts: from the id itself when it is
@@ -396,23 +441,55 @@ function waveTaskIds(record) {
396
441
  const taskId = eventTaskId(record);
397
442
  return taskId ? [taskId] : [];
398
443
  }
444
+ /**
445
+ * The tasks one error-logged event names: the envelope/payload `task_id`
446
+ * (D-245) when the producer set one, else `payload.task_ref` split on `,`
447
+ * and each part trimmed. A comma joins several real ids into one string
448
+ * when a producer logged a single check's failure against more than one
449
+ * task at once — events.ts's append-time guard now refuses to write that
450
+ * shape, but a log written before the guard existed still carries it, and
451
+ * foldTasks() replays every log ever written. Splitting here, not only in
452
+ * assertedTaskIds()'s pre-pass, is what keeps the two agreeing on what the
453
+ * event names (see that function's own comment) — a fix that touched one
454
+ * and not the other used to mint a card whose id was the literal
455
+ * "epic-1/task-1,epic-1/task-2" string.
456
+ */
457
+ function errorTaskRefIds(record) {
458
+ const envelopeId = eventTaskId(record);
459
+ if (envelopeId)
460
+ return [envelopeId];
461
+ const ref = record.payload.task_ref;
462
+ if (!ref)
463
+ return [];
464
+ return ref
465
+ .split(',')
466
+ .map((id) => id.trim())
467
+ .filter(Boolean);
468
+ }
469
+ /**
470
+ * `record.project`, normalized through the legacy alias -- the one place an
471
+ * event's raw project value becomes the current one before it reaches a
472
+ * row, so an old event stamped `project: 'black-smith'` never lands in the
473
+ * projection under that name.
474
+ */
475
+ function recordProject(record) {
476
+ return record.project === undefined ? undefined : normalizeProjectName(record.project);
477
+ }
399
478
  /**
400
479
  * The task ids one event asserts something about, spelled the way foldTasks()'s
401
480
  * switch reads them: the envelope or payload `task_id` (D-245) for the
402
481
  * 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.
482
+ * `error-logged`'s own `payload.task_ref` (or its comma-joined list — see
483
+ * errorTaskRefIds()). Kept beside the switch on purpose — the two must agree
484
+ * on what names a task, or the walk touches an id the pre-pass never saw.
406
485
  */
407
486
  function assertedTaskIds(record) {
408
487
  switch (record.event_type) {
409
488
  case 'wave-admitted':
410
489
  case 'wave-merged':
411
490
  return waveTaskIds(record);
412
- case 'error-logged': {
413
- const id = eventTaskId(record) ?? record.payload.task_ref;
414
- return id ? [id] : [];
415
- }
491
+ case 'error-logged':
492
+ return errorTaskRefIds(record);
416
493
  case 'task-added':
417
494
  case 'gate-outcome':
418
495
  case 'task-superseded': {
@@ -423,6 +500,27 @@ function assertedTaskIds(record) {
423
500
  return [];
424
501
  }
425
502
  }
503
+ /**
504
+ * The agent role an event names, read the one place both spellings live.
505
+ * `dispatch_decision` and `judge-reported` carry it as `agent_role`
506
+ * (DispatchPayload; events.ts's PAYLOAD_TAG_MAP); `task-result-recorded`
507
+ * carries the same fact as `agent` (results.ts's `ResultEnvelope.agent` —
508
+ * the role the dispatcher sent the work to). One reader for both spellings,
509
+ * so the auditor-close/reopen checks below never grow a second string
510
+ * compare that could disagree with this one.
511
+ */
512
+ function closingTurnRole(record) {
513
+ const p = record.payload;
514
+ if (record.event_type === 'task-result-recorded')
515
+ return p?.agent ?? null;
516
+ return p?.agent_role ?? null;
517
+ }
518
+ /** Whether an event names an auditor turn — the only role that closes or
519
+ * reopens a task row outside wave-merged/task-superseded/error-logged (an
520
+ * audit axis never joins a wave, so these are the only signals it gets). */
521
+ function isAuditorTurn(record) {
522
+ return closingTurnRole(record) === 'auditor';
523
+ }
426
524
  export function foldTasks(events, opts = {}) {
427
525
  const byId = new Map();
428
526
  // Normalisation happens at this boundary, once, before any row exists —
@@ -461,12 +559,15 @@ export function foldTasks(events, opts = {}) {
461
559
  taskStatus: 'todo',
462
560
  planVersion: null,
463
561
  objective: null,
562
+ title: null,
563
+ summary: null,
464
564
  claims: null,
465
565
  budgetTokens: null,
466
566
  branch: null,
467
567
  createdAt: ts,
468
568
  updatedAt: ts,
469
569
  project: null,
570
+ terminalAt: null,
470
571
  };
471
572
  // Not every id an event carries is a task, and a ref that is not a
472
573
  // task must never surface as a kanban card. Three shapes are refused by
@@ -545,10 +646,12 @@ export function foldTasks(events, opts = {}) {
545
646
  row.taskStatus = p.task_status ?? row.taskStatus;
546
647
  row.planVersion = p.plan_version ?? row.planVersion;
547
648
  row.objective = p.objective ?? row.objective;
649
+ row.title = p.title ?? row.title;
650
+ row.summary = p.summary ?? row.summary;
548
651
  row.claims = readClaims(p.claims) ?? row.claims;
549
652
  row.budgetTokens = p.budget_tokens ?? row.budgetTokens;
550
653
  row.branch = p.branch ?? branchFor(row.epicId, row.taskId);
551
- row.project = record.project ?? row.project;
654
+ row.project = recordProject(record) ?? row.project;
552
655
  break;
553
656
  }
554
657
  case 'wave-admitted': {
@@ -570,9 +673,44 @@ export function foldTasks(events, opts = {}) {
570
673
  if (!eventTask)
571
674
  break;
572
675
  const row = touch(eventTask, record.ts, record.session_id);
573
- if (!TERMINAL_TASK_STATUSES.has(row.taskStatus))
676
+ const terminal = TERMINAL_TASK_STATUSES.has(row.taskStatus);
677
+ if (!terminal) {
574
678
  row.taskStatus = 'in-progress';
575
- row.project = record.project ?? row.project;
679
+ }
680
+ else if (isAuditorTurn(record) && row.taskStatus === 'completed') {
681
+ // An audit axis never joins a wave, so a `completed` row only ever
682
+ // got there via the judge-reported/task-result-recorded close
683
+ // below — a later auditor dispatch for the same id is a re-run of
684
+ // that axis, not a stray dispatch against a shipped plan task
685
+ // (those always move through wave-merged/task-superseded/
686
+ // error-logged instead, never this role). terminalAt resets to
687
+ // null — schema.ts's comment on tasks.terminalAt ("null while the
688
+ // task is still open") describes this row again, exactly. Any
689
+ // other terminal status (escalated, failed, waived, superseded…)
690
+ // is held open for the operator and must not be reopened here.
691
+ row.taskStatus = 'in-progress';
692
+ row.terminalAt = null;
693
+ }
694
+ row.project = recordProject(record) ?? row.project;
695
+ break;
696
+ }
697
+ case 'judge-reported':
698
+ case 'task-result-recorded': {
699
+ if (!eventTask)
700
+ break;
701
+ if (!isAuditorTurn(record))
702
+ break;
703
+ if (record.event_type === 'task-result-recorded') {
704
+ const p = record.payload;
705
+ if (p.run_status !== 'done')
706
+ break;
707
+ }
708
+ const row = touch(eventTask, record.ts, record.session_id);
709
+ // First terminal transition wins — see schema.ts's terminalAt comment.
710
+ if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
711
+ break;
712
+ row.terminalAt = record.ts;
713
+ row.taskStatus = 'completed';
576
714
  break;
577
715
  }
578
716
  case 'gate-outcome': {
@@ -594,38 +732,51 @@ export function foldTasks(events, opts = {}) {
594
732
  }
595
733
  case 'wave-merged': {
596
734
  for (const taskId of waveTaskIds(record)) {
597
- touch(taskId, record.ts, record.session_id).taskStatus = 'completed';
735
+ const row = touch(taskId, record.ts, record.session_id);
736
+ // First terminal transition wins — see schema.ts's terminalAt comment.
737
+ if (row.terminalAt === null)
738
+ row.terminalAt = record.ts;
739
+ row.taskStatus = 'completed';
598
740
  }
599
741
  break;
600
742
  }
601
743
  case 'task-superseded': {
602
744
  if (!eventTask)
603
745
  break;
604
- touch(eventTask, record.ts, record.session_id).taskStatus = 'superseded';
746
+ const row = touch(eventTask, record.ts, record.session_id);
747
+ if (row.terminalAt === null)
748
+ row.terminalAt = record.ts;
749
+ row.taskStatus = 'superseded';
605
750
  break;
606
751
  }
607
752
  case 'error-logged': {
608
753
  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';
754
+ // errorTaskRefIds() is this event's own spelling (D-245's envelope
755
+ // reader, or a comma-joined `task_ref` split into its real ids) —
756
+ // shared with assertedTaskIds()'s pre-pass so the two agree.
757
+ const taskIds = errorTaskRefIds(record);
758
+ for (const taskId of taskIds) {
759
+ const row = touch(taskId, record.ts, record.session_id);
760
+ row.project = recordProject(record) ?? row.project;
761
+ if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
762
+ continue;
763
+ // Severity decides whether the task moves; the error class decides
764
+ // where. taxonomy.yml: S3 is "real but waivable; batched to operator
765
+ // at epic end", S4 is "logged, never asked" — a budget note or a
766
+ // tool hiccup at that level is on the record but the task carries on,
767
+ // and showing it as blocked is what the board did for 43 of the 55
768
+ // errors logged so far. A record with no severity is treated as
769
+ // major: the write path requires the field, so its absence means a
770
+ // log this reader does not own.
771
+ if (NOTE_ONLY_SEVERITIES.has(p.severity ?? ''))
772
+ continue;
773
+ const nextStatus = p.error?.startsWith('coordination.') ? 'escalated' : 'blocked';
774
+ // Only `escalated` is terminal (TERMINAL_TASK_STATUSES); `blocked` is
775
+ // not, so it never sets terminalAt.
776
+ if (nextStatus === 'escalated' && row.terminalAt === null)
777
+ row.terminalAt = record.ts;
778
+ row.taskStatus = nextStatus;
779
+ }
629
780
  break;
630
781
  }
631
782
  default:
@@ -755,7 +906,7 @@ export function foldEpics(events) {
755
906
  blockers: Array.isArray(p.blockers) ? p.blockers : [],
756
907
  closedAt: record.ts,
757
908
  eventId: event_id,
758
- project: record.project ?? null,
909
+ project: recordProject(record) ?? null,
759
910
  });
760
911
  }
761
912
  return [...byId.values()];
@@ -784,7 +935,7 @@ export function foldEpics(events) {
784
935
  * deliberate that an absent project stays absent on the wire -- but it IS a
785
936
  * gap in these tables, because db/queries.ts's projectOf() resolves a NULL
786
937
  * column to the DEFAULT project. Left alone, every gate outcome and every
787
- * failure of a demo-rpg run reads back as black-smith's.
938
+ * failure of a demo-rpg run reads back as blacksmith's.
788
939
  *
789
940
  * A child row's project is its task's, which is exactly the rule the Scope
790
941
  * docblock in db/queries.ts already states (D-170). Derived from the same
@@ -821,7 +972,7 @@ function projectResolver(taskRows) {
821
972
  * rows fold to project NULL, projectResolver() hands the NULL down to every
822
973
  * dispatch and error under them, and db/queries.ts's projectOf() resolves the
823
974
  * lot to the DEFAULT project: the whole demo-rpg epic filed under
824
- * black-smith, its own board empty. That history cannot be repaired from the
975
+ * blacksmith, its own board empty. That history cannot be repaired from the
825
976
  * log, because the log never held the answer. The plan file did, all along.
826
977
  *
827
978
  * Same source and same precedence as the write side -- what the envelope
@@ -848,8 +999,12 @@ function planProjectResolver(specsDir) {
848
999
  let project = null;
849
1000
  try {
850
1001
  const version = latestPlanVersion(epicId, { specsDir });
851
- if (version !== null)
852
- project = loadPlan(epicId, version, { specsDir }).project ?? null;
1002
+ if (version !== null) {
1003
+ // The plan file is immutable and may still literally say the legacy
1004
+ // name; normalize it the same way an event's project value is.
1005
+ const planProject = loadPlan(epicId, version, { specsDir }).project;
1006
+ project = planProject === undefined ? null : normalizeProjectName(planProject);
1007
+ }
853
1008
  }
854
1009
  catch (err) {
855
1010
  console.error(`db/projector.ts planProjectResolver(): plan file for "${epicId}" ` +
@@ -869,11 +1024,50 @@ function planProjectResolver(specsDir) {
869
1024
  * projectTasks() writes the tasks table from another over every session's
870
1025
  * log, and projectFindings() folds the same events again for
871
1026
  * findings.project. Split the backfill across only one of them and a single
872
- * task reads as demo-rpg's on the board and black-smith's on the errors page.
1027
+ * task reads as demo-rpg's on the board and blacksmith's on the errors page.
873
1028
  */
874
1029
  function foldTasksWithPlanProject(events, opts) {
875
1030
  const projectFromPlan = planProjectResolver(opts.specsDir);
876
- return foldTasks([...events], { specsDir: opts.specsDir }).map((t) => t.project === null ? { ...t, project: projectFromPlan(t.epicId) } : t);
1031
+ const folded = foldTasks([...events], { specsDir: opts.specsDir });
1032
+ // The log before the plan file: a task added with no project -- a follow-up
1033
+ // minted inside a wave, whose runner stamps nothing -- belongs where the
1034
+ // rest of its epic's tasks were stamped. The plan file is only read when
1035
+ // the log has nothing to say, and it may not be the project's own (ui serve
1036
+ // without --specs-dir reads the factory's specs).
1037
+ const fromSiblings = unanimousEpicProjects(folded);
1038
+ return folded.map((t) => t.project === null
1039
+ ? {
1040
+ ...t,
1041
+ project: (t.epicId === null ? undefined : fromSiblings.get(t.epicId)) ??
1042
+ projectFromPlan(t.epicId),
1043
+ }
1044
+ : t);
1045
+ }
1046
+ /**
1047
+ * The one project each epic's tasks agree on, keyed by epic id.
1048
+ *
1049
+ * An epic belongs to one project, so a task of it that was never stamped
1050
+ * belongs to the project its siblings were. An epic whose tasks name two
1051
+ * projects breaks that assumption, and the map leaves it out rather than
1052
+ * pick a winner by log order: an unanswered epic stays NULL, which is
1053
+ * visible; a guessed one is filed under the wrong project silently.
1054
+ */
1055
+ export function unanimousEpicProjects(rows) {
1056
+ const seen = new Map();
1057
+ for (const { epicId, project } of rows) {
1058
+ if (!epicId || project === null)
1059
+ continue;
1060
+ const prior = seen.get(epicId);
1061
+ if (prior === undefined)
1062
+ seen.set(epicId, project);
1063
+ else if (prior !== project)
1064
+ seen.set(epicId, null);
1065
+ }
1066
+ const agreed = new Map();
1067
+ for (const [epicId, project] of seen)
1068
+ if (project !== null)
1069
+ agreed.set(epicId, project);
1070
+ return agreed;
877
1071
  }
878
1072
  export function projectSession(handle, sessionId, events, opts = {}) {
879
1073
  const skippedArtifacts = [];
@@ -889,7 +1083,7 @@ export function projectSession(handle, sessionId, events, opts = {}) {
889
1083
  // plan-file backfill lands here so they agree with the global one.
890
1084
  const taskRows = foldTasksWithPlanProject(events, opts);
891
1085
  const projectForTask = projectResolver(taskRows);
892
- const projectForEpic = new Map(taskRows.flatMap((t) => (t.project === null ? [] : [[t.epicId, t.project]])));
1086
+ const projectForEpic = unanimousEpicProjects(taskRows);
893
1087
  /**
894
1088
  * The project a row inherits from whatever its ref names, task or epic.
895
1089
  *
@@ -899,7 +1093,7 @@ export function projectSession(handle, sessionId, events, opts = {}) {
899
1093
  * them (D-250), so the task leg alone can never answer. Resolving through
900
1094
  * it alone left those rows NULL, and queries.ts's projectOf() reads a NULL
901
1095
  * back as the DEFAULT project, not as "unscoped": every one of them was
902
- * filed under black-smith, including the one stop-the-line error in the
1096
+ * filed under blacksmith, including the one stop-the-line error in the
903
1097
  * shipped logs, which demo-rpg had raised (D-252).
904
1098
  *
905
1099
  * epicOfTaskId() answers for a qualified ref; `?? ref` covers the bare
@@ -939,8 +1133,11 @@ export function projectSession(handle, sessionId, events, opts = {}) {
939
1133
  planVersion: record.plan_version,
940
1134
  causalParent: record.causal_parent,
941
1135
  payload: JSON.stringify(record.payload),
942
- project: record.project ?? projectForRef(eventTask),
1136
+ project: recordProject(record) ?? projectForRef(eventTask),
943
1137
  actor: record.actor,
1138
+ // Copied, never derived: absent on a log written outside a CLI
1139
+ // session or before the stamp existed, and NULL here for both.
1140
+ cliSessionId: record.cli_session_id ?? null,
944
1141
  })
945
1142
  .run();
946
1143
  if (record.event_type === 'user_prompt') {
@@ -973,10 +1170,10 @@ export function projectSession(handle, sessionId, events, opts = {}) {
973
1170
  provider: p.provider,
974
1171
  modelTier: p.model_tier,
975
1172
  specRef: p.spec_ref ?? null,
976
- reason: p.reason ?? null,
1173
+ reason: dispatchReasonText(p),
977
1174
  parentPromptId: p.parent_prompt_id ?? null,
978
1175
  causalParent: record.causal_parent,
979
- project: record.project ?? projectForRef(eventTask),
1176
+ project: recordProject(record) ?? projectForRef(eventTask),
980
1177
  })
981
1178
  .run();
982
1179
  continue;
@@ -1016,7 +1213,7 @@ export function projectSession(handle, sessionId, events, opts = {}) {
1016
1213
  errorClass: cls,
1017
1214
  severity: p.severity,
1018
1215
  detail: p.detail ?? null,
1019
- project: record.project ?? projectForRef(p.task_ref ?? eventTask),
1216
+ project: recordProject(record) ?? projectForRef(p.task_ref ?? eventTask),
1020
1217
  })
1021
1218
  .run();
1022
1219
  continue;
@@ -1040,7 +1237,7 @@ export function projectSession(handle, sessionId, events, opts = {}) {
1040
1237
  reason: p.reason ?? null,
1041
1238
  repoSlug: p.repo_slug ?? null,
1042
1239
  source: p.source ?? '',
1043
- project: record.project ?? projectForRef(p.task_ref ?? eventTask),
1240
+ project: recordProject(record) ?? projectForRef(p.task_ref ?? eventTask),
1044
1241
  })
1045
1242
  .run();
1046
1243
  continue;
@@ -1058,6 +1255,28 @@ export function projectSession(handle, sessionId, events, opts = {}) {
1058
1255
  // nothing. Hold back the one list that cannot be read, name it, and
1059
1256
  // keep going (D-141: a loud undercount beats a crash).
1060
1257
  if (p.artifacts !== undefined && !Array.isArray(p.artifacts)) {
1258
+ const legacyMap = readLegacyArtifactsMap(p.artifacts);
1259
+ if (legacyMap) {
1260
+ legacyMap.forEach(([description, artifactPath], index) => {
1261
+ txDb
1262
+ .insert(schema.artifacts)
1263
+ .values({
1264
+ id: `${event_id}#${index}`,
1265
+ sessionId: record.session_id,
1266
+ taskId,
1267
+ eventId: event_id,
1268
+ ts: record.ts,
1269
+ // Never 'screenshot': this is history from before artifacts
1270
+ // carried a type at all. An image path can still reach the
1271
+ // gallery by its extension, which is what it is.
1272
+ type: 'file',
1273
+ path: artifactPath,
1274
+ description,
1275
+ })
1276
+ .run();
1277
+ });
1278
+ continue;
1279
+ }
1061
1280
  skippedArtifacts.push({
1062
1281
  event_id,
1063
1282
  session_id: record.session_id,
@@ -1180,7 +1399,8 @@ export function projectSession(handle, sessionId, events, opts = {}) {
1180
1399
  export function projectTasks(handle, events, opts = {}) {
1181
1400
  handle.db.transaction((txDb) => {
1182
1401
  txDb.delete(schema.tasks).run();
1183
- for (const task of foldTasksWithPlanProject(events, opts)) {
1402
+ const rows = foldTasksWithPlanProject(events, opts);
1403
+ for (const task of rows) {
1184
1404
  txDb
1185
1405
  .insert(schema.tasks)
1186
1406
  .values({
@@ -1192,15 +1412,30 @@ export function projectTasks(handle, events, opts = {}) {
1192
1412
  taskStatus: task.taskStatus,
1193
1413
  planVersion: task.planVersion,
1194
1414
  objective: task.objective,
1415
+ title: task.title,
1416
+ summary: task.summary,
1195
1417
  claims: task.claims ? JSON.stringify(task.claims) : null,
1196
1418
  budgetTokens: task.budgetTokens,
1197
1419
  branch: task.branch,
1198
1420
  createdAt: task.createdAt,
1199
1421
  updatedAt: task.updatedAt,
1200
1422
  project: task.project,
1423
+ terminalAt: task.terminalAt,
1201
1424
  })
1202
1425
  .run();
1203
1426
  }
1427
+ // The epics row is written per session, and a session that closes an
1428
+ // epic it never planned -- a continuation -- folds none of its tasks, so
1429
+ // the row's own fallback (projectForEpic in projectSession) has nothing
1430
+ // to read and stays NULL. Every session's tasks are in hand here: fill
1431
+ // what is still unanswered, and never overwrite a stamped project.
1432
+ for (const [epicId, project] of unanimousEpicProjects(rows)) {
1433
+ txDb
1434
+ .update(schema.epics)
1435
+ .set({ project })
1436
+ .where(and(eq(schema.epics.epicId, epicId), isNull(schema.epics.project)))
1437
+ .run();
1438
+ }
1204
1439
  });
1205
1440
  }
1206
1441
  /**
@@ -1265,6 +1500,41 @@ function projectLessons(handle, events) {
1265
1500
  }
1266
1501
  });
1267
1502
  }
1503
+ /**
1504
+ * Fully replace the operator_feedback table from EVERY session's log at
1505
+ * once, in one causal order (mergeSessionLogs) — the same shape as
1506
+ * projectLessons() above, and for the same reason: a comment recorded in one
1507
+ * session's log is routinely resolved (bounced, or turned into a follow-up
1508
+ * task) from a later, continuing session, so the table's primary key is the
1509
+ * feedback id, not (session, feedback) — one global key, one global fold.
1510
+ * feedback.ts's foldOperatorFeedback() already resolves a `resolved` event
1511
+ * against the `recorded` row it belongs to; this only re-inserts the result.
1512
+ */
1513
+ function projectOperatorFeedback(handle, events) {
1514
+ handle.db.transaction((txDb) => {
1515
+ txDb.delete(schema.operatorFeedback).run();
1516
+ for (const row of foldOperatorFeedback(events)) {
1517
+ txDb
1518
+ .insert(schema.operatorFeedback)
1519
+ .values({
1520
+ id: row.feedbackId,
1521
+ taskId: row.taskId,
1522
+ sessionId: row.sessionId,
1523
+ body: row.body,
1524
+ kind: row.kind,
1525
+ source: row.source,
1526
+ externalId: row.externalId,
1527
+ author: row.author,
1528
+ recordedAt: row.recordedAt,
1529
+ recordedEventId: row.recordedEventId,
1530
+ resolvedAt: row.resolvedAt,
1531
+ resolution: row.resolution,
1532
+ followUpTaskId: row.followUpTaskId,
1533
+ })
1534
+ .run();
1535
+ }
1536
+ });
1537
+ }
1268
1538
  function findingTimestamps(events) {
1269
1539
  const stamps = new Map();
1270
1540
  for (const { event_id, record } of events) {
@@ -1349,7 +1619,7 @@ function projectFindings(handle, events, opts) {
1349
1619
  // against `<epic>/integration`, a pseudo-task that never gets a task row:
1350
1620
  // no task lookup can ever answer for it, its epic always can, and the row
1351
1621
  // already stores that epic. On the shipped logs this is 37 of 56 findings.
1352
- const projectForEpic = new Map(taskRows.flatMap((t) => (t.project === null ? [] : [[t.epicId, t.project]])));
1622
+ const projectForEpic = unanimousEpicProjects(taskRows);
1353
1623
  const projectFromPlan = planProjectResolver(opts.specsDir);
1354
1624
  const rows = [];
1355
1625
  for (const finding of findings) {
@@ -1387,7 +1657,7 @@ function projectFindings(handle, events, opts) {
1387
1657
  taskId: finding.task_id,
1388
1658
  epicId: findingEpicId,
1389
1659
  project: projectForTask(finding.task_id) ??
1390
- projectForEpic.get(findingEpicId) ??
1660
+ (findingEpicId === null ? undefined : projectForEpic.get(findingEpicId)) ??
1391
1661
  projectFromPlan(findingEpicId),
1392
1662
  fingerprint: finding.fingerprint,
1393
1663
  findingCategory: finding.finding_category,
@@ -1472,6 +1742,7 @@ export async function rebuild(dbPath = STATE_DB_PATH, sessions = 'all', opts = {
1472
1742
  projectTasks(handle, merged, opts);
1473
1743
  const skippedFindings = projectFindings(handle, merged, opts);
1474
1744
  projectLessons(handle, merged);
1745
+ projectOperatorFeedback(handle, merged);
1475
1746
  projectMilestones(handle, opts);
1476
1747
  return {
1477
1748
  sessionsProcessed: sessionIds.length,
@@ -1490,10 +1761,11 @@ export async function rebuild(dbPath = STATE_DB_PATH, sessions = 'all', opts = {
1490
1761
  * replaces only its rows, leaving every other session's projection intact.
1491
1762
  * Safe to call repeatedly while a session is still running (tailing).
1492
1763
  *
1493
- * The four tables that are not session-scoped are rewritten whole on every
1764
+ * The five tables that are not session-scoped are rewritten whole on every
1494
1765
  * call: `milestones` from roadmap.md, and — from every session's log, which is
1495
1766
  * why this reads more than the one session it re-folds — `tasks`
1496
- * (projectTasks()), `lessons` (D-199) and `findings` (D-200).
1767
+ * (projectTasks()), `lessons` (D-199), `findings` (D-200) and
1768
+ * `operator_feedback` (projectOperatorFeedback(), same shape).
1497
1769
  */
1498
1770
  export async function apply(dbPath = STATE_DB_PATH, sessionId, opts = {}) {
1499
1771
  const handle = openDb(dbPath, opts);
@@ -1516,6 +1788,7 @@ export async function apply(dbPath = STATE_DB_PATH, sessionId, opts = {}) {
1516
1788
  projectTasks(handle, merged, opts);
1517
1789
  const skippedFindings = projectFindings(handle, merged, opts);
1518
1790
  projectLessons(handle, merged);
1791
+ projectOperatorFeedback(handle, merged);
1519
1792
  projectMilestones(handle, opts);
1520
1793
  return {
1521
1794
  sessionsProcessed: 1,