@juzser/blacksmith 0.4.0 → 0.5.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 (131) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +5 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +4 -4
  4. package/.claude/agents/{merger.md → bs-merger.md} +4 -4
  5. package/.claude/agents/{planner.md → bs-planner.md} +20 -6
  6. package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
  7. package/.claude/agents/{reviewer.md → bs-reviewer.md} +13 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -10
  11. package/.claude/agents/{tester.md → bs-tester.md} +40 -12
  12. package/.claude/agents/bs-uiux.md +257 -0
  13. package/.claude/agents/{verifier.md → bs-verifier.md} +4 -4
  14. package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
  15. package/.claude/skills/bs/SKILL.md +99 -21
  16. package/.claude/skills/bs/audit.md +34 -27
  17. package/.claude/skills/bs/dispatch.md +100 -50
  18. package/.claude/skills/bs/lessons.md +28 -14
  19. package/.claude/skills/bs/mcp.md +4 -4
  20. package/.claude/skills/bs/new.md +3 -3
  21. package/.claude/skills/bs/plan.md +27 -18
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +181 -49
  24. package/.claude/skills/bs/status.md +14 -7
  25. package/.claude/skills/bs/ui.md +21 -5
  26. package/.claude/skills/bs/waivers.md +78 -2
  27. package/.claude/skills/bs/wave.md +109 -53
  28. package/README.md +53 -32
  29. package/factory/orchestrator/dist/agentNames.js +43 -0
  30. package/factory/orchestrator/dist/agents-registry.js +11 -4
  31. package/factory/orchestrator/dist/agentsSync.js +48 -14
  32. package/factory/orchestrator/dist/artifacts.js +31 -0
  33. package/factory/orchestrator/dist/attribution.js +4 -0
  34. package/factory/orchestrator/dist/audit.js +125 -17
  35. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  36. package/factory/orchestrator/dist/budgets.js +302 -84
  37. package/factory/orchestrator/dist/claims.js +9 -19
  38. package/factory/orchestrator/dist/cli.js +790 -100
  39. package/factory/orchestrator/dist/cliName.js +27 -0
  40. package/factory/orchestrator/dist/crosscheck.js +136 -8
  41. package/factory/orchestrator/dist/daemon.js +154 -36
  42. package/factory/orchestrator/dist/db/projector.js +278 -29
  43. package/factory/orchestrator/dist/db/queries.js +1831 -94
  44. package/factory/orchestrator/dist/db/schema.js +57 -4
  45. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  46. package/factory/orchestrator/dist/delegation.js +86 -19
  47. package/factory/orchestrator/dist/diffstat.js +15 -8
  48. package/factory/orchestrator/dist/dispatchLint.js +13 -37
  49. package/factory/orchestrator/dist/effort.js +14 -2
  50. package/factory/orchestrator/dist/effortTiers.js +13 -0
  51. package/factory/orchestrator/dist/env.js +26 -0
  52. package/factory/orchestrator/dist/epic.js +323 -38
  53. package/factory/orchestrator/dist/epicBudget.js +93 -0
  54. package/factory/orchestrator/dist/epicWidth.js +22 -5
  55. package/factory/orchestrator/dist/errorIssues.js +10 -4
  56. package/factory/orchestrator/dist/escalation.js +2 -2
  57. package/factory/orchestrator/dist/events.js +105 -3
  58. package/factory/orchestrator/dist/feedback.js +169 -0
  59. package/factory/orchestrator/dist/findingContext.js +26 -0
  60. package/factory/orchestrator/dist/findings.js +83 -12
  61. package/factory/orchestrator/dist/gate.js +120 -10
  62. package/factory/orchestrator/dist/goalCheck.js +2 -2
  63. package/factory/orchestrator/dist/harness.js +25 -17
  64. package/factory/orchestrator/dist/hookDecision.js +92 -3
  65. package/factory/orchestrator/dist/integration.js +9 -7
  66. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  67. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  68. package/factory/orchestrator/dist/judgeStopHook.js +90 -32
  69. package/factory/orchestrator/dist/judges.js +419 -21
  70. package/factory/orchestrator/dist/lessons.js +190 -11
  71. package/factory/orchestrator/dist/mcp.js +6 -6
  72. package/factory/orchestrator/dist/paths.js +10 -7
  73. package/factory/orchestrator/dist/plan.js +269 -33
  74. package/factory/orchestrator/dist/planQuorum.js +5 -2
  75. package/factory/orchestrator/dist/policy.js +1275 -30
  76. package/factory/orchestrator/dist/projectName.js +25 -0
  77. package/factory/orchestrator/dist/promptCapture.js +306 -0
  78. package/factory/orchestrator/dist/promptHook.js +33 -0
  79. package/factory/orchestrator/dist/promptImport.js +281 -0
  80. package/factory/orchestrator/dist/prompts.js +13 -0
  81. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  82. package/factory/orchestrator/dist/providers/types.js +1 -1
  83. package/factory/orchestrator/dist/queue.js +4 -4
  84. package/factory/orchestrator/dist/quorum.js +18 -5
  85. package/factory/orchestrator/dist/results.js +25 -7
  86. package/factory/orchestrator/dist/roadmap.js +10 -5
  87. package/factory/orchestrator/dist/run-cli.js +15 -10
  88. package/factory/orchestrator/dist/runner.js +2 -2
  89. package/factory/orchestrator/dist/schemas.js +56 -8
  90. package/factory/orchestrator/dist/severity.js +8 -4
  91. package/factory/orchestrator/dist/spec.js +177 -13
  92. package/factory/orchestrator/dist/specChange.js +13 -9
  93. package/factory/orchestrator/dist/stack.js +6 -6
  94. package/factory/orchestrator/dist/storeRoot.js +90 -0
  95. package/factory/orchestrator/dist/symbols.js +261 -6
  96. package/factory/orchestrator/dist/taskEvents.js +179 -27
  97. package/factory/orchestrator/dist/taskStatus.js +14 -0
  98. package/factory/orchestrator/dist/testerAudit.js +31 -12
  99. package/factory/orchestrator/dist/testgate.js +16 -13
  100. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  101. package/factory/orchestrator/dist/usage.js +97 -40
  102. package/factory/orchestrator/dist/waivers.js +39 -5
  103. package/factory/orchestrator/dist/waveBudget.js +2 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  105. package/factory/orchestrator/dist/workroot.js +1 -1
  106. package/factory/orchestrator/dist/worktree.js +61 -19
  107. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  108. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  109. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  110. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  111. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  112. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  113. package/factory/policies/budgets.yml +123 -33
  114. package/factory/policies/crosscheck.yml +51 -13
  115. package/factory/policies/delegation.yml +3 -3
  116. package/factory/policies/effort.yml +18 -10
  117. package/factory/policies/guardrails.yml +8 -6
  118. package/factory/policies/harness.yml +5 -5
  119. package/factory/policies/lessons.md +53 -2
  120. package/factory/policies/scheduler.yml +4 -4
  121. package/factory/policies/sensitive-paths.yml +1 -1
  122. package/factory/policies/severity.yml +13 -1
  123. package/factory/policies/stack.yml +6 -6
  124. package/factory/policies/taxonomy.yml +9 -3
  125. package/factory/policies/worktree.yml +15 -9
  126. package/factory/specs/roadmap.md +7 -7
  127. package/factory/specs/schema/event.schema.json +8 -1
  128. package/factory/specs/schema/result.schema.json +21 -8
  129. package/factory/specs/schema/task-spec.schema.json +12 -0
  130. package/package.json +16 -12
  131. package/.claude/agents/uiux.md +0 -133
@@ -10,34 +10,122 @@
10
10
  // against the event log by src/escalation.ts. `context_window` and
11
11
  // `effort_scaling` are still prompt-level: agents read them from their own
12
12
  // templates, and nothing here parses them.
13
+ //
14
+ // Every number is sized per effort tier (effort.yml's small / medium / huge,
15
+ // the tier being the epic plan's `effort`). budgets.yml declares the epic cap
16
+ // as an explicit triplet and each task cap once, at medium, with one
17
+ // `task_tier_scale` multiplying them for the other tiers. A policy is loaded
18
+ // FOR a tier: parseBudgetPolicy / loadBudgetPolicy take it, and a caller with
19
+ // no plan in hand gets effort.yml's `default_tier`.
13
20
  import { readFileSync } from 'node:fs';
14
21
  import { parse as parseYaml } from 'yaml';
22
+ import { EFFORT_TIERS, isEffortTier } from './effortTiers.js';
23
+ import { readEnv } from './env.js';
15
24
  import { SmithError } from './errors.js';
16
- import { BUDGETS_POLICY_PATH } from './paths.js';
25
+ import { BUDGETS_POLICY_PATH, EFFORT_POLICY_PATH } from './paths.js';
17
26
  export class BudgetError extends SmithError {
18
27
  }
19
28
  /**
20
- * Deliberately NOT the number budgets.yml declares (4,000,000 since
21
- * 2026-08-11) — the one default here that does not mirror the policy file.
29
+ * The roles budgets.yml prices, one cap each. Every other role the factory
30
+ * dispatches (merger, uiux, scribe, wave-runner) has no cap, and
31
+ * src/budgetAlarm.ts reports a dispatch of one as a hole rather than as free.
32
+ */
33
+ export const PRICED_ROLES = [
34
+ 'coder',
35
+ 'tester',
36
+ 'planner',
37
+ 'researcher',
38
+ 'spec-reviewer',
39
+ 'grader',
40
+ 'reviewer',
41
+ 'verifier',
42
+ 'security-reviewer',
43
+ 'auditor',
44
+ ];
45
+ export function isPricedRole(role) {
46
+ return PRICED_ROLES.includes(role);
47
+ }
48
+ /**
49
+ * Deliberately NOT the number budgets.yml declares (a 4M / 16M / 32M triplet
50
+ * since 2026-09-29) — the one default here that does not mirror the policy
51
+ * file, and the same number at every tier.
22
52
  *
23
- * The other DEFAULT_* constants below match budgets.yml because nobody has
24
- * ever had cause to diverge them. This one has cause: the repo's cap was
25
- * raised by an operator decision sized from a single dogfood epic that
26
- * measured 1,529,963 tokens for two of four tasks, a figure inflated by
27
- * rework `smith escalation check` recorded as ladder violations. That is a
28
- * judgement about this repo's epics, not a floor to hand a project that
29
- * shipped no policy at all — such a project gets the conservative number and
30
- * an alarm that fires early, which is the failure direction to prefer.
53
+ * The other DEFAULT_* numbers below match budgets.yml's medium tier because
54
+ * nobody has ever had cause to diverge them. This one has cause: the repo's
55
+ * cap was raised by operator decisions sized from this repo's own dogfood
56
+ * epics. That is a judgement about this repo's epics, not a floor to hand a
57
+ * project that shipped no policy at all — such a project gets the
58
+ * conservative number and an alarm that fires early, which is the failure
59
+ * direction to prefer.
31
60
  *
32
61
  * Do not "fix" the mismatch by syncing them.
33
62
  */
34
63
  const DEFAULT_EPIC_CAP_TOKENS = 2_000_000;
35
64
  const DEFAULT_EPIC_ALARM_RATIO = 0.7;
36
- const DEFAULT_CODER_CAP_TOKENS = 150_000;
37
- const DEFAULT_CODER_CAP_DIFF_LINES = 400;
38
- const DEFAULT_RESEARCHER_CAP_TOKENS = 60_000;
39
- const DEFAULT_JUDGES_CAP_TOKENS = 40_000;
65
+ const DEFAULT_CODER_CAP_DIFF_LINES = 700;
66
+ /** budgets.yml's medium-tier task caps. */
67
+ const DEFAULT_TASK_CAP_TOKENS = Object.freeze({
68
+ coder: 220_000,
69
+ tester: 60_000,
70
+ planner: 110_000,
71
+ researcher: 80_000,
72
+ 'spec-reviewer': 170_000,
73
+ grader: 110_000,
74
+ reviewer: 60_000,
75
+ verifier: 40_000,
76
+ 'security-reviewer': 80_000,
77
+ auditor: 90_000,
78
+ });
79
+ const DEFAULT_TASK_TIER_SCALE = Object.freeze({
80
+ small: 0.5,
81
+ medium: 1,
82
+ huge: 2,
83
+ });
40
84
  const DEFAULT_PRE_CODE_SHARE_MAX = 0.15;
85
+ /**
86
+ * The four judges a pre-2026-09-29 budgets.yml priced together under
87
+ * `task.judges`. Still read, below a role the file names itself, so an older
88
+ * policy file keeps its numbers.
89
+ */
90
+ const LEGACY_JUDGE_BUCKET_ROLES = [
91
+ 'spec-reviewer',
92
+ 'reviewer',
93
+ 'verifier',
94
+ 'grader',
95
+ ];
96
+ /**
97
+ * effort.yml's `default_tier`, read directly rather than through effort.ts:
98
+ * effort.ts imports plan.ts, and plan.ts imports this module. Read on every
99
+ * call, not cached: the file is small, and a process that outlives an edit to
100
+ * it (the daemon) must not keep budgeting at the tier it read first. A file
101
+ * that cannot be read, or whose `default_tier` is missing or names no tier, is
102
+ * refused the way effort.ts refuses it — a typo there must not quietly size
103
+ * every untiered epic at `medium`.
104
+ */
105
+ function defaultBudgetTier(effortPolicyFile = EFFORT_POLICY_PATH) {
106
+ let doc;
107
+ try {
108
+ doc = parseYaml(readFileSync(effortPolicyFile, 'utf8'));
109
+ }
110
+ catch (error) {
111
+ throw new BudgetError('budgets.invalid-policy', `Cannot read effort.yml's default_tier from ${effortPolicyFile}: ${error.message}. A budget with no tier named is sized at that tier, so there is no budget without it.`, { file: effortPolicyFile });
112
+ }
113
+ const tier = doc?.default_tier;
114
+ if (!isEffortTier(tier)) {
115
+ throw new BudgetError('budgets.invalid-policy', `effort.yml default_tier must be one of ${EFFORT_TIERS.join(', ')}; got ${JSON.stringify(tier ?? null)} in ${effortPolicyFile}.`, { file: effortPolicyFile, field: 'default_tier', value: tier ?? null });
116
+ }
117
+ return tier;
118
+ }
119
+ /**
120
+ * The tier a budget is sized for: the given one when it names an effort tier,
121
+ * otherwise effort.yml's `default_tier`. An absent or unknown value is "no
122
+ * tier chosen", never an error — plan validation owns refusing a bad
123
+ * `effort`. Callers holding a plan pass epicBudget.ts `budgetTierForPlan`,
124
+ * which applies the security floor; a plan's raw `effort` is not its tier.
125
+ */
126
+ export function resolveBudgetTier(tier, effortPolicyFile) {
127
+ return isEffortTier(tier) ? tier : defaultBudgetTier(effortPolicyFile);
128
+ }
41
129
  function optionalString(value) {
42
130
  return typeof value === 'string' && value.length > 0 ? value : null;
43
131
  }
@@ -118,48 +206,154 @@ function capNumberOrNull(field, value) {
118
206
  }
119
207
  return value;
120
208
  }
121
- export function parseBudgetPolicy(yamlText) {
209
+ /**
210
+ * `epic.cap_tokens` for `tier`. A scalar is one cap for every tier (the shape
211
+ * before 2026-09-29, still accepted); a mapping must name all three tiers —
212
+ * a triplet missing one is refused whichever tier is being read, because the
213
+ * missing tier's epics would otherwise fall to a default nobody chose, and so
214
+ * is a key that names no tier (`hughe:`), which would leave its number unread.
215
+ */
216
+ function epicCapFor(raw, tier) {
217
+ if (raw === undefined || raw === null)
218
+ return DEFAULT_EPIC_CAP_TOKENS;
219
+ if (typeof raw !== 'object' || Array.isArray(raw))
220
+ return capNumber('epic.cap_tokens', raw);
221
+ const byTier = raw;
222
+ const unknown = Object.keys(byTier).filter((key) => !isEffortTier(key));
223
+ if (unknown.length > 0) {
224
+ throw new BudgetError('budgets.invalid-policy', `budgets.yml epic.cap_tokens names ${unknown.map((k) => `"${k}"`).join(', ')}, which ${unknown.length === 1 ? 'is not an effort tier' : 'are not effort tiers'}; the keys are ${EFFORT_TIERS.join(', ')}.`, { field: 'epic.cap_tokens', unknown, allowed: [...EFFORT_TIERS] });
225
+ }
226
+ const caps = Object.fromEntries(EFFORT_TIERS.map((t) => [t, capNumber(`epic.cap_tokens.${t}`, byTier[t])]));
227
+ return caps[tier];
228
+ }
229
+ function tierScale(raw, tier) {
230
+ const scales = Object.fromEntries(EFFORT_TIERS.map((t) => [
231
+ t,
232
+ capNumber(`task_tier_scale.${t}`, raw?.[t] ?? DEFAULT_TASK_TIER_SCALE[t]),
233
+ ]));
234
+ return scales[tier];
235
+ }
236
+ /**
237
+ * budgets.yml sized for `tier` (plan `effort`; absent or unknown reads as
238
+ * effort.yml's `default_tier`). Task caps are declared at medium and scaled by
239
+ * `task_tier_scale`; the epic cap is read per tier as declared.
240
+ */
241
+ export function parseBudgetPolicy(yamlText, tier) {
242
+ const resolved = resolveBudgetTier(tier);
122
243
  const doc = (parseYaml(yamlText) ?? {});
244
+ const scale = tierScale(doc.task_tier_scale, resolved);
245
+ const scaled = (n) => Math.round(n * scale);
246
+ const legacyJudges = doc.task?.judges?.cap_tokens === undefined
247
+ ? null
248
+ : capNumber('task.judges.cap_tokens', doc.task.judges.cap_tokens);
249
+ const medium = (role) => {
250
+ const declared = doc.task?.[role]?.cap_tokens;
251
+ if (declared !== undefined)
252
+ return capNumber(`task.${role}.cap_tokens`, declared);
253
+ if (legacyJudges !== null && LEGACY_JUDGE_BUCKET_ROLES.includes(role))
254
+ return legacyJudges;
255
+ return DEFAULT_TASK_CAP_TOKENS[role];
256
+ };
257
+ const task = Object.fromEntries(PRICED_ROLES.map((role) => [role, { capTokens: scaled(medium(role)) }]));
258
+ task.coder = {
259
+ capTokens: task.coder.capTokens,
260
+ capDiffLines: scaled(capNumber('task.coder.cap_diff_lines', doc.task?.coder?.cap_diff_lines ?? DEFAULT_CODER_CAP_DIFF_LINES)),
261
+ };
123
262
  return {
263
+ tier: resolved,
124
264
  epic: {
125
- capTokens: capNumber('epic.cap_tokens', doc.epic?.cap_tokens ?? DEFAULT_EPIC_CAP_TOKENS),
265
+ capTokens: epicCapFor(doc.epic?.cap_tokens, resolved),
126
266
  alarmRatio: capNumber('epic.alarm_ratio', doc.epic?.alarm_ratio ?? DEFAULT_EPIC_ALARM_RATIO),
127
267
  maxInFlightTasks: capNumberOrNull('epic.max_in_flight_tasks', doc.epic?.max_in_flight_tasks ?? null),
128
268
  },
129
- task: {
130
- coder: {
131
- capTokens: capNumber('task.coder.cap_tokens', doc.task?.coder?.cap_tokens ?? DEFAULT_CODER_CAP_TOKENS),
132
- capDiffLines: capNumber('task.coder.cap_diff_lines', doc.task?.coder?.cap_diff_lines ?? DEFAULT_CODER_CAP_DIFF_LINES),
133
- },
134
- researcher: {
135
- capTokens: capNumber('task.researcher.cap_tokens', doc.task?.researcher?.cap_tokens ?? DEFAULT_RESEARCHER_CAP_TOKENS),
136
- },
137
- judges: {
138
- capTokens: capNumber('task.judges.cap_tokens', doc.task?.judges?.cap_tokens ?? DEFAULT_JUDGES_CAP_TOKENS),
139
- },
140
- },
269
+ task,
141
270
  preCodeBudget: {
142
271
  shareOfEpicBudgetMax: capNumber('pre_code_budget.share_of_epic_budget_max', doc.pre_code_budget?.share_of_epic_budget_max ?? DEFAULT_PRE_CODE_SHARE_MAX),
143
272
  },
144
273
  escalationLadder: parseEscalationLadder(doc.escalation_ladder?.rungs),
145
274
  };
146
275
  }
147
- const INT_OVERRIDES = [
148
- ['SMITH_EPIC_CAP_TOKENS', 'epicCap'],
149
- ['SMITH_EPIC_MAX_IN_FLIGHT_TASKS', 'maxInFlight'],
150
- ['SMITH_TASK_CODER_CAP_TOKENS', 'coderCap'],
151
- ['SMITH_TASK_CODER_CAP_DIFF_LINES', 'coderDiff'],
152
- ['SMITH_TASK_RESEARCHER_CAP_TOKENS', 'researcherCap'],
153
- ['SMITH_TASK_JUDGES_CAP_TOKENS', 'judgesCap'],
276
+ /**
277
+ * The declared per-dispatch cap for `role` in this policy's tier, or null when
278
+ * budgets.yml prices no such role.
279
+ */
280
+ export function roleCapTokens(policy, role) {
281
+ return isPricedRole(role) ? policy.task[role].capTokens : null;
282
+ }
283
+ function envNameForRole(role) {
284
+ return `SMITH_TASK_${role.toUpperCase().replace(/-/g, '_')}_CAP_TOKENS`;
285
+ }
286
+ const ENV_FIELDS = [
287
+ {
288
+ name: 'SMITH_EPIC_CAP_TOKENS',
289
+ kind: 'int',
290
+ read: (p) => p.epic.capTokens,
291
+ write: (p, v) => {
292
+ p.epic.capTokens = v;
293
+ },
294
+ },
295
+ {
296
+ name: 'SMITH_EPIC_ALARM_RATIO',
297
+ kind: 'ratio',
298
+ read: (p) => p.epic.alarmRatio,
299
+ write: (p, v) => {
300
+ p.epic.alarmRatio = v;
301
+ },
302
+ },
303
+ {
304
+ name: 'SMITH_EPIC_MAX_IN_FLIGHT_TASKS',
305
+ kind: 'int',
306
+ read: (p) => p.epic.maxInFlightTasks,
307
+ write: (p, v) => {
308
+ p.epic.maxInFlightTasks = v;
309
+ },
310
+ },
311
+ {
312
+ name: envNameForRole('coder'),
313
+ kind: 'int',
314
+ read: (p) => p.task.coder.capTokens,
315
+ write: (p, v) => {
316
+ p.task.coder.capTokens = v;
317
+ },
318
+ },
319
+ {
320
+ name: 'SMITH_TASK_CODER_CAP_DIFF_LINES',
321
+ kind: 'int',
322
+ read: (p) => p.task.coder.capDiffLines,
323
+ write: (p, v) => {
324
+ p.task.coder.capDiffLines = v;
325
+ },
326
+ },
327
+ ...PRICED_ROLES.filter((role) => role !== 'coder').map((role) => ({
328
+ name: envNameForRole(role),
329
+ kind: 'int',
330
+ read: (p) => p.task[role].capTokens,
331
+ write: (p, v) => {
332
+ p.task[role].capTokens = v;
333
+ },
334
+ })),
154
335
  ];
155
- const RATIO_OVERRIDE = 'SMITH_EPIC_ALARM_RATIO';
156
- export const BUDGET_ENV_VARS = Object.freeze([
157
- 'SMITH_EPIC_CAP_TOKENS',
158
- RATIO_OVERRIDE,
159
- ...INT_OVERRIDES.slice(1).map(([name]) => name),
160
- ]);
336
+ /**
337
+ * The pre-2026-09-29 name for the four judges' shared cap. Still honoured —
338
+ * below each judge's own name — so a `.env` copied from an older
339
+ * `.env.example` keeps working; no longer listed there.
340
+ */
341
+ const LEGACY_JUDGES_ENV = 'SMITH_TASK_JUDGES_CAP_TOKENS';
342
+ const TIER_SUFFIXES = EFFORT_TIERS.map((t) => `_${t.toUpperCase()}`);
343
+ function tierSuffix(tier) {
344
+ return `_${tier.toUpperCase()}`;
345
+ }
346
+ /** The documented budget env names, each one bare (the tier variants aside). */
347
+ export const BUDGET_ENV_VARS = Object.freeze(ENV_FIELDS.map((f) => f.name));
348
+ /** Names still read for compatibility but no longer documented. */
349
+ export const LEGACY_BUDGET_ENV_VARS = Object.freeze([LEGACY_JUDGES_ENV]);
350
+ /** Every name the overlay reads: bare, legacy, and each one's tier variants. */
351
+ export const ALL_BUDGET_ENV_NAMES = Object.freeze([...BUDGET_ENV_VARS, ...LEGACY_BUDGET_ENV_VARS].flatMap((name) => [
352
+ name,
353
+ ...TIER_SUFFIXES.map((suffix) => `${name}${suffix}`),
354
+ ]));
161
355
  function envValue(env, name) {
162
- const value = env[name];
356
+ const value = readEnv(env, name);
163
357
  return value === undefined || value === '' ? null : value;
164
358
  }
165
359
  /**
@@ -182,59 +376,83 @@ function envRatio(name, raw) {
182
376
  }
183
377
  return value;
184
378
  }
379
+ function parseEnv(name, kind, raw) {
380
+ return kind === 'ratio' ? envRatio(name, raw) : envPositiveInt(name, raw);
381
+ }
185
382
  /**
186
- * `policy` with every set budget env name applied on top. Pure: it reads only
187
- * `env` and returns a new policy. Every name is validated before any is
188
- * applied, so a bad value refuses the whole overlay rather than half of it.
383
+ * The name that supplies `field` for `tier`, or null: the tier variant, else
384
+ * the bare name, else (for the four old bucket judges) the legacy name with
385
+ * the same two-step precedence.
189
386
  */
190
- export function applyBudgetEnv(policy, env) {
191
- const ints = new Map();
192
- for (const [name, field] of INT_OVERRIDES) {
387
+ function supplierFor(field, tier, env) {
388
+ const candidates = [`${field.name}${tierSuffix(tier)}`, field.name];
389
+ const role = PRICED_ROLES.find((r) => envNameForRole(r) === field.name);
390
+ if (role !== undefined && LEGACY_JUDGE_BUCKET_ROLES.includes(role)) {
391
+ candidates.push(`${LEGACY_JUDGES_ENV}${tierSuffix(tier)}`, LEGACY_JUDGES_ENV);
392
+ }
393
+ return candidates.find((name) => envValue(env, name) !== null) ?? null;
394
+ }
395
+ /**
396
+ * Every set name is validated — every tier's variant, not only this policy's
397
+ * — before any is applied, so a bad value refuses the whole overlay rather
398
+ * than half of it, and a typo in a tier not in play today is caught today.
399
+ */
400
+ function validateBudgetEnv(env) {
401
+ for (const field of ENV_FIELDS) {
402
+ for (const name of [field.name, ...TIER_SUFFIXES.map((s) => `${field.name}${s}`)]) {
403
+ const raw = envValue(env, name);
404
+ if (raw !== null)
405
+ parseEnv(name, field.kind, raw);
406
+ }
407
+ }
408
+ for (const name of [LEGACY_JUDGES_ENV, ...TIER_SUFFIXES.map((s) => `${LEGACY_JUDGES_ENV}${s}`)]) {
193
409
  const raw = envValue(env, name);
194
410
  if (raw !== null)
195
- ints.set(field, envPositiveInt(name, raw));
411
+ envPositiveInt(name, raw);
196
412
  }
197
- const rawRatio = envValue(env, RATIO_OVERRIDE);
198
- const ratio = rawRatio === null ? null : envRatio(RATIO_OVERRIDE, rawRatio);
199
- return {
200
- ...policy,
201
- epic: {
202
- capTokens: ints.get('epicCap') ?? policy.epic.capTokens,
203
- alarmRatio: ratio ?? policy.epic.alarmRatio,
204
- maxInFlightTasks: ints.get('maxInFlight') ?? policy.epic.maxInFlightTasks,
205
- },
206
- task: {
207
- coder: {
208
- capTokens: ints.get('coderCap') ?? policy.task.coder.capTokens,
209
- capDiffLines: ints.get('coderDiff') ?? policy.task.coder.capDiffLines,
210
- },
211
- researcher: { capTokens: ints.get('researcherCap') ?? policy.task.researcher.capTokens },
212
- judges: { capTokens: ints.get('judgesCap') ?? policy.task.judges.capTokens },
213
- },
214
- };
413
+ }
414
+ /**
415
+ * `policy` with every set budget env name applied on top, for the policy's
416
+ * own tier. Pure: it reads only `env` and returns a new policy.
417
+ */
418
+ export function applyBudgetEnv(policy, env) {
419
+ validateBudgetEnv(env);
420
+ const next = structuredClone(policy);
421
+ for (const field of ENV_FIELDS) {
422
+ const supplier = supplierFor(field, policy.tier, env);
423
+ if (supplier === null)
424
+ continue;
425
+ field.write(next, parseEnv(supplier, field.kind, envValue(env, supplier)));
426
+ }
427
+ return next;
215
428
  }
216
429
  /**
217
430
  * The budget env names whose value actually changed a number of `base` (the
218
- * policy as budgets.yml has it) — names only, never values — so a report that
219
- * prints an effective cap can say it did not come from budgets.yml. A name
220
- * set to the value budgets.yml already holds overrode nothing and is left
221
- * out: `.env.example` ships every knob at its default, so a copied `.env`
222
- * sets all of them.
431
+ * policy as budgets.yml has it, for its tier) — names only, never values — so
432
+ * a report that prints an effective cap can say it did not come from
433
+ * budgets.yml. Each is the name that won for that tier: a variant for another
434
+ * tier supplies nothing here and is left out. A name set to the value
435
+ * budgets.yml already holds overrode nothing and is left out too.
223
436
  */
224
437
  export function budgetEnvOverrides(base, env = process.env) {
225
- return BUDGET_ENV_VARS.filter((name) => {
226
- if (envValue(env, name) === null)
227
- return false;
228
- const alone = applyBudgetEnv(base, { [name]: env[name] });
229
- return JSON.stringify(alone) !== JSON.stringify(base);
230
- });
438
+ validateBudgetEnv(env);
439
+ const names = [];
440
+ for (const field of ENV_FIELDS) {
441
+ const supplier = supplierFor(field, base.tier, env);
442
+ if (supplier === null || names.includes(supplier))
443
+ continue;
444
+ const value = parseEnv(supplier, field.kind, envValue(env, supplier));
445
+ if (value !== field.read(base))
446
+ names.push(supplier);
447
+ }
448
+ return names;
231
449
  }
232
450
  /**
233
- * budgets.yml, with the box's env overrides on top. Every consumer reads the
234
- * policy through here, so an override reaches all of them or none.
451
+ * budgets.yml for `tier`, with the box's env overrides on top. Every consumer
452
+ * reads the policy through here, so an override reaches all of them or none.
235
453
  */
236
- export function loadBudgetPolicy(filePath = BUDGETS_POLICY_PATH, env = process.env) {
237
- return applyBudgetEnv(parseBudgetPolicy(readFileSync(filePath, 'utf8')), env);
454
+ export function loadBudgetPolicy(filePath = BUDGETS_POLICY_PATH, env = process.env, tier) {
455
+ return applyBudgetEnv(parseBudgetPolicy(readFileSync(filePath, 'utf8'), tier), env);
238
456
  }
239
457
  /**
240
458
  * For each field a task budget may declare, the mechanism that reads it — or
@@ -248,7 +466,7 @@ export function loadBudgetPolicy(filePath = BUDGETS_POLICY_PATH, env = process.e
248
466
  *
249
467
  * `max_turns` is null on purpose, and not because the wiring is pending: the
250
468
  * turn limit lives in the dispatch harness, which reads the role template's
251
- * `maxTurns` (`.claude/agents/<role>.md`) — per role, never per task
469
+ * `maxTurns` (`.claude/agents/bs-<role>.md`) — per role, never per task
252
470
  * (agent-interviews.md M-4, answered (c) on 2026-08-05 and corrected
253
471
  * 2026-09-11 once the cap was measured). A plan cannot raise or lower a
254
472
  * role's ceiling, so a number here would be a second copy the harness never
@@ -4,7 +4,7 @@ import { parse as parseYaml } from 'yaml';
4
4
  import { SmithError } from './errors.js';
5
5
  import { runGit, runGitRaw } from './git.js';
6
6
  import { WORKTREE_POLICY_PATH } from './paths.js';
7
- import { HELD_OPEN_BY_AN_OPERATOR, TERMINAL_TASK_STATUSES } from './taskStatus.js';
7
+ import { CLOSED_TO_FURTHER_WORK } from './taskStatus.js';
8
8
  export class ClaimsError extends SmithError {
9
9
  }
10
10
  function splitPathSegments(p) {
@@ -467,20 +467,10 @@ export function resolveFindingOwner(filePath, tasks) {
467
467
  }
468
468
  return { owner: 'resolved', taskId: topId, claim: top.claim };
469
469
  }
470
- /**
471
- * Task statuses that mean "this task's diff is closed to further work". A
472
- * finding routed to one of these has nobody to fix it — the branch already
473
- * landed (`completed`), the team already accepted the gap (`waived`), or a
474
- * later plan version replaced the task outright (`superseded`).
475
- *
476
- * That is every terminal status except the ones an operator is still holding:
477
- * a finding about a `failed` or `escalated` task's files belongs on that task
478
- * rather than on a new one competing for the same claims. The exception is
479
- * declared in taskStatus.ts as HELD_OPEN_BY_AN_OPERATOR and subtracted here,
480
- * so the three strings this used to name by hand come from the roster the
481
- * dimension already keeps rather than from a fourth copy of it.
482
- */
483
- const CLOSED_TO_FURTHER_WORK = new Set([...TERMINAL_TASK_STATUSES].filter((status) => !HELD_OPEN_BY_AN_OPERATOR.has(status)));
470
+ // CLOSED_TO_FURTHER_WORK (taskStatus.ts): a finding routed to one of those
471
+ // statuses has nobody to fix it. A finding about a `failed` or `escalated`
472
+ // task's files belongs on that task rather than on a new one competing for
473
+ // the same claims, which is why the operator-held pair is subtracted.
484
474
  /**
485
475
  * Where a finding should land, given who owns its file (D-41/P9-24).
486
476
  *
@@ -559,9 +549,9 @@ export function classifyChanges(changedFiles, patterns, violationCode = 'contrac
559
549
  *
560
550
  * Collector A (P9-3): what this task branch committed, against the integration
561
551
  * branch it was cut from — derived from the branch naming convention
562
- * smith/<epic>/<task-id> -> smith/<epic>/integration (worktree.yml
563
- * `integration_branch.pattern`; a bare "smith/<epic>" ref cannot coexist with
564
- * "smith/<epic>/<task-id>" refs in real git — D/F ref conflict).
552
+ * <prefix>/<epic>/<task-id> -> <prefix>/<epic>/integration, prefix bs or legacy smith (worktree.yml
553
+ * `integration_branch.pattern`; a bare "<prefix>/<epic>" ref cannot coexist with
554
+ * "<prefix>/<epic>/<task-id>" refs in real git — D/F ref conflict).
565
555
  *
566
556
  * The convention is load-bearing here and that is fine: only an agent working
567
557
  * in a task worktree has a claims list, and only a task worktree is on such a
@@ -572,7 +562,7 @@ export function integrationBranchFor(worktreeDir) {
572
562
  const branch = runGit(worktreeDir, ['rev-parse', '--abbrev-ref', 'HEAD']);
573
563
  const lastSlash = branch.lastIndexOf('/');
574
564
  if (lastSlash === -1) {
575
- throw new ClaimsError('claims.cannot-derive-integration-branch', `Branch "${branch}" does not follow the smith/<epic>/<task-id> convention.`, { branch });
565
+ throw new ClaimsError('claims.cannot-derive-integration-branch', `Branch "${branch}" does not follow the bs/<epic>/<task-id> convention.`, { branch });
576
566
  }
577
567
  return `${branch.slice(0, lastSlash)}/integration`;
578
568
  }