@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.
- package/.claude/agents/{auditor.md → bs-auditor.md} +5 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +4 -4
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +13 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +4 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +34 -27
- package/.claude/skills/bs/dispatch.md +100 -50
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +27 -18
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +181 -49
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +109 -53
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +48 -14
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +125 -17
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +790 -100
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +154 -36
- package/factory/orchestrator/dist/db/projector.js +278 -29
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +13 -37
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +323 -38
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +10 -4
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +105 -3
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +83 -12
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +92 -3
- package/factory/orchestrator/dist/integration.js +9 -7
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +90 -32
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +10 -7
- package/factory/orchestrator/dist/plan.js +269 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +4 -4
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +177 -13
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +179 -27
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +16 -13
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +97 -40
- package/factory/orchestrator/dist/waivers.js +39 -5
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +61 -19
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +7 -7
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
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 `
|
|
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
|
-
|
|
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; `
|
|
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 `
|
|
179
|
-
dispatches. `
|
|
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 `
|
|
182
|
-
`latest`: `0.1.0` predates `
|
|
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 `
|
|
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
|
|
210
|
-
`hooks/hooks.json
|
|
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
|
-
|
|
249
|
+
bs stack check # which answers the templates honour,
|
|
237
250
|
# which they only record, which they refuse
|
|
238
|
-
|
|
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 `
|
|
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 `
|
|
304
|
-
command you can run yourself — `
|
|
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 `
|
|
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
|
-
`
|
|
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
|
|
343
|
-
|
|
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 `
|
|
355
|
-
read-only pages to `127.0.0.1`. They are a projection of the event log
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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: `
|
|
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.
|
|
395
|
-
`
|
|
396
|
-
|
|
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 `
|
|
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,
|
|
302
|
+
closeOpen(open, taskId, role, event_id, record.ts, 'error');
|
|
296
303
|
}
|
|
297
|
-
else if (
|
|
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,
|
|
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
|
-
.
|
|
29
|
-
.
|
|
48
|
+
.map((name) => roleOfTemplateFile(name))
|
|
49
|
+
.filter((role) => role !== null)
|
|
30
50
|
.sort();
|
|
31
51
|
}
|
|
32
52
|
/** The frontmatter block's bounds: the text between the leading `---` lines. */
|
|
@@ -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,
|
|
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
|
|
97
|
-
* (non-empty). Every name and value is
|
|
98
|
-
* before anything is written: one bad
|
|
116
|
+
* Rewrite `maxTurns:` for every role whose BS_MAXTURNS_<ROLE> (or its legacy
|
|
117
|
+
* SMITH_MAXTURNS_<ROLE> fallback) is set (non-empty). Every name and value is
|
|
118
|
+
* checked, and every target file read, before anything is written: one bad
|
|
119
|
+
* entry refuses the whole sync.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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 \`
|
|
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
|
-
* (`
|
|
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)}
|
|
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 \`
|
|
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)
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
*
|
|
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 \`
|
|
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
|
|
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');
|