@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,633 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import picomatch from 'picomatch';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { SmithError } from './errors.js';
5
+ import { runGit, runGitRaw } from './git.js';
6
+ import { WORKTREE_POLICY_PATH } from './paths.js';
7
+ export class ClaimsError extends SmithError {
8
+ }
9
+ function splitPathSegments(p) {
10
+ return p.split('/').filter(Boolean);
11
+ }
12
+ function isSegmentPrefix(a, b) {
13
+ const len = Math.min(a.length, b.length);
14
+ for (let i = 0; i < len; i++) {
15
+ if (a[i] !== b[i])
16
+ return false;
17
+ }
18
+ return true;
19
+ }
20
+ /**
21
+ * One concrete string that would satisfy a glob's final path segment: `*`/`?`
22
+ * become a literal placeholder character, a brace set picks its first
23
+ * alternative, a bracket class picks a placeholder — enough to construct a
24
+ * real candidate path, not to enumerate every string the segment accepts.
25
+ */
26
+ export function synthesizeLiteralSegment(segment) {
27
+ return segment
28
+ .replace(/\{([^,}]+)(,[^}]*)?\}/g, '$1')
29
+ .replace(/\[[^\]]*\]/g, 'x')
30
+ .replace(/\*/g, 'x')
31
+ .replace(/\?/g, 'x');
32
+ }
33
+ /** A concrete final-path-segment candidate satisfying `pattern`. */
34
+ function syntheticTail(pattern) {
35
+ const segments = pattern.split('/').filter((s) => s.length > 0);
36
+ const last = segments[segments.length - 1] ?? '';
37
+ if (last === '**')
38
+ return '__smith_probe__'; // pattern ends in /** — any filename qualifies
39
+ return synthesizeLiteralSegment(last);
40
+ }
41
+ function joinBaseAndTail(base, tail) {
42
+ return base ? `${base}/${tail}` : tail;
43
+ }
44
+ /**
45
+ * When neither prefix-containment nor a literal-path check settles it (both
46
+ * sides still have glob magic, e.g. "src/**" vs "**\/config.ts" or
47
+ * "src/**\/*.ts" vs "**\/*.ts"): synthesize concrete candidate paths from
48
+ * each pattern's own literal parts (base of one + synthesized tail of the
49
+ * other, in both directions) and test whether any candidate satisfies BOTH
50
+ * patterns at once. This is an existence check, not exhaustive glob-language
51
+ * intersection — good enough to disprove overlap only when every candidate
52
+ * genuinely fails one side (e.g. disjoint literal extensions).
53
+ */
54
+ function couldJointlyMatch(globA, globB, baseA, baseB) {
55
+ const matchA = picomatch(globA);
56
+ const matchB = picomatch(globB);
57
+ const candidates = [
58
+ joinBaseAndTail(baseA, syntheticTail(globB)),
59
+ joinBaseAndTail(baseB, syntheticTail(globA)),
60
+ ];
61
+ return candidates.some((candidate) => matchA(candidate) && matchB(candidate));
62
+ }
63
+ /**
64
+ * One concrete path a glob grants, for asking whether another glob grants it
65
+ * too. A literal pattern is its own path; anything else is its static base
66
+ * joined to a synthesized tail.
67
+ */
68
+ function concretePath(glob) {
69
+ const scan = picomatch.scan(glob);
70
+ if (!scan.isGlob)
71
+ return glob;
72
+ return joinBaseAndTail(scan.base, syntheticTail(glob));
73
+ }
74
+ /**
75
+ * Does `outer` grant every path `inner` grants? Containment, not
76
+ * intersection — a strictly narrower question than `globsOverlap`, and the
77
+ * one the serialize-always rule actually asks.
78
+ *
79
+ * Approximated the way the rest of this file approximates glob algebra:
80
+ * `inner` must live under `outer`'s static root, and `outer` must accept a
81
+ * concrete path synthesized from `inner`'s own literal parts. Errs toward
82
+ * "no", which is the safe direction here because a false "yes" would
83
+ * serialize two tasks that never shared a file.
84
+ */
85
+ function globContains(outer, inner) {
86
+ if (outer === inner)
87
+ return true;
88
+ const outerScan = picomatch.scan(outer);
89
+ if (!outerScan.isGlob)
90
+ return false; // a literal grants only itself, and inner is not it
91
+ const outerBase = splitPathSegments(outerScan.base);
92
+ if (outerBase.length > 0) {
93
+ const innerBase = splitPathSegments(picomatch.scan(inner).base);
94
+ if (innerBase.length < outerBase.length)
95
+ return false;
96
+ if (!isSegmentPrefix(outerBase, innerBase))
97
+ return false;
98
+ }
99
+ return picomatch(outer)(concretePath(inner));
100
+ }
101
+ /**
102
+ * Practical glob-vs-glob overlap check: two globs overlap when one's static
103
+ * (non-glob) root path is a path-segment prefix of the other's — the
104
+ * standard "directory ownership" heuristic (claims are directory/file-
105
+ * subtree grants, not arbitrary regex sets) — or, when at least one side has
106
+ * no static prefix at all (globstar-anchored, e.g. "**\/pnpm-lock.yaml"),
107
+ * when a literal path on the other side actually matches it, or a
108
+ * synthesized joint-match candidate satisfies both patterns simultaneously.
109
+ * Errs conservative: only concludes "no overlap" once every check has
110
+ * failed to produce a shared match.
111
+ */
112
+ export function globsOverlap(globA, globB) {
113
+ if (globA === globB)
114
+ return true;
115
+ const scanA = picomatch.scan(globA);
116
+ const scanB = picomatch.scan(globB);
117
+ const baseA = splitPathSegments(scanA.base);
118
+ const baseB = splitPathSegments(scanB.base);
119
+ if (baseA.length > 0 && baseB.length > 0) {
120
+ return isSegmentPrefix(baseA, baseB) || isSegmentPrefix(baseB, baseA);
121
+ }
122
+ if (!scanA.isGlob && picomatch(globB)(globA))
123
+ return true;
124
+ if (!scanB.isGlob && picomatch(globA)(globB))
125
+ return true;
126
+ if (!scanA.isGlob || !scanB.isGlob)
127
+ return false; // one side literal and it didn't match -> disjoint
128
+ return couldJointlyMatch(globA, globB, scanA.base, scanB.base);
129
+ }
130
+ export function claimsOverlap(taskA, taskB) {
131
+ const offendingGlobs = [];
132
+ for (const globA of taskA.claims) {
133
+ for (const globB of taskB.claims) {
134
+ if (globsOverlap(globA, globB))
135
+ offendingGlobs.push({ globA, globB });
136
+ }
137
+ }
138
+ return { overlaps: offendingGlobs.length > 0, offendingGlobs };
139
+ }
140
+ export function loadWorktreePolicy(filePath = WORKTREE_POLICY_PATH) {
141
+ const doc = parseYaml(readFileSync(filePath, 'utf8'));
142
+ const globs = doc.serialize_always_globs?.globs;
143
+ if (!Array.isArray(globs)) {
144
+ throw new ClaimsError('claims.invalid-worktree-policy', `${filePath} is missing serialize_always_globs.globs.`);
145
+ }
146
+ return { serializeAlwaysGlobs: globs.map(String) };
147
+ }
148
+ /**
149
+ * Which of the policy's serialize-always globs does this task's claim set
150
+ * touch?
151
+ *
152
+ * Containment in either direction, not overlap. The distinction is the
153
+ * difference between a gate that works and one that never opens: under
154
+ * `globsOverlap`, `src/a/**` "touches" `**\/pnpm-lock.yaml`, because a
155
+ * synthesized `src/a/pnpm-lock.yaml` satisfies both patterns at once. Every
156
+ * realistic claim is a subtree grant, so every pair of tasks touched the
157
+ * lockfile guard together and every wave of two was refused —
158
+ * `serializeAlwaysViolations` on claims that share no file and never would.
159
+ * The old fixture in claims.test.ts states the trap plainly and then steps
160
+ * around it by claiming individual files.
161
+ *
162
+ * A subtree grant is not a lockfile edit. A task that means to edit a
163
+ * protected file claims it (`pnpm-lock.yaml`), or claims a region that holds
164
+ * every such file (`**\/pnpm-lock.yaml`), or is itself inside the protected
165
+ * region (`src/types/foo.ts` under `src/types/**`). Those are the three
166
+ * shapes containment accepts, and they are the three shapes the policy in
167
+ * worktree.yml is written about.
168
+ */
169
+ export function touchesSerializeAlways(task, serializeAlwaysGlobs) {
170
+ const touched = new Set();
171
+ for (const claim of task.claims) {
172
+ for (const guard of serializeAlwaysGlobs) {
173
+ if (globContains(claim, guard) || globContains(guard, claim))
174
+ touched.add(guard);
175
+ }
176
+ }
177
+ return [...touched];
178
+ }
179
+ /** A closed vocabulary, so it can be named in an error without quoting a value. */
180
+ function describeType(value) {
181
+ if (value === null)
182
+ return 'null';
183
+ if (Array.isArray(value))
184
+ return 'a list';
185
+ return typeof value;
186
+ }
187
+ /**
188
+ * Refuse a claim set no comparison can read, before it is compared.
189
+ *
190
+ * The codebase already settles claims-shape questions by asking which
191
+ * direction is safe for the reader asking them. `ownershipFromPlan` drops an
192
+ * unreadable set because "a task without claims owns no file, and is dropped
193
+ * rather than defaulted to owning everything"; spec.ts drops one because that
194
+ * "leaves its sites unclaimed rather than silently covered". Both are right,
195
+ * and both answer a question whose safe direction is *less*.
196
+ *
197
+ * This gate asks the opposite question — may these tasks edit the repo at the
198
+ * same time — and its safe direction is *no*. An empty claim set is pairwise
199
+ * disjoint with every other task in the wave, so defaulting to `[]` here does
200
+ * not fail safe: it returns the single answer that cannot be re-checked
201
+ * afterwards, because by then the wave is already running in parallel
202
+ * worktrees. A claim written as a bare string is worse than empty, since
203
+ * iterating a string yields its characters and 's' overlaps no path: the gate
204
+ * reports a disjointness it computed from letters.
205
+ *
206
+ * The unreadable value is never quoted back. It comes from an unvalidated
207
+ * plan file or from the append-only log, and an error message is not where
208
+ * file content gets to appear (D-198) — the type is a closed vocabulary, the
209
+ * value is not.
210
+ */
211
+ export function readClaimList(task) {
212
+ const { claims } = task;
213
+ if (!Array.isArray(claims)) {
214
+ throw new ClaimsError('claims.unreadable-claims', `Task "${task.task_id}" declares claims as ${describeType(claims)}, not a list of globs, ` +
215
+ 'so this wave cannot be checked for claim overlap. Claims are a list of path globs ' +
216
+ '(task-spec.schema.json); a single glob is a one-element list, not a bare string.', { task_id: task.task_id, received: describeType(claims) });
217
+ }
218
+ const badIndex = claims.findIndex((glob) => typeof glob !== 'string');
219
+ if (badIndex !== -1) {
220
+ throw new ClaimsError('claims.unreadable-claims', `Task "${task.task_id}" declares claim ${badIndex} as ` +
221
+ `${describeType(claims[badIndex])}, not a path glob, so this wave cannot be checked ` +
222
+ 'for claim overlap.', { task_id: task.task_id, index: badIndex, received: describeType(claims[badIndex]) });
223
+ }
224
+ if (claims.length === 0) {
225
+ throw new ClaimsError('claims.empty-claims', `Task "${task.task_id}" claims no files, so it is disjoint from every other task in the ` +
226
+ 'wave for the reason that it may not edit anything. Admitting it defers the mistake to ' +
227
+ 'the first out-of-claim edit; task-spec.schema.json requires at least one claim.', { task_id: task.task_id });
228
+ }
229
+ return { task_id: task.task_id, claims: claims };
230
+ }
231
+ /**
232
+ * Refuse an edge list no traversal can read, before it is traversed.
233
+ *
234
+ * Same door as `readClaimList`, and the same reason: `wave check` reads the
235
+ * plan with a bare `JSON.parse(...) as PlanFile`, so `edges` holds whatever
236
+ * JSON held. The safe direction here is the one that costs a re-plan, not the
237
+ * one that costs a wave — a value that is not a list of edges must not
238
+ * iterate to zero pairs, because zero pairs is the answer that admits.
239
+ *
240
+ * The unreadable value is never quoted back (D-198): the type is a closed
241
+ * vocabulary, plan-file content is not.
242
+ */
243
+ export function readEdgeList(edges) {
244
+ if (!Array.isArray(edges)) {
245
+ throw new ClaimsError('claims.unreadable-edges', `The plan declares edges as ${describeType(edges)}, not a list of dependency edges, so ` +
246
+ 'this wave cannot be checked for tasks the plan orders. Edges are a list of ' +
247
+ '{ task, dependsOn } records; a plan that declares none declares an empty list.', { received: describeType(edges) });
248
+ }
249
+ return edges.map((edge, index) => {
250
+ const record = edge;
251
+ if (typeof record?.task !== 'string' || typeof record?.dependsOn !== 'string') {
252
+ throw new ClaimsError('claims.unreadable-edges', `Edge ${index} declares task as ${describeType(record?.task)} and dependsOn as ` +
253
+ `${describeType(record?.dependsOn)}; both are task ids, so this wave cannot be ` +
254
+ 'checked for tasks the plan orders.', {
255
+ index,
256
+ task: describeType(record?.task),
257
+ dependsOn: describeType(record?.dependsOn),
258
+ });
259
+ }
260
+ return { task: record.task, dependsOn: record.dependsOn };
261
+ });
262
+ }
263
+ /**
264
+ * Which tasks in the wave does the plan order after another task in the wave?
265
+ *
266
+ * Transitive, not direct: `{a, c}` under `c <- b <- a` has no edge joining its
267
+ * own two members, and b's absence from the wave is what makes a and c
268
+ * concurrent, not what makes them safe. Every edge counts, whatever its
269
+ * `edge_type` — `artifact`, `claim-order`, `spec-clause`, `regression-test`
270
+ * and `research-brief` all mean "runs after" (factory/policies/taxonomy.yml).
271
+ *
272
+ * A breadth-first walk gives the shortest chain, and `visited` bounds it: the
273
+ * plan may hold a cycle (only `smith plan validate` rejects those) and this
274
+ * gate must still answer.
275
+ */
276
+ function findDependencyViolations(tasks, edges) {
277
+ const inWave = new Set(tasks.map((task) => task.task_id));
278
+ const dependencies = new Map();
279
+ for (const edge of edges) {
280
+ const existing = dependencies.get(edge.task);
281
+ if (existing)
282
+ existing.push(edge.dependsOn);
283
+ else
284
+ dependencies.set(edge.task, [edge.dependsOn]);
285
+ }
286
+ const violations = [];
287
+ for (const task of tasks) {
288
+ const visited = new Set([task.task_id]);
289
+ let frontier = [[task.task_id]];
290
+ while (frontier.length > 0) {
291
+ const next = [];
292
+ for (const chain of frontier) {
293
+ // A task the plan never mentions has no dependencies, which is not the
294
+ // same fact as "an empty list of them" -- so ask the map, and skip.
295
+ const deps = dependencies.get(chain[chain.length - 1]);
296
+ if (!deps)
297
+ continue;
298
+ for (const dep of deps) {
299
+ if (visited.has(dep))
300
+ continue;
301
+ visited.add(dep);
302
+ const extended = [...chain, dep];
303
+ if (inWave.has(dep)) {
304
+ violations.push({ task: task.task_id, dependsOn: dep, chain: extended });
305
+ }
306
+ // Kept walking either way: a prerequisite inside the wave may itself
307
+ // sit above another one.
308
+ next.push(extended);
309
+ }
310
+ }
311
+ frontier = next;
312
+ }
313
+ }
314
+ return violations;
315
+ }
316
+ /**
317
+ * Verify a proposed wave (NOT computing the largest admissible set — that is
318
+ * the planner's job): tasks must be pairwise claim-disjoint, none may touch a
319
+ * serialize-always glob concurrently with another task in the wave, and none
320
+ * may be one the plan orders after another task in the same wave.
321
+ *
322
+ * `edges` is required rather than defaulted, and that is the point of D-212.
323
+ * `factory/policies/worktree.yml` — the file this gate's own policy comes
324
+ * from — states the remedy it exists to make stick: "tasks with overlapping
325
+ * claims are never scheduled concurrently; they get a dependency edge
326
+ * (edge_type: claim-order) and run serially instead". Cutting that edge is
327
+ * also what narrows the claims, so the plan that took the advice is the plan
328
+ * whose claims this gate then has nothing left to object to. A caller that
329
+ * cannot supply the plan's edges is a caller that cannot answer the question.
330
+ *
331
+ * Every claim set is read before any pair is compared, so a wave carrying one
332
+ * unreadable set is refused as unanswerable rather than answered from the
333
+ * pairs that happened to be readable.
334
+ */
335
+ export function validateWave(proposed, policy, edges) {
336
+ const tasks = proposed.map(readClaimList);
337
+ const dependencyViolations = findDependencyViolations(tasks, readEdgeList(edges));
338
+ const overlapViolations = [];
339
+ const serializeAlwaysViolations = [];
340
+ for (let i = 0; i < tasks.length; i++) {
341
+ for (let j = i + 1; j < tasks.length; j++) {
342
+ const taskA = tasks[i];
343
+ const taskB = tasks[j];
344
+ const overlap = claimsOverlap(taskA, taskB);
345
+ if (overlap.overlaps) {
346
+ overlapViolations.push({
347
+ taskA: taskA.task_id,
348
+ taskB: taskB.task_id,
349
+ offendingGlobs: overlap.offendingGlobs,
350
+ });
351
+ }
352
+ const touchedA = touchesSerializeAlways(taskA, policy.serializeAlwaysGlobs);
353
+ const touchedB = touchesSerializeAlways(taskB, policy.serializeAlwaysGlobs);
354
+ for (const glob of touchedA) {
355
+ if (touchedB.includes(glob)) {
356
+ serializeAlwaysViolations.push({ taskA: taskA.task_id, taskB: taskB.task_id, glob });
357
+ }
358
+ }
359
+ }
360
+ }
361
+ if (overlapViolations.length === 0 &&
362
+ serializeAlwaysViolations.length === 0 &&
363
+ dependencyViolations.length === 0) {
364
+ return { valid: true };
365
+ }
366
+ return { valid: false, overlapViolations, serializeAlwaysViolations, dependencyViolations };
367
+ }
368
+ /** Repo-relative, forward-slashed, no leading "./" — the form claims are written in. */
369
+ function normalizeRepoPath(filePath) {
370
+ return filePath.replace(/\\/g, '/').replace(/^\.\//, '');
371
+ }
372
+ /**
373
+ * Does one claim glob cover one file path? Exported because ownership
374
+ * resolution (below), dispatch-time finding context and the stale-evidence
375
+ * check all ask exactly this question, and two implementations of "does this
376
+ * task's claims cover this finding's file" would be two different answers to
377
+ * one question — a bug, not a duplication (P9-15).
378
+ */
379
+ export function claimCoversPath(claim, filePath) {
380
+ return picomatch(claim)(normalizeRepoPath(filePath));
381
+ }
382
+ /**
383
+ * How specifically a claim names a path, most significant component first:
384
+ * an exact literal claim ("src/parse.ts") outranks any glob, and among globs
385
+ * the one with the deeper static base ("src/parser/**" over "src/**") wins.
386
+ * Deliberately coarse — a finer scale would start inventing an ordering
387
+ * between patterns that genuinely name the same file with equal precision,
388
+ * which is the case this must report rather than resolve.
389
+ */
390
+ function claimSpecificity(claim, normalizedPath) {
391
+ const scan = picomatch.scan(claim);
392
+ const exact = !scan.isGlob && normalizeRepoPath(claim) === normalizedPath ? 1 : 0;
393
+ return [exact, splitPathSegments(scan.base).length];
394
+ }
395
+ function compareSpecificity(a, b) {
396
+ return a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1];
397
+ }
398
+ /**
399
+ * Who owns a finding anchored to `filePath`: the task whose claims cover it,
400
+ * read off the plan's claims map (D-41/P9-24). Ownership is a property of the
401
+ * file, not of whichever task happened to be at the gate when a judge spoke —
402
+ * gating on the latter blocks an unrelated diff and leaves the real owner
403
+ * untouched.
404
+ *
405
+ * Two tasks can legitimately match the same file across different waves
406
+ * (`validateWave` only enforces disjointness *within* a wave), so a
407
+ * specificity tie-break settles the common case. A genuine tie is reported as
408
+ * `ambiguous` rather than guessed — same precedent as `resolveTaskId`
409
+ * (D-46/P9-29): an id nobody can pin down is an operator's call.
410
+ */
411
+ export function resolveFindingOwner(filePath, tasks) {
412
+ const normalizedPath = normalizeRepoPath(filePath);
413
+ // Best (most specific) matching claim per task, so one task listing both
414
+ // "src/*.ts" and "src/**" is never ambiguous with itself.
415
+ const best = new Map();
416
+ for (const task of tasks) {
417
+ const taskId = task.task_id;
418
+ if (taskId === undefined)
419
+ continue;
420
+ for (const claim of task.claims) {
421
+ if (!claimCoversPath(claim, normalizedPath))
422
+ continue;
423
+ const score = claimSpecificity(claim, normalizedPath);
424
+ const current = best.get(taskId);
425
+ if (current === undefined || compareSpecificity(score, current.score) > 0) {
426
+ best.set(taskId, { claim, score });
427
+ }
428
+ }
429
+ }
430
+ if (best.size === 0)
431
+ return { owner: 'unclaimed' };
432
+ const ranked = [...best.entries()].sort(([, a], [, b]) => compareSpecificity(b.score, a.score));
433
+ const [topId, top] = ranked[0];
434
+ const tied = ranked.filter(([, entry]) => compareSpecificity(entry.score, top.score) === 0);
435
+ if (tied.length > 1) {
436
+ return {
437
+ owner: 'ambiguous',
438
+ candidates: tied.map(([taskId, entry]) => ({ taskId, claim: entry.claim })),
439
+ };
440
+ }
441
+ return { owner: 'resolved', taskId: topId, claim: top.claim };
442
+ }
443
+ /**
444
+ * Task statuses that mean "this task's diff is closed to further work". A
445
+ * finding routed to one of these has nobody to fix it — the branch already
446
+ * landed (`completed`), the team already accepted the gap (`waived`), or a
447
+ * later plan version replaced the task outright (`superseded`).
448
+ *
449
+ * `failed` and `escalated` are deliberately NOT here: those tasks are still
450
+ * open work an operator is holding, and a finding about their files belongs
451
+ * on them rather than on a new task competing for the same claims.
452
+ */
453
+ const CLOSED_TO_FURTHER_WORK = new Set(['completed', 'waived', 'superseded']);
454
+ /**
455
+ * Where a finding should land, given who owns its file (D-41/P9-24).
456
+ *
457
+ * `statusOf` is a lookup rather than a map so the caller can defer reading the
458
+ * event log until a finding actually resolves to somebody else — the common
459
+ * case is a judge speaking about the very task at the gate, and that case
460
+ * should cost nothing extra. An owner the log has never heard of counts as
461
+ * open: a task in the plan but not yet in the log is `todo`, not merged.
462
+ */
463
+ export function decideFindingAttribution(ownership, gatedTaskId, statusOf) {
464
+ if (ownership.owner === 'unclaimed') {
465
+ return { attribution: 'follow-up', reason: 'No task in the plan claims this file.' };
466
+ }
467
+ if (ownership.owner === 'ambiguous') {
468
+ // A tie is a reason to escalate only when nothing present can break it.
469
+ // The task at the gate can: it is holding the file right now, and the
470
+ // diff the judge just read is where the fix belongs. Escalating one of
471
+ // the tied owners' own findings minted a follow-up competing for claims
472
+ // that task still held, and — since gate.ts admits nothing but `gated`
473
+ // into `blocking` — let the finding land un-blocked (D-173).
474
+ if (ownership.candidates.some((c) => c.taskId === gatedTaskId)) {
475
+ return { attribution: 'gated' };
476
+ }
477
+ const candidates = ownership.candidates
478
+ .map((c) => `${c.taskId} (${c.claim})`)
479
+ .sort()
480
+ .join(', ');
481
+ return {
482
+ attribution: 'follow-up',
483
+ reason: `Two or more tasks claim this file with equal specificity: ${candidates}.`,
484
+ };
485
+ }
486
+ if (ownership.taskId === gatedTaskId)
487
+ return { attribution: 'gated' };
488
+ const status = statusOf(ownership.taskId);
489
+ if (status !== undefined && CLOSED_TO_FURTHER_WORK.has(status)) {
490
+ return {
491
+ attribution: 'follow-up',
492
+ reason: `Owning task ${ownership.taskId} is already ${status}.`,
493
+ };
494
+ }
495
+ return { attribution: 'reassigned', taskId: ownership.taskId, claim: ownership.claim };
496
+ }
497
+ /**
498
+ * The classifier half of the check (P9-3): given a changed-file set from
499
+ * *some* collector, split it by pattern. Knows nothing about git, branches or
500
+ * worktrees, which is the whole point — the planner and scribe write outside
501
+ * any worktree on an ordinary branch, and before this split the matching logic
502
+ * was reachable only through a collector that threw on their branch names.
503
+ *
504
+ * An empty pattern list puts every change out of bounds rather than passing
505
+ * everything: a role with no declared write root is one that should not be
506
+ * writing.
507
+ */
508
+ export function classifyChanges(changedFiles, patterns, violationCode = 'contract.claim-violation') {
509
+ const isClaimed = patterns.length > 0 ? picomatch([...patterns]) : () => false;
510
+ const inClaim = [];
511
+ const outOfClaim = [];
512
+ for (const file of changedFiles) {
513
+ if (isClaimed(file))
514
+ inClaim.push(file);
515
+ else
516
+ outOfClaim.push(file);
517
+ }
518
+ return {
519
+ inClaim,
520
+ outOfClaim,
521
+ violation: outOfClaim.length > 0 ? { error: violationCode, files: outOfClaim } : null,
522
+ };
523
+ }
524
+ /**
525
+ * Which integration branch a task worktree was cut from, by convention. Its own
526
+ * export because the symbol-impact check (impact.ts) asks the same question of
527
+ * the same worktree, and two derivations of one convention would be two
528
+ * answers.
529
+ *
530
+ * Collector A (P9-3): what this task branch committed, against the integration
531
+ * branch it was cut from — derived from the branch naming convention
532
+ * smith/<epic>/<task-id> -> smith/<epic>/integration (worktree.yml
533
+ * `integration_branch.pattern`; a bare "smith/<epic>" ref cannot coexist with
534
+ * "smith/<epic>/<task-id>" refs in real git — D/F ref conflict).
535
+ *
536
+ * The convention is load-bearing here and that is fine: only an agent working
537
+ * in a task worktree has a claims list, and only a task worktree is on such a
538
+ * branch. What used to be wrong was that this was the *only* way to reach the
539
+ * classifier.
540
+ */
541
+ export function integrationBranchFor(worktreeDir) {
542
+ const branch = runGit(worktreeDir, ['rev-parse', '--abbrev-ref', 'HEAD']);
543
+ const lastSlash = branch.lastIndexOf('/');
544
+ if (lastSlash === -1) {
545
+ throw new ClaimsError('claims.cannot-derive-integration-branch', `Branch "${branch}" does not follow the smith/<epic>/<task-id> convention.`, { branch });
546
+ }
547
+ return `${branch.slice(0, lastSlash)}/integration`;
548
+ }
549
+ export function collectCommittedChanges(worktreeDir) {
550
+ const integrationBranch = integrationBranchFor(worktreeDir);
551
+ const diffOutput = runGit(worktreeDir, ['diff', '--name-only', `${integrationBranch}...HEAD`]);
552
+ return diffOutput
553
+ .split('\n')
554
+ .map((f) => f.trim())
555
+ .filter((f) => f.length > 0);
556
+ }
557
+ /** `git status --porcelain -z` record: two status chars, a space, then the path. */
558
+ const PORCELAIN_STATUS_WIDTH = 3;
559
+ /**
560
+ * Collector B (P9-3): everything uncommitted at `rootDir` —
561
+ * staged, unstaged and untracked alike — with no branch-name convention and no
562
+ * integration branch, so it works on `main` or any ordinary branch.
563
+ *
564
+ * `-z` rather than plain `--porcelain`: git quotes and backslash-escapes paths
565
+ * with spaces or non-ASCII in the default format, and a quoted path silently
566
+ * fails to match a claim glob it should have matched. `--untracked-files=all`
567
+ * because a role's first write is usually a *new* file, and the default
568
+ * `normal` mode would collapse a whole new directory into one entry.
569
+ *
570
+ * Renames report BOTH paths (git emits the new one, then the source): moving a
571
+ * file out of a write root touches two paths, and reporting only the
572
+ * destination hides the more serious half.
573
+ *
574
+ * Paths come back repo-root-relative — git's porcelain format is anchored to
575
+ * the repo root regardless of cwd — which is the form claims and write roots
576
+ * are written in, so a subdirectory root is safe to pass.
577
+ */
578
+ export function collectWorkingTreeChanges(rootDir, options = {}) {
579
+ const statusOutput = runGitRaw(rootDir, [
580
+ 'status',
581
+ '--porcelain=v1',
582
+ '-z',
583
+ '--untracked-files=all',
584
+ ]);
585
+ const fields = statusOutput.split('\0');
586
+ const changed = [];
587
+ for (let i = 0; i < fields.length; i++) {
588
+ const field = fields[i];
589
+ if (field.length <= PORCELAIN_STATUS_WIDTH)
590
+ continue;
591
+ const status = field.slice(0, 2);
592
+ changed.push(field.slice(PORCELAIN_STATUS_WIDTH));
593
+ if (status.startsWith('R') || status.startsWith('C')) {
594
+ const source = fields[i + 1];
595
+ if (source)
596
+ changed.push(source);
597
+ i++;
598
+ }
599
+ }
600
+ if (options.since !== undefined) {
601
+ let diffOutput;
602
+ try {
603
+ diffOutput = runGit(rootDir, ['diff', '--name-only', options.since, '--']);
604
+ }
605
+ catch (cause) {
606
+ throw new ClaimsError('claims.cannot-resolve-since-ref', `git could not diff against "${options.since}": ${cause.message}`, { since: options.since, rootDir });
607
+ }
608
+ for (const file of diffOutput.split('\n')) {
609
+ const trimmed = file.trim();
610
+ if (trimmed.length > 0)
611
+ changed.push(trimmed);
612
+ }
613
+ }
614
+ return [...new Set(changed)];
615
+ }
616
+ /**
617
+ * Did an agent working in a task worktree stay inside its claims? Collector A
618
+ * plus the classifier.
619
+ */
620
+ export function postRunCheck(worktreeDir, claims) {
621
+ return classifyChanges(collectCommittedChanges(worktreeDir), claims);
622
+ }
623
+ /**
624
+ * Did a role that writes *outside* a worktree stay inside its write root
625
+ * (P9-3)? Collector B plus the classifier. The planner
626
+ * (`factory/specs/active/<epic-id>/**`) and the scribe (`state/lessons/**`)
627
+ * are the two: before this, their write roots existed as a sentence in a
628
+ * template and nothing else, and the `/bs` contract could only tell the
629
+ * operator to read `git status` themselves.
630
+ */
631
+ export function writeRootCheck(rootDir, writeRoots, options = {}) {
632
+ return classifyChanges(collectWorkingTreeChanges(rootDir, options), writeRoots, 'contract.write-root-violation');
633
+ }