@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,308 @@
1
+ import { eventTaskId } from './events.js';
2
+ import { epicOfTaskId } from './taskId.js';
3
+ /**
4
+ * Live agent registry (architecture §7 "active agent count by role/model/
5
+ * provider"). An agent is live between its `dispatch_decision` event and
6
+ * whichever terminal event closes it: `task-result-recorded` (a worker's
7
+ * schema-valid Result landed — Phase 5 addition, see db/projector.ts header
8
+ * comment) or `error-logged` referencing the same task (an agent ref, per
9
+ * the task's terminal-event options).
10
+ *
11
+ * D-23/P9-12 — the correlation key is `(task_id, agent_role)`, not `task_id`
12
+ * alone. The old note here claimed "a real dispatcher never runs two agents
13
+ * on one task_id concurrently"; `/bs run` does exactly that, deliberately,
14
+ * up to four times per task (coder, grader, reviewer, security-reviewer).
15
+ * Under the old key each dispatch superseded the previous one, so in wave 3
16
+ * three agents that finished were recorded as abandoned and the fourth
17
+ * stayed `live` forever. `agent_id` is still only a label — the role is what
18
+ * the events already carry, so nothing new has to be threaded through.
19
+ *
20
+ * `round` rides along on the record because a judge redispatch is a genuine
21
+ * second round and the UI should be able to say which one it is looking at.
22
+ * It is deliberately NOT part of the key: a round-2 dispatch arriving while
23
+ * round 1 is still open means round 1 was given up on, and keying on the
24
+ * round would leave it live forever — the same bug, one level down.
25
+ *
26
+ * A second `dispatch_decision` for the same `(task_id, agent_role)` whose
27
+ * prior dispatch is STILL open (no terminal event yet — the escalation ladder
28
+ * redispatching without the first attempt reporting back) closes the old
29
+ * entry as `superseded` rather than leaving it orphaned at `live`: one role
30
+ * does not run twice on one task at once, so an open-on-redispatch entry is
31
+ * stale bookkeeping, not a genuinely live agent — counting it would corrupt
32
+ * the Overview live-agent count and detectStale().
33
+ *
34
+ * Terminal events name a role where they can: `task-result-recorded` carries
35
+ * the Result's `agent`, `judge-reported` carries `agent_role`, `judge-verdict`
36
+ * carries `agent` (D-160). One that names no role is a statement about the
37
+ * whole task — the queue blocking it, the gate refusing it — and closes every
38
+ * entry still open under that task, because after a task-level failure nobody
39
+ * is still running.
40
+ *
41
+ * Above all of those sits `epic-closed` (D-187). Every close named so far
42
+ * needs a task id to speak through, so an agent whose task simply never
43
+ * reported — and an epic-level dispatch, which has no task id to speak
44
+ * through at all — stayed `live` past the verdict, past the end of the run,
45
+ * forever. The epic's own terminal event is the only thing that can say
46
+ * otherwise, and it closes what is still open under that epic in that session
47
+ * as `abandoned`: not superseded (nothing replaced them) and not an error
48
+ * (they were never judged), just outrun by the run they belonged to.
49
+ */
50
+ export const DISPATCH_EVENT_TYPE = 'dispatch_decision';
51
+ /** Phase 5 addition (deviation, noted in the final report): the terminal counterpart to dispatch_decision. */
52
+ export const TASK_RESULT_EVENT_TYPE = 'task-result-recorded';
53
+ /**
54
+ * P9-11's judge half. A judge's return is its artifact, not a Result: it has
55
+ * no `token_usage`, and minting a zeroed one purely to reuse
56
+ * `task-result-recorded` would put fabricated numbers into the very rows
57
+ * budget accounting sums. So the registry learns a second terminal event
58
+ * rather than the event learning to lie.
59
+ */
60
+ export const JUDGE_REPORT_EVENT_TYPE = 'judge-reported';
61
+ /**
62
+ * The cross-provider judge's return (D-160). `recordJudgeRun` writes a real
63
+ * `dispatch_decision` for every external judge, deliberately, so the run shows
64
+ * up in the same registry and timeline as any other agent (architecture §7) —
65
+ * but an external judge never writes a Result and never closes a judge turn,
66
+ * so neither terminal event above could ever speak for it. Its terminal fact
67
+ * is the `judge-verdict` chained off its own dispatch, and until this was
68
+ * read, an API call that came back in eight seconds stayed `live` until the
69
+ * end of the session and turned up in `detectStale` hours later.
70
+ *
71
+ * The role is under `agent`, not `agent_role` — the key `recordJudgeRun`
72
+ * writes (quorum.ts) — which is why this close is spelled out rather than
73
+ * folded in with judge-reported.
74
+ */
75
+ export const JUDGE_VERDICT_EVENT_TYPE = 'judge-verdict';
76
+ export const ERROR_EVENT_TYPE = 'error-logged';
77
+ /**
78
+ * The epic's own terminal fact (D-187). Every close above speaks for a single
79
+ * task, so an agent whose task never reported — and an epic-level dispatch,
80
+ * whose `task_id` is absent by design (event.schema.json: "absent for
81
+ * session-level or epic-level events") and which therefore has no task any
82
+ * close could name — stayed `live` for good. `epic-closed` is the one event
83
+ * that says the run those agents belong to is over: whatever was still open
84
+ * under that epic was abandoned when the verdict landed, not left running.
85
+ */
86
+ export const EPIC_CLOSED_EVENT_TYPE = 'epic-closed';
87
+ /**
88
+ * Every event type `foldAgents` acts on — the dispatch that opens an entry and
89
+ * every terminal that closes one. Exported for callers that slice the event
90
+ * log before folding it (D-161): a slice narrower than this list feeds the
91
+ * fold a history in which agents are dispatched and never finish, and the
92
+ * result is not a smaller answer but a wrong one. Anything that reuses the
93
+ * fold has to reuse its alphabet too.
94
+ */
95
+ export const REGISTRY_EVENT_TYPES = [
96
+ DISPATCH_EVENT_TYPE,
97
+ TASK_RESULT_EVENT_TYPE,
98
+ JUDGE_REPORT_EVENT_TYPE,
99
+ JUDGE_VERDICT_EVENT_TYPE,
100
+ ERROR_EVENT_TYPE,
101
+ EPIC_CLOSED_EVENT_TYPE,
102
+ ];
103
+ /** Runtime mirror of `AgentStatus` (a type-only union has nothing to iterate
104
+ * at runtime) — lets ui/test/taxonomy.test.ts assert its status->tone map
105
+ * covers this list exactly, same drift-guard pattern as roadmap.ts's
106
+ * MILESTONE_STATUSES. */
107
+ export const AGENT_STATUSES = [
108
+ 'live',
109
+ 'done',
110
+ 'error',
111
+ 'superseded',
112
+ 'abandoned',
113
+ ];
114
+ const STATUS_FOR_TERMINAL = {
115
+ result: 'done',
116
+ error: 'error',
117
+ superseded: 'superseded',
118
+ abandoned: 'abandoned',
119
+ };
120
+ /** The open-entry key — the correlation pair, `(task_id, agent_role)`. */
121
+ function openKey(taskId, role) {
122
+ return `${taskId}\u0000${role}`;
123
+ }
124
+ function closeEntry(open, eventId, ts, terminalType) {
125
+ open.terminalEventId = eventId;
126
+ open.terminalAt = ts;
127
+ open.terminalType = terminalType;
128
+ open.status = STATUS_FOR_TERMINAL[terminalType];
129
+ }
130
+ /**
131
+ * Close what this terminal event actually speaks for: one role when it names
132
+ * one, every open entry under the task when it does not.
133
+ */
134
+ function closeOpen(open, taskId, role, eventId, ts, terminalType) {
135
+ if (role) {
136
+ const entry = open.get(openKey(taskId, role));
137
+ if (!entry)
138
+ return;
139
+ closeEntry(entry, eventId, ts, terminalType);
140
+ open.delete(openKey(taskId, role));
141
+ return;
142
+ }
143
+ for (const [key, entry] of open) {
144
+ if (entry.taskId !== taskId)
145
+ continue;
146
+ closeEntry(entry, eventId, ts, terminalType);
147
+ open.delete(key);
148
+ }
149
+ }
150
+ /**
151
+ * Fold a session's full event history into agent registry rows. Pure
152
+ * function over the event list — used identically by db/projector.ts's
153
+ * full rebuild and incremental apply paths (no separate incremental state
154
+ * machine to drift from this one).
155
+ */
156
+ export function foldAgents(events) {
157
+ const open = new Map();
158
+ // Dispatches with no task id have no correlation key, so they cannot live in
159
+ // `open` — but they are still open, and `epic-closed` still speaks for them.
160
+ let openEpicLevel = [];
161
+ const records = [];
162
+ for (const { event_id, record } of events) {
163
+ if (record.event_type === DISPATCH_EVENT_TYPE) {
164
+ const payload = record.payload;
165
+ if (!payload.agent_role || !payload.provider || !payload.model_tier)
166
+ continue;
167
+ // Both levels, because a hand-written dispatch names its task in the
168
+ // payload and leaves the envelope null (D-245) — and D-244's rule that
169
+ // `task_id: ""` names no task now holds at either level. Read one level,
170
+ // or read `''` as an id, and the agent is filed as epic-level while
171
+ // every task-scoped terminal branch below stays guarded on the task id:
172
+ // nothing can ever close it. taskId.ts draws the same line for `''`.
173
+ const taskId = eventTaskId(record);
174
+ // A still-open dispatch for the same (task, role) is superseded, not
175
+ // left live forever — see the "second dispatch_decision" note above.
176
+ // Different roles on one task run side by side and are left alone.
177
+ if (taskId) {
178
+ closeOpen(open, taskId, payload.agent_role, event_id, record.ts, 'superseded');
179
+ }
180
+ const agent = {
181
+ id: event_id,
182
+ sessionId: record.session_id,
183
+ taskId,
184
+ // The payload's own claim first; otherwise the task id spells it. A
185
+ // bare task id (D-130) places nothing, and guessing is what the id
186
+ // rules exist to prevent, so it stays null.
187
+ epicId: payload.epic_id ?? (taskId ? epicOfTaskId(taskId) : null),
188
+ agentId: record.agent_id ?? null,
189
+ agentRole: payload.agent_role,
190
+ round: typeof payload.round === 'number' ? payload.round : 1,
191
+ provider: payload.provider,
192
+ modelTier: payload.model_tier,
193
+ dispatchedAt: record.ts,
194
+ terminalEventId: null,
195
+ terminalAt: null,
196
+ terminalType: null,
197
+ status: 'live',
198
+ };
199
+ records.push(agent);
200
+ if (taskId)
201
+ open.set(openKey(taskId, payload.agent_role), agent);
202
+ else
203
+ openEpicLevel.push(agent);
204
+ continue;
205
+ }
206
+ if (record.event_type === TASK_RESULT_EVENT_TYPE) {
207
+ // The Result names its author in `agent` (result.schema.json's required
208
+ // field). A hand-appended one that omits it still closes the task.
209
+ const payload = record.payload;
210
+ // Both levels: result.schema.json names the task in the payload, and the
211
+ // dispatch this closes may have named it only there too (D-245).
212
+ const taskId = eventTaskId(record);
213
+ if (taskId) {
214
+ closeOpen(open, taskId, payload.agent ?? null, event_id, record.ts, 'result');
215
+ }
216
+ continue;
217
+ }
218
+ if (record.event_type === JUDGE_REPORT_EVENT_TYPE) {
219
+ const payload = record.payload;
220
+ const taskId = eventTaskId(record);
221
+ if (taskId) {
222
+ closeOpen(open, taskId, payload.agent_role ?? null, event_id, record.ts, 'result');
223
+ }
224
+ continue;
225
+ }
226
+ if (record.event_type === JUDGE_VERDICT_EVENT_TYPE) {
227
+ const payload = record.payload;
228
+ const taskId = eventTaskId(record);
229
+ if (taskId) {
230
+ // A run that produced no schema-valid verdict did not do its job, and
231
+ // `ok: false` is how the log says so. Closing it as a result would let
232
+ // a provider failing every call read exactly like one answering every
233
+ // call — the distinction providerAgreement() exists to measure.
234
+ const terminal = payload.ok === false ? 'error' : 'result';
235
+ closeOpen(open, taskId, payload.agent ?? null, event_id, record.ts, terminal);
236
+ }
237
+ continue;
238
+ }
239
+ if (record.event_type === ERROR_EVENT_TYPE) {
240
+ const payload = record.payload;
241
+ // `task_ref` is this event's own spelling; eventTaskId covers the two
242
+ // the rest of the log uses (D-245).
243
+ const taskId = eventTaskId(record) ?? payload.task_ref;
244
+ if (taskId) {
245
+ closeOpen(open, taskId, payload.agent_role ?? null, event_id, record.ts, 'error');
246
+ }
247
+ continue;
248
+ }
249
+ if (record.event_type === EPIC_CLOSED_EVENT_TYPE) {
250
+ const payload = record.payload;
251
+ if (!payload.epic_id)
252
+ continue;
253
+ // Scoped to the session that closed the epic: another session's agents
254
+ // are not this verdict's to speak for. Within it, the epic each entry
255
+ // was dispatched for is now recorded (D-234), so the task-keyed half
256
+ // asks that instead of matching the id's prefix by hand.
257
+ for (const [key, entry] of open) {
258
+ if (entry.sessionId !== record.session_id)
259
+ continue;
260
+ if (entry.epicId !== payload.epic_id)
261
+ continue;
262
+ closeEntry(entry, event_id, record.ts, 'abandoned');
263
+ open.delete(key);
264
+ }
265
+ // The epic-level half keeps D-187's reasoning for an entry nothing
266
+ // places -- no task id and no `epic_id` on its dispatch means no other
267
+ // terminal event can ever name it, so the run's own verdict is the last
268
+ // thing that can close it. But an entry that DID name an epic is placed,
269
+ // and one session runs several epics in a row: sweeping the lot closed
270
+ // the next epic's planner, live and working, on the previous epic's
271
+ // verdict (D-234).
272
+ openEpicLevel = openEpicLevel.filter((entry) => {
273
+ if (entry.sessionId !== record.session_id)
274
+ return true;
275
+ if (entry.epicId !== null && entry.epicId !== payload.epic_id)
276
+ return true;
277
+ closeEntry(entry, event_id, record.ts, 'abandoned');
278
+ return false;
279
+ });
280
+ }
281
+ }
282
+ return records;
283
+ }
284
+ /** Default staleness threshold (hours) when the caller doesn't specify one. */
285
+ export const DEFAULT_STALE_HOURS = 4;
286
+ /**
287
+ * Still-live agents whose dispatch is older than `staleHours` as of `nowIso`
288
+ * (architecture §4 "live agent registry ... stale detection"). Pure over
289
+ * the fold's output — `nowIso` is threaded in rather than read from the
290
+ * clock so this is deterministic and testable.
291
+ */
292
+ export function detectStale(agents, nowIso, staleHours = DEFAULT_STALE_HOURS) {
293
+ const now = new Date(nowIso).getTime();
294
+ const stale = [];
295
+ for (const agent of agents) {
296
+ if (agent.status !== 'live')
297
+ continue;
298
+ const dispatchedMs = new Date(agent.dispatchedAt).getTime();
299
+ const liveHours = (now - dispatchedMs) / (1000 * 60 * 60);
300
+ if (liveHours > staleHours)
301
+ stale.push({ ...agent, liveHours });
302
+ }
303
+ return stale;
304
+ }
305
+ /** Currently live agents (no terminal event yet), the overview page's "live agents" set. */
306
+ export function liveAgents(agents) {
307
+ return agents.filter((a) => a.status === 'live');
308
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * argv → flags, positionals, and the flags nobody declared (D-131/D-132).
3
+ *
4
+ * This lived in cli.ts until D-131, which is most of why D-131 lived as long as
5
+ * it did: cli.ts calls `main()` at import time, so nothing here could be
6
+ * exercised except by building the binary and inferring the parse from a
7
+ * subcommand's output. `--flag=value` was therefore dropped on the floor by
8
+ * every command, and for a flag with a default the whole symptom was that the
9
+ * default won — exit 0, well-formed JSON, the wrong target read.
10
+ *
11
+ * usage.ts is the register of what each command accepts; this module is the
12
+ * only thing that reads a command line against it.
13
+ */
14
+ /**
15
+ * Split `--name=value` at the FIRST `=`, so a value may contain one.
16
+ * Returns null for a token that is not a flag.
17
+ */
18
+ function splitToken(arg) {
19
+ if (!arg.startsWith('--') || arg === '--')
20
+ return null;
21
+ const body = arg.slice(2);
22
+ const eq = body.indexOf('=');
23
+ if (eq < 0)
24
+ return { key: body, inlineValue: undefined };
25
+ // `--=x` names no flag; leave it to the unknown-flag path rather than
26
+ // inventing an empty-named one.
27
+ return { key: body.slice(0, eq), inlineValue: body.slice(eq + 1) };
28
+ }
29
+ export function parseArgs(argv, spec) {
30
+ const positional = [];
31
+ const flags = {};
32
+ const repeated = {};
33
+ const ordered = [];
34
+ const unknown = [];
35
+ const missingValue = [];
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const arg = argv[i];
38
+ const token = splitToken(arg);
39
+ if (token === null) {
40
+ positional.push(arg);
41
+ continue;
42
+ }
43
+ const { key, inlineValue } = token;
44
+ const declared = spec === undefined ? undefined : spec.get(key);
45
+ if (spec !== undefined && declared === undefined && !unknown.includes(key))
46
+ unknown.push(key);
47
+ let value;
48
+ if (inlineValue !== undefined) {
49
+ value = inlineValue;
50
+ }
51
+ else if (declared === false) {
52
+ // Documented as taking nothing: whatever follows belongs to someone else.
53
+ value = 'true';
54
+ }
55
+ else if (spec !== undefined && declared === undefined) {
56
+ // An unknown flag is about to be refused. Consuming the token behind it
57
+ // would turn one clear "no such flag" into that plus a missing positional.
58
+ value = 'true';
59
+ }
60
+ else {
61
+ const next = argv[i + 1];
62
+ if (next !== undefined && !next.startsWith('--')) {
63
+ value = next;
64
+ i++;
65
+ }
66
+ else {
67
+ // Declared to take a value and handed none. `'true'` is still stored,
68
+ // so a caller that ignores the report parses exactly as it did before
69
+ // and cli.ts is the one place that has to refuse. `declared === true`
70
+ // is load-bearing: `undefined` here means no spec was given, and there
71
+ // `'true'` is the only answer available.
72
+ if (declared === true && !missingValue.includes(key))
73
+ missingValue.push(key);
74
+ value = 'true';
75
+ }
76
+ }
77
+ flags[key] = value;
78
+ const seen = repeated[key] ?? [];
79
+ seen.push(value);
80
+ repeated[key] = seen;
81
+ ordered.push({ key, value });
82
+ }
83
+ return { positional, flags, repeated, ordered, unknown, missingValue };
84
+ }
@@ -0,0 +1,111 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { SmithError } from './errors.js';
4
+ import { STATE_ARTIFACTS_DIR } from './paths.js';
5
+ /**
6
+ * Where a task's artifacts belong: `state/artifacts/<task-id>/`, beside
7
+ * `state/results/<task-id>.json`. One home per task, named by the task, so the
8
+ * evidence for a verdict is findable from the verdict without a search.
9
+ *
10
+ * `taskId` arrives from a worker-written result file, so it is not trusted to
11
+ * be a plain name: a `..` segment would compute a home outside the artifacts
12
+ * dir, and every later check would then be reporting about somewhere else.
13
+ */
14
+ export function artifactHome(taskId, artifactsDir = STATE_ARTIFACTS_DIR) {
15
+ return homeUnder(path.resolve(artifactsDir), taskId);
16
+ }
17
+ function homeUnder(root, taskId) {
18
+ if (typeof taskId !== 'string' || taskId.trim() === '') {
19
+ throw new SmithError('artifacts.missing-task-id', `Cannot locate an artifact home without a task id (got ${JSON.stringify(taskId)}). A blank id resolves to ${root}, which holds every task's home — the check would then pass on any path at all.`, { task_id: taskId ?? null, artifacts_dir: root });
20
+ }
21
+ const home = path.resolve(root, taskId);
22
+ if (!contains(root, home)) {
23
+ throw new SmithError('artifacts.unsafe-task-id', `Task id '${taskId}' resolves outside the artifacts directory (${home}). A task id names a directory under ${root}; it cannot climb out of it.`, { task_id: taskId, artifacts_dir: root, resolved: home });
24
+ }
25
+ return home;
26
+ }
27
+ /**
28
+ * `realpathSync`, falling back to the lexical path. A path can exist and still
29
+ * fail to resolve — a permission wall on a parent is enough — and the answer
30
+ * to "did this leave the home" is then the lexical one rather than a throw out
31
+ * of a check that is supposed to return an issue list.
32
+ */
33
+ function realOf(p) {
34
+ try {
35
+ return realpathSync(p);
36
+ }
37
+ catch {
38
+ return p;
39
+ }
40
+ }
41
+ /** True when `child` is `root` itself or lives beneath it. */
42
+ function contains(root, child) {
43
+ if (child === root)
44
+ return true;
45
+ const rel = path.relative(root, child);
46
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
47
+ }
48
+ /**
49
+ * Check every path a result declares as evidence. Relative paths resolve
50
+ * against the task's home, so `coverage.txt` means the obvious thing and the
51
+ * short spelling is also the correct one.
52
+ *
53
+ * Two properties, in this order. Inside the home first: a path in the wrong
54
+ * place is wrong whether or not the file happens to be there today, and saying
55
+ * `missing` about a `/tmp` file that exists would be false. Then existence:
56
+ * an artifact that cannot be opened when the verdict is written is not
57
+ * evidence.
58
+ *
59
+ * Inside is asked twice, of two different things. `path.resolve` answers it
60
+ * about the string, which is what refuses `../elsewhere/coverage.txt`.
61
+ * `realpathSync` answers it about the file, which is what refuses the same
62
+ * file reached through a symlink named `evidence/` — and refuses a home that
63
+ * is itself a link into a worktree, where every relative path under it spells
64
+ * correctly and none of it survives the branch (D-193).
65
+ *
66
+ * Directories pass. An html coverage report and a Playwright trace are real
67
+ * artifacts and neither is a file. What this does not check is whether the
68
+ * bytes support the claim — that is a reviewer's job, not a stat call's.
69
+ */
70
+ export function checkArtifacts(artifacts, opts) {
71
+ const root = path.resolve(opts.artifactsDir ?? STATE_ARTIFACTS_DIR);
72
+ const home = homeUnder(root, opts.taskId);
73
+ const issues = [];
74
+ // Where the home really is, and where it is supposed to be. They differ when
75
+ // the home is itself a link — into a worktree, say — and then every relative
76
+ // path under it spells correctly while none of it outlives the branch. A
77
+ // home that was never created cannot mislead anyone: nothing resolves
78
+ // through it, so every declaration under it comes back `missing` below.
79
+ const realHome = existsSync(home) ? realOf(home) : home;
80
+ const expectedHome = existsSync(home) ? homeUnder(realOf(root), opts.taskId) : home;
81
+ const homeEscaped = realHome !== expectedHome;
82
+ for (const decl of artifacts) {
83
+ const resolved = path.resolve(home, decl.path);
84
+ if (resolved === home) {
85
+ issues.push({ declared: decl.path, resolved, problem: 'no-path' });
86
+ continue;
87
+ }
88
+ if (!contains(home, resolved)) {
89
+ issues.push({ declared: decl.path, resolved, problem: 'outside-home' });
90
+ continue;
91
+ }
92
+ if (homeEscaped) {
93
+ const real = path.resolve(realHome, decl.path);
94
+ issues.push({ declared: decl.path, resolved: real, problem: 'outside-home' });
95
+ continue;
96
+ }
97
+ if (!existsSync(resolved)) {
98
+ issues.push({ declared: decl.path, resolved, problem: 'missing' });
99
+ continue;
100
+ }
101
+ // Asked of the file now, not of the string: `evidence/coverage.txt` is
102
+ // inside the home lexically and outside it in every sense that matters
103
+ // when `evidence` is a link. Report where it went, since an issue naming
104
+ // a correct-looking path is one the operator cannot act on.
105
+ const real = realOf(resolved);
106
+ if (!contains(realHome, real)) {
107
+ issues.push({ declared: decl.path, resolved: real, problem: 'outside-home' });
108
+ }
109
+ }
110
+ return { home, checked: artifacts.length, issues, ok: issues.length === 0 };
111
+ }
@@ -0,0 +1,160 @@
1
+ import { decideFindingAttribution, resolveFindingOwner, } from './claims.js';
2
+ import { foldTasks } from './db/projector.js';
3
+ import { appendEvent, readLineageEvents } from './events.js';
4
+ import { computeFingerprint, reattributeFinding, } from './findings.js';
5
+ import { emitFollowUpTask, followUpTaskId } from './taskEvents.js';
6
+ import { requireEpicOfTaskId } from './taskId.js';
7
+ /**
8
+ * Every epic this module derives becomes a durable artifact — the `epic_id`
9
+ * on a routed finding, and the `<epic>/followup-<fp>` id of a task that gets
10
+ * dispatched. So an id with no epic is refused here rather than answered with
11
+ * itself: `epicOf("task-1")` used to return "task-1", minting the follow-up
12
+ * `task-1/followup-ab12` under an epic that does not exist (D-49/P9-10).
13
+ */
14
+ const epicOf = requireEpicOfTaskId;
15
+ /** Every claim the ownership map records for a task id. */
16
+ function claimsOf(taskId, ownership) {
17
+ return ownership.filter((t) => t.task_id === taskId).flatMap((t) => t.claims);
18
+ }
19
+ /**
20
+ * The claims a follow-up inherits (D-48/P9-31).
21
+ *
22
+ * A follow-up minted with only the file the finding named is a task that
23
+ * cannot fix the bug: the fix lands in the implementation AND its test, and
24
+ * the second edit is out of claim. So the follow-up inherits from whoever
25
+ * resolved its ownership — the owner's whole claim set when one task owns the
26
+ * file, the union of the candidates' sets when several could, and the named
27
+ * file alone only when nobody claims it and there is nothing to inherit.
28
+ *
29
+ * The candidate records carry only the single matching claim, so the full set
30
+ * is looked up by id in the ownership map. Sorted and de-duplicated: the
31
+ * claim set is a set, and a task spec that differs run to run is not one.
32
+ */
33
+ function followUpClaims(owner, filePath, ownership) {
34
+ const inherited = owner.owner === 'resolved'
35
+ ? claimsOf(owner.taskId, ownership)
36
+ : owner.owner === 'ambiguous'
37
+ ? owner.candidates.flatMap((c) => claimsOf(c.taskId, ownership))
38
+ : [];
39
+ return inherited.length === 0 ? [filePath] : [...new Set(inherited)].sort();
40
+ }
41
+ /**
42
+ * Whether any finding's owner needs looking up in the log at all. Only one
43
+ * that resolves to a DIFFERENT task does — the common case is a judge speaking
44
+ * about the very task at the gate, and that case should not pay for a fold of
45
+ * the whole session.
46
+ */
47
+ function needsTaskStatuses(items, ownership, defaultTaskId) {
48
+ return items.some((item) => {
49
+ const owner = resolveFindingOwner(item.filePath, ownership);
50
+ return owner.owner === 'resolved' && owner.taskId !== defaultTaskId;
51
+ });
52
+ }
53
+ /**
54
+ * Decide where each finding goes, writing nothing. Every id is derived (the
55
+ * follow-up's from the fingerprint), so a caller can compute the whole routing
56
+ * and then discard it if the finding turns out to be waived or refuted — and
57
+ * mint no task for a bug nobody is going to fix.
58
+ */
59
+ export async function routeFindings(items, options, ctx, opts = {}) {
60
+ const { defaultTaskId, ownership } = options;
61
+ // Asked once, up front, so an epic that cannot be derived fails before any
62
+ // routing exists rather than on whichever finding happens to need it.
63
+ const defaultEpicId = options.epicId ?? epicOf(defaultTaskId);
64
+ const gated = (item) => ({
65
+ input: item,
66
+ attribution: 'gated',
67
+ taskId: defaultTaskId,
68
+ epicId: defaultEpicId,
69
+ fromTaskId: defaultTaskId,
70
+ reason: '',
71
+ claims: [],
72
+ });
73
+ if (ownership === undefined || ownership.length === 0)
74
+ return items.map(gated);
75
+ // Current status of every task the log knows about, folded once. A task the
76
+ // log has never heard of is simply absent, and reads as open: a task in the
77
+ // plan but not yet in the log is `todo`, not merged.
78
+ //
79
+ // The lineage's log (D-119), because "absent reads as open" is the reading
80
+ // that goes wrong across a session boundary: a task the parent session
81
+ // merged is absent from the child's, so a finding against it would be
82
+ // reassigned to a task that is already done rather than gated.
83
+ const statuses = needsTaskStatuses(items, ownership, defaultTaskId)
84
+ ? new Map(foldTasks(await readLineageEvents(ctx.sessionId, opts)).map((t) => [
85
+ t.taskId,
86
+ t.taskStatus,
87
+ ]))
88
+ : undefined;
89
+ return items.map((item) => {
90
+ const owner = resolveFindingOwner(item.filePath, ownership);
91
+ const decided = decideFindingAttribution(owner, defaultTaskId, (id) => statuses?.get(id));
92
+ if (decided.attribution === 'gated')
93
+ return gated(item);
94
+ if (decided.attribution === 'reassigned') {
95
+ return {
96
+ input: reattributeFinding(item, decided.taskId),
97
+ attribution: 'reassigned',
98
+ taskId: decided.taskId,
99
+ epicId: epicOf(decided.taskId),
100
+ fromTaskId: defaultTaskId,
101
+ reason: `${decided.taskId} claims ${decided.claim}; ${defaultTaskId} does not own this file.`,
102
+ claims: [],
103
+ };
104
+ }
105
+ const epicId = owner.owner === 'resolved' ? epicOf(owner.taskId) : defaultEpicId;
106
+ const taskId = followUpTaskId(epicId, computeFingerprint({
107
+ filePath: item.filePath,
108
+ category: item.finding.finding_category,
109
+ summary: item.finding.summary,
110
+ }));
111
+ return {
112
+ input: reattributeFinding(item, taskId),
113
+ attribution: 'follow-up',
114
+ taskId,
115
+ epicId,
116
+ fromTaskId: defaultTaskId,
117
+ reason: decided.reason,
118
+ claims: followUpClaims(owner, item.filePath, ownership),
119
+ };
120
+ });
121
+ }
122
+ /**
123
+ * Write the two facts a re-attributed finding produces: the follow-up task it
124
+ * needs (only when nobody open could take it), and the move itself. The
125
+ * `finding-reattributed` event is what makes a clean gate outcome readable —
126
+ * without it the log shows a finding under a task that was never gated, and no
127
+ * trace of how it got there.
128
+ *
129
+ * Call this only once the finding has survived intake. A waived or refuted
130
+ * finding minting a `todo` follow-up would block the epic verdict on a bug the
131
+ * team already settled.
132
+ */
133
+ export async function recordReattribution(routed, finding, ctx, opts = {}) {
134
+ if (routed.attribution === 'gated')
135
+ return;
136
+ if (routed.attribution === 'follow-up') {
137
+ await emitFollowUpTask({
138
+ epicId: routed.epicId,
139
+ taskId: routed.taskId,
140
+ objective: `Fix: ${finding.summary}`,
141
+ claims: routed.claims,
142
+ }, ctx, opts);
143
+ }
144
+ await appendEvent({
145
+ session_id: ctx.sessionId,
146
+ actor: ctx.actor ?? 'system',
147
+ event_type: 'finding-reattributed',
148
+ task_id: routed.taskId,
149
+ plan_version: ctx.planVersion,
150
+ causal_parent: ctx.causalParent,
151
+ payload: {
152
+ from_task_id: routed.fromTaskId,
153
+ to_task_id: routed.taskId,
154
+ attribution: routed.attribution,
155
+ file_path: routed.input.filePath,
156
+ finding_id: finding.finding_id,
157
+ reason: routed.reason,
158
+ },
159
+ }, opts);
160
+ }