@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,399 @@
1
+ import { appendEdge, appendEvent, readLineageEvents, } from './events.js';
2
+ import { resolveTaskId } from './plan.js';
3
+ import { taskBranchName } from './worktree.js';
4
+ function envelope(ctx, eventType, payload, taskId) {
5
+ return {
6
+ session_id: ctx.sessionId,
7
+ actor: ctx.actor ?? 'system',
8
+ event_type: eventType,
9
+ plan_version: ctx.planVersion,
10
+ causal_parent: ctx.causalParent,
11
+ payload,
12
+ ...(taskId ? { task_id: taskId } : {}),
13
+ ...(ctx.project ? { project: ctx.project } : {}),
14
+ };
15
+ }
16
+ /**
17
+ * The project this write belongs to: what the caller declared, else what the
18
+ * plan does.
19
+ *
20
+ * D-232. Every plan file under factory/specs/ carries a `project`, and until
21
+ * now nothing at runtime read it -- `plan ingest` handed the plan straight to
22
+ * emitTasksAdded, which used it for the epic id and the task specs and let the
23
+ * project fall on the floor. Every event of a demo-rpg run therefore landed
24
+ * with no project at all, and db/queries.ts's projectOf() resolves an absent
25
+ * one to the default: a whole epic filed under black-smith, missing from the
26
+ * board of the project it was actually part of.
27
+ *
28
+ * The plan is the fallback and not an override, because a caller holding a
29
+ * project knows something the file cannot -- and an absent value stays absent
30
+ * rather than becoming the default here, which is events.ts's rule: the log
31
+ * records what was stamped, and the read helpers do the resolving.
32
+ */
33
+ function planScoped(ctx, plan) {
34
+ const project = ctx.project ?? plan.project;
35
+ return project === undefined ? ctx : { ...ctx, project };
36
+ }
37
+ function addedPayload(plan, task) {
38
+ const budget = task.budget;
39
+ // A task spec is a `Record<string, unknown>`, so its `epic_id` is only a
40
+ // string by convention. `taskBranchName` needs a real one, and a task
41
+ // carrying anything else belongs to the plan's epic by default.
42
+ const epicId = typeof task.epic_id === 'string' ? task.epic_id : plan.epic_id;
43
+ return {
44
+ epic_id: epicId,
45
+ case: task.case,
46
+ origin: task.origin,
47
+ task_status: task.task_status,
48
+ plan_version: task.plan_version,
49
+ objective: task.objective,
50
+ claims: task.claims,
51
+ budget_tokens: budget?.tokens,
52
+ // D-23/P9-12. Declared here rather than derived by the reader: the board
53
+ // wants a branch link the moment a task is added, which is well before a
54
+ // worktree exists, and worktree.ts's convention is the one that decides
55
+ // what `smith worktree create` will actually cut. Reading it off the same
56
+ // function that creates it means the two cannot say different things.
57
+ branch: taskBranchName(epicId, task.task_id),
58
+ };
59
+ }
60
+ /**
61
+ * Collect the task ids that already have an event of `eventType` in this
62
+ * session's LINEAGE. Ingesting a plan twice — a re-run, a resumed session, v2
63
+ * of a plan that carries v1's tasks forward — must not double-write history,
64
+ * and the log itself is the only honest record of what has already been said.
65
+ *
66
+ * The lineage and not one session, since D-119: "a resumed session" is exactly
67
+ * the case a session-scoped read got wrong. Re-ingesting the plan from a
68
+ * continuation found none of the parent's `task-added` events and wrote the
69
+ * whole plan a second time, which is the double-write this guard is for.
70
+ */
71
+ async function idsAlreadyEmitted(eventType, ctx, opts) {
72
+ const events = await readLineageEvents(ctx.sessionId, opts);
73
+ const ids = new Set();
74
+ for (const { record } of events) {
75
+ if (record.event_type === eventType && record.task_id)
76
+ ids.add(record.task_id);
77
+ }
78
+ return ids;
79
+ }
80
+ /**
81
+ * The highest `plan_version` each task id has already been *added* under.
82
+ *
83
+ * D-18. `idsAlreadyEmitted` answers "has this id been added at all", which is
84
+ * the wrong question after an amendment: `draftNextVersion` supersedes a task
85
+ * by REUSING its id (D-121), so v(n+1) carries the dead copy beside a live one
86
+ * at the higher version. Keyed on the id alone, that live record is invisible,
87
+ * the amended task keeps the `plan_version` it was first added under, and
88
+ * `satisfiedAmendments` (epic.ts) — which requires `planVersion >= version` for
89
+ * every id the amendment names — can never discharge the finding that forced
90
+ * the amendment. Epic 1 read that deadlock as "ingest was never run"; running
91
+ * it correctly, at both versions, still deadlocks without this.
92
+ *
93
+ * A missing or non-numeric `plan_version` reads as `-Infinity`, so a task added
94
+ * before this field was carried is re-added once by any plan that names one
95
+ * rather than being frozen out.
96
+ */
97
+ async function addedPlanVersions(ctx, opts) {
98
+ const events = await readLineageEvents(ctx.sessionId, opts);
99
+ const highest = new Map();
100
+ for (const { record } of events) {
101
+ if (record.event_type !== 'task-added' || !record.task_id)
102
+ continue;
103
+ const raw = record.payload.plan_version;
104
+ const version = typeof raw === 'number' && Number.isFinite(raw) ? raw : Number.NEGATIVE_INFINITY;
105
+ const seen = highest.get(record.task_id);
106
+ if (seen === undefined || version > seen)
107
+ highest.set(record.task_id, version);
108
+ }
109
+ return highest;
110
+ }
111
+ /**
112
+ * Read the lineage's log as a register of tasks — every id a `task-added`
113
+ * has introduced, with the claims it was introduced with.
114
+ *
115
+ * D-48/P9-31: a follow-up minted by `findings raise` exists in the log and in
116
+ * no plan file, so the log has to be readable as a register or that task is
117
+ * unaddressable by every producer that resolves through a plan. Claims are
118
+ * carried, not just ids: a wave admitting a task with an empty claim set has
119
+ * admitted a task allowed to touch nothing, which fails at the first edit
120
+ * rather than at admission, where the mistake actually is. They are carried
121
+ * AS WRITTEN for the same reason: substituting `[]` for a claims value this
122
+ * could not read handed `validateWave` the one claim set that overlaps
123
+ * nothing, so an unreadable task was admitted as a disjoint one.
124
+ *
125
+ * Later events win on a repeated id (a re-add carries the newer claims), and
126
+ * first-appearance order is kept so the register reads in log order.
127
+ *
128
+ * Lineage-wide (D-119). This register is what makes a follow-up task
129
+ * addressable at all, and a task the parent session minted does not stop
130
+ * existing because the epic continued somewhere else — a claims check that
131
+ * cannot find the task it is checking is a claims check that passes.
132
+ */
133
+ export async function readAddedTasks(
134
+ // Reading takes only the session — a caller that is not writing should not
135
+ // have to invent a causal parent to ask what exists.
136
+ ctx, opts = {}) {
137
+ const events = await readLineageEvents(ctx.sessionId, opts);
138
+ const byId = new Map();
139
+ for (const { record } of events) {
140
+ if (record.event_type !== 'task-added' || !record.task_id)
141
+ continue;
142
+ byId.set(record.task_id, { taskId: record.task_id, claims: record.payload?.claims });
143
+ }
144
+ return [...byId.values()];
145
+ }
146
+ /**
147
+ * One record per task id, in plan order: the id's live spec, or its last
148
+ * record when every record for the id is dead.
149
+ *
150
+ * D-184. `plan.tasks` is not a backlog. `draftNextVersion` keeps each
151
+ * superseded copy of a task *beside* the record that replaced it, under the
152
+ * same `task_id` (D-121), so a v(n+1) file is routinely longer than the work
153
+ * it describes — envkit-mcp-surface's plan-v5.json holds 13 records for 5
154
+ * live tasks, four ids appearing three times each. This is the same rule
155
+ * `liveSpec`/`livePlanTasks` apply in plan.ts, and for the same reason D-126
156
+ * gave: reading the raw list answers with whichever copy happened to be
157
+ * written last. It is repeated here rather than imported because the dead
158
+ * ids matter to this caller — `livePlanTasks` drops them, and an id whose
159
+ * every record is dead is exactly the one that needs a `task-superseded`.
160
+ */
161
+ function specsToIngest(plan) {
162
+ const seen = new Set();
163
+ const specs = [];
164
+ for (const t of plan.tasks) {
165
+ if (seen.has(t.task_id))
166
+ continue;
167
+ seen.add(t.task_id);
168
+ const records = plan.tasks.filter((r) => r.task_id === t.task_id);
169
+ const live = records.filter((r) => r.task_status !== 'superseded');
170
+ // Last, not first: among the records still alive the later one is the
171
+ // amendment, which is what `liveSpec` means by "the live spec". `?? t`
172
+ // is unreachable — `records` always holds `t` — and is written rather
173
+ // than asserted so the array access needs no cast to stay honest.
174
+ const pool = live.length > 0 ? live : records;
175
+ specs.push(pool[pool.length - 1] ?? t);
176
+ }
177
+ return specs;
178
+ }
179
+ /**
180
+ * Write the plan's backlog into the log: one `task-added` per task id the
181
+ * session has not already recorded, plus a `task-superseded` for any id the
182
+ * plan has no live spec left for (a v(n+1) cut records that as plan state,
183
+ * and the projector needs it as an event to reach the same conclusion).
184
+ *
185
+ * Per id, not per record — see `specsToIngest`. Emitting the dead copies too
186
+ * would say two false things at once: that a task the plan still lists as
187
+ * `todo` was superseded, and that a 5-task backlog is 13 tasks.
188
+ *
189
+ * Returns only the events it actually appended, so a caller can report "5
190
+ * added, 0 already present" truthfully rather than guessing from the plan.
191
+ */
192
+ export async function emitTasksAdded(plan, ctx, opts = {}) {
193
+ const added = await addedPlanVersions(ctx, opts);
194
+ const superseded = await idsAlreadyEmitted('task-superseded', ctx, opts);
195
+ const scoped = planScoped(ctx, plan);
196
+ const written = [];
197
+ // The ids the plan marks as superseded-and-replaced: a dead record beside a
198
+ // live one under the same id is how `draftNextVersion` records an amendment.
199
+ const supersededIds = new Set(plan.tasks.filter((r) => r.task_status === 'superseded').map((r) => r.task_id));
200
+ for (const task of specsToIngest(plan)) {
201
+ const taskId = task.task_id;
202
+ // Re-add only when the live spec has genuinely moved forward: an id absent
203
+ // from the log, or one whose live record now carries a HIGHER plan_version
204
+ // than anything added for it. Equal versions stay idempotent — the same
205
+ // plan ingested twice is still one `task-added` — and a lower version is a
206
+ // stale file, not an amendment, so it is ignored rather than honoured.
207
+ const recorded = added.get(taskId);
208
+ const liveVersion = task.plan_version;
209
+ // D-18b. A higher version alone is not enough: `draftNextVersion` restamps
210
+ // EVERY task with the new plan_version, carried ones included, so keying on
211
+ // the number would re-add tasks whose spec never changed. The amendment's
212
+ // own marker is what counts — an id the amendment touched carries a dead
213
+ // record beside its live one, which is exactly what `supersededIds` holds.
214
+ const amended = recorded !== undefined &&
215
+ supersededIds.has(taskId) &&
216
+ typeof liveVersion === 'number' &&
217
+ liveVersion > recorded;
218
+ if (recorded === undefined || amended) {
219
+ written.push(await appendEvent(envelope(scoped, 'task-added', addedPayload(plan, task), taskId), opts));
220
+ }
221
+ if (task.task_status === 'superseded' && !superseded.has(taskId)) {
222
+ written.push(await appendEvent(envelope(scoped, 'task-superseded', { epic_id: plan.epic_id }, taskId), opts));
223
+ }
224
+ }
225
+ return written;
226
+ }
227
+ /** One arrow's identity in the log: who depends on whom, and in what sense. */
228
+ function edgeKey(task, dependsOn, edgeType) {
229
+ return `${task}\u0000${dependsOn}\u0000${edgeType}`;
230
+ }
231
+ /**
232
+ * The edges this session's LINEAGE has already recorded, keyed by the triple
233
+ * `edgeKey` spells rather than by task id the way `idsAlreadyEmitted` is.
234
+ *
235
+ * A task has as many arrows as it has dependencies, so a task-keyed register
236
+ * would record the first and drop the rest. And the third component is not
237
+ * decoration: demo-rpg-chapter-reading's plan-v1 declares both an `artifact`
238
+ * and a `claim-order` handoff between the same two tasks, which are two
239
+ * different claims about one pair and belong in the log as two events.
240
+ *
241
+ * The lineage and not one session, for D-119's reason -- a resumed session
242
+ * re-ingests the plan it was resumed into, and a session-scoped read would
243
+ * see none of what its parent already wrote.
244
+ */
245
+ async function edgesAlreadyRecorded(ctx, opts) {
246
+ const events = await readLineageEvents(ctx.sessionId, opts);
247
+ const keys = new Set();
248
+ for (const { record } of events) {
249
+ if (record.event_type !== 'edge-recorded' || !record.task_id || !record.edge)
250
+ continue;
251
+ const dependsOn = record.payload.depends_on;
252
+ if (typeof dependsOn !== 'string')
253
+ continue;
254
+ keys.add(edgeKey(record.task_id, dependsOn, record.edge.edge_type));
255
+ }
256
+ return keys;
257
+ }
258
+ /**
259
+ * Write the plan's DAG into the log: one `edge-recorded` per dependency the
260
+ * plan declares that this lineage has not already recorded.
261
+ *
262
+ * D-254. `emitTasksAdded` above ingests the nodes; this ingests the arrows,
263
+ * and until it existed nothing in production ever wrote an `edge-recorded`
264
+ * event at all. The consequence was not a missing feature but a wrong answer:
265
+ * the db's `edges` table was empty in every real session, so the Flow page
266
+ * drew a DAG with no dependencies and laid all 26 tasks out in a single wave,
267
+ * and the Roadmap's mini-timeline reported `dependencyReady` for every next-up
268
+ * task -- vacuously true over an empty dependency list. The scheduler was
269
+ * never wrong, because it reads `plan.edges` from the file directly; only
270
+ * every surface an operator looks at was.
271
+ *
272
+ * Both ends are resolved against the plan before anything is appended, so a
273
+ * plan whose DAG names a task it does not contain is refused whole. That is
274
+ * `emitWaveAdmitted`'s rule and `validatePlan`'s rule; an arrow written to a
275
+ * ghost would be dropped silently by every reader instead of seen by anyone.
276
+ */
277
+ export async function emitEdgesRecorded(plan, ctx, opts = {}) {
278
+ const resolved = plan.edges.map((e) => ({
279
+ task: resolveTaskId(plan, e.task),
280
+ dependsOn: resolveTaskId(plan, e.dependsOn),
281
+ edge_type: e.edge_type,
282
+ edge_provenance: e.edge_provenance,
283
+ }));
284
+ const recorded = await edgesAlreadyRecorded(ctx, opts);
285
+ const scoped = planScoped(ctx, plan);
286
+ const written = [];
287
+ for (const edge of resolved) {
288
+ const key = edgeKey(edge.task, edge.dependsOn, edge.edge_type);
289
+ // Guards the plan against itself as well as against a re-ingest: an edge
290
+ // the file states twice verbatim says nothing the first one did not.
291
+ if (recorded.has(key))
292
+ continue;
293
+ recorded.add(key);
294
+ written.push(await appendEdge(envelope(scoped, 'edge-recorded', { epic_id: plan.epic_id, depends_on: edge.dependsOn }, edge.task), { edge_type: edge.edge_type, edge_provenance: edge.edge_provenance }, opts));
295
+ }
296
+ return written;
297
+ }
298
+ /**
299
+ * Record that a wave was admitted. Every id is resolved against the plan and
300
+ * the log's own register BEFORE anything is appended, so a wave naming one
301
+ * task that does not exist is refused whole rather than half-written — a
302
+ * partially admitted wave is precisely the state that has no owner and no way
303
+ * back. The register is read here rather than passed in (D-48/P9-31) so no
304
+ * caller can forget it and quietly lose the ability to admit a follow-up.
305
+ */
306
+ export async function emitWaveAdmitted(plan, typedIds, ctx, opts = {}, budget) {
307
+ const logged = (await readAddedTasks(ctx, opts)).map((t) => t.taskId);
308
+ const taskIds = typedIds.map((typed) => resolveTaskId(plan, typed, logged));
309
+ return appendEvent(envelope(planScoped(ctx, plan), 'wave-admitted', {
310
+ epic_id: plan.epic_id,
311
+ task_ids: taskIds,
312
+ ...(budget ? { budget } : {}),
313
+ }), opts);
314
+ }
315
+ /**
316
+ * Record that one task's branch landed on the integration branch. One event
317
+ * per task, carrying a single-element `task_ids`, rather than one event per
318
+ * wave: a merge queue that gets three tasks in and fails on the fourth has
319
+ * genuinely merged three, and the log should say so. A wave-shaped event
320
+ * could only be written after the whole wave finished, which is exactly when
321
+ * a crash would lose it.
322
+ *
323
+ * `filesChanged` is what the merge actually rewrote (P9-15). Optional because
324
+ * only the queue can compute it and only after the merge commit exists; a
325
+ * caller that cannot supply it writes the event without the field rather than
326
+ * with an empty list, so "no file list here" and "this merge changed nothing"
327
+ * stay distinguishable to the staleness check reading them back.
328
+ */
329
+ export async function emitWaveMerged(taskId, ctx, opts = {}, filesChanged) {
330
+ const payload = { task_ids: [taskId] };
331
+ if (filesChanged !== undefined)
332
+ payload.files_changed = [...filesChanged];
333
+ return appendEvent(envelope(ctx, 'wave-merged', payload, taskId), opts);
334
+ }
335
+ /**
336
+ * Record that a task is blocked by a classified error. `error` must be a
337
+ * taxonomy error class — appendEvent enforces that, and a `coordination.`
338
+ * prefix is what makes the projector escalate rather than block, so a
339
+ * mis-typed class is a wrong status, not just a wrong label.
340
+ */
341
+ export async function emitTaskBlocked(taskId, error, ctx, opts = {}) {
342
+ return appendEvent(envelope(ctx, 'error-logged', {
343
+ error: error.error,
344
+ severity: error.severity,
345
+ task_ref: taskId,
346
+ ...(error.detail === undefined ? {} : { detail: error.detail }),
347
+ }, taskId), opts);
348
+ }
349
+ /**
350
+ * The id of the follow-up task an unownable finding opens against its epic
351
+ * (D-41/P9-24). Derived from the fingerprint so it is stable: re-running the
352
+ * same gate over the same evidence re-derives the same id and
353
+ * `emitFollowUpTask` recognises it as already written, rather than minting a
354
+ * second task for one bug.
355
+ *
356
+ * The `followup-` prefix keeps it clear of db/projector.ts's `isReservedRef`
357
+ * guard, which drops any id whose last segment is `integration` or a
358
+ * `plan-v<n>` plan ref — a follow-up that the projector refused to
359
+ * materialise would block nothing.
360
+ */
361
+ export function followUpTaskId(epicId, fingerprint) {
362
+ return `${epicId}/followup-${fingerprint.slice(0, 8)}`;
363
+ }
364
+ /**
365
+ * Record a follow-up task for a finding no open task can own: the file's
366
+ * owner already merged, two tasks claim it equally, or nobody claims it at
367
+ * all. `origin: escalation` because that is literally what happened — the
368
+ * gate could not settle it and handed it up to the epic.
369
+ *
370
+ * Returns null when the session already carries this task, so a re-run is a
371
+ * no-op rather than a duplicate. The task is `todo`, which is not terminal-OK,
372
+ * so epic.ts's `summarizeEpic` blocks the epic verdict on it until an operator
373
+ * plans or waives it — the "blocks the epic verdict instead of an unrelated
374
+ * diff" half of P9-24, using the machinery that was already there.
375
+ */
376
+ export async function emitFollowUpTask(input, ctx, opts = {}) {
377
+ const added = await idsAlreadyEmitted('task-added', ctx, opts);
378
+ if (added.has(input.taskId))
379
+ return null;
380
+ return appendEvent(envelope(ctx, 'task-added', {
381
+ epic_id: input.epicId,
382
+ case: 'bugfix',
383
+ origin: 'escalation',
384
+ task_status: 'todo',
385
+ plan_version: ctx.planVersion,
386
+ objective: input.objective,
387
+ claims: input.claims,
388
+ }, input.taskId), opts);
389
+ }
390
+ /**
391
+ * Record that a task was replaced by a later plan version. Resolves against
392
+ * the log's register too (D-48/P9-31): superseding is one of the two terminal
393
+ * exits a follow-up needs, and a follow-up is never in the plan.
394
+ */
395
+ export async function emitTaskSuperseded(plan, typed, ctx, opts = {}) {
396
+ const logged = (await readAddedTasks(ctx, opts)).map((t) => t.taskId);
397
+ const taskId = resolveTaskId(plan, typed, logged);
398
+ return appendEvent(envelope(planScoped(ctx, plan), 'task-superseded', { epic_id: plan.epic_id }, taskId), opts);
399
+ }
@@ -0,0 +1,126 @@
1
+ // The one place that knows what a task id is shaped like (D-49/P9-10).
2
+ //
3
+ // Task ids are "<epic>/<bare>" — plan.ts mints them, worktree.ts branches on
4
+ // them, findings.ts and the projector group by them. Four call sites derived
5
+ // the epic with `taskId.split('/')[0]`, and that expression has no failure
6
+ // mode: given an unqualified "task-0" it returns "task-0" and the caller
7
+ // reads it as an epic named "task-0". Nothing throws, nothing logs, and the
8
+ // comparison against the real epic quietly misses — the dogfood epic showed
9
+ // up in `smith epic verdict` as "has no tasks in the event log" while three
10
+ // tasks sat in it.
11
+ //
12
+ // So the derivation lives here once, and its unqualified answer is `null`,
13
+ // not a plausible lie. A caller that cannot proceed without an epic asks
14
+ // requireEpicOfTaskId() and gets a typed error naming the id.
15
+ import { SmithError } from './errors.js';
16
+ export class TaskIdError extends SmithError {
17
+ }
18
+ const SEPARATOR = '/';
19
+ /**
20
+ * Split a task id into its epic and bare halves, or null when it has no
21
+ * usable epic segment. Both halves must be non-empty: "/task-2" and "epic-1/"
22
+ * are malformed, not qualified.
23
+ *
24
+ * The bare half keeps every remaining separator ("epic-1/followup-ab/extra"
25
+ * -> epic "epic-1", bare "followup-ab/extra"): the epic is the FIRST segment
26
+ * by definition, and re-joining epic + bare must round-trip the input.
27
+ *
28
+ * D-135: the `string` in the signature is a claim, not a guarantee. Every id
29
+ * that reaches these helpers has been through a JSONL round-trip, where a
30
+ * field the writer never set arrives as `undefined` and TypeScript is no
31
+ * longer watching. `undefined.indexOf` threw a bare TypeError with no code
32
+ * and no id in it, from inside a reader three call frames from the bad
33
+ * record. A non-string names no epic, which is exactly what `null` says.
34
+ */
35
+ function split(taskId) {
36
+ if (typeof taskId !== 'string')
37
+ return null;
38
+ const index = taskId.indexOf(SEPARATOR);
39
+ if (index <= 0)
40
+ return null;
41
+ const epicId = taskId.slice(0, index);
42
+ const bare = taskId.slice(index + 1);
43
+ return bare.length === 0 ? null : { epicId, bare };
44
+ }
45
+ /** The epic a task id names, or null when the id does not name one. */
46
+ export function epicOfTaskId(taskId) {
47
+ return split(taskId)?.epicId ?? null;
48
+ }
49
+ /** Whether `epicOfTaskId` can answer for this id. */
50
+ export function isQualifiedTaskId(taskId) {
51
+ return split(taskId) !== null;
52
+ }
53
+ /**
54
+ * The id without its epic prefix; an unqualified id is already bare.
55
+ *
56
+ * A non-string answers `''` rather than falling through to `?? taskId`, which
57
+ * would hand the caller back a non-string through a `: string` signature —
58
+ * the same class of lie this module exists to stop. `''` is safe at every
59
+ * comparison site because no real task id is empty, so it can never make two
60
+ * distinct records look like the same task.
61
+ */
62
+ export function bareTaskId(taskId) {
63
+ if (typeof taskId !== 'string')
64
+ return '';
65
+ return split(taskId)?.bare ?? taskId;
66
+ }
67
+ /**
68
+ * Whether two task ids name the same task, across both spellings.
69
+ *
70
+ * D-130. `task_id` is written both qualified ("<epic>/<task>") and bare
71
+ * ("<task>") for the same task — D-46/P9-10 fixed the producers, but the log
72
+ * is append-only, so every session opened before that fix carries both, and
73
+ * they interleave rather than splitting cleanly at a cutover point. A raw
74
+ * `!==` therefore answered a query by the canonical id with a well-formed
75
+ * SUBSET of the task's history, and a short answer is indistinguishable from
76
+ * a complete one unless the caller already knows the true count.
77
+ *
78
+ * Two qualified ids are still compared whole: "epic-a/task-2" and
79
+ * "epic-b/task-2" are different tasks that happen to share a bare name, and
80
+ * folding them together would trade a silent omission for a silent merge.
81
+ * Only when at least one side is bare — where the epic is genuinely unknown
82
+ * rather than different — do the bare halves decide it.
83
+ *
84
+ * D-143 moved the rule here from `events.ts`, where it was private and only
85
+ * `filterEvents` could reach it while the findings fold went on comparing
86
+ * raw. The rule is about what a task id *means*, so it belongs to the module
87
+ * that owns that question — the same reason `epicOfTaskId` lives here rather
88
+ * than at its four original call sites.
89
+ */
90
+ export function taskIdsMatch(a, b) {
91
+ if (typeof a !== 'string' || typeof b !== 'string' || a === '' || b === '')
92
+ return false;
93
+ if (a === b)
94
+ return true;
95
+ if (isQualifiedTaskId(a) && isQualifiedTaskId(b))
96
+ return false;
97
+ return bareTaskId(a) === bareTaskId(b);
98
+ }
99
+ /**
100
+ * `<epicId>/<taskId>`, idempotent when the id already carries that epic.
101
+ *
102
+ * Throws rather than double-prefixing when the id is qualified under a
103
+ * different epic: `smith/epic-2/epic-1/task-2` is not a branch name anyone
104
+ * meant, and the mismatch is a real disagreement between two producers about
105
+ * which epic owns the task — worth an error, not a silent pick.
106
+ */
107
+ export function qualifyTaskId(epicId, taskId) {
108
+ const parts = split(taskId);
109
+ if (parts === null)
110
+ return `${epicId}${SEPARATOR}${taskId}`;
111
+ if (parts.epicId === epicId)
112
+ return taskId;
113
+ throw new TaskIdError('task-id.epic-mismatch', `Task id "${taskId}" is already qualified under epic "${parts.epicId}"; cannot re-qualify it under "${epicId}".`, { task_id: taskId, epic_id: epicId, qualified_under: parts.epicId });
114
+ }
115
+ /**
116
+ * The epic, or a typed error. For call sites where an epic is structurally
117
+ * required — a follow-up task id, a branch name — and inventing one would
118
+ * produce a valid-looking artifact pointed at nothing.
119
+ */
120
+ export function requireEpicOfTaskId(taskId) {
121
+ const epicId = epicOfTaskId(taskId);
122
+ if (epicId === null) {
123
+ throw new TaskIdError('task-id.unqualified', `Task id "${taskId}" carries no epic. Task ids are "<epic>/<task>" (plan.ts/worktree.ts); an epic cannot be derived from this one.`, { task_id: taskId });
124
+ }
125
+ return epicId;
126
+ }
@@ -0,0 +1,113 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parse as parseYaml } from 'yaml';
3
+ import { SmithError } from './errors.js';
4
+ import { TAXONOMY_PATH } from './paths.js';
5
+ export class TaxonomyError extends SmithError {
6
+ }
7
+ const NON_DIMENSION_KEYS = new Set(['version', 'rules', 'error']);
8
+ // Fields required on a record that are references/collections, not taxonomy
9
+ // tags themselves — presence (non-empty) is enough, no dimension lookup.
10
+ // `model` (P9-23) is required but deliberately open: model ids turn over
11
+ // monthly, and a closed dimension would either reject next month's model or
12
+ // — the likelier outcome — get the field quietly dropped from dispatches
13
+ // instead of the taxonomy being bumped. Presence is what the asymmetry
14
+ // check needs; the exact value is only ever compared against another value.
15
+ const PRESENCE_ONLY_FIELDS = new Set(['task_ref', 'provenance_event_ids', 'model']);
16
+ /** The `error` dimension is validated as a `group.class` pair, not a flat tag. */
17
+ const ERROR_CLASS_FIELD = 'error';
18
+ export function parseTaxonomy(yamlText) {
19
+ const doc = parseYaml(yamlText);
20
+ if (!doc || typeof doc !== 'object') {
21
+ throw new TaxonomyError('taxonomy.invalid-document', 'Taxonomy YAML did not parse to a mapping.');
22
+ }
23
+ const version = doc.version;
24
+ if (typeof version !== 'number') {
25
+ throw new TaxonomyError('taxonomy.invalid-document', 'Taxonomy is missing a numeric `version`.');
26
+ }
27
+ const errorNode = doc.error;
28
+ if (!errorNode || typeof errorNode !== 'object') {
29
+ throw new TaxonomyError('taxonomy.invalid-document', 'Taxonomy is missing the `error` group block.');
30
+ }
31
+ const errorGroups = {};
32
+ for (const [group, classes] of Object.entries(errorNode)) {
33
+ if (!Array.isArray(classes)) {
34
+ throw new TaxonomyError('taxonomy.invalid-document', `error group "${group}" must be an array of classes.`);
35
+ }
36
+ errorGroups[group] = classes.map(String);
37
+ }
38
+ const dimensions = {};
39
+ for (const [key, value] of Object.entries(doc)) {
40
+ if (NON_DIMENSION_KEYS.has(key))
41
+ continue;
42
+ if (!Array.isArray(value))
43
+ continue;
44
+ dimensions[key] = value.map(String);
45
+ }
46
+ const rulesNode = doc.rules;
47
+ const requiredDimensions = rulesNode?.required_dimensions ?? {};
48
+ if (Object.keys(requiredDimensions).length === 0) {
49
+ throw new TaxonomyError('taxonomy.invalid-document', 'Taxonomy is missing rules.required_dimensions.');
50
+ }
51
+ return { version, dimensions, errorGroups, requiredDimensions };
52
+ }
53
+ export function loadTaxonomy(filePath = TAXONOMY_PATH) {
54
+ const text = readFileSync(filePath, 'utf8');
55
+ return parseTaxonomy(text);
56
+ }
57
+ /** Validate a single tag value against one taxonomy dimension. Never silently passes. */
58
+ export function validateTag(taxonomy, dimension, value) {
59
+ const allowed = taxonomy.dimensions[dimension];
60
+ if (!allowed) {
61
+ throw new TaxonomyError('taxonomy.unknown-dimension', `Unknown taxonomy dimension "${dimension}".`, { dimension });
62
+ }
63
+ if (!allowed.includes(value)) {
64
+ throw new TaxonomyError('taxonomy.unknown-tag', `Unknown value "${value}" for taxonomy dimension "${dimension}".`, { dimension, value, allowed });
65
+ }
66
+ }
67
+ /** Validate a grouped error class, always logged/referenced as `group.class`. */
68
+ export function validateErrorClass(taxonomy, value) {
69
+ const parts = value.split('.');
70
+ if (parts.length !== 2 || parts[0] === '' || parts[1] === '') {
71
+ throw new TaxonomyError('taxonomy.invalid-error-class-format', `Error class "${value}" must be in "group.class" form.`, { value });
72
+ }
73
+ const [group, cls] = parts;
74
+ const classes = taxonomy.errorGroups[group];
75
+ if (!classes) {
76
+ throw new TaxonomyError('taxonomy.unknown-error-group', `Unknown error group "${group}".`, {
77
+ group,
78
+ value,
79
+ });
80
+ }
81
+ if (!classes.includes(cls)) {
82
+ throw new TaxonomyError('taxonomy.unknown-error-class', `Unknown error class "${cls}" in group "${group}".`, { group, class: cls, value, allowed: classes });
83
+ }
84
+ }
85
+ /**
86
+ * Enforce the required-dimensions-per-record-type rules (taxonomy.yml `rules:`
87
+ * block). Checks presence of every required field, then validates each
88
+ * field's value against its taxonomy dimension (or the group.class rule for
89
+ * the `error` field, or mere non-empty presence for reference-only fields).
90
+ */
91
+ export function validateRequiredDimensions(taxonomy, recordType, record) {
92
+ const required = taxonomy.requiredDimensions[recordType];
93
+ if (!required) {
94
+ throw new TaxonomyError('taxonomy.unknown-record-type', `Unknown record type "${recordType}" — no required-dimensions rule for it.`, { recordType });
95
+ }
96
+ for (const field of required) {
97
+ const value = record[field];
98
+ const isEmpty = value === undefined ||
99
+ value === null ||
100
+ value === '' ||
101
+ (Array.isArray(value) && value.length === 0);
102
+ if (isEmpty) {
103
+ throw new TaxonomyError('taxonomy.missing-required-dimension', `Record type "${recordType}" is missing required field "${field}".`, { recordType, field });
104
+ }
105
+ if (PRESENCE_ONLY_FIELDS.has(field))
106
+ continue;
107
+ if (field === ERROR_CLASS_FIELD) {
108
+ validateErrorClass(taxonomy, String(value));
109
+ continue;
110
+ }
111
+ validateTag(taxonomy, field, String(value));
112
+ }
113
+ }