@juzser/blacksmith 0.1.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 +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/bs audit` — the store, the fold and the two keys.
|
|
3
|
+
*
|
|
4
|
+
* `docs/specs/audit-command-scope.md` is the contract; this file implements
|
|
5
|
+
* §3 (the two keys and the ranking) and §4 (the findings store). The parts
|
|
6
|
+
* that are judgment rather than mechanism — which axis prompt to write, which
|
|
7
|
+
* cluster is really one defect, which finding becomes epic scope — live in
|
|
8
|
+
* `.claude/skills/bs/audit.md` and stop in front of the operator.
|
|
9
|
+
*
|
|
10
|
+
* Three things about this module are deliberate and easy to undo by accident:
|
|
11
|
+
*
|
|
12
|
+
* - **It is not `findings.ts`.** `factory/specs/schema/finding.schema.json`
|
|
13
|
+
* requires `task_id`, and an audit finding is project-shaped: it is about a
|
|
14
|
+
* file in somebody else's repo, raised before any task exists to own it. So
|
|
15
|
+
* the audit keeps its own store and never calls `smith findings raise`
|
|
16
|
+
* (spec §4.2). What it does reuse, unchanged, is `computeFingerprint` and
|
|
17
|
+
* `normalizeFilePath` — a second normalizer is a second implementation that
|
|
18
|
+
* is allowed to disagree with the one the fingerprints were built with.
|
|
19
|
+
*
|
|
20
|
+
* - **Status is a line, never an edit** (spec §4.3). The store is append-only
|
|
21
|
+
* JSONL and every view of it comes from a replay. A `fixed` finding whose
|
|
22
|
+
* fingerprint comes back is a regression, and the history has to be able to
|
|
23
|
+
* show both.
|
|
24
|
+
*
|
|
25
|
+
* - **The dedupe key and the convergence key are different instruments**
|
|
26
|
+
* (spec §3.1). The fingerprint answers "did I already raise this?"; it
|
|
27
|
+
* cannot answer "did two axes find the same defect?", because two axes
|
|
28
|
+
* describing one defect write two different summaries. Convergence keys on
|
|
29
|
+
* the normalized file path, produces a *candidate* cluster, and hands it
|
|
30
|
+
* over to be judged. Nothing here merges anything.
|
|
31
|
+
*/
|
|
32
|
+
import { randomBytes } from 'node:crypto';
|
|
33
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { SmithError } from './errors.js';
|
|
36
|
+
import { appendEvent } from './events.js';
|
|
37
|
+
import { computeFingerprint, normalizeFilePath } from './findings.js';
|
|
38
|
+
import { runGit } from './git.js';
|
|
39
|
+
import { checkWorktreeImmutable, fingerprintWorktree, } from './immutability.js';
|
|
40
|
+
import { loadTaxonomy } from './taxonomy.js';
|
|
41
|
+
export class AuditError extends SmithError {
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Four, fixed, and not operator-selectable in v1 (spec §2.1). A fifth axis is
|
|
45
|
+
* a spec change, not a flag: the whole design rests on four returns whose
|
|
46
|
+
* overlap is meaningful.
|
|
47
|
+
*/
|
|
48
|
+
export const AUDIT_AXES = ['performance', 'code-quality', 'architecture', 'security'];
|
|
49
|
+
/** Spec §4.3's table, in the order that table is written. */
|
|
50
|
+
export const AUDIT_STATUSES = ['raised', 'merged', 'accepted', 'declined', 'fixed'];
|
|
51
|
+
/** Spec §4.4. Applied when the store is folded, not by a sweeper. */
|
|
52
|
+
export const DECLINE_EXPIRY_DAYS = 90;
|
|
53
|
+
const STORE_DIR = '.blacksmith';
|
|
54
|
+
const STORE_FILE = 'findings.jsonl';
|
|
55
|
+
/**
|
|
56
|
+
* Fields the store mints and the fold owns. An axis that supplies one of them
|
|
57
|
+
* is claiming an identity it cannot know — the mirror of
|
|
58
|
+
* `findings.evidence-carries-identity`, and refused for the same reason.
|
|
59
|
+
*/
|
|
60
|
+
const EVIDENCE_IDENTITY_FIELDS = [
|
|
61
|
+
'fingerprint',
|
|
62
|
+
'status',
|
|
63
|
+
'axis',
|
|
64
|
+
'audit_id',
|
|
65
|
+
'ts',
|
|
66
|
+
'epic',
|
|
67
|
+
'same_as',
|
|
68
|
+
];
|
|
69
|
+
let cachedSeverities;
|
|
70
|
+
function severities() {
|
|
71
|
+
if (cachedSeverities === undefined) {
|
|
72
|
+
const declared = loadTaxonomy().dimensions.severity;
|
|
73
|
+
if (declared === undefined || declared.length === 0) {
|
|
74
|
+
throw new AuditError('audit.no-severity-vocabulary', 'factory/policies/taxonomy.yml declares no `severity` dimension to rank against');
|
|
75
|
+
}
|
|
76
|
+
cachedSeverities = declared;
|
|
77
|
+
}
|
|
78
|
+
return cachedSeverities;
|
|
79
|
+
}
|
|
80
|
+
/** Index in the taxonomy list, which is written most-severe-first. */
|
|
81
|
+
function severityRank(severity) {
|
|
82
|
+
const rank = severities().indexOf(severity);
|
|
83
|
+
return rank === -1 ? severities().length : rank;
|
|
84
|
+
}
|
|
85
|
+
function mostSevere(values) {
|
|
86
|
+
const [worst] = [...values].sort((a, b) => severityRank(a) - severityRank(b));
|
|
87
|
+
if (worst === undefined) {
|
|
88
|
+
throw new AuditError('audit.empty-cluster', 'a cluster with no members has no severity to report', {});
|
|
89
|
+
}
|
|
90
|
+
return worst;
|
|
91
|
+
}
|
|
92
|
+
/** `<project>/.blacksmith/findings.jsonl`, absolute (spec §4). */
|
|
93
|
+
export function auditStorePath(projectDir) {
|
|
94
|
+
return path.join(path.resolve(projectDir), STORE_DIR, STORE_FILE);
|
|
95
|
+
}
|
|
96
|
+
export function readAuditStore(projectDir) {
|
|
97
|
+
const storePath = auditStorePath(projectDir);
|
|
98
|
+
if (!existsSync(storePath))
|
|
99
|
+
return [];
|
|
100
|
+
const lines = [];
|
|
101
|
+
const text = readFileSync(storePath, 'utf8');
|
|
102
|
+
text.split('\n').forEach((raw, index) => {
|
|
103
|
+
if (raw.trim() === '')
|
|
104
|
+
return;
|
|
105
|
+
try {
|
|
106
|
+
lines.push(JSON.parse(raw));
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
throw new AuditError('audit.store-unparseable', `${storePath}:${index + 1} is not JSON: ${error.message}`, { storePath, line: index + 1 });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
export function appendAuditLines(projectDir, lines) {
|
|
115
|
+
if (lines.length === 0)
|
|
116
|
+
return;
|
|
117
|
+
const storePath = auditStorePath(projectDir);
|
|
118
|
+
mkdirSync(path.dirname(storePath), { recursive: true });
|
|
119
|
+
appendFileSync(storePath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8');
|
|
120
|
+
}
|
|
121
|
+
function requireString(item, field, index) {
|
|
122
|
+
const value = item[field];
|
|
123
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
124
|
+
throw new AuditError('audit.evidence-incomplete', `evidence[${index}] has no \`${field}\``, {
|
|
125
|
+
index,
|
|
126
|
+
field,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Validate one axis's evidence and give each item its identity. Pure: it
|
|
133
|
+
* touches no store, so `audit record` can fingerprint first and consult the
|
|
134
|
+
* fold second.
|
|
135
|
+
*/
|
|
136
|
+
export function normalizeEvidence(axis, evidence) {
|
|
137
|
+
if (!AUDIT_AXES.includes(axis)) {
|
|
138
|
+
throw new AuditError('audit.unknown-axis', `\`${axis}\` is not one of the four audit axes`, {
|
|
139
|
+
axis,
|
|
140
|
+
axes: [...AUDIT_AXES],
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return evidence.map((item, index) => {
|
|
144
|
+
for (const field of EVIDENCE_IDENTITY_FIELDS) {
|
|
145
|
+
if (item[field] !== undefined) {
|
|
146
|
+
throw new AuditError('audit.evidence-carries-identity', `evidence[${index}] sets \`${field}\`, which the store mints — an axis reports what it saw, not what it is`, { index, field });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const filePath = normalizeFilePath(requireString(item, 'file_path', index));
|
|
150
|
+
const summary = requireString(item, 'summary', index);
|
|
151
|
+
const severity = requireString(item, 'severity', index);
|
|
152
|
+
if (!severities().includes(severity)) {
|
|
153
|
+
throw new AuditError('audit.unknown-severity', `evidence[${index}] grades \`${severity}\`, which taxonomy.yml does not declare`, { index, severity, severities: [...severities()] });
|
|
154
|
+
}
|
|
155
|
+
const scenario = item.failure_scenario;
|
|
156
|
+
if (scenario === undefined || typeof scenario !== 'object') {
|
|
157
|
+
throw new AuditError('audit.evidence-incomplete', `evidence[${index}] has no \`failure_scenario\` — a finding with no failure to point at is an opinion`, { index, field: 'failure_scenario' });
|
|
158
|
+
}
|
|
159
|
+
for (const leg of ['inputs', 'expected', 'actual']) {
|
|
160
|
+
if (typeof scenario?.[leg] !== 'string' || scenario[leg].trim() === '') {
|
|
161
|
+
throw new AuditError('audit.evidence-incomplete', `evidence[${index}] has no \`failure_scenario.${leg}\` — a finding with no failure to point at is an opinion`, { index, field: `failure_scenario.${leg}` });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const confidence = item.confidence;
|
|
165
|
+
if (typeof confidence !== 'number' || !Number.isFinite(confidence)) {
|
|
166
|
+
throw new AuditError('audit.evidence-incomplete', `evidence[${index}] has no numeric \`confidence\``, { index, field: 'confidence' });
|
|
167
|
+
}
|
|
168
|
+
if (confidence < 0 || confidence > 1) {
|
|
169
|
+
throw new AuditError('audit.confidence-out-of-range', `evidence[${index}] reports confidence ${confidence}; the field is 0..1`, { index, confidence });
|
|
170
|
+
}
|
|
171
|
+
const body = {
|
|
172
|
+
axis,
|
|
173
|
+
severity,
|
|
174
|
+
file_path: filePath,
|
|
175
|
+
summary,
|
|
176
|
+
failure_scenario: {
|
|
177
|
+
inputs: scenario.inputs,
|
|
178
|
+
expected: scenario.expected,
|
|
179
|
+
actual: scenario.actual,
|
|
180
|
+
},
|
|
181
|
+
confidence,
|
|
182
|
+
};
|
|
183
|
+
return { fingerprint: fingerprintOf(body), ...body };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/** The identity `audit record` appends a normalized body under. */
|
|
187
|
+
export function fingerprintOf(body) {
|
|
188
|
+
return computeFingerprint({
|
|
189
|
+
filePath: body.file_path,
|
|
190
|
+
// The axis stands where a task finding's `finding_category` would. The
|
|
191
|
+
// audit record has no category of its own, and the axis is the honest
|
|
192
|
+
// mapping: two axes describing one file are meant to fingerprint apart,
|
|
193
|
+
// because §3.1's convergence is what joins them, not the dedupe key.
|
|
194
|
+
category: body.axis,
|
|
195
|
+
summary: body.summary,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function ageInDays(from, now) {
|
|
199
|
+
const then = Date.parse(from);
|
|
200
|
+
if (Number.isNaN(then))
|
|
201
|
+
return Number.POSITIVE_INFINITY;
|
|
202
|
+
return (now.getTime() - then) / 86_400_000;
|
|
203
|
+
}
|
|
204
|
+
/** Replay the store. Spec §4.3 and §4.4 are both decided here and nowhere else. */
|
|
205
|
+
export function foldAuditStore(lines, opts = {}) {
|
|
206
|
+
const now = opts.now ?? new Date();
|
|
207
|
+
const order = [];
|
|
208
|
+
const histories = new Map();
|
|
209
|
+
for (const line of lines) {
|
|
210
|
+
let history = histories.get(line.fingerprint);
|
|
211
|
+
if (history === undefined) {
|
|
212
|
+
history = [];
|
|
213
|
+
histories.set(line.fingerprint, history);
|
|
214
|
+
order.push(line.fingerprint);
|
|
215
|
+
}
|
|
216
|
+
history.push(line);
|
|
217
|
+
}
|
|
218
|
+
return order.map((fingerprint) => {
|
|
219
|
+
const history = histories.get(fingerprint);
|
|
220
|
+
const bodyLine = [...history].reverse().find((line) => line.status === 'raised');
|
|
221
|
+
if (bodyLine === undefined) {
|
|
222
|
+
throw new AuditError('audit.decision-without-finding', `${fingerprint} carries decisions but no raised line to hang them off`, { fingerprint, statuses: history.map((line) => line.status) });
|
|
223
|
+
}
|
|
224
|
+
const last = history[history.length - 1];
|
|
225
|
+
if (last === undefined) {
|
|
226
|
+
throw new AuditError('audit.decision-without-finding', `${fingerprint} folded to an empty history`, { fingerprint });
|
|
227
|
+
}
|
|
228
|
+
const expired = last.status === 'declined' && ageInDays(last.ts, now) > DECLINE_EXPIRY_DAYS;
|
|
229
|
+
const suppressesReraise = last.status === 'declined' ? !expired : last.status !== 'fixed';
|
|
230
|
+
const sameAs = [...history].reverse().find((line) => line.same_as !== undefined)?.same_as;
|
|
231
|
+
const epic = [...history].reverse().find((line) => line.epic !== undefined)?.epic;
|
|
232
|
+
return {
|
|
233
|
+
fingerprint,
|
|
234
|
+
axis: bodyLine.axis,
|
|
235
|
+
severity: bodyLine.severity,
|
|
236
|
+
file_path: bodyLine.file_path,
|
|
237
|
+
summary: bodyLine.summary,
|
|
238
|
+
failure_scenario: bodyLine.failure_scenario,
|
|
239
|
+
confidence: bodyLine.confidence,
|
|
240
|
+
status: last.status,
|
|
241
|
+
statusTs: last.ts,
|
|
242
|
+
expired,
|
|
243
|
+
suppressesReraise,
|
|
244
|
+
...(sameAs === undefined ? {} : { sameAs }),
|
|
245
|
+
...(epic === undefined ? {} : { epic }),
|
|
246
|
+
history,
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The fingerprints an axis may not raise again. This is the whole of the
|
|
252
|
+
* dedupe half of §3.1 — `audit record` drops what this names and appends the
|
|
253
|
+
* rest.
|
|
254
|
+
*/
|
|
255
|
+
export function suppressedFingerprints(lines, opts = {}) {
|
|
256
|
+
return new Set(foldAuditStore(lines, opts)
|
|
257
|
+
.filter((finding) => finding.suppressesReraise)
|
|
258
|
+
.map((finding) => finding.fingerprint));
|
|
259
|
+
}
|
|
260
|
+
function clusterOf(filePath, findings) {
|
|
261
|
+
const members = findings.filter((finding) => finding.status !== 'merged');
|
|
262
|
+
const mergedInto = findings
|
|
263
|
+
.filter((finding) => finding.status === 'merged')
|
|
264
|
+
.map((finding) => ({ fingerprint: finding.fingerprint, sameAs: finding.sameAs ?? '' }));
|
|
265
|
+
const ranked = members.length > 0 ? members : findings;
|
|
266
|
+
return {
|
|
267
|
+
filePath,
|
|
268
|
+
members,
|
|
269
|
+
mergedInto,
|
|
270
|
+
convergence: new Set(members.map((finding) => finding.axis)).size,
|
|
271
|
+
severity: mostSevere(ranked.map((finding) => finding.severity)),
|
|
272
|
+
confidence: Math.max(...ranked.map((finding) => finding.confidence)),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* §3.1's convergence half. Groups on the normalized file path and hands the
|
|
277
|
+
* clusters over — it does not merge them, and a cluster nobody merges stays
|
|
278
|
+
* several findings.
|
|
279
|
+
*/
|
|
280
|
+
export function clusterByPath(findings) {
|
|
281
|
+
const byPath = new Map();
|
|
282
|
+
for (const finding of findings) {
|
|
283
|
+
const bucket = byPath.get(finding.file_path);
|
|
284
|
+
if (bucket === undefined)
|
|
285
|
+
byPath.set(finding.file_path, [finding]);
|
|
286
|
+
else
|
|
287
|
+
bucket.push(finding);
|
|
288
|
+
}
|
|
289
|
+
return [...byPath.entries()]
|
|
290
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
291
|
+
.map(([filePath, bucket]) => clusterOf(filePath, bucket));
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* §3.2. Severity first, then convergence, then confidence — a high-confidence
|
|
295
|
+
* S4 must never outrank a two-axis S2. The fourth key is the representative
|
|
296
|
+
* fingerprint, so a full tie ranks the same way on every run rather than
|
|
297
|
+
* inheriting the order the store happened to be written in.
|
|
298
|
+
*
|
|
299
|
+
* Only undecided findings are offered: a cluster whose members are all
|
|
300
|
+
* accepted, declined, merged or fixed has already been answered, and putting
|
|
301
|
+
* it back in front of the operator would read as a new question.
|
|
302
|
+
*/
|
|
303
|
+
export function rankClusters(clusters) {
|
|
304
|
+
return clusters
|
|
305
|
+
.map((cluster) => {
|
|
306
|
+
const live = cluster.members.filter((finding) => finding.status === 'raised');
|
|
307
|
+
if (live.length === 0)
|
|
308
|
+
return undefined;
|
|
309
|
+
return {
|
|
310
|
+
...cluster,
|
|
311
|
+
members: live,
|
|
312
|
+
convergence: new Set(live.map((finding) => finding.axis)).size,
|
|
313
|
+
severity: mostSevere(live.map((finding) => finding.severity)),
|
|
314
|
+
confidence: Math.max(...live.map((finding) => finding.confidence)),
|
|
315
|
+
};
|
|
316
|
+
})
|
|
317
|
+
.filter((cluster) => cluster !== undefined)
|
|
318
|
+
.sort((a, b) => {
|
|
319
|
+
const bySeverity = severityRank(a.severity) - severityRank(b.severity);
|
|
320
|
+
if (bySeverity !== 0)
|
|
321
|
+
return bySeverity;
|
|
322
|
+
if (a.convergence !== b.convergence)
|
|
323
|
+
return b.convergence - a.convergence;
|
|
324
|
+
if (a.confidence !== b.confidence)
|
|
325
|
+
return b.confidence - a.confidence;
|
|
326
|
+
const left = representative(a);
|
|
327
|
+
const right = representative(b);
|
|
328
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
function representative(cluster) {
|
|
332
|
+
return [...cluster.members.map((finding) => finding.fingerprint)].sort()[0] ?? cluster.filePath;
|
|
333
|
+
}
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// §6 — the verbs.
|
|
336
|
+
//
|
|
337
|
+
// Every verb takes the project directory as its positional and nothing reads a
|
|
338
|
+
// cwd. The one piece of state between verbs is `<project>/.blacksmith/audit.json`,
|
|
339
|
+
// the manifest of the open audit: `open` writes it, `record` reads the audit id
|
|
340
|
+
// off it, `close` reads its baseline fingerprint and deletes it. It lives under
|
|
341
|
+
// `.blacksmith/` because that is state (spec §1.3), and it is one file rather
|
|
342
|
+
// than a "current audit" pointer because a project has at most one worktree at
|
|
343
|
+
// the fixed audit path, so it has at most one open audit.
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
const MANIFEST_FILE = 'audit.json';
|
|
346
|
+
/** The worktree's name under `<parent>/.wt/<project>/`, beside the task worktrees. */
|
|
347
|
+
const AUDIT_WORKTREE_NAME = 'audit';
|
|
348
|
+
export const AUDIT_DECISIONS = ['accept', 'decline', 'merge'];
|
|
349
|
+
const STATUS_OF_DECISION = {
|
|
350
|
+
accept: 'accepted',
|
|
351
|
+
decline: 'declined',
|
|
352
|
+
merge: 'merged',
|
|
353
|
+
};
|
|
354
|
+
export function auditManifestPath(projectDir) {
|
|
355
|
+
return path.join(path.resolve(projectDir), STORE_DIR, MANIFEST_FILE);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Mirrors `taskWorktreeDir`: beside the clone, never inside it, so a project
|
|
359
|
+
* outside this repo keeps its audit worktree outside it too.
|
|
360
|
+
*/
|
|
361
|
+
export function auditWorktreeDir(projectDir) {
|
|
362
|
+
const project = path.resolve(projectDir);
|
|
363
|
+
return path.join(path.dirname(project), '.wt', path.basename(project), AUDIT_WORKTREE_NAME);
|
|
364
|
+
}
|
|
365
|
+
export function readAuditManifest(projectDir) {
|
|
366
|
+
const manifestPath = auditManifestPath(projectDir);
|
|
367
|
+
if (!existsSync(manifestPath))
|
|
368
|
+
return undefined;
|
|
369
|
+
try {
|
|
370
|
+
return JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
throw new AuditError('audit.manifest-unparseable', `${manifestPath} is not JSON: ${error.message}`, { manifestPath });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function requireOpenAudit(projectDir) {
|
|
377
|
+
const manifest = readAuditManifest(projectDir);
|
|
378
|
+
if (manifest === undefined) {
|
|
379
|
+
throw new AuditError('audit.not-open', `no audit is open on ${path.resolve(projectDir)}: run \`smith audit open\` first`, { projectDir: path.resolve(projectDir), manifestPath: auditManifestPath(projectDir) });
|
|
380
|
+
}
|
|
381
|
+
return manifest;
|
|
382
|
+
}
|
|
383
|
+
function requireGitProject(projectDir) {
|
|
384
|
+
const project = path.resolve(projectDir);
|
|
385
|
+
if (!existsSync(project)) {
|
|
386
|
+
throw new AuditError('audit.project-missing', `${project} does not exist`, { project });
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
runGit(project, ['rev-parse', '--is-inside-work-tree']);
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
throw new AuditError('audit.not-a-git-repository', `${project} is not a git repository: ${error.message}`, { project });
|
|
393
|
+
}
|
|
394
|
+
return project;
|
|
395
|
+
}
|
|
396
|
+
/** `<YYYYMMDD>-<8 hex>`: sortable by day, unique enough to name a worktree by. */
|
|
397
|
+
function mintAuditId(now) {
|
|
398
|
+
const day = now.toISOString().slice(0, 10).replaceAll('-', '');
|
|
399
|
+
return `${day}-${randomBytes(4).toString('hex')}`;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The audit's events are ordinary events on the operator's session, stamped
|
|
403
|
+
* with the project's name so the multi-project timeline can tell whose audit
|
|
404
|
+
* this was (events.ts `project`). The actor defaults to `operator` for the
|
|
405
|
+
* same reason `recordUserPrompt` defaults to `user`: every verb here is one
|
|
406
|
+
* the operator runs, and a judge never writes the store.
|
|
407
|
+
*/
|
|
408
|
+
function emit(eventType, project, ctx, opts, payload) {
|
|
409
|
+
return appendEvent({
|
|
410
|
+
session_id: ctx.sessionId,
|
|
411
|
+
actor: ctx.actor ?? 'operator',
|
|
412
|
+
event_type: eventType,
|
|
413
|
+
plan_version: ctx.planVersion,
|
|
414
|
+
causal_parent: ctx.causalParent,
|
|
415
|
+
project: path.basename(project),
|
|
416
|
+
payload,
|
|
417
|
+
}, opts);
|
|
418
|
+
}
|
|
419
|
+
function view(finding) {
|
|
420
|
+
const { history: _history, ...rest } = finding;
|
|
421
|
+
return rest;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* `audit open <project-dir>`. Resolves the project, creates `.blacksmith/`,
|
|
425
|
+
* cuts the detached worktree at HEAD, fingerprints it, writes the manifest and
|
|
426
|
+
* prints the axis manifest plus the store's live findings.
|
|
427
|
+
*
|
|
428
|
+
* The worktree is what every axis reads (spec §2.2): the project's own working
|
|
429
|
+
* tree is never handed to a judge, so an audit cannot dirty it, and the
|
|
430
|
+
* fingerprint taken here is what `close` verifies against.
|
|
431
|
+
*/
|
|
432
|
+
export async function openAudit(projectDir, ctx, opts = {}) {
|
|
433
|
+
const project = requireGitProject(projectDir);
|
|
434
|
+
const existing = readAuditManifest(project);
|
|
435
|
+
if (existing !== undefined) {
|
|
436
|
+
throw new AuditError('audit.already-open', `audit ${existing.audit_id} is already open on ${project} (worktree ${existing.worktree}): close it first`, { audit_id: existing.audit_id, worktree: existing.worktree });
|
|
437
|
+
}
|
|
438
|
+
const worktree = auditWorktreeDir(project);
|
|
439
|
+
if (existsSync(worktree)) {
|
|
440
|
+
throw new AuditError('audit.worktree-exists', `${worktree} already exists with no manifest naming it — a previous audit did not close; remove it with \`git worktree remove\` and retry`, { worktree });
|
|
441
|
+
}
|
|
442
|
+
const now = new Date();
|
|
443
|
+
const auditId = mintAuditId(now);
|
|
444
|
+
mkdirSync(path.dirname(auditManifestPath(project)), { recursive: true });
|
|
445
|
+
mkdirSync(path.dirname(worktree), { recursive: true });
|
|
446
|
+
runGit(project, ['worktree', 'add', '--detach', worktree, 'HEAD']);
|
|
447
|
+
const head = runGit(worktree, ['rev-parse', 'HEAD']).trim();
|
|
448
|
+
const fingerprint = fingerprintWorktree(worktree);
|
|
449
|
+
const stored = await emit('audit-opened', project, ctx, opts, {
|
|
450
|
+
audit_id: auditId,
|
|
451
|
+
project,
|
|
452
|
+
worktree,
|
|
453
|
+
head,
|
|
454
|
+
axes: [...AUDIT_AXES],
|
|
455
|
+
});
|
|
456
|
+
const manifest = {
|
|
457
|
+
audit_id: auditId,
|
|
458
|
+
project,
|
|
459
|
+
worktree,
|
|
460
|
+
head,
|
|
461
|
+
fingerprint,
|
|
462
|
+
opened_at: now.toISOString(),
|
|
463
|
+
session_id: ctx.sessionId,
|
|
464
|
+
event_id: stored.event_id,
|
|
465
|
+
};
|
|
466
|
+
writeFileSync(auditManifestPath(project), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
467
|
+
const live = foldAuditStore(readAuditStore(project), { now })
|
|
468
|
+
.filter((finding) => finding.suppressesReraise)
|
|
469
|
+
.map(view);
|
|
470
|
+
return {
|
|
471
|
+
audit_id: auditId,
|
|
472
|
+
project,
|
|
473
|
+
worktree,
|
|
474
|
+
head,
|
|
475
|
+
axes: AUDIT_AXES,
|
|
476
|
+
store: auditStorePath(project),
|
|
477
|
+
manifest: auditManifestPath(project),
|
|
478
|
+
live,
|
|
479
|
+
event_id: stored.event_id,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* `audit record <project-dir> --axis <axis> --evidence <file>`. Validates one
|
|
484
|
+
* axis's evidence, fingerprints each item, drops what the fold suppresses and
|
|
485
|
+
* appends the rest as `raised`. Two items in one file with one fingerprint are
|
|
486
|
+
* one finding: the second is dropped too, or the store would carry a
|
|
487
|
+
* duplicate the fold can never tell from a re-raise.
|
|
488
|
+
*/
|
|
489
|
+
export async function recordAudit(projectDir, axis, evidence, ctx, opts = {}) {
|
|
490
|
+
const manifest = requireOpenAudit(projectDir);
|
|
491
|
+
if (!Array.isArray(evidence)) {
|
|
492
|
+
throw new AuditError('audit.evidence-not-a-list', `an axis returns a JSON array of findings, not ${evidence === null ? 'null' : typeof evidence}`, { axis });
|
|
493
|
+
}
|
|
494
|
+
const normalized = normalizeEvidence(axis, evidence);
|
|
495
|
+
const now = new Date();
|
|
496
|
+
const suppressedSet = suppressedFingerprints(readAuditStore(projectDir), { now });
|
|
497
|
+
const appended = [];
|
|
498
|
+
const suppressed = [];
|
|
499
|
+
const seen = new Set();
|
|
500
|
+
for (const item of normalized) {
|
|
501
|
+
if (suppressedSet.has(item.fingerprint) || seen.has(item.fingerprint)) {
|
|
502
|
+
suppressed.push(item.fingerprint);
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
seen.add(item.fingerprint);
|
|
506
|
+
appended.push(item);
|
|
507
|
+
}
|
|
508
|
+
const stored = await emit('audit-finding-raised', manifest.project, ctx, opts, {
|
|
509
|
+
audit_id: manifest.audit_id,
|
|
510
|
+
axis,
|
|
511
|
+
appended: appended.length,
|
|
512
|
+
suppressed: suppressed.length,
|
|
513
|
+
fingerprints: appended.map((item) => item.fingerprint),
|
|
514
|
+
});
|
|
515
|
+
appendAuditLines(projectDir, appended.map((item) => ({
|
|
516
|
+
...item,
|
|
517
|
+
status: 'raised',
|
|
518
|
+
ts: now.toISOString(),
|
|
519
|
+
audit_id: manifest.audit_id,
|
|
520
|
+
session_id: ctx.sessionId,
|
|
521
|
+
event_id: stored.event_id,
|
|
522
|
+
})));
|
|
523
|
+
return {
|
|
524
|
+
audit_id: manifest.audit_id,
|
|
525
|
+
axis,
|
|
526
|
+
appended: appended.map((item) => item.fingerprint),
|
|
527
|
+
suppressed,
|
|
528
|
+
event_id: stored.event_id,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* `audit consolidate <project-dir>`. Pure read: fold, cluster by path, rank.
|
|
533
|
+
* The clusters are candidates (spec §3.1) — the operator decides whether the
|
|
534
|
+
* members of one are one defect, and `audit decide --decision merge` is how
|
|
535
|
+
* that answer is recorded.
|
|
536
|
+
*/
|
|
537
|
+
export function consolidateAudit(projectDir, opts = {}) {
|
|
538
|
+
const project = path.resolve(projectDir);
|
|
539
|
+
const findings = foldAuditStore(readAuditStore(project), opts);
|
|
540
|
+
const counts = Object.fromEntries(AUDIT_STATUSES.map((status) => [status, 0]));
|
|
541
|
+
for (const finding of findings)
|
|
542
|
+
counts[finding.status] += 1;
|
|
543
|
+
return {
|
|
544
|
+
project,
|
|
545
|
+
store: auditStorePath(project),
|
|
546
|
+
counts,
|
|
547
|
+
clusters: rankClusters(clusterByPath(findings)).map((cluster) => ({
|
|
548
|
+
...cluster,
|
|
549
|
+
members: cluster.members.map(view),
|
|
550
|
+
})),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* `audit decide`. Appends the operator's answer. A decision on a finding that
|
|
555
|
+
* was already decided is allowed — the store is a history and the fold takes
|
|
556
|
+
* the last word — but the finding must exist, `merge` must name a survivor,
|
|
557
|
+
* and the other two must not.
|
|
558
|
+
*/
|
|
559
|
+
export async function decideAudit(projectDir, input, ctx, opts = {}) {
|
|
560
|
+
const project = path.resolve(projectDir);
|
|
561
|
+
if (!AUDIT_DECISIONS.includes(input.decision)) {
|
|
562
|
+
throw new AuditError('audit.unknown-decision', `\`${input.decision}\` is not a decision: accept, decline or merge`, { decision: input.decision, decisions: [...AUDIT_DECISIONS] });
|
|
563
|
+
}
|
|
564
|
+
const findings = foldAuditStore(readAuditStore(project));
|
|
565
|
+
const known = new Set(findings.map((finding) => finding.fingerprint));
|
|
566
|
+
if (!known.has(input.fingerprint)) {
|
|
567
|
+
throw new AuditError('audit.unknown-finding', `no finding in ${auditStorePath(project)} has fingerprint ${input.fingerprint}`, { fingerprint: input.fingerprint });
|
|
568
|
+
}
|
|
569
|
+
if (input.decision === 'merge') {
|
|
570
|
+
if (input.sameAs === undefined) {
|
|
571
|
+
throw new AuditError('audit.merge-without-survivor', 'merge needs --same-as: the fingerprint this finding is a duplicate of', { fingerprint: input.fingerprint });
|
|
572
|
+
}
|
|
573
|
+
if (input.sameAs === input.fingerprint) {
|
|
574
|
+
throw new AuditError('audit.merge-into-self', `${input.fingerprint} cannot be merged into itself`, { fingerprint: input.fingerprint });
|
|
575
|
+
}
|
|
576
|
+
if (!known.has(input.sameAs)) {
|
|
577
|
+
throw new AuditError('audit.unknown-survivor', `--same-as ${input.sameAs} names no finding in ${auditStorePath(project)}`, { fingerprint: input.fingerprint, same_as: input.sameAs });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
else if (input.sameAs !== undefined) {
|
|
581
|
+
throw new AuditError('audit.same-as-refused', `--same-as belongs to merge; ${input.decision} names no other finding`, { fingerprint: input.fingerprint, decision: input.decision, same_as: input.sameAs });
|
|
582
|
+
}
|
|
583
|
+
const status = STATUS_OF_DECISION[input.decision];
|
|
584
|
+
const stored = await emit('audit-decision', project, ctx, opts, {
|
|
585
|
+
fingerprint: input.fingerprint,
|
|
586
|
+
decision: input.decision,
|
|
587
|
+
status,
|
|
588
|
+
...(input.sameAs === undefined ? {} : { same_as: input.sameAs }),
|
|
589
|
+
...(input.note === undefined ? {} : { note: input.note }),
|
|
590
|
+
});
|
|
591
|
+
appendAuditLines(project, [
|
|
592
|
+
{
|
|
593
|
+
fingerprint: input.fingerprint,
|
|
594
|
+
status,
|
|
595
|
+
ts: new Date().toISOString(),
|
|
596
|
+
session_id: ctx.sessionId,
|
|
597
|
+
event_id: stored.event_id,
|
|
598
|
+
...(input.sameAs === undefined ? {} : { same_as: input.sameAs }),
|
|
599
|
+
...(input.note === undefined ? {} : { note: input.note }),
|
|
600
|
+
},
|
|
601
|
+
]);
|
|
602
|
+
return {
|
|
603
|
+
fingerprint: input.fingerprint,
|
|
604
|
+
decision: input.decision,
|
|
605
|
+
status,
|
|
606
|
+
...(input.sameAs === undefined ? {} : { same_as: input.sameAs }),
|
|
607
|
+
event_id: stored.event_id,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
function shortFingerprint(fingerprint) {
|
|
611
|
+
return fingerprint.slice(0, 8);
|
|
612
|
+
}
|
|
613
|
+
function severityCounts(findings) {
|
|
614
|
+
const counts = new Map();
|
|
615
|
+
for (const finding of findings)
|
|
616
|
+
counts.set(finding.severity, (counts.get(finding.severity) ?? 0) + 1);
|
|
617
|
+
return [...severities()]
|
|
618
|
+
.filter((severity) => counts.has(severity))
|
|
619
|
+
.map((severity) => `${counts.get(severity)} ${severity}`)
|
|
620
|
+
.join(', ');
|
|
621
|
+
}
|
|
622
|
+
function auditIdsOf(findings) {
|
|
623
|
+
const ids = new Set();
|
|
624
|
+
for (const finding of findings) {
|
|
625
|
+
for (const line of finding.history)
|
|
626
|
+
if (line.audit_id !== undefined)
|
|
627
|
+
ids.add(line.audit_id);
|
|
628
|
+
}
|
|
629
|
+
return [...ids].sort();
|
|
630
|
+
}
|
|
631
|
+
/** One-line markdown for a finding, the way the roadmap goal and the spec table both cite it. */
|
|
632
|
+
function cite(finding) {
|
|
633
|
+
return `**${shortFingerprint(finding.fingerprint)} (${finding.severity}, ${finding.axis})** \`${finding.file_path}\` — ${finding.summary}`;
|
|
634
|
+
}
|
|
635
|
+
function renderMilestone(input, project, findings) {
|
|
636
|
+
const audits = auditIdsOf(findings);
|
|
637
|
+
const goal = [
|
|
638
|
+
`Cut from audit ${audits.join(', ')} of \`${path.basename(project)}\` (${findings.length} accepted finding${findings.length === 1 ? '' : 's'}: ${severityCounts(findings)}).`,
|
|
639
|
+
'Each finding below is one acceptance criterion; the plan decides the tasks.',
|
|
640
|
+
...findings.map((finding) => `${cite(finding)}.`),
|
|
641
|
+
].join(' ');
|
|
642
|
+
return [
|
|
643
|
+
`## ${input.title}`,
|
|
644
|
+
`- id: ${input.epicId}`,
|
|
645
|
+
'- status: planned',
|
|
646
|
+
`- epics: [${input.epicId}]`,
|
|
647
|
+
`- project: ${path.basename(project)}`,
|
|
648
|
+
'- kind: product',
|
|
649
|
+
`- goal: ${goal}`,
|
|
650
|
+
'',
|
|
651
|
+
].join('\n');
|
|
652
|
+
}
|
|
653
|
+
function renderSpec(input, project, findings) {
|
|
654
|
+
const audits = auditIdsOf(findings);
|
|
655
|
+
const cell = (text) => text.replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
656
|
+
const table = [
|
|
657
|
+
'| # | Finding | Sev | Axis | File | What is wrong |',
|
|
658
|
+
'|---|---|---|---|---|---|',
|
|
659
|
+
...findings.map((finding, index) => `| ${index + 1} | \`${shortFingerprint(finding.fingerprint)}\` | ${finding.severity} | ${finding.axis} | \`${cell(finding.file_path)}\` | ${cell(finding.summary)} |`),
|
|
660
|
+
];
|
|
661
|
+
const detail = findings.flatMap((finding) => [
|
|
662
|
+
`### \`${shortFingerprint(finding.fingerprint)}\` — ${finding.summary}`,
|
|
663
|
+
'',
|
|
664
|
+
`- **Fingerprint** — \`${finding.fingerprint}\``,
|
|
665
|
+
`- **Axis / severity / confidence** — ${finding.axis} / ${finding.severity} / ${finding.confidence}`,
|
|
666
|
+
`- **File** — \`${finding.file_path}\``,
|
|
667
|
+
`- **Inputs** — ${finding.failure_scenario.inputs}`,
|
|
668
|
+
`- **Expected** — ${finding.failure_scenario.expected}`,
|
|
669
|
+
`- **Actual** — ${finding.failure_scenario.actual}`,
|
|
670
|
+
'',
|
|
671
|
+
]);
|
|
672
|
+
return [
|
|
673
|
+
`# Epic spec — \`${input.epicId}\``,
|
|
674
|
+
'',
|
|
675
|
+
`- **Epic id** — \`${input.epicId}\``,
|
|
676
|
+
`- **Project** — \`${path.basename(project)}\` at \`${project}\`, and every worktree is placed beside that clone (\`AGENTS.md\` "Worktrees").`,
|
|
677
|
+
`- **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.`,
|
|
679
|
+
'',
|
|
680
|
+
'## What this epic is',
|
|
681
|
+
'',
|
|
682
|
+
`${input.title}. The findings below were raised by the audit's axes, consolidated by path, and accepted by the operator one by one; nothing here was inferred. Each row is one acceptance criterion: the finding's \`failure_scenario\` says what a passing build must no longer do.`,
|
|
683
|
+
'',
|
|
684
|
+
'## Acceptance criteria',
|
|
685
|
+
'',
|
|
686
|
+
...table,
|
|
687
|
+
'',
|
|
688
|
+
'## Findings in full',
|
|
689
|
+
'',
|
|
690
|
+
...detail,
|
|
691
|
+
].join('\n');
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* `audit cut <project-dir> --epic <epic-id> --title <text>`. Renders the
|
|
695
|
+
* roadmap milestone and the epic spec from the accepted findings and stamps
|
|
696
|
+
* the epic id onto each of them. It renders rather than edits: the roadmap
|
|
697
|
+
* belongs to the operator's clone of this factory, and where the milestone
|
|
698
|
+
* goes in it is a judgment `/bs audit` makes in front of them (spec §5).
|
|
699
|
+
*
|
|
700
|
+
* An accepted finding already stamped with another epic is not offered — it is
|
|
701
|
+
* that epic's scope. One stamped with this epic is offered again, so the cut
|
|
702
|
+
* is repeatable.
|
|
703
|
+
*/
|
|
704
|
+
export async function cutAudit(projectDir, input, ctx, opts = {}) {
|
|
705
|
+
const project = path.resolve(projectDir);
|
|
706
|
+
const findings = foldAuditStore(readAuditStore(project)).filter((finding) => finding.status === 'accepted' &&
|
|
707
|
+
(finding.epic === undefined || finding.epic === input.epicId));
|
|
708
|
+
if (findings.length === 0) {
|
|
709
|
+
throw new AuditError('audit.nothing-accepted', `no accepted finding in ${auditStorePath(project)} is free to cut: decide first, or every accepted one already belongs to another epic`, { epic: input.epicId });
|
|
710
|
+
}
|
|
711
|
+
const stored = await emit('audit-cut', project, ctx, opts, {
|
|
712
|
+
epic: input.epicId,
|
|
713
|
+
title: input.title,
|
|
714
|
+
fingerprints: findings.map((finding) => finding.fingerprint),
|
|
715
|
+
});
|
|
716
|
+
const ts = new Date().toISOString();
|
|
717
|
+
appendAuditLines(project, findings
|
|
718
|
+
.filter((finding) => finding.epic !== input.epicId)
|
|
719
|
+
.map((finding) => ({
|
|
720
|
+
fingerprint: finding.fingerprint,
|
|
721
|
+
status: 'accepted',
|
|
722
|
+
ts,
|
|
723
|
+
epic: input.epicId,
|
|
724
|
+
session_id: ctx.sessionId,
|
|
725
|
+
event_id: stored.event_id,
|
|
726
|
+
})));
|
|
727
|
+
return {
|
|
728
|
+
epic: input.epicId,
|
|
729
|
+
project,
|
|
730
|
+
findings: findings.map(view),
|
|
731
|
+
milestone: renderMilestone(input, project, findings),
|
|
732
|
+
spec: renderSpec(input, project, findings),
|
|
733
|
+
event_id: stored.event_id,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
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.
|
|
741
|
+
*/
|
|
742
|
+
export async function resolveAudit(projectDir, epicId, ctx, opts = {}) {
|
|
743
|
+
const project = path.resolve(projectDir);
|
|
744
|
+
const carried = foldAuditStore(readAuditStore(project)).filter((finding) => finding.epic === epicId);
|
|
745
|
+
if (carried.length === 0) {
|
|
746
|
+
throw new AuditError('audit.unknown-epic', `no finding in ${auditStorePath(project)} carries epic ${epicId}: was it cut with \`smith audit cut\`?`, { epic: epicId });
|
|
747
|
+
}
|
|
748
|
+
const open = carried.filter((finding) => finding.status !== 'fixed');
|
|
749
|
+
const stored = await emit('audit-resolved', project, ctx, opts, {
|
|
750
|
+
epic: epicId,
|
|
751
|
+
fixed: open.map((finding) => finding.fingerprint),
|
|
752
|
+
});
|
|
753
|
+
const ts = new Date().toISOString();
|
|
754
|
+
appendAuditLines(project, open.map((finding) => ({
|
|
755
|
+
fingerprint: finding.fingerprint,
|
|
756
|
+
status: 'fixed',
|
|
757
|
+
ts,
|
|
758
|
+
epic: epicId,
|
|
759
|
+
session_id: ctx.sessionId,
|
|
760
|
+
event_id: stored.event_id,
|
|
761
|
+
})));
|
|
762
|
+
return {
|
|
763
|
+
epic: epicId,
|
|
764
|
+
fixed: open.map((finding) => finding.fingerprint),
|
|
765
|
+
already: carried
|
|
766
|
+
.filter((finding) => finding.status === 'fixed')
|
|
767
|
+
.map((finding) => finding.fingerprint),
|
|
768
|
+
event_id: stored.event_id,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* `audit close <project-dir> [--force]`. Verifies the worktree against the
|
|
773
|
+
* fingerprint `open` took, removes it, deletes the manifest and closes the
|
|
774
|
+
* audit in the event log. A worktree that moved is refused (spec §2.2): the
|
|
775
|
+
* findings recorded against it may describe a tree nobody audited, and the
|
|
776
|
+
* operator decides whether that matters, not this verb.
|
|
777
|
+
*/
|
|
778
|
+
export async function closeAudit(projectDir, input, ctx, opts = {}) {
|
|
779
|
+
const manifest = requireOpenAudit(projectDir);
|
|
780
|
+
const present = existsSync(manifest.worktree);
|
|
781
|
+
const check = present
|
|
782
|
+
? checkWorktreeImmutable(manifest.worktree, manifest.fingerprint)
|
|
783
|
+
: { unchanged: true, drift: [] };
|
|
784
|
+
if (!check.unchanged && input.force !== true) {
|
|
785
|
+
throw new AuditError('audit.worktree-moved', `${manifest.worktree} is not the tree audit ${manifest.audit_id} opened: ${check.drift.map((d) => `${d.kind} ${d.path}`).join(', ')} — pass --force to close anyway`, { audit_id: manifest.audit_id, worktree: manifest.worktree, drift: check.drift });
|
|
786
|
+
}
|
|
787
|
+
if (present) {
|
|
788
|
+
runGit(manifest.project, [
|
|
789
|
+
'worktree',
|
|
790
|
+
'remove',
|
|
791
|
+
...(check.unchanged ? [] : ['--force']),
|
|
792
|
+
manifest.worktree,
|
|
793
|
+
]);
|
|
794
|
+
}
|
|
795
|
+
const stored = await emit('audit-closed', manifest.project, ctx, opts, {
|
|
796
|
+
audit_id: manifest.audit_id,
|
|
797
|
+
worktree: manifest.worktree,
|
|
798
|
+
verified: present,
|
|
799
|
+
unchanged: check.unchanged,
|
|
800
|
+
drift: check.drift,
|
|
801
|
+
forced: input.force === true,
|
|
802
|
+
});
|
|
803
|
+
rmSync(auditManifestPath(manifest.project));
|
|
804
|
+
return {
|
|
805
|
+
audit_id: manifest.audit_id,
|
|
806
|
+
worktree: manifest.worktree,
|
|
807
|
+
verified: present,
|
|
808
|
+
unchanged: check.unchanged,
|
|
809
|
+
drift: check.drift,
|
|
810
|
+
event_id: stored.event_id,
|
|
811
|
+
};
|
|
812
|
+
}
|