@juzser/blacksmith 0.4.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 (131) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +5 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +4 -4
  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} +13 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -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} +4 -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 +34 -27
  17. package/.claude/skills/bs/dispatch.md +100 -50
  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 +27 -18
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +181 -49
  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 +109 -53
  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 +48 -14
  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 +125 -17
  35. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  36. package/factory/orchestrator/dist/budgets.js +302 -84
  37. package/factory/orchestrator/dist/claims.js +9 -19
  38. package/factory/orchestrator/dist/cli.js +790 -100
  39. package/factory/orchestrator/dist/cliName.js +27 -0
  40. package/factory/orchestrator/dist/crosscheck.js +136 -8
  41. package/factory/orchestrator/dist/daemon.js +154 -36
  42. package/factory/orchestrator/dist/db/projector.js +278 -29
  43. package/factory/orchestrator/dist/db/queries.js +1831 -94
  44. package/factory/orchestrator/dist/db/schema.js +57 -4
  45. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  46. package/factory/orchestrator/dist/delegation.js +86 -19
  47. package/factory/orchestrator/dist/diffstat.js +15 -8
  48. package/factory/orchestrator/dist/dispatchLint.js +13 -37
  49. package/factory/orchestrator/dist/effort.js +14 -2
  50. package/factory/orchestrator/dist/effortTiers.js +13 -0
  51. package/factory/orchestrator/dist/env.js +26 -0
  52. package/factory/orchestrator/dist/epic.js +323 -38
  53. package/factory/orchestrator/dist/epicBudget.js +93 -0
  54. package/factory/orchestrator/dist/epicWidth.js +22 -5
  55. package/factory/orchestrator/dist/errorIssues.js +10 -4
  56. package/factory/orchestrator/dist/escalation.js +2 -2
  57. package/factory/orchestrator/dist/events.js +105 -3
  58. package/factory/orchestrator/dist/feedback.js +169 -0
  59. package/factory/orchestrator/dist/findingContext.js +26 -0
  60. package/factory/orchestrator/dist/findings.js +83 -12
  61. package/factory/orchestrator/dist/gate.js +120 -10
  62. package/factory/orchestrator/dist/goalCheck.js +2 -2
  63. package/factory/orchestrator/dist/harness.js +25 -17
  64. package/factory/orchestrator/dist/hookDecision.js +92 -3
  65. package/factory/orchestrator/dist/integration.js +9 -7
  66. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  67. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  68. package/factory/orchestrator/dist/judgeStopHook.js +90 -32
  69. package/factory/orchestrator/dist/judges.js +419 -21
  70. package/factory/orchestrator/dist/lessons.js +190 -11
  71. package/factory/orchestrator/dist/mcp.js +6 -6
  72. package/factory/orchestrator/dist/paths.js +10 -7
  73. package/factory/orchestrator/dist/plan.js +269 -33
  74. package/factory/orchestrator/dist/planQuorum.js +5 -2
  75. package/factory/orchestrator/dist/policy.js +1275 -30
  76. package/factory/orchestrator/dist/projectName.js +25 -0
  77. package/factory/orchestrator/dist/promptCapture.js +306 -0
  78. package/factory/orchestrator/dist/promptHook.js +33 -0
  79. package/factory/orchestrator/dist/promptImport.js +281 -0
  80. package/factory/orchestrator/dist/prompts.js +13 -0
  81. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  82. package/factory/orchestrator/dist/providers/types.js +1 -1
  83. package/factory/orchestrator/dist/queue.js +4 -4
  84. package/factory/orchestrator/dist/quorum.js +18 -5
  85. package/factory/orchestrator/dist/results.js +25 -7
  86. package/factory/orchestrator/dist/roadmap.js +10 -5
  87. package/factory/orchestrator/dist/run-cli.js +15 -10
  88. package/factory/orchestrator/dist/runner.js +2 -2
  89. package/factory/orchestrator/dist/schemas.js +56 -8
  90. package/factory/orchestrator/dist/severity.js +8 -4
  91. package/factory/orchestrator/dist/spec.js +177 -13
  92. package/factory/orchestrator/dist/specChange.js +13 -9
  93. package/factory/orchestrator/dist/stack.js +6 -6
  94. package/factory/orchestrator/dist/storeRoot.js +90 -0
  95. package/factory/orchestrator/dist/symbols.js +261 -6
  96. package/factory/orchestrator/dist/taskEvents.js +179 -27
  97. package/factory/orchestrator/dist/taskStatus.js +14 -0
  98. package/factory/orchestrator/dist/testerAudit.js +31 -12
  99. package/factory/orchestrator/dist/testgate.js +16 -13
  100. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  101. package/factory/orchestrator/dist/usage.js +97 -40
  102. package/factory/orchestrator/dist/waivers.js +39 -5
  103. package/factory/orchestrator/dist/waveBudget.js +2 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  105. package/factory/orchestrator/dist/workroot.js +1 -1
  106. package/factory/orchestrator/dist/worktree.js +61 -19
  107. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  108. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  109. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  110. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  111. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  112. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  113. package/factory/policies/budgets.yml +123 -33
  114. package/factory/policies/crosscheck.yml +51 -13
  115. package/factory/policies/delegation.yml +3 -3
  116. package/factory/policies/effort.yml +18 -10
  117. package/factory/policies/guardrails.yml +8 -6
  118. package/factory/policies/harness.yml +5 -5
  119. package/factory/policies/lessons.md +53 -2
  120. package/factory/policies/scheduler.yml +4 -4
  121. package/factory/policies/sensitive-paths.yml +1 -1
  122. package/factory/policies/severity.yml +13 -1
  123. package/factory/policies/stack.yml +6 -6
  124. package/factory/policies/taxonomy.yml +9 -3
  125. package/factory/policies/worktree.yml +15 -9
  126. package/factory/specs/roadmap.md +7 -7
  127. package/factory/specs/schema/event.schema.json +8 -1
  128. package/factory/specs/schema/result.schema.json +21 -8
  129. package/factory/specs/schema/task-spec.schema.json +12 -0
  130. package/package.json +16 -12
  131. package/.claude/agents/uiux.md +0 -133
package/README.md CHANGED
@@ -125,7 +125,7 @@ provider.
125
125
  **The log is the source of truth**
126
126
 
127
127
  Every prompt, dispatch, gate result and error is an append-only event on disk.
128
- The dashboard is a projection of that log, and `smith db rebuild` reconstructs
128
+ The dashboard is a projection of that log, and `bs db rebuild` reconstructs
129
129
  the entire database from the log alone. Nothing the factory did exists only in
130
130
  a chat transcript.
131
131
 
@@ -168,18 +168,27 @@ and two inside Claude Code:
168
168
  /plugin install blacksmith@blacksmith
169
169
  ```
170
170
 
171
- Then `smith init` in the project you want it to work on. That creates
171
+ Two optional plugin modules come from the same marketplace, bs-mod (a live
172
+ HUD of your epic above the prompt) and pr-mod (the repo's open pull requests),
173
+ and [`docs/guide/mods.md`](docs/guide/mods.md) has what each needs:
174
+
175
+ ```
176
+ /plugin install bs-mod@blacksmith
177
+ /plugin install pr-mod@blacksmith
178
+ ```
179
+
180
+ Then `bs init` in the project you want it to work on. That creates
172
181
  `.blacksmith/` beside your code — the event log, your epic plans, a roadmap and
173
- a `stack.yml` to answer — and writes nothing anywhere else; `SMITH_HOME` moves
182
+ a `stack.yml` to answer — and writes nothing anywhere else; `BS_HOME` moves
174
183
  that root if you want one home for several projects. You now have `/bs`.
175
184
 
176
185
  **Both halves are required.** The package
177
186
  ([`@juzser/blacksmith`](https://www.npmjs.com/package/@juzser/blacksmith)) is the
178
- deterministic `smith` CLI; the plugin is `/bs` and the fourteen agent roles it
179
- dispatches. `smith` alone never gives you `/bs`, because Claude Code loads
187
+ deterministic `bs` CLI; the plugin is `/bs` and the fourteen agent roles it
188
+ dispatches. `bs` alone never gives you `/bs`, because Claude Code loads
180
189
  skills from a project's `.claude/`, your `~/.claude/`, or a plugin — never from
181
- `node_modules` — and a `/bs` with no `smith` on PATH can run nothing. Take
182
- `latest`: `0.1.0` predates `smith init` and keeps state inside its own install
190
+ `node_modules` — and a `/bs` with no `bs` on PATH can run nothing. Take
191
+ `latest`: `0.1.0` predates `bs init` and keeps state inside its own install
183
192
  directory, which the next `npm i` replaces.
184
193
 
185
194
  <details>
@@ -197,17 +206,21 @@ bash scripts/check.sh # the gate CI runs; ends in `== PASS ==`
197
206
 
198
207
  Open a Claude Code session in the clone and you have `/bs` already, from the
199
208
  checkout's own `.claude/` — **do not also install the plugin there.** You would
200
- get two of everything (`bs` and `blacksmith:bs`, `auditor` and
201
- `blacksmith:auditor`, once per role), pay the always-on cost twice, and the two
209
+ get two of everything (`bs` and `blacksmith:bs`, `bs-auditor` and
210
+ `blacksmith:bs-auditor`, once per role), pay the always-on cost twice, and the two
202
211
  copies are free to disagree: the plugin's is a pinned checkout of `main`, the
203
212
  project's is whatever branch you have out. `claude plugin disable blacksmith`
204
- settles it.
213
+ settles it. It also turns off the prompt recorder in that clone, which only
214
+ the plugin registers; a clone that wants its prompts recorded keeps the plugin
215
+ enabled and accepts the double listing
216
+ ([INSTALL.md](INSTALL.md#the-install--bs-and-bs)).
205
217
 
206
218
  The dashboard is clone-only because `ui/` is in neither the tarball nor the
207
- plugin, so `smith ui serve` answers `ui.not-built` in an install and means it.
219
+ plugin, so `bs ui serve` answers `ui.not-built` in an install and means it.
208
220
  Enforcement is clone-only because this repo's `.claude/settings.json` deny
209
- rules and its policy hook resolve paths against a checkout; the plugin ships no
210
- `hooks/hooks.json` and loads neither (`Hooks (0)`), since a `/bs` that asked
221
+ rules and its policy hook resolve paths against a checkout; the plugin ships one
222
+ `hooks/hooks.json`, holding only the non-blocking prompt-capture hook, and
223
+ loads neither the deny rules nor the policy guard, since a `/bs` that asked
211
224
  you about every Bash command would be worse than one that asks about none. The
212
225
  rest of that port is scoped in
213
226
  [`docs/specs/plugin-port-scope.md`](docs/specs/plugin-port-scope.md).
@@ -233,9 +246,9 @@ would rather drive it yourself, it is three commands:
233
246
  ```bash
234
247
  $EDITOR factory/policies/stack.yml # your stack answers: language, frontend,
235
248
  # database, deploy target. `none` is fine.
236
- smith stack check # which answers the templates honour,
249
+ bs stack check # which answers the templates honour,
237
250
  # which they only record, which they refuse
238
- smith new my-app --target-dir ~/code/my-app # add --ui for a frontend
251
+ bs new my-app --target-dir ~/code/my-app # add --ui for a frontend
239
252
  ```
240
253
 
241
254
  The last call scaffolds (TS strict, Biome, Vitest, CI), installs, runs the
@@ -278,7 +291,7 @@ it is the ordinary loop: `/bs plan` against that epic, then `/bs run`, and
278
291
  closing the epic marks its findings fixed. There is no audit-specific run
279
292
  path; the command's value is the insight and the ranking.
280
293
 
281
- Underneath it is the `smith audit` family — `open`, `record`, `consolidate`,
294
+ Underneath it is the `bs audit` family — `open`, `record`, `consolidate`,
282
295
  `decide`, `cut`, `resolve`, `close` — and
283
296
  [`docs/specs/audit-command-scope.md`](docs/specs/audit-command-scope.md) is the
284
297
  contract each of them keeps.
@@ -300,10 +313,10 @@ Day to day, from a Claude Code session opened in this repo:
300
313
  | `/bs lessons` | Review pending lesson candidates |
301
314
  | `/bs report` | Render the scribe's progress digest |
302
315
 
303
- Each of those is a playbook, not a script: the deterministic half is a `smith`
304
- command you can run yourself — `smith --help` lists all of them — and the
316
+ Each of those is a playbook, not a script: the deterministic half is a `bs`
317
+ command you can run yourself — `bs --help` lists all of them — and the
305
318
  judgment half is a Claude Code session the playbook dispatches. That is also
306
- the line between the two installs: the `smith` half travels in the package, the
319
+ the line between the two installs: the `bs` half travels in the package, the
307
320
  playbooks are read from a clone.
308
321
 
309
322
  → **[The operator loop](docs/guide/operator-loop.md)** — the six steps, in the
@@ -319,7 +332,7 @@ v1. From there the loop admits a wave whose path claims do not overlap, sends
319
332
  researcher and UI/UX work ahead of code where the epic needs it, runs a coder
320
333
  and a tester in a worktree, grades the result against its own acceptance
321
334
  criteria, then puts it through the gates and a serial merge queue into
322
- `smith/<epic>/integration`. One epic, one integration PR, merged by you.
335
+ `bs/<epic>/integration`. One epic, one integration PR, merged by you.
323
336
 
324
337
  → The pipeline diagram and the reasoning behind each stage:
325
338
  **[architecture §3 — The loop](docs/specs/black-smith-architecture.md#3-the-loop)**.
@@ -339,8 +352,10 @@ runs, plus branch protection — not by trust. Full rules:
339
352
  branch solely through the serial merge queue.
340
353
  - **No autonomous deploy or outbound sends.** Deploys, publishes and message
341
354
  sends each need per-invocation approval.
342
- - **Budgets are declared per role.** 4M tokens per epic with an alarm at 70%;
343
- 150K tokens and 400 diff lines per coder task. Fan-out is bounded by the
355
+ - **Budgets are declared per role and sized per effort tier.** A medium epic
356
+ gets 16M tokens with an alarm at 70%, and a coder task 220K tokens and 700
357
+ diff lines; a small epic gets half the task numbers and 4M, a huge one
358
+ double and 32M. Fan-out is bounded by the
344
359
  claim graph, and `max_in_flight_tasks` is available on top of it, off by
345
360
  default. Which of these *block* versus *report* is spelled out in
346
361
  [`factory/policies/budgets.yml`](factory/policies/budgets.yml) — the task cap
@@ -351,11 +366,14 @@ a public issue.
351
366
 
352
367
  ## The dashboard
353
368
 
354
- Optional, and deliberately small: `/bs ui` (or `smith ui serve`) binds eleven
355
- read-only pages to `127.0.0.1`. They are a projection of the event log and
356
- nothing else — `smith db rebuild` reconstructs them from it, `smith stats`
357
- prints the same facts in a terminal, and nothing you click there dispatches an
358
- agent. The factory runs without ever opening it.
369
+ Optional, and deliberately small: `/bs ui` (or `bs ui serve`) binds eleven
370
+ read-only pages to `127.0.0.1`. They are a projection of the event log —
371
+ `bs db rebuild` reconstructs them from it, `bs stats` prints the same facts in
372
+ a terminal, and nothing you click there dispatches an agent. It also reads
373
+ the local Claude Code session registry, transcripts and prompt history (read-only;
374
+ `--claude-config-dir`, then `$CLAUDE_CONFIG_DIR`, then Claude Code's default),
375
+ served as `GET /api/cli-sessions` to loopback requests only. The factory runs
376
+ without ever opening the dashboard.
359
377
 
360
378
  <table>
361
379
  <tr valign="top">
@@ -386,14 +404,16 @@ shows you. It is part of the clone, not of the package.
386
404
 
387
405
  Phases 1–9 are built and merged: loop runner, worktree engine, gates, state and
388
406
  analytics, dashboard, self-extension, cross-provider judges, hardening. Phase 10
389
- is half in: `smith daemon` watches the factory in the background and its ops
407
+ is half in: `bs daemon` watches the factory in the background and its ops
390
408
  runbook is written; the hosted UI stays deferred. Beside the phases, `/bs audit`
391
409
  is built: an existing project can be read on four axes and one epic cut from
392
410
  what you accept.
393
411
 
394
- The CLI is on npm as `@juzser/blacksmith`, at `0.4.0` — two binaries,
395
- `smith` and `smith-run`, the second (since `0.2.0`) being what runs a
396
- rendered turn on a `cli` harness such as Codex. `0.1.1` was the first version that ran beside
412
+ The CLI is on npm as `@juzser/blacksmith`, at `0.5.0`. Two binaries are
413
+ the ones you run: `bs`, and `bs-run` (since `0.2.0`), which runs a rendered
414
+ turn on a `cli` harness such as Codex. `bs-prompt-hook` is the lean hook
415
+ entry that records operator prompts. `smith` and `smith-run` remain as
416
+ deprecated aliases. `0.1.1` was the first version that ran beside
397
417
  you and knew it was a package, rather than out of the clone-shaped install
398
418
  `0.1.0` was. The package is the binaries and what they
399
419
  read. The dashboard, the docs, the test suite and `/bs` itself come from a
@@ -423,8 +443,9 @@ version in
423
443
  | [`docs/specs/audit-command-scope.md`](docs/specs/audit-command-scope.md) | What `/bs audit` promises an audited project, and why |
424
444
  | [`docs/specs/plugin-port-scope.md`](docs/specs/plugin-port-scope.md) | How `/bs` runs without a clone, and what the plugin leaves behind |
425
445
  | [`docs/guide/dashboard.md`](docs/guide/dashboard.md) | The dashboard tour |
446
+ | [`docs/guide/mods.md`](docs/guide/mods.md) | The optional bs-mod HUD and pr-mod |
426
447
  | [`docs/runbooks/providers.md`](docs/runbooks/providers.md) | Setting up the cross-provider judges |
427
- | [`docs/runbooks/ops.md`](docs/runbooks/ops.md) | Running `smith daemon` unattended |
448
+ | [`docs/runbooks/ops.md`](docs/runbooks/ops.md) | Running `bs daemon` unattended |
428
449
  | [`docs/README.md`](docs/README.md) | Everything else, one line each |
429
450
 
430
451
  Agents read [`AGENTS.md`](AGENTS.md) and [`CLAUDE.md`](CLAUDE.md) instead — this
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The one place a role becomes a Claude Code subagent name, a template file
3
+ * name, and back. The role (`coder`) is data everywhere else: events,
4
+ * policies, ledgers, env names. Only the subagent carries the `bs-` prefix,
5
+ * so that it cannot collide with another plugin's or a user's `coder`.
6
+ */
7
+ export const AGENT_PREFIX = 'bs-';
8
+ /** `coder` -> `bs-coder`: what `subagent_type` and a template's `name:` carry. */
9
+ export function agentNameFor(role) {
10
+ return `${AGENT_PREFIX}${role}`;
11
+ }
12
+ /** `coder` -> `bs-coder.md`: the template file under `.claude/agents/`. */
13
+ export function templateFileFor(role) {
14
+ return `${agentNameFor(role)}.md`;
15
+ }
16
+ /** `bs-coder.md` -> `coder`; null for a file that is not a `bs-<role>.md` template. */
17
+ export function roleOfTemplateFile(file) {
18
+ if (!file.endsWith('.md'))
19
+ return null;
20
+ return roleOfAgentType(file.slice(0, -'.md'.length), { bareAccepted: false });
21
+ }
22
+ /**
23
+ * What Claude Code puts in a hook's `agent_type` or a dispatch's
24
+ * `subagent_type` -> the role. Strips an optional `<namespace>:` and then the
25
+ * `bs-` prefix. For one transition the bare pre-prefix name (`reviewer`,
26
+ * `blacksmith:reviewer`) is accepted too, because a newer CLI can run with an
27
+ * older plugin still installed. Returns a *candidate* role, not a verified one:
28
+ * any other non-empty name (`general-purpose`) comes back as itself, so the
29
+ * caller must check the result against its own role set. Null only for an
30
+ * empty name, a bare `bs-` or `<ns>:`, or (with `bareAccepted: false`) a name
31
+ * without the prefix.
32
+ */
33
+ export function roleOfAgentType(agentType, options = {}) {
34
+ const colon = agentType.lastIndexOf(':');
35
+ const name = colon === -1 ? agentType : agentType.slice(colon + 1);
36
+ if (name.startsWith(AGENT_PREFIX)) {
37
+ const role = name.slice(AGENT_PREFIX.length);
38
+ return role.length > 0 ? role : null;
39
+ }
40
+ if (options.bareAccepted === false)
41
+ return null;
42
+ return name.length > 0 ? name : null;
43
+ }
@@ -289,15 +289,22 @@ export function foldAgents(events) {
289
289
  if (record.event_type === ERROR_EVENT_TYPE) {
290
290
  const payload = record.payload;
291
291
  // `task_ref` is this event's own spelling; eventTaskId covers the two
292
- // the rest of the log uses (D-245).
292
+ // the rest of the log uses (D-245). `agent_role` is the dispatch's own
293
+ // key and this event's usual spelling, but a hand-written one may only
294
+ // carry `agent` (the Result file's spelling), so either key alone still
295
+ // finds the role -- `agent_role` tried first because it was already
296
+ // this fold's only key before `agent` was added as a fallback, same
297
+ // reason task-result-recorded above tries `agent` first for its own
298
+ // history; not the same order, because each fold keeps its own.
293
299
  const taskId = eventTaskId(record) ?? payload.task_ref;
300
+ const role = payload.agent_role ?? payload.agent ?? null;
294
301
  if (taskId) {
295
- closeOpen(open, taskId, payload.agent_role ?? null, event_id, record.ts, 'error');
302
+ closeOpen(open, taskId, role, event_id, record.ts, 'error');
296
303
  }
297
- else if (payload.agent_role) {
304
+ else if (role) {
298
305
  // Only with a role: a session-level error names nobody, and every
299
306
  // epic-level agent stays where it was.
300
- closeOpenEpicLevel(openEpicLevel, record.session_id, payload.agent_role, payload.epic_id, event_id, record.ts, 'error');
307
+ closeOpenEpicLevel(openEpicLevel, record.session_id, role, payload.epic_id, event_id, record.ts, 'error');
301
308
  }
302
309
  continue;
303
310
  }
@@ -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. */
@@ -62,7 +82,7 @@ function withMaxTurns(text, site, value) {
62
82
  * caller like dispatchLint.ts, not a hard failure.
63
83
  */
64
84
  export function readTemplateMaxTurns(agentsDir, role) {
65
- const file = path.join(agentsDir, `${role}.md`);
85
+ const file = path.join(agentsDir, templateFileFor(role));
66
86
  let text;
67
87
  try {
68
88
  text = readFileSync(file, 'utf8');
@@ -93,9 +113,10 @@ function apply(edits, dryRun) {
93
113
  return edits.map((edit) => edit.change);
94
114
  }
95
115
  /**
96
- * Rewrite `maxTurns:` for every role whose SMITH_MAXTURNS_<ROLE> is set
97
- * (non-empty). Every name and value is checked, and every target file read,
98
- * before anything is written: one bad entry refuses the whole sync.
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.
99
120
  */
100
121
  export function syncAgentMaxTurns(options = {}) {
101
122
  const agentsDir = options.agentsDir ?? AGENTS_DIR;
@@ -103,22 +124,35 @@ export function syncAgentMaxTurns(options = {}) {
103
124
  const dryRun = options.dryRun ?? false;
104
125
  const roles = listRoles(agentsDir);
105
126
  const byEnv = new Map(roles.map((role) => [maxTurnsEnvName(role), role]));
106
- 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();
107
132
  for (const name of Object.keys(env).sort()) {
108
- if (!name.startsWith(MAXTURNS_ENV_PREFIX))
133
+ const parsed = parseMaxTurnsEnvName(name);
134
+ if (!parsed)
109
135
  continue;
110
136
  const raw = env[name];
111
137
  if (raw === undefined || raw === '')
112
138
  continue;
113
- const role = byEnv.get(name);
139
+ const role = byEnv.get(parsed.legacyName);
114
140
  if (role === undefined) {
115
141
  throw new AgentsSyncError('agents.unknown-role', `${name} names no agent template; valid roles: ${roles.join(', ')}.`, { variable: name, roles });
116
142
  }
117
- 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
+ });
118
152
  }
119
- requested.sort((a, b) => a.role.localeCompare(b.role));
153
+ const requested = [...byRole.values()].sort((a, b) => a.role.localeCompare(b.role));
120
154
  const edits = requested.map(({ role, env: name, to }) => {
121
- const file = path.join(agentsDir, `${role}.md`);
155
+ const file = path.join(agentsDir, templateFileFor(role));
122
156
  const text = readFileSync(file, 'utf8');
123
157
  const site = findMaxTurns(text, file);
124
158
  return {
@@ -148,7 +182,7 @@ export function resetAgentMaxTurns(options = {}) {
148
182
  throw new AgentsSyncError('agents.reset-unavailable', `--reset needs git HEAD, but ${agentsDir} is not inside a git checkout.`, { agentsDir });
149
183
  }
150
184
  const edits = roles.map((role) => {
151
- const file = path.join(agentsDir, `${role}.md`);
185
+ const file = path.join(agentsDir, templateFileFor(role));
152
186
  const rel = path.relative(topLevel, realpathSync(file)).split(path.sep).join('/');
153
187
  let committed;
154
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({
@@ -34,13 +34,14 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileS
34
34
  import path from 'node:path';
35
35
  import { resolveFindingOwner } from './claims.js';
36
36
  import { SmithError } from './errors.js';
37
- import { appendEvent } from './events.js';
37
+ import { appendEvent, eventTaskId, readLineageEvents, } from './events.js';
38
38
  import { computeFingerprint, normalizeFilePath } from './findings.js';
39
39
  import { runGit } from './git.js';
40
40
  import { checkWorktreeImmutable, fingerprintWorktree, } from './immutability.js';
41
41
  import { isFactoryCheckout, SPECS_ACTIVE_DIR } from './paths.js';
42
- import { latestPlanVersion, loadPlan, planClaimedTasks, } from './plan.js';
42
+ import { latestPlanVersion, livePlanTasks, loadPlan, planClaimedTasks, } from './plan.js';
43
43
  import { defaultKindFor, FACTORY_PROJECT } from './roadmap.js';
44
+ import { bareTaskId, epicOfTaskId, isQualifiedTaskId } from './taskId.js';
44
45
  import { loadTaxonomy } from './taxonomy.js';
45
46
  export class AuditError extends SmithError {
46
47
  }
@@ -50,6 +51,30 @@ export class AuditError extends SmithError {
50
51
  * overlap is meaningful.
51
52
  */
52
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
+ }
53
78
  /** Spec §4.3's table, in the order that table is written. */
54
79
  export const AUDIT_STATUSES = ['raised', 'merged', 'accepted', 'declined', 'fixed'];
55
80
  /** Spec §4.4. Applied when the store is folded, not by a sweeper. */
@@ -400,7 +425,7 @@ export function readAuditManifest(projectDir) {
400
425
  function requireOpenAudit(projectDir) {
401
426
  const manifest = readAuditManifest(projectDir);
402
427
  if (manifest === undefined) {
403
- 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) });
404
429
  }
405
430
  return manifest;
406
431
  }
@@ -429,7 +454,7 @@ function mintAuditId(now) {
429
454
  *
430
455
  * `path.basename(project)` names every OTHER project correctly, but this
431
456
  * clone's own directory is not always named FACTORY_PROJECT
432
- * (`black-smith`) -- roadmap.ts's own note documents the real `black-smith`/
457
+ * (`blacksmith`) -- roadmap.ts's own note documents the real `black-smith`/
433
458
  * `blacksmith` siblings this factory was built beside -- so a self-audit that
434
459
  * used the basename wrote a name `collectProjects()` (projects.ts) had never
435
460
  * seen, sent it looking for a checkout under this clone's own parent, and it
@@ -730,9 +755,9 @@ function renderSpec(input, project, findings) {
730
755
  `# Epic spec — \`${input.epicId}\``,
731
756
  '',
732
757
  `- **Epic id** — \`${input.epicId}\``,
733
- `- **Project** — \`${auditedProjectName(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").`,
734
759
  `- **Roadmap milestone** — \`${input.epicId}\` in \`factory/specs/roadmap.md\`.`,
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\`.`,
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\`.`,
736
761
  '',
737
762
  '## What this epic is',
738
763
  '',
@@ -834,28 +859,111 @@ function matchExceptFingerprints(except, carried, epicId) {
834
859
  }
835
860
  return matched;
836
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
+ }
837
937
  /**
838
938
  * `audit resolve <project-dir> --epic <epic-id>`. Appends a `fixed` line only
839
939
  * 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.
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.
849
953
  */
850
954
  export async function resolveAudit(projectDir, epicId, ctx, opts = {}, resolveOpts = {}) {
851
955
  const project = path.resolve(projectDir);
852
956
  const carried = foldAuditStore(readAuditStore(project)).filter((finding) => finding.epic === epicId);
853
957
  if (carried.length === 0) {
854
- 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 });
855
959
  }
856
960
  const open = carried.filter((finding) => finding.status !== 'fixed');
857
961
  const plan = resolveEpicPlan(epicId, resolveOpts);
858
- const claimedTasks = planClaimedTasks(plan);
962
+ const lineage = await readLineageEvents(ctx.sessionId, opts);
963
+ const claimedTasks = [
964
+ ...planClaimedTasks(plan),
965
+ ...mergedSupersededClaims(plan, mergedTaskIds(lineage), epicId),
966
+ ];
859
967
  const except = matchExceptFingerprints(resolveOpts.except ?? [], carried, epicId);
860
968
  const toFix = open.filter((finding) => !except.has(finding.fingerprint) &&
861
969
  resolveFindingOwner(finding.file_path, claimedTasks).owner !== 'unclaimed');