@juzser/blacksmith 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.claude/agents/auditor.md +3 -0
  2. package/.claude/agents/grader.md +12 -5
  3. package/.claude/agents/reviewer.md +3 -0
  4. package/.claude/agents/security-reviewer.md +3 -0
  5. package/.claude/agents/spec-reviewer.md +3 -0
  6. package/.claude/agents/verifier.md +3 -0
  7. package/.claude/skills/bs/audit.md +12 -5
  8. package/.claude/skills/bs/dispatch.md +40 -4
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +11 -5
  11. package/.claude/skills/bs/wave.md +40 -3
  12. package/README.md +1 -1
  13. package/factory/orchestrator/dist/agentsSync.js +23 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +6 -2
  16. package/factory/orchestrator/dist/cli.js +309 -28
  17. package/factory/orchestrator/dist/crossFinding.js +20 -0
  18. package/factory/orchestrator/dist/daemon.js +198 -31
  19. package/factory/orchestrator/dist/db/projector.js +51 -27
  20. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  21. package/factory/orchestrator/dist/epic.js +179 -12
  22. package/factory/orchestrator/dist/errorIssues.js +175 -10
  23. package/factory/orchestrator/dist/events.js +59 -11
  24. package/factory/orchestrator/dist/findings.js +25 -2
  25. package/factory/orchestrator/dist/gh.js +19 -2
  26. package/factory/orchestrator/dist/git.js +20 -2
  27. package/factory/orchestrator/dist/hookDecision.js +320 -37
  28. package/factory/orchestrator/dist/integration.js +11 -1
  29. package/factory/orchestrator/dist/issueReporter.js +81 -16
  30. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  31. package/factory/orchestrator/dist/logCache.js +291 -0
  32. package/factory/orchestrator/dist/paths.js +10 -0
  33. package/factory/orchestrator/dist/plan.js +130 -0
  34. package/factory/orchestrator/dist/projects.js +12 -1
  35. package/factory/orchestrator/dist/queue.js +469 -28
  36. package/factory/orchestrator/dist/scheduler.js +3 -3
  37. package/factory/orchestrator/dist/spec.js +70 -0
  38. package/factory/orchestrator/dist/taskEvents.js +13 -4
  39. package/factory/orchestrator/dist/taxonomy.js +11 -0
  40. package/factory/orchestrator/dist/testgate.js +4 -3
  41. package/factory/orchestrator/dist/usage.js +18 -10
  42. package/factory/orchestrator/dist/waivers.js +60 -7
  43. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  44. package/factory/orchestrator/dist/worktree.js +19 -6
  45. package/factory/specs/roadmap.md +24 -0
  46. package/package.json +1 -1
@@ -139,7 +139,12 @@ ctx, opts = {}) {
139
139
  for (const { record } of events) {
140
140
  if (record.event_type !== 'task-added' || !record.task_id)
141
141
  continue;
142
- byId.set(record.task_id, { taskId: record.task_id, claims: record.payload?.claims });
142
+ const epicId = record.payload?.epic_id;
143
+ byId.set(record.task_id, {
144
+ taskId: record.task_id,
145
+ claims: record.payload?.claims,
146
+ epicId: typeof epicId === 'string' ? epicId : undefined,
147
+ });
143
148
  }
144
149
  return [...byId.values()];
145
150
  }
@@ -369,9 +374,13 @@ export function followUpTaskId(epicId, fingerprint) {
369
374
  *
370
375
  * Returns null when the session already carries this task, so a re-run is a
371
376
  * no-op rather than a duplicate. The task is `todo`, which is not terminal-OK,
372
- * so epic.ts's `summarizeEpic` blocks the epic verdict on it until an operator
373
- * plans or waives it — the "blocks the epic verdict instead of an unrelated
374
- * diff" half of P9-24, using the machinery that was already there.
377
+ * so epic.ts's `summarizeEpic` blocks the epic verdict on it — the "blocks the
378
+ * epic verdict instead of an unrelated diff" half of P9-24, using the
379
+ * machinery that was already there. Nothing writes a task to `waived`, so the
380
+ * two ways out are completing it (plan it, gate it) or waiving the findings
381
+ * it owns: summarizeEpic reads a follow-up terminal-OK once at least one
382
+ * finding is attributed to it and every one of them is `waived`
383
+ * (epic.ts's clearedFollowUpFindings).
375
384
  */
376
385
  export async function emitFollowUpTask(input, ctx, opts = {}) {
377
386
  const added = await idsAlreadyEmitted('task-added', ctx, opts);
@@ -15,6 +15,14 @@ const NON_DIMENSION_KEYS = new Set(['version', 'rules', 'error']);
15
15
  const PRESENCE_ONLY_FIELDS = new Set(['task_ref', 'provenance_event_ids', 'model']);
16
16
  /** The `error` dimension is validated as a `group.class` pair, not a flat tag. */
17
17
  const ERROR_CLASS_FIELD = 'error';
18
+ // `task_ref` names exactly one task; it is otherwise presence-only (above),
19
+ // which let a comma-joined "epic-1/task-1,epic-1/task-2" through as one
20
+ // non-empty string. db/projector.ts's fold read that string as a single task
21
+ // id and minted a phantom row for it that stayed `todo` forever, holding
22
+ // epic verdict on a task that was never real. Reject the shape here so the
23
+ // log never grows another one — a producer with two failing tasks logs two
24
+ // error-logged events, not one with two ids stitched together.
25
+ const TASK_REF_FIELD = 'task_ref';
18
26
  export function parseTaxonomy(yamlText) {
19
27
  const doc = parseYaml(yamlText);
20
28
  if (!doc || typeof doc !== 'object') {
@@ -102,6 +110,9 @@ export function validateRequiredDimensions(taxonomy, recordType, record) {
102
110
  if (isEmpty) {
103
111
  throw new TaxonomyError('taxonomy.missing-required-dimension', `Record type "${recordType}" is missing required field "${field}".`, { recordType, field });
104
112
  }
113
+ if (field === TASK_REF_FIELD && String(value).includes(',')) {
114
+ throw new TaxonomyError('taxonomy.multi-value-task-ref', `Record type "${recordType}" field "task_ref" names more than one task ("${String(value)}"). Log one error-logged event per task instead.`, { recordType, field, value });
115
+ }
105
116
  if (PRESENCE_ONLY_FIELDS.has(field))
106
117
  continue;
107
118
  if (field === ERROR_CLASS_FIELD) {
@@ -94,8 +94,9 @@ export function projectCommandEnv(env) {
94
94
  * jobs, long-lived children) running as orphans after the parent shell
95
95
  * dies — a real leak in a 24/7 factory (reproduced: manual kill -9 needed).
96
96
  */
97
- function runOne(check, cwd, timeoutMs) {
97
+ function runOne(check, cwd, defaultTimeoutMs) {
98
98
  return new Promise((resolve) => {
99
+ const timeoutMs = check.timeout_ms ?? defaultTimeoutMs;
99
100
  const child = spawn(check.cmd, {
100
101
  cwd,
101
102
  shell: true,
@@ -176,10 +177,10 @@ function runOne(check, cwd, timeoutMs) {
176
177
  * no test-output parsing beyond the process exit code.
177
178
  */
178
179
  export async function run(checks, opts) {
179
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
180
+ const defaultTimeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
180
181
  const results = [];
181
182
  for (const check of checks) {
182
- const result = await runOne(check, opts.cwd, timeoutMs);
183
+ const result = await runOne(check, opts.cwd, defaultTimeoutMs);
183
184
  results.push(result);
184
185
  if (!result.pass && !opts.runAll)
185
186
  break;
@@ -31,7 +31,7 @@ const EVENTS_DIR = `${EVENTS} [--state-dir <dir>]`;
31
31
  */
32
32
  const STATS = '[--db <file>] [--session <id>] [--lineage]';
33
33
  /** The scope both `issues` actions accept: a session's lineage, narrowed by epic and ISO since. */
34
- const ISSUES = '--session <id> [--epic <id>] [--since <iso>] [--state-dir <dir>] [--roadmap-path <file>]';
34
+ const ISSUES = '--session <id> [--epic <id>] [--since <iso>] [--state-dir <dir>] [--roadmap-path <file>] [--specs-dir <dir>]';
35
35
  export const COMMANDS = [
36
36
  {
37
37
  command: 'audit open',
@@ -66,8 +66,8 @@ export const COMMANDS = [
66
66
  {
67
67
  command: 'audit resolve',
68
68
  positionals: '<project-dir>',
69
- flags: `--epic <epic-id> ${EVENTS_DIR}`,
70
- summary: 'Append a fixed line for every finding the epic carried. Run from the epic-close step.',
69
+ flags: `--epic <epic-id> [--except <fp[,fp...]>] [--plan <plan.json>] [--specs-dir <dir>] ${EVENTS_DIR}`,
70
+ summary: "Append a fixed line for every carried finding the epic's newest plan still claims; list the rest as deferred. Run from the epic-close step.",
71
71
  },
72
72
  {
73
73
  command: 'audit close',
@@ -198,7 +198,7 @@ export const COMMANDS = [
198
198
  {
199
199
  command: 'daemon run',
200
200
  positionals: '',
201
- flags: `[--interval <seconds>] [--once] [--dir <dir>] [--project <dir>...] [--no-self] [--db <path>] [--no-db] [--state-dir <dir>]`,
201
+ flags: `[--interval <seconds>] [--once] [--dir <dir>] [--project <dir>...] [--no-self] [--db <path>] [--no-db] [--state-dir <dir>] [--specs-dir <dir>]`,
202
202
  summary: 'Watch the event log in the foreground: budgets, stale agents, work that is due. ' +
203
203
  'Prints one report per tick, one JSON line each; --once prints one document and exits. ' +
204
204
  'Never dispatches.',
@@ -232,21 +232,21 @@ export const COMMANDS = [
232
232
  {
233
233
  command: 'scheduler run',
234
234
  positionals: '',
235
- flags: `[--dry] [--now <iso>] [--project <dir>...] [--no-self] ${EVENTS_DIR}`,
235
+ flags: `[--dry] [--now <iso>] [--project <dir>...] [--no-self] [--specs-dir <dir>] ${EVENTS_DIR}`,
236
236
  summary: 'Propose and enact the next scheduler moves for a session. --dry proposes only.',
237
237
  },
238
238
  {
239
239
  command: 'scheduler admit',
240
240
  positionals: '',
241
241
  flags: '--session <id> [--state-dir <dir>] [--now <iso>] [--project <dir>...] [--no-self] ' +
242
- '[--policy <file>] [--crosscheck <file>]',
242
+ '[--specs-dir <dir>] [--policy <file>] [--crosscheck <file>]',
243
243
  summary: "Classify what's due into auto or operator per scheduler.yml autonomy. Enacts nothing.",
244
244
  },
245
245
  {
246
246
  command: 'worktree create',
247
247
  positionals: '<project-dir> <epic> <task-id>',
248
- flags: '',
249
- summary: 'Cut the isolated worktree and task branch one task is allowed to write in.',
248
+ flags: '[--from <predecessor-task-id> --session <id> [--state-dir <dir>]]',
249
+ summary: 'Cut the isolated worktree and task branch one task is allowed to write in. --from cuts it from a predecessor branch instead of integration, and requires --session (which supplies the log --from is checked against): refused unless that session records <epic>/<task-id> as the logged successor of <epic>/<predecessor-task-id>.',
250
250
  },
251
251
  {
252
252
  command: 'worktree rm',
@@ -275,8 +275,10 @@ export const COMMANDS = [
275
275
  {
276
276
  command: 'queue run',
277
277
  positionals: '<epic>',
278
- flags: '--project <dir> --test-cmd <cmd> --tasks <tasks.json> [--select-test-cmd <cmd>] [--session <id> --causal-parent <event-id> --plan <plan.json> [--plan-version <n>] [--actor <name>]] [--state-dir <dir>]',
279
- summary: 'Serially rebase, test, and merge task branches into the integration branch.',
278
+ flags: '--project <dir> --test-cmd <cmd> --tasks <tasks.json> [--select-test-cmd <cmd>] [--batch] [--session <id> --causal-parent <event-id> --plan <plan.json> [--plan-version <n>] [--actor <name>]] [--state-dir <dir>]',
279
+ summary: 'Serially rebase, test, and merge task branches into the integration branch. ' +
280
+ '--batch (roadmap merge-lanes) groups claim-disjoint tasks with groupForBatch and ' +
281
+ 'tests each group once instead of once per task — see queue.ts and the operator guide.',
280
282
  },
281
283
  {
282
284
  command: 'queue adopt',
@@ -359,6 +361,12 @@ export const COMMANDS = [
359
361
  flags: '[--task <id>] [--policy <path>] [--state-dir <dir>]',
360
362
  summary: 'Assert crosscheck.yml’s role asymmetry against the log. Exit 1 on a violation OR an unverifiable answer.',
361
363
  },
364
+ {
365
+ command: 'dispatch lint',
366
+ positionals: '<prompt-file>',
367
+ flags: '--role <role> --task <id> --session <id> [--agents-dir <dir>] [--state-dir <dir>]',
368
+ summary: "Check a composed dispatch prompt's stated turn budget against the role template's maxTurns, and a judge prompt's declared-artifact line against the ledger. Exit 1 on a violation OR an unverifiable answer. `-` reads stdin.",
369
+ },
362
370
  {
363
371
  command: 'tester check',
364
372
  positionals: '<session-id>',
@@ -1,6 +1,6 @@
1
1
  import { SmithError } from './errors.js';
2
2
  import { appendEvent, readLineageEvents } from './events.js';
3
- import { listFindings, preWaiverStatus, staleFindings, transition, WAIVABLE_STATUSES, } from './findings.js';
3
+ import { computeFingerprint, foldFindings, listFindings, preWaiverStatus, staleFindings, transition, WAIVABLE_STATUSES, } from './findings.js';
4
4
  export class WaiverError extends SmithError {
5
5
  }
6
6
  /**
@@ -10,6 +10,38 @@ export class WaiverError extends SmithError {
10
10
  * call or via a waiver grant).
11
11
  */
12
12
  export const WAIVABLE_SEVERITIES = ['S3-minor', 'S4-nit'];
13
+ /**
14
+ * Every fingerprint that names the same finding as `fingerprint`, for each
15
+ * candidate in `findings` that could stand in for it: its own stored value,
16
+ * and what computeFingerprint would produce today from its persisted
17
+ * file_path/finding_category/summary. Mirrors audit.ts's
18
+ * suppressedFingerprints, for the same reason (issue #178): a `finding-raised`
19
+ * or waiver-decision record written before the normalizer widened to also
20
+ * strip line spans ("lines 1549-1576") fingerprints differently than the
21
+ * identical text does now, so a stored fingerprint and a freshly computed one
22
+ * for the same defect can legitimately disagree.
23
+ *
24
+ * Always includes `fingerprint` itself, so a caller with no backing finding
25
+ * yet (e.g. a batch decision not folded into findings) still gets an
26
+ * exact-match-only set — unchanged behaviour.
27
+ */
28
+ function fingerprintAliases(fingerprint, findings) {
29
+ const aliases = new Set([fingerprint]);
30
+ for (const finding of findings) {
31
+ if (finding.file_path === undefined)
32
+ continue;
33
+ const recomputed = computeFingerprint({
34
+ filePath: finding.file_path,
35
+ category: finding.finding_category,
36
+ summary: finding.summary,
37
+ });
38
+ if (finding.fingerprint === fingerprint || recomputed === fingerprint) {
39
+ aliases.add(finding.fingerprint);
40
+ aliases.add(recomputed);
41
+ }
42
+ }
43
+ return aliases;
44
+ }
13
45
  /**
14
46
  * Fold waiver-granted/waiver-denied events for one fingerprint: the last
15
47
  * decision wins (a denial can, in principle, be revisited by a later grant
@@ -20,13 +52,19 @@ export const WAIVABLE_SEVERITIES = ['S3-minor', 'S4-nit'];
20
52
  * would re-raise a finding the operator already waived the moment an epic
21
53
  * continued in a new session. "Last decision wins" is decided on the merged
22
54
  * order, which is `ts` between sessions and append order within one.
55
+ *
56
+ * Matches against `fingerprintAliases(fingerprint, ...)` rather than strict
57
+ * equality (issue #178): a decision recorded under the fingerprint's old
58
+ * spelling still answers a lookup under its current one, and vice versa.
23
59
  */
24
60
  export async function isWaived(fingerprint, ref, opts = {}) {
25
61
  const events = await readLineageEvents(ref.sessionId, opts);
62
+ const equivalents = fingerprintAliases(fingerprint, foldFindings(events));
26
63
  let waived = false;
27
64
  for (const { record } of events) {
28
65
  const payload = record.payload;
29
- if (payload.fingerprint !== fingerprint)
66
+ const fp = payload.fingerprint;
67
+ if (fp === undefined || !equivalents.has(fp))
30
68
  continue;
31
69
  if (record.event_type === 'waiver-granted')
32
70
  waived = true;
@@ -38,13 +76,17 @@ export async function isWaived(fingerprint, ref, opts = {}) {
38
76
  /**
39
77
  * Has the operator recorded ANY waiver decision for this fingerprint (granted
40
78
  * or denied)? Lineage-wide, for isWaived's reason (D-119): a decision recorded
41
- * in the parent session is still a decision.
79
+ * in the parent session is still a decision. Same alias widening as isWaived,
80
+ * and for the same reason (issue #178).
42
81
  */
43
82
  async function hasDecision(fingerprint, ref, opts) {
44
83
  const events = await readLineageEvents(ref.sessionId, opts);
84
+ const equivalents = fingerprintAliases(fingerprint, foldFindings(events));
45
85
  return events.some(({ record }) => {
46
86
  const payload = record.payload;
47
- return (payload.fingerprint === fingerprint &&
87
+ const fp = payload.fingerprint;
88
+ return (fp !== undefined &&
89
+ equivalents.has(fp) &&
48
90
  (record.event_type === 'waiver-granted' || record.event_type === 'waiver-denied'));
49
91
  });
50
92
  }
@@ -56,10 +98,16 @@ async function hasDecision(fingerprint, ref, opts) {
56
98
  * finding as open forever. Findings the state machine can't legally move
57
99
  * (already terminal, or a non-S3/S4 severity that somehow shares this
58
100
  * fingerprint) are left alone; this is reconciliation, not a mandate.
101
+ *
102
+ * Matched via fingerprintAliases (issue #178), not strict equality: a grant
103
+ * against a finding's current fingerprint also reconciles a row for the
104
+ * identical defect still carrying the pre-widening fingerprint it was raised
105
+ * under, and vice versa.
59
106
  */
60
107
  async function reconcileFindingsToWaived(fingerprint, waiverEventId, ctx, opts) {
61
108
  const findings = await listFindings(ctx.sessionId, {}, opts);
62
- const reconcilable = findings.filter((f) => f.fingerprint === fingerprint &&
109
+ const equivalents = fingerprintAliases(fingerprint, findings);
110
+ const reconcilable = findings.filter((f) => equivalents.has(f.fingerprint) &&
63
111
  WAIVABLE_SEVERITIES.includes(f.severity) &&
64
112
  WAIVABLE_STATUSES.includes(f.finding_status));
65
113
  const transitionCtx = { ...ctx, causalParent: waiverEventId };
@@ -92,11 +140,16 @@ export async function grantWaiver(fingerprint, operatorNote, ctx, opts = {}) {
92
140
  *
93
141
  * Only findings this session's log actually shows at `waived` are touched, and
94
142
  * each goes back to the status its own grant recorded as from_status. A
95
- * finding waived under some other fingerprint is not this denial's business.
143
+ * finding waived under some other fingerprint is not this denial's business —
144
+ * "other" meaning a different defect, not merely a different spelling: matched
145
+ * via fingerprintAliases (issue #178), the same widening reconcileFindingsToWaived
146
+ * applies, so a row still carrying the pre-widening fingerprint it was raised
147
+ * under reopens alongside its current-fingerprint sibling.
96
148
  */
97
149
  async function reconcileFindingsFromWaived(fingerprint, denialEventId, ctx, opts) {
98
150
  const findings = await listFindings(ctx.sessionId, {}, opts);
99
- const reopenable = findings.filter((f) => f.fingerprint === fingerprint && f.finding_status === 'waived');
151
+ const equivalents = fingerprintAliases(fingerprint, findings);
152
+ const reopenable = findings.filter((f) => equivalents.has(f.fingerprint) && f.finding_status === 'waived');
100
153
  const transitionCtx = { ...ctx, causalParent: denialEventId };
101
154
  for (const finding of reopenable) {
102
155
  const to = await preWaiverStatus(finding.finding_id, ctx.sessionId, opts);
@@ -40,6 +40,8 @@
40
40
  * Pure, and it writes nothing. Like `waveNext`, admission stays where it was:
41
41
  * a round simulated here has been admitted by nobody.
42
42
  */
43
+ import { claimsOverlap, readEdgeList } from './claims.js';
44
+ import { planClaimedTasks } from './plan.js';
43
45
  import { computeNextWave, } from './waveNext.js';
44
46
  /**
45
47
  * Reported in this order, which is the order `computeNextWave` tests them in.
@@ -150,3 +152,85 @@ export function scheduleWaves(input) {
150
152
  exitCode: stalled.length > 0 ? 1 : constraints.length > 0 ? 2 : 0,
151
153
  };
152
154
  }
155
+ /**
156
+ * Directed reachability over the plan's `depends_on` edges, both ways: the
157
+ * tasks reachable by walking forward (its descendants) and the tasks
158
+ * reachable by walking backward (its ancestors). Two tasks are connected —
159
+ * "a dependency path between them in either direction" — when either set
160
+ * contains the other, which a chain like C -> B -> A catches for A and C
161
+ * even though no edge names that pair directly.
162
+ */
163
+ function connectedTaskIds(ids, edges) {
164
+ const descendantsOf = new Map();
165
+ const ancestorsOf = new Map();
166
+ for (const id of ids) {
167
+ descendantsOf.set(id, []);
168
+ ancestorsOf.set(id, []);
169
+ }
170
+ for (const edge of edges) {
171
+ if (!ids.has(edge.task) || !ids.has(edge.dependsOn))
172
+ continue;
173
+ descendantsOf.get(edge.dependsOn)?.push(edge.task);
174
+ ancestorsOf.get(edge.task)?.push(edge.dependsOn);
175
+ }
176
+ const reachable = (start, adjacency) => {
177
+ const seen = new Set();
178
+ const stack = [...(adjacency.get(start) ?? [])];
179
+ while (stack.length > 0) {
180
+ const next = stack.pop();
181
+ if (seen.has(next))
182
+ continue;
183
+ seen.add(next);
184
+ for (const n of adjacency.get(next) ?? [])
185
+ if (!seen.has(n))
186
+ stack.push(n);
187
+ }
188
+ return seen;
189
+ };
190
+ const connected = new Map();
191
+ for (const id of ids) {
192
+ connected.set(id, new Set([...reachable(id, descendantsOf), ...reachable(id, ancestorsOf)]));
193
+ }
194
+ return connected;
195
+ }
196
+ const SMALL_MAX_TASKS = 3;
197
+ const SMALL_MAX_CLAIMS = 10;
198
+ /**
199
+ * Pure. Superseded tasks are absent from `planClaimedTasks` already (D-126),
200
+ * so they never enter `widest`, `parallelWith`, or `size` — no separate
201
+ * filter needed here.
202
+ */
203
+ export function computePlanParallelism(plan, policy) {
204
+ const claimedTasks = planClaimedTasks(plan).filter((t) => t.task_id !== undefined);
205
+ const ids = new Set(claimedTasks.map((t) => t.task_id));
206
+ const edges = readEdgeList(plan.edges);
207
+ const connected = connectedTaskIds(ids, edges);
208
+ const parallelWith = {};
209
+ for (const a of claimedTasks) {
210
+ const withIds = [];
211
+ for (const b of claimedTasks) {
212
+ if (a.task_id === b.task_id)
213
+ continue;
214
+ if (connected.get(a.task_id)?.has(b.task_id))
215
+ continue;
216
+ if (claimsOverlap(a, b).overlaps)
217
+ continue;
218
+ withIds.push(b.task_id);
219
+ }
220
+ parallelWith[a.task_id] = withIds.sort();
221
+ }
222
+ const { widest } = scheduleWaves({ plan, policy });
223
+ const claimPaths = new Set();
224
+ for (const t of claimedTasks)
225
+ for (const c of t.claims)
226
+ claimPaths.add(c);
227
+ return {
228
+ widest,
229
+ parallel_with: parallelWith,
230
+ size: {
231
+ tasks: claimedTasks.length,
232
+ claims: claimPaths.size,
233
+ small: claimedTasks.length <= SMALL_MAX_TASKS && claimPaths.size <= SMALL_MAX_CLAIMS,
234
+ },
235
+ };
236
+ }
@@ -127,18 +127,31 @@ function ensureIntegrationBranch(projectDir, epic) {
127
127
  * sibling of whatever directory the project was handed as, never a path of
128
128
  * this module's own choosing, so a project outside this clone keeps its
129
129
  * worktrees outside it too (see taskWorktreeDir)
130
- * on branch smith/<epic>/<task-id>, cut from the CURRENT head of
131
- * smith/<epic>/integration (creating the integration branch from the default
132
- * branch first if it doesn't exist yet).
130
+ * on branch smith/<epic>/<task-id>.
131
+ *
132
+ * Without `opts.from`, cut from the CURRENT head of smith/<epic>/integration
133
+ * (creating the integration branch from the default branch first if it
134
+ * doesn't exist yet). With `opts.from`, cut from smith/<epic>/<from> instead
135
+ * — the predecessor's branch, commits and all — and integration is never
136
+ * consulted.
133
137
  */
134
- export function createTaskWorktree(projectDir, epic, taskId) {
138
+ export function createTaskWorktree(projectDir, epic, taskId, opts = {}) {
135
139
  if (taskId === RESERVED_TASK_ID) {
136
140
  throw new WorktreeError('worktree.reserved-task-id', `Task id "${RESERVED_TASK_ID}" is reserved for the integration branch itself.`, { taskId });
137
141
  }
138
- const integrationBranch = ensureIntegrationBranch(projectDir, epic);
139
142
  const branch = taskBranchName(epic, taskId);
140
143
  const worktreeDir = taskWorktreeDir(projectDir, epic, taskId);
141
- git(projectDir, ['worktree', 'add', '-b', branch, worktreeDir, integrationBranch]);
144
+ let sourceBranch;
145
+ if (opts.from !== undefined) {
146
+ sourceBranch = taskBranchName(epic, opts.from);
147
+ if (!localBranchExists(projectDir, sourceBranch)) {
148
+ throw new WorktreeError('worktree.from-missing', `Predecessor branch ${sourceBranch} does not exist; cannot cut ${taskId} from it.`, { epic, taskId, from: opts.from, branch: sourceBranch });
149
+ }
150
+ }
151
+ else {
152
+ sourceBranch = ensureIntegrationBranch(projectDir, epic);
153
+ }
154
+ git(projectDir, ['worktree', 'add', '-b', branch, worktreeDir, sourceBranch]);
142
155
  return { worktreeDir, branch, epic, taskId };
143
156
  }
144
157
  /** Remove a task's worktree and its local branch after it has merged. */
@@ -175,3 +175,27 @@ all; write `- error_issues: off` by hand to opt a project's tracker out.
175
175
  - status: planned
176
176
  - epics: []
177
177
  - goal: Stop spending judge calls on outputs that a free check could have rejected, and stop grading a capped output as though it were a finished one. Four pieces, independent enough to land separately. **Free critics ahead of paid judges**: an empty diff, a missing structured completion, and claims touched without a single test added are all decidable without a model, and each one currently reaches a judge that pays full price to say so. The OpenHands SDK runs exactly this tier first and classifies what it rejects as behaviour, infrastructure or unclear-request — a taxonomy worth having because "the agent failed" and "the environment failed" are different bugs with different owners, and today they land in the same place. **Grade a budget-capped output as worth less**: SWE-agent's reviewer discards out-of-range verdicts and tie-breaks on cost; here the sharper version is that an output produced by an agent that hit its turn cap is a partial output, and a judge that does not know that is being asked to grade a fragment as a whole — which is how a capped run earns a passing verdict for the third of the task it finished. **Submit-gating and an honest forfeit**: show the worker its own diff before it may report done, with a checklist that names the mistakes workers actually make — test files touched, debug output left, claims exceeded — and give it a structured way to give up. A forfeit is strictly more useful than a confident wrong answer: it costs one round instead of a judge round plus a verifier round plus a rework round, and it is information the lessons pass can learn from. **Budget as an admission check**: `min_budget_for_dispatch` per role, so a role is never dispatched into a budget that cannot finish it, and a ring-fenced judging reserve so a capped epic still gets graded rather than spending its last tokens on more coding. Two things stay open. **Whether a free critic may block or only warn** — a false rejection that costs a whole round is worse than the judge call it saved, so the first critics should probably warn while their precision is measured against `agent-bench`. **What a forfeit does to the escalation ladder** — `budgets.yml` already declares one, and a forfeit is either its first rung or a separate path, but it must not become a cheap exit an agent prefers to the work. Researched 2026-09-18 across SWE-agent and the OpenHands SDK.
178
+
179
+ ## Self-audit 1 — event-log append cost, rename-blind claim diffs, one git entry point
180
+ - id: bs-audit-1
181
+ - status: planned
182
+ - epics: [bs-audit-1]
183
+ - project: black-smith
184
+ - kind: factory
185
+ - goal: Cut from audit 20260925-f336f5e4 of `blacksmith` (7 accepted findings: 3 S2-major, 3 S3-minor, 1 S4-nit). Each finding below is one acceptance criterion; the plan decides the tasks. **d7c644db (S2-major, performance)** `factory/orchestrator/src/events.ts` — appendEvent() re-reads and re-JSON.parses the entire session event log on every single append (readEventsAtPath inside appendEventLocked), while holding both an in-process queue and a cross-process poll lock, so writing to a session log is O(n) per event and O(n^2) for a session of n events.. **0f3f9c23 (S3-minor, performance)** `factory/orchestrator/src/worktree.ts` — listStale() spawns one synchronous `git merge-base --is-ancestor` subprocess per worktree entry in a loop instead of a single batched check, so merge-queue/worktree cleanup cost scales linearly with subprocess spawns rather than with one git invocation.. **ae8cb69e (S2-major, architecture)** `factory/orchestrator/src/queue.ts` — The queue module shells out to git directly with execFileSync instead of going through git.ts, the module the codebase documents as the one place this factory shells out to git, so queue git failures bypass credential redaction and the structured GitCommandError type.. **fd03e000 (S3-minor, architecture)** `factory/orchestrator/src/diffstat.ts` — diffstat.ts calls execFileSync('git', ...) directly for rev-parse/diff/branch instead of through git.ts, duplicating the single git-invocation boundary the codebase declares for itself and losing that module's credential redaction and GitCommandError typing.. **160cc3d7 (S3-minor, architecture)** `factory/orchestrator/src/commit.ts` — commit.ts, which certifies whether a task's work is safe to integrate, runs its own execFileSync('git', ...) calls rather than routing through git.ts, so the one module the codebase names as the sole git entry point has at least two siblings that re-implement direct git shell-outs.. **07c5d1b0 (S2-major, code-quality)** `factory/orchestrator/src/claims.ts` — collectCommittedChanges, the collector behind postRunCheck (the claim-confinement check for a task worktree), runs `git diff --name-only` without `--no-renames`, so a rename that crosses a claim boundary is reported only by its destination path.. **ad9ea9ba (S4-nit, code-quality)** `factory/orchestrator/src/waveNext.ts` — The one-line helper nameList (ids.join(', ')) is duplicated verbatim in waveNext.ts and waveSchedule.ts rather than shared, so the two wave-scheduling files carry two independent copies of the same id-list formatting rule..
186
+
187
+ ## Audit 2: bounded judges, parallel feedback, dashboard origin guard
188
+ - id: bs-audit-2
189
+ - status: planned
190
+ - epics: [bs-audit-2]
191
+ - project: black-smith
192
+ - kind: factory
193
+ - goal: Cut from audit 20260925-49bffa9b of `blacksmith` (5 accepted findings: 2 S2-major, 3 S3-minor). Each finding below is one acceptance criterion; the plan decides the tasks. **50acc356 (S3-minor, performance)** `factory/orchestrator/src/daemon.ts` — runTick() re-reads and re-JSON.parses every session's full .jsonl event log from disk on every daemon tick, with no incremental/fingerprint caching, so the daemon's per-tick cost grows linearly and unboundedly with the factory's total historical event volume.. **d50d4c1f (S3-minor, performance)** `ui/server/src/app.ts` — Every dashboard write route that omits an explicit causalParent (waivers apply-batch, lessons approve/reject/edit) triggers a full read-and-parse of that session's entire event log via resolveContext()/lessonContext() just to read the id of the last event.. **4c610e7c (S2-major, security)** `ui/server/src/app.ts` — The dashboard's write endpoints (waiver apply-batch, lesson approve/reject/edit) have no CSRF protection: there is no Origin/Referer check and Hono's `c.req.json()` parses the request body as JSON regardless of the Content-Type header, so a cross-origin `text/plain` form POST (a CORS-exempt 'simple request' that needs no preflight) is accepted and executed.. **1b8fe398 (S3-minor, architecture)** `.claude/skills/bs/wave.md` — The wave playbook's own loop (steps 2-10) never invokes the one instrument that measures whether a wave actually ran in parallel (auditWaveConcurrency/`smith wave check`, factory/orchestrator/src/waveConcurrency.ts), so the factory's central scaling claim goes unchecked for the entire time it could still be corrected.. **7ef3cd2f (S2-major, architecture)** `.claude/skills/bs/dispatch.md` — The one hard cap that bounds every subagent's work (`maxTurns`, enforced by the Claude Code harness itself) is transmitted into each dispatch prompt purely as prose the dispatching agent must copy correctly by hand from `.claude/agents/<role>.md` 'at dispatch time' — no module in factory/orchestrator/src validates that a dispatch's stated turn budget matches the template the harness will actually enforce, so the one enforcement path for this invariant is a human/LLM re-reading a file correctly every single time.. **Operator-added scope (2026-09-25, from GitHub research, ideas only)**: (6) a `SubagentStop` hook in the judge-class agent templates' frontmatter that blocks a judge from ending while its declared artifact file is absent — complements `agent-resume`, which handles the cap itself; (7) planning emits parallel width mechanically — at plan ingest, tasks with disjoint claims and no dependency edge are marked parallel and the plan's achievable width is reported, and a small epic takes a right-sized planning path with a bounded review focus. The per-task pipeline (no wave barrier) is deliberately out of scope until a pros/cons analysis is decided.
194
+
195
+ ## Self-audit 3: UI read boundary and fail-closed daemon state
196
+ - id: bs-audit-3
197
+ - status: planned
198
+ - epics: [bs-audit-3]
199
+ - project: black-smith
200
+ - kind: factory
201
+ - goal: Cut from audit 20260927-b61eaa7e of `black-smith` (2 accepted findings: 2 S3-minor). Each finding below is one acceptance criterion; the plan decides the tasks. **871ee8f7 (S3-minor, architecture)** `ui/server/src/app.ts` — lessonSession() builds its own drizzle query directly against the `lessons` schema table instead of going through db/queries.ts, the module the file's own header comment declares as the single 1:1 wrapper boundary between the UI and the projection.. **442cd7f2 (S3-minor, code-quality)** `factory/orchestrator/src/daemon.ts` — daemon.ts defines its own readJsonFile() that silently swallows any parse/read error and returns null, diverging from the fail-closed readJsonFile() in cli.ts that throws on malformed JSON, so a corrupted daemon lock/status/memory file is treated as absent rather than reported..
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juzser/blacksmith",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Autonomous agent factory — loop runner and worktree engine.",
6
6
  "keywords": [