@juzser/blacksmith 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +16 -9
  4. package/.claude/agents/{merger.md → bs-merger.md} +4 -4
  5. package/.claude/agents/{planner.md → bs-planner.md} +20 -6
  6. package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
  7. package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
  11. package/.claude/agents/{tester.md → bs-tester.md} +40 -12
  12. package/.claude/agents/bs-uiux.md +257 -0
  13. package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
  14. package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
  15. package/.claude/skills/bs/SKILL.md +99 -21
  16. package/.claude/skills/bs/audit.md +45 -31
  17. package/.claude/skills/bs/dispatch.md +133 -47
  18. package/.claude/skills/bs/lessons.md +28 -14
  19. package/.claude/skills/bs/mcp.md +4 -4
  20. package/.claude/skills/bs/new.md +3 -3
  21. package/.claude/skills/bs/plan.md +57 -16
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +190 -52
  24. package/.claude/skills/bs/status.md +14 -7
  25. package/.claude/skills/bs/ui.md +21 -5
  26. package/.claude/skills/bs/waivers.md +78 -2
  27. package/.claude/skills/bs/wave.md +143 -50
  28. package/README.md +53 -32
  29. package/factory/orchestrator/dist/agentNames.js +43 -0
  30. package/factory/orchestrator/dist/agents-registry.js +11 -4
  31. package/factory/orchestrator/dist/agentsSync.js +70 -13
  32. package/factory/orchestrator/dist/artifacts.js +31 -0
  33. package/factory/orchestrator/dist/attribution.js +4 -0
  34. package/factory/orchestrator/dist/audit.js +245 -20
  35. package/factory/orchestrator/dist/autonomy.js +6 -2
  36. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  37. package/factory/orchestrator/dist/budgets.js +302 -84
  38. package/factory/orchestrator/dist/claims.js +9 -19
  39. package/factory/orchestrator/dist/cli.js +1087 -116
  40. package/factory/orchestrator/dist/cliName.js +27 -0
  41. package/factory/orchestrator/dist/crossFinding.js +20 -0
  42. package/factory/orchestrator/dist/crosscheck.js +136 -8
  43. package/factory/orchestrator/dist/daemon.js +352 -67
  44. package/factory/orchestrator/dist/db/projector.js +327 -54
  45. package/factory/orchestrator/dist/db/queries.js +1831 -94
  46. package/factory/orchestrator/dist/db/schema.js +57 -4
  47. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  48. package/factory/orchestrator/dist/delegation.js +86 -19
  49. package/factory/orchestrator/dist/diffstat.js +15 -8
  50. package/factory/orchestrator/dist/dispatchLint.js +108 -0
  51. package/factory/orchestrator/dist/effort.js +14 -2
  52. package/factory/orchestrator/dist/effortTiers.js +13 -0
  53. package/factory/orchestrator/dist/env.js +26 -0
  54. package/factory/orchestrator/dist/epic.js +494 -42
  55. package/factory/orchestrator/dist/epicBudget.js +93 -0
  56. package/factory/orchestrator/dist/epicWidth.js +22 -5
  57. package/factory/orchestrator/dist/errorIssues.js +182 -11
  58. package/factory/orchestrator/dist/escalation.js +2 -2
  59. package/factory/orchestrator/dist/events.js +164 -14
  60. package/factory/orchestrator/dist/feedback.js +169 -0
  61. package/factory/orchestrator/dist/findingContext.js +26 -0
  62. package/factory/orchestrator/dist/findings.js +108 -14
  63. package/factory/orchestrator/dist/gate.js +120 -10
  64. package/factory/orchestrator/dist/gh.js +19 -2
  65. package/factory/orchestrator/dist/git.js +20 -2
  66. package/factory/orchestrator/dist/goalCheck.js +2 -2
  67. package/factory/orchestrator/dist/harness.js +25 -17
  68. package/factory/orchestrator/dist/hookDecision.js +411 -39
  69. package/factory/orchestrator/dist/integration.js +20 -8
  70. package/factory/orchestrator/dist/issueReporter.js +81 -16
  71. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  72. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  73. package/factory/orchestrator/dist/judgeStopHook.js +239 -0
  74. package/factory/orchestrator/dist/judges.js +419 -21
  75. package/factory/orchestrator/dist/lessons.js +190 -11
  76. package/factory/orchestrator/dist/logCache.js +291 -0
  77. package/factory/orchestrator/dist/mcp.js +6 -6
  78. package/factory/orchestrator/dist/paths.js +20 -7
  79. package/factory/orchestrator/dist/plan.js +399 -33
  80. package/factory/orchestrator/dist/planQuorum.js +5 -2
  81. package/factory/orchestrator/dist/policy.js +1275 -30
  82. package/factory/orchestrator/dist/projectName.js +25 -0
  83. package/factory/orchestrator/dist/projects.js +12 -1
  84. package/factory/orchestrator/dist/promptCapture.js +306 -0
  85. package/factory/orchestrator/dist/promptHook.js +33 -0
  86. package/factory/orchestrator/dist/promptImport.js +281 -0
  87. package/factory/orchestrator/dist/prompts.js +13 -0
  88. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  89. package/factory/orchestrator/dist/providers/types.js +1 -1
  90. package/factory/orchestrator/dist/queue.js +471 -30
  91. package/factory/orchestrator/dist/quorum.js +18 -5
  92. package/factory/orchestrator/dist/results.js +25 -7
  93. package/factory/orchestrator/dist/roadmap.js +10 -5
  94. package/factory/orchestrator/dist/run-cli.js +15 -10
  95. package/factory/orchestrator/dist/runner.js +2 -2
  96. package/factory/orchestrator/dist/scheduler.js +3 -3
  97. package/factory/orchestrator/dist/schemas.js +56 -8
  98. package/factory/orchestrator/dist/severity.js +8 -4
  99. package/factory/orchestrator/dist/spec.js +246 -12
  100. package/factory/orchestrator/dist/specChange.js +13 -9
  101. package/factory/orchestrator/dist/stack.js +6 -6
  102. package/factory/orchestrator/dist/storeRoot.js +90 -0
  103. package/factory/orchestrator/dist/symbols.js +261 -6
  104. package/factory/orchestrator/dist/taskEvents.js +192 -31
  105. package/factory/orchestrator/dist/taskStatus.js +14 -0
  106. package/factory/orchestrator/dist/taxonomy.js +11 -0
  107. package/factory/orchestrator/dist/testerAudit.js +31 -12
  108. package/factory/orchestrator/dist/testgate.js +20 -16
  109. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  110. package/factory/orchestrator/dist/usage.js +113 -48
  111. package/factory/orchestrator/dist/waivers.js +99 -12
  112. package/factory/orchestrator/dist/waveBudget.js +2 -0
  113. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  114. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  115. package/factory/orchestrator/dist/workroot.js +1 -1
  116. package/factory/orchestrator/dist/worktree.js +76 -21
  117. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  118. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  119. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  120. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  121. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  122. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  123. package/factory/policies/budgets.yml +123 -33
  124. package/factory/policies/crosscheck.yml +51 -13
  125. package/factory/policies/delegation.yml +3 -3
  126. package/factory/policies/effort.yml +18 -10
  127. package/factory/policies/guardrails.yml +8 -6
  128. package/factory/policies/harness.yml +5 -5
  129. package/factory/policies/lessons.md +53 -2
  130. package/factory/policies/scheduler.yml +4 -4
  131. package/factory/policies/sensitive-paths.yml +1 -1
  132. package/factory/policies/severity.yml +13 -1
  133. package/factory/policies/stack.yml +6 -6
  134. package/factory/policies/taxonomy.yml +9 -3
  135. package/factory/policies/worktree.yml +15 -9
  136. package/factory/specs/roadmap.md +27 -3
  137. package/factory/specs/schema/event.schema.json +8 -1
  138. package/factory/specs/schema/result.schema.json +21 -8
  139. package/factory/specs/schema/task-spec.schema.json +12 -0
  140. package/package.json +16 -12
  141. package/.claude/agents/uiux.md +0 -133
@@ -12,7 +12,7 @@
12
12
  // already owns — budgetAlarm.ts for spend, agents-registry.ts for stalls,
13
13
  // scheduler.ts for work that is due. Nothing here re-derives them, so the
14
14
  // daemon and `smith status` can never disagree.
15
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import { DEFAULT_STALE_HOURS, detectStale, foldAgents, } from './agents-registry.js';
18
18
  import { admitProposals, } from './autonomy.js';
@@ -20,10 +20,12 @@ import { checkBudgetAlarm } from './budgetAlarm.js';
20
20
  import { loadBudgetPolicy } from './budgets.js';
21
21
  import { loadCrosscheckPolicy } from './crosscheck.js';
22
22
  import { apply, foldTasks, rebuild } from './db/projector.js';
23
+ import { epicBudgetPolicies } from './epicBudget.js';
23
24
  import { summariseEpicWidth, UNMEASURED_HINT } from './epicWidth.js';
24
25
  import { SmithError } from './errors.js';
25
- import { listSessionIds, mergeSessionLogs, parseEventId, readEvents, } from './events.js';
26
+ import { listSessionIds, mergeSessionLogs, parseEventId } from './events.js';
26
27
  import { ageFindings, memoryOf } from './findingAge.js';
28
+ import { LogCache } from './logCache.js';
27
29
  import { STATE_DAEMON_DIR, STATE_DB_PATH, STATE_EVENTS_DIR } from './paths.js';
28
30
  import { unwatchedProjects } from './projects.js';
29
31
  import { FACTORY_PROJECT } from './roadmap.js';
@@ -63,6 +65,20 @@ function admitFor(proposals, events, lens) {
63
65
  function staleSubject(agent) {
64
66
  return agent.taskId ?? agent.epicId ?? agent.agentId ?? agent.id;
65
67
  }
68
+ /**
69
+ * The session an event id names, for a finding that has nothing better to key
70
+ * on than the id of the event that raised it. `null` on anything that is not
71
+ * a well-formed event id, rather than throwing — a proposal's origin is a
72
+ * nicety, not a fact `inspectSession` should die over.
73
+ */
74
+ function originSessionId(eventId) {
75
+ try {
76
+ return parseEventId(eventId).sessionId;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
66
82
  /**
67
83
  * Everything one session's lineage says about itself: spend against the epic
68
84
  * cap, dispatches nobody can be billed for, agents that never came back, and
@@ -73,7 +89,12 @@ function staleSubject(agent) {
73
89
  */
74
90
  export function inspectSession(sessionId, events, opts = {}) {
75
91
  const now = opts.now ?? new Date();
92
+ // An injected policy judges every epic; otherwise each epic is judged
93
+ // against budgets.yml sized for its own plan's effort tier.
76
94
  const budgetPolicy = opts.budgetPolicy ?? loadBudgetPolicy();
95
+ const policyForEpic = opts.budgetPolicy === undefined
96
+ ? epicBudgetPolicies(opts.planOpts === undefined ? {} : { planOpts: opts.planOpts })
97
+ : undefined;
77
98
  const schedulerPolicy = opts.schedulerPolicy ?? loadSchedulerPolicy();
78
99
  const staleHours = opts.staleHours ?? DEFAULT_STALE_HOURS;
79
100
  const findings = [];
@@ -89,7 +110,10 @@ export function inspectSession(sessionId, events, opts = {}) {
89
110
  if (typeof epicId === 'string')
90
111
  closedEpics.add(epicId);
91
112
  }
92
- const budget = checkBudgetAlarm(events, budgetPolicy, { sessionId });
113
+ const budget = checkBudgetAlarm(events, budgetPolicy, {
114
+ sessionId,
115
+ ...(policyForEpic ? { policyForEpic } : {}),
116
+ });
93
117
  for (const epic of budget.epics) {
94
118
  // `under` is the only status that is an answer rather than a question.
95
119
  if (epic.status === 'under')
@@ -119,10 +143,24 @@ export function inspectSession(sessionId, events, opts = {}) {
119
143
  });
120
144
  }
121
145
  for (const agent of detectStale(foldAgents(events), now.toISOString(), staleHours)) {
146
+ // Same rule as the budget check above, and the same reason: a closed
147
+ // epic's dispatch cannot still be live, so flagging it would raise the
148
+ // same line every tick until the log is archived. `foldAgents`'s own
149
+ // epic-closed handling only closes an entry against the session that
150
+ // closed it (D-234) — a dispatch and the epic's close can sit in
151
+ // different sessions of one lineage, which is exactly the shape this
152
+ // tick's merged log hands here, so the same-session fold alone would
153
+ // miss it.
154
+ if (agent.epicId !== null && closedEpics.has(agent.epicId))
155
+ continue;
122
156
  findings.push({
123
157
  kind: 'stale-agent',
124
158
  severity: 'attention',
125
- sessionId,
159
+ // The dispatch's own session, not the tree's primary leaf: a fork
160
+ // asks inspectSession once for the whole tree, and the agent that
161
+ // never came back belongs to whichever session actually dispatched
162
+ // it.
163
+ sessionId: agent.sessionId,
126
164
  subject: staleSubject(agent),
127
165
  detail: `${agent.agentRole} (${agent.provider}/${agent.modelTier}) has been live for ` +
128
166
  `${agent.liveHours.toFixed(1)}h with no result, error or supersession — past the ` +
@@ -142,23 +180,40 @@ export function inspectSession(sessionId, events, opts = {}) {
142
180
  findings.push({
143
181
  kind: 'spec-change',
144
182
  severity: proposal.blocking ? 'attention' : 'info',
145
- sessionId,
183
+ // `proposalId` is the `spec-change-proposed` event's own id, so this is
184
+ // the session that actually raised the proposal — not the tree's
185
+ // primary leaf, which a fork may have picked for an unrelated sibling.
186
+ sessionId: originSessionId(proposal.proposalId) ?? sessionId,
146
187
  subject: proposal.taskId,
147
188
  detail: `${proposal.proposedBy} proposes amending ${proposal.criterionRef}: ${proposal.assumption} ` +
148
189
  `(${proposal.severity}, ${proposal.blocking ? 'blocking' : 'non-blocking'}, ` +
149
- `${proposal.sites.length} site(s)). Answer it with \`smith plan approve ` +
150
- `${proposal.proposalId}\` or \`smith plan reject ${proposal.proposalId}\`.`,
190
+ `${proposal.sites.length} site(s)). Answer it with \`bs plan approve ` +
191
+ `${proposal.proposalId}\` or \`bs plan reject ${proposal.proposalId}\`.`,
151
192
  });
152
193
  }
153
- const proposals = computeProposals({ events, now, policy: schedulerPolicy });
194
+ const proposals = computeProposals({
195
+ events,
196
+ now,
197
+ policy: schedulerPolicy,
198
+ ...(opts.resolveProjectForTaskRef === undefined
199
+ ? {}
200
+ : { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
201
+ ...(opts.selfFallbackForTaskRef === undefined
202
+ ? {}
203
+ : { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
204
+ });
154
205
  const admissions = admitFor(proposals, events, opts.admission);
206
+ // `TaskFoldRow.sessionId` is stamped once, the first time `touch()` builds
207
+ // the row, and never overwritten by a later touch — the task's origin
208
+ // session, not whichever one most recently merged or dispatched against it.
209
+ const taskSession = new Map(foldTasks(events).map((row) => [row.taskId, row.sessionId]));
155
210
  for (const [index, proposal] of proposals.entries()) {
156
211
  const admission = admissions[index];
157
212
  if (proposal.kind === 'recheck') {
158
213
  findings.push({
159
214
  kind: 'recheck',
160
215
  severity: 'info',
161
- sessionId,
216
+ sessionId: taskSession.get(proposal.taskId) ?? sessionId,
162
217
  subject: proposal.taskId,
163
218
  detail: `Recheck due (${proposal.reasons.join(', ')}): ${proposal.mergeCount} later overlapping ` +
164
219
  `merge(s), ${proposal.daysElapsed} day(s) elapsed, confidence ${proposal.confidence}.`,
@@ -172,17 +227,24 @@ export function inspectSession(sessionId, events, opts = {}) {
172
227
  // the discharge command below clears. Reported here and not in
173
228
  // inspectFactory because `issue-reported` lands in the error's own
174
229
  // session, so this lineage is the one that can answer it.
175
- const project = proposal.project === FACTORY_PROJECT ? '' : ` (${proposal.project})`;
230
+ const project = proposal.project === null
231
+ ? ' (unresolved project)'
232
+ : proposal.project === FACTORY_PROJECT
233
+ ? ''
234
+ : ` (${proposal.project})`;
235
+ const projectPhrase = proposal.project === null ? 'an unresolved project' : `project ${proposal.project}`;
176
236
  findings.push({
177
237
  kind: 'unreported-error',
178
238
  severity: 'info',
179
- sessionId,
239
+ // `report.session_id` — the session the error actually lives in, and
240
+ // the same one the detail text below already sends the operator to.
241
+ sessionId: proposal.sessionId,
180
242
  subject: `error ${proposal.fingerprint}${project}`,
181
- detail: `Error ${proposal.fingerprint} in project ${proposal.project} ` +
243
+ detail: `Error ${proposal.fingerprint} in ${projectPhrase} ` +
182
244
  `(${proposal.errorClass}, task ${proposal.taskRef}, ${proposal.occurrences} occurrence(s)) ` +
183
245
  'has no issue-reported event. Report it with ' +
184
- `\`smith issues report --session ${proposal.sessionId}\`; ` +
185
- `\`smith issues preview --session ${proposal.sessionId}\` is the dry run.`,
246
+ `\`bs issues report --session ${proposal.sessionId}\`; ` +
247
+ `\`bs issues preview --session ${proposal.sessionId}\` is the dry run.`,
186
248
  ...(admission === undefined ? {} : { admission }),
187
249
  });
188
250
  }
@@ -208,6 +270,12 @@ export function inspectFactory(events, opts = {}) {
208
270
  policy,
209
271
  ...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
210
272
  ...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
273
+ ...(opts.resolveProjectForTaskRef === undefined
274
+ ? {}
275
+ : { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
276
+ ...(opts.selfFallbackForTaskRef === undefined
277
+ ? {}
278
+ : { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
211
279
  });
212
280
  const admissions = admitFor(proposals, events, opts.admission);
213
281
  for (const [index, proposal] of proposals.entries()) {
@@ -273,8 +341,8 @@ export function inspectFactory(events, opts = {}) {
273
341
  detail: `The last epic this factory closed ran narrow: its widest wave was admitted for ` +
274
342
  `${newest.widest.declared} tasks and ${newest.widest.observed} ran ` +
275
343
  `(closed ${newest.closedAt}, ${width.serialized.length} of ${width.epics.length} ` +
276
- 'closes read here are narrow). `smith epic width` reads every close back; ' +
277
- '`smith wave audit --session <id>` reads the waves behind a live one.',
344
+ 'closes read here are narrow). `bs epic width` reads every close back; ' +
345
+ '`bs wave audit --session <id>` reads the waves behind a live one.',
278
346
  });
279
347
  }
280
348
  else if (width.hint === UNMEASURED_HINT) {
@@ -312,8 +380,8 @@ export function inspectFactory(events, opts = {}) {
312
380
  subject: ref.dir,
313
381
  detail: `${ref.self ? 'This clone' : `\`${ref.name}\`, built by this factory`} is not in ` +
314
382
  'this pass, so nothing is reading its lockfile and no maintenance ' +
315
- `proposal can ever name it. Add \`--project ${ref.dir}\` to \`smith daemon ` +
316
- 'run|start` (and to `smith scheduler run|admit`). `smith projects list` ' +
383
+ `proposal can ever name it. Add \`--project ${ref.dir}\` to \`bs daemon ` +
384
+ 'run|start` (and to `bs scheduler run|admit`). `bs projects list` ' +
317
385
  'prints the whole flag line.',
318
386
  });
319
387
  }
@@ -343,21 +411,29 @@ function errorMessage(err) {
343
411
  /**
344
412
  * One pass over the state directory.
345
413
  *
346
- * Two rules hold it together. It reads every log ONCE and folds lineages in
347
- * memory, so a five-session lineage reports one budget alarm rather than five
348
- * (D-119's argument, read from the other end). And no single unreadable log or
349
- * failed projection ends the tick: both become findings, because a watchdog
350
- * that dies on the first corrupt line is a watchdog that is silent exactly
351
- * when something is wrong.
414
+ * Two rules hold it together. It reads every log ONCE and folds each lineage
415
+ * TREE in memory — not once per leaf — so a five-session chain reports one
416
+ * budget alarm rather than five (D-119's argument, read from the other end),
417
+ * and a base session that forked into several wave children reports it once
418
+ * rather than once per sibling. And no single unreadable log or failed
419
+ * projection ends the tick: both become findings, because a watchdog that
420
+ * dies on the first corrupt line is a watchdog that is silent exactly when
421
+ * something is wrong.
352
422
  */
353
423
  export async function runTick(opts = {}) {
354
424
  const now = opts.now ?? new Date();
355
425
  const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
356
426
  const findings = [];
427
+ // No shared cache passed in: a fresh one is cold for every session, so this
428
+ // reads identically to the old `readEvents`-per-session loop — one full
429
+ // read per log, this call only.
430
+ const cache = opts.logCache ?? new LogCache();
357
431
  const logs = new Map();
432
+ const liveSessionIds = new Set();
358
433
  for (const sessionId of listSessionIds(stateDir)) {
434
+ liveSessionIds.add(sessionId);
359
435
  try {
360
- logs.set(sessionId, await readEvents(sessionId, { stateDir }));
436
+ logs.set(sessionId, await cache.read(sessionId, { stateDir }));
361
437
  }
362
438
  catch (err) {
363
439
  findings.push({
@@ -369,6 +445,13 @@ export async function runTick(opts = {}) {
369
445
  });
370
446
  }
371
447
  }
448
+ // Sessions that disappear from `listSessionIds` are evicted here, on the
449
+ // next tick, so the cache's memory is bounded by live logs, not by
450
+ // history. Driven by the LISTED set, not the read-succeeded set: a session
451
+ // that is listed but unreadable this tick keeps its entry. On a fresh,
452
+ // unshared cache (`opts.logCache` undefined) this is a no-op -- nothing
453
+ // was cached to evict.
454
+ cache.retainOnly(liveSessionIds);
372
455
  const parentOf = new Map();
373
456
  for (const [sessionId, events] of logs) {
374
457
  const root = events.find((e) => e.record.event_type === ROOT_EVENT_TYPE);
@@ -390,17 +473,59 @@ export async function runTick(opts = {}) {
390
473
  ancestors.add(parent);
391
474
  }
392
475
  const leaves = [...logs.keys()].filter((id) => !ancestors.has(id)).sort();
393
- const lineageOf = (leaf) => {
394
- const chain = [];
476
+ // parentOf inverted: a session's immediate children among the sessions this
477
+ // tick loaded. Lets a shared ancestor's descendants be walked forward once,
478
+ // instead of re-walked backward once per leaf below it.
479
+ const childrenOf = new Map();
480
+ for (const [childId, parent] of parentOf) {
481
+ if (parent === null || !logs.has(parent))
482
+ continue;
483
+ const siblings = childrenOf.get(parent) ?? [];
484
+ siblings.push(childId);
485
+ childrenOf.set(parent, siblings);
486
+ }
487
+ // The furthest ancestor of `id` that this tick still holds a log for — the
488
+ // root of the tree `id` belongs to, and the same root for every session in
489
+ // that tree regardless of which leaf asks.
490
+ const rootOf = (id) => {
491
+ let current = id;
395
492
  const seen = new Set();
396
- let current = leaf;
397
- while (current !== null && logs.has(current) && !seen.has(current)) {
493
+ while (true) {
494
+ const parent = parentOf.get(current) ?? null;
495
+ if (parent === null || !logs.has(parent) || seen.has(current))
496
+ return current;
398
497
  seen.add(current);
399
- chain.unshift(current);
400
- current = parentOf.get(current) ?? null;
498
+ current = parent;
499
+ }
500
+ };
501
+ // Every session reachable from `root` by walking children — the whole tree
502
+ // a lineage forks into, not just the one leaf that happened to ask for it.
503
+ // Guarded like rootOf: a cycle in causal parents must cost a tick nothing,
504
+ // not grow this list until the process dies.
505
+ const treeSessions = (root) => {
506
+ const all = [root];
507
+ const seen = new Set(all);
508
+ for (let i = 0; i < all.length; i += 1) {
509
+ for (const child of childrenOf.get(all[i]) ?? []) {
510
+ if (seen.has(child))
511
+ continue;
512
+ seen.add(child);
513
+ all.push(child);
514
+ }
401
515
  }
402
- return chain.map((sessionId) => ({ sessionId, events: logs.get(sessionId) ?? [] }));
516
+ return all;
403
517
  };
518
+ // Leaves grouped by the tree they fork from. A base session with several
519
+ // wave children is one tree with several leaves — inspected once, not once
520
+ // per sibling, so the shared ancestor's findings are not re-emitted once
521
+ // per fork (the D-119 argument, which a single chain never forks to break).
522
+ const leavesByRoot = new Map();
523
+ for (const leaf of leaves) {
524
+ const root = rootOf(leaf);
525
+ const group = leavesByRoot.get(root) ?? [];
526
+ group.push(leaf);
527
+ leavesByRoot.set(root, group);
528
+ }
404
529
  // A tick reports the admission line by default, where `inspectSession` and
405
530
  // `inspectFactory` stay silent unless asked. The asymmetry is deliberate: a
406
531
  // caller who reaches for one function is answering their own question and
@@ -417,11 +542,18 @@ export async function runTick(opts = {}) {
417
542
  now,
418
543
  admission,
419
544
  ...(opts.budgetPolicy === undefined ? {} : { budgetPolicy: opts.budgetPolicy }),
545
+ ...(opts.planOpts === undefined ? {} : { planOpts: opts.planOpts }),
420
546
  ...(opts.schedulerPolicy === undefined ? {} : { schedulerPolicy: opts.schedulerPolicy }),
421
547
  ...(opts.staleHours === undefined ? {} : { staleHours: opts.staleHours }),
422
548
  ...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
423
549
  ...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
424
550
  ...(opts.readProjects === undefined ? {} : { readProjects: opts.readProjects }),
551
+ ...(opts.resolveProjectForTaskRef === undefined
552
+ ? {}
553
+ : { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
554
+ ...(opts.selfFallbackForTaskRef === undefined
555
+ ? {}
556
+ : { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
425
557
  };
426
558
  const projectDb = opts.projectDb ?? true;
427
559
  const dbPath = opts.dbPath ?? STATE_DB_PATH;
@@ -435,18 +567,57 @@ export async function runTick(opts = {}) {
435
567
  sessionId,
436
568
  subject: dbPath,
437
569
  detail: `The read-model refresh failed: ${errorMessage(err)}. The event log is unaffected — ` +
438
- 'it is the UI and `smith status` that are now stale.',
570
+ 'it is the UI and `bs status` that are now stale.',
439
571
  });
440
- for (const leaf of leaves) {
441
- findings.push(...inspectSession(leaf, mergeSessionLogs(lineageOf(leaf)), inspectOpts));
442
- if (!projectDb)
443
- continue;
444
- try {
445
- await apply(dbPath, leaf, dbOpts);
446
- projected += 1;
572
+ for (const [root, group] of leavesByRoot) {
573
+ // Sorted, so a forked tree attributes its shared findings to the same
574
+ // leaf on every tick, rather than whichever one Map iteration hands back
575
+ // first — the same determinism `leaves` itself already sorts for.
576
+ const primary = [...group].sort()[0];
577
+ const merged = mergeSessionLogs(treeSessions(root).map((sessionId) => ({ sessionId, events: logs.get(sessionId) ?? [] })));
578
+ findings.push(...inspectSession(primary, merged, inspectOpts));
579
+ }
580
+ // The signature a leaf's projection was last applied against: every
581
+ // session in its own upward chain, keyed by the fingerprint the cache read
582
+ // it at. Two ticks that see the same fingerprints for the same chain saw
583
+ // no change anywhere that leaf's own projection or lineage-scoped folds
584
+ // depend on, so re-applying would write the same rows a second time.
585
+ // `opts.logCache === undefined` opts a caller all the way out: with no
586
+ // cache to remember a signature against, every leaf is applied every tick,
587
+ // unchanged from before this cache existed.
588
+ const leafSignature = (leaf) => {
589
+ const chain = [];
590
+ const seen = new Set();
591
+ let current = leaf;
592
+ while (current !== null && !seen.has(current)) {
593
+ seen.add(current);
594
+ chain.push(current);
595
+ const parent = parentOf.get(current) ?? null;
596
+ current = parent !== null && logs.has(parent) ? parent : null;
447
597
  }
448
- catch (err) {
449
- findings.push(projectionFailed(leaf, err));
598
+ return chain
599
+ .map((id) => {
600
+ const fp = cache.fingerprintOf(id);
601
+ return fp === undefined ? `${id}:?` : `${id}:${fp.dev}.${fp.ino}.${fp.size}.${fp.mtimeMs}`;
602
+ })
603
+ .join('|');
604
+ };
605
+ const applyFn = opts.applyFn ?? apply;
606
+ if (projectDb) {
607
+ for (const leaf of leaves) {
608
+ const signature = opts.logCache === undefined ? undefined : leafSignature(leaf);
609
+ if (signature !== undefined && opts.logCache?.getAppliedSignature(leaf) === signature) {
610
+ continue;
611
+ }
612
+ try {
613
+ await applyFn(dbPath, leaf, dbOpts);
614
+ projected += 1;
615
+ if (signature !== undefined)
616
+ opts.logCache?.setAppliedSignature(leaf, signature);
617
+ }
618
+ catch (err) {
619
+ findings.push(projectionFailed(leaf, err));
620
+ }
450
621
  }
451
622
  }
452
623
  // The read-model is not made of sessions alone. `milestones` is a full
@@ -479,6 +650,21 @@ export async function runTick(opts = {}) {
479
650
  // nothing still has repos to tend.
480
651
  const all = [...logs.entries()].map(([sessionId, events]) => ({ sessionId, events }));
481
652
  findings.push(...inspectFactory(mergeSessionLogs(all), inspectOpts));
653
+ // The loop's own memory file failed to read this tick (see `TickOptions`).
654
+ // Filed as a finding, not swallowed: an operator watching the report is the
655
+ // one person who can tell whether "everything reads as new" is real churn
656
+ // or this.
657
+ if (opts.memoryUnreadable !== undefined) {
658
+ findings.push({
659
+ kind: 'unreadable-state',
660
+ severity: 'attention',
661
+ sessionId: null,
662
+ subject: opts.memoryUnreadable.path,
663
+ detail: `${opts.memoryUnreadable.path} exists but cannot be read ` +
664
+ `(${opts.memoryUnreadable.reason}): memory was read as empty, so every ` +
665
+ 'standing finding is dated as new this tick.',
666
+ });
667
+ }
482
668
  const aged = ageFindings(opts.memory ?? {}, findings, now);
483
669
  return {
484
670
  at: now.toISOString(),
@@ -497,21 +683,66 @@ export function lockPath(dir) {
497
683
  export function statusPath(dir) {
498
684
  return path.join(dir, STATUS_FILE);
499
685
  }
500
- function readJsonFile(filePath) {
501
- if (!existsSync(filePath))
502
- return null;
686
+ /**
687
+ * Recoverable failure to read a daemon state file — a corrupt `daemon.pid`,
688
+ * `status.json` or `findings.json`. Always names the file and the reason, and
689
+ * always the same recovery: nothing here suggests `smith daemon stop`, which
690
+ * reads the same file and refuses it the same way.
691
+ */
692
+ function unreadableState(filePath, reason) {
693
+ return new DaemonError('daemon.unreadable-state', `${filePath} exists but cannot be read (${reason}), so it is not treated as ` +
694
+ 'absent. Confirm no `bs daemon run` process is using this directory, ' +
695
+ 'then delete the file.', { path: filePath, reason });
696
+ }
697
+ function isRecordShape(value) {
698
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
699
+ }
700
+ function isLockShape(value) {
701
+ return isRecordShape(value) && typeof value.pid === 'number';
702
+ }
703
+ /**
704
+ * Read and parse one state file, or null when there was none.
705
+ *
706
+ * ABSENT and UNREADABLE are two different outcomes, not one. ABSENT is
707
+ * `readFileSync` throwing ENOENT — the ordinary "no daemon has run here yet"
708
+ * case, answered with null. Everything else — a permission error, a
709
+ * `JSON.parse` failure, or a parsed value the wrong shape for `T` — is
710
+ * UNREADABLE: the file exists, so silently treating it as absent would let a
711
+ * corrupt lock be taken over, a corrupt `status.json` read as "not running",
712
+ * or a corrupt memory read as empty, none of which is true. Reading the file
713
+ * directly and catching ENOENT (rather than `existsSync` then `readFileSync`)
714
+ * also closes the gap where the file could vanish between the two calls.
715
+ */
716
+ function readJsonFile(filePath, isValidShape) {
717
+ let raw;
503
718
  try {
504
- return JSON.parse(readFileSync(filePath, 'utf8'));
719
+ raw = readFileSync(filePath, 'utf8');
505
720
  }
506
- catch {
507
- return null;
721
+ catch (err) {
722
+ const code = err.code;
723
+ if (code === 'ENOENT')
724
+ return null;
725
+ throw unreadableState(filePath, code ?? errorMessage(err));
508
726
  }
727
+ let parsed;
728
+ try {
729
+ parsed = JSON.parse(raw);
730
+ }
731
+ catch (err) {
732
+ throw unreadableState(filePath, errorMessage(err));
733
+ }
734
+ if (!isValidShape(parsed))
735
+ throw unreadableState(filePath, 'unexpected shape');
736
+ return parsed;
509
737
  }
738
+ /**
739
+ * Null when there is no lock to read. Throws `daemon.unreadable-state` when
740
+ * the file exists but cannot be parsed, or parses to something without a
741
+ * numeric `pid` — a half-written file from a crash mid-write looks exactly
742
+ * like this, and it must not be mistaken for "nobody is running".
743
+ */
510
744
  export function readLock(dir) {
511
- const lock = readJsonFile(lockPath(dir));
512
- if (lock === null || typeof lock.pid !== 'number')
513
- return null;
514
- return lock;
745
+ return readJsonFile(lockPath(dir), isLockShape);
515
746
  }
516
747
  /** `kill(pid, 0)` — EPERM means a process exists that this user may not signal. */
517
748
  export function processIsAlive(pid) {
@@ -525,14 +756,20 @@ export function processIsAlive(pid) {
525
756
  }
526
757
  /**
527
758
  * Claim `dir` for `lock.pid`, returning the lock this one displaced (null when
528
- * there was nothing to displace, or nothing readable).
759
+ * there was nothing to displace).
529
760
  *
530
761
  * Two daemons ticking the same state dir would double every projection write
531
762
  * and every finding, so a live incumbent is refused. A DEAD incumbent is not:
532
763
  * a crashed daemon leaves its pid file behind, and refusing to start until a
533
- * human runs `rm` would make the first crash permanent. Likewise an
534
- * unparseable file — half a JSON document is what a crash mid-write looks
535
- * like, and it is evidence of a dead writer, not a live one.
764
+ * human runs `rm` would make the first crash permanent.
765
+ *
766
+ * An UNREADABLE incumbent is refused rather than taken over: an unparseable
767
+ * file could be a crash mid-write, but it could also be a live daemon's lock
768
+ * a filesystem fault damaged out from under it, and the two are not
769
+ * distinguishable from the bytes alone. `readLock` throws
770
+ * `daemon.unreadable-state` in that case, and this function does not catch
771
+ * it — the throw happens before anything is written, so the file's bytes are
772
+ * left exactly as found.
536
773
  */
537
774
  export function acquireLock(dir, lock, opts = {}) {
538
775
  const isAlive = opts.isAlive ?? processIsAlive;
@@ -550,14 +787,28 @@ export function acquireLock(dir, lock, opts = {}) {
550
787
  const incumbent = readLock(dir);
551
788
  if (incumbent !== null && isAlive(incumbent.pid)) {
552
789
  throw new DaemonError('daemon.already-running', `A daemon (pid ${incumbent.pid}, started ${incumbent.startedAt}) already holds ${file}. ` +
553
- 'Stop it with `smith daemon stop` before starting another.', { pid: incumbent.pid, lock_path: file });
790
+ 'Stop it with `bs daemon stop` before starting another.', { pid: incumbent.pid, lock_path: file });
554
791
  }
555
792
  writeFileSync(file, body, 'utf8');
556
793
  return incumbent;
557
794
  }
558
- /** True when this call removed the lock; false when it belongs to someone else. */
795
+ /**
796
+ * True when this call removed the lock; false when it belongs to someone
797
+ * else, or cannot be read at all.
798
+ *
799
+ * Never throws, unlike every other reader of this file: it runs from
800
+ * `runDaemon`'s `finally`, where a throw would replace the loop's own
801
+ * outcome — a tick that failed would be reported instead as "could not
802
+ * release the lock", losing the failure that actually mattered.
803
+ */
559
804
  export function releaseLock(dir, pid) {
560
- const lock = readLock(dir);
805
+ let lock;
806
+ try {
807
+ lock = readLock(dir);
808
+ }
809
+ catch {
810
+ return false;
811
+ }
561
812
  if (lock === null || lock.pid !== pid)
562
813
  return false;
563
814
  rmSync(lockPath(dir), { force: true });
@@ -574,8 +825,9 @@ export function writeStatus(dir, report) {
574
825
  writeFileSync(tmp, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
575
826
  renameSync(tmp, target);
576
827
  }
828
+ /** Throws `daemon.unreadable-state` when the file exists but cannot be parsed. */
577
829
  export function readStatus(dir) {
578
- return readJsonFile(statusPath(dir));
830
+ return readJsonFile(statusPath(dir), isRecordShape);
579
831
  }
580
832
  // ---------------------------------------------------------------------------
581
833
  // The finding memory
@@ -587,15 +839,18 @@ export function memoryPath(dir) {
587
839
  * The only state this daemon carries across ticks, and deliberately the least
588
840
  * it could carry: identity -> when it was first seen.
589
841
  *
590
- * Missing or corrupt reads as empty rather than throwing, for the same reason
591
- * `unreadable-log` is a finding and not a crash. A watchdog that dies over its
592
- * own scratch file is silent exactly when something is wrong, and the cost of
593
- * losing this one is a single tick that calls every standing finding new.
842
+ * Absent reads as empty — the ordinary first-tick case. Corrupt throws
843
+ * `daemon.unreadable-state`: `runDaemon`'s `tickWithMemory` is the one caller
844
+ * that catches it, and turns it into a finding of its own (`TickOptions`'s
845
+ * `memoryUnreadable`) rather than swallowing it, because a watchdog that
846
+ * dies over its own scratch file is silent exactly when something is wrong,
847
+ * but one that quietly reads it as empty hides the same fact from the report
848
+ * meant to surface it.
594
849
  */
595
850
  export function readFindingMemory(dir) {
596
- return readJsonFile(memoryPath(dir)) ?? {};
851
+ return readJsonFile(memoryPath(dir), isRecordShape) ?? {};
597
852
  }
598
- /** tmp-then-rename, like the status file: a half-written memory reads as none. */
853
+ /** tmp-then-rename, like the status file: a crash mid-write leaves the old file whole. */
599
854
  export function writeFindingMemory(dir, memory) {
600
855
  mkdirSync(dir, { recursive: true });
601
856
  const target = memoryPath(dir);
@@ -683,10 +938,15 @@ export async function runDaemon(opts) {
683
938
  const tick = opts.tick ?? runTick;
684
939
  const sleep = opts.sleep ?? defaultSleep;
685
940
  const shouldContinue = opts.shouldContinue ?? (() => true);
941
+ // One cache for the whole run: this is the loop finding 50acc356 is about
942
+ // — the process that survives long enough for "every tick re-reads every
943
+ // byte ever written" to matter.
944
+ const logCache = opts.logCache ?? new LogCache();
686
945
  acquireLock(opts.dir, { pid, startedAt, intervalSeconds }, opts.isAlive === undefined ? {} : { isAlive: opts.isAlive });
687
946
  const tickOptions = {
688
947
  ...(opts.now === undefined ? {} : { now: opts.now }),
689
948
  ...(opts.budgetPolicy === undefined ? {} : { budgetPolicy: opts.budgetPolicy }),
949
+ ...(opts.planOpts === undefined ? {} : { planOpts: opts.planOpts }),
690
950
  ...(opts.schedulerPolicy === undefined ? {} : { schedulerPolicy: opts.schedulerPolicy }),
691
951
  ...(opts.staleHours === undefined ? {} : { staleHours: opts.staleHours }),
692
952
  ...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
@@ -695,12 +955,37 @@ export async function runDaemon(opts) {
695
955
  ...(opts.projectDb === undefined ? {} : { projectDb: opts.projectDb }),
696
956
  ...(opts.dbPath === undefined ? {} : { dbPath: opts.dbPath }),
697
957
  ...(opts.dbOpts === undefined ? {} : { dbOpts: opts.dbOpts }),
958
+ ...(opts.applyFn === undefined ? {} : { applyFn: opts.applyFn }),
959
+ logCache,
698
960
  };
699
961
  // Read from disk rather than held in a variable, so a daemon restarted by
700
962
  // cron or by the operator picks up where the last process stopped. A tick
701
963
  // knows nothing about the interval it runs on; this is what joins two.
964
+ //
965
+ // `readFindingMemory` throws `daemon.unreadable-state` for a corrupt file
966
+ // (never for an absent one). That is the ONE failure this catches: the
967
+ // tick still runs, with an empty memory and `memoryUnreadable` set so the
968
+ // loss shows up as a finding in the very report it affected, rather than
969
+ // failing the loop over its own scratch file (D-21).
702
970
  const tickWithMemory = async () => {
703
- const report = await tick({ ...tickOptions, memory: readFindingMemory(opts.dir) });
971
+ let memory = {};
972
+ let memoryUnreadable;
973
+ try {
974
+ memory = readFindingMemory(opts.dir);
975
+ }
976
+ catch (err) {
977
+ if (!(err instanceof DaemonError) || err.code !== 'daemon.unreadable-state')
978
+ throw err;
979
+ memoryUnreadable = {
980
+ path: String(err.details.path),
981
+ reason: String(err.details.reason),
982
+ };
983
+ }
984
+ const report = await tick({
985
+ ...tickOptions,
986
+ memory,
987
+ ...(memoryUnreadable === undefined ? {} : { memoryUnreadable }),
988
+ });
704
989
  writeStatus(opts.dir, report);
705
990
  writeFindingMemory(opts.dir, memoryOf(report.findings));
706
991
  return report;