@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.
Files changed (141) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +16 -9
  4. package/.claude/agents/{merger.md → bs-merger.md} +4 -4
  5. package/.claude/agents/{planner.md → bs-planner.md} +20 -6
  6. package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
  7. package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
  11. package/.claude/agents/{tester.md → bs-tester.md} +40 -12
  12. package/.claude/agents/bs-uiux.md +257 -0
  13. package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
  14. package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
  15. package/.claude/skills/bs/SKILL.md +99 -21
  16. package/.claude/skills/bs/audit.md +45 -31
  17. package/.claude/skills/bs/dispatch.md +133 -47
  18. package/.claude/skills/bs/lessons.md +28 -14
  19. package/.claude/skills/bs/mcp.md +4 -4
  20. package/.claude/skills/bs/new.md +3 -3
  21. package/.claude/skills/bs/plan.md +57 -16
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +190 -52
  24. package/.claude/skills/bs/status.md +14 -7
  25. package/.claude/skills/bs/ui.md +21 -5
  26. package/.claude/skills/bs/waivers.md +78 -2
  27. package/.claude/skills/bs/wave.md +143 -50
  28. package/README.md +53 -32
  29. package/factory/orchestrator/dist/agentNames.js +43 -0
  30. package/factory/orchestrator/dist/agents-registry.js +11 -4
  31. package/factory/orchestrator/dist/agentsSync.js +70 -13
  32. package/factory/orchestrator/dist/artifacts.js +31 -0
  33. package/factory/orchestrator/dist/attribution.js +4 -0
  34. package/factory/orchestrator/dist/audit.js +245 -20
  35. package/factory/orchestrator/dist/autonomy.js +6 -2
  36. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  37. package/factory/orchestrator/dist/budgets.js +302 -84
  38. package/factory/orchestrator/dist/claims.js +9 -19
  39. package/factory/orchestrator/dist/cli.js +1087 -116
  40. package/factory/orchestrator/dist/cliName.js +27 -0
  41. package/factory/orchestrator/dist/crossFinding.js +20 -0
  42. package/factory/orchestrator/dist/crosscheck.js +136 -8
  43. package/factory/orchestrator/dist/daemon.js +352 -67
  44. package/factory/orchestrator/dist/db/projector.js +327 -54
  45. package/factory/orchestrator/dist/db/queries.js +1831 -94
  46. package/factory/orchestrator/dist/db/schema.js +57 -4
  47. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  48. package/factory/orchestrator/dist/delegation.js +86 -19
  49. package/factory/orchestrator/dist/diffstat.js +15 -8
  50. package/factory/orchestrator/dist/dispatchLint.js +108 -0
  51. package/factory/orchestrator/dist/effort.js +14 -2
  52. package/factory/orchestrator/dist/effortTiers.js +13 -0
  53. package/factory/orchestrator/dist/env.js +26 -0
  54. package/factory/orchestrator/dist/epic.js +494 -42
  55. package/factory/orchestrator/dist/epicBudget.js +93 -0
  56. package/factory/orchestrator/dist/epicWidth.js +22 -5
  57. package/factory/orchestrator/dist/errorIssues.js +182 -11
  58. package/factory/orchestrator/dist/escalation.js +2 -2
  59. package/factory/orchestrator/dist/events.js +164 -14
  60. package/factory/orchestrator/dist/feedback.js +169 -0
  61. package/factory/orchestrator/dist/findingContext.js +26 -0
  62. package/factory/orchestrator/dist/findings.js +108 -14
  63. package/factory/orchestrator/dist/gate.js +120 -10
  64. package/factory/orchestrator/dist/gh.js +19 -2
  65. package/factory/orchestrator/dist/git.js +20 -2
  66. package/factory/orchestrator/dist/goalCheck.js +2 -2
  67. package/factory/orchestrator/dist/harness.js +25 -17
  68. package/factory/orchestrator/dist/hookDecision.js +411 -39
  69. package/factory/orchestrator/dist/integration.js +20 -8
  70. package/factory/orchestrator/dist/issueReporter.js +81 -16
  71. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  72. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  73. package/factory/orchestrator/dist/judgeStopHook.js +239 -0
  74. package/factory/orchestrator/dist/judges.js +419 -21
  75. package/factory/orchestrator/dist/lessons.js +190 -11
  76. package/factory/orchestrator/dist/logCache.js +291 -0
  77. package/factory/orchestrator/dist/mcp.js +6 -6
  78. package/factory/orchestrator/dist/paths.js +20 -7
  79. package/factory/orchestrator/dist/plan.js +399 -33
  80. package/factory/orchestrator/dist/planQuorum.js +5 -2
  81. package/factory/orchestrator/dist/policy.js +1275 -30
  82. package/factory/orchestrator/dist/projectName.js +25 -0
  83. package/factory/orchestrator/dist/projects.js +12 -1
  84. package/factory/orchestrator/dist/promptCapture.js +306 -0
  85. package/factory/orchestrator/dist/promptHook.js +33 -0
  86. package/factory/orchestrator/dist/promptImport.js +281 -0
  87. package/factory/orchestrator/dist/prompts.js +13 -0
  88. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  89. package/factory/orchestrator/dist/providers/types.js +1 -1
  90. package/factory/orchestrator/dist/queue.js +471 -30
  91. package/factory/orchestrator/dist/quorum.js +18 -5
  92. package/factory/orchestrator/dist/results.js +25 -7
  93. package/factory/orchestrator/dist/roadmap.js +10 -5
  94. package/factory/orchestrator/dist/run-cli.js +15 -10
  95. package/factory/orchestrator/dist/runner.js +2 -2
  96. package/factory/orchestrator/dist/scheduler.js +3 -3
  97. package/factory/orchestrator/dist/schemas.js +56 -8
  98. package/factory/orchestrator/dist/severity.js +8 -4
  99. package/factory/orchestrator/dist/spec.js +246 -12
  100. package/factory/orchestrator/dist/specChange.js +13 -9
  101. package/factory/orchestrator/dist/stack.js +6 -6
  102. package/factory/orchestrator/dist/storeRoot.js +90 -0
  103. package/factory/orchestrator/dist/symbols.js +261 -6
  104. package/factory/orchestrator/dist/taskEvents.js +192 -31
  105. package/factory/orchestrator/dist/taskStatus.js +14 -0
  106. package/factory/orchestrator/dist/taxonomy.js +11 -0
  107. package/factory/orchestrator/dist/testerAudit.js +31 -12
  108. package/factory/orchestrator/dist/testgate.js +20 -16
  109. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  110. package/factory/orchestrator/dist/usage.js +113 -48
  111. package/factory/orchestrator/dist/waivers.js +99 -12
  112. package/factory/orchestrator/dist/waveBudget.js +2 -0
  113. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  114. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  115. package/factory/orchestrator/dist/workroot.js +1 -1
  116. package/factory/orchestrator/dist/worktree.js +76 -21
  117. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  118. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  119. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  120. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  121. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  122. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  123. package/factory/policies/budgets.yml +123 -33
  124. package/factory/policies/crosscheck.yml +51 -13
  125. package/factory/policies/delegation.yml +3 -3
  126. package/factory/policies/effort.yml +18 -10
  127. package/factory/policies/guardrails.yml +8 -6
  128. package/factory/policies/harness.yml +5 -5
  129. package/factory/policies/lessons.md +53 -2
  130. package/factory/policies/scheduler.yml +4 -4
  131. package/factory/policies/sensitive-paths.yml +1 -1
  132. package/factory/policies/severity.yml +13 -1
  133. package/factory/policies/stack.yml +6 -6
  134. package/factory/policies/taxonomy.yml +9 -3
  135. package/factory/policies/worktree.yml +15 -9
  136. package/factory/specs/roadmap.md +27 -3
  137. package/factory/specs/schema/event.schema.json +8 -1
  138. package/factory/specs/schema/result.schema.json +21 -8
  139. package/factory/specs/schema/task-spec.schema.json +12 -0
  140. package/package.json +16 -12
  141. 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
- .filter((name) => name.endsWith('.md'))
29
- .map((name) => name.slice(0, -'.md'.length))
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 SMITH_MAXTURNS_<ROLE> is set
74
- * (non-empty). Every name and value is checked, and every target file read,
75
- * before anything is written: one bad entry refuses the whole sync.
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
- const requested = [];
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
- if (!name.startsWith(MAXTURNS_ENV_PREFIX))
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(name);
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
- requested.push({ role, env: name, to: positiveInt(name, raw) });
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, `${role}.md`);
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, `${role}.md`);
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
- return new Set(foldAuditStore(lines, opts)
257
- .filter((finding) => finding.suppressesReraise)
258
- .map((finding) => finding.fingerprint));
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 \`smith audit open\` first`, { projectDir: path.resolve(projectDir), manifestPath: auditManifestPath(projectDir) });
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: path.basename(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 \`${path.basename(project)}\` (${findings.length} accepted finding${findings.length === 1 ? '' : 's'}: ${severityCounts(findings)}).`,
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: ${path.basename(project)}`,
648
- '- kind: product',
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** — \`${path.basename(project)}\` at \`${project}\`, and every worktree is placed beside that clone (\`AGENTS.md\` "Worktrees").`,
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 \`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.`,
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
- * `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.
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
- export async function resolveAudit(projectDir, epicId, ctx, opts = {}) {
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 \`smith audit cut\`?`, { epic: epicId });
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: open.map((finding) => finding.fingerprint),
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, open.map((finding) => ({
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: open.map((finding) => finding.fingerprint),
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 the switch stays exhaustive.
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
  }