@juzser/blacksmith 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +16 -9
  4. package/.claude/agents/{merger.md → bs-merger.md} +4 -4
  5. package/.claude/agents/{planner.md → bs-planner.md} +20 -6
  6. package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
  7. package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
  11. package/.claude/agents/{tester.md → bs-tester.md} +40 -12
  12. package/.claude/agents/bs-uiux.md +257 -0
  13. package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
  14. package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
  15. package/.claude/skills/bs/SKILL.md +99 -21
  16. package/.claude/skills/bs/audit.md +45 -31
  17. package/.claude/skills/bs/dispatch.md +133 -47
  18. package/.claude/skills/bs/lessons.md +28 -14
  19. package/.claude/skills/bs/mcp.md +4 -4
  20. package/.claude/skills/bs/new.md +3 -3
  21. package/.claude/skills/bs/plan.md +57 -16
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +190 -52
  24. package/.claude/skills/bs/status.md +14 -7
  25. package/.claude/skills/bs/ui.md +21 -5
  26. package/.claude/skills/bs/waivers.md +78 -2
  27. package/.claude/skills/bs/wave.md +143 -50
  28. package/README.md +53 -32
  29. package/factory/orchestrator/dist/agentNames.js +43 -0
  30. package/factory/orchestrator/dist/agents-registry.js +11 -4
  31. package/factory/orchestrator/dist/agentsSync.js +70 -13
  32. package/factory/orchestrator/dist/artifacts.js +31 -0
  33. package/factory/orchestrator/dist/attribution.js +4 -0
  34. package/factory/orchestrator/dist/audit.js +245 -20
  35. package/factory/orchestrator/dist/autonomy.js +6 -2
  36. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  37. package/factory/orchestrator/dist/budgets.js +302 -84
  38. package/factory/orchestrator/dist/claims.js +9 -19
  39. package/factory/orchestrator/dist/cli.js +1087 -116
  40. package/factory/orchestrator/dist/cliName.js +27 -0
  41. package/factory/orchestrator/dist/crossFinding.js +20 -0
  42. package/factory/orchestrator/dist/crosscheck.js +136 -8
  43. package/factory/orchestrator/dist/daemon.js +352 -67
  44. package/factory/orchestrator/dist/db/projector.js +327 -54
  45. package/factory/orchestrator/dist/db/queries.js +1831 -94
  46. package/factory/orchestrator/dist/db/schema.js +57 -4
  47. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  48. package/factory/orchestrator/dist/delegation.js +86 -19
  49. package/factory/orchestrator/dist/diffstat.js +15 -8
  50. package/factory/orchestrator/dist/dispatchLint.js +108 -0
  51. package/factory/orchestrator/dist/effort.js +14 -2
  52. package/factory/orchestrator/dist/effortTiers.js +13 -0
  53. package/factory/orchestrator/dist/env.js +26 -0
  54. package/factory/orchestrator/dist/epic.js +494 -42
  55. package/factory/orchestrator/dist/epicBudget.js +93 -0
  56. package/factory/orchestrator/dist/epicWidth.js +22 -5
  57. package/factory/orchestrator/dist/errorIssues.js +182 -11
  58. package/factory/orchestrator/dist/escalation.js +2 -2
  59. package/factory/orchestrator/dist/events.js +164 -14
  60. package/factory/orchestrator/dist/feedback.js +169 -0
  61. package/factory/orchestrator/dist/findingContext.js +26 -0
  62. package/factory/orchestrator/dist/findings.js +108 -14
  63. package/factory/orchestrator/dist/gate.js +120 -10
  64. package/factory/orchestrator/dist/gh.js +19 -2
  65. package/factory/orchestrator/dist/git.js +20 -2
  66. package/factory/orchestrator/dist/goalCheck.js +2 -2
  67. package/factory/orchestrator/dist/harness.js +25 -17
  68. package/factory/orchestrator/dist/hookDecision.js +411 -39
  69. package/factory/orchestrator/dist/integration.js +20 -8
  70. package/factory/orchestrator/dist/issueReporter.js +81 -16
  71. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  72. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  73. package/factory/orchestrator/dist/judgeStopHook.js +239 -0
  74. package/factory/orchestrator/dist/judges.js +419 -21
  75. package/factory/orchestrator/dist/lessons.js +190 -11
  76. package/factory/orchestrator/dist/logCache.js +291 -0
  77. package/factory/orchestrator/dist/mcp.js +6 -6
  78. package/factory/orchestrator/dist/paths.js +20 -7
  79. package/factory/orchestrator/dist/plan.js +399 -33
  80. package/factory/orchestrator/dist/planQuorum.js +5 -2
  81. package/factory/orchestrator/dist/policy.js +1275 -30
  82. package/factory/orchestrator/dist/projectName.js +25 -0
  83. package/factory/orchestrator/dist/projects.js +12 -1
  84. package/factory/orchestrator/dist/promptCapture.js +306 -0
  85. package/factory/orchestrator/dist/promptHook.js +33 -0
  86. package/factory/orchestrator/dist/promptImport.js +281 -0
  87. package/factory/orchestrator/dist/prompts.js +13 -0
  88. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  89. package/factory/orchestrator/dist/providers/types.js +1 -1
  90. package/factory/orchestrator/dist/queue.js +471 -30
  91. package/factory/orchestrator/dist/quorum.js +18 -5
  92. package/factory/orchestrator/dist/results.js +25 -7
  93. package/factory/orchestrator/dist/roadmap.js +10 -5
  94. package/factory/orchestrator/dist/run-cli.js +15 -10
  95. package/factory/orchestrator/dist/runner.js +2 -2
  96. package/factory/orchestrator/dist/scheduler.js +3 -3
  97. package/factory/orchestrator/dist/schemas.js +56 -8
  98. package/factory/orchestrator/dist/severity.js +8 -4
  99. package/factory/orchestrator/dist/spec.js +246 -12
  100. package/factory/orchestrator/dist/specChange.js +13 -9
  101. package/factory/orchestrator/dist/stack.js +6 -6
  102. package/factory/orchestrator/dist/storeRoot.js +90 -0
  103. package/factory/orchestrator/dist/symbols.js +261 -6
  104. package/factory/orchestrator/dist/taskEvents.js +192 -31
  105. package/factory/orchestrator/dist/taskStatus.js +14 -0
  106. package/factory/orchestrator/dist/taxonomy.js +11 -0
  107. package/factory/orchestrator/dist/testerAudit.js +31 -12
  108. package/factory/orchestrator/dist/testgate.js +20 -16
  109. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  110. package/factory/orchestrator/dist/usage.js +113 -48
  111. package/factory/orchestrator/dist/waivers.js +99 -12
  112. package/factory/orchestrator/dist/waveBudget.js +2 -0
  113. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  114. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  115. package/factory/orchestrator/dist/workroot.js +1 -1
  116. package/factory/orchestrator/dist/worktree.js +76 -21
  117. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  118. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  119. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  120. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  121. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  122. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  123. package/factory/policies/budgets.yml +123 -33
  124. package/factory/policies/crosscheck.yml +51 -13
  125. package/factory/policies/delegation.yml +3 -3
  126. package/factory/policies/effort.yml +18 -10
  127. package/factory/policies/guardrails.yml +8 -6
  128. package/factory/policies/harness.yml +5 -5
  129. package/factory/policies/lessons.md +53 -2
  130. package/factory/policies/scheduler.yml +4 -4
  131. package/factory/policies/sensitive-paths.yml +1 -1
  132. package/factory/policies/severity.yml +13 -1
  133. package/factory/policies/stack.yml +6 -6
  134. package/factory/policies/taxonomy.yml +9 -3
  135. package/factory/policies/worktree.yml +15 -9
  136. package/factory/specs/roadmap.md +27 -3
  137. package/factory/specs/schema/event.schema.json +8 -1
  138. package/factory/specs/schema/result.schema.json +21 -8
  139. package/factory/specs/schema/task-spec.schema.json +12 -0
  140. package/package.json +16 -12
  141. package/.claude/agents/uiux.md +0 -133
@@ -22,6 +22,19 @@ import { appendEvent } from './events.js';
22
22
  */
23
23
  export class PromptError extends SmithError {
24
24
  }
25
+ /**
26
+ * True when `text` is harness-injected rather than something the operator
27
+ * typed: blank, or a record opening with one of the CLI's own tags (reminders,
28
+ * local and shell command echoes, task notifications, memory input, and a
29
+ * subagent's hand-back, which is model output delivered as a user turn). The
30
+ * one list, shared by the dashboard's reader and any writer of operator
31
+ * prompts.
32
+ */
33
+ export function isHarnessText(text) {
34
+ const trimmed = text.trim();
35
+ return (trimmed === '' ||
36
+ /^<(system-reminder|local-command|command-message|bash-input|bash-stdout|bash-stderr|task-notification|user-memory-input|agent-message)/.test(trimmed));
37
+ }
25
38
  /**
26
39
  * Append one `user_prompt`, returning the stored event.
27
40
  *
@@ -93,7 +93,7 @@ export function extractBalancedJson(text) {
93
93
  /**
94
94
  * Schemas that describe ONE item a `kind: review` judge returns MANY of. Both
95
95
  * of them: `finding` is the stored record (the native reviewer's contract,
96
- * .claude/agents/reviewer.md), `finding-evidence` is the judge's half of it —
96
+ * .claude/agents/bs-reviewer.md), `finding-evidence` is the judge's half of it —
97
97
  * the same evidence without the six identity fields the orchestrator owns.
98
98
  * A set, not a comparison, because the second entry was added a year after the
99
99
  * first and the `=== 'finding'` it replaced would have silently validated an
@@ -136,7 +136,7 @@ function describeShape(parsed) {
136
136
  /**
137
137
  * Extract + schema-validate a judge's raw text response. `finding.schema.json`
138
138
  * describes ONE finding, but a `kind: review` judge's contract
139
- * (.claude/agents/reviewer.md) returns an ARRAY of findings — so against an
139
+ * (.claude/agents/bs-reviewer.md) returns an ARRAY of findings — so against an
140
140
  * ARRAY_VALUED_SCHEMAS name the top-level value must be that array, bare or
141
141
  * wrapped in one single-key object (see `unwrapArray`), and validates
142
142
  * element-wise; the value returned is the array itself, unwrapped. A bare
@@ -1,7 +1,7 @@
1
1
  // Provider-agnostic judge contract (architecture §6): "any model that can
2
2
  // honor the I/O contract can serve" — a JudgeRequest is a plain diff+spec+
3
3
  // prior-findings prompt already assembled by the caller from
4
- // .claude/agents/{reviewer,verifier}.md's own contract, plus a schema
4
+ // .claude/agents/bs-{reviewer,verifier}.md's own contract, plus a schema
5
5
  // name the extracted response is validated against. Transports never see
6
6
  // more than `prompt` — the trust boundary (architecture §6 "External
7
7
  // providers judge; they never gain write access... their findings are
@@ -1,14 +1,17 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { collectCommittedChanges } from './claims.js';
2
+ import { randomUUID } from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { claimsOverlap, collectCommittedChanges, touchesSerializeAlways } from './claims.js';
3
5
  import { certifyCommit, UNCOMMITTED_WORK_CODE } from './commit.js';
4
6
  import { SmithError } from './errors.js';
5
- import { runGit } from './git.js';
7
+ import { validateEventEnvelope } from './events.js';
8
+ import { runGit, runGitRaw, writeMergeTree } from './git.js';
6
9
  import { topoSort } from './graph.js';
7
10
  import { buildSymbolGraph, collectSources } from './symbols.js';
8
11
  import { emitTaskBlocked, emitWaveMerged } from './taskEvents.js';
9
12
  import { projectCommandEnv } from './testgate.js';
10
13
  import { renderSelectedTestCmd, selectTests } from './testSelect.js';
11
- import { integrationBranchName } from './worktree.js';
14
+ import { epicBranchPrefix, integrationBranchName } from './worktree.js';
12
15
  export class QueueError extends SmithError {
13
16
  }
14
17
  /**
@@ -23,6 +26,67 @@ export function admit(tasks, edges = []) {
23
26
  }
24
27
  return result.order;
25
28
  }
29
+ /**
30
+ * Split an admitted order into maximal runs of consecutive tasks a batch
31
+ * step can stack into one candidate and test once (roadmap `merge-lanes`;
32
+ * the batching idea is Zuul's shared-queue gating and Mergify's batch/bisect,
33
+ * https://zuul-ci.org/docs/zuul/latest/gating.html,
34
+ * https://docs.mergify.com/merge-queue/batches/).
35
+ *
36
+ * Greedy, left to right, one open group at a time: a task joins the open
37
+ * group when its claims are disjoint from every member already in it and it
38
+ * does not depend_on one of them; otherwise the open group closes and the
39
+ * task starts the next one. Three conditions never even get a chance to
40
+ * join a group — they close whatever is open and are appended as a group of
41
+ * one, closed immediately, so a task after them starts fresh rather than
42
+ * silently inheriting their neighbours:
43
+ * - no claims at all (nothing to prove disjoint, so nothing to batch with);
44
+ * - a claim a serialize-always glob covers (worktree.yml already refuses to
45
+ * run these concurrently; batching would silently defeat that refusal);
46
+ * - depends_on a task that is anywhere in the walk before it — conservative
47
+ * on purpose: batching a dependency chain has not been proven safe here,
48
+ * even though the chain would still test together inside one group.
49
+ */
50
+ export function groupForBatch(tasks, edges = [], serializeAlwaysGlobs = []) {
51
+ const dependsOn = new Map();
52
+ for (const edge of edges) {
53
+ if (!dependsOn.has(edge.task))
54
+ dependsOn.set(edge.task, new Set());
55
+ dependsOn.get(edge.task).add(edge.dependsOn);
56
+ }
57
+ const byId = new Map(tasks.map((t) => [t.taskId, t]));
58
+ const groups = [];
59
+ let current = [];
60
+ for (const task of tasks) {
61
+ const claims = task.claims ?? [];
62
+ const hasClaims = claims.length > 0;
63
+ const isSerializeAlways = hasClaims && touchesSerializeAlways({ claims }, serializeAlwaysGlobs).length > 0;
64
+ if (!hasClaims || isSerializeAlways) {
65
+ if (current.length > 0)
66
+ groups.push(current);
67
+ groups.push([task.taskId]);
68
+ current = [];
69
+ continue;
70
+ }
71
+ const deps = dependsOn.get(task.taskId);
72
+ const dependsOnGroupMember = deps !== undefined && current.some((id) => deps.has(id));
73
+ const overlapsGroupMember = current.some((id) => {
74
+ const member = byId.get(id);
75
+ const memberClaims = member?.claims ?? [];
76
+ return (memberClaims.length > 0 && claimsOverlap({ claims }, { claims: memberClaims }).overlaps);
77
+ });
78
+ if (dependsOnGroupMember || overlapsGroupMember) {
79
+ groups.push(current);
80
+ current = [task.taskId];
81
+ }
82
+ else {
83
+ current.push(task.taskId);
84
+ }
85
+ }
86
+ if (current.length > 0)
87
+ groups.push(current);
88
+ return groups;
89
+ }
26
90
  const OUTPUT_TAIL_LINES = 50;
27
91
  function tailLines(text, n) {
28
92
  const lines = text.split('\n');
@@ -36,34 +100,21 @@ function conflictingFiles(worktreeDir) {
36
100
  .filter((f) => f.length > 0);
37
101
  }
38
102
  /**
39
- * Serial merge-queue admission step for one task: rebase its branch onto the
40
- * current epic integration head, run the epic's test command, then
41
- * merge --no-ff into the integration branch. Never auto-resolves a conflict
42
- * and never touches main — the caller decides what happens next on a
43
- * non-merged outcome.
103
+ * Certify there is a commit to merge (D-30/P9-8) and rebase the task's
104
+ * branch onto the current integration head. Returns the blocked outcome when
105
+ * either refuses, or null when the branch is ready to test — shared by
106
+ * `step` and the batch path (`runGroup`) so a task that cannot even be
107
+ * rebased fails exactly the same way whether it is running alone or as part
108
+ * of a group.
44
109
  */
45
- export async function step(task, opts) {
46
- const integrationBranch = integrationBranchName(opts.epic);
47
- const events = opts.events;
48
- /**
49
- * Log the outcome the queue just observed — a refusal it has already decided
50
- * on, or a failure it has already seen. Never a prediction about work still
51
- * ahead of it: an event written before the merge that the merge then
52
- * falsifies is exactly the phantom row D-46 is about.
53
- */
54
- const logBlocked = async (error, detail) => {
55
- if (!events)
56
- return;
57
- const { ctx, ...opt } = events;
58
- await emitTaskBlocked(task.taskId, { error, severity: 'S2-major', detail }, ctx, opt);
59
- };
110
+ async function certifyAndRebase(task, integrationBranch, logBlocked) {
60
111
  // Before the rebase, because a rebase, a test run and a merge all "succeed"
61
112
  // against a branch that carries nothing: certify that there is a commit to
62
113
  // merge at all (D-30/P9-8). A task that merges nothing is a bug, not a pass.
63
114
  const commit = certifyCommit(task.worktreeDir, { baseRef: integrationBranch });
64
115
  if (!commit.certified) {
65
116
  const reason = commit.reason;
66
- await logBlocked(reason === 'not-a-git-worktree' ? 'execution.env-failure' : UNCOMMITTED_WORK_CODE, commit.dirty.length > 0
117
+ await logBlocked(task.taskId, reason === 'not-a-git-worktree' ? 'execution.env-failure' : UNCOMMITTED_WORK_CODE, commit.dirty.length > 0
67
118
  ? `${task.branch} has uncommitted work: ${commit.dirty.join(', ')}`
68
119
  : `${task.branch} has nothing to merge into ${integrationBranch} (${reason}).`);
69
120
  return { outcome: 'nothing-to-merge', taskId: task.taskId, reason, dirty: commit.dirty };
@@ -77,14 +128,50 @@ export async function step(task, opts) {
77
128
  catch {
78
129
  const files = conflictingFiles(task.worktreeDir);
79
130
  execFileSync('git', ['rebase', '--abort'], { cwd: task.worktreeDir, stdio: 'pipe' });
80
- await logBlocked('integration.merge-conflict-textual', `Rebase onto ${integrationBranch} conflicted in: ${files.join(', ')}`);
131
+ await logBlocked(task.taskId, 'integration.merge-conflict-textual', `Rebase onto ${integrationBranch} conflicted in: ${files.join(', ')}`);
81
132
  return { outcome: 'rebase-conflict', taskId: task.taskId, conflictingFiles: files };
82
133
  }
134
+ return null;
135
+ }
136
+ /**
137
+ * Serial merge-queue admission step for one task: rebase its branch onto the
138
+ * current epic integration head, run the epic's test command, then
139
+ * merge --no-ff into the integration branch. Never auto-resolves a conflict
140
+ * and never touches main — the caller decides what happens next on a
141
+ * non-merged outcome.
142
+ */
143
+ export async function step(task, opts) {
144
+ const integrationBranch = integrationBranchName(opts.epic, epicBranchPrefix(opts.projectDir, opts.epic));
145
+ const events = opts.events;
146
+ // Refused before certifyAndRebase's real `git rebase` or the merge below
147
+ // ever runs: both mutate git for real, and used to be discovered only by
148
+ // the append at the far end (emitWaveMerged/emitTaskBlocked). A merge that
149
+ // already landed by the time that append throws is a merge the log cannot
150
+ // see — a real incident, not a hypothetical one.
151
+ if (events) {
152
+ const { ctx, ...opt } = events;
153
+ await validateEventEnvelope({ session_id: ctx.sessionId, event_type: 'wave-merged', causal_parent: ctx.causalParent }, opt);
154
+ }
155
+ /**
156
+ * Log the outcome the queue just observed — a refusal it has already decided
157
+ * on, or a failure it has already seen. Never a prediction about work still
158
+ * ahead of it: an event written before the merge that the merge then
159
+ * falsifies is exactly the phantom row D-46 is about.
160
+ */
161
+ const logBlocked = async (taskId, error, detail) => {
162
+ if (!events)
163
+ return;
164
+ const { ctx, ...opt } = events;
165
+ await emitTaskBlocked(taskId, { error, severity: 'S2-major', detail }, ctx, opt);
166
+ };
167
+ const early = await certifyAndRebase(task, integrationBranch, logBlocked);
168
+ if (early)
169
+ return early;
83
170
  const plan = planTestRun(task.worktreeDir, opts);
84
171
  const testOutcome = runTestCmd(plan.cmd, task.worktreeDir);
85
172
  if (!testOutcome.passed) {
86
173
  const outputTail = tailLines(testOutcome.output, OUTPUT_TAIL_LINES);
87
- await logBlocked('execution.test-failure', outputTail);
174
+ await logBlocked(task.taskId, 'execution.test-failure', outputTail);
88
175
  return {
89
176
  outcome: 'tests-failed',
90
177
  taskId: task.taskId,
@@ -92,14 +179,368 @@ export async function step(task, opts) {
92
179
  ...(plan.report ? { tests: plan.report } : {}),
93
180
  };
94
181
  }
95
- execFileSync('git', ['checkout', integrationBranch], { cwd: opts.projectDir, stdio: 'pipe' });
96
- execFileSync('git', ['merge', '--no-ff', task.branch, '-m', `Merge ${task.taskId} into ${integrationBranch}`], { cwd: opts.projectDir, stdio: 'pipe' });
182
+ // Merge where the integration branch already lives, or with no working tree
183
+ // at all — never by `git checkout` in the project directory, which moved
184
+ // the operator's own clone off the branch they were using.
185
+ const message = `Merge ${task.taskId} into ${integrationBranch}`;
186
+ const holder = worktreeHolding(opts.projectDir, integrationBranch);
187
+ if (holder === null) {
188
+ mergeWithoutWorktree(opts.projectDir, integrationBranch, task.branch, message);
189
+ }
190
+ else {
191
+ // A merge into a worktree with edits in it either stops half-way or folds
192
+ // someone's unfinished work into the merge's checkout; neither is ours.
193
+ const dirty = trackedChanges(holder);
194
+ if (dirty.length > 0) {
195
+ await logBlocked(task.taskId, 'execution.env-failure', `${integrationBranch} is checked out in ${holder}, which has uncommitted changes: ${dirty.join(', ')}`);
196
+ return {
197
+ outcome: 'integration-dirty',
198
+ taskId: task.taskId,
199
+ worktree: holder,
200
+ dirty,
201
+ ...(plan.report ? { tests: plan.report } : {}),
202
+ };
203
+ }
204
+ execFileSync('git', ['merge', '--no-ff', task.branch, '-m', message], {
205
+ cwd: holder,
206
+ stdio: 'pipe',
207
+ });
208
+ }
97
209
  if (events) {
98
210
  const { ctx, ...opt } = events;
99
- await emitWaveMerged(task.taskId, ctx, opt, mergedFiles(opts.projectDir));
211
+ await emitWaveMerged(task.taskId, ctx, opt, mergedFiles(opts.projectDir, integrationBranch));
100
212
  }
101
213
  return { outcome: 'merged', taskId: task.taskId, ...(plan.report ? { tests: plan.report } : {}) };
102
214
  }
215
+ /**
216
+ * The worktree (the main one or any linked one) that has `branch` checked
217
+ * out, or null when none does. git refuses to check a branch out in two
218
+ * worktrees at once, so there is at most one. An entry git marks `prunable`
219
+ * — its directory is gone — holds nothing: there is no checkout to merge in,
220
+ * and moving the branch under it harms nothing. It is not pruned here either;
221
+ * the operator's worktree registrations are theirs (architecture §18 rule 10).
222
+ */
223
+ function worktreeHolding(projectDir, branch) {
224
+ const ref = `branch refs/heads/${branch}`;
225
+ const records = runGitRaw(projectDir, ['worktree', 'list', '--porcelain', '-z']).split('\0\0');
226
+ for (const record of records) {
227
+ const lines = record.split('\0');
228
+ const dir = lines.find((l) => l.startsWith('worktree '));
229
+ if (dir === undefined || !lines.includes(ref))
230
+ continue;
231
+ if (lines.some((l) => l === 'prunable' || l.startsWith('prunable ')))
232
+ continue;
233
+ return dir.slice('worktree '.length);
234
+ }
235
+ return null;
236
+ }
237
+ /** Tracked paths with staged or unstaged changes. Untracked files never block a merge. */
238
+ function trackedChanges(worktreeDir) {
239
+ const fields = runGitRaw(worktreeDir, [
240
+ 'status',
241
+ '--porcelain=v1',
242
+ '-z',
243
+ '--untracked-files=no',
244
+ ]).split('\0');
245
+ const paths = [];
246
+ for (let i = 0; i < fields.length; i++) {
247
+ const field = fields[i];
248
+ if (field.length < 4)
249
+ continue;
250
+ paths.push(field.slice(3));
251
+ // A rename or copy carries its source path as the next NUL field.
252
+ if (field[0] === 'R' || field[0] === 'C')
253
+ i++;
254
+ }
255
+ return paths;
256
+ }
257
+ /**
258
+ * Merge `source` into `branch` with plumbing alone, for when no worktree has
259
+ * `branch` checked out: `merge-tree --write-tree` builds the merged tree,
260
+ * `commit-tree` makes the two-parent merge commit (author and committer from
261
+ * git config, as `git merge` would), and `update-ref` moves `branch` only if
262
+ * it still points where the merge started.
263
+ *
264
+ * No working tree means no commit hook runs — deliberately: the queue is
265
+ * plumbing, and the gate runs the suite. It also means nothing is added to,
266
+ * removed from or pruned out of the repository's worktree list.
267
+ *
268
+ * A conflict throws, and so does a `branch` that moved during the merge;
269
+ * either way `branch` is left where it was.
270
+ */
271
+ /** `merge-tree --write-tree` + `commit-tree`, the two-parent merge commit
272
+ * alone — no ref touched. Shared by `mergeWithoutWorktree` (one task, lands
273
+ * immediately) and `attemptCandidate` (N tasks, chained, lands only once at
274
+ * the end). */
275
+ function buildMergeCommit(projectDir, base, head, message) {
276
+ const tree = writeMergeTree(projectDir, base, head);
277
+ return runGit(projectDir, ['commit-tree', tree, '-p', base, '-p', head, '-m', message]);
278
+ }
279
+ function mergeWithoutWorktree(projectDir, branch, source, message) {
280
+ const base = runGit(projectDir, ['rev-parse', '--verify', `refs/heads/${branch}^{commit}`]);
281
+ const head = runGit(projectDir, ['rev-parse', '--verify', `${source}^{commit}`]);
282
+ const commit = buildMergeCommit(projectDir, base, head, message);
283
+ runGit(projectDir, ['update-ref', '-m', message, `refs/heads/${branch}`, commit, base]);
284
+ }
285
+ function landCandidate(projectDir, integrationBranch, base, candidate, message) {
286
+ const holder = worktreeHolding(projectDir, integrationBranch);
287
+ if (holder === null) {
288
+ try {
289
+ runGit(projectDir, [
290
+ 'update-ref',
291
+ '-m',
292
+ message,
293
+ `refs/heads/${integrationBranch}`,
294
+ candidate,
295
+ base,
296
+ ]);
297
+ }
298
+ catch {
299
+ return { outcome: 'integration-moved' };
300
+ }
301
+ return { outcome: 'landed' };
302
+ }
303
+ const dirty = trackedChanges(holder);
304
+ if (dirty.length > 0) {
305
+ return { outcome: 'integration-dirty', worktree: holder, dirty };
306
+ }
307
+ try {
308
+ execFileSync('git', ['merge', '--ff-only', candidate], { cwd: holder, stdio: 'pipe' });
309
+ }
310
+ catch {
311
+ return { outcome: 'integration-moved' };
312
+ }
313
+ return { outcome: 'landed' };
314
+ }
315
+ /**
316
+ * Report `tasks` as blocked by an `integration-dirty` refusal already
317
+ * observed elsewhere — the dirty holder's own outcome (`attemptCandidate`'s
318
+ * landing) or a sibling half's (`bisectGroup`, once one half has found
319
+ * nothing can land). Shared so every task the batch touches gets the same
320
+ * outcome shape and log line regardless of which call site saw the dirty
321
+ * worktree first.
322
+ */
323
+ async function dirtyOutcomes(tasks, landing, integrationBranch, logBlocked) {
324
+ const outcomes = [];
325
+ for (const task of tasks) {
326
+ await logBlocked(task.taskId, 'execution.env-failure', `${integrationBranch} is checked out in ${landing.worktree}, which has uncommitted changes: ${landing.dirty.join(', ')}`);
327
+ outcomes.push({
328
+ outcome: 'integration-dirty',
329
+ taskId: task.taskId,
330
+ worktree: landing.worktree,
331
+ dirty: landing.dirty,
332
+ });
333
+ }
334
+ return outcomes;
335
+ }
336
+ /**
337
+ * A scratch worktree for testing a batch candidate before anything lands —
338
+ * a sibling of the project, the same convention `taskWorktreeDir` uses, but
339
+ * named so it can never collide with a real task id or the reserved
340
+ * `integration` one: nothing outside this function should ever address it.
341
+ */
342
+ function batchCandidateWorktreeDir(projectDir) {
343
+ const project = path.resolve(projectDir);
344
+ return path.join(path.dirname(project), '.wt', path.basename(project), `batch-${randomUUID()}`);
345
+ }
346
+ /**
347
+ * Fold `readyTasks` onto the current integration head with git plumbing
348
+ * alone (no worktree, no branch touched until the very end), test the result
349
+ * once in a throwaway detached worktree, and either land it with one
350
+ * compare-and-swap or bisect it. `readyTasks` have already been certified and
351
+ * rebased by the caller (`runGroup`) — this only builds, tests and lands.
352
+ */
353
+ async function attemptCandidate(readyTasks, opts, integrationBranch, logBlocked,
354
+ /**
355
+ * Set by the caller when it already knows this exact set is guilty of a
356
+ * red parent — the sibling half of a bisection whose other half landed
357
+ * clean. Skips the group's own suite run for 2+ tasks (there is nothing
358
+ * left to learn from testing a candidate already known red) and goes
359
+ * straight to splitting it further. A single task still runs, because its
360
+ * own failure output tail is the thing the caller reports.
361
+ */
362
+ knownRed = false) {
363
+ if (knownRed && readyTasks.length >= 2) {
364
+ return bisectGroup(readyTasks, opts, integrationBranch, logBlocked);
365
+ }
366
+ const projectDir = opts.projectDir;
367
+ const base = runGit(projectDir, [
368
+ 'rev-parse',
369
+ '--verify',
370
+ `refs/heads/${integrationBranch}^{commit}`,
371
+ ]);
372
+ // Chain each task's merge onto the last, exactly like `mergeWithoutWorktree`
373
+ // does for one task — `merge-tree` computes its own merge-base from the two
374
+ // commits it is given, and since every task was rebased onto `base` (not
375
+ // onto each other), that merge-base is `base` at every step. Claim-disjoint
376
+ // tasks (groupForBatch's contract) fold without conflict; a fold that does
377
+ // conflict anyway throws, same as a single-task merge conflict would.
378
+ let head = base;
379
+ const foldCommits = new Map();
380
+ for (const task of readyTasks) {
381
+ const source = runGit(projectDir, ['rev-parse', '--verify', `${task.branch}^{commit}`]);
382
+ const message = `Merge ${task.taskId} into ${integrationBranch}`;
383
+ head = buildMergeCommit(projectDir, head, source, message);
384
+ foldCommits.set(task.taskId, head);
385
+ }
386
+ const candidate = head;
387
+ const worktreeDir = batchCandidateWorktreeDir(projectDir);
388
+ runGit(projectDir, ['worktree', 'add', '--detach', worktreeDir, candidate]);
389
+ let testOutcome;
390
+ try {
391
+ testOutcome = runTestCmd(opts.testCmd, worktreeDir);
392
+ }
393
+ finally {
394
+ // Always removed, including on error — this worktree exists only to run
395
+ // one suite once and has no meaning once that suite has an answer.
396
+ runGit(projectDir, ['worktree', 'remove', '--force', worktreeDir]);
397
+ }
398
+ if (testOutcome.passed) {
399
+ const message = `Batch-merge ${readyTasks.map((t) => t.taskId).join(', ')} into ${integrationBranch}`;
400
+ const landing = landCandidate(projectDir, integrationBranch, base, candidate, message);
401
+ if (landing.outcome === 'integration-dirty') {
402
+ // Mirrors `step`'s own refusal: a worktree holds the integration
403
+ // branch and carries uncommitted tracked changes, so merging into it
404
+ // either stops half-way or folds someone's unfinished work in. The
405
+ // tests already ran and passed; nothing landed.
406
+ const outcomes = await dirtyOutcomes(readyTasks, landing, integrationBranch, logBlocked);
407
+ return { outcomes, suiteRuns: 1 };
408
+ }
409
+ if (landing.outcome === 'integration-moved') {
410
+ // The ref moved under the batch between the candidate's build and its
411
+ // land — D-46's compare-and-swap race, the same one a single task's
412
+ // `mergeWithoutWorktree` throws on, just reachable here with a whole
413
+ // group under test at once. Nothing landed, so every ready task
414
+ // reports it rather than one throw swallowing the rest of the group.
415
+ const outcomes = [];
416
+ for (const task of readyTasks) {
417
+ await logBlocked(task.taskId, 'execution.env-failure', `${integrationBranch} moved to a different commit while this batch was testing; nothing landed.`);
418
+ outcomes.push({ outcome: 'integration-moved', taskId: task.taskId });
419
+ }
420
+ return { outcomes, suiteRuns: 1 };
421
+ }
422
+ const outcomes = [];
423
+ for (const task of readyTasks) {
424
+ const commit = foldCommits.get(task.taskId);
425
+ if (opts.events) {
426
+ const { ctx, ...opt } = opts.events;
427
+ // This task's own merge commit against its own first parent — not
428
+ // the integration tip, which by now carries every task in the batch.
429
+ await emitWaveMerged(task.taskId, ctx, opt, mergedFiles(projectDir, commit));
430
+ }
431
+ outcomes.push({ outcome: 'merged', taskId: task.taskId });
432
+ }
433
+ return { outcomes, suiteRuns: 1 };
434
+ }
435
+ // Red. A lone task's failure is a genuine test failure, reported exactly as
436
+ // `step` reports one. Two or more: bisect — Zuul's shared-queue gating and
437
+ // Mergify's batch/bisect (cited at groupForBatch) — rather than fail every
438
+ // task in the group for one task's sake: split, recurse on the left half,
439
+ // then recurse on the right half. `runGroup` re-certifies and re-rebases
440
+ // each half against whatever the integration head is when its turn comes,
441
+ // which is what makes "land the innocent half, then retest the guilty one
442
+ // rebuilt on the new head" and "the left half was itself red, so it
443
+ // recursed, and only then did the right half get its own turn" the same
444
+ // code path: the right half's `runGroup` call sees the left half's result
445
+ // simply because it starts after the left half's `await` returns.
446
+ if (readyTasks.length === 1) {
447
+ const task = readyTasks[0];
448
+ const outputTail = tailLines(testOutcome.output, OUTPUT_TAIL_LINES);
449
+ await logBlocked(task.taskId, 'execution.test-failure', outputTail);
450
+ return {
451
+ outcomes: [{ outcome: 'tests-failed', taskId: task.taskId, outputTail }],
452
+ suiteRuns: 1,
453
+ };
454
+ }
455
+ const bisected = await bisectGroup(readyTasks, opts, integrationBranch, logBlocked);
456
+ return { outcomes: bisected.outcomes, suiteRuns: 1 + bisected.suiteRuns };
457
+ }
458
+ /**
459
+ * Split a red (or known-red) group in half and recurse on each — the shared
460
+ * tail of `attemptCandidate`'s red path, factored out so a known-red group
461
+ * can jump straight here without spending a suite run to relearn what the
462
+ * caller already knows. `left` runs with no assumption of its own; when it
463
+ * comes back fully merged, the failure cannot be in it, so `right` is
464
+ * dispatched already known red (`runGroup`, ready.length >= 2 skips its own
465
+ * test the same way).
466
+ */
467
+ async function bisectGroup(readyTasks, opts, integrationBranch, logBlocked) {
468
+ const mid = Math.ceil(readyTasks.length / 2);
469
+ const left = await runGroup(readyTasks.slice(0, mid), opts, integrationBranch, logBlocked);
470
+ // Once anything has hit integration-dirty, nothing can land regardless of
471
+ // what the right half's suite says — the checkout stays dirty until an
472
+ // operator clears it — so report the right half the same way instead of
473
+ // spending a suite run (or more bisection) to learn that.
474
+ const dirty = left.outcomes.find((o) => o.outcome === 'integration-dirty');
475
+ if (dirty) {
476
+ const rightOutcomes = await dirtyOutcomes(readyTasks.slice(mid), dirty, integrationBranch, logBlocked);
477
+ return { outcomes: [...left.outcomes, ...rightOutcomes], suiteRuns: left.suiteRuns };
478
+ }
479
+ const rightKnownRed = left.outcomes.every((o) => o.outcome === 'merged');
480
+ const right = await runGroup(readyTasks.slice(mid), opts, integrationBranch, logBlocked, rightKnownRed);
481
+ return {
482
+ outcomes: [...left.outcomes, ...right.outcomes],
483
+ suiteRuns: left.suiteRuns + right.suiteRuns,
484
+ };
485
+ }
486
+ /**
487
+ * Certify and rebase every task in the group, then fold whatever survives
488
+ * into one candidate via `attemptCandidate` — recursing through
489
+ * `attemptCandidate` on a red candidate. Order-preserving: a task's outcome
490
+ * lands at its own position in the returned array no matter which half of a
491
+ * bisection (or neither — a certify/rebase refusal) produced it, so the
492
+ * caller can print outcomes in admitted order the same way `step` does.
493
+ */
494
+ async function runGroup(tasks, opts, integrationBranch, logBlocked, knownRed = false) {
495
+ if (tasks.length === 0)
496
+ return { outcomes: [], suiteRuns: 0 };
497
+ const byTaskId = new Map();
498
+ const ready = [];
499
+ for (const task of tasks) {
500
+ const early = await certifyAndRebase(task, integrationBranch, logBlocked);
501
+ if (early)
502
+ byTaskId.set(task.taskId, early);
503
+ else
504
+ ready.push(task);
505
+ }
506
+ let suiteRuns = 0;
507
+ if (ready.length > 0) {
508
+ const result = await attemptCandidate(ready, opts, integrationBranch, logBlocked, knownRed);
509
+ suiteRuns = result.suiteRuns;
510
+ for (const outcome of result.outcomes)
511
+ byTaskId.set(outcome.taskId, outcome);
512
+ }
513
+ return { outcomes: tasks.map((t) => byTaskId.get(t.taskId)), suiteRuns };
514
+ }
515
+ /**
516
+ * The batch equivalent of `step`, for a group of two or more claim-disjoint
517
+ * tasks (`groupForBatch` decides the grouping; a group of one should just
518
+ * call `step`). Rebases each task in its own worktree exactly as `step`
519
+ * does, stacks the survivors into one candidate with git plumbing alone (no
520
+ * worktree touches the project checkout, no branch moves until the end), and
521
+ * runs the test command once against it. Green lands the whole candidate
522
+ * with one compare-and-swap; red bisects (see `attemptCandidate`) instead of
523
+ * failing every task in the group for one task's sake.
524
+ */
525
+ export async function batchStep(tasks, opts) {
526
+ const integrationBranch = integrationBranchName(opts.epic, epicBranchPrefix(opts.projectDir, opts.epic));
527
+ const events = opts.events;
528
+ // Same envelope check as step(), and for the same reason: runGroup below
529
+ // rebases and merges real git state for every task in the group before its
530
+ // first append. The causal_parent is one value for the whole batch call, so
531
+ // one check here, before runGroup starts, covers it.
532
+ if (events) {
533
+ const { ctx, ...opt } = events;
534
+ await validateEventEnvelope({ session_id: ctx.sessionId, event_type: 'wave-merged', causal_parent: ctx.causalParent }, opt);
535
+ }
536
+ const logBlocked = async (taskId, error, detail) => {
537
+ if (!events)
538
+ return;
539
+ const { ctx, ...opt } = events;
540
+ await emitTaskBlocked(taskId, { error, severity: 'S2-major', detail }, ctx, opt);
541
+ };
542
+ return runGroup(tasks, opts, integrationBranch, logBlocked);
543
+ }
103
544
  /** The commit `ref` names, or null when it names nothing (or nothing committish). */
104
545
  function resolveCommit(projectDir, ref) {
105
546
  try {
@@ -141,7 +582,7 @@ function isAncestor(projectDir, ancestor, descendant) {
141
582
  */
142
583
  export async function adopt(task, opts) {
143
584
  const { projectDir } = opts;
144
- const integrationBranch = integrationBranchName(opts.epic);
585
+ const integrationBranch = integrationBranchName(opts.epic, epicBranchPrefix(projectDir, opts.epic));
145
586
  for (const ref of [integrationBranch, task.branch]) {
146
587
  if (!resolveCommit(projectDir, ref)) {
147
588
  throw new QueueError('queue.adopt-unknown-branch', `Branch "${ref}" does not exist in ${projectDir} — there is nothing to adopt against.`, { branch: ref, taskId: task.taskId });