@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
@@ -4,21 +4,27 @@
4
4
  // omitted, a query spans every projected session (a single Blacksmith
5
5
  // instance is one continuously-running factory, so "no session filter"
6
6
  // is the normal case; a session filter is for debugging one run).
7
- import { and, eq, gte, inArray, lte } from 'drizzle-orm';
7
+ import { and, eq, gte, inArray, isNotNull, lte, max, sql } from 'drizzle-orm';
8
8
  import { isOperatorActor } from '../actors.js';
9
- import { foldAgents, liveAgents as foldLiveAgents, isWorkingAt, REGISTRY_EVENT_TYPES, } from '../agents-registry.js';
9
+ import { DISPATCH_EVENT_TYPE, ERROR_EVENT_TYPE, foldAgents, liveAgents as foldLiveAgents, isWorkingAt, JUDGE_REPORT_EVENT_TYPE, JUDGE_VERDICT_EVENT_TYPE, REGISTRY_EVENT_TYPES, TASK_RESULT_EVENT_TYPE, } from '../agents-registry.js';
10
+ import { isAuditAxisRowId } from '../audit.js';
11
+ import { readTokenUsage } from '../budgetAlarm.js';
12
+ import { SmithError } from '../errors.js';
10
13
  import { compareLogOrder, isLaterEvent, parseEventId, ROOT_EVENT_TYPE } from '../events.js';
11
14
  import { OPEN_FINDING_STATUSES, WAIVABLE_STATUSES } from '../findings.js';
12
15
  import { waveLayers } from '../graph.js';
16
+ import { JUDGE_ROLES } from '../judgeRoles.js';
17
+ import { FACTORY_PROJECT_NAME, normalizeProjectName } from '../projectName.js';
13
18
  import { judgeFailureKind } from '../providers/types.js';
14
19
  import { severityRank } from '../severity.js';
15
- import { epicOfTaskId, taskIdsMatch } from '../taskId.js';
16
- import { TERMINAL_OK_TASK_STATUSES, TERMINAL_TASK_STATUSES } from '../taskStatus.js';
20
+ import { bareTaskId, epicOfTaskId, taskIdsMatch } from '../taskId.js';
21
+ import { CLOSED_TO_FURTHER_WORK, TERMINAL_OK_TASK_STATUSES, TERMINAL_TASK_STATUSES, } from '../taskStatus.js';
17
22
  import { loadTaxonomy } from '../taxonomy.js';
18
23
  import { WAIVABLE_SEVERITIES } from '../waivers.js';
19
- import { agents, artifacts, dispatches, edges, epics, errors, eventsRaw, findings, lessons, milestones, prompts, sessions, tasks, waivers, } from './schema.js';
24
+ import { unanimousEpicProjects } from './projector.js';
25
+ import { agents, artifacts, dispatches, edges, epics, errors, eventsRaw, findings, lessons, milestones, operatorFeedback, prompts, sessions, tasks, waivers, } from './schema.js';
20
26
  /** Phase 6b default: every event/task/etc. logged before the project dimension existed. */
21
- export const DEFAULT_PROJECT = 'black-smith';
27
+ export const DEFAULT_PROJECT = FACTORY_PROJECT_NAME;
22
28
  /**
23
29
  * `null` (never-tagged, pre-Phase-6b) reads as DEFAULT_PROJECT, same as the
24
30
  * schema.ts columns' doc comment promises. Project scoping is applied
@@ -31,7 +37,7 @@ export const DEFAULT_PROJECT = 'black-smith';
31
37
  * array — one filter step ahead of the aggregation, not a SQL rewrite).
32
38
  */
33
39
  function projectOf(value) {
34
- return value ?? DEFAULT_PROJECT;
40
+ return normalizeProjectName(value ?? DEFAULT_PROJECT);
35
41
  }
36
42
  /**
37
43
  * The session narrowing for one `session_id` column: `IN (lineage)` when the
@@ -205,11 +211,19 @@ function projectedContinuations(db, sessionId) {
205
211
  }
206
212
  return out;
207
213
  }
214
+ /**
215
+ * `scope.project`, normalized through `normalizeProjectName` -- the one place
216
+ * a caller's REQUESTED project value (an old bookmark, a CLI `--project
217
+ * black-smith`) is read, so every comparison against it sees today's
218
+ * spelling without every call site having to remember to normalize first.
219
+ */
220
+ function scopeProject(scope) {
221
+ return scope.project === undefined ? undefined : normalizeProjectName(scope.project);
222
+ }
208
223
  /** Filters an already-fetched row array to `scope.project`; a no-op in global mode (project omitted). */
209
224
  function filterByProject(rows, scope) {
210
- return scope.project !== undefined
211
- ? rows.filter((r) => projectOf(r.project) === scope.project)
212
- : rows;
225
+ const project = scopeProject(scope);
226
+ return project !== undefined ? rows.filter((r) => projectOf(r.project) === project) : rows;
213
227
  }
214
228
  /** Rows in the order the log wrote them, oldest first. Never mutates the input. */
215
229
  function inLogOrder(rows) {
@@ -328,14 +342,31 @@ function epicTokenMaps(db, scope, taskRows) {
328
342
  }
329
343
  const epicOf = epicResolver(epicByTask);
330
344
  const spentByEpic = new Map();
345
+ const unmeasuredByEpic = new Map();
331
346
  for (const row of taskResultRows(db, scope)) {
332
347
  const epicId = epicOf(resultTaskId(row));
333
348
  if (!epicId)
334
349
  continue;
335
- const total = row.payload.token_usage?.total_tokens ?? 0;
336
- spentByEpic.set(epicId, (spentByEpic.get(epicId) ?? 0) + total);
350
+ const usage = readTokenUsage(row.payload.token_usage);
351
+ if (usage.measured) {
352
+ spentByEpic.set(epicId, (spentByEpic.get(epicId) ?? 0) + usage.total);
353
+ }
354
+ else {
355
+ spentByEpic.set(epicId, spentByEpic.get(epicId) ?? 0);
356
+ unmeasuredByEpic.set(epicId, (unmeasuredByEpic.get(epicId) ?? 0) + 1);
357
+ }
337
358
  }
338
- return { budgetByEpic, spentByEpic };
359
+ return { budgetByEpic, spentByEpic, unmeasuredByEpic };
360
+ }
361
+ function epicTokenSpends(maps) {
362
+ const { budgetByEpic, spentByEpic, unmeasuredByEpic } = maps;
363
+ const epicIds = new Set([...budgetByEpic.keys(), ...spentByEpic.keys()]);
364
+ return [...epicIds].sort().map((epicId) => ({
365
+ epicId,
366
+ tokensSpent: spentByEpic.get(epicId) ?? 0,
367
+ tokensBudget: budgetByEpic.get(epicId) ?? null,
368
+ unmeasured: unmeasuredByEpic.get(epicId) ?? 0,
369
+ }));
339
370
  }
340
371
  /**
341
372
  * NEXT hides the statuses the plan has nothing left to ask of — every
@@ -404,6 +435,76 @@ function milestoneTaskRefs(milestoneTasks, allTaskRows, edgeRows) {
404
435
  }));
405
436
  return { recentDone, nextUp };
406
437
  }
438
+ /**
439
+ * DS4 S5b fix round 1 — folds one `task_status` into the same grouping
440
+ * `ui/src/lib/kanban.ts`'s `COLUMN_FOR_STATUS` already uses for the Kanban
441
+ * board's columns, condensed from its 5 (+2 hidden) columns to the Roadmap's
442
+ * 5:
443
+ * - Kanban's "Todo" column (todo, ready) -> 'todo'.
444
+ * - Kanban's "Reviewing" column (reviewing, merging) -> 'review'.
445
+ * - Kanban's "Completed" column (completed, waived) -> 'done': exactly
446
+ * `TERMINAL_OK_TASK_STATUSES`, the same set `tasksCompleted` on the same
447
+ * payload already uses, so the two numbers never disagree (fix round 1,
448
+ * finding 1).
449
+ * - `superseded` -> its own bucket, not `done`: a replanned-away task ended
450
+ * the diff, not well, and folding it into `done` read an epic that was
451
+ * replanned away as fully shipped. Kept out of the switch's `default` on
452
+ * purpose, same reason: nobody confuses it with the fail-open case below.
453
+ * - Everything else (Kanban's "In progress" and "Blocked" columns, plus
454
+ * `failed`) -> 'inProgress': still open work, whether or not somebody is
455
+ * actively on it right now. `failed` is terminal to the projector but a
456
+ * person is still holding it (taskStatus.ts's HELD_OPEN_BY_AN_OPERATOR),
457
+ * same as `escalated`/`blocked` already here. A status this switch
458
+ * doesn't yet know about (taxonomy.yml adding a 13th tomorrow) falls here
459
+ * too — open rather than silently counted as done.
460
+ * Exhaustive over this fold (every input lands in exactly one bucket), so the
461
+ * five counts a caller builds from it always sum to the row count they were
462
+ * built from.
463
+ */
464
+ export function statusBucketForTaskStatus(taskStatus) {
465
+ if (TERMINAL_OK_TASK_STATUSES.has(taskStatus))
466
+ return 'done';
467
+ switch (taskStatus) {
468
+ case 'todo':
469
+ case 'ready':
470
+ return 'todo';
471
+ case 'reviewing':
472
+ case 'merging':
473
+ return 'review';
474
+ case 'superseded':
475
+ return 'superseded';
476
+ default:
477
+ return 'inProgress';
478
+ }
479
+ }
480
+ function countStatuses(rows) {
481
+ const counts = { done: 0, review: 0, inProgress: 0, todo: 0, superseded: 0 };
482
+ for (const row of rows)
483
+ counts[statusBucketForTaskStatus(row.taskStatus)] += 1;
484
+ return counts;
485
+ }
486
+ /**
487
+ * DS4 S5b fix round 1 — EpicStatus from a StatusCounts + its total, over the
488
+ * epic's LIVE tasks (`tasksTotal` minus `superseded`): a replanned-away task
489
+ * is not part of what's left to ship, so it neither counts toward `done` nor
490
+ * holds the epic open. Every live task done is `done`; any task in the
491
+ * review or inProgress buckets means at least `in_progress`, rising to
492
+ * `review` only when every non-done live task is itself in the review bucket
493
+ * (no inProgress, no todo left over); some tasks done with the rest not
494
+ * all done is `in_progress` even between waves; otherwise, with nothing
495
+ * done and nothing open yet, `todo` — also the answer for a zero-task epic
496
+ * and for an epic whose tasks are ALL superseded (`live === 0`, so
497
+ * `done === live` is never true).
498
+ */
499
+ function epicStatusFromCounts(counts, tasksTotal) {
500
+ const live = tasksTotal - counts.superseded;
501
+ if (live > 0 && counts.done === live)
502
+ return 'done';
503
+ if (counts.review > 0 || counts.inProgress > 0) {
504
+ return counts.inProgress === 0 && counts.todo === 0 ? 'review' : 'in_progress';
505
+ }
506
+ return counts.done > 0 ? 'in_progress' : 'todo';
507
+ }
407
508
  /**
408
509
  * Join factory/specs/roadmap.md's milestones (db/schema.ts's `milestones`
409
510
  * table) with each milestone's mapped epics' task/token stats. A milestone
@@ -414,19 +515,27 @@ function milestoneTaskRefs(milestoneTasks, allTaskRows, edgeRows) {
414
515
  */
415
516
  function milestoneProgressRows(db, scope = {}, opts = {}) {
416
517
  const allMilestoneRows = db.select().from(milestones).orderBy(milestones.sequence).all();
417
- const milestoneRows = scope.project !== undefined
418
- ? allMilestoneRows.filter((m) => m.project === scope.project)
518
+ const project = scopeProject(scope);
519
+ const milestoneRows = project !== undefined
520
+ ? allMilestoneRows.filter((m) => m.project === project)
419
521
  : allMilestoneRows;
420
522
  if (milestoneRows.length === 0)
421
523
  return [];
422
524
  const taskRows = allTasksForScope(db, scope);
423
- const { budgetByEpic, spentByEpic } = epicTokenMaps(db, scope, taskRows);
525
+ const { budgetByEpic, spentByEpic, unmeasuredByEpic } = epicTokenMaps(db, scope, taskRows);
424
526
  const edgeSessionCond = scopedToSessions(edges.sessionId, scope);
425
527
  const edgeRows = opts.includeTaskRefs
426
528
  ? edgeSessionCond
427
529
  ? db.select().from(edges).where(edgeSessionCond).all()
428
530
  : db.select().from(edges).all()
429
531
  : [];
532
+ const dispatchStartByTask = earliestDispatchByTask(db, scope);
533
+ // DS4 S5b — both prefetched once per call: prUrlByEpic is one scan of
534
+ // eventsRaw (same read kanban() shares via prUrlsByEpic()); epicPromptMemo
535
+ // bounds epicSourcePrompt()'s lineage walk to once per distinct epic id,
536
+ // not once per task, mirroring dispatchStartByTask's per-call prefetch above.
537
+ const prUrlByEpic = prUrlsByEpic(db);
538
+ const epicPromptMemo = new Map();
430
539
  return milestoneRows.map((m) => {
431
540
  const epicIds = JSON.parse(m.epicIds);
432
541
  const epicSet = new Set(epicIds);
@@ -435,8 +544,10 @@ function milestoneProgressRows(db, scope = {}, opts = {}) {
435
544
  let tokensSpent = 0;
436
545
  let tokensBudget = 0;
437
546
  let hasBudget = false;
547
+ let unmeasured = 0;
438
548
  for (const epicId of epicIds) {
439
549
  tokensSpent += spentByEpic.get(epicId) ?? 0;
550
+ unmeasured += unmeasuredByEpic.get(epicId) ?? 0;
440
551
  const budget = budgetByEpic.get(epicId);
441
552
  if (budget !== undefined) {
442
553
  tokensBudget += budget;
@@ -446,6 +557,28 @@ function milestoneProgressRows(db, scope = {}, opts = {}) {
446
557
  const refs = opts.includeTaskRefs
447
558
  ? milestoneTaskRefs(milestoneTasks, taskRows, edgeRows)
448
559
  : undefined;
560
+ const { startedAt, finishedAt } = taskDateRange(milestoneTasks, dispatchStartByTask);
561
+ const epicDates = epicIds.map((epicId) => {
562
+ const epicTasks = milestoneTasks.filter((t) => t.epicId === epicId);
563
+ const statusCounts = countStatuses(epicTasks);
564
+ let sourcePrompt = null;
565
+ if (epicPromptMemo.has(epicId)) {
566
+ sourcePrompt = epicPromptMemo.get(epicId) ?? null;
567
+ }
568
+ else if (epicTasks[0] !== undefined) {
569
+ sourcePrompt = epicSourcePrompt(db, epicTasks[0].sessionId);
570
+ epicPromptMemo.set(epicId, sourcePrompt);
571
+ }
572
+ return {
573
+ epicId,
574
+ ...taskDateRange(epicTasks, dispatchStartByTask),
575
+ statusCounts,
576
+ status: epicStatusFromCounts(statusCounts, epicTasks.length),
577
+ project: m.project,
578
+ prUrl: prUrlByEpic.get(epicId) ?? null,
579
+ sourcePrompt,
580
+ };
581
+ });
449
582
  return {
450
583
  milestoneId: m.milestoneId,
451
584
  name: m.name,
@@ -455,15 +588,73 @@ function milestoneProgressRows(db, scope = {}, opts = {}) {
455
588
  epicIds,
456
589
  tasksTotal: milestoneTasks.length,
457
590
  tasksCompleted,
591
+ statusCounts: countStatuses(milestoneTasks),
458
592
  tokensSpent,
459
593
  tokensBudget: hasBudget ? tokensBudget : null,
594
+ unmeasured,
460
595
  project: m.project,
461
596
  kind: m.kind,
462
597
  errorIssuesEnabled: m.errorIssues,
598
+ startedAt,
599
+ finishedAt,
600
+ epics: epicDates,
463
601
  ...(refs ? { recentDone: refs.recentDone, nextUp: refs.nextUp } : {}),
464
602
  };
465
603
  });
466
604
  }
605
+ /**
606
+ * Every task's earliest `dispatch_decision` timestamp, across the whole
607
+ * scope, in one query — not per milestone or per epic, so a roadmap with N
608
+ * milestones costs one scan of `dispatches` rather than N. A task never
609
+ * dispatched has no entry. Shared by taskDateRange()'s milestone-level and
610
+ * per-epic rollups.
611
+ */
612
+ function earliestDispatchByTask(db, scope) {
613
+ const sessionCond = scopedToSessions(dispatches.sessionId, scope);
614
+ const rows = sessionCond
615
+ ? db
616
+ .select({ taskId: dispatches.taskId, ts: dispatches.ts })
617
+ .from(dispatches)
618
+ .where(sessionCond)
619
+ .all()
620
+ : db.select({ taskId: dispatches.taskId, ts: dispatches.ts }).from(dispatches).all();
621
+ const earliest = new Map();
622
+ for (const row of rows) {
623
+ if (!row.taskId)
624
+ continue;
625
+ const current = earliest.get(row.taskId);
626
+ if (current === undefined || row.ts < current)
627
+ earliest.set(row.taskId, row.ts);
628
+ }
629
+ return earliest;
630
+ }
631
+ /**
632
+ * `startedAt` is the earliest dispatch among a set of task rows (via
633
+ * `dispatchStartByTask`, see earliestDispatchByTask()), or `null` if none of
634
+ * them has one. `finishedAt` is the latest `terminalAt`, but only once every
635
+ * row is terminal — see MilestoneProgress.startedAt for why these are the
636
+ * right sources. Shared by the milestone-level and per-epic rollups in
637
+ * milestoneProgressRows() so the two never disagree about how a date is
638
+ * derived.
639
+ */
640
+ function taskDateRange(rows, dispatchStartByTask) {
641
+ let startedAt = null;
642
+ let finishedAt = null;
643
+ let allTerminal = true;
644
+ for (const row of rows) {
645
+ const dispatchedAt = dispatchStartByTask.get(row.taskId);
646
+ if (dispatchedAt !== undefined && (startedAt === null || dispatchedAt < startedAt)) {
647
+ startedAt = dispatchedAt;
648
+ }
649
+ if (!TERMINAL_TASK_STATUSES.has(row.taskStatus)) {
650
+ allTerminal = false;
651
+ }
652
+ else if (row.terminalAt !== null && (finishedAt === null || row.terminalAt > finishedAt)) {
653
+ finishedAt = row.terminalAt;
654
+ }
655
+ }
656
+ return { startedAt, finishedAt: allTerminal ? finishedAt : null };
657
+ }
467
658
  /** Roadmap page (§5.4): every milestone with its progress + mini-timeline, in roadmap.md order. */
468
659
  export function roadmapPage(db, scope = {}) {
469
660
  return milestoneProgressRows(db, scope, { includeTaskRefs: true });
@@ -505,6 +696,118 @@ function allAgentsForScope(db, scope) {
505
696
  return a.taskId !== null && scopedTasks.some((t) => taskIdsMatch(a.taskId, t.taskId));
506
697
  });
507
698
  }
699
+ /**
700
+ * The projects each session worked on, keyed by session id.
701
+ *
702
+ * Two sources, unioned. The tasks a session owns (tasks.session_id: the
703
+ * first session to touch a task) keep the rule they always had, default
704
+ * project included. But a wave session is a continuation that runs the tasks
705
+ * its epic session added, so it owns almost none: owning alone left most
706
+ * sessions with no project at all. So every task id its events carry and
707
+ * every task and epic its agents name count too, resolved against the tasks
708
+ * table — taskIdsMatch, because the log spells one task both bare and
709
+ * epic-qualified; an epic, or a ref naming an epic (`<epic>/integration`),
710
+ * through the project its tasks agree on.
711
+ *
712
+ * Only the session's own rows: a continuation can start from another
713
+ * project's epic, so the lineage is never unioned in. A ref nothing answers
714
+ * for, or a bare id two projects share, adds nothing — no guess, and no
715
+ * fallback to the default project. Three queries whatever the session count.
716
+ */
717
+ function sessionProjects(db, scope) {
718
+ const bySession = new Map();
719
+ const add = (sessionId, project) => {
720
+ const set = bySession.get(sessionId) ?? new Set();
721
+ set.add(project);
722
+ bySession.set(sessionId, set);
723
+ };
724
+ for (const t of allTasksForScope(db, scope))
725
+ add(t.sessionId, projectOf(t.project));
726
+ // Every task, not the scoped ones: a bare id is only unambiguous if no
727
+ // other project's task shares it, and only the whole table can say so.
728
+ const allTasks = db
729
+ .select({ taskId: tasks.taskId, epicId: tasks.epicId, project: tasks.project })
730
+ .from(tasks)
731
+ .all()
732
+ .map((t) => ({ ...t, project: t.project === null ? null : normalizeProjectName(t.project) }));
733
+ const byId = new Map(allTasks.map((t) => [t.taskId, t]));
734
+ const byBare = new Map();
735
+ for (const t of allTasks) {
736
+ const bare = bareTaskId(t.taskId);
737
+ byBare.set(bare, [...(byBare.get(bare) ?? []), t]);
738
+ }
739
+ const epicProject = unanimousEpicProjects(allTasks);
740
+ const refProject = (ref) => {
741
+ const exact = byId.get(ref)?.project;
742
+ if (exact)
743
+ return exact;
744
+ const refEpic = epicOfTaskId(ref);
745
+ const matched = new Set();
746
+ for (const t of byBare.get(bareTaskId(ref)) ?? []) {
747
+ // A qualified ref names its epic: another epic's bare row is not it.
748
+ if (refEpic !== null && t.epicId !== null && t.epicId !== refEpic)
749
+ continue;
750
+ if (t.project !== null && taskIdsMatch(ref, t.taskId))
751
+ matched.add(t.project);
752
+ }
753
+ if (matched.size > 1)
754
+ return null;
755
+ return [...matched][0] ?? epicProject.get(epicOfTaskId(ref) ?? ref) ?? null;
756
+ };
757
+ const wanted = scopeProject(scope);
758
+ const addRef = (sessionId, project) => {
759
+ if (project && (wanted === undefined || project === wanted))
760
+ add(sessionId, project);
761
+ };
762
+ const eventRefs = db
763
+ .selectDistinct({ sessionId: eventsRaw.sessionId, taskId: eventsRaw.taskId })
764
+ .from(eventsRaw)
765
+ .where(and(isNotNull(eventsRaw.taskId), scopedToSessions(eventsRaw.sessionId, scope)))
766
+ .all();
767
+ for (const e of eventRefs)
768
+ addRef(e.sessionId, refProject(e.taskId));
769
+ const agentRefs = db
770
+ .selectDistinct({ sessionId: agents.sessionId, taskId: agents.taskId, epicId: agents.epicId })
771
+ .from(agents)
772
+ .where(scopedToSessions(agents.sessionId, scope))
773
+ .all();
774
+ for (const a of agentRefs) {
775
+ if (a.taskId !== null)
776
+ addRef(a.sessionId, refProject(a.taskId));
777
+ if (a.epicId !== null)
778
+ addRef(a.sessionId, epicProject.get(a.epicId));
779
+ }
780
+ return bySession;
781
+ }
782
+ /**
783
+ * One grouped query, not one query per session (plan B, DS8). A title never
784
+ * comes from prompt text (a prompt is whatever the operator typed). It is
785
+ * derived from the epic of the session's earliest dispatched agent: the
786
+ * session id itself when it starts with that epic id and a dash (it names
787
+ * the epic and tells sibling sessions of one epic apart), else the epic id.
788
+ * A session with no dispatched agent has no title.
789
+ */
790
+ function sessionTitles(db, scope) {
791
+ const titles = new Map();
792
+ const agentCond = scopedToSessions(agents.sessionId, scope);
793
+ const agentRows = agentCond
794
+ ? db.select().from(agents).where(agentCond).all()
795
+ : db.select().from(agents).all();
796
+ const agentsBySession = new Map();
797
+ for (const a of agentRows) {
798
+ const list = agentsBySession.get(a.sessionId) ?? [];
799
+ list.push(a);
800
+ agentsBySession.set(a.sessionId, list);
801
+ }
802
+ for (const [sessionId, rows] of agentsBySession) {
803
+ const earliest = inLogOrder(rows.map((a) => ({ ts: a.dispatchedAt, eventId: a.id, row: a })))[0];
804
+ const epicId = earliest?.row.epicId;
805
+ if (!epicId)
806
+ continue;
807
+ titles.set(sessionId, sessionId.startsWith(`${epicId}-`) ? sessionId : epicId);
808
+ }
809
+ return titles;
810
+ }
508
811
  /**
509
812
  * Every projected session, most recently active first.
510
813
  *
@@ -534,12 +837,8 @@ export function runningSessions(db, scope = {}, opts = {}) {
534
837
  workingBySession.set(a.sessionId, (workingBySession.get(a.sessionId) ?? 0) + 1);
535
838
  }
536
839
  }
537
- const projectsBySession = new Map();
538
- for (const t of allTasksForScope(db, scope)) {
539
- const set = projectsBySession.get(t.sessionId) ?? new Set();
540
- set.add(projectOf(t.project));
541
- projectsBySession.set(t.sessionId, set);
542
- }
840
+ const projectsBySession = sessionProjects(db, scope);
841
+ const titles = sessionTitles(db, scope);
543
842
  // What each session did most recently. events_raw is not guaranteed to come
544
843
  // back in ts order, so the latest row is chosen by comparison rather than by
545
844
  // trusting scan order — and isLaterEvent settles a tie on ts the same way
@@ -548,6 +847,7 @@ export function runningSessions(db, scope = {}, opts = {}) {
548
847
  const eventQuery = db
549
848
  .select({
550
849
  sessionId: eventsRaw.sessionId,
850
+ taskId: eventsRaw.taskId,
551
851
  ts: eventsRaw.ts,
552
852
  eventType: eventsRaw.eventType,
553
853
  eventId: eventsRaw.eventId,
@@ -559,6 +859,43 @@ export function runningSessions(db, scope = {}, opts = {}) {
559
859
  if (seen === undefined || isLaterEvent(e, seen))
560
860
  lastEvent.set(e.sessionId, e);
561
861
  }
862
+ // A dispatch's role lives in its payload and its task's title in `tasks`;
863
+ // both are read only for the sessions whose last event is a dispatch.
864
+ const dispatchIds = [...lastEvent.values()]
865
+ .filter((e) => e.eventType === 'dispatch_decision')
866
+ .map((e) => e.eventId);
867
+ const roleByEvent = new Map();
868
+ const titleByTask = new Map();
869
+ if (dispatchIds.length > 0) {
870
+ for (const r of db
871
+ .select({ eventId: eventsRaw.eventId, payload: eventsRaw.payload })
872
+ .from(eventsRaw)
873
+ .where(inArray(eventsRaw.eventId, dispatchIds))
874
+ .all()) {
875
+ const role = JSON.parse(r.payload).agent_role;
876
+ if (typeof role === 'string')
877
+ roleByEvent.set(r.eventId, role);
878
+ }
879
+ const taskIds = [...lastEvent.values()].flatMap((e) => (e.taskId ? [e.taskId] : []));
880
+ if (taskIds.length > 0) {
881
+ for (const t of db
882
+ .select({ taskId: tasks.taskId, title: tasks.title })
883
+ .from(tasks)
884
+ .where(inArray(tasks.taskId, taskIds))
885
+ .all()) {
886
+ titleByTask.set(t.taskId, t.title);
887
+ }
888
+ }
889
+ }
890
+ const lastStep = (sessionId) => {
891
+ const e = lastEvent.get(sessionId);
892
+ if (e?.eventType !== 'dispatch_decision')
893
+ return { role: null, task: null };
894
+ return {
895
+ role: roleByEvent.get(e.eventId) ?? null,
896
+ task: (e.taskId && (titleByTask.get(e.taskId) || e.taskId)) || null,
897
+ };
898
+ };
562
899
  return (rows
563
900
  .map((s) => ({
564
901
  sessionId: s.sessionId,
@@ -568,15 +905,59 @@ export function runningSessions(db, scope = {}, opts = {}) {
568
905
  liveAgentCount: liveBySession.get(s.sessionId) ?? 0,
569
906
  workingAgentCount: workingBySession.get(s.sessionId) ?? 0,
570
907
  lastEventType: lastEvent.get(s.sessionId)?.eventType ?? null,
908
+ lastStepRole: lastStep(s.sessionId).role,
909
+ lastStepTask: lastStep(s.sessionId).task,
571
910
  projects: [...(projectsBySession.get(s.sessionId) ?? [])].sort(),
911
+ title: titles.get(s.sessionId) ?? null,
572
912
  }))
573
913
  // Under a project scope, a session belongs to the project only through
574
- // its tasks — one with none (a run that has not planned anything yet) is
575
- // not this project's business, and would otherwise show up in every
576
- // project's Overview at once.
914
+ // the tasks it touched — one with none (a run that has not planned or
915
+ // dispatched anything yet) is not this project's business, and would
916
+ // otherwise show up in every project's Overview at once.
577
917
  .filter((s) => scope.project === undefined || s.projects.length > 0)
578
918
  .sort((a, b) => b.lastEventAt.localeCompare(a.lastEventAt) || a.sessionId.localeCompare(b.sessionId)));
579
919
  }
920
+ /**
921
+ * Which factory sessions each of `cliIds` wrote into: one row per
922
+ * (CLI session, factory session) pair, ordered by CLI id then session id.
923
+ *
924
+ * The answer is read off `events_raw.cli_session_id`, the stamp events.ts
925
+ * puts on every write made inside a CLI session. It is a link and nothing
926
+ * more: a session continued by a second CLI session answers to both, an
927
+ * unstamped event (written outside one, or before the stamp existed) answers
928
+ * to neither, and since an epic session and its subagents can share one CLI
929
+ * session the pair says nothing about which agent wrote what -- so it is
930
+ * never turn or delegation evidence (architecture §18 rule 4).
931
+ */
932
+ export function cliSessionLinks(db, cliIds) {
933
+ // No ids, no question -- and no `inArray(col, [])` either; see
934
+ // scopedToSessions for why this file never builds one.
935
+ if (cliIds.length === 0)
936
+ return [];
937
+ // Aggregated in SQL, one row per pair: a CLI session that wrote thousands
938
+ // of events costs one row here, not thousands folded in JS.
939
+ const rows = db
940
+ .select({
941
+ cliSessionId: eventsRaw.cliSessionId,
942
+ sessionId: eventsRaw.sessionId,
943
+ lastEventAt: max(eventsRaw.ts),
944
+ })
945
+ .from(eventsRaw)
946
+ .where(inArray(eventsRaw.cliSessionId, [...new Set(cliIds)]))
947
+ .groupBy(eventsRaw.cliSessionId, eventsRaw.sessionId)
948
+ .all();
949
+ const links = rows.flatMap((row) => row.cliSessionId === null || row.lastEventAt === null
950
+ ? []
951
+ : [
952
+ {
953
+ cliSessionId: row.cliSessionId,
954
+ sessionId: row.sessionId,
955
+ lastEventAt: row.lastEventAt,
956
+ },
957
+ ]);
958
+ const byCodePoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
959
+ return links.sort((a, b) => byCodePoint(a.cliSessionId, b.cliSessionId) || byCodePoint(a.sessionId, b.sessionId));
960
+ }
580
961
  function groupLiveAgents(rows) {
581
962
  const grouped = new Map();
582
963
  for (const row of rows) {
@@ -635,23 +1016,277 @@ function inFlightEpics(taskRows, closed) {
635
1016
  const closedIds = new Set(closed.map((e) => e.epicId));
636
1017
  return [
637
1018
  ...new Set(taskRows
638
- .filter((t) => t.epicId && !TERMINAL_TASK_STATUSES.has(t.taskStatus) && !closedIds.has(t.epicId))
1019
+ .filter(
1020
+ // ds-spec.md §4.1 "Data/API note" (audit item 2, DS2): a task the
1021
+ // projector will not overwrite (TERMINAL_TASK_STATUSES) is not the
1022
+ // same question as "does this epic still need a person" —
1023
+ // `escalated`/`failed` are HELD_OPEN_BY_AN_OPERATOR (taskStatus.ts),
1024
+ // and no `epic-closed` event exists for them. Filtering on
1025
+ // TERMINAL_TASK_STATUSES alone dropped such an epic out of BOTH
1026
+ // `epicsInFlight` and `closedEpics` the moment its last open task hit
1027
+ // one of those two statuses — it simply disappeared from every
1028
+ // picker that reads this field. CLOSED_TO_FURTHER_WORK excludes
1029
+ // exactly the statuses a person is not expected to come back to.
1030
+ (t) => t.epicId && !CLOSED_TO_FURTHER_WORK.has(t.taskStatus) && !closedIds.has(t.epicId))
639
1031
  .map((t) => t.epicId)),
640
1032
  ].sort();
641
1033
  }
642
1034
  /**
643
- * A finding the operator's pending-waiver count is about: one the waiver
644
- * machinery would actually act on. Both rosters are imported from the modules
645
- * that own them — the severities from waivers.ts, the statuses from findings.ts,
646
- * where they are read off LEGAL_TRANSITIONS — rather than spelled out here.
647
- * This count is a second *reader* of what can be waived and must not become a
648
- * second opinion about it: a count that disagrees with the batch does not
649
- * error, it just tells the operator a different number than `/bs waivers`
650
- * will offer them.
1035
+ * The subset of `inFlightEpics()` an operator would call actually running:
1036
+ * epics with a task in a truly open status, not merely one the projector
1037
+ * still calls non-terminal. An epic whose only open task is `escalated` or
1038
+ * `failed` stays in `inFlightEpics()` (it is still pickable on Kanban/Flow —
1039
+ * see that function's doc), but it has nothing running and belongs in the
1040
+ * inbox, not on the "Running now" card (ds-spec.md §4.1, DS2 review F1).
1041
+ */
1042
+ function activeEpics(taskRows, closed, idle) {
1043
+ const closedIds = new Set(closed.map((e) => e.epicId));
1044
+ const idleIds = new Set(idle.map((e) => e.epicId));
1045
+ return [
1046
+ ...new Set(taskRows
1047
+ .filter((t) => t.epicId &&
1048
+ !TERMINAL_TASK_STATUSES.has(t.taskStatus) &&
1049
+ !closedIds.has(t.epicId) &&
1050
+ !idleIds.has(t.epicId))
1051
+ .map((t) => t.epicId)),
1052
+ ].sort();
1053
+ }
1054
+ /** An epic with no activity for longer than this is idle (7 x 24 h; exactly 7 days is not). */
1055
+ export const EPIC_IDLE_MS = 7 * 24 * 60 * 60 * 1000;
1056
+ /**
1057
+ * The newest event per (task id, payload `epic_id`, project), session-scoped. Read once
1058
+ * per `overview()` and shared with every per-project summary, so a dashboard
1059
+ * poll scans `events_raw` once rather than once per project plus once overall.
1060
+ * Rows without a task id are kept: goal-check, spec-review and plan-amend
1061
+ * events carry only the payload's `epic_id`.
1062
+ */
1063
+ function epicActivityRows(db, scope) {
1064
+ const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
1065
+ // json_valid() first: json_extract() throws on a non-JSON payload, and one
1066
+ // bad row must not fail the whole overview.
1067
+ const payloadEpic = sql `CASE WHEN json_valid(${eventsRaw.payload}) THEN json_extract(${eventsRaw.payload}, '$.epic_id') END`;
1068
+ const query = db
1069
+ .select({
1070
+ taskId: eventsRaw.taskId,
1071
+ payloadEpic,
1072
+ project: eventsRaw.project,
1073
+ last: max(eventsRaw.ts),
1074
+ })
1075
+ .from(eventsRaw);
1076
+ const rows = (sessionCond ? query.where(sessionCond) : query)
1077
+ .groupBy(eventsRaw.taskId, payloadEpic, eventsRaw.project)
1078
+ .all();
1079
+ return rows.flatMap((r) => r.last === null
1080
+ ? []
1081
+ : [
1082
+ {
1083
+ taskId: r.taskId,
1084
+ payloadEpic: typeof r.payloadEpic === 'string' ? r.payloadEpic : null,
1085
+ project: projectOf(r.project),
1086
+ last: r.last,
1087
+ },
1088
+ ]);
1089
+ }
1090
+ /**
1091
+ * The in-flight epics nothing has happened to for more than 7 days before
1092
+ * `nowIso`. An epic's last activity is the newest of its tasks' `updatedAt`
1093
+ * and the `ts` of the newest event that belongs to it: `updatedAt` only moves
1094
+ * when the projector touches the row, so a finding, a judge report or a gate
1095
+ * result logged against a task would otherwise read as silence. An event
1096
+ * belongs to an epic by, in order: its payload `epic_id` (the key the
1097
+ * projector itself trusts, and the only one a task-less event has); the task
1098
+ * id through `epicResolver`, so a bare task id lands on its epic as in the
1099
+ * spend figures; the `<epic>/` prefix of a task id no task row carries, which
1100
+ * is how a plan ref (`<epic>/plan-rN`, `<epic>/spec-review-rN`) names its
1101
+ * epic. Timestamps compare as instants, not strings. An idle epic stays in
1102
+ * `inFlightEpics()` (it is still pickable) and only leaves `activeEpics()`.
1103
+ * With `project` set, only that project's events count (an unset one spans all).
1104
+ */
1105
+ function idleEpics(taskRows, activity, inFlight, nowIso, project) {
1106
+ const lastByEpic = new Map();
1107
+ const bump = (epicId, ts) => {
1108
+ if (epicId === undefined)
1109
+ return;
1110
+ const ms = Date.parse(ts);
1111
+ if (Number.isNaN(ms))
1112
+ return;
1113
+ const seen = lastByEpic.get(epicId);
1114
+ if (seen === undefined || ms > seen)
1115
+ lastByEpic.set(epicId, ms);
1116
+ };
1117
+ const epicByTask = new Map();
1118
+ for (const t of taskRows) {
1119
+ if (!t.epicId)
1120
+ continue;
1121
+ epicByTask.set(t.taskId, t.epicId);
1122
+ bump(t.epicId, t.updatedAt);
1123
+ }
1124
+ const epicOf = epicResolver(epicByTask);
1125
+ for (const r of activity) {
1126
+ if (project !== undefined && r.project !== project)
1127
+ continue;
1128
+ const slash = r.taskId?.lastIndexOf('/') ?? -1;
1129
+ const prefix = r.taskId && slash > 0 ? r.taskId.slice(0, slash) : undefined;
1130
+ bump(r.payloadEpic ?? epicOf(r.taskId ?? undefined) ?? prefix, r.last);
1131
+ }
1132
+ const now = Date.parse(nowIso);
1133
+ const idle = [];
1134
+ for (const epicId of inFlight) {
1135
+ const lastMs = lastByEpic.get(epicId);
1136
+ if (lastMs === undefined)
1137
+ continue;
1138
+ const idleMs = now - lastMs;
1139
+ if (idleMs > EPIC_IDLE_MS)
1140
+ idle.push({ epicId, idleDays: Math.floor(idleMs / (24 * 60 * 60 * 1000)) });
1141
+ }
1142
+ return idle;
1143
+ }
1144
+ /**
1145
+ * A finding the waiver machinery would actually act on. Both rosters are
1146
+ * imported from the modules that own them — the severities from waivers.ts,
1147
+ * the statuses from findings.ts, where they are read off LEGAL_TRANSITIONS —
1148
+ * rather than spelled out here. `bs waivers pending` lists every such finding:
1149
+ * what *could* be waived. The operator's inbox and pending-waiver count are
1150
+ * narrower, see needsOperatorWaiver() below.
651
1151
  */
652
1152
  function awaitsWaiverDecision(f) {
653
1153
  return WAIVABLE_SEVERITIES.includes(f.severity) && WAIVABLE_STATUSES.includes(f.findingStatus);
654
1154
  }
1155
+ /**
1156
+ * The task row a finding's task id names: exact key first, then taskIdsMatch,
1157
+ * the way projectResolver (projector.ts) does it. The log spells one task both
1158
+ * bare ("task-1") and qualified ("epic-a/task-1"), and findings keep whichever
1159
+ * spelling they were raised with. A bare id matches the same-named task of
1160
+ * every epic, so the finding's own epic, when it names one, picks among them.
1161
+ */
1162
+ function taskResolver(taskRows) {
1163
+ const exact = new Map(taskRows.map((t) => [t.taskId, t]));
1164
+ return (taskId, epicId) => {
1165
+ const hit = exact.get(taskId);
1166
+ if (hit)
1167
+ return hit;
1168
+ const matches = taskRows.filter((t) => taskIdsMatch(taskId, t.taskId));
1169
+ return matches.find((t) => epicId !== null && t.epicId === epicId) ?? matches[0];
1170
+ };
1171
+ }
1172
+ /**
1173
+ * An undecided waivable finding the factory will no longer act on, so only the
1174
+ * operator can: its task shipped, or it is epic-level (no task row, such as
1175
+ * `<epic>/integration`) and its epic is closed. A task still open is in the
1176
+ * factory's own fix loop (a follow-up task exists to fix its finding), a
1177
+ * superseded task's findings are moot, and an escalated task has its own inbox
1178
+ * row. No shared status set names "shipped" alone — TERMINAL_OK_TASK_STATUSES
1179
+ * also holds `waived`, which is decided — so `completed` is spelled out. The
1180
+ * inbox and both pendingWaivers counts read this one rule, so they agree.
1181
+ */
1182
+ function needsOperatorWaiver(f, resolveTask, closedEpicIds) {
1183
+ if (!awaitsWaiverDecision(f) || f.waiverId !== null)
1184
+ return false;
1185
+ const t = resolveTask(f.taskId, f.epicId);
1186
+ if (t)
1187
+ return t.taskStatus === 'completed';
1188
+ return f.epicId !== null && closedEpicIds.has(f.epicId);
1189
+ }
1190
+ const INBOX_KIND_RANK = {
1191
+ escalation: 0,
1192
+ waiver: 1,
1193
+ lesson_candidate: 2,
1194
+ };
1195
+ /** A task's Kanban display text — the same `objective ?? taskId` fallback
1196
+ * every other query on this table uses; tasks have no `title` column. */
1197
+ function taskDisplayText(t) {
1198
+ return t.objective ?? t.taskId;
1199
+ }
1200
+ export function inboxRows(db, scope = {}) {
1201
+ const taskRows = allTasksForScope(db, scope);
1202
+ const resolveTask = taskResolver(taskRows);
1203
+ const rows = [];
1204
+ // Escalations: task rows the projector parked at `escalated`
1205
+ // (db/projector.ts's error-logged handling, `coordination.*` errors).
1206
+ // Dispatches for every escalated task are fetched in one query, not one
1207
+ // per task, and grouped by taskId below.
1208
+ const escalatedTaskIds = taskRows
1209
+ .filter((t) => t.taskStatus === 'escalated')
1210
+ .map((t) => t.taskId);
1211
+ const lastDispatchByTask = new Map();
1212
+ if (escalatedTaskIds.length > 0) {
1213
+ const dispatchRows = inLogOrder(db.select().from(dispatches).where(inArray(dispatches.taskId, escalatedTaskIds)).all());
1214
+ for (const d of dispatchRows) {
1215
+ if (d.taskId)
1216
+ lastDispatchByTask.set(d.taskId, d);
1217
+ }
1218
+ }
1219
+ for (const t of taskRows) {
1220
+ if (t.taskStatus !== 'escalated')
1221
+ continue;
1222
+ const lastDispatch = lastDispatchByTask.get(t.taskId);
1223
+ rows.push({
1224
+ id: `escalation:${t.taskId}`,
1225
+ kind: 'escalation',
1226
+ title: taskDisplayText(t),
1227
+ description: lastDispatch ? `Escalated while dispatched as ${lastDispatch.agentRole}` : null,
1228
+ project: projectOf(t.project),
1229
+ taskId: t.taskId,
1230
+ createdAt: t.updatedAt,
1231
+ });
1232
+ }
1233
+ // Pending waivers: findings `needsOperatorWaiver()` covers (shipped work
1234
+ // nothing else will touch), grouped one row per task (the operator waives a
1235
+ // task's batch, not one finding at a time — /bs waivers's own unit).
1236
+ const closedEpicIds = new Set(closedEpicsForScope(db, scope).map((e) => e.epicId));
1237
+ const pendingFindings = allFindingsForScope(db, scope).filter((f) => needsOperatorWaiver(f, resolveTask, closedEpicIds));
1238
+ const pendingByTask = new Map();
1239
+ for (const f of pendingFindings) {
1240
+ const t = resolveTask(f.taskId, f.epicId);
1241
+ const key = t?.taskId ?? f.taskId;
1242
+ const group = pendingByTask.get(key) ?? { t, findingRows: [] };
1243
+ group.findingRows.push(f);
1244
+ pendingByTask.set(key, group);
1245
+ }
1246
+ for (const [taskId, { t, findingRows }] of pendingByTask) {
1247
+ const latest = findingRows.reduce((a, b) => (a.raisedAt > b.raisedAt ? a : b));
1248
+ const count = findingRows.length;
1249
+ rows.push({
1250
+ id: `waiver:${taskId}`,
1251
+ kind: 'waiver',
1252
+ title: t ? taskDisplayText(t) : taskId,
1253
+ description: `${count} finding${count === 1 ? '' : 's'} awaiting a waiver decision`,
1254
+ project: projectOf(latest.project ?? t?.project ?? null),
1255
+ taskId,
1256
+ createdAt: latest.raisedAt,
1257
+ });
1258
+ }
1259
+ // Lesson candidates: what /api/lessons already buckets as 'pending'. No
1260
+ // project column on lessons — always the client's "All projects" group.
1261
+ if (scope.project === undefined) {
1262
+ const sessionCond = scopedToSessions(lessons.sessionId, scope);
1263
+ const lessonRows = sessionCond
1264
+ ? db.select().from(lessons).where(sessionCond).all()
1265
+ : db.select().from(lessons).all();
1266
+ for (const l of lessonRows) {
1267
+ if (LESSON_BUCKET_FOR_STATUS[l.lessonStatus] !== 'pending')
1268
+ continue;
1269
+ rows.push({
1270
+ id: `lesson_candidate:${l.lessonId}`,
1271
+ kind: 'lesson_candidate',
1272
+ title: l.statement,
1273
+ description: null,
1274
+ project: null,
1275
+ taskId: null,
1276
+ createdAt: l.validFrom,
1277
+ });
1278
+ }
1279
+ }
1280
+ return rows.sort((a, b) => {
1281
+ const rank = INBOX_KIND_RANK[a.kind] - INBOX_KIND_RANK[b.kind];
1282
+ if (rank !== 0)
1283
+ return rank;
1284
+ const created = a.createdAt.localeCompare(b.createdAt);
1285
+ if (created !== 0)
1286
+ return created;
1287
+ return a.id.localeCompare(b.id);
1288
+ });
1289
+ }
655
1290
  /**
656
1291
  * One project's overview slice, computed by the same logic overview() itself
657
1292
  * uses (no drift). `nowIso` is the caller's single instant, not a fresh
@@ -659,24 +1294,34 @@ function awaitsWaiverDecision(f) {
659
1294
  * for its own counts, so the project rows and the global card can never
660
1295
  * disagree about which agents are working.
661
1296
  */
662
- function projectSummary(db, project, baseScope, nowIso) {
1297
+ function projectSummary(db, project, baseScope, nowIso, activity) {
663
1298
  const scope = { ...baseScope, project };
664
1299
  const liveRows = allAgentsForScope(db, scope);
665
1300
  const taskRows = allTasksForScope(db, scope);
666
- const epicsInFlight = inFlightEpics(taskRows, closedEpicsForScope(db, scope));
667
- const { budgetByEpic, spentByEpic } = epicTokenMaps(db, scope, taskRows);
1301
+ const closedEpicsHere = closedEpicsForScope(db, scope);
1302
+ const epicsInFlight = inFlightEpics(taskRows, closedEpicsHere);
1303
+ const epicsIdle = idleEpics(taskRows, activity, epicsInFlight, nowIso, project);
1304
+ const epicsActivelyRunning = activeEpics(taskRows, closedEpicsHere, epicsIdle);
1305
+ const tokenMaps = epicTokenMaps(db, scope, taskRows);
1306
+ const { budgetByEpic, spentByEpic, unmeasuredByEpic } = tokenMaps;
668
1307
  const tokensSpent = [...spentByEpic.values()].reduce((s, v) => s + v, 0);
669
1308
  const tokensBudget = budgetByEpic.size > 0 ? [...budgetByEpic.values()].reduce((s, v) => s + v, 0) : null;
1309
+ const unmeasured = [...unmeasuredByEpic.values()].reduce((s, v) => s + v, 0);
670
1310
  const escalations = taskRows.filter((t) => t.taskStatus === 'escalated').length;
671
- const findingRows = allFindingsForScope(db, scope).filter(awaitsWaiverDecision);
672
- const pendingWaivers = findingRows.filter((f) => f.waiverId === null).length;
1311
+ const closedEpicIds = new Set(closedEpicsHere.map((e) => e.epicId));
1312
+ const resolveTask = taskResolver(taskRows);
1313
+ const pendingWaivers = allFindingsForScope(db, scope).filter((f) => needsOperatorWaiver(f, resolveTask, closedEpicIds)).length;
673
1314
  return {
674
1315
  project,
675
1316
  liveAgentCount: liveRows.length,
676
1317
  workingAgentCount: liveRows.filter((a) => isWorkingAt(a.dispatchedAt, nowIso)).length,
677
1318
  epicsInFlight,
1319
+ epicsActivelyRunning,
1320
+ epicsIdle,
678
1321
  tokensSpent,
679
1322
  tokensBudget,
1323
+ unmeasured,
1324
+ tokensByEpic: epicTokenSpends(tokenMaps),
680
1325
  alerts: { escalations, pendingWaivers },
681
1326
  };
682
1327
  }
@@ -780,7 +1425,9 @@ function tokensSpentAt(db, scope, cutoffIso, epicByTask) {
780
1425
  const payload = JSON.parse(r.payload);
781
1426
  if (!epicOf(resultTaskId({ payload, envelopeTaskId: r.taskId })))
782
1427
  continue;
783
- total += payload.token_usage?.total_tokens ?? 0;
1428
+ const usage = readTokenUsage(payload.token_usage);
1429
+ if (usage.measured)
1430
+ total += usage.total;
784
1431
  }
785
1432
  return total;
786
1433
  }
@@ -845,16 +1492,15 @@ export function overview(db, scope = {}, opts = {}) {
845
1492
  const taskRows = allTasksForScope(db, scope);
846
1493
  const closedEpics = closedEpicsForScope(db, scope);
847
1494
  const epicsInFlight = inFlightEpics(taskRows, closedEpics);
848
- const { budgetByEpic, spentByEpic } = epicTokenMaps(db, scope, taskRows);
849
- const epicIds = new Set([...budgetByEpic.keys(), ...spentByEpic.keys()]);
850
- const tokensByEpic = [...epicIds].sort().map((epicId) => ({
851
- epicId,
852
- tokensSpent: spentByEpic.get(epicId) ?? 0,
853
- tokensBudget: budgetByEpic.get(epicId) ?? null,
854
- }));
1495
+ const activity = epicActivityRows(db, scope);
1496
+ const epicsIdle = idleEpics(taskRows, activity, epicsInFlight, nowIso, scopeProject(scope));
1497
+ const epicsActivelyRunning = activeEpics(taskRows, closedEpics, epicsIdle);
1498
+ const { budgetByEpic, spentByEpic, unmeasuredByEpic } = epicTokenMaps(db, scope, taskRows);
1499
+ const tokensByEpic = epicTokenSpends({ budgetByEpic, spentByEpic, unmeasuredByEpic });
855
1500
  const escalations = taskRows.filter((t) => t.taskStatus === 'escalated').length;
856
- const pendingWaiverFindings = allFindingsForScope(db, scope).filter(awaitsWaiverDecision);
857
- const pendingWaivers = pendingWaiverFindings.filter((f) => f.waiverId === null).length;
1501
+ const closedEpicIds = new Set(closedEpics.map((e) => e.epicId));
1502
+ const resolveTask = taskResolver(taskRows);
1503
+ const pendingWaivers = allFindingsForScope(db, scope).filter((f) => needsOperatorWaiver(f, resolveTask, closedEpicIds)).length;
858
1504
  const dispatchSessionCond = scopedToSessions(dispatches.sessionId, scope);
859
1505
  const dispatchRows = dispatchSessionCond
860
1506
  ? db.select().from(dispatches).where(dispatchSessionCond).all()
@@ -866,10 +1512,21 @@ export function overview(db, scope = {}, opts = {}) {
866
1512
  // them left the rows in the ascending order they arrived in: the ten this
867
1513
  // then kept were the *oldest* ten of the burst, under a heading that says
868
1514
  // recent.
869
- const recentDispatches = inLogOrder(scopedDispatches)
1515
+ const recentDispatchRows = inLogOrder(scopedDispatches)
870
1516
  .reverse()
871
- .slice(0, RECENT_DISPATCHES_LIMIT)
872
- .map((d) => ({
1517
+ .slice(0, RECENT_DISPATCHES_LIMIT);
1518
+ // agents.id IS the dispatch event id (agents-registry.ts's foldAgents()),
1519
+ // so a direct id lookup finds the round regardless of whether that agent
1520
+ // is still live or has since gone terminal.
1521
+ const roundByEventId = new Map(recentDispatchRows.length > 0
1522
+ ? db
1523
+ .select({ id: agents.id, round: agents.round })
1524
+ .from(agents)
1525
+ .where(inArray(agents.id, recentDispatchRows.map((d) => d.eventId)))
1526
+ .all()
1527
+ .map((a) => [a.id, a.round])
1528
+ : []);
1529
+ const recentDispatches = recentDispatchRows.map((d) => ({
873
1530
  eventId: d.eventId,
874
1531
  ts: d.ts,
875
1532
  agentRole: d.agentRole,
@@ -877,6 +1534,7 @@ export function overview(db, scope = {}, opts = {}) {
877
1534
  modelTier: d.modelTier,
878
1535
  taskId: d.taskId,
879
1536
  reason: d.reason,
1537
+ round: roundByEventId.get(d.eventId) ?? 1,
880
1538
  }));
881
1539
  let projects;
882
1540
  if (scope.project === undefined) {
@@ -894,15 +1552,25 @@ export function overview(db, scope = {}, opts = {}) {
894
1552
  // Milestones carry no session_id and milestoneProgressRows() never
895
1553
  // filters them by one, so they join the list under a session scope too.
896
1554
  const declaredRows = db.select({ project: milestones.project }).from(milestones).all();
897
- projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope, nowIso));
1555
+ projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope, nowIso, activity));
898
1556
  }
899
1557
  const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
900
1558
  const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000).toISOString();
901
1559
  const liveAgentCountDelta5m = liveRows.length - liveAgentCountAt(db, scope, fiveMinAgo);
902
1560
  const workingAgentCountDelta5m = workingRows.length - workingAgentCountAt(db, scope, fiveMinAgo);
903
- const epicByTask = new Map(taskRows.filter((t) => t.epicId !== null).map((t) => [t.taskId, t.epicId]));
904
- const totalBudgetNow = [...budgetByEpic.values()].reduce((s, v) => s + v, 0);
905
- const totalSpentNow = [...spentByEpic.values()].reduce((s, v) => s + v, 0);
1561
+ // The Budget panel counts the epics the Running-now cards count, so its
1562
+ // 1-hour delta is folded over the same set: running epics that declare a
1563
+ // budget (an unbudgeted epic adds to neither side of the card's ratio).
1564
+ const runningBudgeted = new Set(epicsActivelyRunning.filter((e) => budgetByEpic.has(e)));
1565
+ const epicByTask = new Map(taskRows
1566
+ .filter((t) => t.epicId !== null && runningBudgeted.has(t.epicId))
1567
+ .map((t) => [t.taskId, t.epicId]));
1568
+ const totalBudgetNow = [...budgetByEpic]
1569
+ .filter(([e]) => runningBudgeted.has(e))
1570
+ .reduce((s, [, v]) => s + v, 0);
1571
+ const totalSpentNow = [...spentByEpic]
1572
+ .filter(([e]) => runningBudgeted.has(e))
1573
+ .reduce((s, [, v]) => s + v, 0);
906
1574
  let budgetUsedPctPointDelta1h = null;
907
1575
  if (totalBudgetNow > 0) {
908
1576
  const currentPct = (totalSpentNow / totalBudgetNow) * 100;
@@ -936,6 +1604,8 @@ export function overview(db, scope = {}, opts = {}) {
936
1604
  stalledAgentCount: liveRows.length - workingRows.length,
937
1605
  runningSessions: runningSessions(db, scope, { nowIso }),
938
1606
  epicsInFlight,
1607
+ epicsActivelyRunning,
1608
+ epicsIdle,
939
1609
  closedEpics,
940
1610
  tokensByEpic,
941
1611
  alerts: { escalations, pendingWaivers },
@@ -1033,6 +1703,14 @@ export const FREE_TIMELINE_EVENT_TYPES = [
1033
1703
  // only event written after `epic-closed`, so without it the timeline of a
1034
1704
  // closed epic ends at the closing and never shows what the closing was for.
1035
1705
  'integration-pr-opened',
1706
+ // A comment the operator leaves mid-run on one task (feedback.ts), and its
1707
+ // later resolution -- bounced, turned into a follow-up task, or dismissed.
1708
+ // Both are the operator's own words about a specific task, the same reason
1709
+ // operator-note is on this list, and neither carries a gate_event/graph_event
1710
+ // tag: what happens to the comment is a later PR's playbook wiring, not a
1711
+ // taxonomy outcome this orchestrator declares today.
1712
+ 'operator-feedback-recorded',
1713
+ 'operator-feedback-resolved',
1036
1714
  ];
1037
1715
  let cachedTaxonomy;
1038
1716
  /**
@@ -1061,7 +1739,93 @@ export function timelineEventTypes() {
1061
1739
  ...(cachedTaxonomy.dimensions.graph_event ?? []),
1062
1740
  ];
1063
1741
  }
1064
- function toEntry(row) {
1742
+ /** Every `EventKind`, for routes validating a `kind=` query param against the real set. */
1743
+ export const EVENT_KINDS = [
1744
+ 'Prompt',
1745
+ 'Dispatched',
1746
+ 'Returned',
1747
+ 'Finding',
1748
+ 'Gate',
1749
+ 'Merge',
1750
+ 'Error',
1751
+ 'Feedback',
1752
+ 'System',
1753
+ ];
1754
+ /**
1755
+ * Which of the 9 kinds a raw event renders as (ds-spec.md §4.3's table) —
1756
+ * pure in `(eventType, payload)` so both `timeline()` and `causalChain()` tag
1757
+ * every row the same way. Unnamed types fall to `System`, the feed's
1758
+ * catch-all for rare types.
1759
+ *
1760
+ * DS6 PR3 round 2 reconciles this with `ui/src/lib/timelineDisplay.ts`'s
1761
+ * client-side `kindFor()` fallback switch (which this function already
1762
+ * takes precedence over whenever a paged entry carries a server `kind`):
1763
+ * both now agree that `judge-reported` is `Finding` and
1764
+ * `waiver-granted`/`waiver-denied` are `Feedback`, per §4.3's table
1765
+ * ("Finding (judge verdict)" -> `judge-reported`; "Feedback" ->
1766
+ * `waiver-granted` / `waiver-denied`). The round-1 version of this switch
1767
+ * only matched the 8 event types the mock happens to show, so every other
1768
+ * gate/graph event — `finding-raised`, `schema-check-result`,
1769
+ * `cross-finding-reconciled`, `epic-closed`, and the rest of
1770
+ * taxonomy.yml's `gate_event`/`graph_event` vocabulary — fell through to
1771
+ * `System`, which is where the "most rows are System" visual-pass finding
1772
+ * came from. The groupings below follow the same families `kindFor()`'s
1773
+ * switch already used, just with the one Finding/Feedback swap applied.
1774
+ */
1775
+ export function eventKind(eventType, _payload) {
1776
+ switch (eventType) {
1777
+ case 'user_prompt':
1778
+ case 'operator-note':
1779
+ return 'Prompt';
1780
+ case DISPATCH_EVENT_TYPE:
1781
+ return 'Dispatched';
1782
+ case TASK_RESULT_EVENT_TYPE:
1783
+ return 'Returned';
1784
+ case JUDGE_REPORT_EVENT_TYPE:
1785
+ case 'finding-raised':
1786
+ case 'finding-reverified':
1787
+ case 'finding-suppressed':
1788
+ case 'finding-transitioned':
1789
+ case 'finding-reattributed':
1790
+ case 'severity-decisions':
1791
+ case 'task-waiver-approved':
1792
+ return 'Finding';
1793
+ case 'gate-outcome':
1794
+ case 'testgate-result':
1795
+ case 'schema-check-result':
1796
+ case 'artifact-check-result':
1797
+ case 'commit-check-result':
1798
+ case 'deps-check-result':
1799
+ case 'judges-outstanding':
1800
+ case 'grader-verdict':
1801
+ case 'budget-check-result':
1802
+ case 'coverage-evidence':
1803
+ case 'integration-check':
1804
+ case 'spec-review-recorded':
1805
+ case 'goal-check-recorded':
1806
+ case 'quorum-decision':
1807
+ case 'issue-reported':
1808
+ return 'Gate';
1809
+ case 'wave-merged':
1810
+ case 'epic-closed':
1811
+ case 'integration-pr-opened':
1812
+ return 'Merge';
1813
+ case ERROR_EVENT_TYPE:
1814
+ case 'error-report-proposed':
1815
+ return 'Error';
1816
+ case 'waiver-granted':
1817
+ case 'waiver-denied':
1818
+ case 'judge-verdict':
1819
+ case 'cross-finding-reconciled':
1820
+ case 'spec-change-proposed':
1821
+ case 'spec-change-decided':
1822
+ return 'Feedback';
1823
+ default:
1824
+ return 'System';
1825
+ }
1826
+ }
1827
+ function toEntry(row, nearestPromptId) {
1828
+ const payload = JSON.parse(row.payload);
1065
1829
  return {
1066
1830
  eventId: row.eventId,
1067
1831
  ts: row.ts,
@@ -1070,9 +1834,14 @@ function toEntry(row) {
1070
1834
  agentId: row.agentId,
1071
1835
  planVersion: row.planVersion,
1072
1836
  causalParent: row.causalParent,
1073
- payload: JSON.parse(row.payload),
1837
+ payload,
1074
1838
  project: row.project,
1075
1839
  actor: row.actor,
1840
+ kind: eventKind(row.eventType, payload),
1841
+ nearestPromptId,
1842
+ sessionId: row.sessionId,
1843
+ // Filled per-page by joinSessionTitles() below, same as run/gateCounts.
1844
+ sessionTitle: row.sessionId,
1076
1845
  };
1077
1846
  }
1078
1847
  /**
@@ -1134,35 +1903,50 @@ function applyDecisionsLens(entries) {
1134
1903
  });
1135
1904
  }
1136
1905
  /**
1137
- * Walk causal_parent pointers back to the root, oldest-first.
1138
- *
1139
- * P9-7: the walk crosses session boundaries. Event ids are globally unique
1140
- * (`<session-id>#<index>`), so a hop needs the id alone; constraining every
1141
- * lookup to `sessionId` used to stop the chain dead at the session root of a
1142
- * continued epic — the projection reported a two-event history for a chain
1143
- * that ran back through three sessions. `sessionId` still gates the FIRST
1144
- * lookup, so asking for a chain that does not start in the named session
1145
- * remains an empty answer rather than a silent redirect.
1906
+ * Row cache shared across calls within one `kanban()` invocation (S3 fix):
1907
+ * event ids are globally unique (see the docstring above), so a row fetched
1908
+ * for one task's walk answers for every other walk that crosses the same
1909
+ * event, and the session gate below still applies to it.
1146
1910
  */
1147
- function causalChain(db, sessionId, eventId) {
1148
- const chain = [];
1911
+ function fetchEventRow(db, eventId, cache) {
1912
+ if (cache?.has(eventId))
1913
+ return cache.get(eventId) ?? null;
1914
+ const row = db.select().from(eventsRaw).where(eq(eventsRaw.eventId, eventId)).get() ?? null;
1915
+ cache?.set(eventId, row);
1916
+ return row;
1917
+ }
1918
+ function causalChain(db, sessionId, eventId, eventCache) {
1919
+ const rows = [];
1149
1920
  let currentId = eventId;
1150
1921
  const seen = new Set();
1151
1922
  let first = true;
1152
1923
  while (currentId && !seen.has(currentId)) {
1153
1924
  seen.add(currentId);
1154
- const idMatch = eq(eventsRaw.eventId, currentId);
1155
- const row = db
1156
- .select()
1157
- .from(eventsRaw)
1158
- .where(first ? and(idMatch, eq(eventsRaw.sessionId, sessionId)) : idMatch)
1159
- .get();
1925
+ const row = fetchEventRow(db, currentId, eventCache);
1160
1926
  if (!row)
1161
1927
  break;
1928
+ // Ids are globally unique, so a row fetched for a different session's
1929
+ // walk can still answer this one's first hop — but that hop is only
1930
+ // valid when it actually named a row inside the requested session,
1931
+ // same as the original `and(idMatch, eq(sessionId))` constraint.
1932
+ if (first && row.sessionId !== sessionId)
1933
+ break;
1162
1934
  first = false;
1163
- chain.unshift(toEntry(row));
1935
+ rows.unshift(row);
1164
1936
  currentId = row.causalParent;
1165
1937
  }
1938
+ // `rows` is now the whole ancestor path, oldest-first, so each row's
1939
+ // nearest `user_prompt` is answered by a single forward pass over this
1940
+ // same array rather than a nested call into `nearestUserPrompt` — that
1941
+ // function calls back into `causalChain`, and feeding it one row at a
1942
+ // time from inside this loop was mutual recursion with no base case.
1943
+ let lastPromptId = null;
1944
+ const chain = [];
1945
+ for (const row of rows) {
1946
+ if (row.eventType === 'user_prompt')
1947
+ lastPromptId = row.eventId;
1948
+ chain.push(toEntry(row, lastPromptId));
1949
+ }
1166
1950
  return chain;
1167
1951
  }
1168
1952
  /**
@@ -1188,12 +1972,55 @@ function epicOfEntry(entry) {
1188
1972
  const fromPayload = entry.payload.epic_id;
1189
1973
  return typeof fromPayload === 'string' ? fromPayload : null;
1190
1974
  }
1975
+ /**
1976
+ * Whether an entry belongs to `epicId`. An entry that names an epic itself
1977
+ * (`epicOfEntry` non-null) is decided by that alone: its own epic wins. A
1978
+ * `user_prompt` naming none is derived at read (nothing stored) and shows
1979
+ * under `epicId` when its session holds an entry of that epic, or when its id
1980
+ * is named by a `parent_prompt_id` of such an entry or of any epic-less entry
1981
+ * (the root `session-start`, which carries only `parent_prompt_id`) in one of
1982
+ * those sessions. A prompt carrying its own epic does not make its session an
1983
+ * epic session; it only decides itself. A session holding entries of two
1984
+ * epics shares its epic-less prompts, and the `parent_prompt_id` naming of its
1985
+ * epic-less entries, with both. The `prompts-<uuid>` home log has no entries
1986
+ * of its own. Every other epic-less entry is dropped.
1987
+ */
1988
+ function promptInEpic(entries, epicId) {
1989
+ const sessions = new Set();
1990
+ for (const e of entries) {
1991
+ if (e.eventType !== 'user_prompt' && epicOfEntry(e) === epicId)
1992
+ sessions.add(e.sessionId);
1993
+ }
1994
+ const named = new Set();
1995
+ for (const e of entries) {
1996
+ const own = epicOfEntry(e);
1997
+ if (own !== null ? own !== epicId : !sessions.has(e.sessionId))
1998
+ continue;
1999
+ const parent = e.payload.parent_prompt_id;
2000
+ if (typeof parent === 'string')
2001
+ named.add(parent);
2002
+ }
2003
+ return (e) => {
2004
+ const own = epicOfEntry(e);
2005
+ if (own !== null)
2006
+ return own === epicId;
2007
+ return e.eventType === 'user_prompt' && (sessions.has(e.sessionId) || named.has(e.eventId));
2008
+ };
2009
+ }
1191
2010
  export function timeline(db, filter = {}) {
1192
2011
  if (filter.causalChainFor) {
1193
2012
  if (!filter.sessionId) {
1194
2013
  throw new RangeError('timeline(): causalChainFor requires sessionId to be set.');
1195
2014
  }
1196
- return causalChain(db, filter.sessionId, filter.causalChainFor);
2015
+ if (filter.limit !== undefined ||
2016
+ filter.before !== undefined ||
2017
+ filter.after !== undefined ||
2018
+ filter.kinds !== undefined) {
2019
+ throw new RangeError('timeline(): causalChainFor cannot be combined with limit, before, after or kinds — the causal chain is not pageable.');
2020
+ }
2021
+ const chain = causalChain(db, filter.sessionId, filter.causalChainFor);
2022
+ joinSessionTitles(db, chain);
2023
+ return chain;
1197
2024
  }
1198
2025
  const eventTypes = filter.eventTypes ?? timelineEventTypes();
1199
2026
  const conditions = [inArray(eventsRaw.eventType, eventTypes)];
@@ -1207,13 +2034,202 @@ export function timeline(db, filter = {}) {
1207
2034
  .from(eventsRaw)
1208
2035
  .where(and(...conditions))
1209
2036
  .all());
1210
- let entries = rows.map(toEntry);
2037
+ // `kind` is cheap (pure function of eventType/payload) and the `kinds`
2038
+ // filter needs it, so it is computed for every fetched row up front.
2039
+ // `nearestPromptId` is the expensive one (a causal-parent walk), so it is
2040
+ // deferred until AFTER filtering and paging and only computed for the rows
2041
+ // that actually survive onto the page — see `memoizedNearestPromptId`.
2042
+ let entries = rows.map((row) => toEntry(row, null));
1211
2043
  entries = filterByProject(entries, filter);
1212
- if (filter.epicId)
1213
- entries = entries.filter((e) => epicOfEntry(e) === filter.epicId);
2044
+ if (filter.epicId) {
2045
+ entries = entries.filter(promptInEpic(entries, filter.epicId));
2046
+ }
2047
+ if (filter.kinds?.length) {
2048
+ const kinds = new Set(filter.kinds);
2049
+ entries = entries.filter((e) => kinds.has(e.kind));
2050
+ }
1214
2051
  if (filter.decisionsOnly)
1215
2052
  entries = applyDecisionsLens(entries);
1216
- return entries;
2053
+ const page = paginate(db, entries, filter);
2054
+ // Row lookup for the memo walk: the fetched rows answer most ancestors for
2055
+ // free (one query already brought them all into memory); a fallback cache
2056
+ // handles the rare ancestor outside that set (e.g. a cross-session hop).
2057
+ const rowByEventId = new Map(rows.map((row) => [row.eventId, row]));
2058
+ const ancestorCache = new Map();
2059
+ const getRow = (eventId) => rowByEventId.get(eventId) ?? fetchEventRow(db, eventId, ancestorCache);
2060
+ const promptMemo = new Map();
2061
+ for (const entry of page) {
2062
+ entry.nearestPromptId = memoizedNearestPromptId(entry.eventId, getRow, promptMemo);
2063
+ }
2064
+ joinDispatchRuns(db, page);
2065
+ joinGateCounts(page);
2066
+ joinSessionTitles(db, page);
2067
+ return page;
2068
+ }
2069
+ /**
2070
+ * DS6 PR4b (§4.1 1b) — fills `sessionTitle` on every entry in `page`, one
2071
+ * grouped lookup (`sessionTitles()`, the same logic `runningSessions()` uses
2072
+ * for its "Now running" cards) scoped to only the session ids the page
2073
+ * actually carries, rather than one query per row or an unscoped scan of
2074
+ * every session this factory has ever run.
2075
+ */
2076
+ function joinSessionTitles(db, page) {
2077
+ const sessionIds = [...new Set(page.map((e) => e.sessionId))];
2078
+ if (sessionIds.length === 0)
2079
+ return;
2080
+ const titles = sessionTitles(db, { sessionIds });
2081
+ for (const entry of page) {
2082
+ entry.sessionTitle = titles.get(entry.sessionId) ?? entry.sessionId;
2083
+ }
2084
+ }
2085
+ /**
2086
+ * DS6 PR2 (§4.3 table) — fills `run` on every `Dispatched` entry in `page`,
2087
+ * batched for the page only (same cost discipline as `nearestPromptId`
2088
+ * above): one `agents` query keyed on the page's own dispatch event ids,
2089
+ * then one `eventsRaw` query for the terminal `task_run_result` rows those
2090
+ * `agents` rows name, rather than one query per row.
2091
+ *
2092
+ * `runStatus` is the task-result payload's `run_status` when present, else the
2093
+ * agent's own `status` once it is no longer `live` (done / superseded / error /
2094
+ * abandoned, so steps ended by judge-reported, error-logged, epic-closed etc.
2095
+ * read as finished), else null (truly live). Tokens and duration still come
2096
+ * only from a task-result payload.
2097
+ */
2098
+ function joinDispatchRuns(db, page) {
2099
+ const dispatchIds = page.filter((e) => e.kind === 'Dispatched').map((e) => e.eventId);
2100
+ if (dispatchIds.length === 0)
2101
+ return;
2102
+ const agentRows = db.select().from(agents).where(inArray(agents.id, dispatchIds)).all();
2103
+ const agentByDispatchId = new Map(agentRows.map((a) => [a.id, a]));
2104
+ const terminalEventIds = agentRows
2105
+ .map((a) => a.terminalEventId)
2106
+ .filter((id) => id !== null);
2107
+ const resultRows = terminalEventIds.length === 0
2108
+ ? []
2109
+ : db
2110
+ .select({ eventId: eventsRaw.eventId, payload: eventsRaw.payload })
2111
+ .from(eventsRaw)
2112
+ .where(and(inArray(eventsRaw.eventId, terminalEventIds), eq(eventsRaw.eventType, TASK_RESULT_EVENT_TYPE)))
2113
+ .all();
2114
+ const resultPayloadByEventId = new Map(resultRows.map((r) => [r.eventId, JSON.parse(r.payload)]));
2115
+ for (const entry of page) {
2116
+ if (entry.kind !== 'Dispatched')
2117
+ continue;
2118
+ const agent = agentByDispatchId.get(entry.eventId);
2119
+ if (!agent)
2120
+ continue;
2121
+ const resultPayload = agent.terminalEventId
2122
+ ? resultPayloadByEventId.get(agent.terminalEventId)
2123
+ : undefined;
2124
+ const usage = readTokenUsage(resultPayload?.token_usage);
2125
+ entry.run = {
2126
+ tokensIn: usage.measured ? usage.input : null,
2127
+ tokensOut: usage.measured ? usage.output : null,
2128
+ durationMs: durationMsFromPayload(resultPayload ?? {}),
2129
+ runStatus: typeof resultPayload?.run_status === 'string'
2130
+ ? resultPayload.run_status
2131
+ : agent.status !== 'live'
2132
+ ? agent.status
2133
+ : null,
2134
+ dispatchedAt: agent.dispatchedAt,
2135
+ round: agent.round,
2136
+ };
2137
+ }
2138
+ }
2139
+ /**
2140
+ * DS6 PR2 (§4.3 table) — normalised `{passed, failed}` counts for every
2141
+ * `Gate` entry in `page`, from `testgate-result.results` (or an artifact check's `checked`/`issues`, or
2142
+ * `gate-outcome.results`, if a future writer adds one). `null` ("not
2143
+ * measured") only where counts are expected and missing: an artifact check
2144
+ * with neither field, or a `testgate-result` with no `results` array. Every
2145
+ * other check type without a `results` array never carries counts, so its
2146
+ * `gateCounts` stays unset and the UI leaves the item out. Pure over
2147
+ * already-fetched payloads, so no extra query is needed.
2148
+ */
2149
+ function joinGateCounts(page) {
2150
+ for (const entry of page) {
2151
+ if (entry.kind !== 'Gate')
2152
+ continue;
2153
+ if (entry.eventType === 'artifact-check-result') {
2154
+ // Not a results array: { ok, checked, issues }.
2155
+ const { ok, checked, issues } = entry.payload;
2156
+ if (typeof checked !== 'number' && !Array.isArray(issues)) {
2157
+ entry.gateCounts = null;
2158
+ continue;
2159
+ }
2160
+ const failed = Math.max(Array.isArray(issues) ? issues.length : 0, ok === false ? 1 : 0);
2161
+ const total = typeof checked === 'number' ? checked : 0;
2162
+ entry.gateCounts = { passed: Math.max(0, total - failed), failed };
2163
+ continue;
2164
+ }
2165
+ const results = entry.payload.results;
2166
+ if (!Array.isArray(results)) {
2167
+ if (entry.eventType === 'testgate-result')
2168
+ entry.gateCounts = null;
2169
+ continue;
2170
+ }
2171
+ let passed = 0;
2172
+ let failed = 0;
2173
+ for (const r of results) {
2174
+ if (r && typeof r === 'object' && r.pass === true)
2175
+ passed += 1;
2176
+ else if (r && typeof r === 'object' && r.pass === false)
2177
+ failed += 1;
2178
+ }
2179
+ entry.gateCounts = { passed, failed };
2180
+ }
2181
+ }
2182
+ /** DS6 PR2 — a run's `duration_ms`, or null when the payload carries none (only some results carry it). */
2183
+ function durationMsFromPayload(payload) {
2184
+ return typeof payload.duration_ms === 'number' ? payload.duration_ms : null;
2185
+ }
2186
+ /**
2187
+ * Resolves `before`/`after` to the row's own `{ts, eventId}` so paging can
2188
+ * compare it with `compareLogOrder` — the SAME ordering the rest of the log
2189
+ * uses, never a string compare on the id (`<session>#<index>` ids are not
2190
+ * orderable across sessions; `#9` would sort after `#10` as text).
2191
+ */
2192
+ function resolveCursor(db, eventId) {
2193
+ const row = db.select().from(eventsRaw).where(eq(eventsRaw.eventId, eventId)).get();
2194
+ if (!row) {
2195
+ throw new SmithError('timeline.unknown-cursor', `No event "${eventId}" to page from.`, {
2196
+ eventId,
2197
+ });
2198
+ }
2199
+ return { ts: row.ts, eventId: row.eventId };
2200
+ }
2201
+ /**
2202
+ * DS6 pattern 5 (paginated "Load older" + infinite scroll). A no-op unless
2203
+ * `limit`, `before` or `after` is set — the legacy oldest-first, unbounded
2204
+ * shape stays exactly as it was for every existing caller.
2205
+ *
2206
+ * Paged mode is always newest-first. `before` keeps rows strictly older than
2207
+ * the cursor (the "Load older" page). `after` keeps rows strictly newer; with
2208
+ * `limit` that means the OLDEST `limit` of them — the page adjacent to the
2209
+ * cursor on the newer side, read oldest-first then flipped to the feed's
2210
+ * newest-first order — rather than the newest `limit`, which would skip rows
2211
+ * between the cursor and the page it returned.
2212
+ */
2213
+ function paginate(db, entries, filter) {
2214
+ if (filter.limit === undefined && filter.before === undefined && filter.after === undefined) {
2215
+ return entries;
2216
+ }
2217
+ let page = entries; // still oldest-first here
2218
+ if (filter.before !== undefined) {
2219
+ const cursor = resolveCursor(db, filter.before);
2220
+ page = page.filter((e) => compareLogOrder(e, cursor) < 0);
2221
+ }
2222
+ if (filter.after !== undefined) {
2223
+ const cursor = resolveCursor(db, filter.after);
2224
+ page = page.filter((e) => compareLogOrder(e, cursor) > 0);
2225
+ }
2226
+ if (filter.limit !== undefined) {
2227
+ page =
2228
+ filter.after !== undefined
2229
+ ? page.slice(0, filter.limit)
2230
+ : page.slice(Math.max(0, page.length - filter.limit));
2231
+ }
2232
+ return page.slice().reverse();
1217
2233
  }
1218
2234
  function worstSeverity(severities) {
1219
2235
  let worst = null;
@@ -1236,6 +2252,160 @@ function worstSeverity(severities) {
1236
2252
  * task_status, tagged with case/origin/worst-open-finding severity plus
1237
2253
  * (Phase 6b) title/agent-role/milestone.
1238
2254
  */
2255
+ function firstLineOf(text) {
2256
+ return (text.split('\n')[0] ?? '').trim();
2257
+ }
2258
+ function humanizeEpicId(epicId) {
2259
+ const spaced = epicId.replace(/-/g, ' ');
2260
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
2261
+ }
2262
+ /** DS3 — "<project>: <humanized epic id>[ (finished)]" (audit Kanban-4). */
2263
+ function epicLabelFor(epicId, project, closedEpicIds) {
2264
+ if (!epicId)
2265
+ return null;
2266
+ const label = `${projectOf(project)}: ${humanizeEpicId(epicId)}`;
2267
+ return closedEpicIds.has(epicId) ? `${label} (finished)` : label;
2268
+ }
2269
+ /** The epic a `<epic>/integration` task-ref belongs to, or null for any other ref. */
2270
+ function epicIdOfIntegrationRef(taskRef) {
2271
+ return taskRef.endsWith('/integration') ? taskRef.slice(0, -'/integration'.length) : null;
2272
+ }
2273
+ /**
2274
+ * DS4 S5b — every epic's latest `integration-pr-opened` URL, keyed by
2275
+ * epic id. Extracted out of `kanban()` (where this lived inline) so
2276
+ * `roadmapPage()` can share the exact same read instead of keeping a second
2277
+ * copy; `inLogOrder` means a later row for the same epic overwrites an
2278
+ * earlier one, so the map always holds the latest PR.
2279
+ */
2280
+ function prUrlsByEpic(db) {
2281
+ const integrationRows = inLogOrder(db.select().from(eventsRaw).where(eq(eventsRaw.eventType, 'integration-pr-opened')).all());
2282
+ const result = new Map();
2283
+ for (const r of integrationRows) {
2284
+ const epicRef = r.taskId ? epicIdOfIntegrationRef(r.taskId) : null;
2285
+ if (!epicRef)
2286
+ continue;
2287
+ const payload = JSON.parse(r.payload);
2288
+ if (typeof payload.pr_url === 'string')
2289
+ result.set(epicRef, payload.pr_url);
2290
+ }
2291
+ return result;
2292
+ }
2293
+ /**
2294
+ * Cheap nearest-user_prompt-id resolver for `timeline()`'s full-table path:
2295
+ * the nearest prompt of X is X itself when X is a `user_prompt`, otherwise
2296
+ * the nearest prompt of X's causal parent. `nearestUserPrompt`/`causalChain`
2297
+ * additionally require a matching row in the `prompts` table, but
2298
+ * `projector.ts` inserts that row for EVERY `user_prompt` event in the same
2299
+ * transaction it inserts `events_raw` from, so that check can never miss in
2300
+ * practice and the eventType check alone is equivalent here.
2301
+ *
2302
+ * `causalChain`'s first-hop session gate (the walk refuses to start outside
2303
+ * the session it is asked for) is also moot against this function: every
2304
+ * caller feeds it a row's own `sessionId` as the walk's session (44ed474's
2305
+ * `nearestUserPrompt(db, row.sessionId, row.eventId, ...)`, now this
2306
+ * function's per-row callers in `timeline()`), so the first hop always names
2307
+ * a row inside that very session and the gate never fires either way. Later
2308
+ * hops were never gated in `causalChain` to begin with, so a cross-session
2309
+ * ancestor is reachable from both, identically (parity test:
2310
+ * `timelinePaging.test.ts`'s session-boundary case).
2311
+ *
2312
+ * Walks iteratively (no recursion — a session can chain thousands deep) and
2313
+ * memoises every eventId it visits along the way, not just the one asked
2314
+ * about, so a page of N rows with shared ancestors costs about one row
2315
+ * lookup per DISTINCT event in play rather than one causal walk per row.
2316
+ */
2317
+ function memoizedNearestPromptId(startId, getRow, memo) {
2318
+ const path = [];
2319
+ const visiting = new Set();
2320
+ let current = startId;
2321
+ let result = null;
2322
+ while (current !== null) {
2323
+ const cached = memo.get(current);
2324
+ if (cached !== undefined) {
2325
+ result = cached;
2326
+ break;
2327
+ }
2328
+ if (visiting.has(current)) {
2329
+ result = null;
2330
+ break;
2331
+ }
2332
+ visiting.add(current);
2333
+ const row = getRow(current);
2334
+ if (!row) {
2335
+ memo.set(current, null);
2336
+ result = null;
2337
+ break;
2338
+ }
2339
+ if (row.eventType === 'user_prompt') {
2340
+ memo.set(current, current);
2341
+ result = current;
2342
+ break;
2343
+ }
2344
+ path.push(current);
2345
+ current = row.causalParent;
2346
+ }
2347
+ for (const id of path)
2348
+ memo.set(id, result);
2349
+ return result;
2350
+ }
2351
+ function nearestUserPrompt(db, sessionId, eventId, eventCache) {
2352
+ const chain = causalChain(db, sessionId, eventId, eventCache);
2353
+ for (let i = chain.length - 1; i >= 0; i--) {
2354
+ const entry = chain[i];
2355
+ if (entry?.eventType !== 'user_prompt')
2356
+ continue;
2357
+ const row = db.select().from(prompts).where(eq(prompts.eventId, entry.eventId)).get();
2358
+ if (row)
2359
+ return { prompt: row.prompt, ts: row.ts, eventId: row.eventId, source: 'task' };
2360
+ }
2361
+ return null;
2362
+ }
2363
+ export function createQuoteMemo() {
2364
+ return { events: new Map(), epicPrompt: new Map() };
2365
+ }
2366
+ /**
2367
+ * "Epic started from" fallback (§4.7): the earliest `user_prompt` in the
2368
+ * epic's session lineage. Exported (DS4 S5b) so `milestoneProgressRows()`
2369
+ * can reuse it for `epics[].sourcePrompt` — same shape `requestQuoteForTask()`
2370
+ * returns, so `RequestQuote.vue` renders it unchanged.
2371
+ */
2372
+ export function epicSourcePrompt(db, anySessionIdInEpic) {
2373
+ const lineage = projectedLineage(db, anySessionIdInEpic);
2374
+ if (lineage.length === 0)
2375
+ return null;
2376
+ const rows = inLogOrder(db.select().from(prompts).where(inArray(prompts.sessionId, lineage)).all());
2377
+ const earliest = rows[0];
2378
+ return earliest
2379
+ ? { prompt: earliest.prompt, ts: earliest.ts, eventId: earliest.eventId, source: 'epic' }
2380
+ : null;
2381
+ }
2382
+ /**
2383
+ * DS3 §4.7 — the operator prompt that led to `taskId`: a bounded, cycle-safe
2384
+ * walk up `causal_parent` from the task's own events to the nearest
2385
+ * `user_prompt` (reuses `causalChain`'s `seen` guard), falling back to the
2386
+ * epic's own source prompt when the task's own walk finds none.
2387
+ */
2388
+ export function requestQuoteForTask(db, taskId, taskSessionId, memo) {
2389
+ const firstEvent = inLogOrder(db.select().from(eventsRaw).where(eq(eventsRaw.taskId, taskId)).all())[0];
2390
+ if (firstEvent) {
2391
+ const taskQuote = nearestUserPrompt(db, firstEvent.sessionId, firstEvent.eventId, memo?.events);
2392
+ if (taskQuote)
2393
+ return taskQuote;
2394
+ }
2395
+ // Two tasks named with the same epic share one `epicSourcePrompt` answer
2396
+ // (same lineage walk, same earliest prompt), so the fallback is cached per
2397
+ // epic rather than per task or per session.
2398
+ const epicId = epicOfTaskId(taskId);
2399
+ if (epicId !== null) {
2400
+ const cached = memo?.epicPrompt.get(epicId);
2401
+ if (cached !== undefined)
2402
+ return cached;
2403
+ }
2404
+ const quote = epicSourcePrompt(db, taskSessionId);
2405
+ if (epicId !== null)
2406
+ memo?.epicPrompt.set(epicId, quote);
2407
+ return quote;
2408
+ }
1239
2409
  export function kanban(db, epicId, scope = {}, opts = {}) {
1240
2410
  const nowIso = opts.nowIso ?? new Date().toISOString();
1241
2411
  const epicCond = epicId !== undefined ? eq(tasks.epicId, epicId) : undefined;
@@ -1247,7 +2417,7 @@ export function kanban(db, epicId, scope = {}, opts = {}) {
1247
2417
  .from(tasks)
1248
2418
  .where(and(...taskConds))
1249
2419
  .all()
1250
- : db.select().from(tasks).all(), scope);
2420
+ : db.select().from(tasks).all(), scope).filter((t) => !isAuditAxisRowId(t.taskId));
1251
2421
  const epicIdsInScope = new Set(taskRows.map((t) => t.epicId).filter((e) => e !== null));
1252
2422
  const findingCond = epicId !== undefined ? eq(findings.epicId, epicId) : undefined;
1253
2423
  const findingRows = findingCond
@@ -1309,9 +2479,86 @@ export function kanban(db, epicId, scope = {}, opts = {}) {
1309
2479
  for (const e of JSON.parse(m.epicIds))
1310
2480
  milestoneByEpic.set(e, m.milestoneId);
1311
2481
  }
2482
+ // DS3 item 2 — attempt count, per task_id, from the same dispatch rows above.
2483
+ const attemptCountByTask = new Map();
2484
+ for (const d of dispatchRows) {
2485
+ if (!d.taskId)
2486
+ continue;
2487
+ attemptCountByTask.set(d.taskId, (attemptCountByTask.get(d.taskId) ?? 0) + 1);
2488
+ }
2489
+ // DS3 item 2 — highest judge-role dispatch round per task_id, from the agents fold.
2490
+ const judgeRoleSet = new Set(JUDGE_ROLES);
2491
+ const agentSessionCond = scopedToSessions(agents.sessionId, scope);
2492
+ const agentRows = agentSessionCond
2493
+ ? db.select().from(agents).where(agentSessionCond).all()
2494
+ : db.select().from(agents).all();
2495
+ const judgeRoundByTask = new Map();
2496
+ for (const a of agentRows) {
2497
+ if (!a.taskId || !judgeRoleSet.has(a.agentRole))
2498
+ continue;
2499
+ const current = judgeRoundByTask.get(a.taskId) ?? 0;
2500
+ if (a.round > current)
2501
+ judgeRoundByTask.set(a.taskId, a.round);
2502
+ }
2503
+ // DS3 item 2 — comment count per task_id, from operator_feedback.
2504
+ const feedbackSessionCond = scopedToSessions(operatorFeedback.sessionId, scope);
2505
+ const feedbackRows = feedbackSessionCond
2506
+ ? db.select().from(operatorFeedback).where(feedbackSessionCond).all()
2507
+ : db.select().from(operatorFeedback).all();
2508
+ const commentCountByTask = new Map();
2509
+ for (const f of feedbackRows) {
2510
+ commentCountByTask.set(f.taskId, (commentCountByTask.get(f.taskId) ?? 0) + 1);
2511
+ }
2512
+ // DS3 item 2 — epic-level integration PR url, keyed by epic_id (there is no
2513
+ // per-task PR concept today, only the epic's own integration-pr-opened).
2514
+ // DS4 S5b — extracted to prUrlsByEpic() so roadmapPage() shares this exact
2515
+ // read instead of keeping a second copy.
2516
+ const prUrlByEpic = prUrlsByEpic(db);
2517
+ // DS3 item 2 — this task's dependency edges, resolved against the task rows
2518
+ // already in scope; a dependsOn id outside scope resolves to a null title/status.
2519
+ const taskRowByTaskId = new Map(taskRows.map((t) => [t.taskId, t]));
2520
+ const edgeSessionCond = scopedToSessions(edges.sessionId, scope);
2521
+ const edgeRows = edgeSessionCond
2522
+ ? db.select().from(edges).where(edgeSessionCond).all()
2523
+ : db.select().from(edges).all();
2524
+ const edgesByTask = new Map();
2525
+ for (const e of edgeRows) {
2526
+ const list = edgesByTask.get(e.taskId) ?? [];
2527
+ list.push(e);
2528
+ edgesByTask.set(e.taskId, list);
2529
+ }
2530
+ // DS3 item 2 — epic friendly label; closed epics (epics table holds only those) get " (finished)".
2531
+ const closedEpicIds = new Set(db
2532
+ .select({ epicId: epics.epicId })
2533
+ .from(epics)
2534
+ .all()
2535
+ .map((e) => e.epicId));
2536
+ // DS3 S3 fix — one shared memo for every task's request-quote walk in this
2537
+ // call: bounds the N+1 causal walk/epic-fallback cost that scaled with the
2538
+ // task count (see QuoteMemo's doc comment).
2539
+ const quoteMemo = createQuoteMemo();
2540
+ // A follow-up's origin lives only on the `finding-reattributed` event that
2541
+ // follows its `task-added`; one read of those events, keyed by the follow-up.
2542
+ const parentByFollowUp = new Map();
2543
+ for (const e of db
2544
+ .select({ payload: eventsRaw.payload })
2545
+ .from(eventsRaw)
2546
+ .where(eq(eventsRaw.eventType, 'finding-reattributed'))
2547
+ .orderBy(sql `rowid`)
2548
+ .all()) {
2549
+ const p = JSON.parse(e.payload);
2550
+ if (p.attribution === 'follow-up' &&
2551
+ typeof p.from_task_id === 'string' &&
2552
+ typeof p.to_task_id === 'string' &&
2553
+ !parentByFollowUp.has(p.to_task_id)) {
2554
+ // First event wins: the origin never moves once it is recorded.
2555
+ parentByFollowUp.set(p.to_task_id, p.from_task_id);
2556
+ }
2557
+ }
1312
2558
  const columns = new Map();
1313
2559
  for (const t of taskRows) {
1314
2560
  const column = columns.get(t.taskStatus) ?? [];
2561
+ const quote = requestQuoteForTask(db, t.taskId, t.sessionId, quoteMemo);
1315
2562
  column.push({
1316
2563
  taskId: t.taskId,
1317
2564
  taskStatus: t.taskStatus,
@@ -1325,19 +2572,74 @@ export function kanban(db, epicId, scope = {}, opts = {}) {
1325
2572
  origin: t.origin,
1326
2573
  severity: worstSeverity(openSeverityByTask.get(t.taskId) ?? []),
1327
2574
  },
2575
+ updatedAt: t.updatedAt,
2576
+ project: t.project,
2577
+ attemptCount: attemptCountByTask.get(t.taskId) ?? 0,
2578
+ judgeRound: judgeRoundByTask.get(t.taskId) ?? null,
2579
+ commentCount: commentCountByTask.get(t.taskId) ?? 0,
2580
+ prUrl: t.epicId ? (prUrlByEpic.get(t.epicId) ?? null) : null,
2581
+ dependencies: (edgesByTask.get(t.taskId) ?? []).map((e) => {
2582
+ const dep = taskRowByTaskId.get(e.dependsOn);
2583
+ return {
2584
+ taskId: e.dependsOn,
2585
+ title: dep?.objective ?? null,
2586
+ status: dep?.taskStatus ?? null,
2587
+ edgeType: e.edgeType,
2588
+ };
2589
+ }),
2590
+ epicLabel: epicLabelFor(t.epicId, t.project, closedEpicIds),
2591
+ hasRequest: quote !== null,
2592
+ requestFirstLine: quote ? firstLineOf(quote.prompt) : null,
2593
+ parentTaskId: parentByFollowUp.get(t.taskId) ?? null,
2594
+ parentTitle: taskRowByTaskId.get(parentByFollowUp.get(t.taskId) ?? '')?.objective ?? null,
1328
2595
  });
1329
2596
  columns.set(t.taskStatus, column);
1330
2597
  }
2598
+ // DS3 item 3 — deterministic order within a column: most recently updated
2599
+ // first, task_id as the tiebreaker so two same-instant rows never flap.
1331
2600
  return [...columns.entries()]
1332
2601
  .sort(([a], [b]) => a.localeCompare(b))
1333
- .map(([taskStatus, tasksInColumn]) => ({ taskStatus, tasks: tasksInColumn }));
2602
+ .map(([taskStatus, tasksInColumn]) => ({
2603
+ taskStatus,
2604
+ tasks: [...tasksInColumn].sort((a, b) => {
2605
+ if (a.updatedAt !== b.updatedAt)
2606
+ return a.updatedAt < b.updatedAt ? 1 : -1;
2607
+ return a.taskId < b.taskId ? -1 : a.taskId > b.taskId ? 1 : 0;
2608
+ }),
2609
+ }));
1334
2610
  }
1335
- export function taskDetail(db, taskId) {
2611
+ /**
2612
+ * The per-event position of an artifact row, parsed off its own id
2613
+ * (`${eventId}#${index}`, projector.ts). Splitting on the LAST `#` keeps an
2614
+ * event id that itself contains one (every event id does: `<session>#<n>`)
2615
+ * from being cut in the wrong place.
2616
+ */
2617
+ function artifactIndexOf(id) {
2618
+ const cut = id.lastIndexOf('#');
2619
+ const index = Number(id.slice(cut + 1));
2620
+ return cut === -1 || Number.isNaN(index) ? 0 : index;
2621
+ }
2622
+ export function taskDetail(db, taskId, opts = {}) {
2623
+ const nowIso = opts.nowIso ?? new Date().toISOString();
1336
2624
  const task = db.select().from(tasks).where(eq(tasks.taskId, taskId)).get();
1337
2625
  if (!task)
1338
2626
  return null;
1339
2627
  const dispatchRows = inLogOrder(db.select().from(dispatches).where(eq(dispatches.taskId, taskId)).all());
1340
2628
  const agentRows = db.select().from(agents).where(eq(agents.taskId, taskId)).all();
2629
+ // Mirrors kanban()'s activityByTask: a live agents row dispatched inside
2630
+ // the stale window is "working"; a live row past it is "stalled"; a
2631
+ // working row wins over a stalled one on the same task.
2632
+ let agentActivity = null;
2633
+ for (const a of agentRows) {
2634
+ if (a.status !== 'live')
2635
+ continue;
2636
+ if (isWorkingAt(a.dispatchedAt, nowIso)) {
2637
+ agentActivity = 'working';
2638
+ break;
2639
+ }
2640
+ if (agentActivity === null)
2641
+ agentActivity = 'stalled';
2642
+ }
1341
2643
  const agentByEventId = new Map(agentRows.map((a) => [a.id, a]));
1342
2644
  const attempts = dispatchRows.map((d) => {
1343
2645
  const agent = agentByEventId.get(d.eventId);
@@ -1352,7 +2654,28 @@ export function taskDetail(db, taskId) {
1352
2654
  };
1353
2655
  });
1354
2656
  const findingRows = db.select().from(findings).where(eq(findings.taskId, taskId)).all();
1355
- const artifactRows = db.select().from(artifacts).where(eq(artifacts.taskId, taskId)).all();
2657
+ // Outputs tab, newest first (same operator request as taskRuns() above):
2658
+ // newest event first (`compareLogOrder`, the same log-order comparator the
2659
+ // rest of the log uses — plain `id desc` breaks because the index on
2660
+ // `${eventId}#${index}` is unpadded text, so `#10` sorts between `#1` and
2661
+ // `#2`), and within one event, the order the agent listed the artifacts in
2662
+ // (index ascending).
2663
+ const artifactRows = db
2664
+ .select()
2665
+ .from(artifacts)
2666
+ .where(eq(artifacts.taskId, taskId))
2667
+ .all()
2668
+ .sort((a, b) => {
2669
+ const logCmp = compareLogOrder(b, a);
2670
+ if (logCmp !== 0)
2671
+ return logCmp;
2672
+ return artifactIndexOf(a.id) - artifactIndexOf(b.id);
2673
+ });
2674
+ const feedbackRows = db
2675
+ .select()
2676
+ .from(operatorFeedback)
2677
+ .where(eq(operatorFeedback.taskId, taskId))
2678
+ .all();
1356
2679
  return {
1357
2680
  task,
1358
2681
  claims: task.claims ? JSON.parse(task.claims) : [],
@@ -1360,9 +2683,251 @@ export function taskDetail(db, taskId) {
1360
2683
  agents: agentRows,
1361
2684
  findings: findingRows,
1362
2685
  artifacts: artifactRows,
2686
+ feedback: feedbackRows,
1363
2687
  branch: task.branch,
2688
+ requestQuote: requestQuoteForTask(db, task.taskId, task.sessionId),
2689
+ agentActivity,
2690
+ };
2691
+ }
2692
+ // ---------------------------------------------------------------------------
2693
+ // taskRuns()
2694
+ // ---------------------------------------------------------------------------
2695
+ const RUN_EVENT_TYPES = [
2696
+ DISPATCH_EVENT_TYPE,
2697
+ JUDGE_REPORT_EVENT_TYPE,
2698
+ TASK_RESULT_EVENT_TYPE,
2699
+ ERROR_EVENT_TYPE,
2700
+ JUDGE_VERDICT_EVENT_TYPE,
2701
+ ];
2702
+ const RUN_KIND_BY_EVENT_TYPE = {
2703
+ [DISPATCH_EVENT_TYPE]: 'dispatch',
2704
+ [JUDGE_REPORT_EVENT_TYPE]: 'judge-report',
2705
+ [TASK_RESULT_EVENT_TYPE]: 'result',
2706
+ [ERROR_EVENT_TYPE]: 'error',
2707
+ [JUDGE_VERDICT_EVENT_TYPE]: 'judge-verdict',
2708
+ };
2709
+ function tokensTotalFromPayload(payload) {
2710
+ const usage = readTokenUsage(payload.token_usage);
2711
+ return usage.measured ? usage.total : null;
2712
+ }
2713
+ function outcomeFromPayload(eventType, payload) {
2714
+ if (eventType === TASK_RESULT_EVENT_TYPE && typeof payload.run_status === 'string')
2715
+ return payload.run_status;
2716
+ if (eventType === ERROR_EVENT_TYPE && typeof payload.error === 'string')
2717
+ return payload.error;
2718
+ if (eventType === JUDGE_REPORT_EVENT_TYPE) {
2719
+ // judge-reported carries no accept/dismiss verdict of its own (that is a
2720
+ // separate judge-verdict event, handled below) — the closest the report
2721
+ // payload has to an outcome is whether it attested "no findings" rather
2722
+ // than naming a finding count.
2723
+ if (payload.attested_by !== undefined && payload.attested_by !== null)
2724
+ return 'no-findings';
2725
+ if (typeof payload.finding_count === 'number')
2726
+ return `${payload.finding_count}-findings`;
2727
+ }
2728
+ if (eventType === JUDGE_VERDICT_EVENT_TYPE) {
2729
+ // A verdict that never ran reads like the Activity feed's title: `failed`,
2730
+ // with the error code when the payload names one.
2731
+ if (payload.ok === false)
2732
+ return typeof payload.error_code === 'string' && payload.error_code !== ''
2733
+ ? `failed: ${payload.error_code}`
2734
+ : 'failed';
2735
+ if (typeof payload.verdict === 'string')
2736
+ return payload.verdict;
2737
+ }
2738
+ return null;
2739
+ }
2740
+ function fetchTaskRunRows(db, taskId) {
2741
+ return inLogOrder(db
2742
+ .select()
2743
+ .from(eventsRaw)
2744
+ .where(and(eq(eventsRaw.taskId, taskId), inArray(eventsRaw.eventType, [...RUN_EVENT_TYPES])))
2745
+ .all());
2746
+ }
2747
+ export function taskRuns(db, taskId) {
2748
+ // `fetchTaskRunRows` stays oldest-first (shared with `taskTotals()`, which
2749
+ // reads "first dispatch" / "last result" off that order); newest-first is
2750
+ // this function's own output shape, so the rows are reversed here, once.
2751
+ const rows = fetchTaskRunRows(db, taskId).slice().reverse();
2752
+ // A judge-verdict names neither role nor (mostly) round; the agents row it
2753
+ // terminated does. One query for the page.
2754
+ const verdictIds = rows
2755
+ .filter((r) => r.eventType === JUDGE_VERDICT_EVENT_TYPE)
2756
+ .map((r) => r.eventId);
2757
+ const agentByTerminal = new Map(verdictIds.length === 0
2758
+ ? []
2759
+ : db
2760
+ .select({ t: agents.terminalEventId, role: agents.agentRole, round: agents.round })
2761
+ .from(agents)
2762
+ .where(inArray(agents.terminalEventId, verdictIds))
2763
+ .all()
2764
+ .map((a) => [a.t, a]));
2765
+ return rows.flatMap((r) => {
2766
+ const kind = RUN_KIND_BY_EVENT_TYPE[r.eventType];
2767
+ if (!kind)
2768
+ return [];
2769
+ const payload = JSON.parse(r.payload);
2770
+ const agentRow = agentByTerminal.get(r.eventId);
2771
+ const round = typeof payload.round === 'number' ? payload.round : (agentRow?.round ?? null);
2772
+ return [
2773
+ {
2774
+ eventId: r.eventId,
2775
+ ts: r.ts,
2776
+ kind,
2777
+ agentRole: typeof payload.agent_role === 'string' ? payload.agent_role : (agentRow?.role ?? null),
2778
+ round: round ?? (r.eventType === DISPATCH_EVENT_TYPE ? 1 : null),
2779
+ tokensTotal: tokensTotalFromPayload(payload),
2780
+ outcome: outcomeFromPayload(r.eventType, payload),
2781
+ },
2782
+ ];
2783
+ });
2784
+ }
2785
+ /**
2786
+ * DS3 §2.4c — the per-task totals read above `RunHistoryTimeline`, additive
2787
+ * on `GET /api/tasks/:taskId/runs`. Scoped to the same rows `taskRuns()`
2788
+ * reads (no new event type, no writer); "not measured" is `null`, never a
2789
+ * bare 0 (the spec's own phrasing for this bar).
2790
+ */
2791
+ export function taskTotals(db, taskId) {
2792
+ const rows = fetchTaskRunRows(db, taskId);
2793
+ let tokensSum = 0;
2794
+ let anyTokens = false;
2795
+ let durationSum = 0;
2796
+ let anyDuration = false;
2797
+ let startTs = null;
2798
+ let endTs = null;
2799
+ for (const r of rows) {
2800
+ // A verdict is history-only: the totals never counted it, and still don't.
2801
+ if (r.eventType === JUDGE_VERDICT_EVENT_TYPE)
2802
+ continue;
2803
+ const payload = JSON.parse(r.payload);
2804
+ const tokens = tokensTotalFromPayload(payload);
2805
+ if (tokens !== null) {
2806
+ tokensSum += tokens;
2807
+ anyTokens = true;
2808
+ }
2809
+ const durationMs = durationMsFromPayload(payload);
2810
+ if (durationMs !== null) {
2811
+ durationSum += durationMs;
2812
+ anyDuration = true;
2813
+ }
2814
+ const kind = RUN_KIND_BY_EVENT_TYPE[r.eventType];
2815
+ if (kind === 'dispatch') {
2816
+ if (startTs === null || r.ts < startTs)
2817
+ startTs = r.ts;
2818
+ }
2819
+ if (kind === 'result' || kind === 'error') {
2820
+ if (endTs === null || r.ts > endTs)
2821
+ endTs = r.ts;
2822
+ }
2823
+ }
2824
+ return {
2825
+ tokens: anyTokens ? tokensSum : null,
2826
+ agentTimeMs: anyDuration ? durationSum : null,
2827
+ elapsedMs: startTs !== null && endTs !== null
2828
+ ? new Date(endTs).getTime() - new Date(startTs).getTime()
2829
+ : null,
2830
+ startedAt: startTs,
2831
+ endedAt: endTs,
1364
2832
  };
1365
2833
  }
2834
+ /** `tokensTotalFromPayload` reads `total_tokens` only; this reads the full measured triple. */
2835
+ function tokenUsageFromPayload(payload) {
2836
+ const usage = readTokenUsage(payload.token_usage);
2837
+ if (usage.measured && usage.input !== null && usage.output !== null) {
2838
+ return { state: 'measured', input: usage.input, output: usage.output, total: usage.total };
2839
+ }
2840
+ return { state: 'unmeasured' };
2841
+ }
2842
+ /**
2843
+ * Projection read only (no new writer): every agent `sessionId` has
2844
+ * dispatched, grouped by role in first-dispatch order. An unknown session
2845
+ * returns `{ roles: [] }` — the route turns that into its own 404, since
2846
+ * "no agents" and "no such session" read the same from here.
2847
+ */
2848
+ export function sessionAgents(db, sessionId, opts = {}) {
2849
+ const nowIso = opts.nowIso ?? new Date().toISOString();
2850
+ const agentRows = inLogOrder(db
2851
+ .select()
2852
+ .from(agents)
2853
+ .where(eq(agents.sessionId, sessionId))
2854
+ .all()
2855
+ .map((a) => ({ ...a, ts: a.dispatchedAt, eventId: a.id })));
2856
+ if (agentRows.length === 0)
2857
+ return { sessionId, roles: [] };
2858
+ const taskIds = [...new Set(agentRows.flatMap((a) => (a.taskId ? [a.taskId] : [])))];
2859
+ const taskTitleById = new Map(taskIds.length > 0
2860
+ ? db
2861
+ .select({ taskId: tasks.taskId, title: tasks.title })
2862
+ .from(tasks)
2863
+ .where(inArray(tasks.taskId, taskIds))
2864
+ .all()
2865
+ .map((t) => [t.taskId, t.title])
2866
+ : []);
2867
+ const eventRows = db.select().from(eventsRaw).where(eq(eventsRaw.sessionId, sessionId)).all();
2868
+ const terminalPayloadById = new Map(eventRows.map((e) => [e.eventId, e]));
2869
+ const roleOrder = [];
2870
+ const byRole = new Map();
2871
+ for (const a of agentRows) {
2872
+ const tokens = a.status === 'live'
2873
+ ? { state: 'pending' }
2874
+ : a.status === 'done'
2875
+ ? (() => {
2876
+ const terminalEvent = a.terminalEventId
2877
+ ? terminalPayloadById.get(a.terminalEventId)
2878
+ : undefined;
2879
+ return terminalEvent
2880
+ ? tokenUsageFromPayload(JSON.parse(terminalEvent.payload))
2881
+ : { state: 'unmeasured' };
2882
+ })()
2883
+ : { state: 'none' };
2884
+ const windowEnd = a.terminalAt ?? nowIso;
2885
+ // Scoped to this agent's own task when it has one; an epic-level dispatch
2886
+ // (no task) has no narrower column to filter on than the session itself.
2887
+ const windowEvents = eventRows.filter((e) => {
2888
+ if (e.ts < a.dispatchedAt || e.ts > windowEnd)
2889
+ return false;
2890
+ return a.taskId ? e.taskId === a.taskId : true;
2891
+ });
2892
+ const lastEvent = inLogOrder(windowEvents).at(-1) ?? null;
2893
+ const entry = {
2894
+ id: a.id,
2895
+ agentRole: a.agentRole,
2896
+ provider: a.provider,
2897
+ modelTier: a.modelTier,
2898
+ taskId: a.taskId,
2899
+ taskTitle: a.taskId ? (taskTitleById.get(a.taskId) ?? null) : null,
2900
+ epicId: a.epicId,
2901
+ round: a.round,
2902
+ dispatchedAt: a.dispatchedAt,
2903
+ terminalAt: a.terminalAt,
2904
+ terminalType: a.terminalType,
2905
+ status: a.status,
2906
+ tokens,
2907
+ lastEventType: lastEvent?.eventType ?? null,
2908
+ lastEventAt: lastEvent?.ts ?? null,
2909
+ };
2910
+ if (!byRole.has(a.agentRole)) {
2911
+ roleOrder.push(a.agentRole);
2912
+ byRole.set(a.agentRole, []);
2913
+ }
2914
+ byRole.get(a.agentRole)?.push(entry);
2915
+ }
2916
+ return {
2917
+ sessionId,
2918
+ roles: roleOrder.map((role) => ({ agentRole: role, agents: byRole.get(role) ?? [] })),
2919
+ };
2920
+ }
2921
+ /**
2922
+ * One projected artifact row by its id (`${event_id}#${index}`), for the
2923
+ * dashboard's artifact-serving route — it needs the declaring task and the
2924
+ * declared path to resolve a file, and neither is safe to take from the
2925
+ * request itself.
2926
+ */
2927
+ export function artifactById(db, artifactId) {
2928
+ return db.select().from(artifacts).where(eq(artifacts.id, artifactId)).get() ?? null;
2929
+ }
2930
+ const LESSONS_PASS_COMPLETED_EVENT_TYPE = 'lessons-pass-completed';
1366
2931
  /**
1367
2932
  * Every `lesson_status` the taxonomy declares, mapped to the bucket the
1368
2933
  * Lessons page renders it in. Total on purpose (D-220).
@@ -1388,12 +2953,46 @@ export const LESSON_BUCKET_FOR_STATUS = {
1388
2953
  superseded: 'closed',
1389
2954
  invalidated: 'closed',
1390
2955
  };
2956
+ /**
2957
+ * The owning-session lookup behind the dashboard's three lesson write routes
2958
+ * (ui/server's `lessonSession()`) — the session whose log a lesson's
2959
+ * transition has to fold and append to, not whichever session a request body
2960
+ * names. Returns `null` rather than throwing when no such lesson is
2961
+ * projected; the caller decides what a missing lesson means.
2962
+ */
2963
+ export function lessonOwnerSession(db, lessonId) {
2964
+ const row = db
2965
+ .select({ sessionId: lessons.sessionId })
2966
+ .from(lessons)
2967
+ .where(eq(lessons.lessonId, lessonId))
2968
+ .get();
2969
+ return row ? row.sessionId : null;
2970
+ }
2971
+ /** The latest `lessons-pass-completed` event's ts, scoped the same way as the rest of the page. */
2972
+ function lastLessonsPassAt(db, scope) {
2973
+ const eventCond = eq(eventsRaw.eventType, LESSONS_PASS_COMPLETED_EVENT_TYPE);
2974
+ const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
2975
+ const rows = db
2976
+ .select({ ts: eventsRaw.ts })
2977
+ .from(eventsRaw)
2978
+ .where(sessionCond ? and(eventCond, sessionCond) : eventCond)
2979
+ .all();
2980
+ const first = rows[0];
2981
+ if (!first)
2982
+ return null;
2983
+ return rows.reduce((latest, row) => (row.ts > latest ? row.ts : latest), first.ts);
2984
+ }
1391
2985
  export function lessonsPage(db, scope = {}) {
1392
2986
  const sessionCond = scopedToSessions(lessons.sessionId, scope);
1393
2987
  const rows = sessionCond
1394
2988
  ? db.select().from(lessons).where(sessionCond).all()
1395
2989
  : db.select().from(lessons).all();
1396
- const result = { pending: [], approved: [], closed: [] };
2990
+ const result = {
2991
+ pending: [],
2992
+ approved: [],
2993
+ closed: [],
2994
+ lastCheckedAt: lastLessonsPassAt(db, scope),
2995
+ };
1397
2996
  for (const row of rows) {
1398
2997
  // An unrecognised status lands in `closed` rather than nowhere: being
1399
2998
  // invisible is the defect this map exists to close, and a row the
@@ -1402,7 +3001,15 @@ export function lessonsPage(db, scope = {}) {
1402
3001
  }
1403
3002
  return result;
1404
3003
  }
1405
- export function errorsPage(db, scope = {}) {
3004
+ const TREND_WINDOW_DAYS = 7;
3005
+ /** The UTC calendar day (`YYYY-MM-DD`) `daysAgo` days before `nowIso`'s own day. */
3006
+ function utcDayOffset(nowIso, daysAgo) {
3007
+ const d = new Date(nowIso);
3008
+ d.setUTCHours(0, 0, 0, 0);
3009
+ d.setUTCDate(d.getUTCDate() - daysAgo);
3010
+ return d.toISOString().slice(0, 10);
3011
+ }
3012
+ export function errorsPage(db, scope = {}, opts = {}) {
1406
3013
  const sessionCond = scopedToSessions(errors.sessionId, scope);
1407
3014
  const allRows = sessionCond
1408
3015
  ? db.select().from(errors).where(sessionCond).all()
@@ -1410,6 +3017,10 @@ export function errorsPage(db, scope = {}) {
1410
3017
  const rows = filterByProject(allRows, scope);
1411
3018
  const byClass = new Map();
1412
3019
  const byDay = new Map();
3020
+ const nowIso = opts.nowIso ?? new Date().toISOString();
3021
+ const trendDays = Array.from({ length: TREND_WINDOW_DAYS }, (_, i) => utcDayOffset(nowIso, TREND_WINDOW_DAYS - 1 - i));
3022
+ const trendDayIndex = new Map(trendDays.map((day, i) => [day, i]));
3023
+ const classSummaries = new Map();
1413
3024
  for (const row of rows) {
1414
3025
  const key = `${row.errorGroup}.${row.errorClass}|${row.severity}`;
1415
3026
  const existing = byClass.get(key);
@@ -1426,15 +3037,62 @@ export function errorsPage(db, scope = {}) {
1426
3037
  }
1427
3038
  const day = row.ts.slice(0, 10);
1428
3039
  byDay.set(day, (byDay.get(day) ?? 0) + 1);
3040
+ const classKey = `${row.errorGroup}.${row.errorClass}`;
3041
+ let summary = classSummaries.get(classKey);
3042
+ if (!summary) {
3043
+ summary = {
3044
+ errorGroup: row.errorGroup,
3045
+ errorClass: row.errorClass,
3046
+ count: 0,
3047
+ severityMix: {},
3048
+ lastSeen: row.ts,
3049
+ projects: new Set(),
3050
+ trend7d: new Array(TREND_WINDOW_DAYS).fill(0),
3051
+ };
3052
+ classSummaries.set(classKey, summary);
3053
+ }
3054
+ summary.count += 1;
3055
+ summary.severityMix[row.severity] = (summary.severityMix[row.severity] ?? 0) + 1;
3056
+ if (row.ts > summary.lastSeen)
3057
+ summary.lastSeen = row.ts;
3058
+ if (row.project)
3059
+ summary.projects.add(row.project);
3060
+ const dayIndex = trendDayIndex.get(day);
3061
+ if (dayIndex !== undefined)
3062
+ summary.trend7d[dayIndex] = (summary.trend7d[dayIndex] ?? 0) + 1;
1429
3063
  }
1430
3064
  return {
1431
3065
  byClass: [...byClass.values()].sort((a, b) => b.count - a.count),
1432
3066
  byDay: [...byDay.entries()]
1433
3067
  .sort(([a], [b]) => a.localeCompare(b))
1434
3068
  .map(([day, count]) => ({ day, count })),
3069
+ classSummary: [...classSummaries.entries()]
3070
+ .map(([id, s]) => ({
3071
+ id,
3072
+ errorGroup: s.errorGroup,
3073
+ errorClass: s.errorClass,
3074
+ count: s.count,
3075
+ severityMix: s.severityMix,
3076
+ lastSeen: s.lastSeen,
3077
+ projects: [...s.projects],
3078
+ trend7d: s.trend7d,
3079
+ }))
3080
+ .sort((a, b) => b.count - a.count),
1435
3081
  };
1436
3082
  }
1437
- export function analytics(db, scope = {}) {
3083
+ const PERIOD_DAYS = { '7d': 7, '30d': 30, '90d': 90 };
3084
+ /** The UTC calendar days in `period`'s window ending on `nowIso`'s UTC date, inclusive, oldest-first. */
3085
+ function periodDays(period, nowIso) {
3086
+ const now = new Date(nowIso);
3087
+ const endUtcMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
3088
+ const days = PERIOD_DAYS[period];
3089
+ const out = [];
3090
+ for (let i = days - 1; i >= 0; i -= 1) {
3091
+ out.push(new Date(endUtcMs - i * 86_400_000).toISOString().slice(0, 10));
3092
+ }
3093
+ return out;
3094
+ }
3095
+ export function analytics(db, scope = {}, opts = {}) {
1438
3096
  const taskRows = allTasksForScope(db, scope);
1439
3097
  const inScope = taskInScope(db, scope);
1440
3098
  const throughputByDay = new Map();
@@ -1450,17 +3108,30 @@ export function analytics(db, scope = {}) {
1450
3108
  const eventSessionCond = scopedToSessions(eventsRaw.sessionId, scope);
1451
3109
  const resultRows = eventSessionCond
1452
3110
  ? db
1453
- .select({ taskId: eventsRaw.taskId, payload: eventsRaw.payload })
3111
+ .select({ taskId: eventsRaw.taskId, payload: eventsRaw.payload, ts: eventsRaw.ts })
1454
3112
  .from(eventsRaw)
1455
3113
  .where(and(eq(eventsRaw.eventType, 'task-result-recorded'), eventSessionCond))
1456
3114
  .all()
1457
3115
  : db
1458
- .select({ taskId: eventsRaw.taskId, payload: eventsRaw.payload })
3116
+ .select({ taskId: eventsRaw.taskId, payload: eventsRaw.payload, ts: eventsRaw.ts })
1459
3117
  .from(eventsRaw)
1460
3118
  .where(eq(eventsRaw.eventType, 'task-result-recorded'))
1461
3119
  .all();
3120
+ // No `period`: every row, exactly the pre-DS7 behaviour (backward compat).
3121
+ // With `period`: only rows whose UTC day falls in the window — this also
3122
+ // answers DS7's "per-provider totals for the period" ask (spec §4.4's
3123
+ // "providers with totalTokens > 0"), so no separate field is added for it.
3124
+ const window = opts.period
3125
+ ? periodDays(opts.period, opts.nowIso ?? new Date().toISOString())
3126
+ : null;
3127
+ const windowSet = window ? new Set(window) : null;
3128
+ const scopedResultRows = windowSet
3129
+ ? resultRows.filter((row) => windowSet.has(row.ts.slice(0, 10)))
3130
+ : resultRows;
1462
3131
  const costBuckets = new Map();
1463
- for (const row of resultRows) {
3132
+ const dailyBuckets = new Map();
3133
+ const roleTierBuckets = new Map();
3134
+ for (const row of scopedResultRows) {
1464
3135
  const p = JSON.parse(row.payload);
1465
3136
  // Column first, payload second: both spellings occur, and one real row
1466
3137
  // omits `payload.task_id` while the column carries it.
@@ -1469,21 +3140,85 @@ export function analytics(db, scope = {}) {
1469
3140
  if (!p.model_tier || !p.provider)
1470
3141
  continue;
1471
3142
  const key = `${p.model_tier}|${p.provider}`;
1472
- const bucket = costBuckets.get(key) ?? { taskCount: 0, totalTokens: 0 };
3143
+ const bucket = costBuckets.get(key) ?? { taskCount: 0, totalTokens: 0, unmeasuredTaskCount: 0 };
1473
3144
  bucket.taskCount += 1;
1474
- bucket.totalTokens += p.token_usage?.total_tokens ?? 0;
3145
+ const usage = readTokenUsage(p.token_usage);
3146
+ if (usage.measured) {
3147
+ bucket.totalTokens += usage.total;
3148
+ }
3149
+ else {
3150
+ bucket.unmeasuredTaskCount += 1;
3151
+ }
1475
3152
  costBuckets.set(key, bucket);
3153
+ if (window) {
3154
+ const role = p.agent ?? 'unattributed';
3155
+ const day = row.ts.slice(0, 10);
3156
+ const daily = dailyBuckets.get(day) ?? {
3157
+ tokensByRole: new Map(),
3158
+ tokensByModelTier: new Map(),
3159
+ unmeasuredRunCount: 0,
3160
+ };
3161
+ if (usage.measured) {
3162
+ daily.tokensByRole.set(role, (daily.tokensByRole.get(role) ?? 0) + usage.total);
3163
+ daily.tokensByModelTier.set(p.model_tier, (daily.tokensByModelTier.get(p.model_tier) ?? 0) + usage.total);
3164
+ }
3165
+ else {
3166
+ daily.unmeasuredRunCount += 1;
3167
+ }
3168
+ dailyBuckets.set(day, daily);
3169
+ const roleTierKey = `${role}|${p.model_tier}`;
3170
+ const roleTier = roleTierBuckets.get(roleTierKey) ?? {
3171
+ runCount: 0,
3172
+ tokens: 0,
3173
+ unmeasuredRunCount: 0,
3174
+ };
3175
+ roleTier.runCount += 1;
3176
+ if (usage.measured) {
3177
+ roleTier.tokens += usage.total;
3178
+ }
3179
+ else {
3180
+ roleTier.unmeasuredRunCount += 1;
3181
+ }
3182
+ roleTierBuckets.set(roleTierKey, roleTier);
3183
+ }
1476
3184
  }
1477
3185
  const costByModelTierAndProvider = [...costBuckets.entries()].map(([key, v]) => {
1478
3186
  const [modelTier, provider] = key.split('|');
3187
+ const measuredTaskCount = v.taskCount - v.unmeasuredTaskCount;
1479
3188
  return {
1480
3189
  modelTier,
1481
3190
  provider,
1482
3191
  taskCount: v.taskCount,
1483
3192
  totalTokens: v.totalTokens,
1484
- avgTokensPerTask: v.taskCount > 0 ? v.totalTokens / v.taskCount : 0,
3193
+ avgTokensPerTask: measuredTaskCount > 0 ? v.totalTokens / measuredTaskCount : 0,
3194
+ unmeasuredTaskCount: v.unmeasuredTaskCount,
1485
3195
  };
1486
3196
  });
3197
+ const tokensByDay = window
3198
+ ? window.map((day) => {
3199
+ const daily = dailyBuckets.get(day);
3200
+ return {
3201
+ day,
3202
+ tokensByRole: daily ? Object.fromEntries(daily.tokensByRole) : {},
3203
+ tokensByModelTier: daily ? Object.fromEntries(daily.tokensByModelTier) : {},
3204
+ unmeasuredRunCount: daily?.unmeasuredRunCount ?? 0,
3205
+ };
3206
+ })
3207
+ : undefined;
3208
+ const tokensByRoleAndModelTier = window
3209
+ ? [...roleTierBuckets.entries()].map(([key, v]) => {
3210
+ const [role, modelTier] = key.split('|');
3211
+ const measuredRunCount = v.runCount - v.unmeasuredRunCount;
3212
+ return {
3213
+ role,
3214
+ modelTier,
3215
+ runCount: v.runCount,
3216
+ tokens: v.tokens,
3217
+ avgTokensPerRun: measuredRunCount > 0 ? v.tokens / measuredRunCount : null,
3218
+ unmeasuredRunCount: v.unmeasuredRunCount,
3219
+ };
3220
+ })
3221
+ : undefined;
1487
3222
  const decisionRows = eventSessionCond
1488
3223
  ? db
1489
3224
  .select({ ts: eventsRaw.ts, taskId: eventsRaw.taskId, payload: eventsRaw.payload })
@@ -1535,6 +3270,8 @@ export function analytics(db, scope = {}) {
1535
3270
  // Same db, same scope: a project-scoped page gets project-scoped judge
1536
3271
  // stats, and no second round trip is needed to render the card.
1537
3272
  providerAgreement: providerAgreement(db, scope),
3273
+ ...(tokensByDay ? { tokensByDay } : {}),
3274
+ ...(tokensByRoleAndModelTier ? { tokensByRoleAndModelTier } : {}),
1538
3275
  };
1539
3276
  }
1540
3277
  /**