@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,86 @@
1
+ import { SmithError } from './errors.js';
2
+ export class UnsupportedRuntimeError extends SmithError {
3
+ }
4
+ /**
5
+ * The Node major the repo is pinned to. Kept in lockstep with `.nvmrc` and
6
+ * the root package.json `engines` field — `runtime.test.ts` asserts all three
7
+ * agree, so this constant cannot drift away from the pin silently.
8
+ */
9
+ export const MIN_NODE_MAJOR = 22;
10
+ /**
11
+ * The N-API version the native dependencies are built against.
12
+ * `better-sqlite3` ships one prebuilt binary per platform, compiled for
13
+ * N-API 10 (Node 22+). Node 20 is N-API 9: the binding still *loads*, and
14
+ * then the process dies at `new Database()` with SIGSEGV — no ABI error, no
15
+ * message, both streams empty. That silence is why this guard exists (D-47).
16
+ */
17
+ export const MIN_NAPI = 10;
18
+ export function currentRuntime() {
19
+ return { version: process.version, napi: process.versions.napi };
20
+ }
21
+ export function nodeMajor(version) {
22
+ // Tolerates a bare major so the same parser reads `.nvmrc` ("22") and
23
+ // `process.version` ("v22.23.1") — one definition of "the pinned major".
24
+ const match = /^v?(\d+)(?:\.|$)/.exec(version.trim());
25
+ if (match === null)
26
+ return null;
27
+ const major = Number(match[1]);
28
+ return Number.isFinite(major) ? major : null;
29
+ }
30
+ const REMEDY = 'Run `nvm use` (the repo pins the major in .nvmrc) or install Node ' +
31
+ `${MIN_NODE_MAJOR}+, then reinstall dependencies.`;
32
+ const WHY = 'The prebuilt better-sqlite3 native binding is built for N-API ' +
33
+ `${MIN_NAPI}; on an older runtime it loads and then segfaults at the first ` +
34
+ 'database open, so you get a crash with empty output instead of an error.';
35
+ /**
36
+ * Decide whether this runtime can actually run the orchestrator. Unknown is
37
+ * treated as unsupported: the failure mode being guarded against is a silent
38
+ * crash, so guessing in favour of "probably fine" reintroduces exactly the
39
+ * bug (D-47).
40
+ */
41
+ export function checkRuntime(info = currentRuntime()) {
42
+ const major = nodeMajor(info.version);
43
+ if (major === null) {
44
+ return {
45
+ supported: false,
46
+ reason: `Could not parse a Node major version out of "${info.version}", so this runtime ` +
47
+ `cannot be verified against the required Node >=${MIN_NODE_MAJOR}. ${WHY} ${REMEDY}`,
48
+ };
49
+ }
50
+ if (major < MIN_NODE_MAJOR) {
51
+ const napi = info.napi === undefined ? 'unknown' : info.napi;
52
+ return {
53
+ supported: false,
54
+ reason: `Node ${info.version} (N-API ${napi}) is too old: black-smith requires Node ` +
55
+ `>=${MIN_NODE_MAJOR} (N-API >=${MIN_NAPI}). ${WHY} ${REMEDY}`,
56
+ };
57
+ }
58
+ if (info.napi === undefined) {
59
+ return {
60
+ supported: false,
61
+ reason: `Node ${info.version} reports no N-API version, so the native bindings cannot be ` +
62
+ `matched against the required N-API >=${MIN_NAPI}. ${WHY} ${REMEDY}`,
63
+ };
64
+ }
65
+ const napi = Number(info.napi);
66
+ if (!Number.isFinite(napi) || napi < MIN_NAPI) {
67
+ return {
68
+ supported: false,
69
+ reason: `Node ${info.version} provides N-API ${info.napi}, below the required N-API ` +
70
+ `>=${MIN_NAPI}. ${WHY} ${REMEDY}`,
71
+ };
72
+ }
73
+ return { supported: true, reason: null };
74
+ }
75
+ /** `checkRuntime`, but fatal — for the CLI entrypoint. */
76
+ export function assertRuntimeSupported(info = currentRuntime()) {
77
+ const result = checkRuntime(info);
78
+ if (result.supported)
79
+ return;
80
+ throw new UnsupportedRuntimeError('unsupported-runtime', result.reason ?? 'unsupported runtime', {
81
+ version: info.version,
82
+ napi: info.napi ?? null,
83
+ minNodeMajor: MIN_NODE_MAJOR,
84
+ minNapi: MIN_NAPI,
85
+ });
86
+ }
@@ -0,0 +1,243 @@
1
+ import { canEscalate, isFileScoped } from './severity.js';
2
+ export function lessonReach(lessons) {
3
+ const categories = new Set();
4
+ let escalating = 0;
5
+ let withoutCategory = 0;
6
+ let nonFileScoped = 0;
7
+ for (const lesson of lessons) {
8
+ if (canEscalate(lesson)) {
9
+ escalating += 1;
10
+ categories.add(lesson.category);
11
+ continue;
12
+ }
13
+ // Both reasons are recorded for an entry that has both, because each is a
14
+ // different edit to make the entry live.
15
+ if (lesson.category === '')
16
+ withoutCategory += 1;
17
+ if (!isFileScoped(lesson))
18
+ nonFileScoped += 1;
19
+ }
20
+ return {
21
+ total: lessons.length,
22
+ escalating,
23
+ withoutCategory,
24
+ nonFileScoped,
25
+ categoriesCovered: [...categories].sort(),
26
+ };
27
+ }
28
+ function emptyDay() {
29
+ return {
30
+ intakes: 0,
31
+ emptyIntakes: 0,
32
+ decisions: 0,
33
+ sameMistake: 0,
34
+ quorumSameMistake: 0,
35
+ uninstrumentedDecisions: 0,
36
+ };
37
+ }
38
+ function decide(windows, provenRises, reach, holes) {
39
+ // Order matters, and it is the asymmetry in code. A repeat the log already
40
+ // recorded proves the instrument fired, so a rise between measured windows
41
+ // survives any hole elsewhere in the record rather than being downgraded to
42
+ // "we cannot tell" by an unrelated unknown. Only when nothing has risen does
43
+ // the quality of the instrument decide between "on target" and "unreadable".
44
+ //
45
+ // "Elsewhere" is the load-bearing word, and it used to be unchecked: a rise
46
+ // has two operands, and a hole in the earlier one is not elsewhere. Only a
47
+ // rise the baseline could have contradicted is a fact about the work (D-174).
48
+ if (provenRises.length > 0)
49
+ return 'off-target';
50
+ if (reach.escalating === 0)
51
+ return 'unverifiable';
52
+ if (holes)
53
+ return 'unverifiable';
54
+ if (windows.length === 0)
55
+ return 'unverifiable';
56
+ if (windows.length === 1)
57
+ return 'insufficient-history';
58
+ return 'on-target';
59
+ }
60
+ function pct(rate) {
61
+ return `${(rate * 100).toFixed(1)}%`;
62
+ }
63
+ function describe(report) {
64
+ const head = `${report.totalSameMistake} same-mistake of ${report.totalDecisions} decision(s) across ` +
65
+ `${report.windows.length} measured window(s)` +
66
+ (report.windows.length > 0
67
+ ? ` — ${report.windows.map((w) => `${w.day} ${pct(w.rate)}`).join(', ')}.`
68
+ : '.');
69
+ const holes = [];
70
+ if (report.reach.escalating === 0) {
71
+ holes.push(`none of the ${report.reach.total} compiled lesson(s) can escalate anything ` +
72
+ `(${report.reach.withoutCategory} name no finding_category, ` +
73
+ `${report.reach.nonFileScoped} are not file-scoped), so a zero here is a property ` +
74
+ `of the corpus, not of the work`);
75
+ }
76
+ if (report.intakesWithoutInstrumentRecord > 0) {
77
+ holes.push(`${report.intakesWithoutInstrumentRecord} intake(s) record no lessons_escalating count, ` +
78
+ `so the log does not say whether the gate held any lesson at all`);
79
+ }
80
+ if (report.blindIntakes > 0) {
81
+ holes.push(`${report.blindIntakes} intake(s) ran with no escalating lesson loaded and could not ` +
82
+ `have found a repeat`);
83
+ }
84
+ if (report.unprovenRises.length > 0) {
85
+ holes.push(`the rate rose on ${report.unprovenRises.join(', ')} from a window whose decisions came ` +
86
+ `from a gate that could not have recorded a repeat, so the rise measures the corpus ` +
87
+ `rather than the work`);
88
+ }
89
+ if (report.traceDisagreements.length > 0) {
90
+ holes.push(`the quorum trace saw a same mistake the severity trace did not on ` +
91
+ `${report.traceDisagreements.join(', ')}`);
92
+ }
93
+ if (report.silentDays.length > 0) {
94
+ holes.push(`${report.silentDays.length} day(s) recorded intakes but no decision ` +
95
+ `(${report.silentDays.join(', ')}) and are excluded rather than counted as 0%`);
96
+ }
97
+ const holeText = holes.length > 0 ? ` Holes: ${holes.join('; ')}.` : '';
98
+ switch (report.status) {
99
+ case 'off-target':
100
+ return (`${head} The rate rose between measured windows, against §9.7's monotonically ` +
101
+ `decreasing target. The window it rose from recorded every decision under a gate ` +
102
+ `equipped to escalate, so this stands whatever else the record is missing.${holeText}`);
103
+ case 'insufficient-history':
104
+ return (`${head} One measured window is a reading, not a trend; §9.7's target is about ` +
105
+ `direction and needs a second.${holeText}`);
106
+ case 'unverifiable':
107
+ return report.unprovenRises.length > 0
108
+ ? `${head} The rate rose, but not from a window that could have read anything else: ` +
109
+ `the baseline's zeros belong to the corpus, not to the work.${holeText}`
110
+ : `${head} Nothing here shows the rate rising, but neither can it show the rate ` +
111
+ `falling: the number could not have been other than what it is.${holeText}`;
112
+ default:
113
+ return (`${head} Non-increasing across every measured window, against a corpus that can ` +
114
+ `escalate ${report.reach.escalating} lesson(s) in ` +
115
+ `${report.reach.categoriesCovered.join(', ')}.`);
116
+ }
117
+ }
118
+ /**
119
+ * The same-mistake rate read as §9.7's KPI: is it going down, and is the number
120
+ * one that could have gone up.
121
+ *
122
+ * Read-only over the log. Fails the report on anything but `on-target` — an
123
+ * unreadable instrument is not a clean one, for the same reason
124
+ * dispatchAudit.ts emits a synthetic check for an empty pair set.
125
+ */
126
+ export function checkSameMistakeKpi(events, lessons, options) {
127
+ const reach = lessonReach(lessons);
128
+ const byDay = new Map();
129
+ const dayFor = (day) => {
130
+ let acc = byDay.get(day);
131
+ if (acc === undefined) {
132
+ acc = emptyDay();
133
+ byDay.set(day, acc);
134
+ }
135
+ return acc;
136
+ };
137
+ let intakesWithoutInstrumentRecord = 0;
138
+ let blindIntakes = 0;
139
+ for (const { record } of events) {
140
+ const day = record.ts.slice(0, 10);
141
+ if (record.event_type === 'quorum-decision') {
142
+ if (record.payload.trigger_reason === 'same-mistake')
143
+ dayFor(day).quorumSameMistake += 1;
144
+ continue;
145
+ }
146
+ if (record.event_type !== 'severity-decisions')
147
+ continue;
148
+ const payload = record.payload;
149
+ const acc = dayFor(day);
150
+ acc.intakes += 1;
151
+ const loaded = payload.lessons_escalating;
152
+ if (typeof loaded !== 'number')
153
+ intakesWithoutInstrumentRecord += 1;
154
+ else if (loaded === 0)
155
+ blindIntakes += 1;
156
+ const decisions = payload.decisions ?? [];
157
+ if (decisions.length === 0)
158
+ acc.emptyIntakes += 1;
159
+ let fired = false;
160
+ for (const decision of decisions) {
161
+ acc.decisions += 1;
162
+ if (decision.same_mistake) {
163
+ acc.sameMistake += 1;
164
+ fired = true;
165
+ }
166
+ }
167
+ // What this intake's decisions are worth as a baseline. A recorded repeat
168
+ // is its own attestation — `same_mistake: true` cannot be written by a gate
169
+ // holding nothing to escalate against — so an intake that fired needs no
170
+ // separate count, which is why an event predating `lessons_escalating` can
171
+ // still anchor a rise. An intake that did not fire needs the count, and
172
+ // without it its zeros are the corpus's zeros, not the work's.
173
+ const shownEquipped = fired || (typeof loaded === 'number' && loaded > 0);
174
+ if (!shownEquipped)
175
+ acc.uninstrumentedDecisions += decisions.length;
176
+ }
177
+ const days = [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b));
178
+ const windows = days
179
+ .filter(([, acc]) => acc.decisions > 0)
180
+ .map(([day, acc]) => ({
181
+ day,
182
+ intakes: acc.intakes,
183
+ emptyIntakes: acc.emptyIntakes,
184
+ decisions: acc.decisions,
185
+ sameMistake: acc.sameMistake,
186
+ rate: acc.sameMistake / acc.decisions,
187
+ quorumSameMistake: acc.quorumSameMistake,
188
+ uninstrumentedDecisions: acc.uninstrumentedDecisions,
189
+ }));
190
+ // A day with intakes but no decision at all. Not a rate of zero — there is no
191
+ // denominator to divide by, and inventing one is the whole bug.
192
+ const silentDays = days
193
+ .filter(([, acc]) => acc.decisions === 0 && acc.intakes > 0)
194
+ .map(([day]) => day);
195
+ // The quorum trace is written by a different code path (gate.ts:487) and only
196
+ // when an external provider is enabled, so it is normally silent. What it can
197
+ // never be is *more* clean than the severity trace: a same-mistake quorum on
198
+ // a day the decisions call clean means one of the two traces is wrong.
199
+ const traceDisagreements = days
200
+ .filter(([, acc]) => acc.quorumSameMistake > acc.sameMistake)
201
+ .map(([day]) => day);
202
+ // Split every rise by whether the window it rose FROM could have read
203
+ // anything but low. A baseline holding decisions no gate was equipped to
204
+ // escalate is one of the three zeros in this file's header, and a difference
205
+ // taken against it measures the corpus rather than the factory's conduct —
206
+ // the substitution `reach` already refuses for the level, refused here for
207
+ // the trend.
208
+ const provenRises = [];
209
+ const unprovenRises = [];
210
+ for (let i = 1; i < windows.length; i += 1) {
211
+ const window = windows[i];
212
+ const previous = windows[i - 1];
213
+ if (window.rate <= previous.rate)
214
+ continue;
215
+ if (previous.uninstrumentedDecisions === 0)
216
+ provenRises.push(window.day);
217
+ else
218
+ unprovenRises.push(window.day);
219
+ }
220
+ const monotonic = provenRises.length === 0 && unprovenRises.length === 0;
221
+ const totalDecisions = windows.reduce((sum, w) => sum + w.decisions, 0);
222
+ const totalSameMistake = windows.reduce((sum, w) => sum + w.sameMistake, 0);
223
+ const holes = intakesWithoutInstrumentRecord > 0 ||
224
+ blindIntakes > 0 ||
225
+ unprovenRises.length > 0 ||
226
+ traceDisagreements.length > 0;
227
+ const partial = {
228
+ sessionId: options.sessionId,
229
+ reach,
230
+ windows,
231
+ silentDays,
232
+ totalDecisions,
233
+ totalSameMistake,
234
+ intakesWithoutInstrumentRecord,
235
+ blindIntakes,
236
+ traceDisagreements,
237
+ monotonic,
238
+ provenRises,
239
+ unprovenRises,
240
+ status: decide(windows, provenRises, reach, holes),
241
+ };
242
+ return { ...partial, detail: describe(partial), ok: partial.status === 'on-target' };
243
+ }
@@ -0,0 +1,165 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { SmithError } from './errors.js';
5
+ import { SANDBOX_LEASE_DIR } from './paths.js';
6
+ import { sandboxRoles } from './policy.js';
7
+ /**
8
+ * The judge sandbox: what turns "judges are read-only" from a sentence an
9
+ * agent reads into a rule the machine applies while the judge is running.
10
+ *
11
+ * Before this module the guarantee was `immutability.ts` — fingerprint the
12
+ * worktree, run the judge, compare after. That check is real and it stays
13
+ * (see `checkWorktreeImmutable`), but it is a *detector*, not a guard: by the
14
+ * time it fires the write has happened, the judge's verdict is already
15
+ * untrustworthy, and the task has to go round again. Worse, it can only see
16
+ * what git can see — a judge that curls an endpoint, installs a package, or
17
+ * writes outside the worktree leaves no fingerprint at all, and the six judge
18
+ * roles hold `Bash` in fact even though they are read-only in prose
19
+ * (agent-interviews.md N-10).
20
+ *
21
+ * A lease closes that gap at the only moment where refusing is free: before
22
+ * the command runs. The orchestrator opens one against the worktree it is
23
+ * about to hand a judge; `smith policy hook` resolves the lease from the
24
+ * PreToolUse payload's `cwd` and, while one is open, evaluates the judge
25
+ * rules in guardrails.yml on top of the six that always apply. Closing the
26
+ * lease is the orchestrator's move, not the judge's — `judge-sandbox-escape`
27
+ * exists precisely because `smith sandbox close` is itself a Bash command.
28
+ *
29
+ * Why a file rather than an environment variable: the hook does not run in
30
+ * the judge's process, or even necessarily on the same side of a subagent
31
+ * boundary. `cwd` is the one thing the PreToolUse payload is guaranteed to
32
+ * carry that identifies *where* the command will run, and a worktree path is
33
+ * what a judge dispatch is scoped to. So the lease is keyed by worktree and
34
+ * kept in the main clone, which is also the answer to "could the judge just
35
+ * delete it": that path is outside its worktree, and reaching it is a write
36
+ * the same rule set denies.
37
+ *
38
+ * What this deliberately is NOT: a container, a seccomp filter, or a
39
+ * read-only mount. Those are the real thing and this is not them — a judge
40
+ * that never issues a Bash tool call cannot be watched here at all, and a
41
+ * sufficiently creative command line will get past a matcher, exactly as
42
+ * documented for the six branch/deploy rules. This raises the floor from
43
+ * "detected afterwards" to "refused up front" on every path a judge actually
44
+ * has, and `checkWorktreeImmutable` still runs behind it. Both are stated
45
+ * plainly in docs/standards/guardrails.md rather than sold as a sandbox.
46
+ */
47
+ export class SandboxError extends SmithError {
48
+ }
49
+ /**
50
+ * One file per lease, named by a hash of the worktree path.
51
+ *
52
+ * Per-file rather than one shared JSON document because judges for different
53
+ * tasks run at the same time by design: two waves in flight means two opens
54
+ * and two closes racing, and a read-modify-write of a shared file loses one
55
+ * of them. A hash rather than the path itself because a worktree path is not
56
+ * a legal filename on any platform this runs on.
57
+ */
58
+ export function leasePathFor(worktreeDir, dir = SANDBOX_LEASE_DIR) {
59
+ const key = createHash('sha256').update(path.resolve(worktreeDir)).digest('hex').slice(0, 32);
60
+ return path.join(dir, `${key}.json`);
61
+ }
62
+ function assertLease(value, file) {
63
+ const lease = value;
64
+ if (lease === null ||
65
+ typeof lease !== 'object' ||
66
+ typeof lease.worktreeDir !== 'string' ||
67
+ typeof lease.role !== 'string' ||
68
+ typeof lease.taskId !== 'string' ||
69
+ typeof lease.sessionId !== 'string' ||
70
+ typeof lease.openedAt !== 'string') {
71
+ throw new SandboxError('sandbox.invalid-lease', `${file} is not a sandbox lease ({ worktreeDir, role, taskId, sessionId, openedAt }).`, { file });
72
+ }
73
+ return lease;
74
+ }
75
+ /**
76
+ * Open a lease over `worktreeDir`.
77
+ *
78
+ * Refuses when one is already open, and that refusal is the point rather than
79
+ * bookkeeping tidiness: two judges sharing a worktree means the second one's
80
+ * writes are attributed to the first one's lease and neither verdict can be
81
+ * trusted. The orchestrator's answer is a second worktree, not a second
82
+ * judge in the same tree.
83
+ *
84
+ * Refuses an unknown `role` for a related reason. The role is not a label on
85
+ * the lease, it *selects* which rule set policy.ts applies — the judge rules
86
+ * or a role write scope — so a typo used to be a silent downgrade to whatever
87
+ * the fallback happened to be. Guessing is not an option here and neither is
88
+ * a warning: the lease is written once and read by a hook that has no way to
89
+ * ask what was meant.
90
+ */
91
+ export function openSandbox(input, dir = SANDBOX_LEASE_DIR) {
92
+ const roles = sandboxRoles();
93
+ if (!roles.includes(input.role)) {
94
+ throw new SandboxError('sandbox.unknown-role', `"${input.role}" is not a role guardrails.yml has rules for. Open the lease as one of: ${roles.join(', ')}.`, { role: input.role, roles });
95
+ }
96
+ const worktreeDir = path.resolve(input.worktreeDir);
97
+ const file = leasePathFor(worktreeDir, dir);
98
+ const existing = readSandbox(worktreeDir, dir);
99
+ if (existing) {
100
+ throw new SandboxError('sandbox.already-open', `A ${existing.role} sandbox is already open on ${worktreeDir} (task ${existing.taskId}, since ${existing.openedAt}). Close it, or give this judge its own worktree.`, { worktreeDir, role: existing.role, taskId: existing.taskId });
101
+ }
102
+ const lease = {
103
+ worktreeDir,
104
+ role: input.role,
105
+ taskId: input.taskId,
106
+ sessionId: input.sessionId,
107
+ openedAt: input.openedAt,
108
+ };
109
+ mkdirSync(dir, { recursive: true });
110
+ writeFileSync(file, `${JSON.stringify(lease, null, 2)}\n`, 'utf8');
111
+ return lease;
112
+ }
113
+ /** The lease on exactly this worktree, or null. Does not walk up — see `activeSandboxFor`. */
114
+ export function readSandbox(worktreeDir, dir = SANDBOX_LEASE_DIR) {
115
+ const file = leasePathFor(worktreeDir, dir);
116
+ if (!existsSync(file))
117
+ return null;
118
+ return assertLease(JSON.parse(readFileSync(file, 'utf8')), file);
119
+ }
120
+ /** Close the lease on `worktreeDir`. Returns false when there was none — closing twice is not an error. */
121
+ export function closeSandbox(worktreeDir, dir = SANDBOX_LEASE_DIR) {
122
+ const file = leasePathFor(worktreeDir, dir);
123
+ if (!existsSync(file))
124
+ return false;
125
+ rmSync(file);
126
+ return true;
127
+ }
128
+ /** Every open lease, for `smith sandbox status` and for the resolver below. */
129
+ export function listSandboxes(dir = SANDBOX_LEASE_DIR) {
130
+ if (!existsSync(dir))
131
+ return [];
132
+ const leases = [];
133
+ for (const name of readdirSync(dir).sort()) {
134
+ if (!name.endsWith('.json'))
135
+ continue;
136
+ const file = path.join(dir, name);
137
+ leases.push(assertLease(JSON.parse(readFileSync(file, 'utf8')), file));
138
+ }
139
+ return leases;
140
+ }
141
+ /**
142
+ * The lease covering a command about to run in `cwd`, or null.
143
+ *
144
+ * Matches on containment, not equality: a judge running `pnpm -C ui test`
145
+ * from `<worktree>/ui` is inside the same sandbox as one standing at the
146
+ * root, and a rule that only fired at the exact worktree path would be a
147
+ * `cd` away from being off. When leases nest (they should not, but a
148
+ * worktree inside a worktree is not forbidden by anything) the longest
149
+ * matching path wins, which is the innermost and therefore the most specific
150
+ * claim on the command.
151
+ */
152
+ export function activeSandboxFor(cwd, dir = SANDBOX_LEASE_DIR) {
153
+ const here = path.resolve(cwd);
154
+ let best = null;
155
+ for (const lease of listSandboxes(dir)) {
156
+ const relative = path.relative(lease.worktreeDir, here);
157
+ const contained = relative === '' ||
158
+ (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
159
+ if (!contained)
160
+ continue;
161
+ if (best === null || lease.worktreeDir.length > best.worktreeDir.length)
162
+ best = lease;
163
+ }
164
+ return best;
165
+ }