@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,376 @@
1
+ import { claimCoversPath } from './claims.js';
2
+ import { SmithError } from './errors.js';
3
+ import { appendEvent } from './events.js';
4
+ import {
5
+ // The status an amendment can actually put a finding into. Not `amended`:
6
+ // since D-127 that is reachable only once the amendment's task ids land, so
7
+ // a guard here that asked about the terminal status would be asking about a
8
+ // transition this module never attempts.
9
+ AMEND_PENDING_STATUS, findingScope, LEGAL_TRANSITIONS, listFindings, mintFindings, raiseFinding, SPEC_FINDING_SCOPE, transition, } from './findings.js';
10
+ import { diffPlans, draftNextVersion, livePlanTasks, nextVersion, planRefTaskId, validatePlan, } from './plan.js';
11
+ import { RESERVED_TASK_ID } from './worktree.js';
12
+ /**
13
+ * P9-9/D-33. Every judge in this factory returns findings against a diff, and
14
+ * every finding against a diff blocks the diff. That is right until the judge
15
+ * is reading the plan rather than the code: "the criterion is wrong" recorded
16
+ * as a builder defect blocks a task whose diff cannot legally contain the fix,
17
+ * because the fix is a new plan version and plans are immutable. The dogfood
18
+ * epic deadlocked there — a wave-3 spec defect, correctly found, recorded as a
19
+ * coder failure, bounced back to a coder who had nothing to change.
20
+ *
21
+ * This module is the other route. A spec-scoped finding (findings.ts's
22
+ * `finding_scope`) leaves the task gate untouched and arrives here, where the
23
+ * only legitimate way to change an immutable plan lives: `amendPlan` cuts a
24
+ * new version, records which criterion moved and why, and puts the finding on
25
+ * the amendment path at `amend-pending` — the one exit an S1/S2 spec finding
26
+ * has, since severity.yml makes it categorically unwaivable. It opens that
27
+ * exit; the tasks the new version added or superseded are what walk through
28
+ * it (D-127).
29
+ *
30
+ * The second half is `recordSpecReview`: the closing spec-reviewer dispatch,
31
+ * run at epic close against composite behaviour, when the code the spec
32
+ * describes finally exists. A spec review before the code is written cannot
33
+ * see the wave-3 defect; only this one can, and epic.ts's verdict now refuses
34
+ * to certify an epic that never ran it.
35
+ *
36
+ * Dependency direction is one-way on purpose: epic.ts imports from here, never
37
+ * the reverse, so the fold that decides readiness stays downstream of the
38
+ * facts it folds.
39
+ */
40
+ export class SpecError extends SmithError {
41
+ }
42
+ export const SPEC_REVIEW_EVENT = 'spec-review-recorded';
43
+ /**
44
+ * The graph event the taxonomy has declared since Phase 2 and nothing has ever
45
+ * written (P9-12's shape, in the one place a producer is genuinely owed).
46
+ * `nextVersion()` writes the plan file; this module is what makes the cut a
47
+ * recorded decision rather than a new file appearing on disk.
48
+ */
49
+ export const PLAN_AMENDED_EVENT = 'plan-version-created';
50
+ /** Last-wins fold: a re-review supersedes its predecessor, it does not stack. */
51
+ export function latestSpecReview(events, epicId) {
52
+ let latest = null;
53
+ for (const event of events) {
54
+ if (event.record.event_type !== SPEC_REVIEW_EVENT)
55
+ continue;
56
+ const payload = event.record.payload;
57
+ if (payload.epic_id !== epicId)
58
+ continue;
59
+ latest = {
60
+ epicId,
61
+ planVersion: Number(payload.plan_version ?? 0),
62
+ headSha: String(payload.head_sha ?? ''),
63
+ reviewedBy: String(payload.reviewed_by ?? ''),
64
+ findingIds: (payload.finding_ids ?? []),
65
+ eventId: event.event_id,
66
+ ts: event.record.ts,
67
+ };
68
+ }
69
+ return latest;
70
+ }
71
+ /**
72
+ * The epic gate refusing to certify a spec nobody re-read once the code
73
+ * existed. Mirrors integrationBlockers exactly, including the fail-closed
74
+ * unknown-head case: a review pinned to a sha nobody can compare against is
75
+ * not evidence about anything.
76
+ *
77
+ * There is deliberately no SPEC_REVIEW_NOT_REQUIRED escape hatch. An epic can
78
+ * legitimately owe no MCP surface; every epic has a plan, and every plan can
79
+ * be wrong in a way only the finished code reveals.
80
+ *
81
+ * A review reads two things, so it goes stale two ways (D-125). The sha axis
82
+ * was the only one this function knew, and the plan axis is the one that
83
+ * matters most: an amendment is by definition a plan that just changed because
84
+ * a review found it wrong, so the single moment a plan is likeliest to be
85
+ * freshly defective was the one moment the gate was blind to. `epic verdict`
86
+ * certified a review of v4 against a plan that had been v5 for seconds. The
87
+ * two axes are independent — an amendment can land before any commit
88
+ * implements it — so a current sha must not vouch for a plan the review never
89
+ * read, and neither check is allowed to stand in for the other.
90
+ *
91
+ * `planVersion` is the live plan's version, or null when the epic has no
92
+ * readable plan file. Null casts no vote, which is D-126's deliberate scope
93
+ * line rather than a hole: most epics ran as punch-list branches with no plan
94
+ * directory, and making absence a blocker would make them unclosable. It is
95
+ * a required parameter for the reason `integration` and `mcp` are required on
96
+ * summarizeEpic — a defaulted one is a forgotten argument that manufactures a
97
+ * green.
98
+ */
99
+ export function specReviewBlockers(epicId, status, planVersion) {
100
+ const branch = `smith/${epicId}/${RESERVED_TASK_ID}`;
101
+ const { review, headSha } = status;
102
+ if (review === null) {
103
+ return [
104
+ `Epic "${epicId}" has no closing spec review on record: nothing re-read the plan against the code that now exists. A spec review run before the code was written cannot see the defects the code reveals (D-33).`,
105
+ ];
106
+ }
107
+ if (planVersion !== null) {
108
+ // latestSpecReview reads a missing plan_version as 0, and "I did not
109
+ // record which plan I read" is unverifiable, not current — the same
110
+ // fail-closed call as the unknown head below.
111
+ if (!Number.isInteger(review.planVersion) || review.planVersion < 1) {
112
+ return [
113
+ `The closing spec review for "${epicId}" records no plan version, so it cannot be shown to cover plan v${planVersion}.`,
114
+ ];
115
+ }
116
+ if (review.planVersion < planVersion) {
117
+ return [
118
+ `The closing spec review for "${epicId}" is stale: it read plan v${review.planVersion}, and the epic's live plan is v${planVersion}. Whatever the amendment changed has been reviewed against no spec at all.`,
119
+ ];
120
+ }
121
+ // Newer than the plan on disk: a rolled-back or hand-written record. The
122
+ // review names a version nothing can be compared against, so it is
123
+ // evidence about a plan this epic is not on.
124
+ if (review.planVersion > planVersion) {
125
+ return [
126
+ `The closing spec review for "${epicId}" records plan v${review.planVersion}, and the epic's live plan is v${planVersion}: the review names a version the repository does not have.`,
127
+ ];
128
+ }
129
+ }
130
+ if (headSha === null) {
131
+ return [
132
+ `Could not read the head of ${branch}, so the closing spec review recorded at ${review.headSha.slice(0, 8)} cannot be shown to cover it.`,
133
+ ];
134
+ }
135
+ if (review.headSha !== headSha) {
136
+ return [
137
+ `The closing spec review for "${epicId}" is stale: it read ${review.headSha.slice(0, 8)}, and ${branch} is now at ${headSha.slice(0, 8)}. Anything merged since has been reviewed against no spec at all.`,
138
+ ];
139
+ }
140
+ return [];
141
+ }
142
+ /**
143
+ * Run the closing spec review's bookkeeping: mint the reviewer's evidence as
144
+ * spec-scoped findings, raise each, and record that the review happened.
145
+ *
146
+ * The event is written even when the evidence is empty. "Ran and was clean" and
147
+ * "never ran" are different facts and the epic gate distinguishes them; a
148
+ * review that only logged when it found something would make a silent skip
149
+ * indistinguishable from a pass, which is the same shape of hole D-42 was.
150
+ *
151
+ * Findings are raised BEFORE the review event so the event can cite them: a
152
+ * record naming finding ids that do not exist yet is a record that lies for
153
+ * however long the raise loop takes to crash.
154
+ */
155
+ export async function recordSpecReview(input, ctx, opts = {}) {
156
+ const { epicId, planVersion, headSha, reviewedBy } = input;
157
+ const drafts = mintFindings(input.evidence, {
158
+ taskId: `${epicId}/${RESERVED_TASK_ID}`,
159
+ foundBy: reviewedBy,
160
+ ...(input.reviewedByProvider === undefined
161
+ ? {}
162
+ : { foundByProvider: input.reviewedByProvider }),
163
+ spec: { planVersion },
164
+ }, opts);
165
+ const findingIds = [];
166
+ for (const draft of drafts) {
167
+ const raised = await raiseFinding(draft, ctx, opts);
168
+ // A suppressed raise (fingerprint already waived) logged its own
169
+ // finding-suppressed event and appended no finding. Citing an id that was
170
+ // never raised would make this record unresolvable; the suppression is
171
+ // already in the log where analytics can see the recurrence.
172
+ if (raised.suppressed)
173
+ continue;
174
+ findingIds.push(raised.finding.finding_id);
175
+ }
176
+ const stored = await appendEvent({
177
+ session_id: ctx.sessionId,
178
+ actor: ctx.actor ?? 'system',
179
+ event_type: SPEC_REVIEW_EVENT,
180
+ task_id: `${epicId}/${RESERVED_TASK_ID}`,
181
+ plan_version: ctx.planVersion,
182
+ causal_parent: ctx.causalParent,
183
+ payload: {
184
+ epic_id: epicId,
185
+ plan_version: planVersion,
186
+ head_sha: headSha,
187
+ reviewed_by: reviewedBy,
188
+ ...(input.reviewedByProvider === undefined
189
+ ? {}
190
+ : { reviewed_by_provider: input.reviewedByProvider }),
191
+ finding_ids: findingIds,
192
+ finding_count: findingIds.length,
193
+ },
194
+ }, opts);
195
+ return {
196
+ epicId,
197
+ planVersion,
198
+ headSha,
199
+ reviewedBy,
200
+ findingIds,
201
+ eventId: stored.event_id,
202
+ ts: stored.record.ts,
203
+ };
204
+ }
205
+ /**
206
+ * The one legitimate way to change an immutable plan.
207
+ *
208
+ * Three guards make this an amendment rather than a rewrite: it must cite at
209
+ * least one spec-scoped finding, it must say why, and it must enumerate the
210
+ * sites the finding's shape occurs at. Without the first, "the plan changed"
211
+ * has no recorded cause and the immutability is decorative — a new version any
212
+ * time anyone wants one is just a mutable plan with extra files. Without the
213
+ * second, the diff records what moved and nothing records the argument, which
214
+ * is the half that a future reader actually needs. Without the third, the
215
+ * remediation scope is chosen by whoever writes the amendment and checked by
216
+ * nothing: a finding names one site, and the question of where else that shape
217
+ * lives gets asked by nobody (D-123, and D-83 one run before it — the same
218
+ * defect twice, which is what moved it out of prose and into a guard).
219
+ *
220
+ * Everything validates before anything acts. The alternative ordering — cut
221
+ * the version, then discover a cited finding is already closed — leaves a plan
222
+ * file on disk that no event explains and findings that were never
223
+ * transitioned, and plan files are not deletable by anything in this codebase.
224
+ *
225
+ * What this does NOT do is close the findings it cites. It used to transition
226
+ * them straight to `amended` — terminal, and counted closed by the epic
227
+ * verdict — in the same call that wrote the plan file, so the severity class
228
+ * severity.yml refuses to waive was discharged in full by writing a sentence
229
+ * (D-127). They now reach `amend-pending`, carrying the task ids this version
230
+ * added or superseded. Those ids landing is what earns `amended`.
231
+ */
232
+ export async function amendPlan(input, ctx, opts = {}) {
233
+ const { plan, findingIds, rationale, sites } = input;
234
+ const epicId = plan.epic_id;
235
+ if (findingIds.length === 0) {
236
+ throw new SpecError('plan.amendment-without-finding', `Refusing to amend plan "${epicId}" v${plan.version}: an amendment cites the spec finding that forced it. A version cut on request is not an amendment, it is a mutable plan.`, { epicId, version: plan.version });
237
+ }
238
+ if (rationale.trim() === '') {
239
+ throw new SpecError('plan.amendment-without-rationale', `Refusing to amend plan "${epicId}" v${plan.version} with a blank rationale: the diff already records what moved, and only the rationale records why.`, { epicId, version: plan.version, findingIds: [...findingIds] });
240
+ }
241
+ // Trimmed and de-duplicated once, here: what the guard accepts and what the
242
+ // event records have to be the same list, or the record answers a question
243
+ // the guard never asked.
244
+ const namedSites = [...new Set(sites.map((s) => s.trim()))];
245
+ if (namedSites.length === 0 || namedSites.some((s) => s === '')) {
246
+ throw new SpecError('plan.amendment-without-sites', `Refusing to amend plan "${epicId}" v${plan.version}: name every site the finding's shape occurs at, not only the one it was reported against. A finding names where it was noticed; the amendment has to answer where the shape lives (D-123).`, { epicId, version: plan.version, findingIds: [...findingIds] });
247
+ }
248
+ // listFindings has no by-id filter (it folds by task/epic/status/severity),
249
+ // so index the session's findings here rather than folding once per id.
250
+ const all = await listFindings(ctx.sessionId, {}, opts);
251
+ const byId = new Map(all.map((f) => [f.finding_id, f]));
252
+ const cited = [];
253
+ for (const findingId of findingIds) {
254
+ const finding = byId.get(findingId);
255
+ if (finding === undefined) {
256
+ throw new SpecError('plan.amendment-unknown-finding', `Amendment of "${epicId}" v${plan.version} cites finding ${findingId}, which was never raised in session ${ctx.sessionId}.`, { epicId, version: plan.version, findingId });
257
+ }
258
+ cited.push(finding);
259
+ }
260
+ for (const finding of cited) {
261
+ if (findingScope(finding) !== SPEC_FINDING_SCOPE) {
262
+ throw new SpecError('plan.amendment-not-spec-scoped', `Finding ${finding.finding_id} is scoped ${findingScope(finding)}, not ${SPEC_FINDING_SCOPE}: it says the code is wrong. A new plan version is not how a diff defect gets fixed.`, { epicId, findingId: finding.finding_id, scope: findingScope(finding) });
263
+ }
264
+ }
265
+ for (const finding of cited) {
266
+ if (!(LEGAL_TRANSITIONS[finding.finding_status] ?? []).includes(AMEND_PENDING_STATUS)) {
267
+ throw new SpecError('plan.amendment-finding-closed', `Finding ${finding.finding_id} is already ${finding.finding_status} and cannot reach ${AMEND_PENDING_STATUS}. An amendment answering a closed finding answers nothing.`, { epicId, findingId: finding.finding_id, findingStatus: finding.finding_status });
268
+ }
269
+ }
270
+ // The last guard needs the diff, and the diff is only defined against the
271
+ // version this amendment would cut — so the version is CONSTRUCTED here and
272
+ // written a few lines below, once the guard has passed. That is what
273
+ // `draftNextVersion` is for: "everything validates before anything acts"
274
+ // only holds if this check can run before a plan file exists, and nothing in
275
+ // this codebase deletes a plan file (D-127).
276
+ const draft = draftNextVersion(plan, input.changes ?? {});
277
+ // D-21: a malformed `--changes` file can still produce a draft that reads
278
+ // fine structurally (every entry a plain object with a string task_id) but
279
+ // fails the plan's own schema/taxonomy rules — the exact check `smith plan
280
+ // validate` runs. Plans are immutable and nothing deletes one, so a
281
+ // schema-invalid version written to disk is unrecoverable; refusing it here,
282
+ // before the write, is what closes that loop rather than leaving `smith
283
+ // plan validate` to reject the file this tool just produced.
284
+ const draftValidation = validatePlan(draft, opts);
285
+ if (!draftValidation.valid) {
286
+ throw new SpecError('plan.amendment-invalid-draft', `Refusing to amend plan "${epicId}" v${plan.version}: v${draft.version} would fail its own validation (the same check \`smith plan validate\` runs), so nothing was written. ${draftValidation.errors
287
+ .map((e) => `${e.path}: ${e.message}`)
288
+ .join(' ')}`, { epicId, version: plan.version, nextVersion: draft.version, errors: draftValidation.errors });
289
+ }
290
+ const diff = diffPlans(plan, draft);
291
+ // The task ids this amendment makes the cited findings' discharge
292
+ // condition. `removed` and `carried` are not obligations: a carried task is
293
+ // untouched by this amendment, and a removed one will never land. The two
294
+ // rejected shapes are named apart in the message because the diff is not
295
+ // always empty here: `PlanChanges` has no `remove`, so the only way an id
296
+ // leaves a version is `draftNextVersion` dropping a completed task from the
297
+ // live backlog. That is a real `removed` entry the operator did not ask for,
298
+ // and telling them nothing changed would be arguing about the wrong thing.
299
+ //
300
+ // A superseded id only counts while the amended version still claims it. A
301
+ // supersede whose replacement carries a different id leaves the old one
302
+ // behind as a dead record — `diffPlans` calls that renamed away or retired,
303
+ // and still reports it superseded — and nothing will ever dispatch it. The
304
+ // replacement is in `added`, so the obligation survives the rename; keeping
305
+ // the dead id too would make the finding undischargeable, which is the
306
+ // failure this whole defect is about, reached from the other side.
307
+ const live = new Set(livePlanTasks(draft).map((t) => t.task_id));
308
+ const obligations = [
309
+ ...new Set([...diff.added, ...diff.superseded.filter((id) => live.has(id))]),
310
+ ];
311
+ if (obligations.length === 0) {
312
+ const shape = diff.removed.length > 0
313
+ ? `would drop completed ${diff.removed.join(', ')} and carry the rest forward unchanged`
314
+ : 'would carry every task forward unchanged';
315
+ throw new SpecError('plan.amendment-without-obligation', `Refusing to amend plan "${epicId}" v${plan.version}: v${draft.version} ${shape}, adding no task and superseding none. Those two are the only changes that leave work to land, so ${cited.map((f) => f.finding_id).join(', ')} would have nothing to wait on and cutting the version would discharge the finding on the spot (D-127). Say which task the amendment adds or supersedes.`, {
316
+ epicId,
317
+ version: plan.version,
318
+ nextVersion: draft.version,
319
+ findingIds: [...findingIds],
320
+ diff,
321
+ });
322
+ }
323
+ // Which named sites this version leaves no work to land on. Deliberately not
324
+ // a guard: the fix for a shape in one file legitimately lands in another, and
325
+ // refusing here would price the act of naming a site — pushing the next
326
+ // author toward the shorter list, which is the defect rather than the cure.
327
+ // Recorded so the closing review reads the scope question off the event
328
+ // instead of reconstructing it (D-123).
329
+ // Through `claimCoversPath`, not string equality: claims are globs, and a
330
+ // site under `src/bar/*.ts` is claimed whether or not it is spelled that way.
331
+ // That function is the one place this question gets answered (P9-15).
332
+ // `TaskSpecRecord.claims` is `unknown` and the plan's own schema is what
333
+ // guarantees it is a string array, so it is filtered rather than re-validated
334
+ // here — the same call `ownershipFromPlan` makes. A task with no claims
335
+ // contributes no coverage, which leaves its sites unclaimed rather than
336
+ // silently covered: the safe direction for a list whose job is to be read.
337
+ const obligated = new Set(obligations);
338
+ const claims = draft.tasks
339
+ .filter((t) => obligated.has(t.task_id) && Array.isArray(t.claims))
340
+ .flatMap((t) => t.claims);
341
+ const unclaimedSites = namedSites.filter((s) => !claims.some((c) => claimCoversPath(c, s)));
342
+ // Validation is done; from here the version exists and the record has to
343
+ // catch up to it, so nothing below is allowed to be conditional. The write
344
+ // goes through `nextVersion` rather than from `draft`: `draftNextVersion` is
345
+ // pure, so the two agree by construction, and there stays exactly one
346
+ // function in this codebase that puts a plan version on disk.
347
+ const amended = nextVersion(plan, input.changes ?? {}, opts);
348
+ await appendEvent({
349
+ session_id: ctx.sessionId,
350
+ actor: ctx.actor ?? 'system',
351
+ event_type: PLAN_AMENDED_EVENT,
352
+ task_id: planRefTaskId(epicId, amended.version),
353
+ plan_version: amended.version,
354
+ causal_parent: ctx.causalParent,
355
+ payload: {
356
+ epic_id: epicId,
357
+ version: amended.version,
358
+ previous_version: plan.version,
359
+ amends: cited.map((f) => ({
360
+ finding_id: f.finding_id,
361
+ criterion_ref: f.spec_ref?.criterion_ref ?? null,
362
+ })),
363
+ rationale,
364
+ sites: namedSites,
365
+ sites_unclaimed: unclaimedSites,
366
+ diff,
367
+ },
368
+ }, opts);
369
+ for (const finding of cited) {
370
+ await transition(finding.finding_id, AMEND_PENDING_STATUS, ctx, opts, {
371
+ amendsTaskIds: obligations,
372
+ amendsPlanVersion: amended.version,
373
+ });
374
+ }
375
+ return { plan: amended, diff, sitesUnclaimed: unclaimedSites };
376
+ }