@juzser/blacksmith 0.1.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 (172) hide show
  1. package/.claude/agents/auditor.md +146 -0
  2. package/.claude/agents/coder.md +164 -0
  3. package/.claude/agents/grader.md +110 -0
  4. package/.claude/agents/merger.md +107 -0
  5. package/.claude/agents/planner.md +92 -0
  6. package/.claude/agents/researcher.md +105 -0
  7. package/.claude/agents/reviewer.md +171 -0
  8. package/.claude/agents/scribe.md +72 -0
  9. package/.claude/agents/security-reviewer.md +130 -0
  10. package/.claude/agents/spec-reviewer.md +215 -0
  11. package/.claude/agents/tester.md +121 -0
  12. package/.claude/agents/uiux.md +126 -0
  13. package/.claude/agents/verifier.md +103 -0
  14. package/.claude/agents/wave-runner.md +74 -0
  15. package/LICENSE +21 -0
  16. package/README.md +389 -0
  17. package/factory/orchestrator/dist/actors.js +46 -0
  18. package/factory/orchestrator/dist/agents-registry.js +308 -0
  19. package/factory/orchestrator/dist/args.js +84 -0
  20. package/factory/orchestrator/dist/artifacts.js +111 -0
  21. package/factory/orchestrator/dist/attribution.js +160 -0
  22. package/factory/orchestrator/dist/audit.js +812 -0
  23. package/factory/orchestrator/dist/autonomy.js +151 -0
  24. package/factory/orchestrator/dist/budgetAlarm.js +366 -0
  25. package/factory/orchestrator/dist/budgets.js +196 -0
  26. package/factory/orchestrator/dist/claims.js +633 -0
  27. package/factory/orchestrator/dist/cli.js +3185 -0
  28. package/factory/orchestrator/dist/commit.js +152 -0
  29. package/factory/orchestrator/dist/coverage.js +188 -0
  30. package/factory/orchestrator/dist/crossFinding.js +479 -0
  31. package/factory/orchestrator/dist/crosscheck.js +396 -0
  32. package/factory/orchestrator/dist/daemon.js +707 -0
  33. package/factory/orchestrator/dist/db/projector.js +1317 -0
  34. package/factory/orchestrator/dist/db/queries.js +1714 -0
  35. package/factory/orchestrator/dist/db/schema.js +305 -0
  36. package/factory/orchestrator/dist/delegation.js +400 -0
  37. package/factory/orchestrator/dist/diffstat.js +164 -0
  38. package/factory/orchestrator/dist/dispatchAudit.js +275 -0
  39. package/factory/orchestrator/dist/dotenv.js +72 -0
  40. package/factory/orchestrator/dist/effort.js +202 -0
  41. package/factory/orchestrator/dist/epic.js +1018 -0
  42. package/factory/orchestrator/dist/epicWidth.js +238 -0
  43. package/factory/orchestrator/dist/errorIssues.js +249 -0
  44. package/factory/orchestrator/dist/errors.js +11 -0
  45. package/factory/orchestrator/dist/escalation.js +500 -0
  46. package/factory/orchestrator/dist/events.js +1015 -0
  47. package/factory/orchestrator/dist/findingAge.js +91 -0
  48. package/factory/orchestrator/dist/findingContext.js +103 -0
  49. package/factory/orchestrator/dist/findings.js +1156 -0
  50. package/factory/orchestrator/dist/gate.js +911 -0
  51. package/factory/orchestrator/dist/gh.js +146 -0
  52. package/factory/orchestrator/dist/git.js +87 -0
  53. package/factory/orchestrator/dist/goalCheck.js +380 -0
  54. package/factory/orchestrator/dist/graph.js +61 -0
  55. package/factory/orchestrator/dist/hookDecision.js +106 -0
  56. package/factory/orchestrator/dist/immutability.js +171 -0
  57. package/factory/orchestrator/dist/impact.js +280 -0
  58. package/factory/orchestrator/dist/integration.js +141 -0
  59. package/factory/orchestrator/dist/judgePreflight.js +155 -0
  60. package/factory/orchestrator/dist/judges.js +310 -0
  61. package/factory/orchestrator/dist/lessonAudit.js +399 -0
  62. package/factory/orchestrator/dist/lessons.js +1329 -0
  63. package/factory/orchestrator/dist/mcp.js +555 -0
  64. package/factory/orchestrator/dist/paths.js +82 -0
  65. package/factory/orchestrator/dist/plan.js +502 -0
  66. package/factory/orchestrator/dist/planQuorum.js +421 -0
  67. package/factory/orchestrator/dist/policy.js +1308 -0
  68. package/factory/orchestrator/dist/policyHook.js +28 -0
  69. package/factory/orchestrator/dist/preconditions.js +47 -0
  70. package/factory/orchestrator/dist/projects.js +134 -0
  71. package/factory/orchestrator/dist/prompts.js +57 -0
  72. package/factory/orchestrator/dist/provenance.js +201 -0
  73. package/factory/orchestrator/dist/providers/api-transport.js +117 -0
  74. package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
  75. package/factory/orchestrator/dist/providers/index.js +54 -0
  76. package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
  77. package/factory/orchestrator/dist/providers/types.js +49 -0
  78. package/factory/orchestrator/dist/queue.js +263 -0
  79. package/factory/orchestrator/dist/quorum.js +347 -0
  80. package/factory/orchestrator/dist/quorumEscalations.js +132 -0
  81. package/factory/orchestrator/dist/results.js +65 -0
  82. package/factory/orchestrator/dist/roadmap.js +437 -0
  83. package/factory/orchestrator/dist/runtime.js +86 -0
  84. package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
  85. package/factory/orchestrator/dist/sandbox.js +165 -0
  86. package/factory/orchestrator/dist/scaffold.js +373 -0
  87. package/factory/orchestrator/dist/scheduler.js +408 -0
  88. package/factory/orchestrator/dist/schemas.js +195 -0
  89. package/factory/orchestrator/dist/security.js +222 -0
  90. package/factory/orchestrator/dist/severity.js +293 -0
  91. package/factory/orchestrator/dist/spec.js +376 -0
  92. package/factory/orchestrator/dist/specChange.js +497 -0
  93. package/factory/orchestrator/dist/stack.js +324 -0
  94. package/factory/orchestrator/dist/symbols.js +966 -0
  95. package/factory/orchestrator/dist/taskEvents.js +399 -0
  96. package/factory/orchestrator/dist/taskId.js +126 -0
  97. package/factory/orchestrator/dist/taxonomy.js +113 -0
  98. package/factory/orchestrator/dist/testSelect.js +129 -0
  99. package/factory/orchestrator/dist/testerAudit.js +264 -0
  100. package/factory/orchestrator/dist/testgate.js +171 -0
  101. package/factory/orchestrator/dist/usage.js +874 -0
  102. package/factory/orchestrator/dist/waivers.js +228 -0
  103. package/factory/orchestrator/dist/waveBudget.js +223 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +250 -0
  105. package/factory/orchestrator/dist/waveNext.js +210 -0
  106. package/factory/orchestrator/dist/waveSchedule.js +146 -0
  107. package/factory/orchestrator/dist/worktree.js +207 -0
  108. package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
  109. package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
  110. package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
  111. package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
  112. package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
  113. package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
  114. package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
  115. package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
  116. package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
  117. package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
  118. package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
  119. package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
  120. package/factory/policies/budgets.yml +262 -0
  121. package/factory/policies/crosscheck.yml +308 -0
  122. package/factory/policies/delegation.yml +44 -0
  123. package/factory/policies/effort.yml +142 -0
  124. package/factory/policies/guardrails.yml +306 -0
  125. package/factory/policies/lessons.md +239 -0
  126. package/factory/policies/scheduler.yml +90 -0
  127. package/factory/policies/sensitive-paths.yml +148 -0
  128. package/factory/policies/severity.yml +83 -0
  129. package/factory/policies/stack.yml +60 -0
  130. package/factory/policies/taxonomy.yml +340 -0
  131. package/factory/policies/worktree.yml +95 -0
  132. package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
  133. package/factory/scaffold/base/.gitignore.tmpl +11 -0
  134. package/factory/scaffold/base/AGENTS.md +33 -0
  135. package/factory/scaffold/base/README.md +25 -0
  136. package/factory/scaffold/base/biome.json.tmpl +30 -0
  137. package/factory/scaffold/base/package.json +25 -0
  138. package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
  139. package/factory/scaffold/base/src/index.ts +4 -0
  140. package/factory/scaffold/base/test/index.test.ts +8 -0
  141. package/factory/scaffold/base/tsconfig.json +20 -0
  142. package/factory/scaffold/base/tsconfig.test.json +9 -0
  143. package/factory/scaffold/base/vitest.config.ts +29 -0
  144. package/factory/scaffold/mcp/mcp.manifest.json +22 -0
  145. package/factory/scaffold/mcp/package.fragment.json +9 -0
  146. package/factory/scaffold/mcp/src/mcp/README.md +63 -0
  147. package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
  148. package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
  149. package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
  150. package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
  151. package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
  152. package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
  153. package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
  154. package/factory/scaffold/ui/index.html +12 -0
  155. package/factory/scaffold/ui/package.fragment.json +13 -0
  156. package/factory/scaffold/ui/src/App.vue +10 -0
  157. package/factory/scaffold/ui/src/main.ts +5 -0
  158. package/factory/scaffold/ui/src/shims.d.ts +11 -0
  159. package/factory/scaffold/ui/vite.config.ts +20 -0
  160. package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
  161. package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
  162. package/factory/specs/schema/event.schema.json +82 -0
  163. package/factory/specs/schema/finding-evidence.schema.json +45 -0
  164. package/factory/specs/schema/finding.schema.json +115 -0
  165. package/factory/specs/schema/grader-verdict.schema.json +50 -0
  166. package/factory/specs/schema/judge-verdict.schema.json +19 -0
  167. package/factory/specs/schema/lesson.schema.json +85 -0
  168. package/factory/specs/schema/mcp-manifest.schema.json +230 -0
  169. package/factory/specs/schema/result.schema.json +81 -0
  170. package/factory/specs/schema/spec-change-request.schema.json +46 -0
  171. package/factory/specs/schema/task-spec.schema.json +134 -0
  172. package/package.json +98 -0
@@ -0,0 +1,707 @@
1
+ // Phase 10's background watcher (`smith daemon`). It closes the one gap the
2
+ // roadmap still names: knowing what the factory needs required an operator to
3
+ // keep a session open and re-run `smith status` by hand.
4
+ //
5
+ // What it is NOT is the more tempting thing. This daemon never dispatches an
6
+ // agent, never merges, never writes to a worktree; scheduler.ts says why in
7
+ // its own words ("Never dispatches an agent itself — architecture §12"), and a
8
+ // process that survives the operator's terminal is the last place to relax
9
+ // that. Its whole output is a list of findings and one refreshed projection.
10
+ //
11
+ // Everything it reports is derived from the event log by folds this repo
12
+ // already owns — budgetAlarm.ts for spend, agents-registry.ts for stalls,
13
+ // scheduler.ts for work that is due. Nothing here re-derives them, so the
14
+ // daemon and `smith status` can never disagree.
15
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { DEFAULT_STALE_HOURS, detectStale, foldAgents, } from './agents-registry.js';
18
+ import { admitProposals, } from './autonomy.js';
19
+ import { checkBudgetAlarm } from './budgetAlarm.js';
20
+ import { loadBudgetPolicy } from './budgets.js';
21
+ import { loadCrosscheckPolicy } from './crosscheck.js';
22
+ import { apply, foldTasks, rebuild } from './db/projector.js';
23
+ import { summariseEpicWidth, UNMEASURED_HINT } from './epicWidth.js';
24
+ import { SmithError } from './errors.js';
25
+ import { listSessionIds, mergeSessionLogs, parseEventId, readEvents, } from './events.js';
26
+ import { ageFindings, memoryOf } from './findingAge.js';
27
+ import { STATE_DAEMON_DIR, STATE_DB_PATH, STATE_EVENTS_DIR } from './paths.js';
28
+ import { unwatchedProjects } from './projects.js';
29
+ import { computeProposals, loadSchedulerPolicy, } from './scheduler.js';
30
+ import { foldSpecChanges } from './specChange.js';
31
+ export class DaemonError extends SmithError {
32
+ }
33
+ /** A session log's first event, and the only one allowed to name another session. */
34
+ const ROOT_EVENT_TYPE = 'session-start';
35
+ const LOCK_FILE = 'daemon.pid';
36
+ const STATUS_FILE = 'status.json';
37
+ const MEMORY_FILE = 'findings.json';
38
+ export const DEFAULT_INTERVAL_SECONDS = 300;
39
+ /**
40
+ * One admission per proposal, positionally — `admitProposals` returns them in
41
+ * the order given, which is the only reason the callers may index alongside
42
+ * their own loop.
43
+ *
44
+ * The claims are folded here rather than passed in because a `RecheckProposal`
45
+ * names a task and no paths: without them the security match sees an opaque id
46
+ * and clears a recheck of `src/auth/session.ts`. They come out of the same
47
+ * `foldTasks` the CLI reads, from the events the caller already holds.
48
+ */
49
+ function admitFor(proposals, events, lens) {
50
+ if (lens === undefined || proposals.length === 0)
51
+ return proposals.map(() => undefined);
52
+ const claimsByTask = new Map();
53
+ for (const task of foldTasks(events)) {
54
+ if (task.claims && task.claims.length > 0)
55
+ claimsByTask.set(task.taskId, task.claims);
56
+ }
57
+ return admitProposals(proposals, lens.autonomy, {
58
+ securityKeywords: lens.securityKeywords,
59
+ claimsByTask,
60
+ }).map(({ decision, code, reason }) => ({ decision, code, reason }));
61
+ }
62
+ function staleSubject(agent) {
63
+ return agent.taskId ?? agent.epicId ?? agent.agentId ?? agent.id;
64
+ }
65
+ /**
66
+ * Everything one session's lineage says about itself: spend against the epic
67
+ * cap, dispatches nobody can be billed for, agents that never came back, and
68
+ * completed work the recheck policy says is due another look.
69
+ *
70
+ * Pure — `events` is already-read, `now` is threaded in. The disk work is
71
+ * runTick's.
72
+ */
73
+ export function inspectSession(sessionId, events, opts = {}) {
74
+ const now = opts.now ?? new Date();
75
+ const budgetPolicy = opts.budgetPolicy ?? loadBudgetPolicy();
76
+ const schedulerPolicy = opts.schedulerPolicy ?? loadSchedulerPolicy();
77
+ const staleHours = opts.staleHours ?? DEFAULT_STALE_HOURS;
78
+ const findings = [];
79
+ const budget = checkBudgetAlarm(events, budgetPolicy, { sessionId });
80
+ for (const epic of budget.epics) {
81
+ // `under` is the only status that is an answer rather than a question.
82
+ if (epic.status === 'under')
83
+ continue;
84
+ findings.push({
85
+ kind: 'budget',
86
+ severity: epic.status === 'unverifiable' ? 'info' : 'attention',
87
+ sessionId,
88
+ subject: epic.epicId,
89
+ // The status leads, and the numbers behind it are budgetAlarm's own
90
+ // sentence — two files describing one spend in two registers is how the
91
+ // daemon and `smith budget` start disagreeing.
92
+ detail: `${epic.status}: ${epic.detail}`,
93
+ });
94
+ }
95
+ if (budget.unattributedDispatches > 0) {
96
+ findings.push({
97
+ kind: 'unattributed-spend',
98
+ severity: 'info',
99
+ sessionId,
100
+ subject: `${budget.unattributedDispatches} dispatch(es)`,
101
+ detail: `${budget.unattributedDispatches} dispatch(es) (${budget.unattributedRoles.join(', ')}) ` +
102
+ 'name no epic, so their tokens are in no cap. Attribute them or accept that the ' +
103
+ 'session total is a floor.',
104
+ });
105
+ }
106
+ for (const agent of detectStale(foldAgents(events), now.toISOString(), staleHours)) {
107
+ findings.push({
108
+ kind: 'stale-agent',
109
+ severity: 'attention',
110
+ sessionId,
111
+ subject: staleSubject(agent),
112
+ detail: `${agent.agentRole} (${agent.provider}/${agent.modelTier}) has been live for ` +
113
+ `${agent.liveHours.toFixed(1)}h with no result, error or supersession — past the ` +
114
+ `${staleHours}h threshold. Dispatched ${agent.dispatchedAt}.`,
115
+ });
116
+ }
117
+ // A proposal nobody has answered. `blocking` is the worker's own word for
118
+ // "I cannot go further without this", which makes an unanswered blocking
119
+ // proposal a stalled task and not a queue item — the same split
120
+ // research_request draws.
121
+ //
122
+ // Folded, never staleness-checked: `isStale` reads the plan directory and
123
+ // this function is pure. The daemon's claim is that a decision is
124
+ // outstanding, which stays true whether or not a later version has overtaken
125
+ // the diff; `smith plan proposals` is where that second question is answered.
126
+ for (const proposal of foldSpecChanges(events, { status: 'open' })) {
127
+ findings.push({
128
+ kind: 'spec-change',
129
+ severity: proposal.blocking ? 'attention' : 'info',
130
+ sessionId,
131
+ subject: proposal.taskId,
132
+ detail: `${proposal.proposedBy} proposes amending ${proposal.criterionRef}: ${proposal.assumption} ` +
133
+ `(${proposal.severity}, ${proposal.blocking ? 'blocking' : 'non-blocking'}, ` +
134
+ `${proposal.sites.length} site(s)). Answer it with \`smith plan approve ` +
135
+ `${proposal.proposalId}\` or \`smith plan reject ${proposal.proposalId}\`.`,
136
+ });
137
+ }
138
+ const proposals = computeProposals({ events, now, policy: schedulerPolicy });
139
+ const admissions = admitFor(proposals, events, opts.admission);
140
+ for (const [index, proposal] of proposals.entries()) {
141
+ if (proposal.kind !== 'recheck')
142
+ continue;
143
+ const admission = admissions[index];
144
+ findings.push({
145
+ kind: 'recheck',
146
+ severity: 'info',
147
+ sessionId,
148
+ subject: proposal.taskId,
149
+ detail: `Recheck due (${proposal.reasons.join(', ')}): ${proposal.mergeCount} later overlapping ` +
150
+ `merge(s), ${proposal.daysElapsed} day(s) elapsed, confidence ${proposal.confidence}.`,
151
+ ...(admission === undefined ? {} : { admission }),
152
+ });
153
+ }
154
+ return findings;
155
+ }
156
+ /**
157
+ * The findings that belong to the repo and not to any one session: the growth
158
+ * cadence and the dependency-maintenance pass.
159
+ *
160
+ * Split out of inspectSession deliberately. Both are answered from the whole
161
+ * log, and a cadence attributed to a session is a cadence reported once per
162
+ * session — five open sessions, five identical "growth review due" lines, none
163
+ * of which is about that session.
164
+ */
165
+ export function inspectFactory(events, opts = {}) {
166
+ const now = opts.now ?? new Date();
167
+ const policy = opts.schedulerPolicy ?? loadSchedulerPolicy();
168
+ const findings = [];
169
+ const proposals = computeProposals({
170
+ events,
171
+ now,
172
+ policy,
173
+ ...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
174
+ ...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
175
+ });
176
+ const admissions = admitFor(proposals, events, opts.admission);
177
+ for (const [index, proposal] of proposals.entries()) {
178
+ const admission = admissions[index];
179
+ if (proposal.kind === 'maintenance') {
180
+ findings.push({
181
+ kind: 'maintenance',
182
+ severity: 'info',
183
+ sessionId: null,
184
+ subject: `${proposal.projectDir}: ${proposal.packages.length} package(s)`,
185
+ detail: `${proposal.packages.map((p) => `${p.name} ${p.current}→${p.latest}`).join(', ')} ` +
186
+ `(confidence ${proposal.confidence}, ` +
187
+ `${proposal.autoSchedulable ? 'auto-schedulable' : 'needs an operator'}).`,
188
+ ...(admission === undefined ? {} : { admission }),
189
+ });
190
+ }
191
+ else if (proposal.kind === 'growth-review-due' && events.length > 0) {
192
+ // A cadence needs a log to be a cadence about. `proposeGrowthReview`
193
+ // fires immediately on `lastReviewAt === null`, which is right for a
194
+ // factory that has been working and never reviewed itself and wrong for
195
+ // one that has never done anything at all -- and the second is what an
196
+ // empty log means. The gate belongs on this branch alone: the pass also
197
+ // reads lockfiles and the project register, and neither of those waits
198
+ // on a session to have happened.
199
+ findings.push({
200
+ kind: 'growth-review',
201
+ severity: 'info',
202
+ sessionId: null,
203
+ subject: 'growth-review',
204
+ detail: `A product-growth review is due on the ${proposal.cadenceDays}-day cadence ` +
205
+ `(last: ${proposal.lastReviewAt ?? 'never'}). The planner reads the living spec; ` +
206
+ 'this is the trigger, not the scope.',
207
+ ...(admission === undefined ? {} : { admission }),
208
+ });
209
+ }
210
+ // Rechecks are deliberately dropped here: inspectSession already reports
211
+ // each against the session that owns it, and a second copy with no session
212
+ // is a duplicate an operator cannot act on.
213
+ }
214
+ // The claim this repo rests on, watched instead of waited on. `smith epic
215
+ // width` answers it over all of history the moment somebody types it — and
216
+ // nobody types it, which is the gap a watcher exists to close.
217
+ //
218
+ // Of the NEWEST close only, and that restriction is the whole design.
219
+ // Closes are immutable and the fold covers every one of them, so reporting
220
+ // the fold here would raise the same `attention` every tick forever over an
221
+ // epic nobody can go back and fix. An attention count that cannot return to
222
+ // zero is precisely what the FindingSeverity note above forbids: it teaches
223
+ // an operator to stop reading the number, and takes the real alarms with it.
224
+ // The newest close is a statement about now — it clears itself the moment a
225
+ // wide epic closes, and all of the history stays one command away.
226
+ const width = summariseEpicWidth(events);
227
+ const newest = width.epics[0];
228
+ if (newest !== undefined) {
229
+ if (width.serialized.includes(newest.epicId)) {
230
+ // The summary's own rule, reused rather than restated, so the daemon and
231
+ // `smith epic width` cannot come to disagree about what narrow means.
232
+ findings.push({
233
+ kind: 'factory-width',
234
+ severity: 'attention',
235
+ sessionId: null,
236
+ subject: newest.epicId,
237
+ detail: `The last epic this factory closed ran narrow: its widest wave was admitted for ` +
238
+ `${newest.widest.declared} tasks and ${newest.widest.observed} ran ` +
239
+ `(closed ${newest.closedAt}, ${width.serialized.length} of ${width.epics.length} ` +
240
+ 'closes read here are narrow). `smith epic width` reads every close back; ' +
241
+ '`smith wave audit --session <id>` reads the waves behind a live one.',
242
+ });
243
+ }
244
+ else if (width.hint === UNMEASURED_HINT) {
245
+ // Work to schedule, not a fault: nothing here is known to be wrong. What
246
+ // is wrong is that nothing is known, which is a different thing and the
247
+ // reason summariseEpicWidth refuses to let this state exit 0.
248
+ findings.push({
249
+ kind: 'factory-width',
250
+ severity: 'info',
251
+ sessionId: null,
252
+ subject: 'unmeasured',
253
+ detail: `${width.epics.length} epic(s) closed here and none recorded how wide it ran. ` +
254
+ UNMEASURED_HINT,
255
+ });
256
+ }
257
+ }
258
+ // The other half of the sentence this repo is built to answer: it maintains
259
+ // itself AND the projects it built. `--project` has been able to name every
260
+ // repo since the maintenance pass went plural, and nothing noticed an
261
+ // operator who named fewer than all of them -- so a project could drop out
262
+ // of maintenance by being forgotten, which is the same silence, one level
263
+ // down, that repeating the flag was meant to end.
264
+ //
265
+ // Clearable in one flag, which is the test the factory-width note above sets
266
+ // for any new attention. The register deliberately omits a project it cannot
267
+ // find on disk, so nothing here can raise an alarm that has no action.
268
+ for (const ref of unwatchedRepos(opts)) {
269
+ findings.push({
270
+ kind: 'unwatched-project',
271
+ severity: 'attention',
272
+ sessionId: null,
273
+ // The resolved directory, for the same two reasons `maintenance` leads
274
+ // with it: sessionId is null so the subject is the only field that can
275
+ // tell two repos apart, and it is the exact string that clears this.
276
+ subject: ref.dir,
277
+ detail: `${ref.self ? 'This clone' : `\`${ref.name}\`, built by this factory`} is not in ` +
278
+ 'this pass, so nothing is reading its lockfile and no maintenance ' +
279
+ `proposal can ever name it. Add \`--project ${ref.dir}\` to \`smith daemon ` +
280
+ 'run|start` (and to `smith scheduler run|admit`). `smith projects list` ' +
281
+ 'prints the whole flag line.',
282
+ });
283
+ }
284
+ return findings;
285
+ }
286
+ /**
287
+ * The repos the register knows and this pass was not pointed at.
288
+ *
289
+ * Wrapped in a catch because the register reads an operator-edited file and
290
+ * this is a watcher: D-21 -- a gate that only reports a fact must never crash
291
+ * over that fact. `factoryProjects` already refuses to throw; this makes an
292
+ * injected reader that does cost nothing but its own finding.
293
+ */
294
+ function unwatchedRepos(opts) {
295
+ if (opts.readProjects === undefined)
296
+ return [];
297
+ try {
298
+ return unwatchedProjects(opts.readProjects(), opts.projectDirs ?? []);
299
+ }
300
+ catch {
301
+ return [];
302
+ }
303
+ }
304
+ function errorMessage(err) {
305
+ return err instanceof Error ? err.message : String(err);
306
+ }
307
+ /**
308
+ * One pass over the state directory.
309
+ *
310
+ * Two rules hold it together. It reads every log ONCE and folds lineages in
311
+ * memory, so a five-session lineage reports one budget alarm rather than five
312
+ * (D-119's argument, read from the other end). And no single unreadable log or
313
+ * failed projection ends the tick: both become findings, because a watchdog
314
+ * that dies on the first corrupt line is a watchdog that is silent exactly
315
+ * when something is wrong.
316
+ */
317
+ export async function runTick(opts = {}) {
318
+ const now = opts.now ?? new Date();
319
+ const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
320
+ const findings = [];
321
+ const logs = new Map();
322
+ for (const sessionId of listSessionIds(stateDir)) {
323
+ try {
324
+ logs.set(sessionId, await readEvents(sessionId, { stateDir }));
325
+ }
326
+ catch (err) {
327
+ findings.push({
328
+ kind: 'unreadable-log',
329
+ severity: 'attention',
330
+ sessionId,
331
+ subject: `${sessionId}.jsonl`,
332
+ detail: `The log could not be read: ${errorMessage(err)}`,
333
+ });
334
+ }
335
+ }
336
+ const parentOf = new Map();
337
+ for (const [sessionId, events] of logs) {
338
+ const root = events.find((e) => e.record.event_type === ROOT_EVENT_TYPE);
339
+ const parentEventId = root?.record.causal_parent ?? null;
340
+ let parent = null;
341
+ if (parentEventId !== null) {
342
+ try {
343
+ parent = parseEventId(parentEventId).sessionId;
344
+ }
345
+ catch {
346
+ parent = null;
347
+ }
348
+ }
349
+ parentOf.set(sessionId, parent === sessionId ? null : parent);
350
+ }
351
+ const ancestors = new Set();
352
+ for (const parent of parentOf.values()) {
353
+ if (parent !== null && logs.has(parent))
354
+ ancestors.add(parent);
355
+ }
356
+ const leaves = [...logs.keys()].filter((id) => !ancestors.has(id)).sort();
357
+ const lineageOf = (leaf) => {
358
+ const chain = [];
359
+ const seen = new Set();
360
+ let current = leaf;
361
+ while (current !== null && logs.has(current) && !seen.has(current)) {
362
+ seen.add(current);
363
+ chain.unshift(current);
364
+ current = parentOf.get(current) ?? null;
365
+ }
366
+ return chain.map((sessionId) => ({ sessionId, events: logs.get(sessionId) ?? [] }));
367
+ };
368
+ // A tick reports the admission line by default, where `inspectSession` and
369
+ // `inspectFactory` stay silent unless asked. The asymmetry is deliberate: a
370
+ // caller who reaches for one function is answering their own question and
371
+ // may not want two policy files read, whereas the whole point of a tick is
372
+ // to be the surface nobody is watching — a status file that omitted the one
373
+ // column saying whose queue a finding is in would be omitting it precisely
374
+ // when it matters. Reading policy here also fails loudly at the top of the
375
+ // tick rather than silently downgrading a finding to unadmitted.
376
+ const admission = opts.admission ?? {
377
+ autonomy: (opts.schedulerPolicy ?? loadSchedulerPolicy()).autonomy,
378
+ securityKeywords: loadCrosscheckPolicy().planQuorum.securityKeywords,
379
+ };
380
+ const inspectOpts = {
381
+ now,
382
+ admission,
383
+ ...(opts.budgetPolicy === undefined ? {} : { budgetPolicy: opts.budgetPolicy }),
384
+ ...(opts.schedulerPolicy === undefined ? {} : { schedulerPolicy: opts.schedulerPolicy }),
385
+ ...(opts.staleHours === undefined ? {} : { staleHours: opts.staleHours }),
386
+ ...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
387
+ ...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
388
+ ...(opts.readProjects === undefined ? {} : { readProjects: opts.readProjects }),
389
+ };
390
+ const projectDb = opts.projectDb ?? true;
391
+ const dbPath = opts.dbPath ?? STATE_DB_PATH;
392
+ const dbOpts = { stateDir, ...opts.dbOpts };
393
+ let projected = 0;
394
+ // Carries no session when there was none to blame. Clearable either way: the
395
+ // next tick that can open the file drops it.
396
+ const projectionFailed = (sessionId, err) => ({
397
+ kind: 'projection-failed',
398
+ severity: 'attention',
399
+ sessionId,
400
+ subject: dbPath,
401
+ detail: `The read-model refresh failed: ${errorMessage(err)}. The event log is unaffected — ` +
402
+ 'it is the UI and `smith status` that are now stale.',
403
+ });
404
+ for (const leaf of leaves) {
405
+ findings.push(...inspectSession(leaf, mergeSessionLogs(lineageOf(leaf)), inspectOpts));
406
+ if (!projectDb)
407
+ continue;
408
+ try {
409
+ await apply(dbPath, leaf, dbOpts);
410
+ projected += 1;
411
+ }
412
+ catch (err) {
413
+ findings.push(projectionFailed(leaf, err));
414
+ }
415
+ }
416
+ // The read-model is not made of sessions alone. `milestones` is a full
417
+ // replacement of roadmap.md on every projector call -- projector.ts calls it
418
+ // "not session-scoped" in as many words -- and `apply()` inside the loop
419
+ // above was the only thing a running daemon ever called. So a tick that
420
+ // folded no session refreshed nothing, and on a fresh clone that meant no
421
+ // database file at all: the operator writes a roadmap, starts the watcher,
422
+ // opens the UI, and the Roadmap view and the project switcher are both
423
+ // empty until somebody happens to run `smith db rebuild` by hand. Rebuilding
424
+ // is the honest answer to "no session to fold" and not merely the
425
+ // convenient one: with an empty log it clears nothing and projects the
426
+ // roadmap, which is exactly what that hand-run command does. It stays cheap
427
+ // because it is unreachable the moment one session exists.
428
+ if (projectDb && leaves.length === 0) {
429
+ try {
430
+ await rebuild(dbPath, 'all', dbOpts);
431
+ }
432
+ catch (err) {
433
+ findings.push(projectionFailed(null, err));
434
+ }
435
+ }
436
+ // Always, including on a clone whose event log is empty. This used to be
437
+ // gated on there being a session to fold, which suppressed the right half
438
+ // for the wrong reason: growth-review is the only finding here that is a
439
+ // claim about elapsed history, and `inspectFactory` now gates that branch
440
+ // itself. Maintenance reads lockfiles and `unwatched-project` reads the
441
+ // register; a dependency falls behind on the registry's clock and a repo
442
+ // goes unwatched the moment it is scaffolded, so a factory that has built
443
+ // nothing still has repos to tend.
444
+ const all = [...logs.entries()].map(([sessionId, events]) => ({ sessionId, events }));
445
+ findings.push(...inspectFactory(mergeSessionLogs(all), inspectOpts));
446
+ const aged = ageFindings(opts.memory ?? {}, findings, now);
447
+ return {
448
+ at: now.toISOString(),
449
+ sessions: leaves,
450
+ findings: aged,
451
+ attention: aged.filter((f) => f.severity === 'attention').length,
452
+ newAttention: aged.filter((f) => f.severity === 'attention' && f.isNew).length,
453
+ autoAdmitted: aged.filter((f) => f.admission?.decision === 'auto').length,
454
+ operatorHeld: aged.filter((f) => f.admission?.decision === 'operator').length,
455
+ projected,
456
+ };
457
+ }
458
+ export function lockPath(dir) {
459
+ return path.join(dir, LOCK_FILE);
460
+ }
461
+ export function statusPath(dir) {
462
+ return path.join(dir, STATUS_FILE);
463
+ }
464
+ function readJsonFile(filePath) {
465
+ if (!existsSync(filePath))
466
+ return null;
467
+ try {
468
+ return JSON.parse(readFileSync(filePath, 'utf8'));
469
+ }
470
+ catch {
471
+ return null;
472
+ }
473
+ }
474
+ export function readLock(dir) {
475
+ const lock = readJsonFile(lockPath(dir));
476
+ if (lock === null || typeof lock.pid !== 'number')
477
+ return null;
478
+ return lock;
479
+ }
480
+ /** `kill(pid, 0)` — EPERM means a process exists that this user may not signal. */
481
+ export function processIsAlive(pid) {
482
+ try {
483
+ process.kill(pid, 0);
484
+ return true;
485
+ }
486
+ catch (err) {
487
+ return err.code === 'EPERM';
488
+ }
489
+ }
490
+ /**
491
+ * Claim `dir` for `lock.pid`, returning the lock this one displaced (null when
492
+ * there was nothing to displace, or nothing readable).
493
+ *
494
+ * Two daemons ticking the same state dir would double every projection write
495
+ * and every finding, so a live incumbent is refused. A DEAD incumbent is not:
496
+ * a crashed daemon leaves its pid file behind, and refusing to start until a
497
+ * human runs `rm` would make the first crash permanent. Likewise an
498
+ * unparseable file — half a JSON document is what a crash mid-write looks
499
+ * like, and it is evidence of a dead writer, not a live one.
500
+ */
501
+ export function acquireLock(dir, lock, opts = {}) {
502
+ const isAlive = opts.isAlive ?? processIsAlive;
503
+ mkdirSync(dir, { recursive: true });
504
+ const file = lockPath(dir);
505
+ const body = `${JSON.stringify(lock, null, 2)}\n`;
506
+ try {
507
+ writeFileSync(file, body, { encoding: 'utf8', flag: 'wx' });
508
+ return null;
509
+ }
510
+ catch (err) {
511
+ if (err.code !== 'EEXIST')
512
+ throw err;
513
+ }
514
+ const incumbent = readLock(dir);
515
+ if (incumbent !== null && isAlive(incumbent.pid)) {
516
+ throw new DaemonError('daemon.already-running', `A daemon (pid ${incumbent.pid}, started ${incumbent.startedAt}) already holds ${file}. ` +
517
+ 'Stop it with `smith daemon stop` before starting another.', { pid: incumbent.pid, lock_path: file });
518
+ }
519
+ writeFileSync(file, body, 'utf8');
520
+ return incumbent;
521
+ }
522
+ /** True when this call removed the lock; false when it belongs to someone else. */
523
+ export function releaseLock(dir, pid) {
524
+ const lock = readLock(dir);
525
+ if (lock === null || lock.pid !== pid)
526
+ return false;
527
+ rmSync(lockPath(dir), { force: true });
528
+ return true;
529
+ }
530
+ // ---------------------------------------------------------------------------
531
+ // The status file
532
+ // ---------------------------------------------------------------------------
533
+ /** tmp-then-rename, so a reader polling this file never sees half a document. */
534
+ export function writeStatus(dir, report) {
535
+ mkdirSync(dir, { recursive: true });
536
+ const target = statusPath(dir);
537
+ const tmp = `${target}.tmp`;
538
+ writeFileSync(tmp, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
539
+ renameSync(tmp, target);
540
+ }
541
+ export function readStatus(dir) {
542
+ return readJsonFile(statusPath(dir));
543
+ }
544
+ // ---------------------------------------------------------------------------
545
+ // The finding memory
546
+ // ---------------------------------------------------------------------------
547
+ export function memoryPath(dir) {
548
+ return path.join(dir, MEMORY_FILE);
549
+ }
550
+ /**
551
+ * The only state this daemon carries across ticks, and deliberately the least
552
+ * it could carry: identity -> when it was first seen.
553
+ *
554
+ * Missing or corrupt reads as empty rather than throwing, for the same reason
555
+ * `unreadable-log` is a finding and not a crash. A watchdog that dies over its
556
+ * own scratch file is silent exactly when something is wrong, and the cost of
557
+ * losing this one is a single tick that calls every standing finding new.
558
+ */
559
+ export function readFindingMemory(dir) {
560
+ return readJsonFile(memoryPath(dir)) ?? {};
561
+ }
562
+ /** tmp-then-rename, like the status file: a half-written memory reads as none. */
563
+ export function writeFindingMemory(dir, memory) {
564
+ mkdirSync(dir, { recursive: true });
565
+ const target = memoryPath(dir);
566
+ const tmp = `${target}.tmp`;
567
+ writeFileSync(tmp, `${JSON.stringify(memory, null, 2)}\n`, 'utf8');
568
+ renameSync(tmp, target);
569
+ }
570
+ /**
571
+ * How long a daemon may go quiet before the silence is itself the finding.
572
+ *
573
+ * Three intervals is the miss-two-heartbeats rule: one late tick is a slow fold
574
+ * of a long log, two in a row is a fault. The floor is there because a tick's
575
+ * cost is not proportional to the interval -- folding the event log takes what
576
+ * it takes -- so `--interval 1` would otherwise report a daemon as wedged for
577
+ * doing exactly the work it was told to do too often.
578
+ */
579
+ const STALE_INTERVALS = 3;
580
+ const STALE_FLOOR_SECONDS = 60;
581
+ function staleToleranceSeconds(lock) {
582
+ // D-21: a report that only states a fact must not crash over that fact, and a
583
+ // hand-edited pid file is a bad lock rather than a reason to have no status.
584
+ const interval = typeof lock.intervalSeconds === 'number' && lock.intervalSeconds > 0
585
+ ? lock.intervalSeconds
586
+ : DEFAULT_INTERVAL_SECONDS;
587
+ return Math.max(interval * STALE_INTERVALS, STALE_FLOOR_SECONDS);
588
+ }
589
+ /** Seconds since `iso`, or null when there is no readable timestamp to date. */
590
+ function ageSeconds(now, iso) {
591
+ if (iso === undefined)
592
+ return null;
593
+ const then = Date.parse(iso);
594
+ if (Number.isNaN(then))
595
+ return null;
596
+ return Math.round((now.getTime() - then) / 1000);
597
+ }
598
+ export function daemonStatus(dir, opts = {}) {
599
+ const isAlive = opts.isAlive ?? processIsAlive;
600
+ const now = opts.now ?? new Date();
601
+ const lock = readLock(dir);
602
+ const lastTick = readStatus(dir);
603
+ const running = lock !== null && isAlive(lock.pid);
604
+ const reportAge = ageSeconds(now, lastTick?.at);
605
+ // The freshest thing this daemon is known to have done. `min` rather than the
606
+ // tick alone: a daemon that restarted after a long quiet spell has done
607
+ // something more recent than its predecessor's last report, and dating it
608
+ // from that report would alarm on a watcher that is two seconds old.
609
+ const lockAge = lock === null ? null : ageSeconds(now, lock.startedAt);
610
+ const ages = [reportAge, lockAge].filter((a) => a !== null);
611
+ const quietFor = ages.length === 0 ? null : Math.min(...ages);
612
+ return {
613
+ running,
614
+ stale: running && lock !== null && quietFor !== null && quietFor > staleToleranceSeconds(lock),
615
+ dir,
616
+ lock,
617
+ lastTick,
618
+ reportAgeSeconds: reportAge,
619
+ };
620
+ }
621
+ /**
622
+ * Deliberately NOT unref'd. An unref'd timer lets node decide the event loop
623
+ * is empty and exit mid-sleep — skipping the `finally` below, and leaving a
624
+ * lock behind naming a pid that is already gone.
625
+ */
626
+ function defaultSleep(ms) {
627
+ return new Promise((resolve) => {
628
+ setTimeout(resolve, ms);
629
+ });
630
+ }
631
+ /**
632
+ * Hold the lock, tick, publish, repeat.
633
+ *
634
+ * The `finally` is the point of the whole function: a loop that exits still
635
+ * holding its lock — because a tick threw, because the operator hit ^C — is a
636
+ * loop nothing can restart without a human deleting a file.
637
+ */
638
+ export async function runDaemon(opts) {
639
+ const pid = opts.pid ?? process.pid;
640
+ const intervalSeconds = opts.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS;
641
+ const startedAt = (opts.now ?? new Date()).toISOString();
642
+ const tick = opts.tick ?? runTick;
643
+ const sleep = opts.sleep ?? defaultSleep;
644
+ const shouldContinue = opts.shouldContinue ?? (() => true);
645
+ acquireLock(opts.dir, { pid, startedAt, intervalSeconds }, opts.isAlive === undefined ? {} : { isAlive: opts.isAlive });
646
+ const tickOptions = {
647
+ ...(opts.now === undefined ? {} : { now: opts.now }),
648
+ ...(opts.budgetPolicy === undefined ? {} : { budgetPolicy: opts.budgetPolicy }),
649
+ ...(opts.schedulerPolicy === undefined ? {} : { schedulerPolicy: opts.schedulerPolicy }),
650
+ ...(opts.staleHours === undefined ? {} : { staleHours: opts.staleHours }),
651
+ ...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
652
+ ...(opts.readProjects === undefined ? {} : { readProjects: opts.readProjects }),
653
+ ...(opts.stateDir === undefined ? {} : { stateDir: opts.stateDir }),
654
+ ...(opts.projectDb === undefined ? {} : { projectDb: opts.projectDb }),
655
+ ...(opts.dbPath === undefined ? {} : { dbPath: opts.dbPath }),
656
+ ...(opts.dbOpts === undefined ? {} : { dbOpts: opts.dbOpts }),
657
+ };
658
+ // Read from disk rather than held in a variable, so a daemon restarted by
659
+ // cron or by the operator picks up where the last process stopped. A tick
660
+ // knows nothing about the interval it runs on; this is what joins two.
661
+ const tickWithMemory = async () => {
662
+ const report = await tick({ ...tickOptions, memory: readFindingMemory(opts.dir) });
663
+ writeStatus(opts.dir, report);
664
+ writeFindingMemory(opts.dir, memoryOf(report.findings));
665
+ return report;
666
+ };
667
+ const reports = [];
668
+ try {
669
+ if (opts.once === true) {
670
+ reports.push(await tickWithMemory());
671
+ return reports;
672
+ }
673
+ while (shouldContinue()) {
674
+ reports.push(await tickWithMemory());
675
+ await sleep(intervalSeconds * 1000);
676
+ }
677
+ return reports;
678
+ }
679
+ finally {
680
+ releaseLock(opts.dir, pid);
681
+ }
682
+ }
683
+ /**
684
+ * SIGTERM the process the lock names, then clear the file.
685
+ *
686
+ * `stopped` answers "did this call signal a running daemon", so a stale lock
687
+ * left by a crash reports `stopped: false` with the pid it cleared — the
688
+ * operator learns the daemon was already gone rather than being told it was
689
+ * just stopped.
690
+ */
691
+ export function stopDaemon(dir, opts = {}) {
692
+ const isAlive = opts.isAlive ?? processIsAlive;
693
+ const kill = opts.kill ??
694
+ ((pid) => {
695
+ process.kill(pid, 'SIGTERM');
696
+ });
697
+ const lock = readLock(dir);
698
+ if (lock === null)
699
+ return { stopped: false, pid: null };
700
+ const alive = isAlive(lock.pid);
701
+ if (alive)
702
+ kill(lock.pid);
703
+ rmSync(lockPath(dir), { force: true });
704
+ return { stopped: alive, pid: lock.pid };
705
+ }
706
+ /** Where `smith daemon` keeps its lock and its last tick, when nobody says otherwise. */
707
+ export const DEFAULT_DAEMON_DIR = STATE_DAEMON_DIR;