@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,106 @@
1
+ // The PreToolUse decision, on its own, so that the hot path can hold it
2
+ // without holding the rest of the orchestrator.
3
+ //
4
+ // `.claude/hooks/guard.sh` fires on every Bash/Write/Edit/MultiEdit/
5
+ // NotebookEdit call an agent makes, which makes whatever it execs the most
6
+ // frequently run code in this repo by orders of magnitude. It used to exec
7
+ // `dist/cli.js policy hook`; cli.ts imports the whole orchestrator at module
8
+ // scope, `db/projector.js` and so drizzle-orm among it, and that graph cost
9
+ // ~1.3s to load in front of ~39ms of policy work — a database layer loaded
10
+ // and thrown away once per guarded action. Splitting the decision out lets
11
+ // `policyHook.ts` be an entry point whose imports are only what deciding
12
+ // actually needs, while `cli.ts` keeps `smith policy hook` by calling the
13
+ // same function rather than a second copy of it.
14
+ import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
15
+ import { activeSandboxFor } from './sandbox.js';
16
+ /**
17
+ * Reads one PreToolUse payload and answers with the envelope to print, or
18
+ * `null` to print nothing.
19
+ *
20
+ * Two outcomes, and the caller signals them apart by exit code:
21
+ * - a reachable decision: exit 0, and either this envelope on stdout or
22
+ * silence.
23
+ * - could not reach a decision at all: a throw, left to propagate, which
24
+ * the caller turns into a non-zero exit.
25
+ *
26
+ * A malformed payload takes the second path on purpose. guard.sh's own
27
+ * `extract_field` used to return '' on anything it could not parse, which
28
+ * read as "no tool_name" and let every rule fall through as a silent allow —
29
+ * invisibly, since a BSD-vs-GNU `sed` incompatibility meant this had been
30
+ * happening on every macOS run since Phase 2 (see guard.sh's header).
31
+ * "Cannot parse the input" and "nothing to inspect" must never look the same
32
+ * again: the first is failure, the second is a real allow, and only guard.sh's
33
+ * fail-closed handling of a non-zero exit may turn "failure" into a denial.
34
+ */
35
+ export function decideHookPayload(raw, fallbackCwd) {
36
+ const payload = JSON.parse(raw);
37
+ const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
38
+ const command = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
39
+ // A `Write`/`Edit`/`MultiEdit` payload carries no command; its target is
40
+ // `tool_input.file_path`, absolute. Bash was the only tool worth inspecting
41
+ // while every leased role was a judge, since judges hold no file tools and a
42
+ // shell was their only write path. A tester holds both, so watching only
43
+ // Bash would leave the rule to be routed around with the tool the role was
44
+ // handed. Same shape as `command`: a missing or non-string field reads as
45
+ // absent, never as a guess.
46
+ const filePath = typeof payload.tool_input?.file_path === 'string' ? payload.tool_input.file_path : null;
47
+ // Branch and repo root come from where the command would actually run, which
48
+ // the PreToolUse payload carries as `cwd`. Resolving them from this binary's
49
+ // own checkout instead would be wrong in the case this factory spends most of
50
+ // its time in: guard.sh always execs the main clone's dist, while the session
51
+ // issuing the command is usually inside a per-task git worktree on a
52
+ // different branch. That gap is not academic — `git rebase` on
53
+ // smith/<epic>/integration is a rule-5 denial, and the merge queue performs
54
+ // rebases from a worktree standing on exactly that branch while the main
55
+ // clone stands elsewhere. The old bash hook got this right for free by
56
+ // running `git rev-parse` in its own inherited cwd; the port has to ask for
57
+ // it. `fallbackCwd` covers a payload without the field (a hand-driven
58
+ // invocation, or an older client), and a cwd outside any repo resolves to
59
+ // ''/null, which denies an out-of-bounds removal rather than allowing it.
60
+ const cwd = typeof payload.cwd === 'string' && payload.cwd !== '' ? payload.cwd : fallbackCwd;
61
+ const context = {
62
+ toolName,
63
+ command,
64
+ branch: detectCurrentBranch(cwd),
65
+ repoRoot: detectRepoRoot(cwd),
66
+ // Resolved from the same `cwd`, and for the same reason: the lease is keyed
67
+ // by the worktree a judge was handed, and `cwd` is the only thing in a
68
+ // PreToolUse payload that says where the command will run. No lease is the
69
+ // ordinary case and costs one directory read.
70
+ sandbox: activeSandboxFor(cwd),
71
+ filePath,
72
+ };
73
+ const decision = evaluateCommand(context, loadGuardrailPolicy());
74
+ if (decision.allowed) {
75
+ // Allow prints NOTHING, on purpose, and this is not a cosmetic choice.
76
+ // Claude Code reads an explicit `permissionDecision: "allow"` from a hook
77
+ // as "skip the permission system for this call" — it outranks the
78
+ // operator's own `permissions.deny` list and suppresses the approval
79
+ // prompt. A guard hook that emitted it on every command it did not
80
+ // recognise would therefore not be a guard at all: it would be a blanket
81
+ // auto-approver for everything outside guardrails.yml's rules, which is a
82
+ // strictly wider grant than the hook it replaces ever had. Empty stdout is
83
+ // the protocol's "no opinion, carry on with the normal permission flow",
84
+ // which is exactly what this layer means when no rule fires. The exit code
85
+ // still separates "no opinion" (0, silent) from "could not decide"
86
+ // (non-zero, which guard.sh turns into a deny), so silence here is never
87
+ // load-bearing the way the old hook's was.
88
+ return null;
89
+ }
90
+ // guard.sh only ever surfaced one reason: each rule block()ed and exited
91
+ // immediately on its own match, sequentially, so the first rule in guard.sh's
92
+ // 1-6 order to fire is the only one an agent ever saw. evaluateCommand's
93
+ // checks array preserves that same order, so violations[0] reproduces
94
+ // guard.sh's exact visible behaviour even though evaluateCommand (policy
95
+ // check's diagnostic output) reports every rule that tripped, not just the
96
+ // first.
97
+ const first = decision.violations[0];
98
+ const reason = first ? first.reason : 'guardrails.yml denied this command.';
99
+ return {
100
+ hookSpecificOutput: {
101
+ hookEventName: 'PreToolUse',
102
+ permissionDecision: 'deny',
103
+ permissionDecisionReason: `BLOCKED: ${reason}`,
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,171 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { SmithError } from './errors.js';
5
+ import { runGit as git, runGitRaw } from './git.js';
6
+ export class ImmutabilityError extends SmithError {
7
+ }
8
+ /** The taxonomy class raised when a judge moved the worktree it was judging. */
9
+ export const JUDGE_MUTATION_CODE = 'contract.judge-mutation';
10
+ const PORCELAIN_STATUS_WIDTH = 3; // "XY " — two status chars plus the separator
11
+ const HASH_LENGTH = 12;
12
+ const GONE = 'gone';
13
+ const HEAD_PATH = 'HEAD';
14
+ function assertGitWorktree(worktreeDir) {
15
+ try {
16
+ git(worktreeDir, ['rev-parse', '--is-inside-work-tree']);
17
+ }
18
+ catch (err) {
19
+ throw new ImmutabilityError('immutability.not-a-git-worktree', `${worktreeDir} is not a git worktree: ${err instanceof Error ? err.message : String(err)}`, { worktreeDir });
20
+ }
21
+ }
22
+ function headSha(worktreeDir) {
23
+ try {
24
+ return git(worktreeDir, ['rev-parse', 'HEAD']);
25
+ }
26
+ catch {
27
+ return null; // unborn branch — a worktree with no commits yet
28
+ }
29
+ }
30
+ function currentBranch(worktreeDir) {
31
+ try {
32
+ return git(worktreeDir, ['branch', '--show-current']) || null; // empty when detached
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ function hashFile(worktreeDir, relPath) {
39
+ try {
40
+ return createHash('sha256')
41
+ .update(readFileSync(path.join(worktreeDir, relPath)))
42
+ .digest('hex')
43
+ .slice(0, HASH_LENGTH);
44
+ }
45
+ catch {
46
+ return null; // deleted, a directory, or unreadable — all "no bytes here"
47
+ }
48
+ }
49
+ /**
50
+ * Every path git currently reports as dirty or untracked, `-z`-parsed so
51
+ * spaces and newlines in filenames survive. Renames and copies emit two
52
+ * fields (destination then source); both are recorded, since a judge that
53
+ * renamed a file moved two paths.
54
+ */
55
+ function statusEntries(worktreeDir) {
56
+ const output = runGitRaw(worktreeDir, [
57
+ 'status',
58
+ '--porcelain=v1',
59
+ '-z',
60
+ '--untracked-files=all',
61
+ ]);
62
+ const fields = output.split('\0');
63
+ const entries = {};
64
+ for (let i = 0; i < fields.length; i++) {
65
+ const field = fields[i];
66
+ if (field.length <= PORCELAIN_STATUS_WIDTH)
67
+ continue;
68
+ const status = field.slice(0, 2);
69
+ const target = field.slice(PORCELAIN_STATUS_WIDTH);
70
+ entries[target] = { status, hash: hashFile(worktreeDir, target) };
71
+ if (status.startsWith('R') || status.startsWith('C')) {
72
+ const source = fields[i + 1];
73
+ if (source)
74
+ entries[source] = { status, hash: hashFile(worktreeDir, source) };
75
+ i++;
76
+ }
77
+ }
78
+ return entries;
79
+ }
80
+ /**
81
+ * The state a judge must hand back untouched: HEAD, the checked-out branch,
82
+ * and every dirty/untracked path with a hash of its bytes.
83
+ *
84
+ * Two deliberate choices, both load-bearing:
85
+ *
86
+ * 1. **Content hashes, not just the status list.** Six roles (reviewer,
87
+ * verifier, grader, spec-reviewer, security-reviewer, uiux) are read-only
88
+ * in prose and hold `Bash` in fact (agent-interviews.md N-10), so the
89
+ * guarantee needs a check. A status-only fingerprint has a hole exactly
90
+ * where it matters: the coder leaves `src/parse.ts` dirty, the judge edits
91
+ * it again, and the porcelain line reads `" M src/parse.ts"` both times.
92
+ * The hash closes it.
93
+ * 2. **No `--ignored`.** Judges run the suite — that is what `Bash` is for —
94
+ * and running it writes `node_modules/`, `dist/`, coverage caches. A guard
95
+ * that fired on those would be turned off within a day. Widening
96
+ * `.gitignore` is not an escape hatch: that edit is itself a tracked-file
97
+ * change, and ignore rules never apply to already-tracked files.
98
+ *
99
+ * The remaining hole is honest and small: an edit reverted byte-for-byte
100
+ * before the judge exits is invisible to any before/after comparison.
101
+ */
102
+ export function fingerprintWorktree(worktreeDir) {
103
+ assertGitWorktree(worktreeDir);
104
+ return {
105
+ head: headSha(worktreeDir),
106
+ branch: currentBranch(worktreeDir),
107
+ entries: statusEntries(worktreeDir),
108
+ };
109
+ }
110
+ function describe(entry) {
111
+ return `${entry.status} ${entry.hash ?? GONE}`;
112
+ }
113
+ function assertFingerprint(value) {
114
+ if (value === null ||
115
+ typeof value !== 'object' ||
116
+ typeof value.entries !== 'object' ||
117
+ value.entries === null ||
118
+ !('head' in value)) {
119
+ throw new ImmutabilityError('immutability.invalid-fingerprint', 'The before-fingerprint is not a WorktreeFingerprint ({ head, branch, entries }).', { received: value === null ? 'null' : typeof value });
120
+ }
121
+ }
122
+ /**
123
+ * Compare the worktree as it stands now against the fingerprint taken before
124
+ * a judge ran. Any difference is a violation: the judge is not the coder, and
125
+ * the one path it may write is its own artifact under `state/results/`, which
126
+ * lives outside the worktree.
127
+ */
128
+ export function checkWorktreeImmutable(worktreeDir, before) {
129
+ assertFingerprint(before);
130
+ const after = fingerprintWorktree(worktreeDir);
131
+ const drift = [];
132
+ if (before.head !== after.head) {
133
+ drift.push({ kind: 'head-moved', path: HEAD_PATH, before: before.head, after: after.head });
134
+ }
135
+ if (before.branch !== after.branch) {
136
+ drift.push({
137
+ kind: 'branch-switched',
138
+ path: HEAD_PATH,
139
+ before: before.branch,
140
+ after: after.branch,
141
+ });
142
+ }
143
+ for (const key of [
144
+ ...new Set([...Object.keys(before.entries), ...Object.keys(after.entries)]),
145
+ ].sort()) {
146
+ const wasEntry = before.entries[key];
147
+ const isEntry = after.entries[key];
148
+ if (!wasEntry && isEntry) {
149
+ drift.push({ kind: 'dirtied', path: key, before: null, after: describe(isEntry) });
150
+ }
151
+ else if (wasEntry && !isEntry) {
152
+ drift.push({ kind: 'reverted', path: key, before: describe(wasEntry), after: null });
153
+ }
154
+ else if (wasEntry && isEntry && describe(wasEntry) !== describe(isEntry)) {
155
+ drift.push({
156
+ kind: 'modified',
157
+ path: key,
158
+ before: describe(wasEntry),
159
+ after: describe(isEntry),
160
+ });
161
+ }
162
+ }
163
+ const unchanged = drift.length === 0;
164
+ return {
165
+ unchanged,
166
+ drift,
167
+ violation: unchanged
168
+ ? null
169
+ : { error: JUDGE_MUTATION_CODE, paths: [...new Set(drift.map((d) => d.path))] },
170
+ };
171
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * What a path claim cannot say, said here.
3
+ *
4
+ * `claims.ts` answers "did two tasks write the same file". That question has a
5
+ * blind spot with a name: task A changes `parse()`'s signature in `src/a.ts`,
6
+ * task B calls `parse()` from `src/b.ts`, the two claim lists are disjoint,
7
+ * every gate is green, and integration is where the factory finds out. The
8
+ * conflict was never in a file — it lived on the edge between two files, which
9
+ * is exactly what `symbols.ts` reads.
10
+ *
11
+ * Two checks, and the difference between them is the difference between a risk
12
+ * and a fact:
13
+ *
14
+ * - `waveImpact` runs BEFORE dispatch, over declarations. It can only see that
15
+ * two tasks in one wave sit on either end of a compile-time edge. That is a
16
+ * reason to order them, not evidence that anything is wrong, so it reports
17
+ * `coupled` and stops the wave from running them in parallel.
18
+ * - `exportImpact` runs AFTER the work, over a diff. Here an export that was
19
+ * removed while a file outside the claims still imports it is not a risk —
20
+ * it is a break, and it is provable. A changed signature is weaker: this
21
+ * module reads text, not types, so it says `possible` and means it.
22
+ *
23
+ * Holes are reported, never fatal. The scanner has blind spots (a `.vue` file,
24
+ * an unterminated literal), and failing a wave for the scanner's limits would
25
+ * teach operators to reach for the override — which costs more than the check
26
+ * was ever worth.
27
+ */
28
+ import { existsSync, readFileSync } from 'node:fs';
29
+ import { extname, join } from 'node:path';
30
+ import { claimCoversPath, integrationBranchFor } from './claims.js';
31
+ import { runGit } from './git.js';
32
+ import { DEFAULT_SOURCE_EXTENSIONS, parseModuleFacts, } from './symbols.js';
33
+ /**
34
+ * File -> the task that claims it. First claim wins: a file claimed twice is an
35
+ * overlap, and `validateWave` already refuses that wave for its own reasons.
36
+ */
37
+ function ownersOf(files, tasks) {
38
+ const owner = new Map();
39
+ for (const task of tasks) {
40
+ for (const file of files) {
41
+ if (owner.has(file))
42
+ continue;
43
+ if (task.claims.some((claim) => claimCoversPath(claim, file)))
44
+ owner.set(file, task.task_id);
45
+ }
46
+ }
47
+ return owner;
48
+ }
49
+ export function waveImpact(graph, tasks) {
50
+ // Unreadable files are absent from `modules` but still claimable, and a task
51
+ // that claims one has matched a real file — it is the reading that failed.
52
+ const known = [...graph.modules.keys(), ...graph.unanalyzed].sort();
53
+ const owner = ownersOf(known, tasks);
54
+ const claimed = new Set(owner.values());
55
+ const crossings = new Map();
56
+ const exposure = new Map();
57
+ for (const [exportedBy, edges] of graph.dependents) {
58
+ const producer = owner.get(exportedBy);
59
+ if (producer === undefined)
60
+ continue;
61
+ for (const edge of edges) {
62
+ const consumer = owner.get(edge.from);
63
+ if (consumer === undefined) {
64
+ const key = `${producer} ${exportedBy} ${edge.from}`;
65
+ const seen = exposure.get(key);
66
+ if (seen === undefined) {
67
+ exposure.set(key, {
68
+ producer,
69
+ exportedBy,
70
+ importedBy: edge.from,
71
+ names: new Set(edge.names),
72
+ });
73
+ }
74
+ else {
75
+ for (const name of edge.names)
76
+ seen.names.add(name);
77
+ }
78
+ continue;
79
+ }
80
+ if (consumer === producer)
81
+ continue;
82
+ const key = `${producer} ${consumer} ${exportedBy} ${edge.from}`;
83
+ const seen = crossings.get(key);
84
+ if (seen === undefined) {
85
+ crossings.set(key, {
86
+ producer,
87
+ consumer,
88
+ exportedBy,
89
+ importedBy: edge.from,
90
+ symbols: new Set(edge.names),
91
+ typeOnly: edge.typeOnly,
92
+ dynamic: edge.dynamic,
93
+ });
94
+ }
95
+ else {
96
+ for (const name of edge.names)
97
+ seen.symbols.add(name);
98
+ // One value import is enough to make the coupling a value coupling.
99
+ seen.typeOnly = seen.typeOnly && edge.typeOnly;
100
+ seen.dynamic = seen.dynamic && edge.dynamic;
101
+ }
102
+ }
103
+ }
104
+ const orderedCrossings = [...crossings.values()]
105
+ .map((c) => ({
106
+ producer: c.producer,
107
+ consumer: c.consumer,
108
+ exportedBy: c.exportedBy,
109
+ importedBy: c.importedBy,
110
+ symbols: [...c.symbols].sort(),
111
+ typeOnly: c.typeOnly,
112
+ dynamic: c.dynamic,
113
+ }))
114
+ .sort((a, b) => a.producer.localeCompare(b.producer) ||
115
+ a.consumer.localeCompare(b.consumer) ||
116
+ a.exportedBy.localeCompare(b.exportedBy) ||
117
+ a.importedBy.localeCompare(b.importedBy));
118
+ const orderedExposure = [...exposure.values()]
119
+ .map((e) => ({
120
+ producer: e.producer,
121
+ exportedBy: e.exportedBy,
122
+ importedBy: e.importedBy,
123
+ symbols: [...e.names].sort(),
124
+ }))
125
+ .sort((a, b) => a.producer.localeCompare(b.producer) ||
126
+ a.exportedBy.localeCompare(b.exportedBy) ||
127
+ a.importedBy.localeCompare(b.importedBy));
128
+ const claimsWithoutFiles = tasks
129
+ .filter((task) => !claimed.has(task.task_id))
130
+ .map((task) => task.task_id);
131
+ const unanalyzed = graph.unanalyzed.filter((file) => owner.has(file));
132
+ const unresolved = graph.unresolved.filter((entry) => owner.has(entry.from));
133
+ const hasHoles = unanalyzed.length > 0 || unresolved.length > 0;
134
+ const status = orderedCrossings.length > 0 ? 'coupled' : hasHoles ? 'unverifiable' : 'clean';
135
+ return {
136
+ status,
137
+ ok: status !== 'coupled',
138
+ crossings: orderedCrossings,
139
+ exposure: orderedExposure,
140
+ claimsWithoutFiles,
141
+ unanalyzed,
142
+ unresolved,
143
+ detail: describeWave(status, orderedCrossings, orderedExposure, unanalyzed.length + unresolved.length),
144
+ };
145
+ }
146
+ function describeWave(status, crossings, exposure, holes) {
147
+ const parts = [];
148
+ if (status === 'coupled') {
149
+ const pairs = new Set(crossings.map((c) => `${c.producer}/${c.consumer}`));
150
+ parts.push(`${crossings.length} symbol crossing(s) across ${pairs.size} task pair(s): run them in order, not in parallel.`);
151
+ }
152
+ else if (status === 'unverifiable') {
153
+ parts.push('No crossing found, but the graph has holes over this wave - treat the result as partial.');
154
+ }
155
+ else {
156
+ parts.push('No task in this wave imports a symbol another task in this wave exports.');
157
+ }
158
+ if (exposure.length > 0) {
159
+ parts.push(`${exposure.length} file(s) outside the wave import from claimed files.`);
160
+ }
161
+ if (holes > 0)
162
+ parts.push(`${holes} unreadable or unresolved edge(s) over claimed files.`);
163
+ return parts.join(' ');
164
+ }
165
+ /**
166
+ * The classifier half (P9-3): two texts in, a diff out. Knows nothing about
167
+ * git, which is what lets the wave gate, the merge queue and a test all ask it
168
+ * the same question.
169
+ */
170
+ export function diffExports(before, after, file) {
171
+ const b = parseModuleFacts(before, file);
172
+ const a = parseModuleFacts(after, file);
173
+ if (b.unterminated || a.unterminated) {
174
+ return { file, removed: [], added: [], signatureChanged: [], unverifiable: true };
175
+ }
176
+ const afterNames = new Set(a.exports);
177
+ const beforeNames = new Set(b.exports);
178
+ return {
179
+ file,
180
+ removed: b.exports.filter((name) => !afterNames.has(name)),
181
+ added: a.exports.filter((name) => !beforeNames.has(name)),
182
+ signatureChanged: b.exports.filter((name) => afterNames.has(name) && b.exportSignatures.get(name) !== a.exportSignatures.get(name)),
183
+ unverifiable: false,
184
+ };
185
+ }
186
+ /** A namespace import consumes whatever the module exports, named or not. */
187
+ function consumes(names, symbol) {
188
+ return names.includes('*') || names.includes(symbol);
189
+ }
190
+ /**
191
+ * Whose files broke. `claims` is the acting task's own claim list: an importer
192
+ * the task already owns is not a break, because the same task is free to fix
193
+ * it in the same diff — and usually has.
194
+ */
195
+ export function exportImpact(graph, diffs, claims) {
196
+ const breaks = [];
197
+ const unverifiable = [];
198
+ for (const diff of diffs) {
199
+ if (diff.unverifiable) {
200
+ unverifiable.push(diff.file);
201
+ continue;
202
+ }
203
+ if (diff.removed.length === 0 && diff.signatureChanged.length === 0)
204
+ continue;
205
+ for (const edge of graph.dependents.get(diff.file) ?? []) {
206
+ if (claims.some((claim) => claimCoversPath(claim, edge.from)))
207
+ continue;
208
+ const gone = diff.removed.filter((symbol) => consumes(edge.names, symbol));
209
+ const moved = diff.signatureChanged.filter((symbol) => consumes(edge.names, symbol));
210
+ if (gone.length > 0) {
211
+ breaks.push({
212
+ severity: 'proven',
213
+ reason: 'removed',
214
+ exportedBy: diff.file,
215
+ importedBy: edge.from,
216
+ symbols: gone,
217
+ });
218
+ }
219
+ if (moved.length > 0) {
220
+ breaks.push({
221
+ severity: 'possible',
222
+ reason: 'signature-changed',
223
+ exportedBy: diff.file,
224
+ importedBy: edge.from,
225
+ symbols: moved,
226
+ });
227
+ }
228
+ }
229
+ }
230
+ breaks.sort((a, b) => (a.severity === b.severity ? 0 : a.severity === 'proven' ? -1 : 1) ||
231
+ a.exportedBy.localeCompare(b.exportedBy) ||
232
+ a.importedBy.localeCompare(b.importedBy));
233
+ const proven = breaks.filter((entry) => entry.severity === 'proven').length;
234
+ const possible = breaks.length - proven;
235
+ const parts = [];
236
+ if (proven > 0)
237
+ parts.push(`${proven} importer(s) outside the claims lost a symbol they use.`);
238
+ if (possible > 0) {
239
+ parts.push(`${possible} importer(s) use a symbol whose declaration changed shape.`);
240
+ }
241
+ if (proven === 0 && possible === 0) {
242
+ parts.push('No importer outside the claims depends on what moved.');
243
+ }
244
+ if (unverifiable.length > 0)
245
+ parts.push(`Unreadable, so unchecked: ${unverifiable.join(', ')}.`);
246
+ return { ok: proven === 0, breaks, detail: parts.join(' ') };
247
+ }
248
+ /**
249
+ * The collector half (P9-3): read both sides of each changed file out of git.
250
+ *
251
+ * `ls-tree` before `show` on purpose — a file added on this branch is absent
252
+ * from the integration branch, and asking `show` for it would raise an error
253
+ * that means "new file", which is not an error at all.
254
+ */
255
+ export function collectExportDiffs(worktreeDir, changedFiles) {
256
+ const integrationBranch = integrationBranchFor(worktreeDir);
257
+ const diffs = [];
258
+ for (const file of changedFiles) {
259
+ if (!DEFAULT_SOURCE_EXTENSIONS.includes(extname(file)))
260
+ continue;
261
+ const listed = runGit(worktreeDir, [
262
+ 'ls-tree',
263
+ '-r',
264
+ '--name-only',
265
+ integrationBranch,
266
+ '--',
267
+ file,
268
+ ]);
269
+ const before = listed === '' ? '' : runGit(worktreeDir, ['show', `${integrationBranch}:${file}`]);
270
+ const absolute = join(worktreeDir, file);
271
+ const after = existsSync(absolute) ? readFileSync(absolute, 'utf8') : '';
272
+ if (before === '' && after === '')
273
+ continue;
274
+ const diff = diffExports(before, after, file);
275
+ const moved = diff.removed.length > 0 || diff.added.length > 0 || diff.signatureChanged.length > 0;
276
+ if (diff.unverifiable || moved)
277
+ diffs.push(diff);
278
+ }
279
+ return diffs;
280
+ }