@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,502 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { unreadTaskBudgetFields } from './budgets.js';
4
+ import { SmithError } from './errors.js';
5
+ import { topoSort } from './graph.js';
6
+ import { SPECS_ACTIVE_DIR } from './paths.js';
7
+ import { compileSchemas, validateRecord, } from './schemas.js';
8
+ import { loadTaxonomy, TaxonomyError, validateRequiredDimensions, } from './taxonomy.js';
9
+ export class PlanError extends SmithError {
10
+ }
11
+ /**
12
+ * How much judgment an epic buys, cheapest first — factory/policies/effort.yml
13
+ * declares what each tier means; this is only the closed list of names, kept
14
+ * here beside the `PlanFile.effort` field it types. effort.ts is the semantic
15
+ * owner and re-exports these; it cannot own the list itself, because plan.ts
16
+ * would then have to import it to validate a plan and effort.ts already
17
+ * (transitively) imports plan.ts.
18
+ */
19
+ export const EFFORT_TIERS = ['small', 'medium', 'huge'];
20
+ export function isEffortTier(value) {
21
+ return typeof value === 'string' && EFFORT_TIERS.includes(value);
22
+ }
23
+ /**
24
+ * Pseudo task-id ref for a PLAN-level (not task-level) event: planQuorum.ts's
25
+ * runPlanQuorum() stamps it onto the dispatch_decision/judge-verdict/
26
+ * quorum-decision events its quorum case emits, so provider-agreement
27
+ * analytics group a plan critique like any other dispatch. It is not a task —
28
+ * db/projector.ts's foldTasks() refuses to materialise a row for it, exactly
29
+ * as it does for worktree.ts's `<epic>/integration`. Lives here rather than in
30
+ * planQuorum.ts so the projector can recognise the shape without importing a
31
+ * quorum host (wrong dependency direction: db/ is below the hosts, not above).
32
+ */
33
+ export function planRefTaskId(epicId, version) {
34
+ return `${epicId}/plan-v${version}`;
35
+ }
36
+ const PLAN_REF_PATTERN = /^plan-v\d+$/;
37
+ /** Counterpart to planRefTaskId(): does this task_id name a plan version rather than a task? */
38
+ export function isPlanRefTaskId(taskId) {
39
+ const last = taskId.split('/').pop();
40
+ return last !== undefined && PLAN_REF_PATTERN.test(last);
41
+ }
42
+ /**
43
+ * Mint a task id from the plan rather than trusting the one that was typed.
44
+ *
45
+ * D-46/P9-29: the dogfood epic ended with a phantom task row because one
46
+ * human admitted `envkit/task-1-parse-quotes` and another merged
47
+ * `task-1-parse-quotes`. Nothing in the system objected, because nothing in
48
+ * the system owned the id — it was whatever reached the keyboard. There is
49
+ * no "correct" convention to standardise on here; the plan file already IS
50
+ * the register of what exists, so the fix is to make every producer resolve
51
+ * through it. Both spellings map to the one string the plan holds, an id the
52
+ * plan does not contain is a typed error rather than a silent miss, and an
53
+ * id two tasks could equally mean is refused instead of guessed.
54
+ *
55
+ * D-48/P9-31: the plan is not the *only* register. A follow-up task minted by
56
+ * `findings raise` is written to the event log and nowhere else — the plan
57
+ * that was cut before the finding existed cannot name it. A resolver that
58
+ * consults the plan alone therefore refuses the very task the factory has
59
+ * just created, and the follow-up sits `todo` forever with `smith event
60
+ * append` as its only exit. `alsoKnown` is that second register: ids the log
61
+ * has added. A task found in either source is real; one found in neither is
62
+ * still a typed error, and the message names both places it was looked for.
63
+ */
64
+ /**
65
+ * The epic-qualified/bare spelling rule of resolveTaskId, exported so callers
66
+ * that compare ids across two registers — the plan file and the event log —
67
+ * apply the same one. `epic-1/task-1` and `task-1` are the same task under
68
+ * plan `epic-1`; `epic-2/task-1` is not.
69
+ */
70
+ export function bareTaskId(epicId, taskId) {
71
+ return taskId.startsWith(`${epicId}/`) ? taskId.slice(epicId.length + 1) : taskId;
72
+ }
73
+ export function resolveTaskId(plan, typed, alsoKnown = []) {
74
+ const qualified = `${plan.epic_id}/${typed}`;
75
+ const known = [...plan.tasks.map((t) => t.task_id), ...alsoKnown];
76
+ const matches = known.filter((id) => id === typed ||
77
+ id === qualified ||
78
+ bareTaskId(plan.epic_id, id) === bareTaskId(plan.epic_id, typed));
79
+ const unique = [...new Set(matches)];
80
+ if (unique.length === 1)
81
+ return unique[0];
82
+ if (unique.length === 0) {
83
+ const fromLog = alsoKnown.length === 0 ? '' : ` Added by the log: ${[...new Set(alsoKnown)].join(', ')}.`;
84
+ throw new PlanError('plan.unknown-task', `Plan "${plan.epic_id}" v${plan.version} has no task "${typed}". Known task ids: ${plan.tasks
85
+ .map((t) => t.task_id)
86
+ .join(', ')}.${fromLog}`, { epicId: plan.epic_id, version: plan.version, typed });
87
+ }
88
+ throw new PlanError('plan.ambiguous-task', `Task id "${typed}" is ambiguous in plan "${plan.epic_id}" v${plan.version} — it could mean ${unique.join(' or ')}. Use the full id the plan lists.`, { epicId: plan.epic_id, version: plan.version, typed, candidates: unique });
89
+ }
90
+ let cachedTaxonomy;
91
+ let cachedSchemas;
92
+ function resolveTaxonomyAndSchemas(opts) {
93
+ if (cachedTaxonomy === undefined)
94
+ cachedTaxonomy = loadTaxonomy();
95
+ if (cachedSchemas === undefined)
96
+ cachedSchemas = compileSchemas(cachedTaxonomy);
97
+ const taxonomy = opts.taxonomy ?? cachedTaxonomy;
98
+ const schemas = opts.schemas ?? cachedSchemas;
99
+ return { taxonomy, schemas };
100
+ }
101
+ function planFilePath(epicId, version, opts) {
102
+ const dir = opts.specsDir ?? SPECS_ACTIVE_DIR;
103
+ return path.join(dir, epicId, `plan-v${version}.json`);
104
+ }
105
+ const PLAN_FILE_PATTERN = /^plan-v(\d+)\.json$/;
106
+ /**
107
+ * The highest plan version written for an epic, or null when the epic has no
108
+ * plan directory — which is not an error. Phase 9 was driven as punch-list
109
+ * branches rather than from plan files, and `epic verdict` must still be able
110
+ * to close those epics (D-126: a missing plan casts no vote; a present one
111
+ * does).
112
+ */
113
+ export function latestPlanVersion(epicId, opts = {}) {
114
+ const dir = path.join(opts.specsDir ?? SPECS_ACTIVE_DIR, epicId);
115
+ if (!existsSync(dir))
116
+ return null;
117
+ let latest = null;
118
+ for (const entry of readdirSync(dir)) {
119
+ const m = PLAN_FILE_PATTERN.exec(entry);
120
+ if (m === null)
121
+ continue;
122
+ const version = Number(m[1]);
123
+ if (latest === null || version > latest)
124
+ latest = version;
125
+ }
126
+ return latest;
127
+ }
128
+ export function loadPlan(epicId, version, opts = {}) {
129
+ const filePath = planFilePath(epicId, version, opts);
130
+ if (!existsSync(filePath)) {
131
+ throw new PlanError('plan.not-found', `No plan file at ${filePath}.`, { epicId, version });
132
+ }
133
+ return JSON.parse(readFileSync(filePath, 'utf8'));
134
+ }
135
+ /**
136
+ * Reduce an `output_schema_ref` to the name the compiled schema set knows it
137
+ * by. `compileSchemas` keys on filename-minus-`.schema.json`, so a path, a
138
+ * bare filename, a `$id` URL and a bare name all have to land on the same
139
+ * key. Deliberately permissive about the shape of the ref and strict only
140
+ * about whether the thing it names exists (P9-20 / D-7).
141
+ */
142
+ function schemaRefName(ref) {
143
+ const last = ref.split(/[/\\]/).pop() ?? ref;
144
+ return last.replace(/\.schema\.json$/, '').replace(/\.json$/, '');
145
+ }
146
+ /**
147
+ * Append the legal vocabulary to a taxonomy rejection. validateTag already
148
+ * carries `allowed` in its details and drops it from the sentence; a plan
149
+ * author reading a validation report has no other way to see the list, and
150
+ * "unknown value" without the list is the exact shape of the finding.
151
+ */
152
+ function describeAllowed(err) {
153
+ const allowed = err.details.allowed;
154
+ if (!Array.isArray(allowed) || allowed.length === 0)
155
+ return '';
156
+ return ` Valid: ${allowed.join(', ')}.`;
157
+ }
158
+ /**
159
+ * Validate a plan: every task spec schema/taxonomy-valid, every declared
160
+ * taxonomy dimension on an edge a real value in that vocabulary, dependency
161
+ * edges reference real tasks, and the dependency graph is acyclic. A cycle is
162
+ * fatal and thrown immediately (typed error) rather than accumulated —
163
+ * everything else is collected and returned.
164
+ *
165
+ * P9-20 / D-6: taxonomy.yml has declared `edge_type` and `edge_provenance`
166
+ * required on an edge since v2, and nothing checked membership at the point
167
+ * of writing — a plan carrying `"edge_type": "NOT-A-REAL-EDGE-TYPE"`
168
+ * validated clean. Task specs got this for free through validateRecord's
169
+ * `x-taxonomy` pointers; edges have no schema, so they go through
170
+ * validateRequiredDimensions directly rather than acquiring a second register
171
+ * of what an edge must carry, which could then drift from taxonomy.yml's.
172
+ */
173
+ export function validatePlan(plan, opts = {}) {
174
+ const { taxonomy, schemas } = resolveTaxonomyAndSchemas(opts);
175
+ const errors = [];
176
+ const taskIds = new Set(plan.tasks.map((t) => t.task_id));
177
+ // The effort tier is a closed vocabulary (factory/policies/effort.yml), and
178
+ // a typo in it is the worst kind of quiet: `smith effort show` would read
179
+ // "hgue" as "no tier named" and hand back the policy default, so an epic the
180
+ // operator meant to run at full depth would run at the default one instead.
181
+ // Caught here, at plan time, where the file is still being written.
182
+ if (plan.effort !== undefined && !isEffortTier(plan.effort)) {
183
+ errors.push({
184
+ path: '/effort',
185
+ message: `Effort tier "${String(plan.effort)}" is not one of ${EFFORT_TIERS.join(', ')} (factory/policies/effort.yml). Omit the field to take the policy default.`,
186
+ });
187
+ }
188
+ for (const t of plan.tasks) {
189
+ const result = validateRecord(schemas, taxonomy, 'task-spec', t);
190
+ if (!result.valid) {
191
+ for (const issue of result.errors) {
192
+ errors.push({ path: `/tasks/${t.task_id}${issue.path}`, message: issue.message });
193
+ }
194
+ }
195
+ if (t.plan_version !== plan.version) {
196
+ errors.push({
197
+ path: `/tasks/${t.task_id}/plan_version`,
198
+ message: `Task plan_version ${t.plan_version} does not match plan version ${plan.version}.`,
199
+ });
200
+ }
201
+ // Shape is the schema's business; this only asks whether each declared
202
+ // field has anything that can read it.
203
+ const budget = (typeof t.budget === 'object' && t.budget !== null ? t.budget : {});
204
+ for (const field of unreadTaskBudgetFields(budget)) {
205
+ errors.push({
206
+ path: `/tasks/${t.task_id}/budget/${field}`,
207
+ message: `Budget field "${field}" has no mechanical reader: nothing in the factory can enforce it, so declaring it here states a limit the task will never be held to. Carry the number in the dispatch prompt instead, where it reads as the request it is (agent-interviews.md M-4).`,
208
+ });
209
+ }
210
+ // D-7: the ref is what result.schema.json defers structured_output's shape
211
+ // to, so a ref naming a schema the factory does not have is a check that
212
+ // silently never runs. Reported here, at plan time, rather than at gate
213
+ // intake where the task is already coded.
214
+ const ref = t.output_schema_ref;
215
+ if (typeof ref === 'string' && ref !== '' && !schemas.has(schemaRefName(ref))) {
216
+ errors.push({
217
+ path: `/tasks/${t.task_id}/output_schema_ref`,
218
+ message: `output_schema_ref "${ref}" names no schema in factory/specs/schema. ` +
219
+ `Available: ${[...schemas.keys()].sort().join(', ')}.`,
220
+ });
221
+ }
222
+ }
223
+ plan.edges.forEach((edge, index) => {
224
+ if (!taskIds.has(edge.task)) {
225
+ errors.push({ path: '/edges', message: `Edge references unknown task "${edge.task}".` });
226
+ }
227
+ if (!taskIds.has(edge.dependsOn)) {
228
+ errors.push({
229
+ path: '/edges',
230
+ message: `Edge references unknown dependsOn "${edge.dependsOn}".`,
231
+ });
232
+ }
233
+ try {
234
+ validateRequiredDimensions(taxonomy, 'edge', edge);
235
+ }
236
+ catch (err) {
237
+ // Accumulated, not thrown: a plan with three bad edges should name all
238
+ // three in one pass, the way three bad task specs already do. Only a
239
+ // cycle is fatal here.
240
+ if (!(err instanceof TaxonomyError))
241
+ throw err;
242
+ errors.push({
243
+ path: `/edges/${index}`,
244
+ message: `Edge ${edge.task} <- ${edge.dependsOn}: ${err.message}${describeAllowed(err)}`,
245
+ });
246
+ }
247
+ });
248
+ const topo = topoSort([...taskIds], plan.edges.map((e) => ({ task: e.task, dependsOn: e.dependsOn })));
249
+ if (!topo.ok) {
250
+ throw new PlanError('plan.cyclic-dependency', `Plan "${plan.epic_id}" v${plan.version} has a cyclic dependency among tasks: ${topo.cycle.join(', ')}.`, { cycle: topo.cycle });
251
+ }
252
+ if (errors.length > 0)
253
+ return { valid: false, errors };
254
+ return { valid: true };
255
+ }
256
+ function writePlanFile(plan, opts) {
257
+ const filePath = planFilePath(plan.epic_id, plan.version, opts);
258
+ if (existsSync(filePath)) {
259
+ throw new PlanError('plan.version-exists', `Refusing to overwrite existing plan file ${filePath} — plans are immutable.`, { filePath });
260
+ }
261
+ mkdirSync(path.dirname(filePath), { recursive: true });
262
+ writeFileSync(filePath, `${JSON.stringify(plan, null, 2)}\n`, 'utf8');
263
+ }
264
+ /**
265
+ * The v(n+1) a set of changes WOULD produce: everything `nextVersion` does
266
+ * except put it on disk.
267
+ *
268
+ * Split out for D-127. `amendPlan` has to know the diff before it can decide
269
+ * whether the amendment is legal at all — an amendment that adds and
270
+ * supersedes nothing obligates nothing, and a finding obligating nothing is
271
+ * discharged the instant the version is cut. The diff is only computable
272
+ * against the new version, so either that check runs after a plan file exists
273
+ * (and nothing in this codebase deletes a plan file) or the construction is
274
+ * available without the write. This is that. Pure and deterministic: the
275
+ * caller that validates against the draft and then cuts through `nextVersion`
276
+ * gets the same file both times.
277
+ */
278
+ /**
279
+ * A closed vocabulary, so it can be named in an error without quoting a
280
+ * value (D-198). Exported so epic.ts's summarizeEpic can name the same
281
+ * shape of corruption in a malformed `amends_task_ids` entry the same way.
282
+ */
283
+ export function describeType(value) {
284
+ if (value === null)
285
+ return 'null';
286
+ if (Array.isArray(value))
287
+ return 'a list';
288
+ return typeof value;
289
+ }
290
+ function isPlainObject(value) {
291
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
292
+ }
293
+ /**
294
+ * Refuse a `changes.supersede` no map-shaped read can make, before
295
+ * `draftNextVersion` iterates it as one.
296
+ *
297
+ * D-21: an operator passed `supersede` as an array of task-id strings.
298
+ * `task_id in supersede` was false for every task (arrays have numeric
299
+ * keys, not the task_id strings under test), and `Object.values(anArray)`
300
+ * handed back the array's own elements — a bare string — which
301
+ * `{ ...aString }` then spread character-by-character into a "task record"
302
+ * with no `task_id` at all. That record reached `amendPlan`, which wrote it
303
+ * to an immutable plan file and reported success; `smith plan validate`
304
+ * rejected the file the tool had just produced. Refusing the shape here,
305
+ * before anything is spread, is what closes that hole — and the message
306
+ * names the map-vs-array mistake explicitly, since that is the mistake that
307
+ * actually happened.
308
+ */
309
+ function readSupersedeMap(epicId, version, supersede) {
310
+ if (supersede === undefined)
311
+ return {};
312
+ if (!isPlainObject(supersede)) {
313
+ const arrayNote = Array.isArray(supersede)
314
+ ? ' supersede is a map keyed by the task_id each entry replaces (e.g. ' +
315
+ '{"epic-1/task-3": {...replacement}}) — a list of task ids is a different shape ' +
316
+ 'and replaces nothing.'
317
+ : '';
318
+ throw new PlanError('plan.unreadable-supersede', `Refusing to draft plan "${epicId}" v${version}: changes.supersede is ${describeType(supersede)}, not a map of task_id to replacement task record.${arrayNote}`, { epicId, version, received: describeType(supersede) });
319
+ }
320
+ for (const [key, value] of Object.entries(supersede)) {
321
+ if (!isPlainObject(value) || typeof value.task_id !== 'string') {
322
+ throw new PlanError('plan.unreadable-supersede-entry', `Refusing to draft plan "${epicId}" v${version}: changes.supersede["${key}"] is ${describeType(value)}, not a task record carrying a string task_id.`, { epicId, version, key, received: describeType(value) });
323
+ }
324
+ }
325
+ return supersede;
326
+ }
327
+ /**
328
+ * Same guard as `readSupersedeMap`, for `changes.added`: a plain list whose
329
+ * entries are task-record-shaped objects carrying a string `task_id`. Same
330
+ * treatment because the failure is the same shape of mistake — an entry this
331
+ * codebase would otherwise spread into a plan file with no `task_id` to
332
+ * identify it by.
333
+ */
334
+ function readAddedList(epicId, version, added) {
335
+ if (added === undefined)
336
+ return [];
337
+ if (!Array.isArray(added)) {
338
+ throw new PlanError('plan.unreadable-added', `Refusing to draft plan "${epicId}" v${version}: changes.added is ${describeType(added)}, not a list of task records.`, { epicId, version, received: describeType(added) });
339
+ }
340
+ added.forEach((t, index) => {
341
+ if (!isPlainObject(t) || typeof t.task_id !== 'string') {
342
+ throw new PlanError('plan.unreadable-added-entry', `Refusing to draft plan "${epicId}" v${version}: changes.added[${index}] is ${describeType(t)}, not a task record carrying a string task_id.`, { epicId, version, index, received: describeType(t) });
343
+ }
344
+ });
345
+ return added;
346
+ }
347
+ export function draftNextVersion(prev, changes) {
348
+ const newVersion = prev.version + 1;
349
+ const supersede = readSupersedeMap(prev.epic_id, newVersion, changes.supersede);
350
+ const carried = [];
351
+ for (const t of prev.tasks) {
352
+ if (t.task_status === 'completed' && !(t.task_id in supersede)) {
353
+ continue; // done; not carried into the live backlog
354
+ }
355
+ if (t.task_id in supersede) {
356
+ carried.push({ ...t, task_status: 'superseded', plan_version: newVersion });
357
+ continue;
358
+ }
359
+ carried.push({ ...t, plan_version: newVersion });
360
+ }
361
+ const replacements = Object.values(supersede).map((t) => ({ ...t, plan_version: newVersion }));
362
+ const added = readAddedList(prev.epic_id, newVersion, changes.added).map((t) => ({
363
+ ...t,
364
+ plan_version: newVersion,
365
+ }));
366
+ const tasks = [...carried, ...replacements, ...added];
367
+ // An edge may only name a task this version declares -- `validatePlan`'s own
368
+ // rule. Dropping a completed task from the live backlog is what breaks it:
369
+ // the id leaves, the edges pointing at it did not, and v(n+1) then failed the
370
+ // validation the operator guide tells the operator to run, on an id the file
371
+ // no longer mentions. Plans are immutable and nothing deletes one, so that
372
+ // left no way out.
373
+ //
374
+ // Dropping the edge loses nothing: the only ids that leave are completed and
375
+ // not superseded, so the ordering constraint is already discharged, and a
376
+ // superseded task is carried forward (as `superseded`) and keeps its edges.
377
+ // Applied to the CARRIED edges only -- an edge this amendment adds is
378
+ // something the author wrote, and deleting it silently would answer a typo
379
+ // with a plan that no longer says what they asked for. `validatePlan` names
380
+ // that one instead.
381
+ const declared = new Set(tasks.map((t) => t.task_id));
382
+ const carriedEdges = prev.edges.filter((e) => declared.has(e.task) && declared.has(e.dependsOn));
383
+ return {
384
+ epic_id: prev.epic_id,
385
+ version: newVersion,
386
+ status: 'active',
387
+ tasks,
388
+ edges: [...carriedEdges, ...(changes.newEdges ?? [])],
389
+ ...(prev.project ? { project: prev.project } : {}),
390
+ ...(prev.effort ? { effort: prev.effort } : {}),
391
+ };
392
+ }
393
+ /**
394
+ * Cut plan v(n+1): carries forward unfinished tasks (bumping plan_version),
395
+ * marks superseded tasks, adds new tasks, and writes the new immutable
396
+ * version file. Never mutates the previous version's file.
397
+ *
398
+ * The only function in this codebase that puts a plan version on disk — which
399
+ * is why `draftNextVersion` is exported for inspection and `writePlanFile`
400
+ * is not: a caller that could write half of a version could write a version
401
+ * no event explains.
402
+ */
403
+ export function nextVersion(prev, changes, opts = {}) {
404
+ const newPlan = draftNextVersion(prev, changes);
405
+ writePlanFile(newPlan, opts);
406
+ return newPlan;
407
+ }
408
+ /**
409
+ * The task id's live spec in a plan version: the last record that has not
410
+ * been superseded, or undefined when every record for that id is dead.
411
+ *
412
+ * A plan version holds each superseded copy of a task *alongside* its
413
+ * replacement, and both carry the same `task_id` whenever the amendment kept
414
+ * the id (D-121) — so "the spec for this id" is only well defined once the
415
+ * dead copies are set aside. Indexing the whole task list into a Map by id
416
+ * silently answers with whichever record happened to be written last.
417
+ */
418
+ function liveSpec(plan, id) {
419
+ let found;
420
+ for (const t of plan.tasks) {
421
+ if (t.task_id === id && t.task_status !== 'superseded')
422
+ found = t;
423
+ }
424
+ return found;
425
+ }
426
+ /**
427
+ * Every id's live spec, in plan order — the same `liveSpec` rule applied to
428
+ * the whole file rather than one id, so a reader can ask "what does this plan
429
+ * version still claim?" without re-deriving the supersede semantics.
430
+ *
431
+ * D-126: this is what `epic verdict` consults. `plan.tasks` itself is the
432
+ * wrong answer to that question, because it also holds every dead record the
433
+ * amendments left behind.
434
+ */
435
+ export function livePlanTasks(plan) {
436
+ const seen = new Set();
437
+ const live = [];
438
+ for (const t of plan.tasks) {
439
+ if (seen.has(t.task_id))
440
+ continue;
441
+ seen.add(t.task_id);
442
+ const spec = liveSpec(plan, t.task_id);
443
+ if (spec !== undefined)
444
+ live.push(spec);
445
+ }
446
+ return live;
447
+ }
448
+ /**
449
+ * What makes two records the same *spec*: everything except the version stamp
450
+ * and the workflow status. `nextVersion` rewrites `plan_version` on every
451
+ * record it carries, and a task moving todo → in-progress is not an
452
+ * amendment; neither may register as a spec change.
453
+ *
454
+ * Nested key order is inherited from whichever JSON the record was parsed
455
+ * from, so this compares as text rather than structurally. That is the safe
456
+ * direction: a reordered-but-identical spec reads as *superseded*, which
457
+ * tells the operator to look, whereas the failure being fixed here is a
458
+ * changed spec reading as *carried*, which tells them not to.
459
+ */
460
+ function specSignature(t) {
461
+ const entries = Object.entries(t)
462
+ .filter(([k]) => k !== 'plan_version' && k !== 'task_status')
463
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
464
+ return JSON.stringify(entries);
465
+ }
466
+ /**
467
+ * Diff two plan versions: added / removed / superseded / carried task ids.
468
+ *
469
+ * `superseded` means "this id's live spec was replaced between A and B",
470
+ * which is the question an amendment is actually asking. It deliberately does
471
+ * not read `task_status === 'superseded'` off a single record: `supersede` is
472
+ * keyed by task_id and a replacement that keeps its id — the shape every real
473
+ * amendment has used — leaves the plan holding a dead record and a live one
474
+ * under the same key.
475
+ */
476
+ export function diffPlans(vA, vB) {
477
+ const aIds = new Set(vA.tasks.map((t) => t.task_id));
478
+ const bIds = new Set(vB.tasks.map((t) => t.task_id));
479
+ const added = [];
480
+ const superseded = [];
481
+ const carried = [];
482
+ const removed = [];
483
+ for (const id of bIds) {
484
+ if (!aIds.has(id))
485
+ added.push(id);
486
+ }
487
+ for (const id of aIds) {
488
+ if (!bIds.has(id)) {
489
+ removed.push(id);
490
+ continue;
491
+ }
492
+ const liveA = liveSpec(vA, id);
493
+ const liveB = liveSpec(vB, id);
494
+ // A live spec that lost its replacement (renamed away, or retired) counts
495
+ // as superseded too — the id survives in the file only as a dead record.
496
+ const same = liveA === undefined
497
+ ? liveB === undefined
498
+ : liveB !== undefined && specSignature(liveA) === specSignature(liveB);
499
+ (same ? carried : superseded).push(id);
500
+ }
501
+ return { added, removed, superseded, carried };
502
+ }