@juzser/blacksmith 0.4.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 (131) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +5 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +4 -4
  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} +13 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -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} +4 -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 +34 -27
  17. package/.claude/skills/bs/dispatch.md +100 -50
  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 +27 -18
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +181 -49
  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 +109 -53
  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 +48 -14
  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 +125 -17
  35. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  36. package/factory/orchestrator/dist/budgets.js +302 -84
  37. package/factory/orchestrator/dist/claims.js +9 -19
  38. package/factory/orchestrator/dist/cli.js +790 -100
  39. package/factory/orchestrator/dist/cliName.js +27 -0
  40. package/factory/orchestrator/dist/crosscheck.js +136 -8
  41. package/factory/orchestrator/dist/daemon.js +154 -36
  42. package/factory/orchestrator/dist/db/projector.js +278 -29
  43. package/factory/orchestrator/dist/db/queries.js +1831 -94
  44. package/factory/orchestrator/dist/db/schema.js +57 -4
  45. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  46. package/factory/orchestrator/dist/delegation.js +86 -19
  47. package/factory/orchestrator/dist/diffstat.js +15 -8
  48. package/factory/orchestrator/dist/dispatchLint.js +13 -37
  49. package/factory/orchestrator/dist/effort.js +14 -2
  50. package/factory/orchestrator/dist/effortTiers.js +13 -0
  51. package/factory/orchestrator/dist/env.js +26 -0
  52. package/factory/orchestrator/dist/epic.js +323 -38
  53. package/factory/orchestrator/dist/epicBudget.js +93 -0
  54. package/factory/orchestrator/dist/epicWidth.js +22 -5
  55. package/factory/orchestrator/dist/errorIssues.js +10 -4
  56. package/factory/orchestrator/dist/escalation.js +2 -2
  57. package/factory/orchestrator/dist/events.js +105 -3
  58. package/factory/orchestrator/dist/feedback.js +169 -0
  59. package/factory/orchestrator/dist/findingContext.js +26 -0
  60. package/factory/orchestrator/dist/findings.js +83 -12
  61. package/factory/orchestrator/dist/gate.js +120 -10
  62. package/factory/orchestrator/dist/goalCheck.js +2 -2
  63. package/factory/orchestrator/dist/harness.js +25 -17
  64. package/factory/orchestrator/dist/hookDecision.js +92 -3
  65. package/factory/orchestrator/dist/integration.js +9 -7
  66. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  67. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  68. package/factory/orchestrator/dist/judgeStopHook.js +90 -32
  69. package/factory/orchestrator/dist/judges.js +419 -21
  70. package/factory/orchestrator/dist/lessons.js +190 -11
  71. package/factory/orchestrator/dist/mcp.js +6 -6
  72. package/factory/orchestrator/dist/paths.js +10 -7
  73. package/factory/orchestrator/dist/plan.js +269 -33
  74. package/factory/orchestrator/dist/planQuorum.js +5 -2
  75. package/factory/orchestrator/dist/policy.js +1275 -30
  76. package/factory/orchestrator/dist/projectName.js +25 -0
  77. package/factory/orchestrator/dist/promptCapture.js +306 -0
  78. package/factory/orchestrator/dist/promptHook.js +33 -0
  79. package/factory/orchestrator/dist/promptImport.js +281 -0
  80. package/factory/orchestrator/dist/prompts.js +13 -0
  81. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  82. package/factory/orchestrator/dist/providers/types.js +1 -1
  83. package/factory/orchestrator/dist/queue.js +4 -4
  84. package/factory/orchestrator/dist/quorum.js +18 -5
  85. package/factory/orchestrator/dist/results.js +25 -7
  86. package/factory/orchestrator/dist/roadmap.js +10 -5
  87. package/factory/orchestrator/dist/run-cli.js +15 -10
  88. package/factory/orchestrator/dist/runner.js +2 -2
  89. package/factory/orchestrator/dist/schemas.js +56 -8
  90. package/factory/orchestrator/dist/severity.js +8 -4
  91. package/factory/orchestrator/dist/spec.js +177 -13
  92. package/factory/orchestrator/dist/specChange.js +13 -9
  93. package/factory/orchestrator/dist/stack.js +6 -6
  94. package/factory/orchestrator/dist/storeRoot.js +90 -0
  95. package/factory/orchestrator/dist/symbols.js +261 -6
  96. package/factory/orchestrator/dist/taskEvents.js +179 -27
  97. package/factory/orchestrator/dist/taskStatus.js +14 -0
  98. package/factory/orchestrator/dist/testerAudit.js +31 -12
  99. package/factory/orchestrator/dist/testgate.js +16 -13
  100. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  101. package/factory/orchestrator/dist/usage.js +97 -40
  102. package/factory/orchestrator/dist/waivers.js +39 -5
  103. package/factory/orchestrator/dist/waveBudget.js +2 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  105. package/factory/orchestrator/dist/workroot.js +1 -1
  106. package/factory/orchestrator/dist/worktree.js +61 -19
  107. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  108. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  109. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  110. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  111. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  112. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  113. package/factory/policies/budgets.yml +123 -33
  114. package/factory/policies/crosscheck.yml +51 -13
  115. package/factory/policies/delegation.yml +3 -3
  116. package/factory/policies/effort.yml +18 -10
  117. package/factory/policies/guardrails.yml +8 -6
  118. package/factory/policies/harness.yml +5 -5
  119. package/factory/policies/lessons.md +53 -2
  120. package/factory/policies/scheduler.yml +4 -4
  121. package/factory/policies/sensitive-paths.yml +1 -1
  122. package/factory/policies/severity.yml +13 -1
  123. package/factory/policies/stack.yml +6 -6
  124. package/factory/policies/taxonomy.yml +9 -3
  125. package/factory/policies/worktree.yml +15 -9
  126. package/factory/specs/roadmap.md +7 -7
  127. package/factory/specs/schema/event.schema.json +8 -1
  128. package/factory/specs/schema/result.schema.json +21 -8
  129. package/factory/specs/schema/task-spec.schema.json +12 -0
  130. package/package.json +16 -12
  131. 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
@@ -421,6 +466,15 @@ function errorTaskRefIds(record) {
421
466
  .map((id) => id.trim())
422
467
  .filter(Boolean);
423
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
+ }
424
478
  /**
425
479
  * The task ids one event asserts something about, spelled the way foldTasks()'s
426
480
  * switch reads them: the envelope or payload `task_id` (D-245) for the
@@ -446,6 +500,27 @@ function assertedTaskIds(record) {
446
500
  return [];
447
501
  }
448
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
+ }
449
524
  export function foldTasks(events, opts = {}) {
450
525
  const byId = new Map();
451
526
  // Normalisation happens at this boundary, once, before any row exists —
@@ -484,12 +559,15 @@ export function foldTasks(events, opts = {}) {
484
559
  taskStatus: 'todo',
485
560
  planVersion: null,
486
561
  objective: null,
562
+ title: null,
563
+ summary: null,
487
564
  claims: null,
488
565
  budgetTokens: null,
489
566
  branch: null,
490
567
  createdAt: ts,
491
568
  updatedAt: ts,
492
569
  project: null,
570
+ terminalAt: null,
493
571
  };
494
572
  // Not every id an event carries is a task, and a ref that is not a
495
573
  // task must never surface as a kanban card. Three shapes are refused by
@@ -568,10 +646,12 @@ export function foldTasks(events, opts = {}) {
568
646
  row.taskStatus = p.task_status ?? row.taskStatus;
569
647
  row.planVersion = p.plan_version ?? row.planVersion;
570
648
  row.objective = p.objective ?? row.objective;
649
+ row.title = p.title ?? row.title;
650
+ row.summary = p.summary ?? row.summary;
571
651
  row.claims = readClaims(p.claims) ?? row.claims;
572
652
  row.budgetTokens = p.budget_tokens ?? row.budgetTokens;
573
653
  row.branch = p.branch ?? branchFor(row.epicId, row.taskId);
574
- row.project = record.project ?? row.project;
654
+ row.project = recordProject(record) ?? row.project;
575
655
  break;
576
656
  }
577
657
  case 'wave-admitted': {
@@ -593,9 +673,44 @@ export function foldTasks(events, opts = {}) {
593
673
  if (!eventTask)
594
674
  break;
595
675
  const row = touch(eventTask, record.ts, record.session_id);
596
- if (!TERMINAL_TASK_STATUSES.has(row.taskStatus))
676
+ const terminal = TERMINAL_TASK_STATUSES.has(row.taskStatus);
677
+ if (!terminal) {
678
+ row.taskStatus = 'in-progress';
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.
597
691
  row.taskStatus = 'in-progress';
598
- row.project = record.project ?? row.project;
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';
599
714
  break;
600
715
  }
601
716
  case 'gate-outcome': {
@@ -617,14 +732,21 @@ export function foldTasks(events, opts = {}) {
617
732
  }
618
733
  case 'wave-merged': {
619
734
  for (const taskId of waveTaskIds(record)) {
620
- 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';
621
740
  }
622
741
  break;
623
742
  }
624
743
  case 'task-superseded': {
625
744
  if (!eventTask)
626
745
  break;
627
- 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';
628
750
  break;
629
751
  }
630
752
  case 'error-logged': {
@@ -635,7 +757,7 @@ export function foldTasks(events, opts = {}) {
635
757
  const taskIds = errorTaskRefIds(record);
636
758
  for (const taskId of taskIds) {
637
759
  const row = touch(taskId, record.ts, record.session_id);
638
- row.project = record.project ?? row.project;
760
+ row.project = recordProject(record) ?? row.project;
639
761
  if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
640
762
  continue;
641
763
  // Severity decides whether the task moves; the error class decides
@@ -648,7 +770,12 @@ export function foldTasks(events, opts = {}) {
648
770
  // log this reader does not own.
649
771
  if (NOTE_ONLY_SEVERITIES.has(p.severity ?? ''))
650
772
  continue;
651
- row.taskStatus = p.error?.startsWith('coordination.') ? 'escalated' : 'blocked';
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;
652
779
  }
653
780
  break;
654
781
  }
@@ -779,7 +906,7 @@ export function foldEpics(events) {
779
906
  blockers: Array.isArray(p.blockers) ? p.blockers : [],
780
907
  closedAt: record.ts,
781
908
  eventId: event_id,
782
- project: record.project ?? null,
909
+ project: recordProject(record) ?? null,
783
910
  });
784
911
  }
785
912
  return [...byId.values()];
@@ -808,7 +935,7 @@ export function foldEpics(events) {
808
935
  * deliberate that an absent project stays absent on the wire -- but it IS a
809
936
  * gap in these tables, because db/queries.ts's projectOf() resolves a NULL
810
937
  * column to the DEFAULT project. Left alone, every gate outcome and every
811
- * failure of a demo-rpg run reads back as black-smith's.
938
+ * failure of a demo-rpg run reads back as blacksmith's.
812
939
  *
813
940
  * A child row's project is its task's, which is exactly the rule the Scope
814
941
  * docblock in db/queries.ts already states (D-170). Derived from the same
@@ -845,7 +972,7 @@ function projectResolver(taskRows) {
845
972
  * rows fold to project NULL, projectResolver() hands the NULL down to every
846
973
  * dispatch and error under them, and db/queries.ts's projectOf() resolves the
847
974
  * lot to the DEFAULT project: the whole demo-rpg epic filed under
848
- * 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
849
976
  * log, because the log never held the answer. The plan file did, all along.
850
977
  *
851
978
  * Same source and same precedence as the write side -- what the envelope
@@ -872,8 +999,12 @@ function planProjectResolver(specsDir) {
872
999
  let project = null;
873
1000
  try {
874
1001
  const version = latestPlanVersion(epicId, { specsDir });
875
- if (version !== null)
876
- 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
+ }
877
1008
  }
878
1009
  catch (err) {
879
1010
  console.error(`db/projector.ts planProjectResolver(): plan file for "${epicId}" ` +
@@ -893,11 +1024,50 @@ function planProjectResolver(specsDir) {
893
1024
  * projectTasks() writes the tasks table from another over every session's
894
1025
  * log, and projectFindings() folds the same events again for
895
1026
  * findings.project. Split the backfill across only one of them and a single
896
- * 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.
897
1028
  */
898
1029
  function foldTasksWithPlanProject(events, opts) {
899
1030
  const projectFromPlan = planProjectResolver(opts.specsDir);
900
- 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;
901
1071
  }
902
1072
  export function projectSession(handle, sessionId, events, opts = {}) {
903
1073
  const skippedArtifacts = [];
@@ -913,7 +1083,7 @@ export function projectSession(handle, sessionId, events, opts = {}) {
913
1083
  // plan-file backfill lands here so they agree with the global one.
914
1084
  const taskRows = foldTasksWithPlanProject(events, opts);
915
1085
  const projectForTask = projectResolver(taskRows);
916
- const projectForEpic = new Map(taskRows.flatMap((t) => (t.project === null ? [] : [[t.epicId, t.project]])));
1086
+ const projectForEpic = unanimousEpicProjects(taskRows);
917
1087
  /**
918
1088
  * The project a row inherits from whatever its ref names, task or epic.
919
1089
  *
@@ -923,7 +1093,7 @@ export function projectSession(handle, sessionId, events, opts = {}) {
923
1093
  * them (D-250), so the task leg alone can never answer. Resolving through
924
1094
  * it alone left those rows NULL, and queries.ts's projectOf() reads a NULL
925
1095
  * back as the DEFAULT project, not as "unscoped": every one of them was
926
- * 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
927
1097
  * shipped logs, which demo-rpg had raised (D-252).
928
1098
  *
929
1099
  * epicOfTaskId() answers for a qualified ref; `?? ref` covers the bare
@@ -963,8 +1133,11 @@ export function projectSession(handle, sessionId, events, opts = {}) {
963
1133
  planVersion: record.plan_version,
964
1134
  causalParent: record.causal_parent,
965
1135
  payload: JSON.stringify(record.payload),
966
- project: record.project ?? projectForRef(eventTask),
1136
+ project: recordProject(record) ?? projectForRef(eventTask),
967
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,
968
1141
  })
969
1142
  .run();
970
1143
  if (record.event_type === 'user_prompt') {
@@ -997,10 +1170,10 @@ export function projectSession(handle, sessionId, events, opts = {}) {
997
1170
  provider: p.provider,
998
1171
  modelTier: p.model_tier,
999
1172
  specRef: p.spec_ref ?? null,
1000
- reason: p.reason ?? null,
1173
+ reason: dispatchReasonText(p),
1001
1174
  parentPromptId: p.parent_prompt_id ?? null,
1002
1175
  causalParent: record.causal_parent,
1003
- project: record.project ?? projectForRef(eventTask),
1176
+ project: recordProject(record) ?? projectForRef(eventTask),
1004
1177
  })
1005
1178
  .run();
1006
1179
  continue;
@@ -1040,7 +1213,7 @@ export function projectSession(handle, sessionId, events, opts = {}) {
1040
1213
  errorClass: cls,
1041
1214
  severity: p.severity,
1042
1215
  detail: p.detail ?? null,
1043
- project: record.project ?? projectForRef(p.task_ref ?? eventTask),
1216
+ project: recordProject(record) ?? projectForRef(p.task_ref ?? eventTask),
1044
1217
  })
1045
1218
  .run();
1046
1219
  continue;
@@ -1064,7 +1237,7 @@ export function projectSession(handle, sessionId, events, opts = {}) {
1064
1237
  reason: p.reason ?? null,
1065
1238
  repoSlug: p.repo_slug ?? null,
1066
1239
  source: p.source ?? '',
1067
- project: record.project ?? projectForRef(p.task_ref ?? eventTask),
1240
+ project: recordProject(record) ?? projectForRef(p.task_ref ?? eventTask),
1068
1241
  })
1069
1242
  .run();
1070
1243
  continue;
@@ -1082,6 +1255,28 @@ export function projectSession(handle, sessionId, events, opts = {}) {
1082
1255
  // nothing. Hold back the one list that cannot be read, name it, and
1083
1256
  // keep going (D-141: a loud undercount beats a crash).
1084
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
+ }
1085
1280
  skippedArtifacts.push({
1086
1281
  event_id,
1087
1282
  session_id: record.session_id,
@@ -1204,7 +1399,8 @@ export function projectSession(handle, sessionId, events, opts = {}) {
1204
1399
  export function projectTasks(handle, events, opts = {}) {
1205
1400
  handle.db.transaction((txDb) => {
1206
1401
  txDb.delete(schema.tasks).run();
1207
- for (const task of foldTasksWithPlanProject(events, opts)) {
1402
+ const rows = foldTasksWithPlanProject(events, opts);
1403
+ for (const task of rows) {
1208
1404
  txDb
1209
1405
  .insert(schema.tasks)
1210
1406
  .values({
@@ -1216,15 +1412,30 @@ export function projectTasks(handle, events, opts = {}) {
1216
1412
  taskStatus: task.taskStatus,
1217
1413
  planVersion: task.planVersion,
1218
1414
  objective: task.objective,
1415
+ title: task.title,
1416
+ summary: task.summary,
1219
1417
  claims: task.claims ? JSON.stringify(task.claims) : null,
1220
1418
  budgetTokens: task.budgetTokens,
1221
1419
  branch: task.branch,
1222
1420
  createdAt: task.createdAt,
1223
1421
  updatedAt: task.updatedAt,
1224
1422
  project: task.project,
1423
+ terminalAt: task.terminalAt,
1225
1424
  })
1226
1425
  .run();
1227
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
+ }
1228
1439
  });
1229
1440
  }
1230
1441
  /**
@@ -1289,6 +1500,41 @@ function projectLessons(handle, events) {
1289
1500
  }
1290
1501
  });
1291
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
+ }
1292
1538
  function findingTimestamps(events) {
1293
1539
  const stamps = new Map();
1294
1540
  for (const { event_id, record } of events) {
@@ -1373,7 +1619,7 @@ function projectFindings(handle, events, opts) {
1373
1619
  // against `<epic>/integration`, a pseudo-task that never gets a task row:
1374
1620
  // no task lookup can ever answer for it, its epic always can, and the row
1375
1621
  // already stores that epic. On the shipped logs this is 37 of 56 findings.
1376
- const projectForEpic = new Map(taskRows.flatMap((t) => (t.project === null ? [] : [[t.epicId, t.project]])));
1622
+ const projectForEpic = unanimousEpicProjects(taskRows);
1377
1623
  const projectFromPlan = planProjectResolver(opts.specsDir);
1378
1624
  const rows = [];
1379
1625
  for (const finding of findings) {
@@ -1411,7 +1657,7 @@ function projectFindings(handle, events, opts) {
1411
1657
  taskId: finding.task_id,
1412
1658
  epicId: findingEpicId,
1413
1659
  project: projectForTask(finding.task_id) ??
1414
- projectForEpic.get(findingEpicId) ??
1660
+ (findingEpicId === null ? undefined : projectForEpic.get(findingEpicId)) ??
1415
1661
  projectFromPlan(findingEpicId),
1416
1662
  fingerprint: finding.fingerprint,
1417
1663
  findingCategory: finding.finding_category,
@@ -1496,6 +1742,7 @@ export async function rebuild(dbPath = STATE_DB_PATH, sessions = 'all', opts = {
1496
1742
  projectTasks(handle, merged, opts);
1497
1743
  const skippedFindings = projectFindings(handle, merged, opts);
1498
1744
  projectLessons(handle, merged);
1745
+ projectOperatorFeedback(handle, merged);
1499
1746
  projectMilestones(handle, opts);
1500
1747
  return {
1501
1748
  sessionsProcessed: sessionIds.length,
@@ -1514,10 +1761,11 @@ export async function rebuild(dbPath = STATE_DB_PATH, sessions = 'all', opts = {
1514
1761
  * replaces only its rows, leaving every other session's projection intact.
1515
1762
  * Safe to call repeatedly while a session is still running (tailing).
1516
1763
  *
1517
- * 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
1518
1765
  * call: `milestones` from roadmap.md, and — from every session's log, which is
1519
1766
  * why this reads more than the one session it re-folds — `tasks`
1520
- * (projectTasks()), `lessons` (D-199) and `findings` (D-200).
1767
+ * (projectTasks()), `lessons` (D-199), `findings` (D-200) and
1768
+ * `operator_feedback` (projectOperatorFeedback(), same shape).
1521
1769
  */
1522
1770
  export async function apply(dbPath = STATE_DB_PATH, sessionId, opts = {}) {
1523
1771
  const handle = openDb(dbPath, opts);
@@ -1540,6 +1788,7 @@ export async function apply(dbPath = STATE_DB_PATH, sessionId, opts = {}) {
1540
1788
  projectTasks(handle, merged, opts);
1541
1789
  const skippedFindings = projectFindings(handle, merged, opts);
1542
1790
  projectLessons(handle, merged);
1791
+ projectOperatorFeedback(handle, merged);
1543
1792
  projectMilestones(handle, opts);
1544
1793
  return {
1545
1794
  sessionsProcessed: 1,