@juzser/blacksmith 0.2.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 (71) 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 +25 -9
  8. package/.claude/skills/bs/dispatch.md +60 -7
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +18 -13
  11. package/.claude/skills/bs/wave.md +68 -10
  12. package/README.md +4 -4
  13. package/factory/orchestrator/dist/agentsSync.js +178 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +25 -0
  16. package/factory/orchestrator/dist/budgets.js +91 -2
  17. package/factory/orchestrator/dist/claims.js +8 -4
  18. package/factory/orchestrator/dist/cli.js +394 -26
  19. package/factory/orchestrator/dist/crossFinding.js +20 -0
  20. package/factory/orchestrator/dist/daemon.js +230 -41
  21. package/factory/orchestrator/dist/db/projector.js +93 -36
  22. package/factory/orchestrator/dist/db/queries.js +58 -35
  23. package/factory/orchestrator/dist/db/schema.js +24 -0
  24. package/factory/orchestrator/dist/dispatchAudit.js +10 -2
  25. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  26. package/factory/orchestrator/dist/epic.js +192 -39
  27. package/factory/orchestrator/dist/epicWidth.js +25 -20
  28. package/factory/orchestrator/dist/errorIssues.js +209 -24
  29. package/factory/orchestrator/dist/eventOrder.js +102 -0
  30. package/factory/orchestrator/dist/events.js +73 -91
  31. package/factory/orchestrator/dist/findingContext.js +1 -13
  32. package/factory/orchestrator/dist/findings.js +132 -11
  33. package/factory/orchestrator/dist/gh.js +29 -6
  34. package/factory/orchestrator/dist/git.js +20 -2
  35. package/factory/orchestrator/dist/harness.js +92 -10
  36. package/factory/orchestrator/dist/hookDecision.js +320 -37
  37. package/factory/orchestrator/dist/impact.js +24 -2
  38. package/factory/orchestrator/dist/integration.js +11 -1
  39. package/factory/orchestrator/dist/issueReporter.js +415 -0
  40. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  41. package/factory/orchestrator/dist/judges.js +1 -1
  42. package/factory/orchestrator/dist/lessons.js +46 -18
  43. package/factory/orchestrator/dist/logCache.js +291 -0
  44. package/factory/orchestrator/dist/paths.js +10 -0
  45. package/factory/orchestrator/dist/plan.js +138 -4
  46. package/factory/orchestrator/dist/planQuorum.js +1 -1
  47. package/factory/orchestrator/dist/policy.js +57 -11
  48. package/factory/orchestrator/dist/projects.js +12 -1
  49. package/factory/orchestrator/dist/queue.js +477 -29
  50. package/factory/orchestrator/dist/scheduler.js +51 -0
  51. package/factory/orchestrator/dist/severity.js +40 -8
  52. package/factory/orchestrator/dist/spec.js +70 -0
  53. package/factory/orchestrator/dist/taskEvents.js +13 -4
  54. package/factory/orchestrator/dist/taskStatus.js +74 -0
  55. package/factory/orchestrator/dist/taxonomy.js +11 -0
  56. package/factory/orchestrator/dist/testgate.js +32 -3
  57. package/factory/orchestrator/dist/usage.js +42 -9
  58. package/factory/orchestrator/dist/waivers.js +60 -9
  59. package/factory/orchestrator/dist/waveConcurrency.js +29 -0
  60. package/factory/orchestrator/dist/waveNext.js +12 -10
  61. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  62. package/factory/orchestrator/dist/worktree.js +19 -6
  63. package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
  64. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  65. package/factory/policies/budgets.yml +9 -2
  66. package/factory/policies/crosscheck.yml +2 -2
  67. package/factory/policies/lessons.md +298 -0
  68. package/factory/policies/scheduler.yml +27 -2
  69. package/factory/policies/taxonomy.yml +10 -3
  70. package/factory/specs/roadmap.md +70 -23
  71. package/package.json +8 -8
@@ -14,22 +14,25 @@
14
14
  // the same modules are fine and stay up here: tsc erases them, so they cost
15
15
  // nothing at runtime. `test/cliBoot.test.ts` reads the built graph and fails if
16
16
  // the database layer creeps back into it.
17
- import { spawn } from 'node:child_process';
17
+ import { execFileSync, spawn } from 'node:child_process';
18
18
  import { mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import { pathToFileURL } from 'node:url';
21
+ import { resetAgentMaxTurns, syncAgentMaxTurns } from './agentsSync.js';
21
22
  import { parseArgs } from './args.js';
22
23
  import { closeAudit, consolidateAudit, cutAudit, decideAudit, openAudit, recordAudit, resolveAudit, } from './audit.js';
23
24
  import { checkBudgetAlarm } from './budgetAlarm.js';
24
- import { loadBudgetPolicy } from './budgets.js';
25
+ import { budgetEnvOverrides, loadBudgetPolicy } from './budgets.js';
25
26
  import { collectCommittedChanges, loadWorktreePolicy, postRunCheck, readClaimList, validateWave, writeRootCheck, } from './claims.js';
26
27
  import { collectCoverageEvidence } from './coverage.js';
27
28
  import { loadCrosscheckPolicy } from './crosscheck.js';
28
29
  import { independentFinderRequest, reconcile, runIndependentFinder, } from './crossFinding.js';
29
30
  import { checkDelegationGrants, checkDelegationLog, loadDelegationPolicy } from './delegation.js';
30
31
  import { checkDispatchAsymmetry } from './dispatchAudit.js';
32
+ import { lintDispatchPrompt } from './dispatchLint.js';
31
33
  import { loadDotEnv } from './dotenv.js';
32
34
  import { loadEffortPolicy, resolveEffort } from './effort.js';
35
+ import { ISSUE_CANDIDATE_EVENT_TYPES } from './errorIssues.js';
33
36
  import { SmithError } from './errors.js';
34
37
  import { checkEscalationLadder } from './escalation.js';
35
38
  import { appendEvent, filterEvents, followEvents, listSessionIds, mergeSessionLogs, readEvents, readLineageEvents, requireSession, sessionTree, startSession, tailEvents, } from './events.js';
@@ -41,25 +44,28 @@ import { decideHookPayload } from './hookDecision.js';
41
44
  import { checkWorktreeImmutable, fingerprintWorktree, } from './immutability.js';
42
45
  import { collectExportDiffs, exportImpact, waveImpact } from './impact.js';
43
46
  import { integrationHeadSha, runIntegrationCheck } from './integration.js';
47
+ import { previewOutcomes, reportErrors } from './issueReporter.js';
44
48
  import { judgePreflight } from './judgePreflight.js';
45
49
  import { outstandingJudges, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, } from './judges.js';
46
50
  import { addMcpSurface, resolveMcpSurface, runMcpCheck } from './mcp.js';
47
51
  import { DOTENV_PATH, LESSONS_MD_PATH, lessonsReadPath, REPO_ROOT, SANDBOX_LEASE_DIR, STATE_DB_PATH, } from './paths.js';
48
- import { diffPlans, livePlanTasks, resolveTaskId, validatePlan, } from './plan.js';
52
+ import { bareTaskId, diffPlans, livePlanTasks, planProjectResolverForTaskRef, planSelfFallbackForTaskRef, resolveTaskId, validatePlan, } from './plan.js';
49
53
  import { runPlanQuorum } from './planQuorum.js';
50
54
  import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
55
+ import { factoryProjects } from './projects.js';
51
56
  import { recordUserPrompt } from './prompts.js';
52
57
  import { checkBrief, wrapIngested } from './provenance.js';
53
58
  import { runJudge } from './providers/index.js';
54
- import { admit, adopt, step } from './queue.js';
59
+ import { admit, adopt, batchStep, groupForBatch, step } from './queue.js';
55
60
  import { stampResultEnvelope } from './results.js';
61
+ import { FACTORY_PROJECT, isErrorTrackerWritable, loadRoadmap } from './roadmap.js';
56
62
  import { checkRuntime } from './runtime.js';
57
63
  import { checkSameMistakeKpi } from './sameMistakeKpi.js';
58
64
  import { activeSandboxFor, closeSandbox, listSandboxes, openSandbox, } from './sandbox.js';
59
65
  import { registerProjectInRoadmap, scaffoldProject } from './scaffold.js';
60
66
  import { loadSensitivePathsPolicy, securityTriggers, } from './security.js';
61
67
  import { parseLessons } from './severity.js';
62
- import { amendPlan, recordSpecReview } from './spec.js';
68
+ import { amendPlan, recordSpecReview, taskSuccessors } from './spec.js';
63
69
  import { approveSpecChange, listSpecChanges, proposeSpecChange, rejectSpecChange, } from './specChange.js';
64
70
  import { checkStack, loadStackAnswers } from './stack.js';
65
71
  import { buildSymbolGraph, collectSources } from './symbols.js';
@@ -79,6 +85,29 @@ function printJson(value) {
79
85
  function readJsonFile(filePath) {
80
86
  return JSON.parse(readFileSync(filePath, 'utf8'));
81
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
+ }
82
111
  /** A task spec's `budget.tokens` when it is a usable positive number, else null. */
83
112
  function declaredTokens(budget) {
84
113
  if (typeof budget !== 'object' || budget === null)
@@ -298,7 +327,111 @@ function eventContextFromFlags(flags) {
298
327
  function eventOptsFromFlags(flags) {
299
328
  return flags['state-dir'] ? { stateDir: flags['state-dir'] } : {};
300
329
  }
301
- /** Where plan version files are read from and written to; defaults to factory/specs/active. */
330
+ /** The runner `issues report` hands to `reportErrors`: git and `gh` through one call, never a throw. */
331
+ function issueCommandRunner(cmd, args) {
332
+ try {
333
+ const stdout = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
334
+ return { status: 0, stdout, stderr: '' };
335
+ }
336
+ catch (err) {
337
+ const e = err;
338
+ return {
339
+ status: typeof e.status === 'number' ? e.status : null,
340
+ stdout: e.stdout ?? '',
341
+ stderr: e.stderr ?? '',
342
+ ...(typeof e.code === 'string' ? { spawnError: e.code } : {}),
343
+ };
344
+ }
345
+ }
346
+ /** `--epic`/`--since` narrow the CANDIDATES, never the `issue-reported` history dedup reads. */
347
+ function scopeIssueCandidates(events, flags) {
348
+ const epic = flags.epic;
349
+ const since = flags.since;
350
+ if (epic === undefined && since === undefined)
351
+ return events;
352
+ return events.filter(({ record }) => {
353
+ if (!ISSUE_CANDIDATE_EVENT_TYPES.has(record.event_type))
354
+ return true;
355
+ if (epic !== undefined && !(record.task_id ?? '').startsWith(`${epic}/`))
356
+ return false;
357
+ return since === undefined || record.ts >= since;
358
+ });
359
+ }
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
+ */
409
+ async function issueInputs(flags) {
410
+ const sessionId = requireFlag(flags, 'session');
411
+ const eventOpts = eventOptsFromFlags(flags);
412
+ requireSession(sessionId, eventOpts);
413
+ const lineageEvents = await readLineageEvents(sessionId, eventOpts);
414
+ const events = scopeIssueCandidates(lineageEvents, flags);
415
+ const milestones = loadRoadmap(flags['roadmap-path']);
416
+ const isEnabled = (project) => isErrorTrackerWritable(milestones, project);
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
+ };
429
+ }
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
+ */
302
435
  function planOptsFromFlags(flags) {
303
436
  return flags['specs-dir'] ? { specsDir: flags['specs-dir'] } : {};
304
437
  }
@@ -687,8 +820,19 @@ async function nextWaveInputFrom(planFile, flags) {
687
820
  ? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
688
821
  : [];
689
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.
690
834
  const followUps = logged
691
- .filter((t) => !planIds.has(t.taskId))
835
+ .filter((t) => !planIds.has(t.taskId) && t.epicId === planOnDisk.epic_id)
692
836
  .map((t) => ({
693
837
  task_id: t.taskId,
694
838
  // The log is the only register that holds this task's status, so a
@@ -853,7 +997,20 @@ async function main() {
853
997
  }
854
998
  if (namespace === 'audit' && action === 'resolve') {
855
999
  const [projectDir] = requirePositionals(positional, usageFor('audit resolve'));
856
- 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
+ }));
857
1014
  return 0;
858
1015
  }
859
1016
  if (namespace === 'audit' && action === 'close') {
@@ -959,6 +1116,14 @@ async function main() {
959
1116
  // what went unnoticed.
960
1117
  const edges = await emitEdgesRecorded(plan, ctx, opts);
961
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());
962
1127
  printJson({
963
1128
  epic: plan.epic_id,
964
1129
  version: plan.version,
@@ -966,6 +1131,7 @@ async function main() {
966
1131
  superseded: written.length - added,
967
1132
  skipped: plan.tasks.length - added,
968
1133
  edges: edges.length,
1134
+ parallelism,
969
1135
  });
970
1136
  return 0;
971
1137
  }
@@ -1197,7 +1363,15 @@ async function main() {
1197
1363
  if (result.valid && !coupled && !blocked && flags.dry !== 'true') {
1198
1364
  await emitWaveAdmitted(plan, taskIds, eventContextFromFlags(flags), eventOptsFromFlags(flags), admissionBudget(budget, overridden ? rationale : undefined));
1199
1365
  }
1200
- printJson({ ...result, symbolImpact, budget });
1366
+ // Names only, and only when some differ from budgets.yml: which caps came
1367
+ // from the box's env rather than the file.
1368
+ const envOverrides = budgetEnvOverrides(loadBudgetPolicy(flags['budget-policy'], {}));
1369
+ printJson({
1370
+ ...result,
1371
+ symbolImpact,
1372
+ budget,
1373
+ ...(envOverrides.length > 0 ? { budgetEnvOverrides: envOverrides } : {}),
1374
+ });
1201
1375
  return result.valid && !coupled && !blocked ? 0 : 1;
1202
1376
  }
1203
1377
  if (namespace === 'wave' && action === 'next') {
@@ -1227,9 +1401,13 @@ async function main() {
1227
1401
  // Static would be fine — waveSchedule.js reaches nothing waveNext.js has
1228
1402
  // not already put on the boot path — but the input assembly it is handed
1229
1403
  // may dynamically import `db/projector.js`, so the await is here anyway.
1230
- const { scheduleWaves } = await import('./waveSchedule.js');
1231
- const schedule = scheduleWaves(await nextWaveInputFrom(planFile, flags));
1232
- 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 });
1233
1411
  // Writes nothing, for the reason `wave next` writes nothing and one more:
1234
1412
  // every round after the first is a simulation. The tasks it marks complete
1235
1413
  // were completed by nobody, and a log that recorded them would be claiming
@@ -1348,7 +1526,21 @@ async function main() {
1348
1526
  const projectDirs = resolveProjectDirs(repeated.project, {
1349
1527
  self: flags['no-self'] !== 'true',
1350
1528
  });
1351
- 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
+ };
1352
1544
  if (dry) {
1353
1545
  printJson({ proposals: computeProposals(input) });
1354
1546
  return 0;
@@ -1395,11 +1587,17 @@ async function main() {
1395
1587
  // This clone joins the pass by default (contract clause 1); `--no-self`
1396
1588
  // is the refusal path.
1397
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.
1398
1594
  const proposals = computeProposals({
1399
1595
  events,
1400
1596
  now,
1401
1597
  policy,
1402
1598
  projectDirs: resolveProjectDirs(repeated.project, { self: flags['no-self'] !== 'true' }),
1599
+ resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
1600
+ selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
1403
1601
  });
1404
1602
  // A RecheckProposal names a task and no paths, so without this the
1405
1603
  // security match would see an opaque id and clear a recheck of
@@ -1448,6 +1646,15 @@ async function main() {
1448
1646
  ...eventOptsFromFlags(flags),
1449
1647
  projectDirs: resolveProjectDirs(repeated.project, { self }),
1450
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)),
1451
1658
  ...(flags.db ? { dbPath: flags.db } : {}),
1452
1659
  ...(flags['no-db'] === 'true' ? { projectDb: false } : {}),
1453
1660
  };
@@ -1583,6 +1790,25 @@ async function main() {
1583
1790
  }
1584
1791
  if (namespace === 'worktree' && action === 'create') {
1585
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
+ }
1586
1812
  printJson(createTaskWorktree(projectDir, epic, taskId));
1587
1813
  return 0;
1588
1814
  }
@@ -1699,6 +1925,28 @@ async function main() {
1699
1925
  if (events && !flags.plan) {
1700
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 });
1701
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();
1702
1950
  if (flags.plan) {
1703
1951
  const plan = readJsonFile(flags.plan);
1704
1952
  // …and the plan is not the only register: a follow-up minted by
@@ -1710,6 +1958,11 @@ async function main() {
1710
1958
  const loggedIds = logged.map((t) => t.taskId);
1711
1959
  for (const task of tasks)
1712
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
+ ]));
1713
1966
  // D-186: the ids are the plan's now, so the order can be too. `--tasks`
1714
1967
  // is hand-written, and merging in the order someone typed lets a task
1715
1968
  // land before the task it declares `depends_on` — the epic's cumulative
@@ -1717,21 +1970,81 @@ async function main() {
1717
1970
  // and blames the wrong branch when it goes red. `admit()` exists for
1718
1971
  // exactly this and had no caller until here; a follow-up the plan never
1719
1972
  // declared carries no edges, so it just sorts by id among its peers.
1720
- 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);
1721
1974
  // Stable: two tasks the plan does not order keep the order admit gave
1722
1975
  // them, and a duplicated id is still run twice rather than dropped.
1723
1976
  tasks.sort((a, b) => order.indexOf(a.taskId) - order.indexOf(b.taskId));
1724
1977
  }
1978
+ const stepOpts = {
1979
+ projectDir,
1980
+ epic,
1981
+ testCmd,
1982
+ ...(selectTestCmd !== undefined ? { selectTestCmd } : {}),
1983
+ ...(events ? { events } : {}),
1984
+ };
1725
1985
  const outcomes = [];
1726
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
+ }
1727
2046
  for (const task of tasks) {
1728
- const outcome = await step(task, {
1729
- projectDir,
1730
- epic,
1731
- testCmd,
1732
- ...(selectTestCmd !== undefined ? { selectTestCmd } : {}),
1733
- ...(events ? { events } : {}),
1734
- });
2047
+ const outcome = await step(task, stepOpts);
1735
2048
  outcomes.push(outcome);
1736
2049
  if (outcome.outcome !== 'merged') {
1737
2050
  allMerged = false;
@@ -1960,6 +2273,27 @@ async function main() {
1960
2273
  printJson(report);
1961
2274
  return report.ok ? 0 : 1;
1962
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
+ }
1963
2297
  if (namespace === 'tester' && action === 'check') {
1964
2298
  // The other half of `dispatch check`: that one asks whether the critic ran
1965
2299
  // on the finder's model, this one asks whether a tester ran at all, in a
@@ -2087,7 +2421,10 @@ async function main() {
2087
2421
  const spec = readJsonFile(specFile);
2088
2422
  // The worktree is a full checkout, so it is both halves of the question:
2089
2423
  // the diff this task committed, and everyone in the repo who imports it.
2090
- const diffs = collectExportDiffs(worktreeDir, collectCommittedChanges(worktreeDir));
2424
+ // The promises go in too: a promised file the collector cannot read is
2425
+ // reported unverified rather than dropped, which is the only way absent
2426
+ // from the diff keeps meaning untouched.
2427
+ const diffs = collectExportDiffs(worktreeDir, collectCommittedChanges(worktreeDir), spec.keeps_exports ?? []);
2091
2428
  const graph = buildSymbolGraph(collectSources(worktreeDir));
2092
2429
  const report = exportImpact(graph, diffs, spec.claims, spec.keeps_exports ?? []);
2093
2430
  printJson(report);
@@ -2121,6 +2458,24 @@ async function main() {
2121
2458
  printJson(result);
2122
2459
  return 0;
2123
2460
  }
2461
+ if (namespace === 'issues' && action === 'report') {
2462
+ // The one path in this epic that runs `gh` for real.
2463
+ const { events, isEnabled, register, eventOpts, resolveProject, selfFallback, lineageEvents } = await issueInputs(flags);
2464
+ const clock = () => new Date().toISOString();
2465
+ printJson(await reportErrors(events, isEnabled, register, issueCommandRunner, clock, eventOpts, resolveProject, selfFallback, lineageEvents));
2466
+ return 0;
2467
+ }
2468
+ if (namespace === 'issues' && action === 'preview') {
2469
+ // Same inputs, no `gh` and no event: the runner throws if anything
2470
+ // reaches it, so a preview that spawned would fail loudly, not quietly.
2471
+ const { events, isEnabled, register, resolveProject, selfFallback, lineageEvents } = await issueInputs(flags);
2472
+ const neverRun = (cmd) => {
2473
+ throw new Error(`issues preview must never run a command, asked for ${cmd}`);
2474
+ };
2475
+ const clock = () => new Date().toISOString();
2476
+ printJson(await previewOutcomes(events, isEnabled, register, neverRun, clock, resolveProject, selfFallback, lineageEvents));
2477
+ return 0;
2478
+ }
2124
2479
  // The judge sandbox (A3). `worktree fingerprint`/`verify` above detect a
2125
2480
  // judge that wrote; these three refuse it up front. The orchestrator opens a
2126
2481
  // lease before handing a worktree to a judge and closes it when the verdict
@@ -2185,6 +2540,18 @@ async function main() {
2185
2540
  printJson(summarizeHarnesses({ policy: loadHarnessPolicy(flags.policy) }));
2186
2541
  return 0;
2187
2542
  }
2543
+ // A turn cap is per role (the template's frontmatter), never per task, and
2544
+ // Claude Code reads it at spawn — so the box-level override has to land in
2545
+ // the file. The edit is local and left uncommitted on purpose; `--reset`
2546
+ // puts back what git HEAD says.
2547
+ if (namespace === 'agents' && action === 'sync') {
2548
+ const agentsDir = flags['agents-dir'];
2549
+ const dryRun = flags['dry-run'] === 'true';
2550
+ printJson(flags.reset === 'true'
2551
+ ? resetAgentMaxTurns({ ...(agentsDir ? { agentsDir } : {}), dryRun })
2552
+ : syncAgentMaxTurns({ ...(agentsDir ? { agentsDir } : {}), dryRun }));
2553
+ return 0;
2554
+ }
2188
2555
  if (namespace === 'harness' && action === 'plan') {
2189
2556
  const tier = flags.tier;
2190
2557
  const invocation = planWorkerTurn({
@@ -2281,7 +2648,7 @@ async function main() {
2281
2648
  const { runGate } = await import('./gate.js');
2282
2649
  const [taskId] = requirePositionals(positional, usageFor('gate run'));
2283
2650
  const worktreeDir = requireFlag(flags, 'worktree');
2284
- const checks = readJsonFile(requireFlag(flags, 'checks'));
2651
+ const checks = readChecksFile(requireFlag(flags, 'checks'));
2285
2652
  // The result file has the same two intake shapes as findings below, and for
2286
2653
  // the same reason. With `--agent`, `--result` is the worker's half —
2287
2654
  // run_status/structured_output/artifacts — and the dispatcher stamps the
@@ -2337,7 +2704,7 @@ async function main() {
2337
2704
  // close per role: a judge that splits its findings across two files still
2338
2705
  // owes one turn, and a second report against it would be a duplicate.
2339
2706
  //
2340
- // FD-1 (csb-audit-1): `--grader` is the same hand-over for the grader. Its
2707
+ // FD-1: `--grader` is the same hand-over for the grader. Its
2341
2708
  // verdict document is not a findings list, so until `judge report` learned
2342
2709
  // the shape the grader's turn stayed open with its verdict on the command
2343
2710
  // line, and the gate blocked on the judge it was about to read.
@@ -2449,7 +2816,8 @@ async function main() {
2449
2816
  sessionId,
2450
2817
  ...(flags.epic ? { epicId: flags.epic } : {}),
2451
2818
  });
2452
- printJson(report);
2819
+ const envOverrides = budgetEnvOverrides(loadBudgetPolicy(flags.policy, {}));
2820
+ printJson(envOverrides.length > 0 ? { ...report, budgetEnvOverrides: envOverrides } : report);
2453
2821
  return report.ok ? 0 : 1;
2454
2822
  }
2455
2823
  // The consumer architecture §9.7's "monotonically decreasing same-mistake
@@ -2477,7 +2845,7 @@ async function main() {
2477
2845
  if (namespace === 'integration' && action === 'check') {
2478
2846
  const epicId = requireFlag(flags, 'epic');
2479
2847
  const projectDir = requireFlag(flags, 'project');
2480
- const checks = readJsonFile(requireFlag(flags, 'checks'));
2848
+ const checks = readChecksFile(requireFlag(flags, 'checks'));
2481
2849
  const ctx = eventContextFromFlags(flags);
2482
2850
  const record = await runIntegrationCheck({
2483
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);