@revisium/orchestrator 0.1.0-alpha.9 → 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 (103) hide show
  1. package/README.md +143 -157
  2. package/control-plane/default-playbook/catalog/pipelines.json +217 -8
  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/control-plane/bootstrap.js +14 -1
  19. package/dist/control-plane/bootstrap.js.map +1 -1
  20. package/dist/control-plane/client-transport.js +1 -1
  21. package/dist/control-plane/client-transport.js.map +1 -1
  22. package/dist/control-plane/default-playbook-policy.js +593 -0
  23. package/dist/control-plane/default-playbook-policy.js.map +1 -0
  24. package/dist/control-plane/seed-default-playbook.js +77 -8
  25. package/dist/control-plane/seed-default-playbook.js.map +1 -1
  26. package/dist/e2e/kit/agents.js +15 -21
  27. package/dist/e2e/kit/agents.js.map +1 -1
  28. package/dist/e2e/kit/assertions.js +12 -8
  29. package/dist/e2e/kit/assertions.js.map +1 -1
  30. package/dist/e2e/kit/fake-integrator.js +11 -2
  31. package/dist/e2e/kit/fake-integrator.js.map +1 -1
  32. package/dist/e2e/kit/git-target-repo.js +1 -1
  33. package/dist/engine/dbos.service.js +3 -0
  34. package/dist/engine/dbos.service.js.map +1 -1
  35. package/dist/features/pr/queries/impl/get-pr-readiness.query.js.map +1 -1
  36. package/dist/features/runs/commands/impl/create-run.command.js.map +1 -1
  37. package/dist/features/runs/queries/handlers/runs-query.handlers.js +1 -0
  38. package/dist/features/runs/queries/handlers/runs-query.handlers.js.map +1 -1
  39. package/dist/mcp/mcp-capabilities.js +14 -1
  40. package/dist/mcp/mcp-capabilities.js.map +1 -1
  41. package/dist/mcp/mcp-facade.service.js +306 -11
  42. package/dist/mcp/mcp-facade.service.js.map +1 -1
  43. package/dist/mcp/mcp-tools.js +41 -14
  44. package/dist/mcp/mcp-tools.js.map +1 -1
  45. package/dist/observability/activity-signal.js +107 -0
  46. package/dist/observability/activity-signal.js.map +1 -0
  47. package/dist/observability/index.js +1 -0
  48. package/dist/observability/index.js.map +1 -1
  49. package/dist/pipeline/data-driven-task.workflow.js +482 -40
  50. package/dist/pipeline/data-driven-task.workflow.js.map +1 -1
  51. package/dist/pipeline/pipeline.service.js +195 -101
  52. package/dist/pipeline/pipeline.service.js.map +1 -1
  53. package/dist/pipeline/route-contract.js +4 -3
  54. package/dist/pipeline/route-contract.js.map +1 -1
  55. package/dist/pipeline-core/kit/fixtures.js +72 -21
  56. package/dist/pipeline-core/kit/fixtures.js.map +1 -1
  57. package/dist/playbook/import-mapper.js +8 -1
  58. package/dist/playbook/import-mapper.js.map +1 -1
  59. package/dist/poller/pr-readiness-core.js +299 -28
  60. package/dist/poller/pr-readiness-core.js.map +1 -1
  61. package/dist/revisium/playbooks.service.js +17 -2
  62. package/dist/revisium/playbooks.service.js.map +1 -1
  63. package/dist/revisium/run.service.js +1 -0
  64. package/dist/revisium/run.service.js.map +1 -1
  65. package/dist/run/append-event.js +8 -5
  66. package/dist/run/append-event.js.map +1 -1
  67. package/dist/run/create-run.js +5 -1
  68. package/dist/run/create-run.js.map +1 -1
  69. package/dist/run/inspect-run.js +10 -3
  70. package/dist/run/inspect-run.js.map +1 -1
  71. package/dist/run/issue-ref.js +88 -0
  72. package/dist/run/issue-ref.js.map +1 -0
  73. package/dist/runners/claude-code.service.js +2 -2
  74. package/dist/runners/claude-code.service.js.map +1 -1
  75. package/dist/runners/codex.service.js +2 -2
  76. package/dist/runners/codex.service.js.map +1 -1
  77. package/dist/runners/integrator-branch-naming.js +13 -8
  78. package/dist/runners/integrator-branch-naming.js.map +1 -1
  79. package/dist/runners/integrator.js +254 -89
  80. package/dist/runners/integrator.js.map +1 -1
  81. package/dist/runners/worktree.service.js +2 -2
  82. package/dist/runners/worktree.service.js.map +1 -1
  83. package/dist/task-control-plane/pr-readiness.service.js +15 -13
  84. package/dist/task-control-plane/pr-readiness.service.js.map +1 -1
  85. package/dist/task-control-plane/run-watch.service.js +234 -3
  86. package/dist/task-control-plane/run-watch.service.js.map +1 -1
  87. package/dist/task-control-plane/task-control-plane-api.service.js +272 -25
  88. package/dist/task-control-plane/task-control-plane-api.service.js.map +1 -1
  89. package/dist/worker/artifact-store.js +21 -3
  90. package/dist/worker/artifact-store.js.map +1 -1
  91. package/dist/worker/build-context.js +19 -3
  92. package/dist/worker/build-context.js.map +1 -1
  93. package/dist/worker/claude-code-runner.js +76 -15
  94. package/dist/worker/claude-code-runner.js.map +1 -1
  95. package/dist/worker/codex-runner.js +40 -13
  96. package/dist/worker/codex-runner.js.map +1 -1
  97. package/dist/worker/process-executor.js +149 -20
  98. package/dist/worker/process-executor.js.map +1 -1
  99. package/dist/worker/runner-common.js +29 -0
  100. package/dist/worker/runner-common.js.map +1 -1
  101. package/dist/worker/runner.js +16 -1
  102. package/dist/worker/runner.js.map +1 -1
  103. 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,16 +188,10 @@ 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
- // The clean base branch isn't at origin/<base>. SELF-HEAL instead of dead-ending: a sibling run
172
- // merging advances origin/<base>, and the old hard block forced a manual `git pull` + re-run for
173
- // every other in-flight run (slice 142 / dogfood). The tree is already clean (step 2), so if the
174
- // base is simply BEHIND (HEAD is an ancestor of origin/<base>) we fast-forward it — exactly what
175
- // `git pull --ff-only` does, and harmless for worktree-isolated runs (they re-cut from origin anyway).
176
- // Only a genuinely DIVERGED base (local commits absent from the remote) still blocks for a human.
177
195
  let behind = false;
178
196
  try {
179
197
  execGit(['merge-base', '--is-ancestor', 'HEAD', `origin/${base}`], cwd);
@@ -185,33 +203,11 @@ export async function preflightLive(taskId, base, deps) {
185
203
  if (!behind) {
186
204
  return {
187
205
  needsHuman: true,
188
- lesson: `target repo base branch ${base} has DIVERGED from origin/${base} ` +
206
+ lesson: `target repo base branch ${base} has local-only or diverged commits relative to origin/${base} ` +
189
207
  `(HEAD=${headSha.slice(0, 8)} has local commits absent from origin/${base}@${originSha.slice(0, 8)}); ` +
190
208
  `reconcile manually, then retry --live`,
191
209
  };
192
210
  }
193
- try {
194
- execGit(['merge', '--ff-only', `origin/${base}`], cwd); // clean + ancestor → cannot conflict
195
- }
196
- catch (err) {
197
- return {
198
- needsHuman: true,
199
- lesson: `live preflight: fast-forward of ${base} to origin/${base} failed — ${String(err)}; pull ${base} manually and retry --live`,
200
- };
201
- }
202
- }
203
- if (headBranch !== base) {
204
- try {
205
- execGit(['merge-base', '--is-ancestor', `origin/${base}`, 'HEAD'], cwd);
206
- }
207
- catch {
208
- return {
209
- needsHuman: true,
210
- lesson: `target repo branch is not based on fresh origin/${base} ` +
211
- `(HEAD=${headBranch}@${headSha.slice(0, 8)}, expected origin/${base}@${originSha.slice(0, 8)} as ancestor); ` +
212
- `rebase or merge origin/${base}, then retry --live`,
213
- };
214
- }
215
211
  }
216
212
  return { ok: true };
217
213
  }
@@ -225,8 +221,43 @@ export function stubIntegrate(input) {
225
221
  prUrl: 'stub://pr/placeholder',
226
222
  branch: `feat/${input.taskId}-stub`,
227
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 } : {}),
228
250
  };
229
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
+ }
230
261
  // ─── REAL integrator (live only) ─────────────────────────────────────────────
231
262
  /**
232
263
  * integrate — REAL integrator (live only).
@@ -234,12 +265,14 @@ export function stubIntegrate(input) {
234
265
  * push + find-or-create PR even if commit already happened (ahead guard).
235
266
  */
236
267
  export async function integrate(input, deps) {
268
+ if (input.change)
269
+ return integrateProducedChange(input, deps, input.change);
237
270
  const { execGit: git, execGh: gh, resolveRunCwd } = deps;
238
271
  // Resolve the run's ISOLATED worktree (plan 0017) — it is already checked out on `branch`, so the
239
272
  // branchExists→switch path below is a no-op and the dirty-tree `switch -c origin/<base>` (which
240
273
  // failed when the base checkout carried prior-run changes) is never taken.
241
274
  const cwd = await resolveRunCwd(input.runId, input.taskId);
242
- const branch = branchName(input.taskId, input.title);
275
+ const branch = branchName(input.taskId, input.title, input.issueRef);
243
276
  // 1. Derive owner/repo
244
277
  const ownerRepoResult = resolveOwnerRepo(git, cwd);
245
278
  if ('needsHuman' in ownerRepoResult)
@@ -257,38 +290,97 @@ export async function integrate(input, deps) {
257
290
  // 4. Stage all changes (safe: clean-repo precondition from preflight)
258
291
  git(['add', '-A'], cwd);
259
292
  // 5. Commit decision (B4 replay safety)
260
- let hasStagedDiff;
261
- try {
262
- git(['diff', '--cached', '--quiet'], cwd);
263
- hasStagedDiff = false; // exit 0 → nothing staged
264
- }
265
- catch {
266
- hasStagedDiff = true; // exit 1 → staged diff present
267
- }
268
- if (hasStagedDiff) {
293
+ if (stagedDiffPresent(git, cwd)) {
269
294
  // Commit — NO Co-Authored-By, NO summary footer (MEMORY)
270
- const commitMsg = `feat: ${input.title}`;
295
+ const commitMsg = commitMessage(input.title, input.issueRef, ownerRepo);
271
296
  git(['commit', '-m', commitMsg], cwd);
272
297
  }
273
298
  else {
274
299
  // No staged diff — check if branch is ahead of origin/<base>
275
300
  const ahead = countAhead(git, cwd, branch, input.base);
276
301
  if (ahead === 0) {
277
- // Nothing to integrate no commit, not ahead
278
- return {
279
- needsHuman: true,
280
- lesson: 'nothing to integrate — no staged changes and branch is not ahead of origin/' + input.base,
281
- };
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 };
282
320
  }
283
321
  // Ahead but no staged diff → commit happened on a prior attempt; fall through to push
284
322
  }
285
323
  // 6. Push (idempotent — no force)
286
324
  git(['push', '-u', 'origin', branch], cwd);
287
325
  // 7. Find-or-create PR
288
- const prResult = findOrCreatePr(ownerRepo, branch, input.base, input.title, gh);
326
+ const prResult = findOrCreatePr(ownerRepo, branch, input.base, input.title, input.issueRef, gh);
289
327
  if ('needsHuman' in prResult)
290
328
  return prResult;
291
- 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
+ };
292
384
  }
293
385
  /**
294
386
  * confirmMerge — REAL (live only). Ensures the run's PR is actually merged before the run reaches its
@@ -296,8 +388,8 @@ export async function integrate(input, deps) {
296
388
  * base — truly disposable). Idempotent + replay-safe:
297
389
  *
298
390
  * 1. `gh pr view` the run's branch. Already `merged` (a human merged it) → succeed.
299
- * 2. Not merged + OPEN + `mergeStateStatus === CLEAN` (CI green, no conflicts) `gh pr merge --squash
300
- * --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.
301
393
  * 3. Not mergeable (not OPEN, or not CLEAN — red CI / conflicts / blocked) → block (needsHuman), which
302
394
  * routes to a `blocked` terminal and KEEPS the worktree for rework.
303
395
  *
@@ -307,7 +399,7 @@ export async function integrate(input, deps) {
307
399
  export async function confirmMerge(input, deps) {
308
400
  const { execGit: git, execGh: gh, resolveRunCwd } = deps;
309
401
  const cwd = await resolveRunCwd(input.runId, input.taskId);
310
- const branch = branchName(input.taskId, input.title);
402
+ const branch = branchName(input.taskId, input.title, input.issueRef);
311
403
  const ownerRepoResult = resolveOwnerRepo(git, cwd);
312
404
  if ('needsHuman' in ownerRepoResult)
313
405
  return ownerRepoResult;
@@ -323,7 +415,7 @@ export async function confirmMerge(input, deps) {
323
415
  };
324
416
  const pr = view();
325
417
  if (pr.state === 'MERGED')
326
- return { merged: true, prNumber: pr.number, prUrl: pr.url };
418
+ return { merged: true, prNumber: pr.number, prUrl: pr.url, ...(input.issueRef ? { issueRef: input.issueRef } : {}) };
327
419
  if (pr.state !== 'OPEN') {
328
420
  return { needsHuman: true, lesson: `PR #${pr.number} is ${pr.state} (not OPEN) and not merged — resolve manually` };
329
421
  }
@@ -336,16 +428,33 @@ export async function confirmMerge(input, deps) {
336
428
  `conflicts, or required reviews pending; merge it manually (or fix + re-run) then cleanup`,
337
429
  };
338
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
+ }
339
438
  // The integrator opens the PR as a DRAFT (human review at the merge gate); `gh pr merge` refuses a
340
439
  // draft, so mark it ready first (the approved merge gate IS that review). Idempotent: a no-op once ready.
341
440
  if (pr.isDraft) {
342
441
  gh(['pr', 'ready', branch, '--repo', ownerRepo]);
343
442
  }
344
443
  // Merge (squash) — idempotent: a replay re-views first (step 1) and short-circuits on state MERGED.
345
- 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
+ }
346
455
  const after = view();
347
456
  if (after.state === 'MERGED')
348
- return { merged: true, prNumber: after.number, prUrl: after.url };
457
+ return { merged: true, prNumber: after.number, prUrl: after.url, ...(input.issueRef ? { issueRef: input.issueRef } : {}) };
349
458
  return { needsHuman: true, lesson: `PR #${after.number} merge did not take effect (state=${after.state}) — verify manually` };
350
459
  }
351
460
  /** Default polling bounds — pr-readiness parity (20 polls × 30s), overridable via env so the e2e suite
@@ -354,18 +463,39 @@ function envInt(name, fallback) {
354
463
  const n = Number.parseInt(process.env[name] ?? '', 10);
355
464
  return Number.isFinite(n) && n > 0 ? n : fallback;
356
465
  }
357
- function defaultCollect(repo, branch, base, execGh) {
466
+ function defaultCollect(repo, branch, base, execGh, issueRef) {
358
467
  // pollPr classifies by CI checks + unresolved review THREADS only — it does not read the PR comment
359
468
  // feed, so suppress the `api repos/.../{reviews,comments}` calls (`includeComments: false`). Those
360
469
  // extra calls are pure overhead here and the review-thread query already carries the actionable
361
470
  // feedback the loop acts on.
362
- 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) => ({
363
472
  pr: { number: r.pr.number, headSha: r.pr.headSha },
364
473
  checks: { pending: r.checks.pending, fail: r.checks.fail, list: r.checks.list },
365
474
  reviewThreads: { items: r.reviewThreads.items },
475
+ readinessVerdict: r.verdict,
476
+ nextAction: r.nextAction,
477
+ evidence: r.evidence,
366
478
  }));
367
479
  }
368
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
+ }
369
499
  /**
370
500
  * pollPr — REAL (live only). Polls `collectPrReadiness` until CI is terminal (no pending checks) or a
371
501
  * timeout, then classifies the feedback by TYPE (plan 0018): unresolved review threads → review_changes;
@@ -376,19 +506,20 @@ export async function pollPr(input, deps) {
376
506
  const { execGit: git, execGh: gh, resolveRunCwd } = deps;
377
507
  const collect = deps.collect ?? defaultCollect;
378
508
  const sleep = deps.sleep ?? defaultSleep;
509
+ const requiredChecks = deps.requiredChecks ?? fetchRequiredCheckNames;
379
510
  // Read env at CALL time (not module load) so the e2e harness can shrink the poll budget regardless
380
511
  // of import order; prod default stays 20 × 30s.
381
512
  const maxPolls = deps.maxPolls ?? envInt('REVO_POLL_PR_MAX_POLLS', 20);
382
513
  const intervalMs = deps.pollIntervalMs ?? envInt('REVO_POLL_PR_INTERVAL_MS', 30_000);
383
514
  const cwd = await resolveRunCwd(input.runId, input.taskId);
384
- const branch = branchName(input.taskId, input.title);
515
+ const branch = branchName(input.taskId, input.title, input.issueRef);
385
516
  const ownerRepoResult = resolveOwnerRepo(git, cwd);
386
517
  if ('needsHuman' in ownerRepoResult)
387
518
  return ownerRepoResult;
388
519
  const { ownerRepo } = ownerRepoResult;
389
520
  let readiness;
390
521
  for (let i = 0; i < maxPolls; i++) {
391
- readiness = await collect(ownerRepo, branch, input.base, gh);
522
+ readiness = await collect(ownerRepo, branch, input.base, gh, input.issueRef);
392
523
  // Terminal once nothing is pending and at least one check has registered (matches pr-readiness).
393
524
  if (readiness.checks.pending.length === 0 && readiness.checks.list.length > 0)
394
525
  break;
@@ -404,10 +535,7 @@ export async function pollPr(input, deps) {
404
535
  }
405
536
  // `settled` is the CI-terminal readiness (defined past the guard); the review-grace loop may refresh it.
406
537
  let settled = readiness;
407
- // CI failures come from the CI-terminal snapshot (terminal → won't change under us).
408
- const ciFailures = settled.checks.list
409
- .filter((c) => settled.checks.fail.includes(c.name))
410
- .map((c) => ({ name: c.name, conclusion: c.result }));
538
+ const initialCiFailures = ciFailuresFrom(settled);
411
539
  // CI green → flip the PR draft→ready so CodeRabbit / human reviewers actually engage. They SKIP
412
540
  // draft PRs, which silently bypassed the entire review loop (the PR was readied only at confirmMerge,
413
541
  // i.e. after the merge gate) — slice 142. Then wait a BOUNDED grace for review threads to surface
@@ -415,7 +543,7 @@ export async function pollPr(input, deps) {
415
543
  // BLOCK on a review arriving: the human merge gate is the backstop, so an absent / rate-limited
416
544
  // reviewer falls through to it rather than deadlocking the run. CI-red stays draft (don't request
417
545
  // review of broken code) and routes to ci_changes for a fix first.
418
- if (ciFailures.length === 0) {
546
+ if (initialCiFailures.length === 0) {
419
547
  try {
420
548
  gh(['pr', 'ready', branch, '--repo', ownerRepo]);
421
549
  }
@@ -425,9 +553,19 @@ export async function pollPr(input, deps) {
425
553
  const reviewGracePolls = deps.reviewGracePolls ?? envInt('REVO_POLL_PR_REVIEW_GRACE_POLLS', 4);
426
554
  for (let i = 0; i < reviewGracePolls && settled.reviewThreads.items.length === 0; i++) {
427
555
  await sleep(intervalMs);
428
- settled = await collect(ownerRepo, branch, input.base, gh);
556
+ settled = await collect(ownerRepo, branch, input.base, gh, input.issueRef);
429
557
  }
430
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
+ }
431
569
  const reviewThreads = settled.reviewThreads.items.map((t) => ({
432
570
  threadId: t.id,
433
571
  path: t.path,
@@ -435,11 +573,34 @@ export async function pollPr(input, deps) {
435
573
  author: t.author,
436
574
  body: t.body,
437
575
  }));
438
- // Decision order (plan 0018): review threads first, then CI, else clean.
439
- 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';
440
599
  return {
441
600
  prNumber: settled.pr.number ?? null,
442
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 } : {}),
443
604
  verdict,
444
605
  ciFailures,
445
606
  reviewThreads,
@@ -555,6 +716,10 @@ let IntegratorService = class IntegratorService {
555
716
  runPreflight = (taskId, base) => {
556
717
  return preflightLive(taskId, base, this.deps);
557
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
+ };
558
723
  /**
559
724
  * Real pollPr — live path. Same fail-loud pinned-gh handling as runIntegrate (the review/CI poll reads
560
725
  * the PR via the pinned account, never the ambient one). Blocks if the pinned identity is unresolved.
@@ -569,7 +734,7 @@ let IntegratorService = class IntegratorService {
569
734
  };
570
735
  /** Stub pollPr — script path; zero external effects (reports a clean PR so the loop converges to merge). */
571
736
  runPollStub = (_input) => {
572
- return { prNumber: null, headSha: 'stub', verdict: 'clean', ciFailures: [], reviewThreads: [] };
737
+ return { prNumber: null, headSha: 'stub', evidence: ['stub pollPr readiness: clean'], verdict: 'clean', ciFailures: [], reviewThreads: [] };
573
738
  };
574
739
  /**
575
740
  * Real respondThreads — live path. Same fail-loud pinned-gh handling: refuse the ambient account.