@juzser/blacksmith 0.2.0 → 0.4.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 (71) hide show
  1. package/.claude/agents/auditor.md +3 -0
  2. package/.claude/agents/grader.md +12 -5
  3. package/.claude/agents/reviewer.md +3 -0
  4. package/.claude/agents/security-reviewer.md +3 -0
  5. package/.claude/agents/spec-reviewer.md +3 -0
  6. package/.claude/agents/verifier.md +3 -0
  7. package/.claude/skills/bs/audit.md +25 -9
  8. package/.claude/skills/bs/dispatch.md +60 -7
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +18 -13
  11. package/.claude/skills/bs/wave.md +68 -10
  12. package/README.md +4 -4
  13. package/factory/orchestrator/dist/agentsSync.js +178 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +25 -0
  16. package/factory/orchestrator/dist/budgets.js +91 -2
  17. package/factory/orchestrator/dist/claims.js +8 -4
  18. package/factory/orchestrator/dist/cli.js +394 -26
  19. package/factory/orchestrator/dist/crossFinding.js +20 -0
  20. package/factory/orchestrator/dist/daemon.js +230 -41
  21. package/factory/orchestrator/dist/db/projector.js +93 -36
  22. package/factory/orchestrator/dist/db/queries.js +58 -35
  23. package/factory/orchestrator/dist/db/schema.js +24 -0
  24. package/factory/orchestrator/dist/dispatchAudit.js +10 -2
  25. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  26. package/factory/orchestrator/dist/epic.js +192 -39
  27. package/factory/orchestrator/dist/epicWidth.js +25 -20
  28. package/factory/orchestrator/dist/errorIssues.js +209 -24
  29. package/factory/orchestrator/dist/eventOrder.js +102 -0
  30. package/factory/orchestrator/dist/events.js +73 -91
  31. package/factory/orchestrator/dist/findingContext.js +1 -13
  32. package/factory/orchestrator/dist/findings.js +132 -11
  33. package/factory/orchestrator/dist/gh.js +29 -6
  34. package/factory/orchestrator/dist/git.js +20 -2
  35. package/factory/orchestrator/dist/harness.js +92 -10
  36. package/factory/orchestrator/dist/hookDecision.js +320 -37
  37. package/factory/orchestrator/dist/impact.js +24 -2
  38. package/factory/orchestrator/dist/integration.js +11 -1
  39. package/factory/orchestrator/dist/issueReporter.js +415 -0
  40. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  41. package/factory/orchestrator/dist/judges.js +1 -1
  42. package/factory/orchestrator/dist/lessons.js +46 -18
  43. package/factory/orchestrator/dist/logCache.js +291 -0
  44. package/factory/orchestrator/dist/paths.js +10 -0
  45. package/factory/orchestrator/dist/plan.js +138 -4
  46. package/factory/orchestrator/dist/planQuorum.js +1 -1
  47. package/factory/orchestrator/dist/policy.js +57 -11
  48. package/factory/orchestrator/dist/projects.js +12 -1
  49. package/factory/orchestrator/dist/queue.js +477 -29
  50. package/factory/orchestrator/dist/scheduler.js +51 -0
  51. package/factory/orchestrator/dist/severity.js +40 -8
  52. package/factory/orchestrator/dist/spec.js +70 -0
  53. package/factory/orchestrator/dist/taskEvents.js +13 -4
  54. package/factory/orchestrator/dist/taskStatus.js +74 -0
  55. package/factory/orchestrator/dist/taxonomy.js +11 -0
  56. package/factory/orchestrator/dist/testgate.js +32 -3
  57. package/factory/orchestrator/dist/usage.js +42 -9
  58. package/factory/orchestrator/dist/waivers.js +60 -9
  59. package/factory/orchestrator/dist/waveConcurrency.js +29 -0
  60. package/factory/orchestrator/dist/waveNext.js +12 -10
  61. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  62. package/factory/orchestrator/dist/worktree.js +19 -6
  63. package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
  64. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  65. package/factory/policies/budgets.yml +9 -2
  66. package/factory/policies/crosscheck.yml +2 -2
  67. package/factory/policies/lessons.md +298 -0
  68. package/factory/policies/scheduler.yml +27 -2
  69. package/factory/policies/taxonomy.yml +10 -3
  70. package/factory/specs/roadmap.md +70 -23
  71. package/package.json +8 -8
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `smith agents sync` — set each agent template's `maxTurns:` from the box's
3
+ * env, or put it back to the committed value.
4
+ *
5
+ * A turn cap is per role, never per task: Claude Code reads it from the
6
+ * template's frontmatter when it spawns the agent, so the only lever is the
7
+ * template file itself. This rewrites that one line and nothing else. The
8
+ * edit is local and left uncommitted on purpose — a raised cap on one box is
9
+ * that box's business, and `--reset` restores what git HEAD says.
10
+ *
11
+ * SMITH_MAXTURNS_<ROLE> names the role upper-cased with `-` as `_`
12
+ * (spec-reviewer → SMITH_MAXTURNS_SPEC_REVIEWER).
13
+ */
14
+ import { readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { SmithError } from './errors.js';
17
+ import { GitCommandError, runGit, runGitRaw } from './git.js';
18
+ import { AGENTS_DIR } from './paths.js';
19
+ export class AgentsSyncError extends SmithError {
20
+ }
21
+ export const MAXTURNS_ENV_PREFIX = 'SMITH_MAXTURNS_';
22
+ export function maxTurnsEnvName(role) {
23
+ return `${MAXTURNS_ENV_PREFIX}${role.toUpperCase().replaceAll('-', '_')}`;
24
+ }
25
+ const MAX_TURNS_LINE = /^maxTurns:[ \t]*(\S*)[ \t]*$/m;
26
+ function listRoles(agentsDir) {
27
+ return readdirSync(agentsDir)
28
+ .filter((name) => name.endsWith('.md'))
29
+ .map((name) => name.slice(0, -'.md'.length))
30
+ .sort();
31
+ }
32
+ /** The frontmatter block's bounds: the text between the leading `---` lines. */
33
+ function frontmatterBounds(text) {
34
+ const open = text.match(/^---\r?\n/);
35
+ if (!open)
36
+ return null;
37
+ const start = open[0].length;
38
+ const close = /^---[ \t]*$/m.exec(text.slice(start));
39
+ if (!close)
40
+ return null;
41
+ return { start, end: start + close.index };
42
+ }
43
+ function findMaxTurns(text, file) {
44
+ const bounds = frontmatterBounds(text);
45
+ const block = bounds ? text.slice(bounds.start, bounds.end) : '';
46
+ const match = bounds ? MAX_TURNS_LINE.exec(block) : null;
47
+ const raw = match?.[1] ?? '';
48
+ if (!bounds || !match || !/^[0-9]+$/.test(raw)) {
49
+ throw new AgentsSyncError('agents.no-max-turns', `${file} has no frontmatter "maxTurns: <integer>" line to rewrite.`, { file });
50
+ }
51
+ const valueStart = bounds.start + match.index + match[0].indexOf(raw, 'maxTurns:'.length);
52
+ return { value: Number(raw), from: valueStart, to: valueStart + raw.length };
53
+ }
54
+ function withMaxTurns(text, site, value) {
55
+ return `${text.slice(0, site.from)}${value}${text.slice(site.to)}`;
56
+ }
57
+ /**
58
+ * The template's current `maxTurns:`, read fresh from disk — never cached,
59
+ * because `smith agents sync` may have rewritten the file since the caller
60
+ * last looked. Unlike `findMaxTurns`, this never throws: a role with no
61
+ * template, or a template with no `maxTurns:` line, is "cannot verify" for a
62
+ * caller like dispatchLint.ts, not a hard failure.
63
+ */
64
+ export function readTemplateMaxTurns(agentsDir, role) {
65
+ const file = path.join(agentsDir, `${role}.md`);
66
+ let text;
67
+ try {
68
+ text = readFileSync(file, 'utf8');
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ try {
74
+ return findMaxTurns(text, file).value;
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ }
80
+ function positiveInt(name, raw) {
81
+ const value = /^[0-9]+$/.test(raw) ? Number(raw) : Number.NaN;
82
+ if (!Number.isSafeInteger(value) || value <= 0) {
83
+ throw new AgentsSyncError('agents.invalid-env', `${name} must be a positive integer (digits only); got ${JSON.stringify(raw)}.`, { variable: name });
84
+ }
85
+ return value;
86
+ }
87
+ function apply(edits, dryRun) {
88
+ if (!dryRun) {
89
+ for (const edit of edits)
90
+ if (edit.change.changed)
91
+ writeFileSync(edit.file, edit.next);
92
+ }
93
+ return edits.map((edit) => edit.change);
94
+ }
95
+ /**
96
+ * Rewrite `maxTurns:` for every role whose SMITH_MAXTURNS_<ROLE> is set
97
+ * (non-empty). Every name and value is checked, and every target file read,
98
+ * before anything is written: one bad entry refuses the whole sync.
99
+ */
100
+ export function syncAgentMaxTurns(options = {}) {
101
+ const agentsDir = options.agentsDir ?? AGENTS_DIR;
102
+ const env = options.env ?? process.env;
103
+ const dryRun = options.dryRun ?? false;
104
+ const roles = listRoles(agentsDir);
105
+ const byEnv = new Map(roles.map((role) => [maxTurnsEnvName(role), role]));
106
+ const requested = [];
107
+ for (const name of Object.keys(env).sort()) {
108
+ if (!name.startsWith(MAXTURNS_ENV_PREFIX))
109
+ continue;
110
+ const raw = env[name];
111
+ if (raw === undefined || raw === '')
112
+ continue;
113
+ const role = byEnv.get(name);
114
+ if (role === undefined) {
115
+ throw new AgentsSyncError('agents.unknown-role', `${name} names no agent template; valid roles: ${roles.join(', ')}.`, { variable: name, roles });
116
+ }
117
+ requested.push({ role, env: name, to: positiveInt(name, raw) });
118
+ }
119
+ requested.sort((a, b) => a.role.localeCompare(b.role));
120
+ const edits = requested.map(({ role, env: name, to }) => {
121
+ const file = path.join(agentsDir, `${role}.md`);
122
+ const text = readFileSync(file, 'utf8');
123
+ const site = findMaxTurns(text, file);
124
+ return {
125
+ change: { role, env: name, from: site.value, to, changed: site.value !== to },
126
+ file,
127
+ next: withMaxTurns(text, site, to),
128
+ };
129
+ });
130
+ return { mode: 'sync', dryRun, agentsDir, roles, changes: apply(edits, dryRun) };
131
+ }
132
+ /**
133
+ * Put every template's `maxTurns:` back to the value committed at git HEAD,
134
+ * touching only that line — any other local edit to the file stays. Refuses
135
+ * when the templates are not in a git checkout, rather than guess a default.
136
+ */
137
+ export function resetAgentMaxTurns(options = {}) {
138
+ const agentsDir = options.agentsDir ?? AGENTS_DIR;
139
+ const dryRun = options.dryRun ?? false;
140
+ const roles = listRoles(agentsDir);
141
+ let topLevel;
142
+ try {
143
+ topLevel = runGit(agentsDir, ['rev-parse', '--show-toplevel']);
144
+ }
145
+ catch (err) {
146
+ if (!(err instanceof GitCommandError))
147
+ throw err;
148
+ throw new AgentsSyncError('agents.reset-unavailable', `--reset needs git HEAD, but ${agentsDir} is not inside a git checkout.`, { agentsDir });
149
+ }
150
+ const edits = roles.map((role) => {
151
+ const file = path.join(agentsDir, `${role}.md`);
152
+ const rel = path.relative(topLevel, realpathSync(file)).split(path.sep).join('/');
153
+ let committed;
154
+ try {
155
+ committed = runGitRaw(topLevel, ['show', `HEAD:${rel}`]);
156
+ }
157
+ catch (err) {
158
+ if (!(err instanceof GitCommandError))
159
+ throw err;
160
+ throw new AgentsSyncError('agents.reset-unavailable', `--reset needs ${rel} at git HEAD, and it is not there.`, { file: rel });
161
+ }
162
+ const to = findMaxTurns(committed, `HEAD:${rel}`).value;
163
+ const text = readFileSync(file, 'utf8');
164
+ const site = findMaxTurns(text, file);
165
+ return {
166
+ change: {
167
+ role,
168
+ env: maxTurnsEnvName(role),
169
+ from: site.value,
170
+ to,
171
+ changed: site.value !== to,
172
+ },
173
+ file,
174
+ next: withMaxTurns(text, site, to),
175
+ };
176
+ });
177
+ return { mode: 'reset', dryRun, agentsDir, roles, changes: apply(edits, dryRun) };
178
+ }
@@ -32,11 +32,15 @@
32
32
  import { randomBytes } from 'node:crypto';
33
33
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
34
34
  import path from 'node:path';
35
+ import { resolveFindingOwner } from './claims.js';
35
36
  import { SmithError } from './errors.js';
36
37
  import { appendEvent } from './events.js';
37
38
  import { computeFingerprint, normalizeFilePath } from './findings.js';
38
39
  import { runGit } from './git.js';
39
40
  import { checkWorktreeImmutable, fingerprintWorktree, } from './immutability.js';
41
+ import { isFactoryCheckout, SPECS_ACTIVE_DIR } from './paths.js';
42
+ import { latestPlanVersion, loadPlan, planClaimedTasks, } from './plan.js';
43
+ import { defaultKindFor, FACTORY_PROJECT } from './roadmap.js';
40
44
  import { loadTaxonomy } from './taxonomy.js';
41
45
  export class AuditError extends SmithError {
42
46
  }
@@ -251,11 +255,31 @@ export function foldAuditStore(lines, opts = {}) {
251
255
  * The fingerprints an axis may not raise again. This is the whole of the
252
256
  * dedupe half of §3.1 — `audit record` drops what this names and appends the
253
257
  * rest.
258
+ *
259
+ * Each suppressing finding contributes two fingerprints, not one. Its
260
+ * stored `finding.fingerprint` is whatever `computeFingerprint` produced
261
+ * *when the line was written* — widening that normalizer (issue #178) does
262
+ * not rewrite `findings.jsonl`, so a decline recorded before a normalizer
263
+ * fix keeps its old hash forever. The raised line still carries its own
264
+ * `file_path`/`axis`/`summary`, so recomputing the fingerprint with today's
265
+ * normalizer costs nothing and lets a pre-existing decline suppress a
266
+ * re-raise that only differs in what the new normalizer now strips — no
267
+ * store rewrite, no migration. When the stored hash and the recomputed one
268
+ * already agree (the common case), the second `add` is a no-op.
254
269
  */
255
270
  export function suppressedFingerprints(lines, opts = {}) {
256
- return new Set(foldAuditStore(lines, opts)
257
- .filter((finding) => finding.suppressesReraise)
258
- .map((finding) => finding.fingerprint));
271
+ const set = new Set();
272
+ for (const finding of foldAuditStore(lines, opts)) {
273
+ if (!finding.suppressesReraise)
274
+ continue;
275
+ set.add(finding.fingerprint);
276
+ set.add(computeFingerprint({
277
+ filePath: finding.file_path,
278
+ category: finding.axis,
279
+ summary: finding.summary,
280
+ }));
281
+ }
282
+ return set;
259
283
  }
260
284
  function clusterOf(filePath, findings) {
261
285
  const members = findings.filter((finding) => finding.status !== 'merged');
@@ -398,6 +422,39 @@ function mintAuditId(now) {
398
422
  const day = now.toISOString().slice(0, 10).replaceAll('-', '');
399
423
  return `${day}-${randomBytes(4).toString('hex')}`;
400
424
  }
425
+ /**
426
+ * The name an audit calls `project` by, everywhere one is needed: the
427
+ * event's `project` field, the cut milestone's `- project:` line and goal
428
+ * sentence, and the cut spec's Project line.
429
+ *
430
+ * `path.basename(project)` names every OTHER project correctly, but this
431
+ * clone's own directory is not always named FACTORY_PROJECT
432
+ * (`black-smith`) -- roadmap.ts's own note documents the real `black-smith`/
433
+ * `blacksmith` siblings this factory was built beside -- so a self-audit that
434
+ * used the basename wrote a name `collectProjects()` (projects.ts) had never
435
+ * seen, sent it looking for a checkout under this clone's own parent, and it
436
+ * found this very clone: registered a second time as a foreign, non-self ref
437
+ * that `--no-self` could not filter. Comparing against REPO_ROOT, resolved
438
+ * the same way `unwatchedProjects` (projects.ts) already does, is what tells
439
+ * "this clone, filed under its own directory name" from "a project that is
440
+ * genuinely named that".
441
+ */
442
+ export function auditedProjectName(project) {
443
+ return isFactoryCheckout(project) ? FACTORY_PROJECT : path.basename(project);
444
+ }
445
+ /**
446
+ * The `- kind:` a cut milestone declares alongside auditedProjectName's
447
+ * `- project:` line. roadmap.ts's own parser fixes the two together
448
+ * (`roadmap.factory-kind-fixed`): a milestone naming FACTORY_PROJECT must be
449
+ * kind "factory", never "product". A cut that named a self-audit
450
+ * FACTORY_PROJECT but still wrote a hard-coded "product" kind would stamp a
451
+ * milestone that fails to parse the moment `loadRoadmap()` reads it back, so
452
+ * this feeds auditedProjectName's own answer through roadmap.ts's
453
+ * defaultKindFor rather than re-deriving it separately.
454
+ */
455
+ export function auditedProjectKind(project) {
456
+ return defaultKindFor(auditedProjectName(project));
457
+ }
401
458
  /**
402
459
  * The audit's events are ordinary events on the operator's session, stamped
403
460
  * with the project's name so the multi-project timeline can tell whose audit
@@ -412,7 +469,7 @@ function emit(eventType, project, ctx, opts, payload) {
412
469
  event_type: eventType,
413
470
  plan_version: ctx.planVersion,
414
471
  causal_parent: ctx.causalParent,
415
- project: path.basename(project),
472
+ project: auditedProjectName(project),
416
473
  payload,
417
474
  }, opts);
418
475
  }
@@ -635,7 +692,7 @@ function cite(finding) {
635
692
  function renderMilestone(input, project, findings) {
636
693
  const audits = auditIdsOf(findings);
637
694
  const goal = [
638
- `Cut from audit ${audits.join(', ')} of \`${path.basename(project)}\` (${findings.length} accepted finding${findings.length === 1 ? '' : 's'}: ${severityCounts(findings)}).`,
695
+ `Cut from audit ${audits.join(', ')} of \`${auditedProjectName(project)}\` (${findings.length} accepted finding${findings.length === 1 ? '' : 's'}: ${severityCounts(findings)}).`,
639
696
  'Each finding below is one acceptance criterion; the plan decides the tasks.',
640
697
  ...findings.map((finding) => `${cite(finding)}.`),
641
698
  ].join(' ');
@@ -644,8 +701,8 @@ function renderMilestone(input, project, findings) {
644
701
  `- id: ${input.epicId}`,
645
702
  '- status: planned',
646
703
  `- epics: [${input.epicId}]`,
647
- `- project: ${path.basename(project)}`,
648
- '- kind: product',
704
+ `- project: ${auditedProjectName(project)}`,
705
+ `- kind: ${auditedProjectKind(project)}`,
649
706
  `- goal: ${goal}`,
650
707
  '',
651
708
  ].join('\n');
@@ -673,9 +730,9 @@ function renderSpec(input, project, findings) {
673
730
  `# Epic spec — \`${input.epicId}\``,
674
731
  '',
675
732
  `- **Epic id** — \`${input.epicId}\``,
676
- `- **Project** — \`${path.basename(project)}\` at \`${project}\`, and every worktree is placed beside that clone (\`AGENTS.md\` "Worktrees").`,
733
+ `- **Project** — \`${auditedProjectName(project)}\` at \`${project}\`, and every worktree is placed beside that clone (\`AGENTS.md\` "Worktrees").`,
677
734
  `- **Roadmap milestone** — \`${input.epicId}\` in \`factory/specs/roadmap.md\`.`,
678
- `- **Provenance** — cut by \`smith audit cut\` from audit ${audits.join(', ')} (${findings.length} accepted finding${findings.length === 1 ? '' : 's'}: ${severityCounts(findings)}). Each finding carries this epic id in \`.blacksmith/findings.jsonl\`; \`smith audit resolve\` marks them fixed when the epic closes.`,
735
+ `- **Provenance** — cut by \`smith audit cut\` from audit ${audits.join(', ')} (${findings.length} accepted finding${findings.length === 1 ? '' : 's'}: ${severityCounts(findings)}). Each finding carries this epic id in \`.blacksmith/findings.jsonl\`; \`smith audit resolve\` marks fixed only the ones a task in this plan still claims, and leaves the rest \`deferred\`.`,
679
736
  '',
680
737
  '## What this epic is',
681
738
  '',
@@ -734,24 +791,83 @@ export async function cutAudit(projectDir, input, ctx, opts = {}) {
734
791
  };
735
792
  }
736
793
  /**
737
- * `audit resolve <project-dir> --epic <epic-id>`. Appends a `fixed` line for
738
- * every finding the epic carried. Called from `/bs run`'s epic-close step, so
739
- * it is repeatable; an epic no finding carries is refused, because the likely
740
- * cause is a typo and a silent no-op would leave every finding open.
794
+ * The epic's newest plan, however `resolveOpts` asks for it found. Refuses
795
+ * rather than guesses: `resolveAudit` must never treat "I could not find a
796
+ * plan" as "nothing is claimed", because that reading marks every carried
797
+ * finding fixed.
798
+ */
799
+ function resolveEpicPlan(epicId, resolveOpts) {
800
+ if (resolveOpts.plan !== undefined)
801
+ return resolveOpts.plan;
802
+ const planOpts = resolveOpts.planOpts ?? {};
803
+ const version = latestPlanVersion(epicId, planOpts);
804
+ if (version === null) {
805
+ const searchedDir = path.join(planOpts.specsDir ?? SPECS_ACTIVE_DIR, epicId);
806
+ throw new AuditError('audit.no-plan', `no plan found for epic ${epicId}: \`audit resolve\` only marks a finding fixed when some plan task claims its file, so a missing plan cannot be treated as an empty one. Searched ${searchedDir}. If this epic's plans live elsewhere, pass --specs-dir <dir> naming the directory they live in, or --plan <plan.json> to point at one directly.`, { epic: epicId });
807
+ }
808
+ return loadPlan(epicId, version, planOpts);
809
+ }
810
+ /**
811
+ * `--except` entries against the epic's carried fingerprints, full or a
812
+ * unique prefix — the same latitude other audit verbs give a fingerprint
813
+ * argument. A value that matches nothing is refused as a likely typo (same
814
+ * spirit as `audit.unknown-epic`); one whose prefix matches more than one
815
+ * carried fingerprint is refused rather than guessed.
816
+ */
817
+ function matchExceptFingerprints(except, carried, epicId) {
818
+ const known = carried.map((finding) => finding.fingerprint);
819
+ const matched = new Set();
820
+ for (const value of except) {
821
+ if (known.includes(value)) {
822
+ matched.add(value);
823
+ continue;
824
+ }
825
+ const candidates = known.filter((fingerprint) => fingerprint.startsWith(value));
826
+ if (candidates.length === 1) {
827
+ matched.add(candidates[0]);
828
+ continue;
829
+ }
830
+ if (candidates.length > 1) {
831
+ throw new AuditError('audit.ambiguous-except', `--except ${value} matches ${candidates.length} findings epic ${epicId} carries: ${candidates.join(', ')}`, { epic: epicId, value, candidates });
832
+ }
833
+ throw new AuditError('audit.unknown-finding', `--except ${value} matches no finding epic ${epicId} carries`, { epic: epicId, value });
834
+ }
835
+ return matched;
836
+ }
837
+ /**
838
+ * `audit resolve <project-dir> --epic <epic-id>`. Appends a `fixed` line only
839
+ * for a carried finding some task in the epic's newest plan still claims
840
+ * (`resolveFindingOwner`, D-41/P9-24) — an operator who scoped the plan
841
+ * narrower than the audit deferred the rest on purpose, and marking them
842
+ * fixed anyway would erase that choice. Unclaimed findings stay exactly as
843
+ * they were and come back in `deferred`; `--except` forces specific
844
+ * fingerprints there even when a task claims them. Called from `/bs run`'s
845
+ * epic-close step, so it is repeatable; an epic no finding carries is
846
+ * refused, because the likely cause is a typo and a silent no-op would leave
847
+ * every finding open. A plan that cannot be found is refused too
848
+ * (`audit.no-plan`): being unsure must never mark anything fixed.
741
849
  */
742
- export async function resolveAudit(projectDir, epicId, ctx, opts = {}) {
850
+ export async function resolveAudit(projectDir, epicId, ctx, opts = {}, resolveOpts = {}) {
743
851
  const project = path.resolve(projectDir);
744
852
  const carried = foldAuditStore(readAuditStore(project)).filter((finding) => finding.epic === epicId);
745
853
  if (carried.length === 0) {
746
854
  throw new AuditError('audit.unknown-epic', `no finding in ${auditStorePath(project)} carries epic ${epicId}: was it cut with \`smith audit cut\`?`, { epic: epicId });
747
855
  }
748
856
  const open = carried.filter((finding) => finding.status !== 'fixed');
857
+ const plan = resolveEpicPlan(epicId, resolveOpts);
858
+ const claimedTasks = planClaimedTasks(plan);
859
+ const except = matchExceptFingerprints(resolveOpts.except ?? [], carried, epicId);
860
+ const toFix = open.filter((finding) => !except.has(finding.fingerprint) &&
861
+ resolveFindingOwner(finding.file_path, claimedTasks).owner !== 'unclaimed');
862
+ const toFixFingerprints = new Set(toFix.map((finding) => finding.fingerprint));
863
+ const deferred = open.filter((finding) => !toFixFingerprints.has(finding.fingerprint));
749
864
  const stored = await emit('audit-resolved', project, ctx, opts, {
750
865
  epic: epicId,
751
- fixed: open.map((finding) => finding.fingerprint),
866
+ fixed: toFix.map((finding) => finding.fingerprint),
867
+ deferred: deferred.map((finding) => finding.fingerprint),
752
868
  });
753
869
  const ts = new Date().toISOString();
754
- appendAuditLines(project, open.map((finding) => ({
870
+ appendAuditLines(project, toFix.map((finding) => ({
755
871
  fingerprint: finding.fingerprint,
756
872
  status: 'fixed',
757
873
  ts,
@@ -761,10 +877,11 @@ export async function resolveAudit(projectDir, epicId, ctx, opts = {}) {
761
877
  })));
762
878
  return {
763
879
  epic: epicId,
764
- fixed: open.map((finding) => finding.fingerprint),
880
+ fixed: toFix.map((finding) => finding.fingerprint),
765
881
  already: carried
766
882
  .filter((finding) => finding.status === 'fixed')
767
883
  .map((finding) => finding.fingerprint),
884
+ deferred: deferred.map((finding) => finding.fingerprint),
768
885
  event_id: stored.event_id,
769
886
  };
770
887
  }
@@ -60,6 +60,8 @@ function subjectOf(proposal) {
60
60
  }
61
61
  case 'growth-review-due':
62
62
  return `product-growth review, ${proposal.cadenceDays}-day cadence`;
63
+ case 'error-report':
64
+ return `issue report for error ${proposal.fingerprint} (${proposal.errorClass}, task ${proposal.taskRef})`;
63
65
  }
64
66
  }
65
67
  /** The text the security keywords are matched against, named by field so a hit can say where. */
@@ -76,6 +78,17 @@ function securityFields(proposal, ctx) {
76
78
  return proposal.packages.map((p) => ({ field: 'package', value: p.name }));
77
79
  case 'growth-review-due':
78
80
  return [];
81
+ case 'error-report':
82
+ // Refused ahead of the keyword match (see classify() below); listed so
83
+ // the switch stays exhaustive. proposal.project is only ever null when
84
+ // errorIssues.ts's fail-closed fold could not resolve one, which this
85
+ // branch never actually returns for -- see classify()'s early
86
+ // 'tracker-write-never-auto' refusal for every 'error-report' kind.
87
+ return [
88
+ { field: 'task ref', value: proposal.taskRef },
89
+ { field: 'error class', value: proposal.errorClass },
90
+ { field: 'project', value: proposal.project ?? '(unresolved)' },
91
+ ];
79
92
  }
80
93
  }
81
94
  function classify(proposal, policy, ctx) {
@@ -95,6 +108,18 @@ function classify(proposal, policy, ctx) {
95
108
  reason: 'A product-growth review proposes SCOPE, and scope is the operator\'s (architecture §12: "product-growth proposals always wait for an operator tick"). Listing the kind in auto_dispatch_kinds changes nothing.',
96
109
  };
97
110
  }
111
+ // Same place, same reason shape: reporting an error writes to an external
112
+ // issue tracker under an authenticated `gh` token whose scope includes
113
+ // `repo`, and scheduler.yml's autonomy block already holds that anything
114
+ // touching auth or secrets always waits for a person. Ahead of the
115
+ // whitelist so that listing the kind changes nothing.
116
+ if (proposal.kind === 'error-report') {
117
+ return {
118
+ decision: 'operator',
119
+ code: 'tracker-write-never-auto',
120
+ reason: 'Reporting an error writes to an external issue tracker under an authenticated gh token whose scope includes repo. scheduler.yml\'s autonomy block holds that anything touching auth or secrets always waits for a person, so listing "error-report" in auto_dispatch_kinds changes nothing.',
121
+ };
122
+ }
98
123
  if (!policy.autoDispatchKinds.includes(proposal.kind)) {
99
124
  return {
100
125
  decision: 'operator',
@@ -144,8 +144,97 @@ export function parseBudgetPolicy(yamlText) {
144
144
  escalationLadder: parseEscalationLadder(doc.escalation_ladder?.rungs),
145
145
  };
146
146
  }
147
- export function loadBudgetPolicy(filePath = BUDGETS_POLICY_PATH) {
148
- return parseBudgetPolicy(readFileSync(filePath, 'utf8'));
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'],
154
+ ];
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
+ ]);
161
+ function envValue(env, name) {
162
+ const value = env[name];
163
+ return value === undefined || value === '' ? null : value;
164
+ }
165
+ /**
166
+ * Strict on purpose, and stricter than the YAML: a `.env` line is typed by
167
+ * hand, and `4M`, `4e6` or `4_000_000` read as something else by every parser
168
+ * that accepts them. Digits only, no sign, no exponent, greater than zero.
169
+ * The error names the variable and the value it was given, and nothing else.
170
+ */
171
+ function envPositiveInt(name, raw) {
172
+ const value = /^[0-9]+$/.test(raw) ? Number(raw) : Number.NaN;
173
+ if (!Number.isSafeInteger(value) || value <= 0) {
174
+ throw new BudgetError('budgets.invalid-env', `${name} must be a positive integer (digits only); got ${JSON.stringify(raw)}.`, { variable: name });
175
+ }
176
+ return value;
177
+ }
178
+ function envRatio(name, raw) {
179
+ const value = /^(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$/.test(raw) ? Number(raw) : Number.NaN;
180
+ if (!Number.isFinite(value) || value <= 0 || value > 1) {
181
+ throw new BudgetError('budgets.invalid-env', `${name} must be a decimal in (0, 1]; got ${JSON.stringify(raw)}.`, { variable: name });
182
+ }
183
+ return value;
184
+ }
185
+ /**
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.
189
+ */
190
+ export function applyBudgetEnv(policy, env) {
191
+ const ints = new Map();
192
+ for (const [name, field] of INT_OVERRIDES) {
193
+ const raw = envValue(env, name);
194
+ if (raw !== null)
195
+ ints.set(field, envPositiveInt(name, raw));
196
+ }
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
+ };
215
+ }
216
+ /**
217
+ * 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.
223
+ */
224
+ 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
+ });
231
+ }
232
+ /**
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.
235
+ */
236
+ export function loadBudgetPolicy(filePath = BUDGETS_POLICY_PATH, env = process.env) {
237
+ return applyBudgetEnv(parseBudgetPolicy(readFileSync(filePath, 'utf8')), env);
149
238
  }
150
239
  /**
151
240
  * For each field a task budget may declare, the mechanism that reads it — or
@@ -4,6 +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
8
  export class ClaimsError extends SmithError {
8
9
  }
9
10
  function splitPathSegments(p) {
@@ -472,11 +473,14 @@ export function resolveFindingOwner(filePath, tasks) {
472
473
  * landed (`completed`), the team already accepted the gap (`waived`), or a
473
474
  * later plan version replaced the task outright (`superseded`).
474
475
  *
475
- * `failed` and `escalated` are deliberately NOT here: those tasks are still
476
- * open work an operator is holding, and a finding about their files belongs
477
- * on them rather than on a new task competing for the same claims.
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.
478
482
  */
479
- const CLOSED_TO_FURTHER_WORK = new Set(['completed', 'waived', 'superseded']);
483
+ const CLOSED_TO_FURTHER_WORK = new Set([...TERMINAL_TASK_STATUSES].filter((status) => !HELD_OPEN_BY_AN_OPERATOR.has(status)));
480
484
  /**
481
485
  * Where a finding should land, given who owns its file (D-41/P9-24).
482
486
  *