@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
@@ -29,6 +29,7 @@ import { loadCrosscheckPolicy } from './crosscheck.js';
29
29
  import { independentFinderRequest, reconcile, runIndependentFinder, } from './crossFinding.js';
30
30
  import { checkDelegationGrants, checkDelegationLog, loadDelegationPolicy } from './delegation.js';
31
31
  import { checkDispatchAsymmetry } from './dispatchAudit.js';
32
+ import { lintDispatchPrompt } from './dispatchLint.js';
32
33
  import { loadDotEnv } from './dotenv.js';
33
34
  import { loadEffortPolicy, resolveEffort } from './effort.js';
34
35
  import { ISSUE_CANDIDATE_EVENT_TYPES } from './errorIssues.js';
@@ -48,23 +49,23 @@ import { judgePreflight } from './judgePreflight.js';
48
49
  import { outstandingJudges, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, } from './judges.js';
49
50
  import { addMcpSurface, resolveMcpSurface, runMcpCheck } from './mcp.js';
50
51
  import { DOTENV_PATH, LESSONS_MD_PATH, lessonsReadPath, REPO_ROOT, SANDBOX_LEASE_DIR, STATE_DB_PATH, } from './paths.js';
51
- import { diffPlans, livePlanTasks, resolveTaskId, validatePlan, } from './plan.js';
52
+ import { bareTaskId, diffPlans, livePlanTasks, planProjectResolverForTaskRef, planSelfFallbackForTaskRef, resolveTaskId, validatePlan, } from './plan.js';
52
53
  import { runPlanQuorum } from './planQuorum.js';
53
54
  import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
54
55
  import { factoryProjects } from './projects.js';
55
56
  import { recordUserPrompt } from './prompts.js';
56
57
  import { checkBrief, wrapIngested } from './provenance.js';
57
58
  import { runJudge } from './providers/index.js';
58
- import { admit, adopt, step } from './queue.js';
59
+ import { admit, adopt, batchStep, groupForBatch, step } from './queue.js';
59
60
  import { stampResultEnvelope } from './results.js';
60
- import { isErrorTrackerWritable, loadRoadmap } from './roadmap.js';
61
+ import { FACTORY_PROJECT, isErrorTrackerWritable, loadRoadmap } from './roadmap.js';
61
62
  import { checkRuntime } from './runtime.js';
62
63
  import { checkSameMistakeKpi } from './sameMistakeKpi.js';
63
64
  import { activeSandboxFor, closeSandbox, listSandboxes, openSandbox, } from './sandbox.js';
64
65
  import { registerProjectInRoadmap, scaffoldProject } from './scaffold.js';
65
66
  import { loadSensitivePathsPolicy, securityTriggers, } from './security.js';
66
67
  import { parseLessons } from './severity.js';
67
- import { amendPlan, recordSpecReview } from './spec.js';
68
+ import { amendPlan, recordSpecReview, taskSuccessors } from './spec.js';
68
69
  import { approveSpecChange, listSpecChanges, proposeSpecChange, rejectSpecChange, } from './specChange.js';
69
70
  import { checkStack, loadStackAnswers } from './stack.js';
70
71
  import { buildSymbolGraph, collectSources } from './symbols.js';
@@ -84,6 +85,29 @@ function printJson(value) {
84
85
  function readJsonFile(filePath) {
85
86
  return JSON.parse(readFileSync(filePath, 'utf8'));
86
87
  }
88
+ /**
89
+ * `--checks <file>`'s intake, both places it's read (`gate run`,
90
+ * `integration check`). Same fail-closed shape as `boundedIntFlag`, moved
91
+ * from a flag to a JSON field: a check's own `timeout_ms` goes straight into
92
+ * a `setTimeout` call in testgate.ts, and a non-positive or non-integer value
93
+ * there does not fail loudly — it fails as either an instant, unexplained
94
+ * timeout or one that silently never fires. Above 2^31-1 Node clamps the
95
+ * delay to 1ms, so an oversized value is the instant kind too. Refused here,
96
+ * at the point the file is read, rather than let a gate run act on either.
97
+ */
98
+ const MAX_CHECK_TIMEOUT_MS = 2_147_483_647;
99
+ function readChecksFile(filePath) {
100
+ const checks = readJsonFile(filePath);
101
+ for (const check of checks) {
102
+ const timeoutMs = check.timeout_ms;
103
+ if (timeoutMs === undefined)
104
+ continue;
105
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_CHECK_TIMEOUT_MS) {
106
+ throw new SmithError('gate.invalid-check-timeout', `"${check.name}" in ${filePath} has timeout_ms ${JSON.stringify(timeoutMs)}; it must be a positive whole number of milliseconds, at most ${MAX_CHECK_TIMEOUT_MS}.`, { name: check.name, timeout_ms: timeoutMs, file: filePath });
107
+ }
108
+ }
109
+ return checks;
110
+ }
87
111
  /** A task spec's `budget.tokens` when it is a usable positive number, else null. */
88
112
  function declaredTokens(budget) {
89
113
  if (typeof budget !== 'object' || budget === null)
@@ -333,17 +357,81 @@ function scopeIssueCandidates(events, flags) {
333
357
  return since === undefined || record.ts >= since;
334
358
  });
335
359
  }
336
- /** Both `issues` actions read the same inputs; only the entry point differs. */
360
+ /**
361
+ * Both `issues` actions read the same inputs; only the entry point differs.
362
+ *
363
+ * `resolveProject` and `selfFallback` together close the privacy-leak gap an
364
+ * unstamped row otherwise falls into: a session or plan driving a project
365
+ * other than this factory writes error/gate/task-added rows with no
366
+ * top-level `project` stamp. Both answer from the row's own task ref's
367
+ * epic's plan file (D-246's `db/projector.ts` precedent, restated here
368
+ * without a DB dependency `cli.ts`'s boot graph cannot carry) -- a plan on
369
+ * disk naming a project wins outright via `resolveProject`; a plan on disk
370
+ * naming none is this checkout's own epic, so `selfFallback` resolves it to
371
+ * `FACTORY_PROJECT`; no plan found at all (a foreign epic, or a bare ref)
372
+ * stays `null` from both, and `reportErrors`/`previewOutcomes` skip that row
373
+ * with `skipped-unresolved-project` rather than ever defaulting it into this
374
+ * factory's own repository.
375
+ *
376
+ * The two are kept as SEPARATE resolvers, not one combined
377
+ * `planProjectResolverForTaskRefOrSelf`, so `withSessionFallback`
378
+ * (`errorIssues.ts`) can try them as distinct tiers with a session's own
379
+ * stamp riding between them: `resolveProject` first, then any `project`
380
+ * stamp elsewhere in the same session (or a bare ref's `task-added`
381
+ * `epic_id` re-asked of `resolveProject`), and only then `selfFallback`
382
+ * (S2-c) -- a session-mate's real stamp naming a different project must
383
+ * outrank this weakest, locally-guessed signal, which a single combined
384
+ * resolver tried as one first tier could not guarantee.
385
+ *
386
+ * `--specs-dir` (via `planOptsFromFlags`) and `--state-dir` (via
387
+ * `eventOptsFromFlags`) are two independent flags on purpose: `--state-dir`
388
+ * says where THIS session's own events live, `--specs-dir` says where the
389
+ * epics IT references keep their plans, and a session's events and the
390
+ * plans its tasks belong to are not always colocated (the daemon's own
391
+ * event log and a target checkout's specs tree, for one). Requiring them to
392
+ * travel together would make `issues report` unable to point one flag at a
393
+ * remote-events state dir while resolving projects from this checkout's own
394
+ * specs/active, which is `smith`'s ordinary daemon posture. Callers who do
395
+ * mean the same tree for both pass the same path to both flags.
396
+ *
397
+ * `lineageEvents` is the session's UNSCOPED lineage, read before
398
+ * `scopeIssueCandidates` narrows `events` to `--epic`/`--since` (S3):
399
+ * `ORIGIN_STAMP_EVENT_TYPES` overlaps the scoped `ISSUE_CANDIDATE_EVENT_TYPES`
400
+ * set, so `sessionProjectStamps` reading straight off the narrowed `events`
401
+ * could lose a session-mate's stamp purely because `--epic`/`--since`
402
+ * excluded that row, changing an unrelated in-scope row's resolved project
403
+ * -- and therefore its fingerprint -- based only on which scoping flags were
404
+ * passed. `reportErrors`/`previewOutcomes` fold `events` for candidates but
405
+ * consult `lineageEvents` for the session-stamp tier, so `--epic`/`--since`
406
+ * narrow what gets reported without perturbing how a row's project is
407
+ * resolved.
408
+ */
337
409
  async function issueInputs(flags) {
338
410
  const sessionId = requireFlag(flags, 'session');
339
411
  const eventOpts = eventOptsFromFlags(flags);
340
412
  requireSession(sessionId, eventOpts);
341
- const events = scopeIssueCandidates(await readLineageEvents(sessionId, eventOpts), flags);
413
+ const lineageEvents = await readLineageEvents(sessionId, eventOpts);
414
+ const events = scopeIssueCandidates(lineageEvents, flags);
342
415
  const milestones = loadRoadmap(flags['roadmap-path']);
343
416
  const isEnabled = (project) => isErrorTrackerWritable(milestones, project);
344
- return { events, isEnabled, register: factoryProjects(), eventOpts };
417
+ const planOpts = planOptsFromFlags(flags);
418
+ const resolveProject = planProjectResolverForTaskRef(planOpts);
419
+ const selfFallback = planSelfFallbackForTaskRef(FACTORY_PROJECT, planOpts);
420
+ return {
421
+ events,
422
+ isEnabled,
423
+ register: factoryProjects(),
424
+ eventOpts,
425
+ resolveProject,
426
+ selfFallback,
427
+ lineageEvents,
428
+ };
345
429
  }
346
- /** Where plan version files are read from and written to; defaults to factory/specs/active. */
430
+ /**
431
+ * Where plan version files are read from and written to; defaults to
432
+ * factory/specs/active. See `issueInputs`'s doc comment for why this
433
+ * deliberately does not travel with `--state-dir`.
434
+ */
347
435
  function planOptsFromFlags(flags) {
348
436
  return flags['specs-dir'] ? { specsDir: flags['specs-dir'] } : {};
349
437
  }
@@ -732,8 +820,19 @@ async function nextWaveInputFrom(planFile, flags) {
732
820
  ? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
733
821
  : [];
734
822
  const planIds = new Set(planOnDisk.tasks.map((t) => t.task_id));
823
+ // A lineage is not one epic. `readAddedTasks` reads the WHOLE lineage
824
+ // (D-119), so a resumed session that ever ran a different, earlier epic
825
+ // carries that epic's `task-added` rows too — and a row an operator
826
+ // abandoned non-terminal (never merged, waived, or superseded) reads back
827
+ // here exactly like a genuine follow-up of THIS plan. Unfiltered, it was
828
+ // spliced into `plan.tasks` below and reached `computeNextWave` as a
829
+ // candidate or an occupied claim-holder: proposed as this epic's own work,
830
+ // and able to defer this epic's tasks on a claim overlap that has nothing
831
+ // to do with them. `wave check` never has this problem because it only
832
+ // ever looks at the ids an operator names — matching that, a follow-up
833
+ // only counts here when its own `task-added` named this plan's epic.
735
834
  const followUps = logged
736
- .filter((t) => !planIds.has(t.taskId))
835
+ .filter((t) => !planIds.has(t.taskId) && t.epicId === planOnDisk.epic_id)
737
836
  .map((t) => ({
738
837
  task_id: t.taskId,
739
838
  // The log is the only register that holds this task's status, so a
@@ -898,7 +997,20 @@ async function main() {
898
997
  }
899
998
  if (namespace === 'audit' && action === 'resolve') {
900
999
  const [projectDir] = requirePositionals(positional, usageFor('audit resolve'));
901
- printJson(await resolveAudit(projectDir, requireFlag(flags, 'epic'), eventContextFromFlags(flags), eventOptsFromFlags(flags)));
1000
+ // Comma-split, not repeated: a fingerprint has no commas, and deferring
1001
+ // more than one at a time is the common case (D-33's precedent, `plan
1002
+ // amend --findings`).
1003
+ const except = flags.except
1004
+ ? flags.except
1005
+ .split(',')
1006
+ .map((fp) => fp.trim())
1007
+ .filter((fp) => fp !== '')
1008
+ : undefined;
1009
+ printJson(await resolveAudit(projectDir, requireFlag(flags, 'epic'), eventContextFromFlags(flags), eventOptsFromFlags(flags), {
1010
+ ...(except ? { except } : {}),
1011
+ ...(flags.plan ? { plan: readJsonFile(flags.plan) } : {}),
1012
+ planOpts: planOptsFromFlags(flags),
1013
+ }));
902
1014
  return 0;
903
1015
  }
904
1016
  if (namespace === 'audit' && action === 'close') {
@@ -1004,6 +1116,14 @@ async function main() {
1004
1116
  // what went unnoticed.
1005
1117
  const edges = await emitEdgesRecorded(plan, ctx, opts);
1006
1118
  const added = written.filter((e) => e.record.event_type === 'task-added').length;
1119
+ // Criterion 7: a plan that serializes by claim geometry alone used to be
1120
+ // invisible until `/bs run` actually hit the ceiling. Dynamic for the
1121
+ // same reason `wave schedule` imports it dynamically -- nothing an
1122
+ // ingest that never schedules needs should be on the boot path every
1123
+ // `smith --help` walks. No repo scan here: `parallel_with` is
1124
+ // claims-and-edges only, so the input carries no crossings.
1125
+ const { computePlanParallelism } = await import('./waveSchedule.js');
1126
+ const parallelism = computePlanParallelism(plan, loadWorktreePolicy());
1007
1127
  printJson({
1008
1128
  epic: plan.epic_id,
1009
1129
  version: plan.version,
@@ -1011,6 +1131,7 @@ async function main() {
1011
1131
  superseded: written.length - added,
1012
1132
  skipped: plan.tasks.length - added,
1013
1133
  edges: edges.length,
1134
+ parallelism,
1014
1135
  });
1015
1136
  return 0;
1016
1137
  }
@@ -1280,9 +1401,13 @@ async function main() {
1280
1401
  // Static would be fine — waveSchedule.js reaches nothing waveNext.js has
1281
1402
  // not already put on the boot path — but the input assembly it is handed
1282
1403
  // may dynamically import `db/projector.js`, so the await is here anyway.
1283
- const { scheduleWaves } = await import('./waveSchedule.js');
1284
- const schedule = scheduleWaves(await nextWaveInputFrom(planFile, flags));
1285
- printJson(schedule);
1404
+ const { computePlanParallelism, scheduleWaves } = await import('./waveSchedule.js');
1405
+ const input = await nextWaveInputFrom(planFile, flags);
1406
+ const schedule = scheduleWaves(input);
1407
+ // Same `size` block `plan ingest` prints under `parallelism`, so the
1408
+ // operator sees the small-epic bound from either command.
1409
+ const { size } = computePlanParallelism(input.plan, input.policy);
1410
+ printJson({ ...schedule, size });
1286
1411
  // Writes nothing, for the reason `wave next` writes nothing and one more:
1287
1412
  // every round after the first is a simulation. The tasks it marks complete
1288
1413
  // were completed by nobody, and a log that recorded them would be claiming
@@ -1401,7 +1526,21 @@ async function main() {
1401
1526
  const projectDirs = resolveProjectDirs(repeated.project, {
1402
1527
  self: flags['no-self'] !== 'true',
1403
1528
  });
1404
- const input = { events, now, projectDirs };
1529
+ // Same resolver `issues report`/`preview` and the daemon use (D-246): an
1530
+ // error-report proposal's fingerprint is hashed over its `project`
1531
+ // (computeFingerprint), so this verb has to resolve a row's project the
1532
+ // same way the reporter that later clears the proposal does. A divergent
1533
+ // resolver here -- or none, which folds every row to `project: null` --
1534
+ // hashes a different fingerprint than the reporter's `issue-reported`
1535
+ // event names, so the proposal `scheduler run` keeps proposing never
1536
+ // matches the one already answered and is re-appended forever (S1-b).
1537
+ const input = {
1538
+ events,
1539
+ now,
1540
+ projectDirs,
1541
+ resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
1542
+ selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
1543
+ };
1405
1544
  if (dry) {
1406
1545
  printJson({ proposals: computeProposals(input) });
1407
1546
  return 0;
@@ -1448,11 +1587,17 @@ async function main() {
1448
1587
  // This clone joins the pass by default (contract clause 1); `--no-self`
1449
1588
  // is the refusal path.
1450
1589
  const { resolveProjectDirs } = await import('./projects.js');
1590
+ // Same resolver as `scheduler run` above, for the same reason (S1-b): a
1591
+ // divergent or absent resolver hashes a different fingerprint than the
1592
+ // reporter's `issue-reported` event names, so an already-answered
1593
+ // error-report proposal never clears.
1451
1594
  const proposals = computeProposals({
1452
1595
  events,
1453
1596
  now,
1454
1597
  policy,
1455
1598
  projectDirs: resolveProjectDirs(repeated.project, { self: flags['no-self'] !== 'true' }),
1599
+ resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
1600
+ selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
1456
1601
  });
1457
1602
  // A RecheckProposal names a task and no paths, so without this the
1458
1603
  // security match would see an opaque id and clear a recheck of
@@ -1501,6 +1646,15 @@ async function main() {
1501
1646
  ...eventOptsFromFlags(flags),
1502
1647
  projectDirs: resolveProjectDirs(repeated.project, { self }),
1503
1648
  readProjects: () => factoryProjects().filter((ref) => self || !ref.self),
1649
+ // Same D-246 self-fallback as issueInputs (see its doc comment): a
1650
+ // plan on disk naming no project is this checkout's own epic, so it
1651
+ // reads as FACTORY_PROJECT rather than staying an unresolved null the
1652
+ // daemon would report as an unlabeled finding and the on/off switch
1653
+ // for 'black-smith' would never get asked about. Split into strict
1654
+ // (resolveProjectForTaskRef) and self-only (selfFallbackForTaskRef)
1655
+ // tiers so a session's own stamp can ride between them (S2-c).
1656
+ resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
1657
+ selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
1504
1658
  ...(flags.db ? { dbPath: flags.db } : {}),
1505
1659
  ...(flags['no-db'] === 'true' ? { projectDb: false } : {}),
1506
1660
  };
@@ -1636,6 +1790,25 @@ async function main() {
1636
1790
  }
1637
1791
  if (namespace === 'worktree' && action === 'create') {
1638
1792
  const [projectDir, epic, taskId] = requirePositionals(positional, usageFor('worktree create'));
1793
+ if (flags.from !== undefined) {
1794
+ // `--from` reuses a predecessor's commits, so it may only be spent on a
1795
+ // task the log actually names as that predecessor's successor — never
1796
+ // on a bare "I want the same branch history" request. `--session` is
1797
+ // what supplies the log to check against; without it there is nothing
1798
+ // to refuse against, so demand it explicitly rather than silently
1799
+ // trusting an unauthenticated `--from`.
1800
+ const sessionId = requireFlag(flags, 'session');
1801
+ const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
1802
+ const successors = taskSuccessors(events, epic);
1803
+ const from = flags.from;
1804
+ const expected = bareTaskId(epic, taskId);
1805
+ const actual = successors.get(from) ?? successors.get(`${epic}/${from}`);
1806
+ if (actual === undefined || bareTaskId(epic, actual) !== expected) {
1807
+ throw new SmithError('worktree.not-a-successor', `${epic}/${taskId} is not logged as the successor of ${epic}/${from}; run \`plan propose\`/\`plan approve\` with a supersede pairing them first.`, { epic, taskId, from });
1808
+ }
1809
+ printJson(createTaskWorktree(projectDir, epic, taskId, { from }));
1810
+ return 0;
1811
+ }
1639
1812
  printJson(createTaskWorktree(projectDir, epic, taskId));
1640
1813
  return 0;
1641
1814
  }
@@ -1752,6 +1925,28 @@ async function main() {
1752
1925
  if (events && !flags.plan) {
1753
1926
  throw new SmithError('cli.missing-flag', 'queue run --session also needs --plan <plan.json>: a merge may only be logged under the task id the plan declares.', { epic });
1754
1927
  }
1928
+ // --batch (roadmap merge-lanes) groups tasks with groupForBatch before
1929
+ // any of them run, and that grouping needs two things `--tasks` alone
1930
+ // does not carry: the plan's `edges`, so a task never stacks with
1931
+ // something it depends_on, and — for a tasks-file entry that omits its
1932
+ // own `claims` — the plan's claim list, so a hand-typed batch run is not
1933
+ // blind to a disjointness `wave admit` already knows. Same shape as the
1934
+ // `--session` guard just above, and for the same reason: refuse the run
1935
+ // whole rather than group some tasks correctly and others by guesswork.
1936
+ if (flags.batch === 'true' && !flags.plan) {
1937
+ throw new SmithError('cli.missing-flag', 'queue run --batch also needs --plan <plan.json>: grouping reads its dependency edges and claim lists from there.', { epic });
1938
+ }
1939
+ // `attemptCandidate` runs the epic's test command directly against the
1940
+ // whole batch candidate — there is no per-task file set to narrow it to,
1941
+ // so a `--select-test-cmd` template would either render nonsensically or
1942
+ // silently run the full suite while the outcome claimed it was selected
1943
+ // (the same lie D-260 refuses above). Refuse the combination instead of
1944
+ // ignoring the flag.
1945
+ if (flags.batch === 'true' && selectTestCmd !== undefined) {
1946
+ throw new SmithError('cli.incompatible-flags', 'queue run --batch does not support --select-test-cmd: a batch candidate has no single task to narrow the test command to.', { epic });
1947
+ }
1948
+ let edges = [];
1949
+ let claimsById = new Map();
1755
1950
  if (flags.plan) {
1756
1951
  const plan = readJsonFile(flags.plan);
1757
1952
  // …and the plan is not the only register: a follow-up minted by
@@ -1763,6 +1958,11 @@ async function main() {
1763
1958
  const loggedIds = logged.map((t) => t.taskId);
1764
1959
  for (const task of tasks)
1765
1960
  task.taskId = resolveTaskId(plan, task.taskId, loggedIds);
1961
+ edges = plan.edges.map((e) => ({ task: e.task, dependsOn: e.dependsOn }));
1962
+ claimsById = new Map(plan.tasks.map((t) => [
1963
+ t.task_id,
1964
+ Array.isArray(t.claims) ? t.claims.filter((c) => typeof c === 'string') : [],
1965
+ ]));
1766
1966
  // D-186: the ids are the plan's now, so the order can be too. `--tasks`
1767
1967
  // is hand-written, and merging in the order someone typed lets a task
1768
1968
  // land before the task it declares `depends_on` — the epic's cumulative
@@ -1770,21 +1970,81 @@ async function main() {
1770
1970
  // and blames the wrong branch when it goes red. `admit()` exists for
1771
1971
  // exactly this and had no caller until here; a follow-up the plan never
1772
1972
  // declared carries no edges, so it just sorts by id among its peers.
1773
- const order = admit(tasks.map((t) => ({ task_id: t.taskId })), plan.edges.map((e) => ({ task: e.task, dependsOn: e.dependsOn })));
1973
+ const order = admit(tasks.map((t) => ({ task_id: t.taskId })), edges);
1774
1974
  // Stable: two tasks the plan does not order keep the order admit gave
1775
1975
  // them, and a duplicated id is still run twice rather than dropped.
1776
1976
  tasks.sort((a, b) => order.indexOf(a.taskId) - order.indexOf(b.taskId));
1777
1977
  }
1978
+ const stepOpts = {
1979
+ projectDir,
1980
+ epic,
1981
+ testCmd,
1982
+ ...(selectTestCmd !== undefined ? { selectTestCmd } : {}),
1983
+ ...(events ? { events } : {}),
1984
+ };
1778
1985
  const outcomes = [];
1779
1986
  let allMerged = true;
1987
+ if (flags.batch === 'true') {
1988
+ const byId = new Map(tasks.map((t) => [t.taskId, t]));
1989
+ // A tasks-file entry's own `claims` wins over the plan's — the plan
1990
+ // describes what the task was authored to touch, the tasks file is
1991
+ // what actually ran, and the two can disagree the same way a diff can
1992
+ // outgrow the claim it was cut from.
1993
+ const groupable = tasks.map((t) => ({
1994
+ taskId: t.taskId,
1995
+ claims: t.claims ?? claimsById.get(t.taskId),
1996
+ }));
1997
+ const groups = groupForBatch(groupable, edges, loadWorktreePolicy().serializeAlwaysGlobs);
1998
+ const batches = [];
1999
+ for (const group of groups) {
2000
+ const groupTasks = group.map((id) => {
2001
+ const t = byId.get(id);
2002
+ if (!t) {
2003
+ // groupForBatch only ever returns ids it was handed, so this
2004
+ // would mean the group and --tasks have drifted apart — a wrong
2005
+ // candidate is worse than a refusal.
2006
+ throw new SmithError('queue.batch-unknown-task', `--batch grouped task "${id}" but no task by that id was in --tasks.`, { epic, taskId: id });
2007
+ }
2008
+ return t;
2009
+ });
2010
+ // groupForBatch's own contract (see its doc comment): a group of one
2011
+ // is not a batch, it's a task that closed the open group before it —
2012
+ // no candidate to stack, so it runs through the plain step it would
2013
+ // have taken without --batch at all, rather than through batchStep's
2014
+ // fold/candidate-worktree machinery for a "stack" of one.
2015
+ if (groupTasks.length === 1) {
2016
+ const [only] = groupTasks;
2017
+ const outcome = await step(only, stepOpts);
2018
+ outcomes.push(outcome);
2019
+ const landed = outcome.outcome === 'merged';
2020
+ // `step` refuses before any test runs on `nothing-to-merge` (D-30's
2021
+ // uncommitted-work guard) and `rebase-conflict` — 0 suites ran, not
2022
+ // 1, for either. Every other outcome it can return (`merged`,
2023
+ // `tests-failed`, `integration-dirty`) only happens after
2024
+ // `runTestCmd`.
2025
+ const suiteRuns = outcome.outcome === 'nothing-to-merge' || outcome.outcome === 'rebase-conflict' ? 0 : 1;
2026
+ batches.push({ task_ids: group, suite_runs: suiteRuns, landed });
2027
+ if (!landed) {
2028
+ allMerged = false;
2029
+ break;
2030
+ }
2031
+ }
2032
+ else {
2033
+ const result = await batchStep(groupTasks, stepOpts);
2034
+ outcomes.push(...result.outcomes);
2035
+ const landed = result.outcomes.every((o) => o.outcome === 'merged');
2036
+ batches.push({ task_ids: group, suite_runs: result.suiteRuns, landed });
2037
+ if (!landed) {
2038
+ allMerged = false;
2039
+ break;
2040
+ }
2041
+ }
2042
+ }
2043
+ printJson({ outcomes, batches });
2044
+ return allMerged ? 0 : 1;
2045
+ }
1780
2046
  for (const task of tasks) {
1781
- const outcome = await step(task, {
1782
- projectDir,
1783
- epic,
1784
- testCmd,
1785
- ...(selectTestCmd !== undefined ? { selectTestCmd } : {}),
1786
- ...(events ? { events } : {}),
1787
- });
2047
+ const outcome = await step(task, stepOpts);
1788
2048
  outcomes.push(outcome);
1789
2049
  if (outcome.outcome !== 'merged') {
1790
2050
  allMerged = false;
@@ -2013,6 +2273,27 @@ async function main() {
2013
2273
  printJson(report);
2014
2274
  return report.ok ? 0 : 1;
2015
2275
  }
2276
+ if (namespace === 'dispatch' && action === 'lint') {
2277
+ // dispatch.md "Carry into the prompt" / "Declare each judge's artifact":
2278
+ // catches a stated turn budget that disagrees with the template's
2279
+ // `maxTurns` in either direction, a template it cannot read at all, and a
2280
+ // judge prompt missing (or mismatching) its declared-artifact line,
2281
+ // before the agent ever runs. Fails closed: any applicable status but
2282
+ // `ok` exits 1. See dispatchLint.ts for why each status means what it
2283
+ // means. `-` reads stdin, same convention as `prompt wrap`/`prompt record`.
2284
+ const [file] = requirePositionals(positional, usageFor('dispatch lint'), 1);
2285
+ const prompt = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8');
2286
+ const report = await lintDispatchPrompt({
2287
+ prompt,
2288
+ role: requireFlag(flags, 'role'),
2289
+ taskId: requireFlag(flags, 'task'),
2290
+ sessionId: requireFlag(flags, 'session'),
2291
+ ...(flags['agents-dir'] ? { agentsDir: flags['agents-dir'] } : {}),
2292
+ eventOpts: eventOptsFromFlags(flags),
2293
+ });
2294
+ printJson(report);
2295
+ return report.exitCode;
2296
+ }
2016
2297
  if (namespace === 'tester' && action === 'check') {
2017
2298
  // The other half of `dispatch check`: that one asks whether the critic ran
2018
2299
  // on the finder's model, this one asks whether a tester ran at all, in a
@@ -2179,20 +2460,20 @@ async function main() {
2179
2460
  }
2180
2461
  if (namespace === 'issues' && action === 'report') {
2181
2462
  // The one path in this epic that runs `gh` for real.
2182
- const { events, isEnabled, register, eventOpts } = await issueInputs(flags);
2463
+ const { events, isEnabled, register, eventOpts, resolveProject, selfFallback, lineageEvents } = await issueInputs(flags);
2183
2464
  const clock = () => new Date().toISOString();
2184
- printJson(await reportErrors(events, isEnabled, register, issueCommandRunner, clock, eventOpts));
2465
+ printJson(await reportErrors(events, isEnabled, register, issueCommandRunner, clock, eventOpts, resolveProject, selfFallback, lineageEvents));
2185
2466
  return 0;
2186
2467
  }
2187
2468
  if (namespace === 'issues' && action === 'preview') {
2188
2469
  // Same inputs, no `gh` and no event: the runner throws if anything
2189
2470
  // reaches it, so a preview that spawned would fail loudly, not quietly.
2190
- const { events, isEnabled, register } = await issueInputs(flags);
2471
+ const { events, isEnabled, register, resolveProject, selfFallback, lineageEvents } = await issueInputs(flags);
2191
2472
  const neverRun = (cmd) => {
2192
2473
  throw new Error(`issues preview must never run a command, asked for ${cmd}`);
2193
2474
  };
2194
2475
  const clock = () => new Date().toISOString();
2195
- printJson(await previewOutcomes(events, isEnabled, register, neverRun, clock));
2476
+ printJson(await previewOutcomes(events, isEnabled, register, neverRun, clock, resolveProject, selfFallback, lineageEvents));
2196
2477
  return 0;
2197
2478
  }
2198
2479
  // The judge sandbox (A3). `worktree fingerprint`/`verify` above detect a
@@ -2367,7 +2648,7 @@ async function main() {
2367
2648
  const { runGate } = await import('./gate.js');
2368
2649
  const [taskId] = requirePositionals(positional, usageFor('gate run'));
2369
2650
  const worktreeDir = requireFlag(flags, 'worktree');
2370
- const checks = readJsonFile(requireFlag(flags, 'checks'));
2651
+ const checks = readChecksFile(requireFlag(flags, 'checks'));
2371
2652
  // The result file has the same two intake shapes as findings below, and for
2372
2653
  // the same reason. With `--agent`, `--result` is the worker's half —
2373
2654
  // run_status/structured_output/artifacts — and the dispatcher stamps the
@@ -2564,7 +2845,7 @@ async function main() {
2564
2845
  if (namespace === 'integration' && action === 'check') {
2565
2846
  const epicId = requireFlag(flags, 'epic');
2566
2847
  const projectDir = requireFlag(flags, 'project');
2567
- const checks = readJsonFile(requireFlag(flags, 'checks'));
2848
+ const checks = readChecksFile(requireFlag(flags, 'checks'));
2568
2849
  const ctx = eventContextFromFlags(flags);
2569
2850
  const record = await runIntegrationCheck({
2570
2851
  epicId,
@@ -123,6 +123,26 @@ export function reconcile(input) {
123
123
  if (!nativeByFingerprint.has(finding.fingerprint)) {
124
124
  nativeByFingerprint.set(finding.fingerprint, finding);
125
125
  }
126
+ // Also key by the fingerprint computeFingerprint() would produce for this
127
+ // record TODAY, not just the one stored at raise time. `input.native`
128
+ // comes straight out of listFindings() (a lineage-wide, possibly
129
+ // long-lived read) while `group.fingerprint` below is always computed
130
+ // fresh from the independent provider's evidence -- so a native finding
131
+ // raised before issue #178 widened the normalizer (its summary containing
132
+ // a line span the old algorithm did not strip) would otherwise never
133
+ // match an independent finder's evidence for the identical bug, and get
134
+ // reported as `co-located`/`independent-only` plus a duplicate
135
+ // `native-only` instead of `corroborated`.
136
+ if (finding.file_path !== undefined) {
137
+ const recomputed = computeFingerprint({
138
+ filePath: finding.file_path,
139
+ category: finding.finding_category,
140
+ summary: finding.summary,
141
+ });
142
+ if (!nativeByFingerprint.has(recomputed)) {
143
+ nativeByFingerprint.set(recomputed, finding);
144
+ }
145
+ }
126
146
  if (finding.file_path === undefined)
127
147
  continue;
128
148
  const key = coLocationKey(finding.file_path, finding.finding_category);