@revisium/orchestrator 0.1.0-alpha.8 → 0.1.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 (127) hide show
  1. package/README.md +150 -130
  2. package/control-plane/default-playbook/catalog/pipelines.json +280 -11
  3. package/control-plane/default-playbook/package.json +1 -1
  4. package/control-plane/default-playbook/prompts/developer.md +1 -0
  5. package/control-plane/default-playbook/prompts/triager.md +3 -0
  6. package/dist/api/graphql-api/graphql-ws/subscription-mappers.js +3 -0
  7. package/dist/api/graphql-api/graphql-ws/subscription-mappers.js.map +1 -1
  8. package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js +6 -0
  9. package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js.map +1 -1
  10. package/dist/api/graphql-api/pr/model/pr-readiness.model.js +30 -0
  11. package/dist/api/graphql-api/pr/model/pr-readiness.model.js.map +1 -1
  12. package/dist/api/graphql-api/runs/inputs/create-run.input.js +6 -0
  13. package/dist/api/graphql-api/runs/inputs/create-run.input.js.map +1 -1
  14. package/dist/api/graphql-api/runs/model/run.model.js +6 -0
  15. package/dist/api/graphql-api/runs/model/run.model.js.map +1 -1
  16. package/dist/api/graphql-api/share/model/issue-ref.model.js +53 -0
  17. package/dist/api/graphql-api/share/model/issue-ref.model.js.map +1 -0
  18. package/dist/cli/commands/lifecycle.js +113 -8
  19. package/dist/cli/commands/lifecycle.js.map +1 -1
  20. package/dist/cli/commands/mcp.js +8 -1
  21. package/dist/cli/commands/mcp.js.map +1 -1
  22. package/dist/cli/commands/process-tree.js +42 -0
  23. package/dist/cli/commands/process-tree.js.map +1 -1
  24. package/dist/cli/commands/rogue-reaper.js +63 -0
  25. package/dist/cli/commands/rogue-reaper.js.map +1 -0
  26. package/dist/config.js +9 -0
  27. package/dist/config.js.map +1 -1
  28. package/dist/control-plane/bootstrap.js +14 -1
  29. package/dist/control-plane/bootstrap.js.map +1 -1
  30. package/dist/control-plane/client-transport.js +1 -1
  31. package/dist/control-plane/client-transport.js.map +1 -1
  32. package/dist/control-plane/default-playbook-policy.js +593 -0
  33. package/dist/control-plane/default-playbook-policy.js.map +1 -0
  34. package/dist/control-plane/seed-default-playbook.js +77 -8
  35. package/dist/control-plane/seed-default-playbook.js.map +1 -1
  36. package/dist/e2e/kit/agents.js +15 -21
  37. package/dist/e2e/kit/agents.js.map +1 -1
  38. package/dist/e2e/kit/assertions.js +18 -13
  39. package/dist/e2e/kit/assertions.js.map +1 -1
  40. package/dist/e2e/kit/fake-integrator.js +11 -2
  41. package/dist/e2e/kit/fake-integrator.js.map +1 -1
  42. package/dist/e2e/kit/gh-emulator.js +2 -1
  43. package/dist/e2e/kit/gh-emulator.js.map +1 -1
  44. package/dist/e2e/kit/git-target-repo.js +1 -1
  45. package/dist/engine/dbos.service.js +3 -0
  46. package/dist/engine/dbos.service.js.map +1 -1
  47. package/dist/features/pr/queries/impl/get-pr-readiness.query.js.map +1 -1
  48. package/dist/features/runs/commands/impl/create-run.command.js.map +1 -1
  49. package/dist/features/runs/queries/handlers/runs-query.handlers.js +1 -0
  50. package/dist/features/runs/queries/handlers/runs-query.handlers.js.map +1 -1
  51. package/dist/host/daemon.js +14 -1
  52. package/dist/host/daemon.js.map +1 -1
  53. package/dist/host/host-runtime.js +20 -2
  54. package/dist/host/host-runtime.js.map +1 -1
  55. package/dist/mcp/mcp-capabilities.js +16 -1
  56. package/dist/mcp/mcp-capabilities.js.map +1 -1
  57. package/dist/mcp/mcp-facade.service.js +325 -12
  58. package/dist/mcp/mcp-facade.service.js.map +1 -1
  59. package/dist/mcp/mcp-tools.js +58 -12
  60. package/dist/mcp/mcp-tools.js.map +1 -1
  61. package/dist/observability/activity-signal.js +107 -0
  62. package/dist/observability/activity-signal.js.map +1 -0
  63. package/dist/observability/index.js +1 -0
  64. package/dist/observability/index.js.map +1 -1
  65. package/dist/pipeline/data-driven-task.workflow.js +549 -44
  66. package/dist/pipeline/data-driven-task.workflow.js.map +1 -1
  67. package/dist/pipeline/pipeline.service.js +195 -101
  68. package/dist/pipeline/pipeline.service.js.map +1 -1
  69. package/dist/pipeline/route-contract.js +4 -3
  70. package/dist/pipeline/route-contract.js.map +1 -1
  71. package/dist/pipeline-core/interpret.js +2 -0
  72. package/dist/pipeline-core/interpret.js.map +1 -1
  73. package/dist/pipeline-core/kit/builders.js +2 -0
  74. package/dist/pipeline-core/kit/builders.js.map +1 -1
  75. package/dist/pipeline-core/kit/fixtures.js +72 -21
  76. package/dist/pipeline-core/kit/fixtures.js.map +1 -1
  77. package/dist/pipeline-core/types.js +2 -0
  78. package/dist/pipeline-core/types.js.map +1 -1
  79. package/dist/pipeline-core/validate-dataflow.js +32 -0
  80. package/dist/pipeline-core/validate-dataflow.js.map +1 -1
  81. package/dist/playbook/import-mapper.js +8 -1
  82. package/dist/playbook/import-mapper.js.map +1 -1
  83. package/dist/poller/pr-readiness-core.js +299 -28
  84. package/dist/poller/pr-readiness-core.js.map +1 -1
  85. package/dist/revisium/playbooks.service.js +17 -2
  86. package/dist/revisium/playbooks.service.js.map +1 -1
  87. package/dist/revisium/run.service.js +1 -0
  88. package/dist/revisium/run.service.js.map +1 -1
  89. package/dist/run/append-event.js +8 -5
  90. package/dist/run/append-event.js.map +1 -1
  91. package/dist/run/create-run.js +5 -1
  92. package/dist/run/create-run.js.map +1 -1
  93. package/dist/run/inspect-run.js +30 -4
  94. package/dist/run/inspect-run.js.map +1 -1
  95. package/dist/run/issue-ref.js +88 -0
  96. package/dist/run/issue-ref.js.map +1 -0
  97. package/dist/runners/claude-code.service.js +2 -2
  98. package/dist/runners/claude-code.service.js.map +1 -1
  99. package/dist/runners/codex.service.js +2 -2
  100. package/dist/runners/codex.service.js.map +1 -1
  101. package/dist/runners/integrator-branch-naming.js +33 -13
  102. package/dist/runners/integrator-branch-naming.js.map +1 -1
  103. package/dist/runners/integrator.js +285 -72
  104. package/dist/runners/integrator.js.map +1 -1
  105. package/dist/runners/worktree.service.js +2 -2
  106. package/dist/runners/worktree.service.js.map +1 -1
  107. package/dist/task-control-plane/pr-readiness.service.js +15 -13
  108. package/dist/task-control-plane/pr-readiness.service.js.map +1 -1
  109. package/dist/task-control-plane/run-watch.service.js +512 -0
  110. package/dist/task-control-plane/run-watch.service.js.map +1 -0
  111. package/dist/task-control-plane/task-control-plane-api.service.js +277 -25
  112. package/dist/task-control-plane/task-control-plane-api.service.js.map +1 -1
  113. package/dist/worker/artifact-store.js +21 -3
  114. package/dist/worker/artifact-store.js.map +1 -1
  115. package/dist/worker/build-context.js +19 -3
  116. package/dist/worker/build-context.js.map +1 -1
  117. package/dist/worker/claude-code-runner.js +76 -15
  118. package/dist/worker/claude-code-runner.js.map +1 -1
  119. package/dist/worker/codex-runner.js +40 -13
  120. package/dist/worker/codex-runner.js.map +1 -1
  121. package/dist/worker/process-executor.js +149 -20
  122. package/dist/worker/process-executor.js.map +1 -1
  123. package/dist/worker/runner-common.js +29 -0
  124. package/dist/worker/runner-common.js.map +1 -1
  125. package/dist/worker/runner.js +16 -1
  126. package/dist/worker/runner.js.map +1 -1
  127. package/package.json +1 -1
@@ -18,7 +18,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
18
18
  * Exposes:
19
19
  * - integrate(input, deps) — REAL integrator (live only); git/gh side effects; resumable.
20
20
  * - stubIntegrate(input) — STUB (script only); ZERO external effects; pure + deterministic.
21
- * - preflightLive(taskId, base, deps) — LIVE PREFLIGHT; clean check + base freshness; one-shot.
21
+ * - preflightLive(taskId, base, deps) — LIVE PREFLIGHT; clean check + base availability; one-shot.
22
22
  * - IntegratorService — @Injectable wrapper with bound arrow properties.
23
23
  *
24
24
  * The pure primitives (resolveExecutable, branchName, parseOwnerRepo) and shared types live in
@@ -27,8 +27,9 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
27
27
  */
28
28
  import { Inject, Injectable } from '@nestjs/common';
29
29
  import { execFileSync } from 'node:child_process';
30
- import { collectPrReadiness } from '../poller/pr-readiness-core.js';
30
+ import { collectPrReadiness, fetchRequiredCheckNames, } from '../poller/pr-readiness-core.js';
31
31
  import { RunService } from '../revisium/run.service.js';
32
+ import { issueRefTag } from '../run/issue-ref.js';
32
33
  import { resolveGhAccount, resolvePinnedGh } from './gh-identity.js';
33
34
  import { gitAbsPath, branchExists, countAhead } from './integrator-git.js';
34
35
  import { resolveOwnerRepo } from './integrator-remote.js';
@@ -38,7 +39,23 @@ import { branchName } from './integrator-branch-naming.js';
38
39
  export { resolveExecutable } from './integrator-git.js';
39
40
  export { branchName };
40
41
  export { parseOwnerRepo } from './integrator-remote.js';
41
- function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
42
+ function issueBoundTitle(title, issueRef, ownerRepo) {
43
+ const tag = issueRefTag(issueRef, ownerRepo);
44
+ return tag ? `${tag} ${title}` : title;
45
+ }
46
+ function commitMessage(title, issueRef, ownerRepo) {
47
+ const tag = issueRefTag(issueRef, ownerRepo);
48
+ return tag ? `feat: ${tag} ${title}` : `feat: ${title}`;
49
+ }
50
+ function parsePrList(raw) {
51
+ try {
52
+ return JSON.parse(raw);
53
+ }
54
+ catch {
55
+ throw new Error(`gh pr list returned non-JSON: ${raw.slice(0, 200)}`);
56
+ }
57
+ }
58
+ function matchingOpenPr(ownerRepo, branch, base, execGh, jsonFields) {
42
59
  const raw = execGh([
43
60
  'pr',
44
61
  'list',
@@ -49,21 +66,14 @@ function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
49
66
  '--state',
50
67
  'open',
51
68
  '--json',
52
- 'number,url,baseRefName',
69
+ jsonFields,
53
70
  ]);
54
- let entries;
55
- try {
56
- entries = JSON.parse(raw);
57
- }
58
- catch {
59
- throw new Error(`gh pr list returned non-JSON: ${raw.slice(0, 200)}`);
60
- }
61
- const matching = entries.filter((p) => p.baseRefName === base);
71
+ const matching = parsePrList(raw).filter((p) => p.baseRefName === base);
62
72
  if (matching.length === 1) {
63
73
  const pr = matching[0];
64
74
  if (!pr)
65
75
  throw new Error('unexpected empty match');
66
- return { prUrl: pr.url, prNumber: pr.number };
76
+ return { prUrl: pr.url, prNumber: pr.number, ...(pr.headRefOid ? { headSha: pr.headRefOid } : {}) };
67
77
  }
68
78
  if (matching.length > 1) {
69
79
  const candidates = matching.map((p) => `#${p.number}`).join(', ');
@@ -72,7 +82,9 @@ function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
72
82
  lesson: `Ambiguous: ${matching.length} open PRs for branch "${branch}" targeting "${base}" in ${ownerRepo} — candidates ${candidates} — manual review needed`,
73
83
  };
74
84
  }
75
- // 0 matches — create
85
+ return null;
86
+ }
87
+ function createPr(ownerRepo, branch, base, title, issueRef, execGh) {
76
88
  const createOut = execGh([
77
89
  'pr',
78
90
  'create',
@@ -84,7 +96,7 @@ function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
84
96
  '--head',
85
97
  branch,
86
98
  '--title',
87
- title,
99
+ issueBoundTitle(title, issueRef, ownerRepo),
88
100
  '--body',
89
101
  '',
90
102
  ]);
@@ -115,17 +127,29 @@ function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
115
127
  }
116
128
  return { prUrl: viewData.url, prNumber: viewData.number };
117
129
  }
130
+ function findOrCreatePr(ownerRepo, branch, base, title, issueRef, execGh) {
131
+ const existing = matchingOpenPr(ownerRepo, branch, base, execGh, 'number,url,baseRefName');
132
+ if (existing && !('needsHuman' in existing)) {
133
+ return { prUrl: existing.prUrl, prNumber: existing.prNumber };
134
+ }
135
+ if (existing)
136
+ return existing;
137
+ return createPr(ownerRepo, branch, base, title, issueRef, execGh);
138
+ }
139
+ function findExistingPrWithHead(ownerRepo, branch, base, execGh) {
140
+ return matchingOpenPr(ownerRepo, branch, base, execGh, 'number,url,baseRefName,headRefOid');
141
+ }
118
142
  // ─── Preflight (B5+B7) ────────────────────────────────────────────────────────
119
143
  /**
120
- * preflightLive — clean check + base freshness, evaluated ONCE as a memoized DBOS step.
144
+ * preflightLive — clean check + base availability, evaluated ONCE as a memoized DBOS step.
121
145
  * Only called on live runs; script/stub runs skip this entirely.
122
146
  *
123
147
  * 1. git fetch origin <base> (idempotent — the only mutation)
124
148
  * 2. git status --porcelain → non-empty → block (repo not clean)
125
- * 3. Verify base branch runs are exactly on origin/<base>, while feature branch runs
126
- * are based on origin/<base> mismatch block
149
+ * 3. Verify origin/<base> resolves. Non-base caller branches are allowed because the run worktree
150
+ * is cut from origin/<base>; the caller checkout is never switched or rebased.
127
151
  *
128
- * Returns { ok: true } when the repo is clean and based on fresh origin/<base>.
152
+ * Returns { ok: true } when the caller repo is clean and origin/<base> is available.
129
153
  * Returns IntegratorBlocked on ANY failure — no throw, so DBOS does NOT retry.
130
154
  */
131
155
  export async function preflightLive(taskId, base, deps) {
@@ -150,9 +174,9 @@ export async function preflightLive(taskId, base, deps) {
150
174
  lesson: `target repo ${cwd} is not clean (${lineCount} uncommitted change${lineCount === 1 ? '' : 's'}); commit/stash and retry --live`,
151
175
  };
152
176
  }
153
- // 3. Base freshness:
154
- // - base branch itself must exactly match origin/<base>
155
- // - feature branches are valid when origin/<base> is an ancestor of HEAD
177
+ // 3. Base availability. The isolated run worktree is created later from origin/<base>, so feature
178
+ // branches in the caller checkout do not need to be based on the fresh base. The only caller-branch
179
+ // policy we preserve here is fail-loud protection for local-only commits on the base branch itself.
156
180
  let headBranch;
157
181
  let headSha;
158
182
  let originSha;
@@ -164,27 +188,24 @@ export async function preflightLive(taskId, base, deps) {
164
188
  catch (err) {
165
189
  return {
166
190
  needsHuman: true,
167
- lesson: `live preflight: cannot verify base freshness — ${String(err)}`,
191
+ lesson: `live preflight: cannot resolve origin/${base} after fetch — ${String(err)}`,
168
192
  };
169
193
  }
170
194
  if (headBranch === base && headSha !== originSha) {
171
- return {
172
- needsHuman: true,
173
- lesson: `target repo base branch is not on fresh origin/${base} ` +
174
- `(HEAD=${headBranch}@${headSha.slice(0, 8)}, expected ${base}@${originSha.slice(0, 8)}); ` +
175
- `pull ${base}, then retry --live`,
176
- };
177
- }
178
- if (headBranch !== base) {
195
+ let behind = false;
179
196
  try {
180
- execGit(['merge-base', '--is-ancestor', `origin/${base}`, 'HEAD'], cwd);
197
+ execGit(['merge-base', '--is-ancestor', 'HEAD', `origin/${base}`], cwd);
198
+ behind = true;
181
199
  }
182
200
  catch {
201
+ behind = false; // not an ancestor → diverged (or unrelated)
202
+ }
203
+ if (!behind) {
183
204
  return {
184
205
  needsHuman: true,
185
- lesson: `target repo branch is not based on fresh origin/${base} ` +
186
- `(HEAD=${headBranch}@${headSha.slice(0, 8)}, expected origin/${base}@${originSha.slice(0, 8)} as ancestor); ` +
187
- `rebase or merge origin/${base}, then retry --live`,
206
+ lesson: `target repo base branch ${base} has local-only or diverged commits relative to origin/${base} ` +
207
+ `(HEAD=${headSha.slice(0, 8)} has local commits absent from origin/${base}@${originSha.slice(0, 8)}); ` +
208
+ `reconcile manually, then retry --live`,
188
209
  };
189
210
  }
190
211
  }
@@ -200,8 +221,43 @@ export function stubIntegrate(input) {
200
221
  prUrl: 'stub://pr/placeholder',
201
222
  branch: `feat/${input.taskId}-stub`,
202
223
  prNumber: 0,
224
+ ...(input.issueRef ? { issueRef: input.issueRef } : {}),
225
+ };
226
+ }
227
+ export async function captureProducedChange(input, deps) {
228
+ const { execGit: git, resolveRunCwd } = deps;
229
+ const cwd = await resolveRunCwd(input.runId, input.taskId);
230
+ const branch = branchName(input.taskId, input.title, input.issueRef);
231
+ const ownerRepoResult = resolveOwnerRepo(git, cwd);
232
+ const ownerRepo = 'needsHuman' in ownerRepoResult ? undefined : ownerRepoResult.ownerRepo;
233
+ if (branchExists(git, cwd, branch)) {
234
+ git(['switch', branch], cwd);
235
+ }
236
+ else {
237
+ git(['switch', '-c', branch], cwd);
238
+ }
239
+ git(['add', '-A'], cwd);
240
+ if (stagedDiffPresent(git, cwd)) {
241
+ git(['commit', '-m', commitMessage(input.title, input.issueRef, ownerRepo)], cwd);
242
+ }
243
+ const headSha = git(['rev-parse', 'HEAD'], cwd).trim();
244
+ return {
245
+ branch,
246
+ headSha,
247
+ ...(input.issueRef ? { issueRef: input.issueRef } : {}),
248
+ worktreePath: cwd,
249
+ ...(input.artifactRef ? { artifactRef: input.artifactRef } : {}),
203
250
  };
204
251
  }
252
+ function stagedDiffPresent(git, cwd) {
253
+ try {
254
+ git(['diff', '--cached', '--quiet'], cwd);
255
+ return false;
256
+ }
257
+ catch {
258
+ return true;
259
+ }
260
+ }
205
261
  // ─── REAL integrator (live only) ─────────────────────────────────────────────
206
262
  /**
207
263
  * integrate — REAL integrator (live only).
@@ -209,12 +265,14 @@ export function stubIntegrate(input) {
209
265
  * push + find-or-create PR even if commit already happened (ahead guard).
210
266
  */
211
267
  export async function integrate(input, deps) {
268
+ if (input.change)
269
+ return integrateProducedChange(input, deps, input.change);
212
270
  const { execGit: git, execGh: gh, resolveRunCwd } = deps;
213
271
  // Resolve the run's ISOLATED worktree (plan 0017) — it is already checked out on `branch`, so the
214
272
  // branchExists→switch path below is a no-op and the dirty-tree `switch -c origin/<base>` (which
215
273
  // failed when the base checkout carried prior-run changes) is never taken.
216
274
  const cwd = await resolveRunCwd(input.runId, input.taskId);
217
- const branch = branchName(input.taskId, input.title);
275
+ const branch = branchName(input.taskId, input.title, input.issueRef);
218
276
  // 1. Derive owner/repo
219
277
  const ownerRepoResult = resolveOwnerRepo(git, cwd);
220
278
  if ('needsHuman' in ownerRepoResult)
@@ -232,38 +290,97 @@ export async function integrate(input, deps) {
232
290
  // 4. Stage all changes (safe: clean-repo precondition from preflight)
233
291
  git(['add', '-A'], cwd);
234
292
  // 5. Commit decision (B4 replay safety)
235
- let hasStagedDiff;
236
- try {
237
- git(['diff', '--cached', '--quiet'], cwd);
238
- hasStagedDiff = false; // exit 0 → nothing staged
239
- }
240
- catch {
241
- hasStagedDiff = true; // exit 1 → staged diff present
242
- }
243
- if (hasStagedDiff) {
293
+ if (stagedDiffPresent(git, cwd)) {
244
294
  // Commit — NO Co-Authored-By, NO summary footer (MEMORY)
245
- const commitMsg = `feat: ${input.title}`;
295
+ const commitMsg = commitMessage(input.title, input.issueRef, ownerRepo);
246
296
  git(['commit', '-m', commitMsg], cwd);
247
297
  }
248
298
  else {
249
299
  // No staged diff — check if branch is ahead of origin/<base>
250
300
  const ahead = countAhead(git, cwd, branch, input.base);
251
301
  if (ahead === 0) {
252
- // Nothing to integrate no commit, not ahead
253
- return {
254
- needsHuman: true,
255
- lesson: 'nothing to integrate — no staged changes and branch is not ahead of origin/' + input.base,
256
- };
302
+ // Probe the BASE checkout for uncommitted changes: if it is dirty the developer wrote OUTSIDE
303
+ // the run's worktree (the slice-143 / #130 symptom). Only changes the diagnostic lesson; no
304
+ // destructive action, so a rare mis-attribution is still a useful signal.
305
+ let lesson = 'nothing to integrate — no staged changes and branch is not ahead of origin/' + input.base;
306
+ try {
307
+ const baseCwd = await deps.resolveTaskCwd(input.taskId);
308
+ const basePorcelain = git(['status', '--porcelain'], baseCwd).trim();
309
+ if (basePorcelain !== '') {
310
+ lesson =
311
+ `developer produced changes but the run's worktree is empty — they appear to have been ` +
312
+ `written OUTSIDE the worktree (the base checkout ${baseCwd} is dirty); see slice 143. ` +
313
+ `Re-run; the agent must write under its cwd / $REVO_WORKTREE_PATH.`;
314
+ }
315
+ }
316
+ catch {
317
+ // git error on the base — keep the generic lesson
318
+ }
319
+ return { needsHuman: true, lesson };
257
320
  }
258
321
  // Ahead but no staged diff → commit happened on a prior attempt; fall through to push
259
322
  }
260
323
  // 6. Push (idempotent — no force)
261
324
  git(['push', '-u', 'origin', branch], cwd);
262
325
  // 7. Find-or-create PR
263
- const prResult = findOrCreatePr(ownerRepo, branch, input.base, input.title, gh);
326
+ const prResult = findOrCreatePr(ownerRepo, branch, input.base, input.title, input.issueRef, gh);
264
327
  if ('needsHuman' in prResult)
265
328
  return prResult;
266
- return { prUrl: prResult.prUrl, branch, prNumber: prResult.prNumber };
329
+ return { prUrl: prResult.prUrl, branch, prNumber: prResult.prNumber, ...(input.issueRef ? { issueRef: input.issueRef } : {}) };
330
+ }
331
+ async function integrateProducedChange(input, deps, change) {
332
+ const { execGit: git, execGh: gh } = deps;
333
+ const cwd = change.worktreePath ?? await deps.resolveRunCwd(input.runId, input.taskId);
334
+ const branch = change.branch;
335
+ const issueRef = change.issueRef ?? input.issueRef;
336
+ const ownerRepoResult = resolveOwnerRepo(git, cwd);
337
+ if ('needsHuman' in ownerRepoResult)
338
+ return ownerRepoResult;
339
+ const { ownerRepo } = ownerRepoResult;
340
+ git(['fetch', 'origin', input.base], cwd);
341
+ const existing = findExistingPrWithHead(ownerRepo, branch, input.base, gh);
342
+ if (existing && 'needsHuman' in existing)
343
+ return existing;
344
+ if (existing?.headSha === change.headSha) {
345
+ return {
346
+ prUrl: existing.prUrl,
347
+ branch,
348
+ prNumber: existing.prNumber,
349
+ ...(issueRef ? { issueRef } : {}),
350
+ headSha: change.headSha,
351
+ status: 'noop',
352
+ message: 'nothing to integrate — produced head already pushed and equals PR head',
353
+ };
354
+ }
355
+ if (!existing && countAhead(git, cwd, change.headSha, input.base) === 0) {
356
+ return {
357
+ needsHuman: true,
358
+ lesson: `nothing to integrate — produced head ${change.headSha.slice(0, 8)} is not ahead of ` +
359
+ `origin/${input.base} and no open PR exists`,
360
+ };
361
+ }
362
+ git(['push', 'origin', `${change.headSha}:refs/heads/${branch}`], cwd);
363
+ if (existing) {
364
+ return {
365
+ prUrl: existing.prUrl,
366
+ branch,
367
+ prNumber: existing.prNumber,
368
+ ...(issueRef ? { issueRef } : {}),
369
+ headSha: change.headSha,
370
+ status: 'pushed',
371
+ };
372
+ }
373
+ const created = createPr(ownerRepo, branch, input.base, input.title, issueRef, gh);
374
+ if ('needsHuman' in created)
375
+ return created;
376
+ return {
377
+ prUrl: created.prUrl,
378
+ branch,
379
+ prNumber: created.prNumber,
380
+ ...(issueRef ? { issueRef } : {}),
381
+ headSha: change.headSha,
382
+ status: 'pushed',
383
+ };
267
384
  }
268
385
  /**
269
386
  * confirmMerge — REAL (live only). Ensures the run's PR is actually merged before the run reaches its
@@ -271,8 +388,8 @@ export async function integrate(input, deps) {
271
388
  * base — truly disposable). Idempotent + replay-safe:
272
389
  *
273
390
  * 1. `gh pr view` the run's branch. Already `merged` (a human merged it) → succeed.
274
- * 2. Not merged + OPEN + `mergeStateStatus === CLEAN` (CI green, no conflicts) `gh pr merge --squash
275
- * --delete-branch`, then re-view to CONFIRM merged → succeed; otherwise block.
391
+ * 2. Not merged + OPEN + `mergeStateStatus === CLEAN` (CI green, no conflicts) + a fresh readiness head
392
+ * → `gh pr merge --squash --delete-branch --match-head-commit <sha>`, then re-view to CONFIRM merged.
276
393
  * 3. Not mergeable (not OPEN, or not CLEAN — red CI / conflicts / blocked) → block (needsHuman), which
277
394
  * routes to a `blocked` terminal and KEEPS the worktree for rework.
278
395
  *
@@ -282,7 +399,7 @@ export async function integrate(input, deps) {
282
399
  export async function confirmMerge(input, deps) {
283
400
  const { execGit: git, execGh: gh, resolveRunCwd } = deps;
284
401
  const cwd = await resolveRunCwd(input.runId, input.taskId);
285
- const branch = branchName(input.taskId, input.title);
402
+ const branch = branchName(input.taskId, input.title, input.issueRef);
286
403
  const ownerRepoResult = resolveOwnerRepo(git, cwd);
287
404
  if ('needsHuman' in ownerRepoResult)
288
405
  return ownerRepoResult;
@@ -298,7 +415,7 @@ export async function confirmMerge(input, deps) {
298
415
  };
299
416
  const pr = view();
300
417
  if (pr.state === 'MERGED')
301
- return { merged: true, prNumber: pr.number, prUrl: pr.url };
418
+ return { merged: true, prNumber: pr.number, prUrl: pr.url, ...(input.issueRef ? { issueRef: input.issueRef } : {}) };
302
419
  if (pr.state !== 'OPEN') {
303
420
  return { needsHuman: true, lesson: `PR #${pr.number} is ${pr.state} (not OPEN) and not merged — resolve manually` };
304
421
  }
@@ -311,16 +428,33 @@ export async function confirmMerge(input, deps) {
311
428
  `conflicts, or required reviews pending; merge it manually (or fix + re-run) then cleanup`,
312
429
  };
313
430
  }
431
+ const expectedHeadSha = input.mergeReadiness?.headSha.trim();
432
+ if (!expectedHeadSha) {
433
+ return {
434
+ needsHuman: true,
435
+ lesson: `PR #${pr.number} merge requires a fresh merge readiness headSha guard — re-run readiness before approving merge`,
436
+ };
437
+ }
314
438
  // The integrator opens the PR as a DRAFT (human review at the merge gate); `gh pr merge` refuses a
315
439
  // draft, so mark it ready first (the approved merge gate IS that review). Idempotent: a no-op once ready.
316
440
  if (pr.isDraft) {
317
441
  gh(['pr', 'ready', branch, '--repo', ownerRepo]);
318
442
  }
319
443
  // Merge (squash) — idempotent: a replay re-views first (step 1) and short-circuits on state MERGED.
320
- gh(['pr', 'merge', branch, '--repo', ownerRepo, '--squash', '--delete-branch']);
444
+ try {
445
+ gh(['pr', 'merge', branch, '--repo', ownerRepo, '--squash', '--delete-branch', '--match-head-commit', expectedHeadSha]);
446
+ }
447
+ catch (err) {
448
+ const message = err instanceof Error ? err.message : String(err);
449
+ return {
450
+ needsHuman: true,
451
+ lesson: `PR #${pr.number} merge was blocked by the GitHub head guard ` +
452
+ `(expected ${expectedHeadSha}): ${message || 'merge command failed'}`,
453
+ };
454
+ }
321
455
  const after = view();
322
456
  if (after.state === 'MERGED')
323
- return { merged: true, prNumber: after.number, prUrl: after.url };
457
+ return { merged: true, prNumber: after.number, prUrl: after.url, ...(input.issueRef ? { issueRef: input.issueRef } : {}) };
324
458
  return { needsHuman: true, lesson: `PR #${after.number} merge did not take effect (state=${after.state}) — verify manually` };
325
459
  }
326
460
  /** Default polling bounds — pr-readiness parity (20 polls × 30s), overridable via env so the e2e suite
@@ -329,18 +463,39 @@ function envInt(name, fallback) {
329
463
  const n = Number.parseInt(process.env[name] ?? '', 10);
330
464
  return Number.isFinite(n) && n > 0 ? n : fallback;
331
465
  }
332
- function defaultCollect(repo, branch, base, execGh) {
466
+ function defaultCollect(repo, branch, base, execGh, issueRef) {
333
467
  // pollPr classifies by CI checks + unresolved review THREADS only — it does not read the PR comment
334
468
  // feed, so suppress the `api repos/.../{reviews,comments}` calls (`includeComments: false`). Those
335
469
  // extra calls are pure overhead here and the review-thread query already carries the actionable
336
470
  // feedback the loop acts on.
337
- return collectPrReadiness({ repo, headBranch: branch, baseBranch: base, includeReviewThreads: true, includeComments: false }, execGh).then((r) => ({
471
+ return collectPrReadiness({ repo, headBranch: branch, baseBranch: base, issueRef, includeReviewThreads: true, includeComments: false }, execGh).then((r) => ({
338
472
  pr: { number: r.pr.number, headSha: r.pr.headSha },
339
473
  checks: { pending: r.checks.pending, fail: r.checks.fail, list: r.checks.list },
340
474
  reviewThreads: { items: r.reviewThreads.items },
475
+ readinessVerdict: r.verdict,
476
+ nextAction: r.nextAction,
477
+ evidence: r.evidence,
341
478
  }));
342
479
  }
343
480
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
481
+ function ciFailuresFrom(readiness) {
482
+ return readiness.checks.list
483
+ .filter((c) => readiness.checks.fail.includes(c.name))
484
+ .map((c) => ({ name: c.name, conclusion: c.result }));
485
+ }
486
+ function readinessRequiresReview(readiness) {
487
+ return readiness.readinessVerdict === 'needs_human'
488
+ || readiness.readinessVerdict === 'needs_work'
489
+ || readiness.nextAction === 'human_decision'
490
+ || readiness.nextAction === 'reviewer_triage'
491
+ || readiness.nextAction === 'developer_fix';
492
+ }
493
+ function readinessEvidence(readiness) {
494
+ return [
495
+ readiness.readinessVerdict ? `readiness verdict=${readiness.readinessVerdict}` : undefined,
496
+ readiness.nextAction ? `readiness nextAction=${readiness.nextAction}` : undefined,
497
+ ].filter((item) => item !== undefined);
498
+ }
344
499
  /**
345
500
  * pollPr — REAL (live only). Polls `collectPrReadiness` until CI is terminal (no pending checks) or a
346
501
  * timeout, then classifies the feedback by TYPE (plan 0018): unresolved review threads → review_changes;
@@ -351,19 +506,20 @@ export async function pollPr(input, deps) {
351
506
  const { execGit: git, execGh: gh, resolveRunCwd } = deps;
352
507
  const collect = deps.collect ?? defaultCollect;
353
508
  const sleep = deps.sleep ?? defaultSleep;
509
+ const requiredChecks = deps.requiredChecks ?? fetchRequiredCheckNames;
354
510
  // Read env at CALL time (not module load) so the e2e harness can shrink the poll budget regardless
355
511
  // of import order; prod default stays 20 × 30s.
356
512
  const maxPolls = deps.maxPolls ?? envInt('REVO_POLL_PR_MAX_POLLS', 20);
357
513
  const intervalMs = deps.pollIntervalMs ?? envInt('REVO_POLL_PR_INTERVAL_MS', 30_000);
358
514
  const cwd = await resolveRunCwd(input.runId, input.taskId);
359
- const branch = branchName(input.taskId, input.title);
515
+ const branch = branchName(input.taskId, input.title, input.issueRef);
360
516
  const ownerRepoResult = resolveOwnerRepo(git, cwd);
361
517
  if ('needsHuman' in ownerRepoResult)
362
518
  return ownerRepoResult;
363
519
  const { ownerRepo } = ownerRepoResult;
364
520
  let readiness;
365
521
  for (let i = 0; i < maxPolls; i++) {
366
- readiness = await collect(ownerRepo, branch, input.base, gh);
522
+ readiness = await collect(ownerRepo, branch, input.base, gh, input.issueRef);
367
523
  // Terminal once nothing is pending and at least one check has registered (matches pr-readiness).
368
524
  if (readiness.checks.pending.length === 0 && readiness.checks.list.length > 0)
369
525
  break;
@@ -377,21 +533,74 @@ export async function pollPr(input, deps) {
377
533
  lesson: `pollPr timed out after ${maxPolls} polls — CI checks still pending or none registered for ${branch}`,
378
534
  };
379
535
  }
380
- const reviewThreads = readiness.reviewThreads.items.map((t) => ({
536
+ // `settled` is the CI-terminal readiness (defined past the guard); the review-grace loop may refresh it.
537
+ let settled = readiness;
538
+ const initialCiFailures = ciFailuresFrom(settled);
539
+ // CI green → flip the PR draft→ready so CodeRabbit / human reviewers actually engage. They SKIP
540
+ // draft PRs, which silently bypassed the entire review loop (the PR was readied only at confirmMerge,
541
+ // i.e. after the merge gate) — slice 142. Then wait a BOUNDED grace for review threads to surface
542
+ // before declaring clean, so we don't merge-gate a millisecond before CodeRabbit posts. We never
543
+ // BLOCK on a review arriving: the human merge gate is the backstop, so an absent / rate-limited
544
+ // reviewer falls through to it rather than deadlocking the run. CI-red stays draft (don't request
545
+ // review of broken code) and routes to ci_changes for a fix first.
546
+ if (initialCiFailures.length === 0) {
547
+ try {
548
+ gh(['pr', 'ready', branch, '--repo', ownerRepo]);
549
+ }
550
+ catch {
551
+ // already ready / nothing to ready — idempotent best-effort
552
+ }
553
+ const reviewGracePolls = deps.reviewGracePolls ?? envInt('REVO_POLL_PR_REVIEW_GRACE_POLLS', 4);
554
+ for (let i = 0; i < reviewGracePolls && settled.reviewThreads.items.length === 0; i++) {
555
+ await sleep(intervalMs);
556
+ settled = await collect(ownerRepo, branch, input.base, gh, input.issueRef);
557
+ }
558
+ }
559
+ if (settled.checks.pending.length > 0 || settled.checks.list.length === 0) {
560
+ const detail = settled.checks.pending.length > 0
561
+ ? `pending checks: ${settled.checks.pending.join(', ')}`
562
+ : 'no checks registered';
563
+ const evidence = settled.evidence.length > 0 ? ` Evidence: ${settled.evidence.join(' | ')}` : '';
564
+ return {
565
+ needsHuman: true,
566
+ lesson: `pollPr found unsettled readiness after readying ${branch} - ${detail}.${evidence}`,
567
+ };
568
+ }
569
+ const reviewThreads = settled.reviewThreads.items.map((t) => ({
381
570
  threadId: t.id,
382
571
  path: t.path,
383
572
  line: t.line,
384
573
  author: t.author,
385
574
  body: t.body,
386
575
  }));
387
- const ciFailures = readiness.checks.list
388
- .filter((c) => readiness.checks.fail.includes(c.name))
389
- .map((c) => ({ name: c.name, conclusion: c.result }));
390
- // Decision order (plan 0018): review threads first, then CI, else clean.
391
- const verdict = reviewThreads.length > 0 ? 'review_changes' : ciFailures.length > 0 ? 'ci_changes' : 'clean';
576
+ const ciFailures = ciFailuresFrom(settled);
577
+ // Only REQUIRED-check failures drive the `ci_changes` verdict (PR #135 fix): on this repo only
578
+ // "Verify"/"E2E"/"Required checks" gate the merge — Sonar/CodeRabbit are advisory, and a failing
579
+ // advisory check used to wrongly trigger a pointless ciRework loop. `gh pr view` does not carry
580
+ // isRequired, so we ask GitHub directly. ciFailures keeps the FULL failing list for downstream
581
+ // context; the verdict keys off the required-only subset.
582
+ // FAIL-SAFE: if the required set can't be determined (gh error or empty), fall back to counting
583
+ // ALL failures (current behavior) — never silently treat an unknown required-set as clean.
584
+ let ciVerdictFailures = ciFailures;
585
+ if (ciFailures.length > 0 && settled.pr.number !== null) {
586
+ try {
587
+ const required = requiredChecks(ownerRepo, settled.pr.number, gh);
588
+ if (required.size > 0)
589
+ ciVerdictFailures = ciFailures.filter((f) => required.has(f.name));
590
+ }
591
+ catch {
592
+ // gh failed → keep the full failing list (fail-safe: unknown required-set ≠ clean).
593
+ }
594
+ }
595
+ // Decision order (plan 0018): review threads first, then CI (required-only), else clean.
596
+ const verdict = reviewThreads.length > 0 || readinessRequiresReview(settled)
597
+ ? 'review_changes'
598
+ : ciVerdictFailures.length > 0 ? 'ci_changes' : 'clean';
392
599
  return {
393
- prNumber: readiness.pr.number ?? null,
394
- headSha: readiness.pr.headSha,
600
+ prNumber: settled.pr.number ?? null,
601
+ headSha: settled.pr.headSha,
602
+ evidence: [...settled.evidence, ...readinessEvidence(settled), `PR headSha=${settled.pr.headSha}`, `pollPr verdict=${verdict}`],
603
+ ...(input.issueRef ? { issueRef: input.issueRef } : {}),
395
604
  verdict,
396
605
  ciFailures,
397
606
  reviewThreads,
@@ -507,6 +716,10 @@ let IntegratorService = class IntegratorService {
507
716
  runPreflight = (taskId, base) => {
508
717
  return preflightLive(taskId, base, this.deps);
509
718
  };
719
+ /** Capture a change-producing developer step as a git head artifact before reviewer/integrator handoff. */
720
+ runCaptureProducedChange = (input) => {
721
+ return captureProducedChange(input, this.deps);
722
+ };
510
723
  /**
511
724
  * Real pollPr — live path. Same fail-loud pinned-gh handling as runIntegrate (the review/CI poll reads
512
725
  * the PR via the pinned account, never the ambient one). Blocks if the pinned identity is unresolved.
@@ -521,7 +734,7 @@ let IntegratorService = class IntegratorService {
521
734
  };
522
735
  /** Stub pollPr — script path; zero external effects (reports a clean PR so the loop converges to merge). */
523
736
  runPollStub = (_input) => {
524
- return { prNumber: null, headSha: 'stub', verdict: 'clean', ciFailures: [], reviewThreads: [] };
737
+ return { prNumber: null, headSha: 'stub', evidence: ['stub pollPr readiness: clean'], verdict: 'clean', ciFailures: [], reviewThreads: [] };
525
738
  };
526
739
  /**
527
740
  * Real respondThreads — live path. Same fail-loud pinned-gh handling: refuse the ambient account.