@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,497 @@
1
+ import { SmithError } from './errors.js';
2
+ import { appendEvent, readLineageEvents } from './events.js';
3
+ import { listFindings, mintFindings, raiseFinding, transition, } from './findings.js';
4
+ import { diffPlans, draftNextVersion, latestPlanVersion, livePlanTasks, validatePlan, } from './plan.js';
5
+ import { compileSchemas, validateRecord } from './schemas.js';
6
+ import { amendPlan } from './spec.js';
7
+ import { loadTaxonomy } from './taxonomy.js';
8
+ /**
9
+ * The worker half of a living spec.
10
+ *
11
+ * D-33 gave the operator a legitimate way out of a spec defect: `amendPlan`
12
+ * cuts a new plan version against the spec-scoped finding that forced it, and
13
+ * every version stays immutable and on the event log. It left two gaps on the
14
+ * other side of the wall.
15
+ *
16
+ * The first is who may say it. Only a judge dispatched with `spec: {planVersion}`
17
+ * can mint a spec-scoped finding, so a coder that reads a criterion, builds
18
+ * against it and discovers halfway through that it assumes something untrue
19
+ * has no way to say so. Its choices were to build the wrong thing or to fail
20
+ * the task, and the dogfood log has both.
21
+ *
22
+ * The second is who writes the fix. `plan amend` requires the operator to
23
+ * hand-author the `--changes` JSON — the content of the amendment, which the
24
+ * worker that hit the wall knows and the operator does not. "Approve quickly"
25
+ * is not possible while approving means writing the diff yourself.
26
+ *
27
+ * This module is the bridge, and its shape is forced rather than chosen. A
28
+ * worker cannot emit an event — only the node that dispatched it can — so the
29
+ * worker's signal is a field returned in `structured_output`, exactly like
30
+ * `research_request` and for the same reason: a returned field is the only
31
+ * shape that survives the worker dying mid-flight, which is precisely what
32
+ * discovering a wrong assumption tends to produce.
33
+ *
34
+ * What the dispatcher records is a PROPOSAL. It is data, not a command:
35
+ * nothing here writes a plan file, and the only path to a new version is still
36
+ * `amendPlan`, with every guard it already has. What approval removes is the
37
+ * typing, not the checking.
38
+ */
39
+ export class SpecChangeError extends SmithError {
40
+ }
41
+ /** A worker returned a spec diff. Recorded; nothing has moved. */
42
+ export const SPEC_CHANGE_PROPOSED_EVENT = 'spec-change-proposed';
43
+ /** An operator answered one. Approval is what calls `plan amend`. */
44
+ export const SPEC_CHANGE_DECIDED_EVENT = 'spec-change-decided';
45
+ /**
46
+ * The severity a proposal carries when the worker states none. S2 rather than
47
+ * S3 because the floor matters: `raiseFinding` only consults the waiver list
48
+ * for `S3-minor`/`S4-nit` (D-196), so a default inside that band would let a
49
+ * standing waiver silently swallow the anchor an amendment has to cite.
50
+ */
51
+ const DEFAULT_SEVERITY = 'S2-major';
52
+ /**
53
+ * Fixed, not taken from the worker. Every proposal makes the same claim — the
54
+ * criterion does not describe the system it is about — and that claim is a
55
+ * correctness one regardless of which subsystem exposed it.
56
+ */
57
+ const PROPOSAL_CATEGORY = 'correctness';
58
+ let cachedTaxonomy;
59
+ let cachedSchemas;
60
+ /** Same lazy pair every module that validates a record keeps, for the same
61
+ * reason: compiling the schema set is not free and the set never changes
62
+ * inside a process. `opts` still wins, so a test can hand in its own. */
63
+ function loadCachedTaxonomy() {
64
+ if (cachedTaxonomy === undefined)
65
+ cachedTaxonomy = loadTaxonomy();
66
+ return cachedTaxonomy;
67
+ }
68
+ function resolveSchemas(opts) {
69
+ if (opts.schemas !== undefined)
70
+ return opts.schemas;
71
+ if (cachedSchemas === undefined)
72
+ cachedSchemas = compileSchemas(loadCachedTaxonomy());
73
+ return cachedSchemas;
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // Propose
77
+ // ---------------------------------------------------------------------------
78
+ /**
79
+ * Record a worker's spec diff as a proposal.
80
+ *
81
+ * Everything validates before anything acts. A proposal reaches an operator's
82
+ * queue only if it could actually be applied — the draft is built and run
83
+ * through the plan's own validator here rather than at approval, so an
84
+ * unappliable diff is refused while the worker that wrote it is still the one
85
+ * being told, not hours later when an operator tries to say yes.
86
+ *
87
+ * It writes no plan file. The version is cut by `plan amend` alone.
88
+ */
89
+ export async function proposeSpecChange(input, ctx, opts = {}) {
90
+ const { plan, taskId, proposedBy, request } = input;
91
+ const epicId = plan.epic_id;
92
+ // The shape check first, and against the published schema rather than a
93
+ // second reading of it here. `request` arrives from a worker's
94
+ // `structured_output`, which is to say from a model: it is the least
95
+ // trustworthy input this module takes, and the field the guards below never
96
+ // reach on their own is `severity`, whose taxonomy membership the schema's
97
+ // `x-taxonomy` pointer resolves. Without this the first thing to notice an
98
+ // invented severity would be the finding schema, several steps later, and
99
+ // it would name the finding rather than the request that malformed it.
100
+ const shape = validateRecord(resolveSchemas(opts), opts.taxonomy ?? loadCachedTaxonomy(), 'spec-change-request', request);
101
+ if (!shape.valid) {
102
+ throw new SpecChangeError('spec-change.proposal-malformed', `Refusing a spec change proposal from ${proposedBy} on "${taskId}": the request does not match the spec-change-request schema. ${shape.errors.map((e) => `${e.path}: ${e.message}`).join(' ')}`, { taskId, proposedBy, errors: shape.errors });
103
+ }
104
+ const criterionRef = request.criterion_ref?.trim() ?? '';
105
+ if (criterionRef === '') {
106
+ throw new SpecChangeError('spec-change.proposal-without-criterion', `Refusing a spec change proposal from ${proposedBy} on "${taskId}": it names no criterion_ref. "The plan is wrong" has to say which clause — the same rule mintFindings enforces on spec-review evidence.`, { taskId, proposedBy });
107
+ }
108
+ const assumption = request.assumption?.trim() ?? '';
109
+ const evidence = request.evidence?.trim() ?? '';
110
+ if (assumption === '' || evidence === '') {
111
+ throw new SpecChangeError('spec-change.proposal-without-argument', `Refusing a spec change proposal against ${criterionRef}: it states ${assumption === '' ? 'no assumption' : 'no evidence'}. The operator is being asked to overturn a criterion, and the assumption plus what contradicts it is the whole of what they have to go on.`, { taskId, criterionRef, hasAssumption: assumption !== '', hasEvidence: evidence !== '' });
112
+ }
113
+ // D-123, asked at the one point in the pipeline where the answer is known.
114
+ // The worker has just read the code the wrong assumption's shape occurs in;
115
+ // the operator has not, and `plan amend` will demand this list from whoever
116
+ // is standing there at approval time.
117
+ const sites = [...new Set((request.sites ?? []).map((site) => site.trim()))].filter((site) => site !== '');
118
+ if (sites.length === 0) {
119
+ throw new SpecChangeError('spec-change.proposal-without-sites', `Refusing a spec change proposal against ${criterionRef}: it names no sites. Every place the wrong assumption's shape occurs, not only where you hit it — you are better placed to enumerate them now than an operator is later.`, { taskId, criterionRef });
120
+ }
121
+ // The same check `amendPlan` runs on its own draft (D-21), one step earlier.
122
+ // Plans are immutable and nothing deletes one, so the cost of finding this
123
+ // out after the write is unrecoverable; the cost of finding it out here is a
124
+ // refusal the worker can still answer.
125
+ const draft = draftNextVersion(plan, request.changes ?? {});
126
+ const validation = validatePlan(draft, opts);
127
+ if (!validation.valid) {
128
+ throw new SpecChangeError('spec-change.proposal-invalid-draft', `Refusing a spec change proposal against ${criterionRef}: v${draft.version} would fail its own validation, so it could never be approved. ${validation.errors.map((e) => `${e.path}: ${e.message}`).join(' ')}`, { taskId, criterionRef, errors: validation.errors });
129
+ }
130
+ const diff = diffPlans(plan, draft);
131
+ // Mirrors `amendPlan`'s obligation rule: added tasks, plus superseded ones
132
+ // that are still live in the draft. A proposal that obligates nothing would
133
+ // be approved into a finding that discharges the instant it is parked — an
134
+ // amendment nobody has to build, which is another way of saying no
135
+ // amendment at all.
136
+ const live = new Set(livePlanTasks(draft).map((t) => t.task_id));
137
+ const obligations = [
138
+ ...new Set([...diff.added, ...diff.superseded.filter((id) => live.has(id))]),
139
+ ];
140
+ if (obligations.length === 0) {
141
+ throw new SpecChangeError('spec-change.proposal-without-obligation', `Refusing a spec change proposal against ${criterionRef}: the diff adds no task and supersedes no live one, so approving it would obligate nobody to build anything. Say which task's spec changes.`, { taskId, criterionRef, diff });
142
+ }
143
+ const severity = request.severity ?? DEFAULT_SEVERITY;
144
+ const summary = proposalSummary(criterionRef, assumption);
145
+ const item = {
146
+ file_path: sites[0],
147
+ finding_category: PROPOSAL_CATEGORY,
148
+ severity,
149
+ summary,
150
+ failure_scenario: {
151
+ inputs: `${taskId} built against ${criterionRef}`,
152
+ expected: assumption,
153
+ actual: evidence,
154
+ },
155
+ criterion_ref: criterionRef,
156
+ };
157
+ const drafts = mintFindings([item], {
158
+ taskId,
159
+ foundBy: proposedBy,
160
+ ...(input.proposedByProvider === undefined
161
+ ? {}
162
+ : { foundByProvider: input.proposedByProvider }),
163
+ // What makes this finding spec-scoped, and so routable out of the task
164
+ // gate to `amendPlan` instead of back to a coder with nothing to change.
165
+ spec: { planVersion: plan.version },
166
+ }, opts);
167
+ const minted = drafts[0];
168
+ if (minted === undefined)
169
+ throw new Error('unreachable: one evidence item mints one draft');
170
+ const raised = await raiseFinding(minted, ctx, opts);
171
+ if (raised.suppressed) {
172
+ // `recordSpecReview` skips a suppressed raise, and is right to: a
173
+ // suppressed review finding is one the operator has already answered.
174
+ // Here the finding is not a record of the proposal — it is the anchor
175
+ // `amendPlan` cites, and a proposal without one can never be approved.
176
+ // Failing loudly beats queueing something permanently unapprovable.
177
+ throw new SpecChangeError('spec-change.proposal-suppressed', `Refusing a spec change proposal against ${criterionRef}: a standing waiver on fingerprint ${raised.fingerprint} suppressed the finding an approval would have to cite, so the proposal could never be applied. Deny the waiver, or raise the proposal's severity above the waivable band.`, { taskId, criterionRef, fingerprint: raised.fingerprint, severity });
178
+ }
179
+ const stored = await appendEvent({
180
+ session_id: ctx.sessionId,
181
+ actor: ctx.actor ?? 'system',
182
+ event_type: SPEC_CHANGE_PROPOSED_EVENT,
183
+ task_id: taskId,
184
+ plan_version: ctx.planVersion,
185
+ causal_parent: ctx.causalParent,
186
+ payload: {
187
+ epic_id: epicId,
188
+ base_version: plan.version,
189
+ proposed_by: proposedBy,
190
+ ...(input.proposedByProvider === undefined
191
+ ? {}
192
+ : { proposed_by_provider: input.proposedByProvider }),
193
+ finding_id: raised.finding.finding_id,
194
+ criterion_ref: criterionRef,
195
+ assumption,
196
+ evidence,
197
+ sites,
198
+ changes: request.changes ?? {},
199
+ diff,
200
+ blocking: request.blocking === true,
201
+ severity,
202
+ },
203
+ }, opts);
204
+ return {
205
+ proposalId: stored.event_id,
206
+ epicId,
207
+ taskId,
208
+ baseVersion: plan.version,
209
+ proposedBy,
210
+ ...(input.proposedByProvider === undefined
211
+ ? {}
212
+ : { proposedByProvider: input.proposedByProvider }),
213
+ findingId: raised.finding.finding_id,
214
+ criterionRef,
215
+ assumption,
216
+ evidence,
217
+ sites,
218
+ changes: request.changes ?? {},
219
+ diff,
220
+ blocking: request.blocking === true,
221
+ severity,
222
+ status: 'open',
223
+ decision: null,
224
+ ts: stored.record.ts,
225
+ };
226
+ }
227
+ /**
228
+ * The finding's summary, and so — through `computeFingerprint` — the identity
229
+ * two proposals against the same wrong assumption share. Built from the
230
+ * criterion and the assumption rather than the evidence, so a second worker
231
+ * that hits the same wall from a different file dedups onto the first.
232
+ */
233
+ function proposalSummary(criterionRef, assumption) {
234
+ return `${criterionRef} rests on a wrong assumption: ${assumption}`;
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // Read
238
+ // ---------------------------------------------------------------------------
239
+ /**
240
+ * Fold the log into proposals. Pure, and deliberately unable to say `stale`:
241
+ * staleness is a fact about the plan files that exist right now, and a fold
242
+ * that quietly read the disk would make a re-fold of an old log disagree with
243
+ * the log.
244
+ */
245
+ export function foldSpecChanges(events, filter = {}) {
246
+ const byId = new Map();
247
+ for (const stored of events) {
248
+ const { record } = stored;
249
+ if (record.event_type === SPEC_CHANGE_PROPOSED_EVENT) {
250
+ const payload = record.payload;
251
+ byId.set(stored.event_id, {
252
+ proposalId: stored.event_id,
253
+ epicId: payload.epic_id ?? '',
254
+ taskId: record.task_id ?? '',
255
+ baseVersion: payload.base_version ?? 0,
256
+ proposedBy: payload.proposed_by ?? 'unknown',
257
+ ...(payload.proposed_by_provider === undefined
258
+ ? {}
259
+ : { proposedByProvider: payload.proposed_by_provider }),
260
+ findingId: payload.finding_id ?? '',
261
+ criterionRef: payload.criterion_ref ?? '',
262
+ assumption: payload.assumption ?? '',
263
+ evidence: payload.evidence ?? '',
264
+ sites: [...(payload.sites ?? [])],
265
+ changes: payload.changes ?? {},
266
+ diff: payload.diff ?? { added: [], removed: [], superseded: [], carried: [] },
267
+ blocking: payload.blocking === true,
268
+ severity: payload.severity ?? DEFAULT_SEVERITY,
269
+ status: 'open',
270
+ decision: null,
271
+ ts: record.ts,
272
+ });
273
+ continue;
274
+ }
275
+ if (record.event_type === SPEC_CHANGE_DECIDED_EVENT) {
276
+ const payload = record.payload;
277
+ const target = payload.proposal_id ? byId.get(payload.proposal_id) : undefined;
278
+ // A decision naming no proposal this fold has seen is dropped rather
279
+ // than invented: the proposal is the record, and half a record read back
280
+ // as a whole one is worse than a gap.
281
+ if (target === undefined)
282
+ continue;
283
+ const decision = payload.decision === 'rejected' ? 'rejected' : 'approved';
284
+ target.status = decision;
285
+ target.decision = {
286
+ decision,
287
+ decidedBy: payload.decided_by ?? 'unknown',
288
+ rationale: payload.rationale ?? '',
289
+ planVersion: payload.plan_version ?? null,
290
+ ts: record.ts,
291
+ };
292
+ }
293
+ }
294
+ return [...byId.values()].filter((p) => (filter.epicId === undefined || p.epicId === filter.epicId) &&
295
+ (filter.taskId === undefined || p.taskId === filter.taskId) &&
296
+ (filter.status === undefined || p.status === filter.status));
297
+ }
298
+ /**
299
+ * Has the plan moved out from under this proposal? The diff was computed
300
+ * against `baseVersion`; if a later version exists, nobody has checked that it
301
+ * still applies, and `writePlanFile` would refuse the collision anyway with a
302
+ * message about version numbers rather than about the proposal.
303
+ *
304
+ * A missing plan directory casts no vote (D-126) — `latestPlanVersion` returns
305
+ * null, and a proposal held against a plan not yet on disk stays open.
306
+ */
307
+ export function isStale(proposal, opts = {}) {
308
+ const latest = latestPlanVersion(proposal.epicId, opts);
309
+ return latest !== null && latest > proposal.baseVersion;
310
+ }
311
+ /**
312
+ * The operator's queue: the fold, with staleness applied, then filtered.
313
+ *
314
+ * The status filter runs last on purpose. Asking for `open` should not return
315
+ * a proposal the plan has already outrun, and asking for `stale` should return
316
+ * exactly those — neither is answerable before the disk has been consulted.
317
+ */
318
+ export async function listSpecChanges(sessionId, filter = {}, opts = {}) {
319
+ const events = await readLineageEvents(sessionId, opts);
320
+ const { status, ...rest } = filter;
321
+ const folded = foldSpecChanges(events, rest);
322
+ const marked = folded.map((proposal) => proposal.status === 'open' && isStale(proposal, opts)
323
+ ? { ...proposal, status: 'stale' }
324
+ : proposal);
325
+ return status === undefined ? marked : marked.filter((p) => p.status === status);
326
+ }
327
+ async function requireProposal(proposalId, opts, ctx) {
328
+ const listed = await listSpecChanges(ctx.sessionId, {}, opts);
329
+ const found = listed.find((p) => p.proposalId === proposalId);
330
+ if (found === undefined) {
331
+ throw new SpecChangeError('spec-change.unknown-proposal', `No spec change proposal "${proposalId}" on this session's lineage. \`smith plan proposals\` lists what is there.`, { proposalId, sessionId: ctx.sessionId });
332
+ }
333
+ return found;
334
+ }
335
+ function refuseIfDecided(proposal) {
336
+ if (proposal.decision !== null) {
337
+ throw new SpecChangeError('spec-change.already-decided', `Spec change proposal ${proposal.proposalId} was already ${proposal.decision.decision} by ${proposal.decision.decidedBy}. A decision is a fact on the log, not a draft — re-propose against the current plan version instead.`, { proposalId: proposal.proposalId, decision: proposal.decision.decision });
338
+ }
339
+ }
340
+ // ---------------------------------------------------------------------------
341
+ // Decide
342
+ // ---------------------------------------------------------------------------
343
+ /**
344
+ * Approve a proposal: cut the version the worker asked for.
345
+ *
346
+ * This adds no power. It delegates to `amendPlan`, which still demands a
347
+ * spec-scoped finding, a non-blank rationale, a site list, a draft that
348
+ * validates and a diff that obligates somebody — every one of those is
349
+ * satisfied from the proposal rather than relaxed. What the operator no longer
350
+ * has to do is type them.
351
+ */
352
+ export async function approveSpecChange(input, ctx, opts = {}) {
353
+ const proposal = await requireProposal(input.proposalId, opts, ctx);
354
+ refuseIfDecided(proposal);
355
+ if (input.plan.epic_id !== proposal.epicId) {
356
+ throw new SpecChangeError('spec-change.approval-wrong-epic', `Spec change proposal ${proposal.proposalId} is against epic "${proposal.epicId}", but the plan supplied is "${input.plan.epic_id}". Amending the wrong epic from the right proposal is the one mistake this guard exists to make impossible.`, { proposalId: proposal.proposalId, expected: proposal.epicId, given: input.plan.epic_id });
357
+ }
358
+ // Fail closed on both axes of staleness, the same pair `specReviewBlockers`
359
+ // reads (D-125/D-126): the version the proposal was drafted against, and the
360
+ // versions that exist now. Without this the fallback is `writePlanFile`'s
361
+ // `plan.version-exists` — a correct refusal carrying a message about file
362
+ // names, at the point where the operator wanted to hear about their diff.
363
+ const latest = latestPlanVersion(proposal.epicId, opts);
364
+ const overtaken = latest !== null && latest > proposal.baseVersion;
365
+ if (input.plan.version !== proposal.baseVersion || overtaken) {
366
+ const current = overtaken ? latest : input.plan.version;
367
+ throw new SpecChangeError('spec-change.approval-stale', `Spec change proposal ${proposal.proposalId} was drafted against "${proposal.epicId}" v${proposal.baseVersion}, and the plan has since moved to v${current}. Its diff has not been checked against the newer version — re-propose against v${current} rather than applying it blind.`, {
368
+ proposalId: proposal.proposalId,
369
+ baseVersion: proposal.baseVersion,
370
+ planVersion: input.plan.version,
371
+ latestVersion: latest,
372
+ });
373
+ }
374
+ // The point of the whole exercise. `amendPlan` refuses a blank rationale,
375
+ // and rightly — the diff records what moved and only the rationale records
376
+ // why. Approval stays one command by REUSING the worker's own recorded
377
+ // argument, not by relaxing that guard and not by inventing a sentence: the
378
+ // reason on the log is the reason the change was made.
379
+ const rationale = input.rationale?.trim() !== undefined && input.rationale.trim() !== ''
380
+ ? input.rationale.trim()
381
+ : defaultRationale(proposal);
382
+ const amendment = await amendPlan({
383
+ plan: input.plan,
384
+ findingIds: [proposal.findingId],
385
+ rationale,
386
+ sites: proposal.sites,
387
+ changes: proposal.changes,
388
+ }, ctx, opts);
389
+ const stored = await appendEvent({
390
+ session_id: ctx.sessionId,
391
+ actor: ctx.actor ?? 'system',
392
+ event_type: SPEC_CHANGE_DECIDED_EVENT,
393
+ task_id: proposal.taskId,
394
+ plan_version: ctx.planVersion,
395
+ causal_parent: ctx.causalParent,
396
+ payload: {
397
+ proposal_id: proposal.proposalId,
398
+ epic_id: proposal.epicId,
399
+ decision: 'approved',
400
+ decided_by: input.decidedBy,
401
+ rationale,
402
+ plan_version: amendment.plan.version,
403
+ },
404
+ }, opts);
405
+ return {
406
+ proposal: {
407
+ ...proposal,
408
+ status: 'approved',
409
+ decision: {
410
+ decision: 'approved',
411
+ decidedBy: input.decidedBy,
412
+ rationale,
413
+ planVersion: amendment.plan.version,
414
+ ts: stored.record.ts,
415
+ },
416
+ },
417
+ plan: amendment.plan,
418
+ diff: amendment.diff,
419
+ sitesUnclaimed: amendment.sitesUnclaimed,
420
+ };
421
+ }
422
+ /**
423
+ * The worker's argument, replayed as the amendment's reason.
424
+ *
425
+ * Nothing here is the machine's opinion: every clause is a field the worker
426
+ * wrote. That is what makes it safe to use unattended — an operator who wants
427
+ * to say something else passes `--rationale` and this is never reached.
428
+ */
429
+ function defaultRationale(proposal) {
430
+ return `Approved ${proposal.proposalId}: ${proposal.proposedBy} on ${proposal.taskId} found that ${proposal.criterionRef} assumes "${proposal.assumption}", contradicted by ${proposal.evidence}.`;
431
+ }
432
+ /**
433
+ * Reject a proposal: the criterion stands.
434
+ *
435
+ * This refutes the finding, which is the strong reading and the reason the
436
+ * rationale is mandatory — the worker is being sent back to build against the
437
+ * criterion it just argued was wrong, and "no" without a reason is an
438
+ * instruction it cannot act on.
439
+ *
440
+ * There is a third answer this deliberately does not cover. An operator who
441
+ * agrees the criterion is wrong but dislikes the proposed shape does not
442
+ * reject: they run `plan amend` citing the same still-`raised` finding with
443
+ * their own `--changes`. That path needs no code here, and rejection stays
444
+ * unambiguous because of it.
445
+ */
446
+ export async function rejectSpecChange(input, ctx, opts = {}) {
447
+ const proposal = await requireProposal(input.proposalId, opts, ctx);
448
+ refuseIfDecided(proposal);
449
+ const rationale = input.rationale?.trim() ?? '';
450
+ if (rationale === '') {
451
+ throw new SpecChangeError('spec-change.rejection-without-rationale', `Refusing to reject spec change proposal ${proposal.proposalId} without a rationale. The worker is being sent back to build against ${proposal.criterionRef} after arguing it is wrong; "no" on its own is not something it can act on.`, { proposalId: proposal.proposalId, criterionRef: proposal.criterionRef });
452
+ }
453
+ const stored = await appendEvent({
454
+ session_id: ctx.sessionId,
455
+ actor: ctx.actor ?? 'system',
456
+ event_type: SPEC_CHANGE_DECIDED_EVENT,
457
+ task_id: proposal.taskId,
458
+ plan_version: ctx.planVersion,
459
+ causal_parent: ctx.causalParent,
460
+ payload: {
461
+ proposal_id: proposal.proposalId,
462
+ epic_id: proposal.epicId,
463
+ decision: 'rejected',
464
+ decided_by: input.decidedBy,
465
+ rationale,
466
+ plan_version: null,
467
+ },
468
+ }, opts);
469
+ await refuteAnchor(proposal, ctx, opts);
470
+ return {
471
+ ...proposal,
472
+ status: 'rejected',
473
+ decision: {
474
+ decision: 'rejected',
475
+ decidedBy: input.decidedBy,
476
+ rationale,
477
+ planVersion: null,
478
+ ts: stored.record.ts,
479
+ },
480
+ };
481
+ }
482
+ /**
483
+ * Close the finding the proposal raised. Best-effort by design: a finding an
484
+ * operator has already waived or expired by hand is a decision that outranks
485
+ * this one, and a rejection that threw over it would leave the decision on the
486
+ * log with no way to record the same answer twice.
487
+ */
488
+ async function refuteAnchor(proposal, ctx, opts) {
489
+ const findings = await listFindings(ctx.sessionId, {}, opts);
490
+ const anchor = findings.find((f) => f.finding_id === proposal.findingId);
491
+ if (anchor === undefined || anchor.finding_status !== 'raised')
492
+ return;
493
+ // The reason rides on `spec-change-decided`, where a reader looking for why
494
+ // the proposal died will actually be standing. Repeating it on the
495
+ // transition would be a second copy free to drift from the first.
496
+ await transition(proposal.findingId, 'refuted', ctx, opts);
497
+ }