@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,275 @@
1
+ import { eventTaskId } from './events.js';
2
+ import { taskIdsMatch } from './taskId.js';
3
+ /**
4
+ * Critic work that reaches the event log without a `dispatch_decision`.
5
+ *
6
+ * `key` is the payload field naming the role that did the work, and `label`
7
+ * is how the operator hears it in a detail line. Adding an entry widens the
8
+ * audit's domain; leaving one off is how D-124 happened, so a new event type
9
+ * that records a critic's output belongs here at the moment it is written.
10
+ */
11
+ const CRITIC_WORK_EVENTS = {
12
+ 'spec-review-recorded': { key: 'reviewed_by', label: 'spec review' },
13
+ // `smith epic goal-check` reaches the log the same way `epic spec-review`
14
+ // does — a judge's verdict on the planner's output, written by a command
15
+ // rather than by a dispatch — so it is in the domain for the same reason,
16
+ // and listed here at the moment it was written rather than after a D-124.
17
+ 'goal-check-recorded': { key: 'checked_by', label: 'spec-vs-goal check' },
18
+ };
19
+ function payloadString(payload, key) {
20
+ const value = payload[key];
21
+ return typeof value === 'string' && value.length > 0 ? value : null;
22
+ }
23
+ /**
24
+ * Whether two task scopes can answer for each other.
25
+ *
26
+ * They disagree only when both are named and differ. A dispatch with no task
27
+ * id answers for anything: pre-task-id history is on disk and unrewritable,
28
+ * and refusing it would turn every one of those runs into a wall of false
29
+ * alarms rather than a finding anyone can act on.
30
+ *
31
+ * D-181: "differ" is taskIdsMatch's question, not `===`'s. The log writes one
32
+ * task both qualified and bare, so a raw comparison here un-pairs a critic
33
+ * from the very finder it ran against whenever the two dispatches were
34
+ * spelled differently -- and the report says `unverifiable` about a pair it
35
+ * holds both halves of. taskIdsMatch keeps this function's own guarantee: two
36
+ * DIFFERENT qualified ids stay different tasks even when their bare halves
37
+ * collide, which is the whole point of scoping the finder search.
38
+ */
39
+ function scopesAgree(a, b) {
40
+ return a === null || b === null || taskIdsMatch(a, b);
41
+ }
42
+ /**
43
+ * Flatten a session's events down to its dispatch records, in log order.
44
+ *
45
+ * Pre-P9-23 events are kept with `model: null` rather than filtered out.
46
+ * They are on disk and are never rewritten (the log is append-only), and
47
+ * dropping them would turn "this dispatch cannot be checked" into "no such
48
+ * dispatch happened" — the report would look emptier and cleaner than the
49
+ * history it describes.
50
+ */
51
+ export function readDispatchRecords(events) {
52
+ const records = [];
53
+ for (const stored of events) {
54
+ const record = stored.record;
55
+ if (record.event_type !== 'dispatch_decision')
56
+ continue;
57
+ const payload = (record.payload ?? {});
58
+ const role = payloadString(payload, 'agent_role');
59
+ if (!role)
60
+ continue;
61
+ records.push({
62
+ eventId: stored.event_id,
63
+ ts: record.ts ?? '',
64
+ taskId: eventTaskId(record),
65
+ role,
66
+ provider: payloadString(payload, 'provider') ?? '',
67
+ model: payloadString(payload, 'model'),
68
+ modelTier: payloadString(payload, 'model_tier'),
69
+ agentId: typeof record.agent_id === 'string' && record.agent_id ? record.agent_id : null,
70
+ });
71
+ }
72
+ return records;
73
+ }
74
+ /**
75
+ * Flatten a session's events down to its critic-work records, in log order.
76
+ *
77
+ * The mirror of readDispatchRecords for the other route into the log. A
78
+ * record here is a claim that a critic role produced something; it carries no
79
+ * model, because no dispatch chose one, which is exactly why an uncovered
80
+ * record can only ever be `unverifiable`.
81
+ */
82
+ export function readCriticWorkRecords(events) {
83
+ const records = [];
84
+ for (const stored of events) {
85
+ const record = stored.record;
86
+ const spec = CRITIC_WORK_EVENTS[record.event_type];
87
+ if (!spec)
88
+ continue;
89
+ const payload = (record.payload ?? {});
90
+ const role = payloadString(payload, spec.key);
91
+ if (!role)
92
+ continue;
93
+ records.push({
94
+ eventId: stored.event_id,
95
+ ts: record.ts ?? '',
96
+ taskId: eventTaskId(record),
97
+ role,
98
+ label: spec.label,
99
+ });
100
+ }
101
+ return records;
102
+ }
103
+ /**
104
+ * The critic-work records no dispatch of that role can answer for.
105
+ *
106
+ * A record is covered when a dispatch of the same role falls in the window
107
+ * (previous record of that role, this record] — since the last time this
108
+ * critic recorded something, and no later than the record itself.
109
+ *
110
+ * The window is the whole check. Treating dispatches as fungible tokens —
111
+ * five spec-reviewer dispatches, so the next five recorded reviews are fine —
112
+ * is exactly the laundering `precedingFinder` refuses on the finder side, and
113
+ * it is not hypothetical: dogfood-mcp-1 dispatched five spec-reviewers across
114
+ * two days and then recorded two more reviews by hand three days later, which
115
+ * a fungible count would have passed. A dispatch that predates the previous
116
+ * record already answered for that one; it cannot answer twice.
117
+ */
118
+ function uncoveredCriticWork(work, dispatches) {
119
+ const uncovered = [];
120
+ let since = '';
121
+ for (const record of [...work].sort((a, b) => a.ts.localeCompare(b.ts))) {
122
+ if (!dispatches.some((d) => d.ts > since && d.ts <= record.ts))
123
+ uncovered.push(record);
124
+ since = record.ts;
125
+ }
126
+ return uncovered;
127
+ }
128
+ /**
129
+ * The finder dispatch a given critic dispatch was reviewing: the latest one
130
+ * at or before the critic's timestamp, on the critic's own task.
131
+ *
132
+ * "At or before" is the whole point. A later re-plan on a different model
133
+ * cannot retroactively make an earlier review adversarial, and comparing a
134
+ * critic against the newest finder dispatch in the session would let exactly
135
+ * that launder a violation into a pass.
136
+ *
137
+ * The task axis is the same argument on the other coordinate, and it is not
138
+ * hypothetical: on dogfood-mcp-1 four `integration` verifiers were answered
139
+ * for by a task-4 reviewer dispatched three days earlier, on a task with no
140
+ * reviewer dispatch of its own anywhere in the log. Different code, different
141
+ * finding, reported ok. Evidence about one task vouches for nothing about
142
+ * another (D-172).
143
+ */
144
+ function precedingFinder(finders, critic) {
145
+ let best = null;
146
+ for (const finder of finders) {
147
+ if (finder.ts > critic.ts)
148
+ continue;
149
+ if (!scopesAgree(finder.taskId, critic.taskId))
150
+ continue;
151
+ if (!best || finder.ts >= best.ts)
152
+ best = finder;
153
+ }
154
+ return best;
155
+ }
156
+ function checkOneCritic(pair, critic, finders) {
157
+ const finder = precedingFinder(finders, critic);
158
+ const base = {
159
+ finder: pair.finder,
160
+ critic: pair.critic,
161
+ criticEventId: critic.eventId,
162
+ criticModel: critic.model,
163
+ finderEventId: finder?.eventId ?? null,
164
+ finderModel: finder?.model ?? null,
165
+ };
166
+ if (!finder) {
167
+ return {
168
+ ...base,
169
+ status: 'unverifiable',
170
+ detail: `${pair.critic} ran at ${critic.ts}${critic.taskId ? ` on ${critic.taskId}` : ''} with no preceding ${pair.finder} dispatch to compare against.`,
171
+ };
172
+ }
173
+ if (!critic.model || !finder.model) {
174
+ const missing = !critic.model ? pair.critic : pair.finder;
175
+ return {
176
+ ...base,
177
+ status: 'unverifiable',
178
+ detail: `The ${missing} dispatch records no model, so finder_ne_critic cannot be evaluated.`,
179
+ };
180
+ }
181
+ if (critic.model === finder.model) {
182
+ return {
183
+ ...base,
184
+ status: 'violation',
185
+ detail: `${pair.critic} ran on ${critic.model}, the same model as the ${pair.finder} dispatch it followed.`,
186
+ };
187
+ }
188
+ return {
189
+ ...base,
190
+ status: 'ok',
191
+ detail: `${pair.critic} ran on ${critic.model}; ${pair.finder} ran on ${finder.model}.`,
192
+ };
193
+ }
194
+ /**
195
+ * Assert crosscheck.yml's finder_ne_critic against what the log actually
196
+ * records. Every critic dispatch is checked, not just the last one: a session
197
+ * that got it wrong once and right afterwards did get it wrong once.
198
+ */
199
+ export function checkDispatchAsymmetry(events, pairs, options) {
200
+ const taskId = options.taskId ?? null;
201
+ const all = readDispatchRecords(events);
202
+ // D-181: `--task` is answered across both spellings of the id. The log
203
+ // writes one task both qualified and bare, so a raw `===` here answered the
204
+ // operator with the subset that happened to be spelled their way — and a
205
+ // dropped critic dispatch reads as a critic that never ran.
206
+ const inScope = (scope) => (r) => r.taskId !== null && taskIdsMatch(r.taskId, scope);
207
+ const records = taskId ? all.filter(inScope(taskId)) : all;
208
+ const allWork = readCriticWorkRecords(events);
209
+ const criticWork = taskId ? allWork.filter(inScope(taskId)) : allWork;
210
+ // A policy that declares no pairs cannot be satisfied OR violated, and
211
+ // reporting that as a pass would advertise an audit nobody configured.
212
+ if (pairs.length === 0) {
213
+ return {
214
+ sessionId: options.sessionId,
215
+ taskId,
216
+ dispatchesExamined: records.length,
217
+ criticWorkExamined: criticWork.length,
218
+ checks: [
219
+ {
220
+ finder: '*',
221
+ critic: '*',
222
+ status: 'unverifiable',
223
+ criticEventId: null,
224
+ criticModel: null,
225
+ finderEventId: null,
226
+ finderModel: null,
227
+ detail: 'crosscheck.yml declares no asymmetric_roles.pairs, so finder_ne_critic asserts nothing.',
228
+ },
229
+ ],
230
+ ok: false,
231
+ };
232
+ }
233
+ const checks = [];
234
+ for (const pair of pairs) {
235
+ const critics = records.filter((r) => r.role === pair.critic);
236
+ const work = criticWork.filter((r) => r.role === pair.critic);
237
+ if (critics.length === 0 && work.length === 0) {
238
+ checks.push({
239
+ finder: pair.finder,
240
+ critic: pair.critic,
241
+ status: 'not-applicable',
242
+ criticEventId: null,
243
+ criticModel: null,
244
+ finderEventId: null,
245
+ finderModel: null,
246
+ detail: `No ${pair.critic} dispatch in scope; the pair had nothing to check.`,
247
+ });
248
+ continue;
249
+ }
250
+ const finders = records.filter((r) => r.role === pair.finder);
251
+ for (const critic of critics) {
252
+ checks.push(checkOneCritic(pair, critic, finders));
253
+ }
254
+ for (const record of uncoveredCriticWork(work, critics)) {
255
+ checks.push({
256
+ finder: pair.finder,
257
+ critic: pair.critic,
258
+ status: 'unverifiable',
259
+ criticEventId: record.eventId,
260
+ criticModel: null,
261
+ finderEventId: null,
262
+ finderModel: null,
263
+ detail: `${pair.critic} recorded a ${record.label} at ${record.ts} with no ${pair.critic} dispatch behind it, so no model is on record and finder_ne_critic cannot be evaluated.`,
264
+ });
265
+ }
266
+ }
267
+ return {
268
+ sessionId: options.sessionId,
269
+ taskId,
270
+ dispatchesExamined: records.length,
271
+ criticWorkExamined: criticWork.length,
272
+ checks,
273
+ ok: checks.every((c) => c.status === 'ok' || c.status === 'not-applicable'),
274
+ };
275
+ }
@@ -0,0 +1,72 @@
1
+ // The join between where the runbook tells an operator to put a key and where
2
+ // the code looks for one.
3
+ //
4
+ // `docs/runbooks/providers.md` says: set `DEEPSEEK_API_KEY` in `.env`
5
+ // (gitignored, per `docs/standards/guardrails.md`). `preconditions.ts` reads
6
+ // `process.env` and nothing else. Both were true for months and nothing joined
7
+ // them, so a key placed exactly where the repo asked for it resolved
8
+ // `enabled: auto` to off and `smith judge preflight` reported it unset. This
9
+ // module is that join and nothing more.
10
+ //
11
+ // Two decisions worth stating, because both are the kind that get "fixed"
12
+ // later by someone who did not know they were chosen:
13
+ //
14
+ // **A file never beats the runner.** CI and the daemon export secrets into the
15
+ // environment; `.env` is a convenience for a developer's box. A convenience
16
+ // that silently replaces the real thing is how a run spends a stale key and
17
+ // nobody can see why. So an already-set name is left exactly as it is, and is
18
+ // not reported as loaded.
19
+ //
20
+ // **Not `node --env-file`.** Node can do this at the flag, but the binary is
21
+ // reached by more than one road -- `smith`, `node dist/cli.js`, a daemon
22
+ // spawn, the UI server importing a module -- and a flag on one road covers one
23
+ // road. Doing it in-process at the entry covers all of them.
24
+ //
25
+ // Nothing here ever returns, throws, or logs a value: the return is the list
26
+ // of names, which is the same discipline `smith judge preflight` already
27
+ // keeps.
28
+ import { readFileSync } from 'node:fs';
29
+ /** `KEY=value`, with an optional `export` and space either side of the `=`. */
30
+ const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
31
+ /** Strip one layer of matching quotes, and only a matching pair. */
32
+ function unquote(raw) {
33
+ const value = raw.trim();
34
+ const first = value.slice(0, 1);
35
+ const quoted = value.length >= 2 && (first === '"' || first === "'") && value.endsWith(first);
36
+ // Inside quotes every character is the operator's: a `#` is part of the key,
37
+ // not the start of a comment, and trailing space was typed on purpose.
38
+ return quoted ? value.slice(1, -1) : value;
39
+ }
40
+ /**
41
+ * Read `file` into `env`, setting only names it does not already hold.
42
+ *
43
+ * Returns the names it set -- never the values, and never anything about the
44
+ * names it skipped. A missing or unreadable file is nothing to do, not an
45
+ * error: most boxes have no `.env` and the factory runs fine on them.
46
+ */
47
+ export function loadDotEnv(file, env = process.env) {
48
+ let contents;
49
+ try {
50
+ contents = readFileSync(file, 'utf8');
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ const named = [];
56
+ for (const line of contents.split('\n')) {
57
+ if (/^\s*(?:#|$)/.test(line))
58
+ continue;
59
+ const match = ASSIGNMENT.exec(line);
60
+ if (match === null)
61
+ continue;
62
+ const name = match[1];
63
+ const raw = match[2];
64
+ if (name === undefined || raw === undefined)
65
+ continue;
66
+ if (env[name] !== undefined)
67
+ continue;
68
+ env[name] = unquote(raw);
69
+ named.push(name);
70
+ }
71
+ return named;
72
+ }
@@ -0,0 +1,202 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parse as parseYaml } from 'yaml';
3
+ import { SmithError } from './errors.js';
4
+ import { EFFORT_POLICY_PATH } from './paths.js';
5
+ import { EFFORT_TIERS, isEffortTier } from './plan.js';
6
+ import { evaluatePlanSecurityTriggers } from './planQuorum.js';
7
+ /**
8
+ * How much judgment an epic buys — factory/policies/effort.yml's host.
9
+ *
10
+ * An internal tool and a payments rewrite were running the same sixteen-step
11
+ * loop, because the loop was the only one there was. Making a small epic cheap
12
+ * meant skipping steps by hand and remembering which ones, which is the
13
+ * "recall, do not ask" failure sensitive-paths.yml and crosscheck.yml were
14
+ * each written to end. This module ends it for step count: the tiers are
15
+ * declared in the policy file, the plan file names one, and
16
+ * `smith effort show` answers so the `/bs` playbooks never have to
17
+ * decide twice.
18
+ *
19
+ * Two invariants make the feature safe to ship:
20
+ *
21
+ * 1. **`huge` is today's flow, unchanged.** Nothing about black-smith's own
22
+ * behaviour moves until an epic is assigned a lower tier — effort.test.ts
23
+ * pins every `huge` knob to what the `/bs` playbooks already write, so a
24
+ * future tune of `huge` has to be a deliberate edit to a red test rather
25
+ * than a quiet regression for every epic that never asked for a tier.
26
+ * 2. **A tier scales judgment steps only.** Never a mechanical oracle, never a
27
+ * guardrail: gates, coverage, claim disjointness, worktree isolation, the
28
+ * security-reviewer's own dispatch triggers, operator sign-off, and the
29
+ * event log run identically at every tier. The policy file's `invariants:`
30
+ * list says so in prose and `resolveEffort` returns it verbatim beside
31
+ * every profile, so a caller reading the cheap answer reads the limit of it
32
+ * in the same breath. A `small` tier that skipped tests would not be cheap,
33
+ * it would be unverified.
34
+ *
35
+ * The tier is per EPIC, not per project (the operator's call): it is chosen at
36
+ * `/bs plan` time and stored on the plan file, so one project runs a `small`
37
+ * epic and a `huge` one without re-configuring anything.
38
+ */
39
+ export class EffortError extends SmithError {
40
+ }
41
+ /**
42
+ * The tier names, ordered cheapest-first — the index is the rank the security
43
+ * floor compares on. Declared in plan.ts (beside the `PlanFile.effort` field
44
+ * that carries them, so `validatePlan` can check one without importing this
45
+ * module) and re-exported here, which is where callers should read it from:
46
+ * this file is what gives the names meaning.
47
+ */
48
+ export { EFFORT_TIERS, isEffortTier } from './plan.js';
49
+ /** Cheapest = 0. Only ever compared, never persisted — the names are the contract. */
50
+ function tierRank(tier) {
51
+ return EFFORT_TIERS.indexOf(tier);
52
+ }
53
+ const PRE_CODE_RESEARCH = ['when-needed', 'never'];
54
+ const PRE_CODE_UIUX = ['always', 'when-ui-criterion', 'never'];
55
+ const SPEC_REVIEW_ROUNDS = ['until-clean', 'single-pass'];
56
+ const PLAN_QUORUM = ['always', 'when-triggered'];
57
+ const CLOSING_SPEC_REVIEW = ['always', 'when-plan-amended'];
58
+ // --- parsing ---------------------------------------------------------------
59
+ // Every reader below rejects rather than defaults. A policy file that half
60
+ // parses is worse than one that fails: the missing half becomes an unstated
61
+ // tier assignment, which is exactly the thing this module exists to end.
62
+ function requireNode(node, what) {
63
+ if (typeof node !== 'object' || node === null || Array.isArray(node)) {
64
+ throw new EffortError('effort.policy-invalid', `effort.yml: ${what} must be a mapping.`);
65
+ }
66
+ return node;
67
+ }
68
+ function requireEnum(value, allowed, where) {
69
+ if (typeof value !== 'string' || !allowed.includes(value)) {
70
+ throw new EffortError('effort.policy-invalid', `effort.yml: ${where} is ${JSON.stringify(value)}; allowed: ${allowed.join(', ')}.`, { where, value, allowed: [...allowed] });
71
+ }
72
+ return value;
73
+ }
74
+ function requireNumber(value, where, min, max) {
75
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
76
+ throw new EffortError('effort.policy-invalid', `effort.yml: ${where} is ${JSON.stringify(value)}; expected a number in [${min}, ${max}].`, { where, value });
77
+ }
78
+ return value;
79
+ }
80
+ function requireStringList(value, where, minLength) {
81
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string' || v.trim() === '')) {
82
+ throw new EffortError('effort.policy-invalid', `effort.yml: ${where} must be a list of non-empty strings.`, { where });
83
+ }
84
+ if (value.length < minLength) {
85
+ throw new EffortError('effort.policy-invalid', `effort.yml: ${where} needs at least ${minLength} entr${minLength === 1 ? 'y' : 'ies'}.`, { where });
86
+ }
87
+ return value.map(String);
88
+ }
89
+ function parseProfile(node, tier) {
90
+ const t = requireNode(node, `tiers.${tier}`);
91
+ const summary = typeof t.summary === 'string' ? t.summary.trim() : '';
92
+ const graderRounds = requireNumber(t.grader_rounds, `tiers.${tier}.grader_rounds`, 1, 2);
93
+ if (!Number.isInteger(graderRounds)) {
94
+ throw new EffortError('effort.policy-invalid', `effort.yml: tiers.${tier}.grader_rounds must be a whole number of rounds.`);
95
+ }
96
+ const verifierSeverities = requireStringList(t.verifier_severities, `tiers.${tier}.verifier_severities`, 1);
97
+ // S1 is not a tier's to drop: a stop-the-line finding reaching the coder
98
+ // unrefuted is the round trip the adversarial pass exists to prevent, and it
99
+ // costs the same at every scale.
100
+ if (!verifierSeverities.includes('S1-stop-the-line')) {
101
+ throw new EffortError('effort.policy-invalid', `effort.yml: tiers.${tier}.verifier_severities must keep "S1-stop-the-line" — no tier may leave a stop-the-line finding unverified.`, { tier });
102
+ }
103
+ return {
104
+ summary,
105
+ preCodeResearch: requireEnum(t.pre_code_research, PRE_CODE_RESEARCH, `tiers.${tier}.pre_code_research`),
106
+ preCodeUiux: requireEnum(t.pre_code_uiux, PRE_CODE_UIUX, `tiers.${tier}.pre_code_uiux`),
107
+ specReviewRounds: requireEnum(t.spec_review_rounds, SPEC_REVIEW_ROUNDS, `tiers.${tier}.spec_review_rounds`),
108
+ planQuorum: requireEnum(t.plan_quorum, PLAN_QUORUM, `tiers.${tier}.plan_quorum`),
109
+ graderRounds,
110
+ verifierSeverities,
111
+ verifierS3SpotCheckRatio: requireNumber(t.verifier_s3_spot_check_ratio, `tiers.${tier}.verifier_s3_spot_check_ratio`, 0, 1),
112
+ closingSpecReview: requireEnum(t.closing_spec_review, CLOSING_SPEC_REVIEW, `tiers.${tier}.closing_spec_review`),
113
+ };
114
+ }
115
+ export function parseEffortPolicy(yamlText) {
116
+ const doc = requireNode(parseYaml(yamlText), 'the document');
117
+ const tiersNode = requireNode(doc.tiers, 'tiers');
118
+ // Presence of the whole table first, then each profile: an operator who
119
+ // deleted two tiers should be told that, not handed the first missing knob
120
+ // of the one tier they kept.
121
+ const missing = EFFORT_TIERS.filter((tier) => !(tier in tiersNode));
122
+ if (missing.length > 0) {
123
+ throw new EffortError('effort.policy-invalid', `effort.yml: tiers is missing ${missing.map((t) => `"${t}"`).join(', ')}. All of ${EFFORT_TIERS.join(', ')} must be declared — a missing tier is a silent default.`, { missing });
124
+ }
125
+ const tiers = {};
126
+ for (const tier of EFFORT_TIERS) {
127
+ tiers[tier] = parseProfile(tiersNode[tier], tier);
128
+ }
129
+ return {
130
+ version: typeof doc.version === 'number' ? doc.version : 1,
131
+ defaultTier: requireEnum(doc.default_tier, EFFORT_TIERS, 'default_tier'),
132
+ securityFloor: requireEnum(doc.security_floor, EFFORT_TIERS, 'security_floor'),
133
+ tiers,
134
+ invariants: requireStringList(doc.invariants, 'invariants', 1),
135
+ };
136
+ }
137
+ export function loadEffortPolicy(filePath = EFFORT_POLICY_PATH) {
138
+ return parseEffortPolicy(readFileSync(filePath, 'utf8'));
139
+ }
140
+ /**
141
+ * Pure, no I/O: decide which tier an epic runs at and hand back the profile
142
+ * plus the evidence for the decision.
143
+ *
144
+ * The floor only ever raises. An operator who asks for `huge` on a
145
+ * security-sensitive epic gets `huge`; one who asks for `small` gets the
146
+ * floor, and `floorApplied` says so rather than leaving them to notice their
147
+ * request was overridden by comparing two tables. Security sensitivity is read
148
+ * from the plan's LIVE tasks (D-113/D-185), never `plan.tasks`: a superseded
149
+ * task is history, and an epic must not be held at a higher tier by an ask it
150
+ * has already withdrawn.
151
+ */
152
+ export function resolveEffort(policy, securityPolicy, opts = {}) {
153
+ // A typo'd --effort must not degrade into the default: that is the same
154
+ // silence validatePlan rejects a typo'd plan field for.
155
+ if (opts.override !== undefined && !isEffortTier(opts.override)) {
156
+ throw new EffortError('effort.unknown-tier', `Effort tier "${opts.override}" is not one of ${EFFORT_TIERS.join(', ')} (factory/policies/effort.yml).`, { tier: opts.override, allowed: [...EFFORT_TIERS] });
157
+ }
158
+ const fromPlan = isEffortTier(opts.plan?.effort) ? opts.plan.effort : null;
159
+ const requested = opts.override ?? fromPlan;
160
+ const requestedFrom = opts.override !== undefined ? 'flag' : fromPlan !== null ? 'plan' : 'default';
161
+ const base = requested ?? policy.defaultTier;
162
+ const securityFloorEvaluated = opts.plan !== undefined;
163
+ const securityTriggers = opts.plan ? evaluatePlanSecurityTriggers(opts.plan, securityPolicy) : [];
164
+ const floorApplied = securityTriggers.length > 0 && tierRank(base) < tierRank(policy.securityFloor);
165
+ const effective = floorApplied ? policy.securityFloor : base;
166
+ return {
167
+ requested,
168
+ requestedFrom,
169
+ defaultTier: policy.defaultTier,
170
+ effective,
171
+ floorApplied,
172
+ securityFloor: policy.securityFloor,
173
+ securityFloorEvaluated,
174
+ securityTriggers,
175
+ reason: explain({
176
+ base,
177
+ effective,
178
+ floorApplied,
179
+ requestedFrom,
180
+ securityFloor: policy.securityFloor,
181
+ securityFloorEvaluated,
182
+ triggerCount: securityTriggers.length,
183
+ }),
184
+ profile: policy.tiers[effective],
185
+ invariants: policy.invariants,
186
+ };
187
+ }
188
+ function explain(facts) {
189
+ const source = facts.requestedFrom === 'default'
190
+ ? `nobody named a tier, so the policy default "${facts.base}" applies`
191
+ : `the ${facts.requestedFrom} asked for "${facts.base}"`;
192
+ if (!facts.securityFloorEvaluated) {
193
+ return `${source}; the security floor was not evaluated — no plan was supplied, so this is the tier's profile and not a decision about a specific epic.`;
194
+ }
195
+ if (facts.floorApplied) {
196
+ return `${source}; raised to "${facts.effective}" by the security floor — ${facts.triggerCount} security trigger(s) fired on this plan's live tasks (crosscheck.yml plan_quorum).`;
197
+ }
198
+ if (facts.triggerCount === 0) {
199
+ return `${source}; no security trigger fired on this plan's live tasks, so the floor changed nothing.`;
200
+ }
201
+ return `${source}; ${facts.triggerCount} security trigger(s) fired, but "${facts.base}" is already at or above the "${facts.securityFloor}" floor.`;
202
+ }