@juzser/blacksmith 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.claude/agents/auditor.md +3 -0
  2. package/.claude/agents/grader.md +12 -5
  3. package/.claude/agents/reviewer.md +3 -0
  4. package/.claude/agents/security-reviewer.md +3 -0
  5. package/.claude/agents/spec-reviewer.md +3 -0
  6. package/.claude/agents/verifier.md +3 -0
  7. package/.claude/skills/bs/audit.md +12 -5
  8. package/.claude/skills/bs/dispatch.md +40 -4
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +11 -5
  11. package/.claude/skills/bs/wave.md +40 -3
  12. package/README.md +1 -1
  13. package/factory/orchestrator/dist/agentsSync.js +23 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +6 -2
  16. package/factory/orchestrator/dist/cli.js +309 -28
  17. package/factory/orchestrator/dist/crossFinding.js +20 -0
  18. package/factory/orchestrator/dist/daemon.js +198 -31
  19. package/factory/orchestrator/dist/db/projector.js +51 -27
  20. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  21. package/factory/orchestrator/dist/epic.js +179 -12
  22. package/factory/orchestrator/dist/errorIssues.js +175 -10
  23. package/factory/orchestrator/dist/events.js +59 -11
  24. package/factory/orchestrator/dist/findings.js +25 -2
  25. package/factory/orchestrator/dist/gh.js +19 -2
  26. package/factory/orchestrator/dist/git.js +20 -2
  27. package/factory/orchestrator/dist/hookDecision.js +320 -37
  28. package/factory/orchestrator/dist/integration.js +11 -1
  29. package/factory/orchestrator/dist/issueReporter.js +81 -16
  30. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  31. package/factory/orchestrator/dist/logCache.js +291 -0
  32. package/factory/orchestrator/dist/paths.js +10 -0
  33. package/factory/orchestrator/dist/plan.js +130 -0
  34. package/factory/orchestrator/dist/projects.js +12 -1
  35. package/factory/orchestrator/dist/queue.js +469 -28
  36. package/factory/orchestrator/dist/scheduler.js +3 -3
  37. package/factory/orchestrator/dist/spec.js +70 -0
  38. package/factory/orchestrator/dist/taskEvents.js +13 -4
  39. package/factory/orchestrator/dist/taxonomy.js +11 -0
  40. package/factory/orchestrator/dist/testgate.js +4 -3
  41. package/factory/orchestrator/dist/usage.js +18 -10
  42. package/factory/orchestrator/dist/waivers.js +60 -7
  43. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  44. package/factory/orchestrator/dist/worktree.js +19 -6
  45. package/factory/specs/roadmap.md +24 -0
  46. package/package.json +1 -1
@@ -5,6 +5,9 @@ model: sonnet
5
5
  effort: high
6
6
  tools: Read, Grep, Glob, Bash
7
7
  maxTurns: 15
8
+ hooks:
9
+ Stop:
10
+ - hooks: [{type: command, command: "[ -f \"$CLAUDE_PROJECT_DIR/.claude/hooks/judge-stop.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/judge-stop.sh\" || exit 0"}]
8
11
  ---
9
12
 
10
13
  # Auditor
@@ -5,6 +5,9 @@ model: sonnet
5
5
  effort: low
6
6
  tools: Read, Grep, Glob, Bash
7
7
  maxTurns: 15
8
+ hooks:
9
+ Stop:
10
+ - hooks: [{type: command, command: "[ -f \"$CLAUDE_PROJECT_DIR/.claude/hooks/judge-stop.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/judge-stop.sh\" || exit 0"}]
8
11
  ---
9
12
 
10
13
  # Grader
@@ -43,7 +46,9 @@ edit does not save a round-trip, it costs the whole one.
43
46
  rubric — nothing broader.
44
47
  - Bounce back to the worker with specific, itemized gaps (not vibes).
45
48
  - **Max 2 rounds.** After that, pass whatever exists through to the gates —
46
- the gates, not the grader, decide final pass/fail.
49
+ the gates, not the grader, decide final pass/fail. The re-scope path after a
50
+ round-2 `fail` is `.claude/skills/bs/dispatch.md`'s "Round counting and
51
+ escalation" — read it there rather than here.
47
52
  - Grader never edits code and never talks to the operator.
48
53
  - **Never compact your context** (`budgets.yml` `context_window`,
49
54
  `narrowing_roles`). You grade evidence rather than build on it; a
@@ -101,10 +106,12 @@ harness counts the tokens; the dispatcher stamps them.
101
106
  {"status": "done", "overall": "fail", "round": 2, "artifact_path": "state/results/<task-id>.grader-r2.json"}
102
107
  ```
103
108
 
104
- Two rounds is a hard stop, not a suggestion. A `fail` at round 2 goes back to
105
- the planner for re-scoping; you do not open round 3, and you do not soften the
106
- verdict to avoid the stop. The schema caps `round` at 2, so a round-3 file
107
- does not quietly pass the gate — it blocks it as invalid.
109
+ Two rounds is a hard stop, not a suggestion. A `fail` at round 2 is re-scoped
110
+ through dispatch.md's "Round counting and escalation" (plan propose/approve a
111
+ successor task id, then `worktree create --from`); you do not open round 3 on
112
+ this id, and you do not soften the verdict to avoid the stop. The schema caps
113
+ `round` at 2, so a round-3 file does not quietly pass the gate — it blocks it
114
+ as invalid.
108
115
 
109
116
  ## What the gate does with this file
110
117
 
@@ -5,6 +5,9 @@ model: sonnet
5
5
  effort: medium
6
6
  tools: Read, Grep, Glob, Bash
7
7
  maxTurns: 15
8
+ hooks:
9
+ Stop:
10
+ - hooks: [{type: command, command: "[ -f \"$CLAUDE_PROJECT_DIR/.claude/hooks/judge-stop.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/judge-stop.sh\" || exit 0"}]
8
11
  ---
9
12
 
10
13
  # Reviewer
@@ -5,6 +5,9 @@ model: sonnet
5
5
  effort: high
6
6
  tools: Read, Grep, Glob, Bash
7
7
  maxTurns: 15
8
+ hooks:
9
+ Stop:
10
+ - hooks: [{type: command, command: "[ -f \"$CLAUDE_PROJECT_DIR/.claude/hooks/judge-stop.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/judge-stop.sh\" || exit 0"}]
8
11
  ---
9
12
 
10
13
  # Security Reviewer
@@ -5,6 +5,9 @@ model: sonnet
5
5
  effort: high
6
6
  tools: Read, Grep, Glob, Bash
7
7
  maxTurns: 15
8
+ hooks:
9
+ Stop:
10
+ - hooks: [{type: command, command: "[ -f \"$CLAUDE_PROJECT_DIR/.claude/hooks/judge-stop.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/judge-stop.sh\" || exit 0"}]
8
11
  ---
9
12
 
10
13
  # Spec Reviewer
@@ -5,6 +5,9 @@ model: opus
5
5
  effort: xhigh
6
6
  tools: Read, Grep, Glob, Bash
7
7
  maxTurns: 15
8
+ hooks:
9
+ Stop:
10
+ - hooks: [{type: command, command: "[ -f \"$CLAUDE_PROJECT_DIR/.claude/hooks/judge-stop.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/judge-stop.sh\" || exit 0"}]
8
11
  ---
9
12
 
10
13
  # Verifier
@@ -254,8 +254,15 @@ is already gone closes with `verified: false` and no refusal.
254
254
  The epic is now an ordinary roadmap milestone: run `/bs plan` on it
255
255
  ([`plan.md`](plan.md)), then `/bs run`. When that run reaches its epic-close
256
256
  step, it runs
257
- `smith audit resolve <project-dir> --epic <epic-id> --session <session-id> --causal-parent <event-id>`,
258
- which appends `fixed` for every finding the epic carried — repeatable, and
259
- `audit.unknown-epic` if the id was never cut from this store. That is the
260
- only step of an audit that runs outside this playbook, and it is why the
261
- store, not this transcript, is where a finding's life is kept.
257
+ `smith audit resolve <project-dir> --epic <epic-id> --session <session-id> --causal-parent <event-id> [--specs-dir <dir>]`,
258
+ which reads the epic's newest plan and appends `fixed` only for a carried
259
+ finding some task in it still claims — the rest come back `deferred`, still
260
+ `accepted`, because the plan scoped them out on purpose. Repeatable;
261
+ `audit.unknown-epic` if the id was never cut from this store, `audit.no-plan`
262
+ if no plan can be found (refuses rather than guess). The default search is
263
+ the work root's specs dir; when this project's plans live elsewhere, pass
264
+ `--specs-dir` naming the same specs dir the epic was run from. `--except
265
+ <fp[,fp...]>` forces specific fingerprints into `deferred` even when a task
266
+ claims them. That is the only step of an audit that runs outside this
267
+ playbook, and it is why the store, not this transcript, is where a finding's
268
+ life is kept.
@@ -25,6 +25,16 @@ it. `smith agents sync` may have rewritten it locally from
25
25
  `SMITH_MAXTURNS_<ROLE>` (an uncommitted, per-box edit), and Claude Code
26
26
  enforces what the file says. A sync reaches only agents spawned after it.
27
27
 
28
+ State it on its own line, verbatim — `Turn budget: 40` — and, on a judge
29
+ dispatch, the declared-artifact line below verbatim too. `smith dispatch
30
+ lint <prompt-file> --role <role> --task <id> --session <id>` reads a
31
+ composed prompt back and checks both against the template and the ledger,
32
+ before the agent ever runs. It fails closed: a number that does not match
33
+ the template — higher (the promise-more-than-the-template mistake above) or
34
+ lower — a missing turn-budget line, a template it cannot read at all, and a
35
+ judge prompt missing or mismatching its artifact line are each a lint
36
+ failure, not a maybe.
37
+
28
38
  ## Splice the compiled lessons into every prompt (agent-interviews.md N-9, P9-2)
29
39
 
30
40
  Before you dispatch, run
@@ -151,10 +161,30 @@ memory of its previous attempt and will happily report round 1 forever.
151
161
  reason). An unlogged escalation is a cost you cannot attribute later — and
152
162
  `model_tier` alone cannot even tell you *which* frontier model you escalated
153
163
  to, since opus and fable share the tier.
154
- - **The grader's 2-round cap is a hard stop, not a ladder rung.** At a round-2
155
- `fail` the task goes back to the planner for re-scoping. Do not re-dispatch
156
- the grader, and do not escalate the *grader* — the gates decide pass/fail,
157
- and a third grading round only buys a more expensive opinion.
164
+ - **The grader's 2-round cap is a hard stop, not a ladder rung.** At a
165
+ round-2 `fail` the operator re-scopes through a concrete, logged path, not
166
+ a bare re-dispatch:
167
+ 1. `smith plan propose` with a `supersede` pairing the failing task id to a
168
+ new one (`PlanChanges.supersede`, spec.ts's `amendPlan`) — the operator
169
+ approves the re-scoping, this command only records the proposal.
170
+ 2. `smith plan approve` the proposed version. This is what writes the
171
+ `successors{old->new}` pairing (spec.ts's `taskSuccessors`) that
172
+ everything below checks against.
173
+ 3. `smith worktree create --from <old-task-id> --session <id> ...` to cut
174
+ the successor's branch from the predecessor's HEAD instead of
175
+ integration — refused as `worktree.not-a-successor` unless step 2 logged
176
+ that exact pairing.
177
+ 4. Rerun wave steps 5-7 (dispatch, review, gate) under the new task id. The
178
+ grader starts that id at round 1 — the ledger's round counter is per
179
+ `(task_id, role)`, and a new id has never opened a round.
180
+ 5. Gate with `--plan` naming the new plan version.
181
+
182
+ A same-id re-dispatch of round 3 is never legal, superseded or not: the cap
183
+ binds the id, and a kept-id supersede (same task_id, new claims/criteria)
184
+ does not reset the round either — only a **new** task id starts a fresh
185
+ ledger. Do not re-dispatch the grader on the old id, and do not escalate the
186
+ *grader* — the gates decide pass/fail, and a third grading round on the same
187
+ id only buys a more expensive opinion.
158
188
  - **Never escalate a judge to break a tie with another judge.** That is what
159
189
  the cross-check quorum is for (`crosscheck.yml`); a bigger critic is still
160
190
  one critic.
@@ -324,6 +354,12 @@ true one on the record. `--no-findings` records an operator
324
354
  *attestation* rather than a review — use it only for a judge that ran outside
325
355
  the factory; a judge that genuinely found nothing writes `[]` and reports.
326
356
 
357
+ Tell the judge the exact path on its own line, verbatim —
358
+ `Declared artifact: /abs/path/<task-id>.reviewer.json` — the spelling `smith
359
+ dispatch lint` checks the composed prompt against once the dispatch above
360
+ has landed in the ledger. Missing, relative, or a path that does not match
361
+ what `--artifact` declared are each a lint failure, not a maybe.
362
+
327
363
  ## Dispatching the security-reviewer (agent-interviews.md N-7)
328
364
 
329
365
  Do not eyeball the path list — ask:
@@ -44,7 +44,26 @@ there means "not looked at", not "looked at and clean".
44
44
  — a *different model* than the planner's, per its own frontmatter — to
45
45
  hunt spec gaps/ambiguities/missing-nonfunctional clauses
46
46
  (`docs/standards/agent-constraints.md` "planner"; `severity.yml`: most
47
- spec gaps land S2, blocking sign-off).
47
+ spec gaps land S2, blocking sign-off). Name the review focus in the
48
+ dispatch itself, as a short list of at most six items, so findings come
49
+ back tagged to one of them:
50
+
51
+ 1. Claims and dependency edges, cross-checked against
52
+ `smith claims impact`.
53
+ 2. TDD-ability -- a named failing test per task, before its code.
54
+ 3. Nonfunctional and security clauses.
55
+ 4. Budget and diff-cap fit.
56
+ 5. Acceptance-criterion coverage -- every epic criterion maps to a task.
57
+ 6. From round 2 on, only the previous round's findings plus any task
58
+ that changed since then.
59
+
60
+ When `size.small` reads true on the draft (`smith wave schedule` or
61
+ `smith plan ingest`'s `parallelism.size`, run against the draft plan
62
+ file), recommend effort tier `small` -- single-pass spec review -- to
63
+ the operator unless a security trigger floors it first, and say why in
64
+ the same message: a plan that cannot spread past a few tasks and a
65
+ handful of claim paths does not need a second review round to find what
66
+ one pass already covers.
48
67
  4. Planner fixes flagged issues; repeat 2–3 until the spec-reviewer has
49
68
  nothing S1/S2 left — or exactly once when `profile.specReviewRounds` is
50
69
  `single-pass` (`small`), where the planner fixes what came back and
@@ -127,6 +146,11 @@ there means "not looked at", not "looked at and clean".
127
146
  decide whether to re-slice, and say which you chose when you present the
128
147
  plan.
129
148
 
149
+ The same JSON also carries `size` -- `{tasks, claims, small}` over live
150
+ tasks only -- the block `plan ingest` prints again under `parallelism`
151
+ at step 7; `small` is true at or under 3 live tasks and 10 distinct
152
+ claim paths, and this is where the step 3 tier read gets confirmed.
153
+
130
154
  Beside the schedule, the read that names what it only counts:
131
155
 
132
156
  ```bash
@@ -163,6 +187,14 @@ there means "not looked at", not "looked at and clean".
163
187
  UI check found behind two epics. It is idempotent, so run it again on a
164
188
  resumed session; read `added` and `edges` back, and say so when either is
165
189
  0 on a plan that has tasks or edges.
190
+
191
+ Read `parallelism.widest` back from that same output, too. `parallel_with`
192
+ underneath it is claims-and-edges only -- import-graph crossings stay
193
+ `wave schedule --repo`'s job, not this key's -- so it can only ever name
194
+ a width that job would not contradict. A `widest` of 1 on a
195
+ plan of two or more tasks means every task the wave loop will ever run
196
+ sits behind some claim or edge that serializes it: flag that to the
197
+ operator now, before a run discovers it as a stalled wave.
166
198
  8. If this epic opens a new roadmap milestone, add it to
167
199
  `factory/specs/roadmap.md` (planner-maintained, architecture §12) — a
168
200
  roadmap change is itself a scope change and needs the same operator nod.
@@ -123,8 +123,9 @@ under it, which is the wrong `--state-dir`, or a wave session opened without
123
123
  done; a green gate on serialized work is exactly the outcome the two
124
124
  playbooks are written to prevent.
125
125
 
126
- 11. Repeat until every task in the live plan version is
127
- `completed`/`superseded`/`waived`. Dispatch the **`planner`** again for
126
+ 11. Repeat until every task in the live plan version is `completed`/`waived`,
127
+ or `superseded` by a successor that is itself terminal-OK (recursively —
128
+ a successor chain counts too). Dispatch the **`planner`** again for
128
129
  the epic verdict against acceptance criteria — gaps found → a NEW
129
130
  `plan-v(n+1)` with inferred tasks (never a live-graph mutation,
130
131
  architecture §12), auto-scheduled at confidence ≥0.8 else parked for an
@@ -292,9 +293,14 @@ playbooks are written to prevent.
292
293
  yourself** — closing over a hold is the operator's call; ask for it and
293
294
  quote the blockers. If the epic was cut by `/bs audit`
294
295
  ([`audit.md`](audit.md) step 10), follow the close with
295
- `smith audit resolve <project-dir> --epic <epic> --session <session-id> --causal-parent <event-id>`
296
- so every finding the epic carried is marked `fixed` in the project's
297
- audit store — the store, not the epic, is where a finding's life ends.
296
+ `smith audit resolve <project-dir> --epic <epic> --session <session-id> --causal-parent <event-id> [--specs-dir <dir>]`
297
+ so every finding this plan still claims is marked `fixed` in the
298
+ project's audit store — the rest come back `deferred`, left exactly as
299
+ they were, because the plan scoped them out on purpose. The store, not
300
+ the epic, is where a finding's life ends. `resolve` reads the epic's
301
+ newest plan from the work root's specs dir by default; when this
302
+ project's plans live elsewhere, pass `--specs-dir` naming the same specs
303
+ dir the epic was run from, or it refuses rather than guess.
298
304
  17. Open **one integration PR per epic** with the scribe-written body
299
305
  (`/bs report`'s playbook, [`report.md`](report.md)) — screenshots, test
300
306
  results, reviewer verdict, waivers granted, timeline link. The head is
@@ -147,6 +147,28 @@ one thing this playbook never asks you to.
147
147
  carries `keeps_exports`, restate the promise in the dispatch in one line:
148
148
  the file list, and "keep every existing export's name and declaration;
149
149
  adding is fine".
150
+
151
+ Once every coder of the wave has been dispatched — this fan-out has gone
152
+ out for every admitted task — audit the wave's own parallelism while it
153
+ can still be corrected: `smith wave audit --session <id> --epic <epic>`.
154
+ `wave check` is the step-1 gate that certified, before any dispatch, that
155
+ these tasks could run together; `wave audit` reads the log back after
156
+ dispatch and says whether they did.
157
+ It is a read, so it costs nothing against "No lockstep barrier" above and
158
+ waits for no task. Read the `verdict` field (`WAVE_VERDICTS`,
159
+ `waveConcurrency.ts`), not only the exit code — `partial` also exits 0:
160
+ - `serialized` or `partial` — the wave is not running the width it was
161
+ admitted at, and steps 5–7 have not gone out yet, so it can still be
162
+ fixed: dispatch every remaining phase of this wave as one message of
163
+ parallel calls, per "One message, many dispatches" above, and record
164
+ the verdict as an operator-visible event — `error-logged` with error
165
+ `contract.constraint-decay` (`taxonomy.yml`): the wave-runner stopped
166
+ honoring that section's standing constraint.
167
+ - `unobserved` — the log holds no dispatch for any task this wave
168
+ admitted. That is a defect in the log, not in how the wave ran; record
169
+ it the same way, as `error-logged` with error `execution.env-failure`.
170
+ - `parallel` or `single` — nothing further; the wave ran as wide as it
171
+ was admitted.
150
172
  5. Dispatch **`tester`** (`.claude/agents/tester.md`) for missing unit
151
173
  coverage and epic-level e2e/screenshots.
152
174
  - Then the **uiux visual pass**, but only when all three hold: the task
@@ -164,7 +186,8 @@ one thing this playbook never asks you to.
164
186
  runs; it never decides pass/fail itself. At one round the grader scores
165
187
  once and a failed criterion is the coder's bounce, not a re-grade. Its result file is an **input to
166
188
  step 7**, not a note to the operator: pass it as `--grader` or the rubric
167
- gates nothing (D-34).
189
+ gates nothing (D-34). A round-2 `fail` re-scopes via dispatch.md's "Round
190
+ counting and escalation", not a round 3 on this id.
168
191
  7. Run the gate pipeline:
169
192
  `smith gate run <task-id> --worktree <dir> --checks checks.json --result
170
193
  result.json --grader state/results/<task-id>.grader-r<round>.json
@@ -238,8 +261,18 @@ one thing this playbook never asks you to.
238
261
  — see "Round counting and escalation" above. Then have the log check
239
262
  you: `smith escalation check <session-id> --task <task-id>`, which
240
263
  exits 1 if the rung you just climbed is not evidenced.
241
- 10. Gate outcome `pass`/`pass-with-waivers-pending` → before admitting,
242
- ask what the diff did to everyone outside the claims, and whether it
264
+ 10. Gate outcome `pass`/`pass-with-waivers-pending` → before admitting, audit
265
+ the wave's own parallelism: `smith wave audit --session <id> --epic
266
+ <epic>`. It reads the log back and says whether the tasks admitted
267
+ together actually ran together — `parallel` when every admitted task was
268
+ in flight at once, `partial` when two or more overlapped but never all,
269
+ `serialized` when work is recorded but no two tasks ever overlapped,
270
+ `single` when the wave admitted one task (nothing to be parallel about),
271
+ and `unobserved` when the wave was admitted and the log shows no work for
272
+ any of its tasks. Exit 1 on `serialized`, exit 2 on `unobserved` — either
273
+ is a fact for the wave-loop's hand-back, not a reason to hold the merge:
274
+ report the verdict and keep going to admission. Then ask what the diff
275
+ did to everyone outside the claims, and whether it
243
276
  kept what the spec promised:
244
277
  `smith claims impact <worktree-dir> <spec.json>`. Exit 1 means a
245
278
  `proven` break — this task removed an export a file outside its claims
@@ -271,6 +304,10 @@ one thing this playbook never asks you to.
271
304
  auto-resolve a semantic conflict silently (`worktree.yml`
272
305
  `conflict_resolution_ladder`).
273
306
 
307
+ An `integration-dirty` outcome names a `worktree` holding
308
+ `smith/<epic>/integration` with uncommitted tracked changes (`dirty`):
309
+ commit or stash those there, then re-run `smith queue run`.
310
+
274
311
  Once the wave is merged, check the epic's spend — `smith budget alarm
275
312
  <session-id> [--epic <epic>]`. Exit 1 means either the epic crossed
276
313
  `alarm_ratio` (re-plan the remaining work to fit, or ask the operator —
package/README.md CHANGED
@@ -391,7 +391,7 @@ runbook is written; the hosted UI stays deferred. Beside the phases, `/bs audit`
391
391
  is built: an existing project can be read on four axes and one epic cut from
392
392
  what you accept.
393
393
 
394
- The CLI is on npm as `@juzser/blacksmith`, at `0.3.0` — two binaries,
394
+ The CLI is on npm as `@juzser/blacksmith`, at `0.4.0` — two binaries,
395
395
  `smith` and `smith-run`, the second (since `0.2.0`) being what runs a
396
396
  rendered turn on a `cli` harness such as Codex. `0.1.1` was the first version that ran beside
397
397
  you and knew it was a package, rather than out of the clone-shaped install
@@ -54,6 +54,29 @@ function findMaxTurns(text, file) {
54
54
  function withMaxTurns(text, site, value) {
55
55
  return `${text.slice(0, site.from)}${value}${text.slice(site.to)}`;
56
56
  }
57
+ /**
58
+ * The template's current `maxTurns:`, read fresh from disk — never cached,
59
+ * because `smith agents sync` may have rewritten the file since the caller
60
+ * last looked. Unlike `findMaxTurns`, this never throws: a role with no
61
+ * template, or a template with no `maxTurns:` line, is "cannot verify" for a
62
+ * caller like dispatchLint.ts, not a hard failure.
63
+ */
64
+ export function readTemplateMaxTurns(agentsDir, role) {
65
+ const file = path.join(agentsDir, `${role}.md`);
66
+ let text;
67
+ try {
68
+ text = readFileSync(file, 'utf8');
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ try {
74
+ return findMaxTurns(text, file).value;
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ }
57
80
  function positiveInt(name, raw) {
58
81
  const value = /^[0-9]+$/.test(raw) ? Number(raw) : Number.NaN;
59
82
  if (!Number.isSafeInteger(value) || value <= 0) {
@@ -32,11 +32,15 @@
32
32
  import { randomBytes } from 'node:crypto';
33
33
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
34
34
  import path from 'node:path';
35
+ import { resolveFindingOwner } from './claims.js';
35
36
  import { SmithError } from './errors.js';
36
37
  import { appendEvent } from './events.js';
37
38
  import { computeFingerprint, normalizeFilePath } from './findings.js';
38
39
  import { runGit } from './git.js';
39
40
  import { checkWorktreeImmutable, fingerprintWorktree, } from './immutability.js';
41
+ import { isFactoryCheckout, SPECS_ACTIVE_DIR } from './paths.js';
42
+ import { latestPlanVersion, loadPlan, planClaimedTasks, } from './plan.js';
43
+ import { defaultKindFor, FACTORY_PROJECT } from './roadmap.js';
40
44
  import { loadTaxonomy } from './taxonomy.js';
41
45
  export class AuditError extends SmithError {
42
46
  }
@@ -251,11 +255,31 @@ export function foldAuditStore(lines, opts = {}) {
251
255
  * The fingerprints an axis may not raise again. This is the whole of the
252
256
  * dedupe half of §3.1 — `audit record` drops what this names and appends the
253
257
  * rest.
258
+ *
259
+ * Each suppressing finding contributes two fingerprints, not one. Its
260
+ * stored `finding.fingerprint` is whatever `computeFingerprint` produced
261
+ * *when the line was written* — widening that normalizer (issue #178) does
262
+ * not rewrite `findings.jsonl`, so a decline recorded before a normalizer
263
+ * fix keeps its old hash forever. The raised line still carries its own
264
+ * `file_path`/`axis`/`summary`, so recomputing the fingerprint with today's
265
+ * normalizer costs nothing and lets a pre-existing decline suppress a
266
+ * re-raise that only differs in what the new normalizer now strips — no
267
+ * store rewrite, no migration. When the stored hash and the recomputed one
268
+ * already agree (the common case), the second `add` is a no-op.
254
269
  */
255
270
  export function suppressedFingerprints(lines, opts = {}) {
256
- return new Set(foldAuditStore(lines, opts)
257
- .filter((finding) => finding.suppressesReraise)
258
- .map((finding) => finding.fingerprint));
271
+ const set = new Set();
272
+ for (const finding of foldAuditStore(lines, opts)) {
273
+ if (!finding.suppressesReraise)
274
+ continue;
275
+ set.add(finding.fingerprint);
276
+ set.add(computeFingerprint({
277
+ filePath: finding.file_path,
278
+ category: finding.axis,
279
+ summary: finding.summary,
280
+ }));
281
+ }
282
+ return set;
259
283
  }
260
284
  function clusterOf(filePath, findings) {
261
285
  const members = findings.filter((finding) => finding.status !== 'merged');
@@ -398,6 +422,39 @@ function mintAuditId(now) {
398
422
  const day = now.toISOString().slice(0, 10).replaceAll('-', '');
399
423
  return `${day}-${randomBytes(4).toString('hex')}`;
400
424
  }
425
+ /**
426
+ * The name an audit calls `project` by, everywhere one is needed: the
427
+ * event's `project` field, the cut milestone's `- project:` line and goal
428
+ * sentence, and the cut spec's Project line.
429
+ *
430
+ * `path.basename(project)` names every OTHER project correctly, but this
431
+ * clone's own directory is not always named FACTORY_PROJECT
432
+ * (`black-smith`) -- roadmap.ts's own note documents the real `black-smith`/
433
+ * `blacksmith` siblings this factory was built beside -- so a self-audit that
434
+ * used the basename wrote a name `collectProjects()` (projects.ts) had never
435
+ * seen, sent it looking for a checkout under this clone's own parent, and it
436
+ * found this very clone: registered a second time as a foreign, non-self ref
437
+ * that `--no-self` could not filter. Comparing against REPO_ROOT, resolved
438
+ * the same way `unwatchedProjects` (projects.ts) already does, is what tells
439
+ * "this clone, filed under its own directory name" from "a project that is
440
+ * genuinely named that".
441
+ */
442
+ export function auditedProjectName(project) {
443
+ return isFactoryCheckout(project) ? FACTORY_PROJECT : path.basename(project);
444
+ }
445
+ /**
446
+ * The `- kind:` a cut milestone declares alongside auditedProjectName's
447
+ * `- project:` line. roadmap.ts's own parser fixes the two together
448
+ * (`roadmap.factory-kind-fixed`): a milestone naming FACTORY_PROJECT must be
449
+ * kind "factory", never "product". A cut that named a self-audit
450
+ * FACTORY_PROJECT but still wrote a hard-coded "product" kind would stamp a
451
+ * milestone that fails to parse the moment `loadRoadmap()` reads it back, so
452
+ * this feeds auditedProjectName's own answer through roadmap.ts's
453
+ * defaultKindFor rather than re-deriving it separately.
454
+ */
455
+ export function auditedProjectKind(project) {
456
+ return defaultKindFor(auditedProjectName(project));
457
+ }
401
458
  /**
402
459
  * The audit's events are ordinary events on the operator's session, stamped
403
460
  * with the project's name so the multi-project timeline can tell whose audit
@@ -412,7 +469,7 @@ function emit(eventType, project, ctx, opts, payload) {
412
469
  event_type: eventType,
413
470
  plan_version: ctx.planVersion,
414
471
  causal_parent: ctx.causalParent,
415
- project: path.basename(project),
472
+ project: auditedProjectName(project),
416
473
  payload,
417
474
  }, opts);
418
475
  }
@@ -635,7 +692,7 @@ function cite(finding) {
635
692
  function renderMilestone(input, project, findings) {
636
693
  const audits = auditIdsOf(findings);
637
694
  const goal = [
638
- `Cut from audit ${audits.join(', ')} of \`${path.basename(project)}\` (${findings.length} accepted finding${findings.length === 1 ? '' : 's'}: ${severityCounts(findings)}).`,
695
+ `Cut from audit ${audits.join(', ')} of \`${auditedProjectName(project)}\` (${findings.length} accepted finding${findings.length === 1 ? '' : 's'}: ${severityCounts(findings)}).`,
639
696
  'Each finding below is one acceptance criterion; the plan decides the tasks.',
640
697
  ...findings.map((finding) => `${cite(finding)}.`),
641
698
  ].join(' ');
@@ -644,8 +701,8 @@ function renderMilestone(input, project, findings) {
644
701
  `- id: ${input.epicId}`,
645
702
  '- status: planned',
646
703
  `- epics: [${input.epicId}]`,
647
- `- project: ${path.basename(project)}`,
648
- '- kind: product',
704
+ `- project: ${auditedProjectName(project)}`,
705
+ `- kind: ${auditedProjectKind(project)}`,
649
706
  `- goal: ${goal}`,
650
707
  '',
651
708
  ].join('\n');
@@ -673,9 +730,9 @@ function renderSpec(input, project, findings) {
673
730
  `# Epic spec — \`${input.epicId}\``,
674
731
  '',
675
732
  `- **Epic id** — \`${input.epicId}\``,
676
- `- **Project** — \`${path.basename(project)}\` at \`${project}\`, and every worktree is placed beside that clone (\`AGENTS.md\` "Worktrees").`,
733
+ `- **Project** — \`${auditedProjectName(project)}\` at \`${project}\`, and every worktree is placed beside that clone (\`AGENTS.md\` "Worktrees").`,
677
734
  `- **Roadmap milestone** — \`${input.epicId}\` in \`factory/specs/roadmap.md\`.`,
678
- `- **Provenance** — cut by \`smith audit cut\` from audit ${audits.join(', ')} (${findings.length} accepted finding${findings.length === 1 ? '' : 's'}: ${severityCounts(findings)}). Each finding carries this epic id in \`.blacksmith/findings.jsonl\`; \`smith audit resolve\` marks them fixed when the epic closes.`,
735
+ `- **Provenance** — cut by \`smith audit cut\` from audit ${audits.join(', ')} (${findings.length} accepted finding${findings.length === 1 ? '' : 's'}: ${severityCounts(findings)}). Each finding carries this epic id in \`.blacksmith/findings.jsonl\`; \`smith audit resolve\` marks fixed only the ones a task in this plan still claims, and leaves the rest \`deferred\`.`,
679
736
  '',
680
737
  '## What this epic is',
681
738
  '',
@@ -734,24 +791,83 @@ export async function cutAudit(projectDir, input, ctx, opts = {}) {
734
791
  };
735
792
  }
736
793
  /**
737
- * `audit resolve <project-dir> --epic <epic-id>`. Appends a `fixed` line for
738
- * every finding the epic carried. Called from `/bs run`'s epic-close step, so
739
- * it is repeatable; an epic no finding carries is refused, because the likely
740
- * cause is a typo and a silent no-op would leave every finding open.
794
+ * The epic's newest plan, however `resolveOpts` asks for it found. Refuses
795
+ * rather than guesses: `resolveAudit` must never treat "I could not find a
796
+ * plan" as "nothing is claimed", because that reading marks every carried
797
+ * finding fixed.
798
+ */
799
+ function resolveEpicPlan(epicId, resolveOpts) {
800
+ if (resolveOpts.plan !== undefined)
801
+ return resolveOpts.plan;
802
+ const planOpts = resolveOpts.planOpts ?? {};
803
+ const version = latestPlanVersion(epicId, planOpts);
804
+ if (version === null) {
805
+ const searchedDir = path.join(planOpts.specsDir ?? SPECS_ACTIVE_DIR, epicId);
806
+ throw new AuditError('audit.no-plan', `no plan found for epic ${epicId}: \`audit resolve\` only marks a finding fixed when some plan task claims its file, so a missing plan cannot be treated as an empty one. Searched ${searchedDir}. If this epic's plans live elsewhere, pass --specs-dir <dir> naming the directory they live in, or --plan <plan.json> to point at one directly.`, { epic: epicId });
807
+ }
808
+ return loadPlan(epicId, version, planOpts);
809
+ }
810
+ /**
811
+ * `--except` entries against the epic's carried fingerprints, full or a
812
+ * unique prefix — the same latitude other audit verbs give a fingerprint
813
+ * argument. A value that matches nothing is refused as a likely typo (same
814
+ * spirit as `audit.unknown-epic`); one whose prefix matches more than one
815
+ * carried fingerprint is refused rather than guessed.
816
+ */
817
+ function matchExceptFingerprints(except, carried, epicId) {
818
+ const known = carried.map((finding) => finding.fingerprint);
819
+ const matched = new Set();
820
+ for (const value of except) {
821
+ if (known.includes(value)) {
822
+ matched.add(value);
823
+ continue;
824
+ }
825
+ const candidates = known.filter((fingerprint) => fingerprint.startsWith(value));
826
+ if (candidates.length === 1) {
827
+ matched.add(candidates[0]);
828
+ continue;
829
+ }
830
+ if (candidates.length > 1) {
831
+ throw new AuditError('audit.ambiguous-except', `--except ${value} matches ${candidates.length} findings epic ${epicId} carries: ${candidates.join(', ')}`, { epic: epicId, value, candidates });
832
+ }
833
+ throw new AuditError('audit.unknown-finding', `--except ${value} matches no finding epic ${epicId} carries`, { epic: epicId, value });
834
+ }
835
+ return matched;
836
+ }
837
+ /**
838
+ * `audit resolve <project-dir> --epic <epic-id>`. Appends a `fixed` line only
839
+ * for a carried finding some task in the epic's newest plan still claims
840
+ * (`resolveFindingOwner`, D-41/P9-24) — an operator who scoped the plan
841
+ * narrower than the audit deferred the rest on purpose, and marking them
842
+ * fixed anyway would erase that choice. Unclaimed findings stay exactly as
843
+ * they were and come back in `deferred`; `--except` forces specific
844
+ * fingerprints there even when a task claims them. Called from `/bs run`'s
845
+ * epic-close step, so it is repeatable; an epic no finding carries is
846
+ * refused, because the likely cause is a typo and a silent no-op would leave
847
+ * every finding open. A plan that cannot be found is refused too
848
+ * (`audit.no-plan`): being unsure must never mark anything fixed.
741
849
  */
742
- export async function resolveAudit(projectDir, epicId, ctx, opts = {}) {
850
+ export async function resolveAudit(projectDir, epicId, ctx, opts = {}, resolveOpts = {}) {
743
851
  const project = path.resolve(projectDir);
744
852
  const carried = foldAuditStore(readAuditStore(project)).filter((finding) => finding.epic === epicId);
745
853
  if (carried.length === 0) {
746
854
  throw new AuditError('audit.unknown-epic', `no finding in ${auditStorePath(project)} carries epic ${epicId}: was it cut with \`smith audit cut\`?`, { epic: epicId });
747
855
  }
748
856
  const open = carried.filter((finding) => finding.status !== 'fixed');
857
+ const plan = resolveEpicPlan(epicId, resolveOpts);
858
+ const claimedTasks = planClaimedTasks(plan);
859
+ const except = matchExceptFingerprints(resolveOpts.except ?? [], carried, epicId);
860
+ const toFix = open.filter((finding) => !except.has(finding.fingerprint) &&
861
+ resolveFindingOwner(finding.file_path, claimedTasks).owner !== 'unclaimed');
862
+ const toFixFingerprints = new Set(toFix.map((finding) => finding.fingerprint));
863
+ const deferred = open.filter((finding) => !toFixFingerprints.has(finding.fingerprint));
749
864
  const stored = await emit('audit-resolved', project, ctx, opts, {
750
865
  epic: epicId,
751
- fixed: open.map((finding) => finding.fingerprint),
866
+ fixed: toFix.map((finding) => finding.fingerprint),
867
+ deferred: deferred.map((finding) => finding.fingerprint),
752
868
  });
753
869
  const ts = new Date().toISOString();
754
- appendAuditLines(project, open.map((finding) => ({
870
+ appendAuditLines(project, toFix.map((finding) => ({
755
871
  fingerprint: finding.fingerprint,
756
872
  status: 'fixed',
757
873
  ts,
@@ -761,10 +877,11 @@ export async function resolveAudit(projectDir, epicId, ctx, opts = {}) {
761
877
  })));
762
878
  return {
763
879
  epic: epicId,
764
- fixed: open.map((finding) => finding.fingerprint),
880
+ fixed: toFix.map((finding) => finding.fingerprint),
765
881
  already: carried
766
882
  .filter((finding) => finding.status === 'fixed')
767
883
  .map((finding) => finding.fingerprint),
884
+ deferred: deferred.map((finding) => finding.fingerprint),
768
885
  event_id: stored.event_id,
769
886
  };
770
887
  }
@@ -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
  }