@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
|
@@ -9,24 +9,44 @@
|
|
|
9
9
|
* that box's business, and `--reset` restores what git HEAD says.
|
|
10
10
|
*
|
|
11
11
|
* SMITH_MAXTURNS_<ROLE> names the role upper-cased with `-` as `_`
|
|
12
|
-
* (spec-reviewer → SMITH_MAXTURNS_SPEC_REVIEWER).
|
|
12
|
+
* (spec-reviewer → SMITH_MAXTURNS_SPEC_REVIEWER). BS_MAXTURNS_<ROLE> is the
|
|
13
|
+
* current spelling of the same variable (bs-rename, operator decision 3);
|
|
14
|
+
* SMITH_MAXTURNS_<ROLE> still works as a fallback, and BS_ wins when both are
|
|
15
|
+
* set for the same role.
|
|
13
16
|
*/
|
|
14
17
|
import { readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
15
18
|
import path from 'node:path';
|
|
19
|
+
import { roleOfTemplateFile, templateFileFor } from './agentNames.js';
|
|
16
20
|
import { SmithError } from './errors.js';
|
|
17
21
|
import { GitCommandError, runGit, runGitRaw } from './git.js';
|
|
18
22
|
import { AGENTS_DIR } from './paths.js';
|
|
19
23
|
export class AgentsSyncError extends SmithError {
|
|
20
24
|
}
|
|
21
25
|
export const MAXTURNS_ENV_PREFIX = 'SMITH_MAXTURNS_';
|
|
26
|
+
const BS_MAXTURNS_ENV_PREFIX = 'BS_MAXTURNS_';
|
|
22
27
|
export function maxTurnsEnvName(role) {
|
|
23
28
|
return `${MAXTURNS_ENV_PREFIX}${role.toUpperCase().replaceAll('-', '_')}`;
|
|
24
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Recognises a `SMITH_MAXTURNS_<ROLE>` or `BS_MAXTURNS_<ROLE>` env name, and
|
|
32
|
+
* maps either spelling to the legacy-style name `byEnv` (below) is keyed by.
|
|
33
|
+
*/
|
|
34
|
+
function parseMaxTurnsEnvName(name) {
|
|
35
|
+
if (name.startsWith(BS_MAXTURNS_ENV_PREFIX)) {
|
|
36
|
+
return {
|
|
37
|
+
legacyName: `${MAXTURNS_ENV_PREFIX}${name.slice(BS_MAXTURNS_ENV_PREFIX.length)}`,
|
|
38
|
+
isBs: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (name.startsWith(MAXTURNS_ENV_PREFIX))
|
|
42
|
+
return { legacyName: name, isBs: false };
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
25
45
|
const MAX_TURNS_LINE = /^maxTurns:[ \t]*(\S*)[ \t]*$/m;
|
|
26
46
|
function listRoles(agentsDir) {
|
|
27
47
|
return readdirSync(agentsDir)
|
|
28
|
-
.
|
|
29
|
-
.
|
|
48
|
+
.map((name) => roleOfTemplateFile(name))
|
|
49
|
+
.filter((role) => role !== null)
|
|
30
50
|
.sort();
|
|
31
51
|
}
|
|
32
52
|
/** The frontmatter block's bounds: the text between the leading `---` lines. */
|
|
@@ -54,6 +74,29 @@ function findMaxTurns(text, file) {
|
|
|
54
74
|
function withMaxTurns(text, site, value) {
|
|
55
75
|
return `${text.slice(0, site.from)}${value}${text.slice(site.to)}`;
|
|
56
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* The template's current `maxTurns:`, read fresh from disk — never cached,
|
|
79
|
+
* because `smith agents sync` may have rewritten the file since the caller
|
|
80
|
+
* last looked. Unlike `findMaxTurns`, this never throws: a role with no
|
|
81
|
+
* template, or a template with no `maxTurns:` line, is "cannot verify" for a
|
|
82
|
+
* caller like dispatchLint.ts, not a hard failure.
|
|
83
|
+
*/
|
|
84
|
+
export function readTemplateMaxTurns(agentsDir, role) {
|
|
85
|
+
const file = path.join(agentsDir, templateFileFor(role));
|
|
86
|
+
let text;
|
|
87
|
+
try {
|
|
88
|
+
text = readFileSync(file, 'utf8');
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return findMaxTurns(text, file).value;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
57
100
|
function positiveInt(name, raw) {
|
|
58
101
|
const value = /^[0-9]+$/.test(raw) ? Number(raw) : Number.NaN;
|
|
59
102
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
@@ -70,9 +113,10 @@ function apply(edits, dryRun) {
|
|
|
70
113
|
return edits.map((edit) => edit.change);
|
|
71
114
|
}
|
|
72
115
|
/**
|
|
73
|
-
* Rewrite `maxTurns:` for every role whose
|
|
74
|
-
* (non-empty). Every name and value is
|
|
75
|
-
* before anything is written: one bad
|
|
116
|
+
* Rewrite `maxTurns:` for every role whose BS_MAXTURNS_<ROLE> (or its legacy
|
|
117
|
+
* SMITH_MAXTURNS_<ROLE> fallback) is set (non-empty). Every name and value is
|
|
118
|
+
* checked, and every target file read, before anything is written: one bad
|
|
119
|
+
* entry refuses the whole sync.
|
|
76
120
|
*/
|
|
77
121
|
export function syncAgentMaxTurns(options = {}) {
|
|
78
122
|
const agentsDir = options.agentsDir ?? AGENTS_DIR;
|
|
@@ -80,22 +124,35 @@ export function syncAgentMaxTurns(options = {}) {
|
|
|
80
124
|
const dryRun = options.dryRun ?? false;
|
|
81
125
|
const roles = listRoles(agentsDir);
|
|
82
126
|
const byEnv = new Map(roles.map((role) => [maxTurnsEnvName(role), role]));
|
|
83
|
-
|
|
127
|
+
// Keyed by the legacy-style name, so a BS_ hit and a SMITH_ hit for the
|
|
128
|
+
// same role collide here rather than both queuing an edit. BS_MAXTURNS_
|
|
129
|
+
// always sorts before SMITH_MAXTURNS_ (`B` < `S`), so a later legacy hit
|
|
130
|
+
// for a role BS_ already claimed is dropped, not overwritten.
|
|
131
|
+
const byRole = new Map();
|
|
84
132
|
for (const name of Object.keys(env).sort()) {
|
|
85
|
-
|
|
133
|
+
const parsed = parseMaxTurnsEnvName(name);
|
|
134
|
+
if (!parsed)
|
|
86
135
|
continue;
|
|
87
136
|
const raw = env[name];
|
|
88
137
|
if (raw === undefined || raw === '')
|
|
89
138
|
continue;
|
|
90
|
-
const role = byEnv.get(
|
|
139
|
+
const role = byEnv.get(parsed.legacyName);
|
|
91
140
|
if (role === undefined) {
|
|
92
141
|
throw new AgentsSyncError('agents.unknown-role', `${name} names no agent template; valid roles: ${roles.join(', ')}.`, { variable: name, roles });
|
|
93
142
|
}
|
|
94
|
-
|
|
143
|
+
const existing = byRole.get(parsed.legacyName);
|
|
144
|
+
if (existing?.isBs && !parsed.isBs)
|
|
145
|
+
continue;
|
|
146
|
+
byRole.set(parsed.legacyName, {
|
|
147
|
+
role,
|
|
148
|
+
env: name,
|
|
149
|
+
to: positiveInt(name, raw),
|
|
150
|
+
isBs: parsed.isBs,
|
|
151
|
+
});
|
|
95
152
|
}
|
|
96
|
-
requested.sort((a, b) => a.role.localeCompare(b.role));
|
|
153
|
+
const requested = [...byRole.values()].sort((a, b) => a.role.localeCompare(b.role));
|
|
97
154
|
const edits = requested.map(({ role, env: name, to }) => {
|
|
98
|
-
const file = path.join(agentsDir,
|
|
155
|
+
const file = path.join(agentsDir, templateFileFor(role));
|
|
99
156
|
const text = readFileSync(file, 'utf8');
|
|
100
157
|
const site = findMaxTurns(text, file);
|
|
101
158
|
return {
|
|
@@ -125,7 +182,7 @@ export function resetAgentMaxTurns(options = {}) {
|
|
|
125
182
|
throw new AgentsSyncError('agents.reset-unavailable', `--reset needs git HEAD, but ${agentsDir} is not inside a git checkout.`, { agentsDir });
|
|
126
183
|
}
|
|
127
184
|
const edits = roles.map((role) => {
|
|
128
|
-
const file = path.join(agentsDir,
|
|
185
|
+
const file = path.join(agentsDir, templateFileFor(role));
|
|
129
186
|
const rel = path.relative(topLevel, realpathSync(file)).split(path.sep).join('/');
|
|
130
187
|
let committed;
|
|
131
188
|
try {
|
|
@@ -72,6 +72,37 @@ function contains(root, child) {
|
|
|
72
72
|
const rel = path.relative(root, child);
|
|
73
73
|
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Resolve one declared artifact path to an absolute, real, on-disk location
|
|
77
|
+
* inside `<artifactsDir>/<taskId>/`, for a reader that is about to open the
|
|
78
|
+
* bytes rather than merely check the declaration — the dashboard's artifact
|
|
79
|
+
* route, not the gate. Reuses `checkArtifacts`'s containment and realpath
|
|
80
|
+
* logic so the two agree about what "inside the home" means; returns `null`
|
|
81
|
+
* for anything that logic would flag (`..`, an absolute path, a symlink that
|
|
82
|
+
* escapes, a missing file) rather than a reason, since the only caller turns
|
|
83
|
+
* every reason into the same 404.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveArtifactPath(taskId, declaredPath, artifactsDir = STATE_ARTIFACTS_DIR) {
|
|
86
|
+
try {
|
|
87
|
+
const root = path.resolve(artifactsDir);
|
|
88
|
+
const home = homeUnder(root, taskId);
|
|
89
|
+
if (path.isAbsolute(declaredPath))
|
|
90
|
+
return null;
|
|
91
|
+
const resolved = path.resolve(home, declaredPath);
|
|
92
|
+
if (resolved === home || !contains(home, resolved))
|
|
93
|
+
return null;
|
|
94
|
+
if (!existsSync(resolved))
|
|
95
|
+
return null;
|
|
96
|
+
const realHome = realOf(home);
|
|
97
|
+
const real = realOf(resolved);
|
|
98
|
+
if (!contains(realHome, real))
|
|
99
|
+
return null;
|
|
100
|
+
return real;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
75
106
|
/**
|
|
76
107
|
* Check every path a result declares as evidence. Relative paths resolve
|
|
77
108
|
* against the task's home, so `coverage.txt` means the obvious thing and the
|
|
@@ -129,6 +129,9 @@ export async function routeFindings(items, options, ctx, opts = {}) {
|
|
|
129
129
|
* Call this only once the finding has survived intake. A waived or refuted
|
|
130
130
|
* finding minting a `todo` follow-up would block the epic verdict on a bug the
|
|
131
131
|
* team already settled.
|
|
132
|
+
*
|
|
133
|
+
* `ctx.project`, when set, is stamped on the follow-up; without it the
|
|
134
|
+
* follow-up inherits the project the log holds for `routed.fromTaskId`.
|
|
132
135
|
*/
|
|
133
136
|
export async function recordReattribution(routed, finding, ctx, opts = {}) {
|
|
134
137
|
if (routed.attribution === 'gated')
|
|
@@ -139,6 +142,7 @@ export async function recordReattribution(routed, finding, ctx, opts = {}) {
|
|
|
139
142
|
taskId: routed.taskId,
|
|
140
143
|
objective: `Fix: ${finding.summary}`,
|
|
141
144
|
claims: routed.claims,
|
|
145
|
+
fromTaskId: routed.fromTaskId,
|
|
142
146
|
}, ctx, opts);
|
|
143
147
|
}
|
|
144
148
|
await appendEvent({
|
|
@@ -32,11 +32,16 @@
|
|
|
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
|
-
import { appendEvent } from './events.js';
|
|
37
|
+
import { appendEvent, eventTaskId, readLineageEvents, } 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, livePlanTasks, loadPlan, planClaimedTasks, } from './plan.js';
|
|
43
|
+
import { defaultKindFor, FACTORY_PROJECT } from './roadmap.js';
|
|
44
|
+
import { bareTaskId, epicOfTaskId, isQualifiedTaskId } from './taskId.js';
|
|
40
45
|
import { loadTaxonomy } from './taxonomy.js';
|
|
41
46
|
export class AuditError extends SmithError {
|
|
42
47
|
}
|
|
@@ -46,6 +51,30 @@ export class AuditError extends SmithError {
|
|
|
46
51
|
* overlap is meaningful.
|
|
47
52
|
*/
|
|
48
53
|
export const AUDIT_AXES = ['performance', 'code-quality', 'architecture', 'security'];
|
|
54
|
+
/**
|
|
55
|
+
* `mintAuditId` (audit.ts) mints `<YYYYMMDD>-<8 hex>` — a day stamp and eight
|
|
56
|
+
* hex characters joined by "-", never a "/". Audit.md's "Declare the artifact
|
|
57
|
+
* before the call" names the task id an audit axis turn dispatches and
|
|
58
|
+
* reports against as `<audit-id>.<axis>`. An ordinary task id is always
|
|
59
|
+
* "<epic>/<bare>" (taskId.ts) — qualified with a "/" by construction — so the
|
|
60
|
+
* two shapes cannot collide: this checks the exact mint format, not just the
|
|
61
|
+
* absence of "/", so a coincidentally dotted bare task id still reads as
|
|
62
|
+
* ordinary rather than as an axis.
|
|
63
|
+
*/
|
|
64
|
+
const AUDIT_AXIS_TASK_ID = new RegExp(`^\\d{8}-[0-9a-f]{8}\\.(${AUDIT_AXES.join('|')})$`);
|
|
65
|
+
export function isAuditAxisTaskId(taskId) {
|
|
66
|
+
return AUDIT_AXIS_TASK_ID.test(taskId);
|
|
67
|
+
}
|
|
68
|
+
const AUDIT_AXIS_ROW_ID = new RegExp(`^\\d{8}-[0-9a-f]{8}\\.(${AUDIT_AXES.join('|')})(-[A-Za-z0-9-]+)?$`);
|
|
69
|
+
/**
|
|
70
|
+
* Broader than `isAuditAxisTaskId`: also matches a re-run or cross-check turn
|
|
71
|
+
* dispatched under a suffixed id (`<audit-id>.performance-2`, `.security-codex`).
|
|
72
|
+
* Used only by the dashboard Kanban read to hide those rows; the judge gate
|
|
73
|
+
* keeps the exact matcher so its behaviour does not widen.
|
|
74
|
+
*/
|
|
75
|
+
export function isAuditAxisRowId(taskId) {
|
|
76
|
+
return AUDIT_AXIS_ROW_ID.test(taskId);
|
|
77
|
+
}
|
|
49
78
|
/** Spec §4.3's table, in the order that table is written. */
|
|
50
79
|
export const AUDIT_STATUSES = ['raised', 'merged', 'accepted', 'declined', 'fixed'];
|
|
51
80
|
/** Spec §4.4. Applied when the store is folded, not by a sweeper. */
|
|
@@ -251,11 +280,31 @@ export function foldAuditStore(lines, opts = {}) {
|
|
|
251
280
|
* The fingerprints an axis may not raise again. This is the whole of the
|
|
252
281
|
* dedupe half of §3.1 — `audit record` drops what this names and appends the
|
|
253
282
|
* rest.
|
|
283
|
+
*
|
|
284
|
+
* Each suppressing finding contributes two fingerprints, not one. Its
|
|
285
|
+
* stored `finding.fingerprint` is whatever `computeFingerprint` produced
|
|
286
|
+
* *when the line was written* — widening that normalizer (issue #178) does
|
|
287
|
+
* not rewrite `findings.jsonl`, so a decline recorded before a normalizer
|
|
288
|
+
* fix keeps its old hash forever. The raised line still carries its own
|
|
289
|
+
* `file_path`/`axis`/`summary`, so recomputing the fingerprint with today's
|
|
290
|
+
* normalizer costs nothing and lets a pre-existing decline suppress a
|
|
291
|
+
* re-raise that only differs in what the new normalizer now strips — no
|
|
292
|
+
* store rewrite, no migration. When the stored hash and the recomputed one
|
|
293
|
+
* already agree (the common case), the second `add` is a no-op.
|
|
254
294
|
*/
|
|
255
295
|
export function suppressedFingerprints(lines, opts = {}) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
296
|
+
const set = new Set();
|
|
297
|
+
for (const finding of foldAuditStore(lines, opts)) {
|
|
298
|
+
if (!finding.suppressesReraise)
|
|
299
|
+
continue;
|
|
300
|
+
set.add(finding.fingerprint);
|
|
301
|
+
set.add(computeFingerprint({
|
|
302
|
+
filePath: finding.file_path,
|
|
303
|
+
category: finding.axis,
|
|
304
|
+
summary: finding.summary,
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
return set;
|
|
259
308
|
}
|
|
260
309
|
function clusterOf(filePath, findings) {
|
|
261
310
|
const members = findings.filter((finding) => finding.status !== 'merged');
|
|
@@ -376,7 +425,7 @@ export function readAuditManifest(projectDir) {
|
|
|
376
425
|
function requireOpenAudit(projectDir) {
|
|
377
426
|
const manifest = readAuditManifest(projectDir);
|
|
378
427
|
if (manifest === undefined) {
|
|
379
|
-
throw new AuditError('audit.not-open', `no audit is open on ${path.resolve(projectDir)}: run \`
|
|
428
|
+
throw new AuditError('audit.not-open', `no audit is open on ${path.resolve(projectDir)}: run \`bs audit open\` first`, { projectDir: path.resolve(projectDir), manifestPath: auditManifestPath(projectDir) });
|
|
380
429
|
}
|
|
381
430
|
return manifest;
|
|
382
431
|
}
|
|
@@ -398,6 +447,39 @@ function mintAuditId(now) {
|
|
|
398
447
|
const day = now.toISOString().slice(0, 10).replaceAll('-', '');
|
|
399
448
|
return `${day}-${randomBytes(4).toString('hex')}`;
|
|
400
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* The name an audit calls `project` by, everywhere one is needed: the
|
|
452
|
+
* event's `project` field, the cut milestone's `- project:` line and goal
|
|
453
|
+
* sentence, and the cut spec's Project line.
|
|
454
|
+
*
|
|
455
|
+
* `path.basename(project)` names every OTHER project correctly, but this
|
|
456
|
+
* clone's own directory is not always named FACTORY_PROJECT
|
|
457
|
+
* (`blacksmith`) -- roadmap.ts's own note documents the real `black-smith`/
|
|
458
|
+
* `blacksmith` siblings this factory was built beside -- so a self-audit that
|
|
459
|
+
* used the basename wrote a name `collectProjects()` (projects.ts) had never
|
|
460
|
+
* seen, sent it looking for a checkout under this clone's own parent, and it
|
|
461
|
+
* found this very clone: registered a second time as a foreign, non-self ref
|
|
462
|
+
* that `--no-self` could not filter. Comparing against REPO_ROOT, resolved
|
|
463
|
+
* the same way `unwatchedProjects` (projects.ts) already does, is what tells
|
|
464
|
+
* "this clone, filed under its own directory name" from "a project that is
|
|
465
|
+
* genuinely named that".
|
|
466
|
+
*/
|
|
467
|
+
export function auditedProjectName(project) {
|
|
468
|
+
return isFactoryCheckout(project) ? FACTORY_PROJECT : path.basename(project);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* The `- kind:` a cut milestone declares alongside auditedProjectName's
|
|
472
|
+
* `- project:` line. roadmap.ts's own parser fixes the two together
|
|
473
|
+
* (`roadmap.factory-kind-fixed`): a milestone naming FACTORY_PROJECT must be
|
|
474
|
+
* kind "factory", never "product". A cut that named a self-audit
|
|
475
|
+
* FACTORY_PROJECT but still wrote a hard-coded "product" kind would stamp a
|
|
476
|
+
* milestone that fails to parse the moment `loadRoadmap()` reads it back, so
|
|
477
|
+
* this feeds auditedProjectName's own answer through roadmap.ts's
|
|
478
|
+
* defaultKindFor rather than re-deriving it separately.
|
|
479
|
+
*/
|
|
480
|
+
export function auditedProjectKind(project) {
|
|
481
|
+
return defaultKindFor(auditedProjectName(project));
|
|
482
|
+
}
|
|
401
483
|
/**
|
|
402
484
|
* The audit's events are ordinary events on the operator's session, stamped
|
|
403
485
|
* with the project's name so the multi-project timeline can tell whose audit
|
|
@@ -412,7 +494,7 @@ function emit(eventType, project, ctx, opts, payload) {
|
|
|
412
494
|
event_type: eventType,
|
|
413
495
|
plan_version: ctx.planVersion,
|
|
414
496
|
causal_parent: ctx.causalParent,
|
|
415
|
-
project:
|
|
497
|
+
project: auditedProjectName(project),
|
|
416
498
|
payload,
|
|
417
499
|
}, opts);
|
|
418
500
|
}
|
|
@@ -635,7 +717,7 @@ function cite(finding) {
|
|
|
635
717
|
function renderMilestone(input, project, findings) {
|
|
636
718
|
const audits = auditIdsOf(findings);
|
|
637
719
|
const goal = [
|
|
638
|
-
`Cut from audit ${audits.join(', ')} of \`${
|
|
720
|
+
`Cut from audit ${audits.join(', ')} of \`${auditedProjectName(project)}\` (${findings.length} accepted finding${findings.length === 1 ? '' : 's'}: ${severityCounts(findings)}).`,
|
|
639
721
|
'Each finding below is one acceptance criterion; the plan decides the tasks.',
|
|
640
722
|
...findings.map((finding) => `${cite(finding)}.`),
|
|
641
723
|
].join(' ');
|
|
@@ -644,8 +726,8 @@ function renderMilestone(input, project, findings) {
|
|
|
644
726
|
`- id: ${input.epicId}`,
|
|
645
727
|
'- status: planned',
|
|
646
728
|
`- epics: [${input.epicId}]`,
|
|
647
|
-
`- project: ${
|
|
648
|
-
|
|
729
|
+
`- project: ${auditedProjectName(project)}`,
|
|
730
|
+
`- kind: ${auditedProjectKind(project)}`,
|
|
649
731
|
`- goal: ${goal}`,
|
|
650
732
|
'',
|
|
651
733
|
].join('\n');
|
|
@@ -673,9 +755,9 @@ function renderSpec(input, project, findings) {
|
|
|
673
755
|
`# Epic spec — \`${input.epicId}\``,
|
|
674
756
|
'',
|
|
675
757
|
`- **Epic id** — \`${input.epicId}\``,
|
|
676
|
-
`- **Project** — \`${
|
|
758
|
+
`- **Project** — \`${auditedProjectName(project)}\`; every worktree is placed beside the clone the run is handed (\`AGENTS.md\` "Worktrees").`,
|
|
677
759
|
`- **Roadmap milestone** — \`${input.epicId}\` in \`factory/specs/roadmap.md\`.`,
|
|
678
|
-
`- **Provenance** — cut by \`
|
|
760
|
+
`- **Provenance** — cut by \`bs 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\`; \`bs audit resolve\` marks fixed only the ones a task in this plan still claims, and leaves the rest \`deferred\`.`,
|
|
679
761
|
'',
|
|
680
762
|
'## What this epic is',
|
|
681
763
|
'',
|
|
@@ -734,24 +816,166 @@ export async function cutAudit(projectDir, input, ctx, opts = {}) {
|
|
|
734
816
|
};
|
|
735
817
|
}
|
|
736
818
|
/**
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
819
|
+
* The epic's newest plan, however `resolveOpts` asks for it found. Refuses
|
|
820
|
+
* rather than guesses: `resolveAudit` must never treat "I could not find a
|
|
821
|
+
* plan" as "nothing is claimed", because that reading marks every carried
|
|
822
|
+
* finding fixed.
|
|
823
|
+
*/
|
|
824
|
+
function resolveEpicPlan(epicId, resolveOpts) {
|
|
825
|
+
if (resolveOpts.plan !== undefined)
|
|
826
|
+
return resolveOpts.plan;
|
|
827
|
+
const planOpts = resolveOpts.planOpts ?? {};
|
|
828
|
+
const version = latestPlanVersion(epicId, planOpts);
|
|
829
|
+
if (version === null) {
|
|
830
|
+
const searchedDir = path.join(planOpts.specsDir ?? SPECS_ACTIVE_DIR, epicId);
|
|
831
|
+
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 });
|
|
832
|
+
}
|
|
833
|
+
return loadPlan(epicId, version, planOpts);
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* `--except` entries against the epic's carried fingerprints, full or a
|
|
837
|
+
* unique prefix — the same latitude other audit verbs give a fingerprint
|
|
838
|
+
* argument. A value that matches nothing is refused as a likely typo (same
|
|
839
|
+
* spirit as `audit.unknown-epic`); one whose prefix matches more than one
|
|
840
|
+
* carried fingerprint is refused rather than guessed.
|
|
741
841
|
*/
|
|
742
|
-
|
|
842
|
+
function matchExceptFingerprints(except, carried, epicId) {
|
|
843
|
+
const known = carried.map((finding) => finding.fingerprint);
|
|
844
|
+
const matched = new Set();
|
|
845
|
+
for (const value of except) {
|
|
846
|
+
if (known.includes(value)) {
|
|
847
|
+
matched.add(value);
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
const candidates = known.filter((fingerprint) => fingerprint.startsWith(value));
|
|
851
|
+
if (candidates.length === 1) {
|
|
852
|
+
matched.add(candidates[0]);
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
if (candidates.length > 1) {
|
|
856
|
+
throw new AuditError('audit.ambiguous-except', `--except ${value} matches ${candidates.length} findings epic ${epicId} carries: ${candidates.join(', ')}`, { epic: epicId, value, candidates });
|
|
857
|
+
}
|
|
858
|
+
throw new AuditError('audit.unknown-finding', `--except ${value} matches no finding epic ${epicId} carries`, { epic: epicId, value });
|
|
859
|
+
}
|
|
860
|
+
return matched;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Every task id a `wave-merged` event says landed on the epic's integration
|
|
864
|
+
* branch — the merge queue's `emitWaveMerged` (`taskEvents.ts`) is the one
|
|
865
|
+
* writer, so this is "this task's change shipped," not a guess reconstructed
|
|
866
|
+
* from gate or task-result events. Both fields it may carry are read: the
|
|
867
|
+
* `payload.task_ids` array `emitWaveMerged` always sets, and the envelope
|
|
868
|
+
* `task_id` some hand-appended events set alone (`db/projector.ts`'s
|
|
869
|
+
* `waveTaskIds` comment: reading only one of the two silently dropped merges
|
|
870
|
+
* during the dogfood run).
|
|
871
|
+
*/
|
|
872
|
+
function mergedTaskIds(events) {
|
|
873
|
+
const ids = new Set();
|
|
874
|
+
for (const { record } of events) {
|
|
875
|
+
if (record.event_type !== 'wave-merged')
|
|
876
|
+
continue;
|
|
877
|
+
const payloadIds = record.payload.task_ids;
|
|
878
|
+
if (Array.isArray(payloadIds)) {
|
|
879
|
+
for (const id of payloadIds) {
|
|
880
|
+
if (typeof id === 'string' && id.length > 0)
|
|
881
|
+
ids.add(id);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
const taskId = eventTaskId(record);
|
|
885
|
+
if (taskId !== null)
|
|
886
|
+
ids.add(taskId);
|
|
887
|
+
}
|
|
888
|
+
return ids;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Claims of a plan task that is `superseded` in the newest plan but whose
|
|
892
|
+
* change already merged into the epic's integration branch before a later
|
|
893
|
+
* replan superseded it — the false negative `planClaimedTasks` alone cannot
|
|
894
|
+
* see, because `livePlanTasks` drops every dead id whether or not its work
|
|
895
|
+
* ever shipped.
|
|
896
|
+
*
|
|
897
|
+
* A task superseded BEFORE it merged must still not count: that is the
|
|
898
|
+
* "operator scoped the plan narrower on purpose" case this function's caller
|
|
899
|
+
* already protects, and `merged` (built only from `wave-merged` events) is
|
|
900
|
+
* empty for it. A same-id supersede (D-121) is excluded too, via `liveIds` —
|
|
901
|
+
* its live successor is already in `planClaimedTasks`, so counting the dead
|
|
902
|
+
* copy again would only repeat the same claims under a second entry.
|
|
903
|
+
*
|
|
904
|
+
* A merged id counts only when it is qualified under THIS epic
|
|
905
|
+
* (`epicOfTaskId(id) === epicId`) and its bare half matches the plan task's.
|
|
906
|
+
* `taskIdsMatch`'s cross-spelling fallback is deliberately not used here: a
|
|
907
|
+
* `wave-merged` payload carries no `epic_id`, and `readLineageEvents` is not
|
|
908
|
+
* epic-scoped, so a bare id in the lineage could belong to a legacy
|
|
909
|
+
* pre-D-46 session or to a different epic that merged waves in the same
|
|
910
|
+
* lineage. Trusting a bare match would mark a finding `fixed` when nothing
|
|
911
|
+
* in *this* epic actually shipped — the wrong direction for an operation
|
|
912
|
+
* that must never be unsure in the optimistic direction. A bare merged id is
|
|
913
|
+
* therefore ignored, same as before this file learned about merges at all.
|
|
914
|
+
*/
|
|
915
|
+
function mergedSupersededClaims(plan, merged, epicId) {
|
|
916
|
+
if (merged.size === 0)
|
|
917
|
+
return [];
|
|
918
|
+
const liveIds = new Set(livePlanTasks(plan).map((task) => task.task_id));
|
|
919
|
+
const mergedBareIds = new Set([...merged]
|
|
920
|
+
.filter((id) => isQualifiedTaskId(id) && epicOfTaskId(id) === epicId)
|
|
921
|
+
.map((id) => bareTaskId(id)));
|
|
922
|
+
const claimed = new Map();
|
|
923
|
+
for (const task of plan.tasks) {
|
|
924
|
+
if (task.task_status !== 'superseded')
|
|
925
|
+
continue;
|
|
926
|
+
if (liveIds.has(task.task_id))
|
|
927
|
+
continue;
|
|
928
|
+
if (claimed.has(task.task_id))
|
|
929
|
+
continue;
|
|
930
|
+
if (mergedBareIds.has(bareTaskId(task.task_id)))
|
|
931
|
+
claimed.set(task.task_id, task);
|
|
932
|
+
}
|
|
933
|
+
return [...claimed.values()]
|
|
934
|
+
.filter((task) => Array.isArray(task.claims))
|
|
935
|
+
.map((task) => ({ task_id: task.task_id, claims: task.claims }));
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* `audit resolve <project-dir> --epic <epic-id>`. Appends a `fixed` line only
|
|
939
|
+
* for a carried finding some task in the epic's newest plan still claims
|
|
940
|
+
* (`resolveFindingOwner`, D-41/P9-24), OR a task the plan now shows
|
|
941
|
+
* `superseded` whose change already merged into the epic's integration
|
|
942
|
+
* branch before it was superseded (`mergedSupersededClaims`) — a task
|
|
943
|
+
* superseded before it ever merged delivered nothing, so it is not counted
|
|
944
|
+
* either way. An operator who scoped the plan narrower than the audit
|
|
945
|
+
* deferred the rest on purpose, and marking them fixed anyway would erase
|
|
946
|
+
* that choice. Unclaimed findings stay exactly as they were and come back in
|
|
947
|
+
* `deferred`; `--except` forces specific fingerprints there even when a task
|
|
948
|
+
* claims them. Called from `/bs run`'s epic-close step, so it is repeatable;
|
|
949
|
+
* an epic no finding carries is refused, because the likely cause is a typo
|
|
950
|
+
* and a silent no-op would leave every finding open. A plan that cannot be
|
|
951
|
+
* found is refused too (`audit.no-plan`): being unsure must never mark
|
|
952
|
+
* anything fixed.
|
|
953
|
+
*/
|
|
954
|
+
export async function resolveAudit(projectDir, epicId, ctx, opts = {}, resolveOpts = {}) {
|
|
743
955
|
const project = path.resolve(projectDir);
|
|
744
956
|
const carried = foldAuditStore(readAuditStore(project)).filter((finding) => finding.epic === epicId);
|
|
745
957
|
if (carried.length === 0) {
|
|
746
|
-
throw new AuditError('audit.unknown-epic', `no finding in ${auditStorePath(project)} carries epic ${epicId}: was it cut with \`
|
|
958
|
+
throw new AuditError('audit.unknown-epic', `no finding in ${auditStorePath(project)} carries epic ${epicId}: was it cut with \`bs audit cut\`?`, { epic: epicId });
|
|
747
959
|
}
|
|
748
960
|
const open = carried.filter((finding) => finding.status !== 'fixed');
|
|
961
|
+
const plan = resolveEpicPlan(epicId, resolveOpts);
|
|
962
|
+
const lineage = await readLineageEvents(ctx.sessionId, opts);
|
|
963
|
+
const claimedTasks = [
|
|
964
|
+
...planClaimedTasks(plan),
|
|
965
|
+
...mergedSupersededClaims(plan, mergedTaskIds(lineage), epicId),
|
|
966
|
+
];
|
|
967
|
+
const except = matchExceptFingerprints(resolveOpts.except ?? [], carried, epicId);
|
|
968
|
+
const toFix = open.filter((finding) => !except.has(finding.fingerprint) &&
|
|
969
|
+
resolveFindingOwner(finding.file_path, claimedTasks).owner !== 'unclaimed');
|
|
970
|
+
const toFixFingerprints = new Set(toFix.map((finding) => finding.fingerprint));
|
|
971
|
+
const deferred = open.filter((finding) => !toFixFingerprints.has(finding.fingerprint));
|
|
749
972
|
const stored = await emit('audit-resolved', project, ctx, opts, {
|
|
750
973
|
epic: epicId,
|
|
751
|
-
fixed:
|
|
974
|
+
fixed: toFix.map((finding) => finding.fingerprint),
|
|
975
|
+
deferred: deferred.map((finding) => finding.fingerprint),
|
|
752
976
|
});
|
|
753
977
|
const ts = new Date().toISOString();
|
|
754
|
-
appendAuditLines(project,
|
|
978
|
+
appendAuditLines(project, toFix.map((finding) => ({
|
|
755
979
|
fingerprint: finding.fingerprint,
|
|
756
980
|
status: 'fixed',
|
|
757
981
|
ts,
|
|
@@ -761,10 +985,11 @@ export async function resolveAudit(projectDir, epicId, ctx, opts = {}) {
|
|
|
761
985
|
})));
|
|
762
986
|
return {
|
|
763
987
|
epic: epicId,
|
|
764
|
-
fixed:
|
|
988
|
+
fixed: toFix.map((finding) => finding.fingerprint),
|
|
765
989
|
already: carried
|
|
766
990
|
.filter((finding) => finding.status === 'fixed')
|
|
767
991
|
.map((finding) => finding.fingerprint),
|
|
992
|
+
deferred: deferred.map((finding) => finding.fingerprint),
|
|
768
993
|
event_id: stored.event_id,
|
|
769
994
|
};
|
|
770
995
|
}
|
|
@@ -79,11 +79,15 @@ function securityFields(proposal, ctx) {
|
|
|
79
79
|
case 'growth-review-due':
|
|
80
80
|
return [];
|
|
81
81
|
case 'error-report':
|
|
82
|
-
// Refused ahead of the keyword match; listed so
|
|
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.
|
|
83
87
|
return [
|
|
84
88
|
{ field: 'task ref', value: proposal.taskRef },
|
|
85
89
|
{ field: 'error class', value: proposal.errorClass },
|
|
86
|
-
{ field: 'project', value: proposal.project },
|
|
90
|
+
{ field: 'project', value: proposal.project ?? '(unresolved)' },
|
|
87
91
|
];
|
|
88
92
|
}
|
|
89
93
|
}
|