@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.
- package/.claude/agents/auditor.md +3 -0
- package/.claude/agents/grader.md +12 -5
- package/.claude/agents/reviewer.md +3 -0
- package/.claude/agents/security-reviewer.md +3 -0
- package/.claude/agents/spec-reviewer.md +3 -0
- package/.claude/agents/verifier.md +3 -0
- package/.claude/skills/bs/audit.md +25 -9
- package/.claude/skills/bs/dispatch.md +60 -7
- package/.claude/skills/bs/plan.md +33 -1
- package/.claude/skills/bs/run.md +18 -13
- package/.claude/skills/bs/wave.md +68 -10
- package/README.md +4 -4
- package/factory/orchestrator/dist/agentsSync.js +178 -0
- package/factory/orchestrator/dist/audit.js +134 -17
- package/factory/orchestrator/dist/autonomy.js +25 -0
- package/factory/orchestrator/dist/budgets.js +91 -2
- package/factory/orchestrator/dist/claims.js +8 -4
- package/factory/orchestrator/dist/cli.js +394 -26
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/daemon.js +230 -41
- package/factory/orchestrator/dist/db/projector.js +93 -36
- package/factory/orchestrator/dist/db/queries.js +58 -35
- package/factory/orchestrator/dist/db/schema.js +24 -0
- package/factory/orchestrator/dist/dispatchAudit.js +10 -2
- package/factory/orchestrator/dist/dispatchLint.js +132 -0
- package/factory/orchestrator/dist/epic.js +192 -39
- package/factory/orchestrator/dist/epicWidth.js +25 -20
- package/factory/orchestrator/dist/errorIssues.js +209 -24
- package/factory/orchestrator/dist/eventOrder.js +102 -0
- package/factory/orchestrator/dist/events.js +73 -91
- package/factory/orchestrator/dist/findingContext.js +1 -13
- package/factory/orchestrator/dist/findings.js +132 -11
- package/factory/orchestrator/dist/gh.js +29 -6
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/harness.js +92 -10
- package/factory/orchestrator/dist/hookDecision.js +320 -37
- package/factory/orchestrator/dist/impact.js +24 -2
- package/factory/orchestrator/dist/integration.js +11 -1
- package/factory/orchestrator/dist/issueReporter.js +415 -0
- package/factory/orchestrator/dist/judgeStopHook.js +181 -0
- package/factory/orchestrator/dist/judges.js +1 -1
- package/factory/orchestrator/dist/lessons.js +46 -18
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/paths.js +10 -0
- package/factory/orchestrator/dist/plan.js +138 -4
- package/factory/orchestrator/dist/planQuorum.js +1 -1
- package/factory/orchestrator/dist/policy.js +57 -11
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/queue.js +477 -29
- package/factory/orchestrator/dist/scheduler.js +51 -0
- package/factory/orchestrator/dist/severity.js +40 -8
- package/factory/orchestrator/dist/spec.js +70 -0
- package/factory/orchestrator/dist/taskEvents.js +13 -4
- package/factory/orchestrator/dist/taskStatus.js +74 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testgate.js +32 -3
- package/factory/orchestrator/dist/usage.js +42 -9
- package/factory/orchestrator/dist/waivers.js +60 -9
- package/factory/orchestrator/dist/waveConcurrency.js +29 -0
- package/factory/orchestrator/dist/waveNext.js +12 -10
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/worktree.js +19 -6
- package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +9 -2
- package/factory/policies/crosscheck.yml +2 -2
- package/factory/policies/lessons.md +298 -0
- package/factory/policies/scheduler.yml +27 -2
- package/factory/policies/taxonomy.yml +10 -3
- package/factory/specs/roadmap.md +70 -23
- 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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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:
|
|
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 \`${
|
|
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: ${
|
|
648
|
-
|
|
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** — \`${
|
|
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
|
|
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
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
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([
|
|
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
|
*
|