@juzser/blacksmith 0.1.0 → 0.2.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 (72) hide show
  1. package/.claude/agents/coder.md +9 -3
  2. package/.claude/agents/grader.md +13 -5
  3. package/.claude/agents/merger.md +2 -0
  4. package/.claude/agents/planner.md +22 -1
  5. package/.claude/agents/researcher.md +3 -1
  6. package/.claude/agents/reviewer.md +9 -2
  7. package/.claude/agents/security-reviewer.md +15 -7
  8. package/.claude/agents/spec-reviewer.md +12 -3
  9. package/.claude/agents/tester.md +5 -2
  10. package/.claude/agents/uiux.md +10 -3
  11. package/.claude/agents/verifier.md +8 -3
  12. package/.claude/skills/bs/SKILL.md +119 -0
  13. package/.claude/skills/bs/audit.md +252 -0
  14. package/.claude/skills/bs/dispatch.md +394 -0
  15. package/.claude/skills/bs/lessons.md +73 -0
  16. package/.claude/skills/bs/mcp.md +29 -0
  17. package/.claude/skills/bs/new.md +35 -0
  18. package/.claude/skills/bs/plan.md +172 -0
  19. package/.claude/skills/bs/report.md +129 -0
  20. package/.claude/skills/bs/run.md +327 -0
  21. package/.claude/skills/bs/status.md +54 -0
  22. package/.claude/skills/bs/ui.md +19 -0
  23. package/.claude/skills/bs/waivers.md +13 -0
  24. package/.claude/skills/bs/wave.md +267 -0
  25. package/README.md +136 -79
  26. package/factory/orchestrator/dist/actors.js +4 -3
  27. package/factory/orchestrator/dist/agents-registry.js +96 -19
  28. package/factory/orchestrator/dist/artifacts.js +36 -4
  29. package/factory/orchestrator/dist/budgets.js +7 -5
  30. package/factory/orchestrator/dist/claims.js +27 -1
  31. package/factory/orchestrator/dist/cli.js +196 -27
  32. package/factory/orchestrator/dist/crosscheck.js +11 -0
  33. package/factory/orchestrator/dist/daemon.js +30 -5
  34. package/factory/orchestrator/dist/db/projector.js +272 -90
  35. package/factory/orchestrator/dist/db/queries.js +83 -14
  36. package/factory/orchestrator/dist/db/schema.js +7 -0
  37. package/factory/orchestrator/dist/events.js +88 -4
  38. package/factory/orchestrator/dist/harness.js +516 -0
  39. package/factory/orchestrator/dist/impact.js +76 -5
  40. package/factory/orchestrator/dist/judges.js +49 -8
  41. package/factory/orchestrator/dist/lessons.js +4 -4
  42. package/factory/orchestrator/dist/mcp.js +4 -3
  43. package/factory/orchestrator/dist/paths.js +169 -15
  44. package/factory/orchestrator/dist/plan.js +61 -1
  45. package/factory/orchestrator/dist/planQuorum.js +36 -8
  46. package/factory/orchestrator/dist/providers/api-transport.js +62 -5
  47. package/factory/orchestrator/dist/providers/cli-transport.js +7 -73
  48. package/factory/orchestrator/dist/providers/index.js +1 -0
  49. package/factory/orchestrator/dist/providers/schema-validate.js +58 -7
  50. package/factory/orchestrator/dist/providers/types.js +5 -0
  51. package/factory/orchestrator/dist/roadmap.js +3 -3
  52. package/factory/orchestrator/dist/run-cli.js +130 -0
  53. package/factory/orchestrator/dist/runner.js +264 -0
  54. package/factory/orchestrator/dist/scaffold.js +3 -2
  55. package/factory/orchestrator/dist/spawn.js +84 -0
  56. package/factory/orchestrator/dist/stack.js +10 -2
  57. package/factory/orchestrator/dist/templateFragments.js +178 -0
  58. package/factory/orchestrator/dist/usage.js +30 -9
  59. package/factory/orchestrator/dist/waveNext.js +42 -14
  60. package/factory/orchestrator/dist/waveSchedule.js +7 -1
  61. package/factory/orchestrator/dist/workroot.js +113 -0
  62. package/factory/orchestrator/drizzle/0011_findings_spec_ref.sql +3 -0
  63. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  64. package/factory/policies/budgets.yml +7 -4
  65. package/factory/policies/crosscheck.yml +10 -8
  66. package/factory/policies/harness.yml +82 -0
  67. package/factory/policies/lessons.md +122 -0
  68. package/factory/policies/severity.yml +1 -1
  69. package/factory/policies/taxonomy.yml +6 -2
  70. package/factory/specs/roadmap.md +154 -0
  71. package/factory/specs/schema/task-spec.schema.json +5 -0
  72. package/package.json +10 -7
@@ -6,7 +6,7 @@
6
6
  // is the normal case; a session filter is for debugging one run).
7
7
  import { and, eq, gte, inArray, lte } from 'drizzle-orm';
8
8
  import { isOperatorActor } from '../actors.js';
9
- import { foldAgents, liveAgents as foldLiveAgents, REGISTRY_EVENT_TYPES, } from '../agents-registry.js';
9
+ import { foldAgents, liveAgents as foldLiveAgents, isWorkingAt, REGISTRY_EVENT_TYPES, } from '../agents-registry.js';
10
10
  import { compareLogOrder, isLaterEvent, parseEventId, ROOT_EVENT_TYPE } from '../events.js';
11
11
  import { waveLayers } from '../graph.js';
12
12
  import { judgeFailureKind } from '../providers/types.js';
@@ -514,14 +514,19 @@ function allAgentsForScope(db, scope) {
514
514
  * Ties on `lastEventAt` break on `sessionId` so the order is stable under the
515
515
  * dashboard's 5s poll rather than shuffling between renders.
516
516
  */
517
- export function runningSessions(db, scope = {}) {
517
+ export function runningSessions(db, scope = {}, opts = {}) {
518
+ const nowIso = opts.nowIso ?? new Date().toISOString();
518
519
  const sessionCond = scopedToSessions(sessions.sessionId, scope);
519
520
  const rows = sessionCond
520
521
  ? db.select().from(sessions).where(sessionCond).all()
521
522
  : db.select().from(sessions).all();
522
523
  const liveBySession = new Map();
524
+ const workingBySession = new Map();
523
525
  for (const a of allAgentsForScope(db, scope)) {
524
526
  liveBySession.set(a.sessionId, (liveBySession.get(a.sessionId) ?? 0) + 1);
527
+ if (isWorkingAt(a.dispatchedAt, nowIso)) {
528
+ workingBySession.set(a.sessionId, (workingBySession.get(a.sessionId) ?? 0) + 1);
529
+ }
525
530
  }
526
531
  const projectsBySession = new Map();
527
532
  for (const t of allTasksForScope(db, scope)) {
@@ -555,6 +560,7 @@ export function runningSessions(db, scope = {}) {
555
560
  lastEventAt: s.lastEventAt,
556
561
  eventCount: s.eventCount,
557
562
  liveAgentCount: liveBySession.get(s.sessionId) ?? 0,
563
+ workingAgentCount: workingBySession.get(s.sessionId) ?? 0,
558
564
  lastEventType: lastEvent.get(s.sessionId)?.eventType ?? null,
559
565
  projects: [...(projectsBySession.get(s.sessionId) ?? [])].sort(),
560
566
  }))
@@ -621,8 +627,14 @@ function inFlightEpics(taskRows, closed) {
621
627
  .map((t) => t.epicId)),
622
628
  ].sort();
623
629
  }
624
- /** One project's overview slice, computed by the same logic overview() itself uses (no drift). */
625
- function projectSummary(db, project, baseScope) {
630
+ /**
631
+ * One project's overview slice, computed by the same logic overview() itself
632
+ * uses (no drift). `nowIso` is the caller's single instant, not a fresh
633
+ * read: overview() hands every per-project summary the same "now" it used
634
+ * for its own counts, so the project rows and the global card can never
635
+ * disagree about which agents are working.
636
+ */
637
+ function projectSummary(db, project, baseScope, nowIso) {
626
638
  const scope = { ...baseScope, project };
627
639
  const liveRows = allAgentsForScope(db, scope);
628
640
  const taskRows = allTasksForScope(db, scope);
@@ -637,6 +649,7 @@ function projectSummary(db, project, baseScope) {
637
649
  return {
638
650
  project,
639
651
  liveAgentCount: liveRows.length,
652
+ workingAgentCount: liveRows.filter((a) => isWorkingAt(a.dispatchedAt, nowIso)).length,
640
653
  epicsInFlight,
641
654
  tokensSpent,
642
655
  tokensBudget,
@@ -664,7 +677,7 @@ const SNAPSHOT_EVENT_TYPES = REGISTRY_EVENT_TYPES;
664
677
  * project, so its agent stayed in the live count and vanished from the
665
678
  * historical one, and the StatCard reported an arrival that never happened.
666
679
  */
667
- function liveAgentCountAt(db, scope, cutoffIso) {
680
+ function liveAgentsAt(db, scope, cutoffIso) {
668
681
  const conds = [lte(eventsRaw.ts, cutoffIso), inArray(eventsRaw.eventType, SNAPSHOT_EVENT_TYPES)];
669
682
  const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
670
683
  if (sessionCond)
@@ -690,9 +703,25 @@ function liveAgentCountAt(db, scope, cutoffIso) {
690
703
  }));
691
704
  const live = foldLiveAgents(foldAgents(storedEvents));
692
705
  if (scope.project === undefined)
693
- return live.length;
706
+ return live;
694
707
  const scopedTaskIds = new Set(allTasksForScope(db, scope).map((t) => t.taskId));
695
- return live.filter((a) => a.taskId !== null && scopedTaskIds.has(a.taskId)).length;
708
+ return live.filter((a) => a.taskId !== null && scopedTaskIds.has(a.taskId));
709
+ }
710
+ function liveAgentCountAt(db, scope, cutoffIso) {
711
+ return liveAgentsAt(db, scope, cutoffIso).length;
712
+ }
713
+ /**
714
+ * The "working" end of workingAgentCountDelta5m's subtraction: the agents
715
+ * live as of `cutoffIso` (the very re-fold liveAgentCountAt does, project
716
+ * scoping included) that were ALSO within DEFAULT_STALE_HOURS of dispatch
717
+ * *at that cutoff*. Judging the historical rows against today's clock
718
+ * instead would be D-170 again in a new disguise — the "now" end would
719
+ * apply a 4h window and the "ago" end a 4h05m one, and an agent that
720
+ * stalled since the cutoff would read as no change.
721
+ */
722
+ function workingAgentCountAt(db, scope, cutoffIso) {
723
+ return liveAgentsAt(db, scope, cutoffIso).filter((a) => isWorkingAt(a.dispatchedAt, cutoffIso))
724
+ .length;
696
725
  }
697
726
  /**
698
727
  * Total tokens spent (task-result-recorded token_usage) across `epicByTask`'s tasks, as of `cutoffIso`.
@@ -781,7 +810,14 @@ function tokensBudgetedAt(db, scope, cutoffIso, epicByTask) {
781
810
  return total;
782
811
  }
783
812
  export function overview(db, scope = {}, opts = {}) {
813
+ // One instant for everything this call reports. runningSessions(), the
814
+ // per-project summaries and the deltas each take the clock as an argument
815
+ // rather than reading it, so a request that straddles a threshold cannot
816
+ // count an agent as working in one field and stalled in the next.
817
+ const now = opts.nowIso ? new Date(opts.nowIso) : new Date();
818
+ const nowIso = now.toISOString();
784
819
  const liveRows = allAgentsForScope(db, scope);
820
+ const workingRows = liveRows.filter((a) => isWorkingAt(a.dispatchedAt, nowIso));
785
821
  const taskRows = allTasksForScope(db, scope);
786
822
  const closedEpics = closedEpicsForScope(db, scope);
787
823
  const epicsInFlight = inFlightEpics(taskRows, closedEpics);
@@ -835,12 +871,12 @@ export function overview(db, scope = {}, opts = {}) {
835
871
  // Milestones carry no session_id and milestoneProgressRows() never
836
872
  // filters them by one, so they join the list under a session scope too.
837
873
  const declaredRows = db.select({ project: milestones.project }).from(milestones).all();
838
- projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope));
874
+ projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope, nowIso));
839
875
  }
840
- const now = opts.nowIso ? new Date(opts.nowIso) : new Date();
841
876
  const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
842
877
  const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000).toISOString();
843
878
  const liveAgentCountDelta5m = liveRows.length - liveAgentCountAt(db, scope, fiveMinAgo);
879
+ const workingAgentCountDelta5m = workingRows.length - workingAgentCountAt(db, scope, fiveMinAgo);
844
880
  const epicByTask = new Map(taskRows.filter((t) => t.epicId !== null).map((t) => [t.taskId, t.epicId]));
845
881
  const totalBudgetNow = [...budgetByEpic.values()].reduce((s, v) => s + v, 0);
846
882
  const totalSpentNow = [...spentByEpic.values()].reduce((s, v) => s + v, 0);
@@ -873,7 +909,9 @@ export function overview(db, scope = {}, opts = {}) {
873
909
  dispatchedAt: a.dispatchedAt,
874
910
  })),
875
911
  liveAgentCount: liveRows.length,
876
- runningSessions: runningSessions(db, scope),
912
+ workingAgentCount: workingRows.length,
913
+ stalledAgentCount: liveRows.length - workingRows.length,
914
+ runningSessions: runningSessions(db, scope, { nowIso }),
877
915
  epicsInFlight,
878
916
  closedEpics,
879
917
  tokensByEpic,
@@ -881,6 +919,7 @@ export function overview(db, scope = {}, opts = {}) {
881
919
  milestoneProgress: milestoneProgressRows(db, scope),
882
920
  recentDispatches,
883
921
  liveAgentCountDelta5m,
922
+ workingAgentCountDelta5m,
884
923
  budgetUsedPctPointDelta1h,
885
924
  ...(projects ? { projects } : {}),
886
925
  };
@@ -959,6 +998,12 @@ export const FREE_TIMELINE_EVENT_TYPES = [
959
998
  'audit-cut',
960
999
  'audit-resolved',
961
1000
  'audit-closed',
1001
+ // The integration PR run.md step 17 opens, appended by hand through `smith
1002
+ // event append` under `<epic>/integration`. It is the epic's terminal
1003
+ // deliverable -- the one thing the operator is asked to merge -- and the
1004
+ // only event written after `epic-closed`, so without it the timeline of a
1005
+ // closed epic ends at the closing and never shows what the closing was for.
1006
+ 'integration-pr-opened',
962
1007
  ];
963
1008
  let cachedTaxonomy;
964
1009
  /**
@@ -1174,7 +1219,8 @@ function worstSeverity(severities) {
1174
1219
  * task_status, tagged with case/origin/worst-open-finding severity plus
1175
1220
  * (Phase 6b) title/agent-role/milestone.
1176
1221
  */
1177
- export function kanban(db, epicId, scope = {}) {
1222
+ export function kanban(db, epicId, scope = {}, opts = {}) {
1223
+ const nowIso = opts.nowIso ?? new Date().toISOString();
1178
1224
  const epicCond = epicId !== undefined ? eq(tasks.epicId, epicId) : undefined;
1179
1225
  const sessionCond = scopedToSessions(tasks.sessionId, scope);
1180
1226
  const taskConds = [epicCond, sessionCond].filter((c) => c !== undefined);
@@ -1221,6 +1267,18 @@ export function kanban(db, epicId, scope = {}) {
1221
1267
  modelTier: d.modelTier,
1222
1268
  });
1223
1269
  }
1270
+ // Who is still on each task, from the agents rows (live only — see
1271
+ // allAgentsForScope). A working row wins over a stalled one on the same
1272
+ // task for the reason flowGraph gives: the chip says who is on the task.
1273
+ const activityByTask = new Map();
1274
+ for (const a of allAgentsForScope(db, scope)) {
1275
+ if (!a.taskId)
1276
+ continue;
1277
+ if (isWorkingAt(a.dispatchedAt, nowIso))
1278
+ activityByTask.set(a.taskId, 'working');
1279
+ else if (!activityByTask.has(a.taskId))
1280
+ activityByTask.set(a.taskId, 'stalled');
1281
+ }
1224
1282
  // epicId -> milestoneId, from roadmap.md's milestones table.
1225
1283
  const milestoneRows = db.select().from(milestones).all();
1226
1284
  const milestoneByEpic = new Map();
@@ -1237,6 +1295,7 @@ export function kanban(db, epicId, scope = {}) {
1237
1295
  title: t.objective,
1238
1296
  agentRole: latestAgentRoleByTask.get(t.taskId)?.agentRole ?? null,
1239
1297
  agentModelTier: latestAgentRoleByTask.get(t.taskId)?.modelTier ?? null,
1298
+ agentActivity: activityByTask.get(t.taskId) ?? null,
1240
1299
  milestoneId: t.epicId ? (milestoneByEpic.get(t.epicId) ?? null) : null,
1241
1300
  tags: {
1242
1301
  case: t.caseTag,
@@ -1462,7 +1521,8 @@ export function analytics(db, scope = {}) {
1462
1521
  * stack.md: "no separate layout library" — this IS that orchestrator
1463
1522
  * utility, exposed via this query/endpoint per the same doc's instruction).
1464
1523
  */
1465
- export function flowGraph(db, filter = {}) {
1524
+ export function flowGraph(db, filter = {}, opts = {}) {
1525
+ const nowIso = opts.nowIso ?? new Date().toISOString();
1466
1526
  let taskRows = allTasksForScope(db, filter);
1467
1527
  if (filter.epicId)
1468
1528
  taskRows = taskRows.filter((t) => t.epicId === filter.epicId);
@@ -1526,9 +1586,17 @@ export function flowGraph(db, filter = {}) {
1526
1586
  const scopedEdges = edgeRows.filter((e) => taskIds.has(e.taskId) && taskIds.has(e.dependsOn));
1527
1587
  const liveRows = allAgentsForScope(db, filter);
1528
1588
  const liveRoleByTask = new Map();
1589
+ // The same last-write-wins walk, over the working rows only. Where a task
1590
+ // carries both a stalled live row and a working one, the working role is
1591
+ // the one to name: the marker says who is on the task, not who was
1592
+ // dispatched to it most recently.
1593
+ const workingRoleByTask = new Map();
1529
1594
  for (const a of liveRows) {
1530
- if (a.taskId)
1531
- liveRoleByTask.set(a.taskId, a.agentRole);
1595
+ if (!a.taskId)
1596
+ continue;
1597
+ liveRoleByTask.set(a.taskId, a.agentRole);
1598
+ if (isWorkingAt(a.dispatchedAt, nowIso))
1599
+ workingRoleByTask.set(a.taskId, a.agentRole);
1532
1600
  }
1533
1601
  const waveMap = waveLayers([...taskIds], scopedEdges.map((e) => ({ task: e.taskId, dependsOn: e.dependsOn })));
1534
1602
  const nodes = taskRows.map((t) => ({
@@ -1536,6 +1604,7 @@ export function flowGraph(db, filter = {}) {
1536
1604
  taskStatus: t.taskStatus,
1537
1605
  title: t.objective,
1538
1606
  liveAgentRole: liveRoleByTask.get(t.taskId) ?? null,
1607
+ workingAgentRole: workingRoleByTask.get(t.taskId) ?? null,
1539
1608
  planVersion: t.planVersion,
1540
1609
  wave: waveMap.get(t.taskId) ?? 0,
1541
1610
  }));
@@ -189,6 +189,13 @@ export const findings = sqliteTable('findings', {
189
189
  raisedAt: text('raised_at').notNull(),
190
190
  updatedAt: text('updated_at').notNull(),
191
191
  project: text('project'), // Phase 6b — see eventsRaw's project comment above.
192
+ // What a spec finding is about (findings.ts SpecRef). The scope is filled
193
+ // in from findingScope(), so an absent field lands as 'diff' rather than
194
+ // as a null the reader has to know the default for; the two spec_ref
195
+ // columns are null on every diff finding because there is no criterion.
196
+ findingScope: text('finding_scope').notNull().default('diff'),
197
+ specPlanVersion: integer('spec_plan_version'),
198
+ criterionRef: text('criterion_ref'),
192
199
  }, (t) => [
193
200
  index('findings_session_idx').on(t.sessionId),
194
201
  index('findings_task_idx').on(t.taskId),
@@ -203,10 +203,20 @@ async function readEventsAtPath(filePath, sessionId) {
203
203
  .trimEnd()
204
204
  .split('\n')
205
205
  .filter((line) => line.length > 0);
206
- return lines.map((line, index) => ({
207
- event_id: `${sessionId}#${index}`,
208
- record: JSON.parse(line),
209
- }));
206
+ return lines.map((line, index) => {
207
+ let record;
208
+ try {
209
+ record = JSON.parse(line);
210
+ }
211
+ catch (err) {
212
+ // A bare SyntaxError says "Unexpected token" and nothing else: not which
213
+ // log, not which line. The dashboard prints this for every session it
214
+ // then fails to fold (the global folds read every log), so the message
215
+ // has to carry the path the operator needs to open.
216
+ throw new EventError('events.unreadable-session-log', `Line ${index + 1} of ${filePath} is not JSON, so session "${sessionId}" cannot be read: ${errorText(err)}.`, { session_id: sessionId, path: filePath, line: index + 1 });
217
+ }
218
+ return { event_id: `${sessionId}#${index}`, record };
219
+ });
210
220
  }
211
221
  /**
212
222
  * Per-log serialization *inside one process*: concurrent appendEvent calls on
@@ -504,7 +514,31 @@ function validatePayloadDimensions(taxonomy, record) {
504
514
  const TYPED_PAYLOAD_SCHEMAS = {
505
515
  'finding-raised': 'finding',
506
516
  };
517
+ /**
518
+ * The one field of a `task-result-recorded` payload a reader iterates. The
519
+ * projector writes `artifacts[]` into rows inside the session's transaction,
520
+ * so an object here (csb-audit-1#100 carried `{ claude_half, external_half,
521
+ * repair_brief }`) rolled back the whole session and the dashboard drew
522
+ * nothing for it. Pinning only this field is deliberate: the full result
523
+ * schema is not enforced on the event, because real logs carry many
524
+ * hand-written result payloads that no reader rehydrates -- refusing them
525
+ * would refuse history for a shape nothing reads.
526
+ */
527
+ function validateResultArtifactsShape(record) {
528
+ if (record.event_type !== 'task-result-recorded')
529
+ return;
530
+ const payload = record.payload;
531
+ const artifacts = payload?.artifacts;
532
+ if (artifacts === undefined || Array.isArray(artifacts))
533
+ return;
534
+ throw new EventError('events.invalid-typed-payload', 'Event payload for "task-result-recorded" carries an `artifacts` that is not an array. The projector iterates this list to write artifact rows, so any other shape becomes a crash that rolls back the whole session rather than an error here.', {
535
+ event_type: record.event_type,
536
+ schema: 'result',
537
+ errors: [{ path: '/artifacts', message: `must be an array, got ${typeof artifacts}` }],
538
+ });
539
+ }
507
540
  function validateTypedPayload(schemas, taxonomy, record) {
541
+ validateResultArtifactsShape(record);
508
542
  const schemaName = TYPED_PAYLOAD_SCHEMAS[record.event_type];
509
543
  if (schemaName === undefined)
510
544
  return;
@@ -1013,3 +1047,53 @@ export function filterEvents(events, filter) {
1013
1047
  return true;
1014
1048
  });
1015
1049
  }
1050
+ /**
1051
+ * How often `--follow` re-reads a log it is following.
1052
+ *
1053
+ * A second is well under the interval an operator perceives as lag, and a
1054
+ * re-read costs what one `smith event tail` costs -- the poll is not what
1055
+ * makes a busy factory expensive. There is no flag for it on purpose: a
1056
+ * knob no answer depends on is a knob every reader of `--help` pays for.
1057
+ */
1058
+ export const FOLLOW_TICK_MS = 1000;
1059
+ /**
1060
+ * The events in `events` that `seen` does not already hold, in `events`'
1061
+ * order.
1062
+ *
1063
+ * The cursor is a set of ids rather than a count because `--lineage` merges
1064
+ * several logs by `ts` (mergeSessionLogs above): an event appended now can
1065
+ * sort BEHIND one already printed, and a stream cannot un-print. A length
1066
+ * cursor would either re-emit the tail or skip the new event outright, and
1067
+ * both are worse than holding the ids -- which cost what the read they came
1068
+ * from already cost.
1069
+ */
1070
+ export function unseenEvents(events, seen) {
1071
+ return events.filter((event) => !seen.has(event.event_id));
1072
+ }
1073
+ /**
1074
+ * Read, emit what is new, sleep, repeat -- until `shouldContinue` says stop.
1075
+ *
1076
+ * The seams are runDaemon's, for the same reason: the contract of a loop is
1077
+ * what it does AROUND the read, and a loop that owns its own clock can only
1078
+ * be tested by waiting. `sleep` is where an interrupt lands, so the caller
1079
+ * that wires SIGINT resolves the sleep rather than waiting out the interval.
1080
+ *
1081
+ * Returns how many events it emitted, which is the only thing about a stream
1082
+ * worth having after it ends.
1083
+ */
1084
+ export async function followEvents(opts) {
1085
+ const intervalMs = opts.intervalMs ?? FOLLOW_TICK_MS;
1086
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1087
+ const shouldContinue = opts.shouldContinue ?? (() => true);
1088
+ const seen = new Set(opts.seen ?? []);
1089
+ let emitted = 0;
1090
+ while (shouldContinue()) {
1091
+ for (const event of unseenEvents(await opts.read(), seen)) {
1092
+ seen.add(event.event_id);
1093
+ opts.emit(event);
1094
+ emitted += 1;
1095
+ }
1096
+ await sleep(intervalMs);
1097
+ }
1098
+ return emitted;
1099
+ }