@revisium/orchestrator 0.1.0-alpha.9 → 0.2.0-alpha.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 (249) hide show
  1. package/README.md +210 -146
  2. package/control-plane/bootstrap.config.json +43 -1
  3. package/control-plane/default-playbook/catalog/pipelines.json +1157 -10
  4. package/control-plane/default-playbook/catalog/roles.json +54 -0
  5. package/control-plane/default-playbook/package.json +1 -1
  6. package/control-plane/default-playbook/prompts/developer.md +1 -0
  7. package/control-plane/default-playbook/prompts/triager.md +3 -0
  8. package/dist/api/graphql-api/graphql-ws/pubsub.module.js +0 -1
  9. package/dist/api/graphql-api/graphql-ws/pubsub.module.js.map +1 -1
  10. package/dist/api/graphql-api/graphql-ws/run-progress-subscription-poller.service.js +0 -1
  11. package/dist/api/graphql-api/graphql-ws/run-progress-subscription-poller.service.js.map +1 -1
  12. package/dist/api/graphql-api/graphql-ws/subscription-bridge.service.js +0 -1
  13. package/dist/api/graphql-api/graphql-ws/subscription-bridge.service.js.map +1 -1
  14. package/dist/api/graphql-api/graphql-ws/subscription-mappers.js +3 -0
  15. package/dist/api/graphql-api/graphql-ws/subscription-mappers.js.map +1 -1
  16. package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js +6 -0
  17. package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js.map +1 -1
  18. package/dist/api/graphql-api/pr/model/pr-readiness.model.js +26 -0
  19. package/dist/api/graphql-api/pr/model/pr-readiness.model.js.map +1 -1
  20. package/dist/api/graphql-api/runs/inputs/create-run.input.js +6 -0
  21. package/dist/api/graphql-api/runs/inputs/create-run.input.js.map +1 -1
  22. package/dist/api/graphql-api/runs/model/run.model.js +6 -0
  23. package/dist/api/graphql-api/runs/model/run.model.js.map +1 -1
  24. package/dist/api/graphql-api/share/model/issue-ref.model.js +53 -0
  25. package/dist/api/graphql-api/share/model/issue-ref.model.js.map +1 -0
  26. package/dist/cli/commands/doctor-report.js +0 -18
  27. package/dist/cli/commands/doctor-report.js.map +1 -1
  28. package/dist/cli/commands/lifecycle.js +3 -71
  29. package/dist/cli/commands/lifecycle.js.map +1 -1
  30. package/dist/cli/commands/mcp.js +0 -14
  31. package/dist/cli/commands/mcp.js.map +1 -1
  32. package/dist/cli/commands/process-tree.js +4 -26
  33. package/dist/cli/commands/process-tree.js.map +1 -1
  34. package/dist/cli/commands/revisium-helpers.js +0 -6
  35. package/dist/cli/commands/revisium-helpers.js.map +1 -1
  36. package/dist/cli/commands/rogue-reaper.js +0 -34
  37. package/dist/cli/commands/rogue-reaper.js.map +1 -1
  38. package/dist/cli/index.js +0 -4
  39. package/dist/cli/index.js.map +1 -1
  40. package/dist/cli/program.js +0 -5
  41. package/dist/cli/program.js.map +1 -1
  42. package/dist/config.js +0 -25
  43. package/dist/config.js.map +1 -1
  44. package/dist/control-plane/bootstrap.js +12 -27
  45. package/dist/control-plane/bootstrap.js.map +1 -1
  46. package/dist/control-plane/change-notifications.js +0 -1
  47. package/dist/control-plane/change-notifications.js.map +1 -1
  48. package/dist/control-plane/client-transport.js +1 -12
  49. package/dist/control-plane/client-transport.js.map +1 -1
  50. package/dist/control-plane/default-playbook-policy.js +593 -0
  51. package/dist/control-plane/default-playbook-policy.js.map +1 -0
  52. package/dist/control-plane/definitions.js +8 -17
  53. package/dist/control-plane/definitions.js.map +1 -1
  54. package/dist/control-plane/inbox.js +2 -120
  55. package/dist/control-plane/inbox.js.map +1 -1
  56. package/dist/control-plane/resolve-cwd.js +0 -61
  57. package/dist/control-plane/resolve-cwd.js.map +1 -1
  58. package/dist/control-plane/schema-migration.js +0 -9
  59. package/dist/control-plane/schema-migration.js.map +1 -1
  60. package/dist/control-plane/seed-default-playbook.js +50 -46
  61. package/dist/control-plane/seed-default-playbook.js.map +1 -1
  62. package/dist/control-plane/steps.js +0 -15
  63. package/dist/control-plane/steps.js.map +1 -1
  64. package/dist/e2e/kit/agents.js +15 -21
  65. package/dist/e2e/kit/agents.js.map +1 -1
  66. package/dist/e2e/kit/assertions.js +12 -8
  67. package/dist/e2e/kit/assertions.js.map +1 -1
  68. package/dist/e2e/kit/fake-integrator.js +11 -2
  69. package/dist/e2e/kit/fake-integrator.js.map +1 -1
  70. package/dist/e2e/kit/git-target-repo.js +1 -1
  71. package/dist/engine/dbos.service.js +12 -179
  72. package/dist/engine/dbos.service.js.map +1 -1
  73. package/dist/engine/ensure-postgres.js +0 -48
  74. package/dist/engine/ensure-postgres.js.map +1 -1
  75. package/dist/features/pr/queries/impl/get-pr-readiness.query.js.map +1 -1
  76. package/dist/features/runs/commands/impl/create-run.command.js.map +1 -1
  77. package/dist/features/runs/queries/handlers/runs-query.handlers.js +1 -0
  78. package/dist/features/runs/queries/handlers/runs-query.handlers.js.map +1 -1
  79. package/dist/host/daemon.js +2 -21
  80. package/dist/host/daemon.js.map +1 -1
  81. package/dist/host/dbos-identity.js +0 -31
  82. package/dist/host/dbos-identity.js.map +1 -1
  83. package/dist/host/ensure-host.js +1 -37
  84. package/dist/host/ensure-host.js.map +1 -1
  85. package/dist/host/ensure-revisium.js +1 -135
  86. package/dist/host/ensure-revisium.js.map +1 -1
  87. package/dist/host/host-runtime.js +0 -28
  88. package/dist/host/host-runtime.js.map +1 -1
  89. package/dist/host/host.lifecycle.js +0 -27
  90. package/dist/host/host.lifecycle.js.map +1 -1
  91. package/dist/host/queue-ownership.js +0 -42
  92. package/dist/host/queue-ownership.js.map +1 -1
  93. package/dist/host/queue-poller-census.js +1 -28
  94. package/dist/host/queue-poller-census.js.map +1 -1
  95. package/dist/http/graphql-host.module.js +0 -3
  96. package/dist/http/graphql-host.module.js.map +1 -1
  97. package/dist/mcp/mcp-capabilities.js +23 -5
  98. package/dist/mcp/mcp-capabilities.js.map +1 -1
  99. package/dist/mcp/mcp-facade.service.js +335 -22
  100. package/dist/mcp/mcp-facade.service.js.map +1 -1
  101. package/dist/mcp/mcp-http.service.js +0 -18
  102. package/dist/mcp/mcp-http.service.js.map +1 -1
  103. package/dist/mcp/mcp-tools.js +41 -36
  104. package/dist/mcp/mcp-tools.js.map +1 -1
  105. package/dist/observability/activity-signal.js +103 -0
  106. package/dist/observability/activity-signal.js.map +1 -0
  107. package/dist/observability/agent-activity-reporter.js +0 -4
  108. package/dist/observability/agent-activity-reporter.js.map +1 -1
  109. package/dist/observability/index.js +1 -0
  110. package/dist/observability/index.js.map +1 -1
  111. package/dist/pipeline/await-human.js +0 -40
  112. package/dist/pipeline/await-human.js.map +1 -1
  113. package/dist/pipeline/data-driven-task.workflow.js +563 -251
  114. package/dist/pipeline/data-driven-task.workflow.js.map +1 -1
  115. package/dist/pipeline/data-driven-template.js +0 -25
  116. package/dist/pipeline/data-driven-template.js.map +1 -1
  117. package/dist/pipeline/pipeline.module.js +0 -12
  118. package/dist/pipeline/pipeline.module.js.map +1 -1
  119. package/dist/pipeline/pipeline.service.js +194 -189
  120. package/dist/pipeline/pipeline.service.js.map +1 -1
  121. package/dist/pipeline/route-contract.js +4 -3
  122. package/dist/pipeline/route-contract.js.map +1 -1
  123. package/dist/pipeline-core/index.js +1 -13
  124. package/dist/pipeline-core/index.js.map +1 -1
  125. package/dist/pipeline-core/interpret.js +21 -94
  126. package/dist/pipeline-core/interpret.js.map +1 -1
  127. package/dist/pipeline-core/kit/assertions.js +0 -19
  128. package/dist/pipeline-core/kit/assertions.js.map +1 -1
  129. package/dist/pipeline-core/kit/builders.js +1 -21
  130. package/dist/pipeline-core/kit/builders.js.map +1 -1
  131. package/dist/pipeline-core/kit/drive.js +1 -31
  132. package/dist/pipeline-core/kit/drive.js.map +1 -1
  133. package/dist/pipeline-core/kit/fixtures.js +78 -101
  134. package/dist/pipeline-core/kit/fixtures.js.map +1 -1
  135. package/dist/pipeline-core/kit/index.js +0 -8
  136. package/dist/pipeline-core/kit/index.js.map +1 -1
  137. package/dist/pipeline-core/types.js +13 -52
  138. package/dist/pipeline-core/types.js.map +1 -1
  139. package/dist/pipeline-core/validate-capability.js +0 -3
  140. package/dist/pipeline-core/validate-capability.js.map +1 -1
  141. package/dist/pipeline-core/validate-conflicts.js +0 -9
  142. package/dist/pipeline-core/validate-conflicts.js.map +1 -1
  143. package/dist/pipeline-core/validate-dataflow.js +0 -17
  144. package/dist/pipeline-core/validate-dataflow.js.map +1 -1
  145. package/dist/pipeline-core/validate-diff.js +3 -36
  146. package/dist/pipeline-core/validate-diff.js.map +1 -1
  147. package/dist/pipeline-core/validate-edges.js +0 -6
  148. package/dist/pipeline-core/validate-edges.js.map +1 -1
  149. package/dist/pipeline-core/validate-graph.js +0 -11
  150. package/dist/pipeline-core/validate-graph.js.map +1 -1
  151. package/dist/pipeline-core/validate-loops.js +37 -44
  152. package/dist/pipeline-core/validate-loops.js.map +1 -1
  153. package/dist/pipeline-core/validate-parallel.js +9 -15
  154. package/dist/pipeline-core/validate-parallel.js.map +1 -1
  155. package/dist/pipeline-core/validate-sink.js +0 -7
  156. package/dist/pipeline-core/validate-sink.js.map +1 -1
  157. package/dist/pipeline-core/validate-topology.js +1 -20
  158. package/dist/pipeline-core/validate-topology.js.map +1 -1
  159. package/dist/pipeline-core/validate-verdict.js +52 -7
  160. package/dist/pipeline-core/validate-verdict.js.map +1 -1
  161. package/dist/pipeline-core/validate.js +5 -29
  162. package/dist/pipeline-core/validate.js.map +1 -1
  163. package/dist/playbook/catalog-loader.js +8 -3
  164. package/dist/playbook/catalog-loader.js.map +1 -1
  165. package/dist/playbook/import-mapper.js +4 -5
  166. package/dist/playbook/import-mapper.js.map +1 -1
  167. package/dist/poller/pr-readiness-core.js +226 -29
  168. package/dist/poller/pr-readiness-core.js.map +1 -1
  169. package/dist/revisium/inbox.service.js +0 -24
  170. package/dist/revisium/inbox.service.js.map +1 -1
  171. package/dist/revisium/playbooks.service.js +15 -2
  172. package/dist/revisium/playbooks.service.js.map +1 -1
  173. package/dist/revisium/revisium.module.js +0 -12
  174. package/dist/revisium/revisium.module.js.map +1 -1
  175. package/dist/revisium/roles.service.js +0 -6
  176. package/dist/revisium/roles.service.js.map +1 -1
  177. package/dist/revisium/run.service.js +2 -75
  178. package/dist/revisium/run.service.js.map +1 -1
  179. package/dist/revisium/tokens.js +0 -4
  180. package/dist/revisium/tokens.js.map +1 -1
  181. package/dist/run/append-event.js +6 -37
  182. package/dist/run/append-event.js.map +1 -1
  183. package/dist/run/block-run.js.map +1 -1
  184. package/dist/run/cancel-run.js +0 -33
  185. package/dist/run/cancel-run.js.map +1 -1
  186. package/dist/run/complete-run.js.map +1 -1
  187. package/dist/run/create-run.js +5 -8
  188. package/dist/run/create-run.js.map +1 -1
  189. package/dist/run/fail-run.js +0 -3
  190. package/dist/run/fail-run.js.map +1 -1
  191. package/dist/run/inspect-run.js +10 -29
  192. package/dist/run/inspect-run.js.map +1 -1
  193. package/dist/run/issue-ref.js +88 -0
  194. package/dist/run/issue-ref.js.map +1 -0
  195. package/dist/run/run-outputs.js +0 -19
  196. package/dist/run/run-outputs.js.map +1 -1
  197. package/dist/run/terminal-run-status.js +0 -12
  198. package/dist/run/terminal-run-status.js.map +1 -1
  199. package/dist/runners/claude-code.service.js +2 -13
  200. package/dist/runners/claude-code.service.js.map +1 -1
  201. package/dist/runners/codex.service.js +2 -11
  202. package/dist/runners/codex.service.js.map +1 -1
  203. package/dist/runners/gh-identity.js +0 -52
  204. package/dist/runners/gh-identity.js.map +1 -1
  205. package/dist/runners/integrator-branch-naming.js +16 -19
  206. package/dist/runners/integrator-branch-naming.js.map +1 -1
  207. package/dist/runners/integrator-git.js +0 -25
  208. package/dist/runners/integrator-git.js.map +1 -1
  209. package/dist/runners/integrator-remote.js +0 -2
  210. package/dist/runners/integrator-remote.js.map +1 -1
  211. package/dist/runners/integrator-types.js +0 -7
  212. package/dist/runners/integrator-types.js.map +1 -1
  213. package/dist/runners/integrator.js +231 -230
  214. package/dist/runners/integrator.js.map +1 -1
  215. package/dist/runners/runner.module.js +0 -6
  216. package/dist/runners/runner.module.js.map +1 -1
  217. package/dist/runners/tokens.js +0 -4
  218. package/dist/runners/tokens.js.map +1 -1
  219. package/dist/runners/worktree.service.js +3 -19
  220. package/dist/runners/worktree.service.js.map +1 -1
  221. package/dist/task-control-plane/pr-readiness.service.js +15 -13
  222. package/dist/task-control-plane/pr-readiness.service.js.map +1 -1
  223. package/dist/task-control-plane/run-watch.service.js +170 -63
  224. package/dist/task-control-plane/run-watch.service.js.map +1 -1
  225. package/dist/task-control-plane/task-control-plane-api.service.js +285 -40
  226. package/dist/task-control-plane/task-control-plane-api.service.js.map +1 -1
  227. package/dist/worker/artifact-store.js +21 -3
  228. package/dist/worker/artifact-store.js.map +1 -1
  229. package/dist/worker/build-context.js +10 -10
  230. package/dist/worker/build-context.js.map +1 -1
  231. package/dist/worker/claude-code-runner.js +76 -48
  232. package/dist/worker/claude-code-runner.js.map +1 -1
  233. package/dist/worker/codex-runner.js +128 -35
  234. package/dist/worker/codex-runner.js.map +1 -1
  235. package/dist/worker/git-worktree-manager.js +20 -67
  236. package/dist/worker/git-worktree-manager.js.map +1 -1
  237. package/dist/worker/process-executor.js +144 -27
  238. package/dist/worker/process-executor.js.map +1 -1
  239. package/dist/worker/result-envelope.js +33 -49
  240. package/dist/worker/result-envelope.js.map +1 -1
  241. package/dist/worker/runner-common.js +29 -0
  242. package/dist/worker/runner-common.js.map +1 -1
  243. package/dist/worker/runner-dispatch.js +0 -3
  244. package/dist/worker/runner-dispatch.js.map +1 -1
  245. package/dist/worker/runner.js +16 -1
  246. package/dist/worker/runner.js.map +1 -1
  247. package/dist/worker/stub-runner.js +0 -7
  248. package/dist/worker/stub-runner.js.map +1 -1
  249. package/package.json +2 -2
@@ -10,35 +10,35 @@ var __metadata = (this && this.__metadata) || function (k, v) {
10
10
  var __param = (this && this.__param) || function (paramIndex, decorator) {
11
11
  return function (target, key) { decorator(target, key, paramIndex); }
12
12
  };
13
- /**
14
- * integrator.ts — deterministic, replay-safe code integrator.
15
- *
16
- * DBOS-SEALED: zero @dbos-inc imports. Registration happens in PipelineService ctor.
17
- *
18
- * Exposes:
19
- * - integrate(input, deps) — REAL integrator (live only); git/gh side effects; resumable.
20
- * - stubIntegrate(input) — STUB (script only); ZERO external effects; pure + deterministic.
21
- * - preflightLive(taskId, base, deps) — LIVE PREFLIGHT; clean check + base freshness; one-shot.
22
- * - IntegratorService — @Injectable wrapper with bound arrow properties.
23
- *
24
- * The pure primitives (resolveExecutable, branchName, parseOwnerRepo) and shared types live in
25
- * sibling modules (integrator-git / -branch-naming / -remote / -types) and are re-exported here so
26
- * the public import path ('./integrator.js') is unchanged.
27
- */
28
13
  import { Inject, Injectable } from '@nestjs/common';
29
14
  import { execFileSync } from 'node:child_process';
30
- import { collectPrReadiness } from '../poller/pr-readiness-core.js';
15
+ import { collectPrReadiness, fetchRequiredCheckNames, } from '../poller/pr-readiness-core.js';
31
16
  import { RunService } from '../revisium/run.service.js';
17
+ import { issueRefTag } from '../run/issue-ref.js';
32
18
  import { resolveGhAccount, resolvePinnedGh } from './gh-identity.js';
33
19
  import { gitAbsPath, branchExists, countAhead } from './integrator-git.js';
34
20
  import { resolveOwnerRepo } from './integrator-remote.js';
35
21
  import { branchName } from './integrator-branch-naming.js';
36
- // Public-API re-exports — the implementations now live in focused sibling modules, but callers
37
- // (worktree.service, git-worktree-manager, the test kit) keep importing from './integrator.js'.
38
22
  export { resolveExecutable } from './integrator-git.js';
39
23
  export { branchName };
40
24
  export { parseOwnerRepo } from './integrator-remote.js';
41
- function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
25
+ function issueBoundTitle(title, issueRef, ownerRepo) {
26
+ const tag = issueRefTag(issueRef, ownerRepo);
27
+ return tag ? `${tag} ${title}` : title;
28
+ }
29
+ function commitMessage(title, issueRef, ownerRepo) {
30
+ const tag = issueRefTag(issueRef, ownerRepo);
31
+ return tag ? `feat: ${tag} ${title}` : `feat: ${title}`;
32
+ }
33
+ function parsePrList(raw) {
34
+ try {
35
+ return JSON.parse(raw);
36
+ }
37
+ catch {
38
+ throw new Error(`gh pr list returned non-JSON: ${raw.slice(0, 200)}`);
39
+ }
40
+ }
41
+ function matchingOpenPr(ownerRepo, branch, base, execGh, jsonFields) {
42
42
  const raw = execGh([
43
43
  'pr',
44
44
  'list',
@@ -49,21 +49,14 @@ function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
49
49
  '--state',
50
50
  'open',
51
51
  '--json',
52
- 'number,url,baseRefName',
52
+ jsonFields,
53
53
  ]);
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);
54
+ const matching = parsePrList(raw).filter((p) => p.baseRefName === base);
62
55
  if (matching.length === 1) {
63
56
  const pr = matching[0];
64
57
  if (!pr)
65
58
  throw new Error('unexpected empty match');
66
- return { prUrl: pr.url, prNumber: pr.number };
59
+ return { prUrl: pr.url, prNumber: pr.number, ...(pr.headRefOid ? { headSha: pr.headRefOid } : {}) };
67
60
  }
68
61
  if (matching.length > 1) {
69
62
  const candidates = matching.map((p) => `#${p.number}`).join(', ');
@@ -72,7 +65,9 @@ function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
72
65
  lesson: `Ambiguous: ${matching.length} open PRs for branch "${branch}" targeting "${base}" in ${ownerRepo} — candidates ${candidates} — manual review needed`,
73
66
  };
74
67
  }
75
- // 0 matches — create
68
+ return null;
69
+ }
70
+ function createPr(ownerRepo, branch, base, title, issueRef, execGh) {
76
71
  const createOut = execGh([
77
72
  'pr',
78
73
  'create',
@@ -84,13 +79,11 @@ function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
84
79
  '--head',
85
80
  branch,
86
81
  '--title',
87
- title,
82
+ issueBoundTitle(title, issueRef, ownerRepo),
88
83
  '--body',
89
84
  '',
90
85
  ]);
91
- // gh pr create prints the PR url on stdout
92
86
  const createdUrl = createOut.trim();
93
- // Fetch back number from the URL
94
87
  const viewRaw = execGh([
95
88
  'pr',
96
89
  'view',
@@ -105,8 +98,6 @@ function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
105
98
  viewData = JSON.parse(viewRaw);
106
99
  }
107
100
  catch {
108
- // gh pr view returned non-JSON after create — cannot determine real PR url/number.
109
- // A live integrator must NEVER emit a stub:// url; surface as needsHuman for human review.
110
101
  return {
111
102
  needsHuman: true,
112
103
  lesson: `gh pr view returned non-JSON after create (url=${createdUrl || 'empty'}); ` +
@@ -115,23 +106,21 @@ function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
115
106
  }
116
107
  return { prUrl: viewData.url, prNumber: viewData.number };
117
108
  }
118
- // ─── Preflight (B5+B7) ────────────────────────────────────────────────────────
119
- /**
120
- * preflightLive clean check + base freshness, evaluated ONCE as a memoized DBOS step.
121
- * Only called on live runs; script/stub runs skip this entirely.
122
- *
123
- * 1. git fetch origin <base> (idempotent — the only mutation)
124
- * 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
127
- *
128
- * Returns { ok: true } when the repo is clean and based on fresh origin/<base>.
129
- * Returns IntegratorBlocked on ANY failure — no throw, so DBOS does NOT retry.
130
- */
109
+ function findOrCreatePr(ownerRepo, branch, base, title, issueRef, execGh) {
110
+ const existing = matchingOpenPr(ownerRepo, branch, base, execGh, 'number,url,baseRefName');
111
+ if (existing && !('needsHuman' in existing)) {
112
+ return { prUrl: existing.prUrl, prNumber: existing.prNumber };
113
+ }
114
+ if (existing)
115
+ return existing;
116
+ return createPr(ownerRepo, branch, base, title, issueRef, execGh);
117
+ }
118
+ function findExistingPrWithHead(ownerRepo, branch, base, execGh) {
119
+ return matchingOpenPr(ownerRepo, branch, base, execGh, 'number,url,baseRefName,headRefOid');
120
+ }
131
121
  export async function preflightLive(taskId, base, deps) {
132
122
  const { execGit, resolveTaskCwd } = deps;
133
123
  const cwd = await resolveTaskCwd(taskId);
134
- // 1. Fetch (only mutation; idempotent; ignore errors — fetch failure handled as mismatch)
135
124
  try {
136
125
  execGit(['fetch', 'origin', base], cwd);
137
126
  }
@@ -141,7 +130,6 @@ export async function preflightLive(taskId, base, deps) {
141
130
  lesson: `live preflight: git fetch origin ${base} failed — base branch may not exist on remote: ${String(err)}`,
142
131
  };
143
132
  }
144
- // 2. Clean check
145
133
  const porcelain = execGit(['status', '--porcelain'], cwd).trim();
146
134
  if (porcelain !== '') {
147
135
  const lineCount = porcelain.split('\n').length;
@@ -150,9 +138,6 @@ export async function preflightLive(taskId, base, deps) {
150
138
  lesson: `target repo ${cwd} is not clean (${lineCount} uncommitted change${lineCount === 1 ? '' : 's'}); commit/stash and retry --live`,
151
139
  };
152
140
  }
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
156
141
  let headBranch;
157
142
  let headSha;
158
143
  let originSha;
@@ -164,150 +149,176 @@ export async function preflightLive(taskId, base, deps) {
164
149
  catch (err) {
165
150
  return {
166
151
  needsHuman: true,
167
- lesson: `live preflight: cannot verify base freshness — ${String(err)}`,
152
+ lesson: `live preflight: cannot resolve origin/${base} after fetch — ${String(err)}`,
168
153
  };
169
154
  }
170
155
  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
156
  let behind = false;
178
157
  try {
179
158
  execGit(['merge-base', '--is-ancestor', 'HEAD', `origin/${base}`], cwd);
180
159
  behind = true;
181
160
  }
182
161
  catch {
183
- behind = false; // not an ancestor → diverged (or unrelated)
162
+ behind = false;
184
163
  }
185
164
  if (!behind) {
186
165
  return {
187
166
  needsHuman: true,
188
- lesson: `target repo base branch ${base} has DIVERGED from origin/${base} ` +
167
+ lesson: `target repo base branch ${base} has local-only or diverged commits relative to origin/${base} ` +
189
168
  `(HEAD=${headSha.slice(0, 8)} has local commits absent from origin/${base}@${originSha.slice(0, 8)}); ` +
190
169
  `reconcile manually, then retry --live`,
191
170
  };
192
171
  }
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
172
  }
216
173
  return { ok: true };
217
174
  }
218
- // ─── STUB integrator (script only) ───────────────────────────────────────────
219
- /**
220
- * stubIntegrate — PURE, zero side effects; returns a placeholder result.
221
- * Used by `script` mode (default + --stub). Makes NO execGit/execGh calls.
222
- */
223
175
  export function stubIntegrate(input) {
224
176
  return {
225
177
  prUrl: 'stub://pr/placeholder',
226
178
  branch: `feat/${input.taskId}-stub`,
227
179
  prNumber: 0,
180
+ ...(input.issueRef ? { issueRef: input.issueRef } : {}),
181
+ };
182
+ }
183
+ export async function captureProducedChange(input, deps) {
184
+ const { execGit: git, resolveRunCwd } = deps;
185
+ const cwd = await resolveRunCwd(input.runId, input.taskId);
186
+ const branch = branchName(input.taskId, input.title, input.issueRef);
187
+ const ownerRepoResult = resolveOwnerRepo(git, cwd);
188
+ const ownerRepo = 'needsHuman' in ownerRepoResult ? undefined : ownerRepoResult.ownerRepo;
189
+ if (branchExists(git, cwd, branch)) {
190
+ git(['switch', branch], cwd);
191
+ }
192
+ else {
193
+ git(['switch', '-c', branch], cwd);
194
+ }
195
+ git(['add', '-A'], cwd);
196
+ if (stagedDiffPresent(git, cwd)) {
197
+ git(['commit', '-m', commitMessage(input.title, input.issueRef, ownerRepo)], cwd);
198
+ }
199
+ const headSha = git(['rev-parse', 'HEAD'], cwd).trim();
200
+ return {
201
+ branch,
202
+ headSha,
203
+ ...(input.issueRef ? { issueRef: input.issueRef } : {}),
204
+ worktreePath: cwd,
205
+ ...(input.artifactRef ? { artifactRef: input.artifactRef } : {}),
228
206
  };
229
207
  }
230
- // ─── REAL integrator (live only) ─────────────────────────────────────────────
231
- /**
232
- * integrate — REAL integrator (live only).
233
- * Replay-safe: branch create-if-absent (no clobber); commit only if staged diff;
234
- * push + find-or-create PR even if commit already happened (ahead guard).
235
- */
208
+ function stagedDiffPresent(git, cwd) {
209
+ try {
210
+ git(['diff', '--cached', '--quiet'], cwd);
211
+ return false;
212
+ }
213
+ catch {
214
+ return true;
215
+ }
216
+ }
236
217
  export async function integrate(input, deps) {
218
+ if (input.change)
219
+ return integrateProducedChange(input, deps, input.change);
237
220
  const { execGit: git, execGh: gh, resolveRunCwd } = deps;
238
- // Resolve the run's ISOLATED worktree (plan 0017) — it is already checked out on `branch`, so the
239
- // branchExists→switch path below is a no-op and the dirty-tree `switch -c origin/<base>` (which
240
- // failed when the base checkout carried prior-run changes) is never taken.
241
221
  const cwd = await resolveRunCwd(input.runId, input.taskId);
242
- const branch = branchName(input.taskId, input.title);
243
- // 1. Derive owner/repo
222
+ const branch = branchName(input.taskId, input.title, input.issueRef);
244
223
  const ownerRepoResult = resolveOwnerRepo(git, cwd);
245
224
  if ('needsHuman' in ownerRepoResult)
246
225
  return ownerRepoResult;
247
226
  const { ownerRepo } = ownerRepoResult;
248
- // 2. Fetch base
249
227
  git(['fetch', 'origin', input.base], cwd);
250
- // 3. Branch create-if-absent; never clobber a prior commit
251
228
  if (branchExists(git, cwd, branch)) {
252
229
  git(['switch', branch], cwd);
253
230
  }
254
231
  else {
255
232
  git(['switch', '-c', branch, `origin/${input.base}`], cwd);
256
233
  }
257
- // 4. Stage all changes (safe: clean-repo precondition from preflight)
258
234
  git(['add', '-A'], cwd);
259
- // 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) {
269
- // Commit — NO Co-Authored-By, NO summary footer (MEMORY)
270
- const commitMsg = `feat: ${input.title}`;
235
+ if (stagedDiffPresent(git, cwd)) {
236
+ const commitMsg = commitMessage(input.title, input.issueRef, ownerRepo);
271
237
  git(['commit', '-m', commitMsg], cwd);
272
238
  }
273
239
  else {
274
- // No staged diff — check if branch is ahead of origin/<base>
275
240
  const ahead = countAhead(git, cwd, branch, input.base);
276
241
  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
- };
242
+ let lesson = 'nothing to integrate — no staged changes and branch is not ahead of origin/' + input.base;
243
+ try {
244
+ const baseCwd = await deps.resolveTaskCwd(input.taskId);
245
+ const basePorcelain = git(['status', '--porcelain'], baseCwd).trim();
246
+ if (basePorcelain !== '') {
247
+ lesson =
248
+ `developer produced changes but the run's worktree is empty — they appear to have been ` +
249
+ `written OUTSIDE the worktree (the base checkout ${baseCwd} is dirty); see slice 143. ` +
250
+ `Re-run; the agent must write under its cwd / $REVO_WORKTREE_PATH.`;
251
+ }
252
+ }
253
+ catch {
254
+ }
255
+ return { needsHuman: true, lesson };
282
256
  }
283
- // Ahead but no staged diff → commit happened on a prior attempt; fall through to push
284
257
  }
285
- // 6. Push (idempotent — no force)
286
258
  git(['push', '-u', 'origin', branch], cwd);
287
- // 7. Find-or-create PR
288
- const prResult = findOrCreatePr(ownerRepo, branch, input.base, input.title, gh);
259
+ const prResult = findOrCreatePr(ownerRepo, branch, input.base, input.title, input.issueRef, gh);
289
260
  if ('needsHuman' in prResult)
290
261
  return prResult;
291
- return { prUrl: prResult.prUrl, branch, prNumber: prResult.prNumber };
262
+ return { prUrl: prResult.prUrl, branch, prNumber: prResult.prNumber, ...(input.issueRef ? { issueRef: input.issueRef } : {}) };
263
+ }
264
+ async function integrateProducedChange(input, deps, change) {
265
+ const { execGit: git, execGh: gh } = deps;
266
+ const cwd = change.worktreePath ?? await deps.resolveRunCwd(input.runId, input.taskId);
267
+ const branch = change.branch;
268
+ const issueRef = change.issueRef ?? input.issueRef;
269
+ const ownerRepoResult = resolveOwnerRepo(git, cwd);
270
+ if ('needsHuman' in ownerRepoResult)
271
+ return ownerRepoResult;
272
+ const { ownerRepo } = ownerRepoResult;
273
+ git(['fetch', 'origin', input.base], cwd);
274
+ const existing = findExistingPrWithHead(ownerRepo, branch, input.base, gh);
275
+ if (existing && 'needsHuman' in existing)
276
+ return existing;
277
+ if (existing?.headSha === change.headSha) {
278
+ return {
279
+ prUrl: existing.prUrl,
280
+ branch,
281
+ prNumber: existing.prNumber,
282
+ ...(issueRef ? { issueRef } : {}),
283
+ headSha: change.headSha,
284
+ status: 'noop',
285
+ message: 'nothing to integrate — produced head already pushed and equals PR head',
286
+ };
287
+ }
288
+ if (!existing && countAhead(git, cwd, change.headSha, input.base) === 0) {
289
+ return {
290
+ needsHuman: true,
291
+ lesson: `nothing to integrate — produced head ${change.headSha.slice(0, 8)} is not ahead of ` +
292
+ `origin/${input.base} and no open PR exists`,
293
+ };
294
+ }
295
+ git(['push', 'origin', `${change.headSha}:refs/heads/${branch}`], cwd);
296
+ if (existing) {
297
+ return {
298
+ prUrl: existing.prUrl,
299
+ branch,
300
+ prNumber: existing.prNumber,
301
+ ...(issueRef ? { issueRef } : {}),
302
+ headSha: change.headSha,
303
+ status: 'pushed',
304
+ };
305
+ }
306
+ const created = createPr(ownerRepo, branch, input.base, input.title, issueRef, gh);
307
+ if ('needsHuman' in created)
308
+ return created;
309
+ return {
310
+ prUrl: created.prUrl,
311
+ branch,
312
+ prNumber: created.prNumber,
313
+ ...(issueRef ? { issueRef } : {}),
314
+ headSha: change.headSha,
315
+ status: 'pushed',
316
+ };
292
317
  }
293
- /**
294
- * confirmMerge — REAL (live only). Ensures the run's PR is actually merged before the run reaches its
295
- * success terminal (so the worktree, released on `succeeded`, is cleaned only once the branch is in the
296
- * base — truly disposable). Idempotent + replay-safe:
297
- *
298
- * 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.
301
- * 3. Not mergeable (not OPEN, or not CLEAN — red CI / conflicts / blocked) → block (needsHuman), which
302
- * routes to a `blocked` terminal and KEEPS the worktree for rework.
303
- *
304
- * Merge method is `--squash` by default; node-level parameterization (method/policy) is a deferred
305
- * follow-up (run-time node params).
306
- */
307
318
  export async function confirmMerge(input, deps) {
308
319
  const { execGit: git, execGh: gh, resolveRunCwd } = deps;
309
320
  const cwd = await resolveRunCwd(input.runId, input.taskId);
310
- const branch = branchName(input.taskId, input.title);
321
+ const branch = branchName(input.taskId, input.title, input.issueRef);
311
322
  const ownerRepoResult = resolveOwnerRepo(git, cwd);
312
323
  if ('needsHuman' in ownerRepoResult)
313
324
  return ownerRepoResult;
@@ -323,12 +334,10 @@ export async function confirmMerge(input, deps) {
323
334
  };
324
335
  const pr = view();
325
336
  if (pr.state === 'MERGED')
326
- return { merged: true, prNumber: pr.number, prUrl: pr.url };
337
+ return { merged: true, prNumber: pr.number, prUrl: pr.url, ...(input.issueRef ? { issueRef: input.issueRef } : {}) };
327
338
  if (pr.state !== 'OPEN') {
328
339
  return { needsHuman: true, lesson: `PR #${pr.number} is ${pr.state} (not OPEN) and not merged — resolve manually` };
329
340
  }
330
- // Only auto-merge a CLEAN PR (CI green, no conflicts). Other states (BLOCKED/DIRTY/UNSTABLE/BEHIND…)
331
- // require a human — block and keep the worktree for rework.
332
341
  if (pr.mergeStateStatus !== 'CLEAN') {
333
342
  return {
334
343
  needsHuman: true,
@@ -336,60 +345,80 @@ export async function confirmMerge(input, deps) {
336
345
  `conflicts, or required reviews pending; merge it manually (or fix + re-run) then cleanup`,
337
346
  };
338
347
  }
339
- // The integrator opens the PR as a DRAFT (human review at the merge gate); `gh pr merge` refuses a
340
- // draft, so mark it ready first (the approved merge gate IS that review). Idempotent: a no-op once ready.
348
+ const expectedHeadSha = input.mergeReadiness?.headSha.trim();
349
+ if (!expectedHeadSha) {
350
+ return {
351
+ needsHuman: true,
352
+ lesson: `PR #${pr.number} merge requires a fresh merge readiness headSha guard — re-run readiness before approving merge`,
353
+ };
354
+ }
341
355
  if (pr.isDraft) {
342
356
  gh(['pr', 'ready', branch, '--repo', ownerRepo]);
343
357
  }
344
- // 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']);
358
+ try {
359
+ gh(['pr', 'merge', branch, '--repo', ownerRepo, '--squash', '--delete-branch', '--match-head-commit', expectedHeadSha]);
360
+ }
361
+ catch (err) {
362
+ const message = err instanceof Error ? err.message : String(err);
363
+ return {
364
+ needsHuman: true,
365
+ lesson: `PR #${pr.number} merge was blocked by the GitHub head guard ` +
366
+ `(expected ${expectedHeadSha}): ${message || 'merge command failed'}`,
367
+ };
368
+ }
346
369
  const after = view();
347
370
  if (after.state === 'MERGED')
348
- return { merged: true, prNumber: after.number, prUrl: after.url };
371
+ return { merged: true, prNumber: after.number, prUrl: after.url, ...(input.issueRef ? { issueRef: input.issueRef } : {}) };
349
372
  return { needsHuman: true, lesson: `PR #${after.number} merge did not take effect (state=${after.state}) — verify manually` };
350
373
  }
351
- /** Default polling bounds — pr-readiness parity (20 polls × 30s), overridable via env so the e2e suite
352
- * doesn't real-sleep; also injectable (deps.maxPolls/pollIntervalMs) so unit tests run instantly. */
353
374
  function envInt(name, fallback) {
354
375
  const n = Number.parseInt(process.env[name] ?? '', 10);
355
376
  return Number.isFinite(n) && n > 0 ? n : fallback;
356
377
  }
357
- function defaultCollect(repo, branch, base, execGh) {
358
- // pollPr classifies by CI checks + unresolved review THREADS only it does not read the PR comment
359
- // feed, so suppress the `api repos/.../{reviews,comments}` calls (`includeComments: false`). Those
360
- // extra calls are pure overhead here and the review-thread query already carries the actionable
361
- // feedback the loop acts on.
362
- return collectPrReadiness({ repo, headBranch: branch, baseBranch: base, includeReviewThreads: true, includeComments: false }, execGh).then((r) => ({
378
+ function defaultCollect(repo, branch, base, execGh, issueRef) {
379
+ return collectPrReadiness({ repo, headBranch: branch, baseBranch: base, issueRef, includeReviewThreads: true, includeComments: false }, execGh).then((r) => ({
363
380
  pr: { number: r.pr.number, headSha: r.pr.headSha },
364
381
  checks: { pending: r.checks.pending, fail: r.checks.fail, list: r.checks.list },
365
382
  reviewThreads: { items: r.reviewThreads.items },
383
+ readinessVerdict: r.verdict,
384
+ nextAction: r.nextAction,
385
+ evidence: r.evidence,
366
386
  }));
367
387
  }
368
388
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
369
- /**
370
- * pollPr — REAL (live only). Polls `collectPrReadiness` until CI is terminal (no pending checks) or a
371
- * timeout, then classifies the feedback by TYPE (plan 0018): unresolved review threads → review_changes;
372
- * else failing checks ci_changes; else clean. A timeout with checks still pending → block (human).
373
- * Replay-safe at the DBOS-step boundary (memoized result), idempotent (read-only gh).
374
- */
389
+ function ciFailuresFrom(readiness) {
390
+ return readiness.checks.list
391
+ .filter((c) => readiness.checks.fail.includes(c.name))
392
+ .map((c) => ({ name: c.name, conclusion: c.result }));
393
+ }
394
+ function readinessRequiresReview(readiness) {
395
+ return readiness.readinessVerdict === 'needs_human'
396
+ || readiness.nextAction === 'human_decision'
397
+ || readiness.nextAction === 'reviewer_triage'
398
+ || (readiness.nextAction === 'developer_fix' && readiness.checks.fail.length === 0);
399
+ }
400
+ function readinessEvidence(readiness) {
401
+ return [
402
+ readiness.readinessVerdict ? `readiness verdict=${readiness.readinessVerdict}` : undefined,
403
+ readiness.nextAction ? `readiness nextAction=${readiness.nextAction}` : undefined,
404
+ ].filter((item) => item !== undefined);
405
+ }
375
406
  export async function pollPr(input, deps) {
376
407
  const { execGit: git, execGh: gh, resolveRunCwd } = deps;
377
408
  const collect = deps.collect ?? defaultCollect;
378
409
  const sleep = deps.sleep ?? defaultSleep;
379
- // Read env at CALL time (not module load) so the e2e harness can shrink the poll budget regardless
380
- // of import order; prod default stays 20 × 30s.
410
+ const requiredChecks = deps.requiredChecks ?? fetchRequiredCheckNames;
381
411
  const maxPolls = deps.maxPolls ?? envInt('REVO_POLL_PR_MAX_POLLS', 20);
382
412
  const intervalMs = deps.pollIntervalMs ?? envInt('REVO_POLL_PR_INTERVAL_MS', 30_000);
383
413
  const cwd = await resolveRunCwd(input.runId, input.taskId);
384
- const branch = branchName(input.taskId, input.title);
414
+ const branch = branchName(input.taskId, input.title, input.issueRef);
385
415
  const ownerRepoResult = resolveOwnerRepo(git, cwd);
386
416
  if ('needsHuman' in ownerRepoResult)
387
417
  return ownerRepoResult;
388
418
  const { ownerRepo } = ownerRepoResult;
389
419
  let readiness;
390
420
  for (let i = 0; i < maxPolls; i++) {
391
- readiness = await collect(ownerRepo, branch, input.base, gh);
392
- // Terminal once nothing is pending and at least one check has registered (matches pr-readiness).
421
+ readiness = await collect(ownerRepo, branch, input.base, gh, input.issueRef);
393
422
  if (readiness.checks.pending.length === 0 && readiness.checks.list.length > 0)
394
423
  break;
395
424
  readiness = undefined;
@@ -402,32 +431,30 @@ export async function pollPr(input, deps) {
402
431
  lesson: `pollPr timed out after ${maxPolls} polls — CI checks still pending or none registered for ${branch}`,
403
432
  };
404
433
  }
405
- // `settled` is the CI-terminal readiness (defined past the guard); the review-grace loop may refresh it.
406
434
  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 }));
411
- // CI green → flip the PR draft→ready so CodeRabbit / human reviewers actually engage. They SKIP
412
- // draft PRs, which silently bypassed the entire review loop (the PR was readied only at confirmMerge,
413
- // i.e. after the merge gate) — slice 142. Then wait a BOUNDED grace for review threads to surface
414
- // before declaring clean, so we don't merge-gate a millisecond before CodeRabbit posts. We never
415
- // BLOCK on a review arriving: the human merge gate is the backstop, so an absent / rate-limited
416
- // reviewer falls through to it rather than deadlocking the run. CI-red stays draft (don't request
417
- // review of broken code) and routes to ci_changes for a fix first.
418
- if (ciFailures.length === 0) {
435
+ const initialCiFailures = ciFailuresFrom(settled);
436
+ if (initialCiFailures.length === 0) {
419
437
  try {
420
438
  gh(['pr', 'ready', branch, '--repo', ownerRepo]);
421
439
  }
422
440
  catch {
423
- // already ready / nothing to ready — idempotent best-effort
424
441
  }
425
442
  const reviewGracePolls = deps.reviewGracePolls ?? envInt('REVO_POLL_PR_REVIEW_GRACE_POLLS', 4);
426
443
  for (let i = 0; i < reviewGracePolls && settled.reviewThreads.items.length === 0; i++) {
427
444
  await sleep(intervalMs);
428
- settled = await collect(ownerRepo, branch, input.base, gh);
445
+ settled = await collect(ownerRepo, branch, input.base, gh, input.issueRef);
429
446
  }
430
447
  }
448
+ if (settled.checks.pending.length > 0 || settled.checks.list.length === 0) {
449
+ const detail = settled.checks.pending.length > 0
450
+ ? `pending checks: ${settled.checks.pending.join(', ')}`
451
+ : 'no checks registered';
452
+ const evidence = settled.evidence.length > 0 ? ` Evidence: ${settled.evidence.join(' | ')}` : '';
453
+ return {
454
+ needsHuman: true,
455
+ lesson: `pollPr found unsettled readiness after readying ${branch} - ${detail}.${evidence}`,
456
+ };
457
+ }
431
458
  const reviewThreads = settled.reviewThreads.items.map((t) => ({
432
459
  threadId: t.id,
433
460
  path: t.path,
@@ -435,19 +462,31 @@ export async function pollPr(input, deps) {
435
462
  author: t.author,
436
463
  body: t.body,
437
464
  }));
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';
465
+ const ciFailures = ciFailuresFrom(settled);
466
+ let ciVerdictFailures = ciFailures;
467
+ if (ciFailures.length > 0 && settled.pr.number !== null) {
468
+ try {
469
+ const required = requiredChecks(ownerRepo, settled.pr.number, gh);
470
+ if (required.size > 0)
471
+ ciVerdictFailures = ciFailures.filter((f) => required.has(f.name));
472
+ }
473
+ catch {
474
+ }
475
+ }
476
+ const verdict = reviewThreads.length > 0 || readinessRequiresReview(settled)
477
+ ? 'review_changes'
478
+ : ciVerdictFailures.length > 0 ? 'ci_changes' : 'clean';
440
479
  return {
441
480
  prNumber: settled.pr.number ?? null,
442
481
  headSha: settled.pr.headSha,
482
+ evidence: [...settled.evidence, ...readinessEvidence(settled), `PR headSha=${settled.pr.headSha}`, `pollPr verdict=${verdict}`],
483
+ ...(input.issueRef ? { issueRef: input.issueRef } : {}),
443
484
  verdict,
444
485
  ciFailures,
445
486
  reviewThreads,
446
487
  };
447
488
  }
448
489
  const TRIAGE_DECISIONS = new Set(['fix', 'wontfix', 'question']);
449
- /** Coerce the analyst's (LLM-produced, untrusted-shape) triage output to a Triage, dropping malformed
450
- * items so respondThreads never throws on a null/garbled entry (CodeRabbit). */
451
490
  export function asTriage(value) {
452
491
  if (value === null || typeof value !== 'object')
453
492
  return { items: [] };
@@ -460,20 +499,13 @@ export function asTriage(value) {
460
499
  : [];
461
500
  return { items };
462
501
  }
463
- /**
464
- * respondThreads — REAL (live only). For each triaged thread we acted on (decision fix OR wontfix —
465
- * plan 0018 decision #2), reply in the thread then resolve it, via the gh-pinned GraphQL API
466
- * (`addPullRequestReviewThreadReply` then `resolveReviewThread`). Question items are skipped (they go to
467
- * the question gate, not auto-resolved). Idempotent: resolving an already-resolved thread is a no-op for
468
- * the loop (a reopened/new comment is caught by the next pollPr).
469
- */
470
502
  export async function respondThreads(triage, deps) {
471
503
  const { execGh: gh } = deps;
472
504
  let replied = 0;
473
505
  let resolved = 0;
474
506
  for (const item of triage.items) {
475
507
  if (item.decision !== 'fix' && item.decision !== 'wontfix')
476
- continue; // skip questions
508
+ continue;
477
509
  const body = item.replyText ?? (item.decision === 'wontfix' ? 'Acknowledged; not changing.' : 'Addressed.');
478
510
  gh([
479
511
  'api', 'graphql',
@@ -491,37 +523,20 @@ export async function respondThreads(triage, deps) {
491
523
  }
492
524
  return { replied, resolved };
493
525
  }
494
- // ─── IntegratorService ─────────────────────────────────────────────────────────
495
- /** Default execGit implementation wrapping execFileSync with a resolved absolute path. */
496
526
  function defaultExecGit(args, cwd) {
497
527
  return execFileSync(gitAbsPath(), args, { encoding: 'utf8', cwd, timeout: 60_000 });
498
528
  }
499
- /**
500
- * IntegratorService — @Injectable wrapper over integrate / stubIntegrate / preflightLive.
501
- * Exposes bound arrow properties so they survive being passed to registerStep unbound.
502
- * DBOS-SEALED: zero @dbos-inc imports; PipelineService registers these as steps.
503
- */
504
529
  let IntegratorService = class IntegratorService {
505
530
  runService;
506
531
  deps;
507
532
  constructor(runService) {
508
533
  this.runService = runService;
509
- // execGh is resolved per-run inside runIntegrate (fail-loud on an unresolved pinned identity),
510
- // so it is NOT built here — only the git + cwd deps are stable at construction.
511
534
  this.deps = {
512
535
  execGit: defaultExecGit,
513
536
  resolveTaskCwd: this.runService.makeResolveTaskCwd(),
514
537
  resolveRunCwd: this.runService.makeResolveRunCwd(),
515
538
  };
516
539
  }
517
- /**
518
- * Real integrator — live path. Arrow property: safe to pass unbound to registerStep.
519
- *
520
- * 0008 #1 (fail-loud, 2026-06-12 dogfood): resolve the PINNED gh identity first. If its token
521
- * cannot be resolved we REFUSE to fall back to the ambient gh account (which would open the PR
522
- * as the wrong user) and block as needsHuman instead. Only on success do we run the integrator
523
- * with the pinned execGh.
524
- */
525
540
  runIntegrate = (input) => {
526
541
  const pinned = resolvePinnedGh();
527
542
  if ('needsHuman' in pinned) {
@@ -531,14 +546,9 @@ let IntegratorService = class IntegratorService {
531
546
  console.log(`[integrator] gh pinned to account '${resolveGhAccount()}' (GH_TOKEN, not ambient)`);
532
547
  return integrate(input, { ...this.deps, execGh: pinned.execGh });
533
548
  };
534
- /** Stub integrator — script path; zero external effects. */
535
549
  runStub = (input) => {
536
550
  return stubIntegrate(input);
537
551
  };
538
- /**
539
- * Real confirm-merge — live path. Same fail-loud pinned-gh handling as runIntegrate: refuse the
540
- * ambient account, block if the pinned identity cannot be resolved.
541
- */
542
552
  runConfirmMerge = (input) => {
543
553
  const pinned = resolvePinnedGh();
544
554
  if ('needsHuman' in pinned) {
@@ -547,18 +557,15 @@ let IntegratorService = class IntegratorService {
547
557
  }
548
558
  return confirmMerge(input, { ...this.deps, execGh: pinned.execGh });
549
559
  };
550
- /** Stub confirm-merge — script path; zero external effects (treats the stub PR as merged). */
551
560
  runConfirmStub = (input) => {
552
561
  return { merged: true, prNumber: 0, prUrl: `stub://pr/${input.taskId}/merged` };
553
562
  };
554
- /** Live preflight — clean check + base invariant. Arrow property for safe unbound registration. */
555
563
  runPreflight = (taskId, base) => {
556
564
  return preflightLive(taskId, base, this.deps);
557
565
  };
558
- /**
559
- * Real pollPr — live path. Same fail-loud pinned-gh handling as runIntegrate (the review/CI poll reads
560
- * the PR via the pinned account, never the ambient one). Blocks if the pinned identity is unresolved.
561
- */
566
+ runCaptureProducedChange = (input) => {
567
+ return captureProducedChange(input, this.deps);
568
+ };
562
569
  runPollPr = (input) => {
563
570
  const pinned = resolvePinnedGh();
564
571
  if ('needsHuman' in pinned) {
@@ -567,14 +574,9 @@ let IntegratorService = class IntegratorService {
567
574
  }
568
575
  return pollPr(input, { ...this.deps, execGh: pinned.execGh });
569
576
  };
570
- /** Stub pollPr — script path; zero external effects (reports a clean PR so the loop converges to merge). */
571
577
  runPollStub = (_input) => {
572
- return { prNumber: null, headSha: 'stub', verdict: 'clean', ciFailures: [], reviewThreads: [] };
578
+ return { prNumber: null, headSha: 'stub', evidence: ['stub pollPr readiness: clean'], verdict: 'clean', ciFailures: [], reviewThreads: [] };
573
579
  };
574
- /**
575
- * Real respondThreads — live path. Same fail-loud pinned-gh handling: refuse the ambient account.
576
- * The consumed `triage` rides in IntegratorInput.triage (hydrated by the adapter from run_outputs).
577
- */
578
580
  runRespondThreads = (input) => {
579
581
  const pinned = resolvePinnedGh();
580
582
  if ('needsHuman' in pinned) {
@@ -583,7 +585,6 @@ let IntegratorService = class IntegratorService {
583
585
  }
584
586
  return respondThreads(asTriage(input.triage), { execGh: pinned.execGh });
585
587
  };
586
- /** Stub respondThreads — script path; zero external effects (no threads to reply/resolve). */
587
588
  runRespondStub = (_input) => {
588
589
  return { replied: 0, resolved: 0 };
589
590
  };