@juzser/blacksmith 0.3.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.
- package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- 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
|
-
*
|
|
21
|
-
*
|
|
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_*
|
|
24
|
-
* ever had cause to diverge them. This one has cause: the repo's
|
|
25
|
-
* raised by
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
[
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
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
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
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
|
-
|
|
411
|
+
envPositiveInt(name, raw);
|
|
196
412
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
219
|
-
* prints an effective cap can say it did not come from
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
*
|
|
563
|
-
* `integration_branch.pattern`; a bare "
|
|
564
|
-
* "
|
|
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
|
|
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
|
}
|