@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.
- package/.claude/agents/auditor.md +3 -0
- package/.claude/agents/grader.md +12 -5
- package/.claude/agents/reviewer.md +3 -0
- package/.claude/agents/security-reviewer.md +3 -0
- package/.claude/agents/spec-reviewer.md +3 -0
- package/.claude/agents/verifier.md +3 -0
- package/.claude/skills/bs/audit.md +12 -5
- package/.claude/skills/bs/dispatch.md +40 -4
- package/.claude/skills/bs/plan.md +33 -1
- package/.claude/skills/bs/run.md +11 -5
- package/.claude/skills/bs/wave.md +40 -3
- package/README.md +1 -1
- package/factory/orchestrator/dist/agentsSync.js +23 -0
- package/factory/orchestrator/dist/audit.js +134 -17
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/cli.js +309 -28
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/daemon.js +198 -31
- package/factory/orchestrator/dist/db/projector.js +51 -27
- package/factory/orchestrator/dist/dispatchLint.js +132 -0
- package/factory/orchestrator/dist/epic.js +179 -12
- package/factory/orchestrator/dist/errorIssues.js +175 -10
- package/factory/orchestrator/dist/events.js +59 -11
- package/factory/orchestrator/dist/findings.js +25 -2
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/hookDecision.js +320 -37
- package/factory/orchestrator/dist/integration.js +11 -1
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgeStopHook.js +181 -0
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/paths.js +10 -0
- package/factory/orchestrator/dist/plan.js +130 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/queue.js +469 -28
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/spec.js +70 -0
- package/factory/orchestrator/dist/taskEvents.js +13 -4
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testgate.js +4 -3
- package/factory/orchestrator/dist/usage.js +18 -10
- package/factory/orchestrator/dist/waivers.js +60 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/worktree.js +19 -6
- package/factory/specs/roadmap.md +24 -0
- 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
|
package/.claude/agents/grader.md
CHANGED
|
@@ -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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
155
|
-
`fail` the
|
|
156
|
-
|
|
157
|
-
|
|
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.
|
package/.claude/skills/bs/run.md
CHANGED
|
@@ -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
|
-
`
|
|
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
|
|
297
|
-
audit store — the
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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:
|
|
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 \`${
|
|
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: ${
|
|
648
|
-
|
|
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** — \`${
|
|
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
|
|
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
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
|
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
|
}
|