@uchibeke/gallivant 0.2.0 → 0.3.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 (133) hide show
  1. package/README.md +120 -60
  2. package/SECURITY.md +9 -0
  3. package/bin/gallivant.mjs +41 -14
  4. package/bin/promote-bitbucket.mjs +40 -0
  5. package/bin/task-pr.mjs +9 -0
  6. package/bin/task-review.mjs +15 -0
  7. package/docs/README.md +6 -2
  8. package/docs/agent-setup.md +55 -41
  9. package/docs/architecture.md +11 -3
  10. package/docs/bitbucket-promotion.md +69 -0
  11. package/docs/bitbucket-review.md +330 -0
  12. package/docs/chains.md +30 -1
  13. package/docs/codex-cloud.md +7 -0
  14. package/docs/codex-review.md +50 -0
  15. package/docs/commands.md +103 -14
  16. package/docs/config.md +45 -13
  17. package/docs/execution.md +1 -1
  18. package/docs/first-party-review.md +266 -0
  19. package/docs/git-hosts.md +294 -0
  20. package/docs/guardrails.md +56 -0
  21. package/docs/install.md +137 -67
  22. package/docs/launch.md +7 -4
  23. package/docs/network-recovery.md +9 -0
  24. package/docs/openai-agents.md +3 -2
  25. package/docs/prd.md +23 -2
  26. package/docs/preparation.md +2 -2
  27. package/docs/release-qualification.md +1 -1
  28. package/docs/releasing.md +46 -22
  29. package/docs/task-delivery.md +5 -5
  30. package/docs/task-profiles.md +537 -0
  31. package/docs/tasks.md +59 -197
  32. package/lib/attribution.mjs +7 -3
  33. package/lib/block.mjs +10 -1
  34. package/lib/chains.mjs +101 -30
  35. package/lib/codex_cloud.mjs +2 -1
  36. package/lib/codex_cloud_input.mjs +15 -6
  37. package/lib/codex_cloud_result.mjs +1 -1
  38. package/lib/commands/doctor.mjs +22 -453
  39. package/lib/commands/init.mjs +281 -28
  40. package/lib/commands/register.mjs +81 -13
  41. package/lib/commands/resume.mjs +5 -1
  42. package/lib/commands/review.mjs +66 -0
  43. package/lib/commands/serve.mjs +8 -0
  44. package/lib/commands/start.mjs +30 -11
  45. package/lib/commands/status.mjs +18 -2
  46. package/lib/commands/task.mjs +9 -1
  47. package/lib/commands/up.mjs +19 -11
  48. package/lib/commands/why.mjs +7 -3
  49. package/lib/config.mjs +42 -9
  50. package/lib/context.mjs +28 -21
  51. package/lib/control.mjs +21 -6
  52. package/lib/credential.mjs +20 -14
  53. package/lib/deliverable.mjs +2 -0
  54. package/lib/events.mjs +96 -53
  55. package/lib/execution.mjs +132 -11
  56. package/lib/execution_preparation.mjs +16 -1
  57. package/lib/execution_result.mjs +26 -5
  58. package/lib/first_party_review.mjs +618 -0
  59. package/lib/fs.mjs +10 -3
  60. package/lib/gh.mjs +144 -56
  61. package/lib/git.mjs +133 -32
  62. package/lib/git_diff.mjs +121 -0
  63. package/lib/harness.mjs +14 -10
  64. package/lib/host_contract.mjs +71 -0
  65. package/lib/host_definitions.mjs +22 -0
  66. package/lib/hosts/bitbucket.mjs +553 -0
  67. package/lib/hosts/bitbucket_contract.mjs +57 -0
  68. package/lib/hosts/bitbucket_credential.mjs +62 -0
  69. package/lib/hosts/bitbucket_review.mjs +44 -0
  70. package/lib/hosts/bitbucket_review_producer.mjs +50 -0
  71. package/lib/hosts/github.mjs +57 -0
  72. package/lib/hosts/github_contract.mjs +27 -0
  73. package/lib/hosts/github_doctor.mjs +482 -0
  74. package/lib/hosts/github_review_summary.mjs +38 -0
  75. package/lib/hosts/transport_doctor.mjs +35 -0
  76. package/lib/hosts.mjs +130 -0
  77. package/lib/learning_qualification.mjs +1 -1
  78. package/lib/notify.mjs +1 -1
  79. package/lib/openai_agents_input.mjs +7 -2
  80. package/lib/openai_agents_sources.mjs +16 -8
  81. package/lib/plugin_package.mjs +13 -1
  82. package/lib/pr_operation.mjs +41 -0
  83. package/lib/prd.mjs +18 -3
  84. package/lib/preparation_codex.mjs +6 -1
  85. package/lib/probe.mjs +6 -4
  86. package/lib/process.mjs +24 -0
  87. package/lib/promotion.mjs +132 -0
  88. package/lib/promotion_git.mjs +63 -0
  89. package/lib/prompt.mjs +16 -13
  90. package/lib/provider_health.mjs +14 -3
  91. package/lib/repository.mjs +68 -0
  92. package/lib/repository_preflight.mjs +11 -0
  93. package/lib/review_evaluation.mjs +25 -0
  94. package/lib/review_evidence.mjs +500 -0
  95. package/lib/review_history.mjs +73 -0
  96. package/lib/review_image.mjs +145 -0
  97. package/lib/review_link.mjs +12 -0
  98. package/lib/review_policy.mjs +132 -0
  99. package/lib/review_policy_contract.mjs +18 -0
  100. package/lib/review_state.mjs +13 -0
  101. package/lib/review_visual.mjs +94 -0
  102. package/lib/runtime-environment.mjs +6 -0
  103. package/lib/runtime_node.mjs +3 -2
  104. package/lib/runtime_state.mjs +39 -5
  105. package/lib/sources/prd.mjs +6 -3
  106. package/lib/state.mjs +137 -24
  107. package/lib/task_completion.mjs +7 -3
  108. package/lib/task_contract.mjs +201 -27
  109. package/lib/task_delivery.mjs +9 -2
  110. package/lib/task_delivery_contract.mjs +13 -9
  111. package/lib/task_delivery_view.mjs +18 -2
  112. package/lib/task_environment.mjs +13 -1
  113. package/lib/task_limits.mjs +13 -0
  114. package/lib/task_model_config.mjs +99 -0
  115. package/lib/task_model_route.mjs +243 -0
  116. package/lib/task_prepare.mjs +21 -8
  117. package/lib/task_profile.mjs +1875 -0
  118. package/lib/task_repository_context.mjs +65 -0
  119. package/lib/tasks.mjs +252 -51
  120. package/lib/tick.mjs +373 -98
  121. package/lib/time.mjs +1 -0
  122. package/lib/tracker/pages.mjs +19 -5
  123. package/lib/tracker/review.mjs +21 -0
  124. package/lib/tracker_process.mjs +8 -1
  125. package/lib/verification_sandbox.mjs +2 -1
  126. package/lib/work_artifacts.mjs +79 -0
  127. package/npm-shrinkwrap.json +12 -3
  128. package/package.json +9 -3
  129. package/templates/plugin/task-delivery.SKILL.md +17 -10
  130. package/templates/plugin/task-intake-contract.json +18 -0
  131. package/templates/plugin/task-profile-requests.mjs +1448 -0
  132. package/templates/tracker/tracker.css +3 -0
  133. package/templates/tracker/tracker.js +38 -0
package/lib/tick.mjs CHANGED
@@ -1,3 +1,9 @@
1
+ import { createHash } from "node:crypto";
2
+ import { PUBLICATION_POLICY_VERSION, publicationPolicyOf, workArtifactViolations, workArtifactError } from "./work_artifacts.mjs";
3
+ import { gitDiffChangedPaths } from "./git_diff.mjs";
4
+ import { hosts, completionAdapter } from "./hosts.mjs";
5
+ import { assertRepositoryAccess, observedIdentityProblem, terminalIdentityProblem } from "./host_contract.mjs";
6
+ import { hostApi } from "./hosts.mjs";
1
7
  // The tick: the one actor that changes state. Every `poll_seconds` it walks the registry once, in the order PRD 6.8
2
8
  // lays out: settle finished runs, fetch, terminal states, events, dispatch, ready, silence.
3
9
  //
@@ -11,20 +17,24 @@
11
17
  // on, so a push after that reopens it; `checks_sha` is the head whose failing checks were already dispatched;
12
18
  // `receipt_hash` is the completion receipt when the last run started.
13
19
  import { STATES, handleFor, prdIdOf, advanceCursor } from "./state.mjs";
14
- import { deriveEvents, makeTrust, reviewerVerdict, reviewerClock, isGreen, addedLines, hasOwnComment, eventKey, isFixCommand } from "./events.mjs";
20
+ import { repositoryReviewConfig } from "./review_policy.mjs";
21
+ import { readFeedback, requiredFeedback, deriveEvents, makeTrust, reviewerVerdict, exactReviewerVerdict, reviewerClock, isGreen, addedLines, hasOwnComment, eventKey, isFixCommand } from "./events.mjs";
22
+ import { legacyReviewPolicy, policyConfig, reviewBinding, reviewVerdict, needsFirstParty, needsExternal, reviewDigest } from "./review_policy.mjs";
23
+ import { findingsPrompt } from "./review_evidence.mjs";
15
24
  import { fixPrompt, registerPrompt } from "./prompt.mjs";
16
25
  import { parsePrd, verifyCommands, criteriaFromPrd, briefDivergence, divergenceReason, renderDivergence, retakePrdSnapshot } from "./prd.mjs";
17
26
  import { gate, denialsPrompt, receiptInstructions, receiptHash, receiptHashes } from "./deliverable.mjs";
18
27
  import { normalizePublicBody } from "./block.mjs";
19
- import { ROUND_MARKER, NUDGE_MARKER, READY_LABEL, NEEDS_LABEL, actor, normalizeLogin } from "./gh.mjs";
28
+ import { ROUND_MARKER, NUDGE_MARKER, READY_LABEL, NEEDS_LABEL, actor, normalizeLogin } from "./host_contract.mjs";
20
29
  import { publicPrViolations, publicCommentViolations, publicCorrection, publicReviewHash } from "./public_output.mjs";
21
30
  import { hasLegacyStatusLabels } from "./public-format.mjs";
22
31
  import { adapterFor, buildRun, capabilitiesFor } from "./harness.mjs";
23
- import { executionRunner, unsubmittedInitialDispatch } from "./execution.mjs";
32
+ import { executionRunner, reserveExecutionEpoch, unsubmittedInitialDispatch } from "./execution.mjs";
24
33
  import { reconcileExecutionResult, executionResultCheckpoint, requireVerificationSandbox } from "./execution_result.mjs";
25
34
  import { readJson } from "./fs.mjs";
26
35
  import { join } from "node:path";
27
- import { runEnv, prdIdFromBranch } from "./git.mjs";
36
+ import { isDeepStrictEqual } from "node:util";
37
+ import { runSessionEnv, prdIdFromBranch } from "./git.mjs";
28
38
  import { resolveCredentialFor } from "./credential.mjs";
29
39
  import { requireIdentity } from "./identity.mjs";
30
40
  import { resolveContextPaths, resolveTimeout } from "./config.mjs";
@@ -38,6 +48,7 @@ import { hash as learningHash } from "./learning_data.mjs";
38
48
  import { boundedCall } from "./execution_io.mjs";
39
49
  import { networkFailure, validNetworkRetry, NETWORK_RETRY_DELAYS, NETWORK_RECOVERY_WINDOW } from "./network_recovery.mjs";
40
50
  import { completionStopsAutomation } from "./task_completion.mjs";
51
+ import { withReviewState } from "./review_state.mjs";
41
52
 
42
53
  const CONCURRENCY = 4;
43
54
 
@@ -85,6 +96,7 @@ export const READY_LABEL_REMOVAL = "ready label removal";
85
96
  export const READY_LABEL_WRITE = "ready label write";
86
97
  export const ROUND_COMMENT = "round comment";
87
98
  export const COMPLETION_GATE = "completion gate";
99
+ export const REVIEW_CAPTURE = "review evidence capture";
88
100
  export const PUBLIC_METADATA = "public metadata write";
89
101
  export const REGULAR_PR = "regular PR conversion";
90
102
 
@@ -97,7 +109,8 @@ const fetchFrom = iso => (iso ? new Date(Date.parse(floorSecond(iso)) - 1000).to
97
109
  export function makeTick(deps) {
98
110
  // `writePrd` is here for one caller: the re-snapshot of a brief edited outside its contract. It is the only write to
99
111
  // the snapshot the tick makes, and it is not the tick overruling the operator, it is the tick following them.
100
- const { config, state, gh, git, notify, log, clock = Date, ownLogin, readPrd, writePrd = () => {}, fetch = globalThis.fetch, aportApiKey = null, credential: ctxCredential = null, identity: ctxIdentity = null } = deps;
112
+ const { config, state, git, notify, log, clock = Date, ownLogin, readPrd, writePrd = () => {}, fetch = globalThis.fetch, aportApiKey = null, credential: ctxCredential = null, identity: ctxIdentity = null } = deps;
113
+ const host = hostApi(deps);
101
114
  const runnerFor = id => executionRunner(deps, id);
102
115
  const completed = (id, entry) => {
103
116
  if (!completionStopsAutomation(entry)) return false;
@@ -109,29 +122,48 @@ export function makeTick(deps) {
109
122
  const startupReconciliation = new Set(deps.startupReconciliation === false ? [] : state.entries().map(([id]) => id));
110
123
  // Embedders may restore the canonical cadence after a bounded wake.
111
124
  let nextChainAt = deps.nextChainAt ?? 0;
112
- const trust = makeTrust(config, ownLogin);
125
+ const ownLogins = new Map();
126
+ const loginFor = entry => ownLogins.get(entry.repo) || ownLogin;
127
+ const reviewConfigFor = entry => policyConfig(config, entry);
128
+ const trustFor = entry => makeTrust(reviewConfigFor(entry), loginFor(entry));
113
129
  const learning = deps.learning || deps.learningFor?.(ownLogin);
114
130
  const observationQueue = [];
115
131
  let observationOffset = 0, observationSelection = null;
116
132
  const now = () => nowIso(clock);
117
133
 
118
- const save = (id, patch) => state.update(id, { ...patch, updated_at: now() });
134
+ // Progress polling is not itself a task change. Preserve the UI timestamp
135
+ // and let state.update skip the write when every proposed field is unchanged.
136
+ const save = (id, patch) => state.update(id, current =>
137
+ Object.entries(patch).some(([key, value]) => !isDeepStrictEqual(current[key], value))
138
+ ? { ...patch, updated_at: now() } : {});
139
+ const reviewDispositionPatch = (id, entry, key, patch) => {
140
+ if (!key) return {};
141
+ const current = entry.firstPartyReview?.key === key;
142
+ const record = current ? entry.firstPartyReview : state.reviewHistoryRecord ? state.reviewHistoryRecord(id, key) : (entry.reviewHistory || []).findLast(r => r.key === key);
143
+ if (record?._gallivantReviewArchive) throw new Error("historical review must be integrity-checked before updating its disposition");
144
+ if (!record) return {};
145
+ const updated = { ...record, disposition: { ...record.disposition, ...patch } };
146
+ return { ...(current ? { firstPartyReview: updated } : {}), reviewHistory: [...(entry.reviewHistory || []), updated] };
147
+ };
148
+ const externalVerdict = (entry, input) => entry.reviewPolicy?.version === 1
149
+ ? exactReviewerVerdict(input, host.refSha ? sha => host.refSha(entry.repo, sha) : null)
150
+ : reviewerVerdict(input);
119
151
 
120
152
  const recoveryIdentity = (entry, session = entry.session) => ({ prd: entry.prd, harness: entry.harness, session, branch: entry.branch });
121
153
 
122
154
  // Keep this boundary separate from the event cursor: an uncorrected own reply must not disappear after a dispatch.
123
155
  const ownPublicComments = (entry, comments) => comments.filter(c => {
124
- const author = actor(c.user ?? c.author);
125
- return !author.bot && author.login === normalizeLogin(ownLogin)
156
+ const author = actor(c.actor ?? c.user ?? c.author);
157
+ return !author.bot && author.login === normalizeLogin(loginFor(entry))
126
158
  && !isFixCommand(c.body)
127
- && Date.parse(c.updated_at ?? c.updatedAt ?? c.submitted_at ?? c.created_at ?? c.createdAt) >= Date.parse(entry.public_since);
159
+ && Date.parse(c.updatedAt ?? c.updated_at ?? c.at ?? c.submitted_at ?? c.createdAt ?? c.created_at) >= Date.parse(entry.public_since);
128
160
  });
129
161
 
130
162
  async function currentPublicComments(entry) {
131
163
  const [comments, inline, reviews] = await Promise.all([
132
- gh.issueComments(entry.repo, entry.pr, fetchFrom(entry.public_since)),
133
- gh.inlineComments(entry.repo, entry.pr, fetchFrom(entry.public_since)),
134
- gh.reviews(entry.repo, entry.pr),
164
+ host.issueComments(entry.repo, entry.pr, fetchFrom(entry.public_since)),
165
+ host.inlineComments(entry.repo, entry.pr, fetchFrom(entry.public_since)),
166
+ host.reviews(entry.repo, entry.pr),
135
167
  ]);
136
168
  return ownPublicComments(entry, [...comments, ...inline, ...reviews.map(r => ({ ...r, public_kind: "review" }))]);
137
169
  }
@@ -172,9 +204,9 @@ export function makeTick(deps) {
172
204
  // reachable at all is the same question for both writes, and `addLabel` is the one that must land, so that is
173
205
  // what the retry tracks. A removal that fails for any other reason is retried with it on the next tick.
174
206
  let removed = true;
175
- try { await gh.removeLabel(e.repo, e.pr, READY_LABEL); }
207
+ try { await host.removeLabel(e.repo, e.pr, READY_LABEL); }
176
208
  catch (err) { removed = false; log.warn(`${id}: ready label not removed (${err.message})`); }
177
- try { await gh.addLabel(e.repo, e.pr, NEEDS_LABEL); }
209
+ try { await host.addLabel(e.repo, e.pr, NEEDS_LABEL); }
178
210
  catch (err) { log.warn(`${id}: needs-human label not written (${err.message}); retrying next tick`); return false; }
179
211
  if (!removed) log.warn(`${id}: retrying the ready-label removal next tick`);
180
212
  return removed;
@@ -275,6 +307,7 @@ export function makeTick(deps) {
275
307
 
276
308
  /** The checkout, recreated from the PR branch when it is gone (a lost disk, a cleaned worktrees folder). */
277
309
  const ensureWorktree = async (id, entry) => {
310
+ assertRepositoryAccess(capabilitiesFor(entry.harness), (deps.hosts ?? hosts).select(entry).repository);
278
311
  if (!git.exists(entry.worktree)) {
279
312
  await git.fromBranch({ nameWithOwner: entry.repo, prdId: prdIdFromBranch(entry.branch) ?? prdIdOf(id), branch: entry.branch, path: entry.worktree });
280
313
  restoreSources(id, entry);
@@ -317,7 +350,11 @@ export function makeTick(deps) {
317
350
  if (!entry.base || !git.mergeBase) return null;
318
351
 
319
352
  let r;
320
- try { await git.ensureHttpsRemote?.(entry.worktree); r = await git.mergeBase(entry.worktree, entry.base); }
353
+ try {
354
+ const hook = git.installHooks?.(entry.worktree, publicationPolicyOf(prdFor(id), { sources: entry.sources || [], base: entry.base }));
355
+ if (hook && !hook.installed) log.warn(`${id}: the commit-msg hook could not be written into ${entry.worktree} (${hook.why}); local commit checks are unavailable; coordinator publication checks remain mandatory`);
356
+ await git.ensureHttpsRemote?.(entry.worktree, entry.repo); r = await git.mergeBase(entry.worktree, entry.base);
357
+ }
321
358
  catch (e) {
322
359
  // The fetch or a rev-parse failed, so no merge was attempted and the tree is untouched. Same shape as a conflict
323
360
  // to the caller, because the outcome for this round is the same: it does not start, and the operator is told which
@@ -337,9 +374,9 @@ export function makeTick(deps) {
337
374
  * registry is saved, so a crash in between leaves a comment, not a lost one; the check makes the retry harmless.
338
375
  */
339
376
  async function commentOnce(entry, body, marker, sinceIso) {
340
- const own = await gh.issueComments(entry.repo, entry.pr, sinceIso);
341
- if (hasOwnComment(own, ownLogin, marker, sinceIso)) return false;
342
- await gh.comment(entry.repo, entry.pr, body);
377
+ const own = await host.issueComments(entry.repo, entry.pr, sinceIso);
378
+ if (hasOwnComment(own, loginFor(entry), marker, sinceIso)) return false;
379
+ await host.comment(entry.repo, entry.pr, body);
343
380
  return true;
344
381
  }
345
382
 
@@ -362,7 +399,12 @@ export function makeTick(deps) {
362
399
  */
363
400
  async function reopen(id, entry) {
364
401
  if (entry.state !== STATES.ready) return;
365
- await gh.removeLabel(entry.repo, entry.pr, READY_LABEL);
402
+ if (entry.reviewPolicy?.version === 1) {
403
+ save(id, { state: STATES.prOpen, review_readiness: null, review_label_pending: true,
404
+ ...(entry.completionEvidence?.delivery ? { completionEvidence: { ...entry.completionEvidence, delivery: { ...entry.completionEvidence.delivery, invalidatedAt: now(), invalidationReason: "review readiness reopened" } } } : {}) });
405
+ }
406
+ await host.removeLabel(entry.repo, entry.pr, READY_LABEL);
407
+ if (entry.reviewPolicy?.version === 1) save(id, { review_label_pending: false });
366
408
  // `gate_skipped` describes the ready state it was recorded with, so it goes as the ready state goes. Left behind,
367
409
  // `status` and `why` would keep warning about a gate that did not run for a head nobody is being asked to merge.
368
410
  if (entry.gate_skipped) save(id, { gate_skipped: null });
@@ -417,7 +459,7 @@ export function makeTick(deps) {
417
459
 
418
460
  function assertProviderHealth(entry) {
419
461
  const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
420
- if (health && !health.ok) throw Object.assign(new Error(`provider health unavailable: ${health.detail}; ${health.fix || "restore this provider account"}`), { code: "PROVIDER_UNAVAILABLE" });
462
+ if (health && !health.ok) throw Object.assign(new Error(`provider health unavailable: ${health.detail}; ${health.fix || "restore this provider account"}`), { code: "PROVIDER_UNAVAILABLE", retryableProviderPending: health.pending === true });
421
463
  }
422
464
 
423
465
  /**
@@ -433,7 +475,7 @@ export function makeTick(deps) {
433
475
  * to push, which is money spent for work nobody can reach. The throw is caught by the caller, which stops the entry
434
476
  * with `no-credential` and the fix, so the operator is told rather than left reading a dead run's log.
435
477
  */
436
- async function startRun(id, entry, { name, prompt, ownership = null, paths = [], dispatchDeadline = null }) {
478
+ async function startRun(id, entry, { name, prompt, ownership = null, paths = [], dispatchDeadline = null, beforeDispatch = null }) {
437
479
  const assertDispatchOpen = () => {
438
480
  if (dispatchDeadline != null && clock.now() >= dispatchDeadline) throw Object.assign(new Error("network recovery window expired before dispatch"), { code: "NETWORK_RECOVERY_EXPIRED" });
439
481
  const current = state.get(id);
@@ -442,9 +484,20 @@ export function makeTick(deps) {
442
484
  if (stopped) throw Object.assign(new Error(`dispatch stopped: ${stopped}`), { code: "EXECUTION_STOPPED" });
443
485
  };
444
486
  assertDispatchOpen();
487
+ const selected = (deps.hosts ?? hosts).select(entry);
488
+ assertRepositoryAccess(capabilitiesFor(entry.harness), selected.repository);
489
+ repositoryReviewConfig(config, entry.repo, selected.adapter);
490
+ if (selected.adapter.features?.strictIdentity && entry.pr) {
491
+ const current = await host.prView(entry.repo, entry.pr);
492
+ const problem = observedIdentityProblem(entry, current) || (current.state !== "OPEN" ? "PR is no longer open; reconcile its terminal identity before resuming" : null);
493
+ if (problem) {
494
+ await needsHuman(id, "pr-identity", { last: { ...entry.last, at: now(), event: problem } });
495
+ throw Object.assign(new Error(problem), { code: "PR_IDENTITY" });
496
+ }
497
+ }
445
498
  assertProviderHealth(entry);
446
499
  if (capabilitiesFor(entry.harness).transport === "remote") await requireVerificationSandbox();
447
- await git.ensureHttpsRemote?.(entry.worktree);
500
+ await git.ensureHttpsRemote?.(entry.worktree, entry.repo);
448
501
  if (entry.brief_changes?.length && name !== "resume") {
449
502
  // A prior manual resnapshot may have failed after updating daemon state but before copying into the checkout.
450
503
  const brief = readPrd(id);
@@ -453,6 +506,16 @@ export function makeTick(deps) {
453
506
  prompt = `THE BRIEF HAS CHANGED. Reread the brief and its carried files in the worktree.\n${entry.brief_changes.join("\n")}\n\n${prompt}`;
454
507
  }
455
508
  const execution = runnerFor(id), remote = execution.capabilities?.transport === "remote";
509
+ if (execution.ledgerEpoch > 0) reserveExecutionEpoch({ root: execution.root, taskId: id,
510
+ provider: entry.harness, account: execution.account, epoch: execution.ledgerEpoch, pr: entry.pr });
511
+ // A registration made by older code can be recognized from its exact settled
512
+ // old ledger without modifying that ledger. Persist the chosen new epoch
513
+ // before the first launch changes `last` and `execution`, which are the
514
+ // evidence the read-only recovery rule needs on later process starts.
515
+ if (execution.ledgerEpoch > 0 && entry.execution_epoch == null) {
516
+ entry = state.update(id, { execution_epoch: execution.ledgerEpoch,
517
+ execution_handoff: { from: entry.execution?.provider, to: entry.harness, previousRun: entry.last?.run ?? null, at: now() } });
518
+ }
456
519
  const mode = entry.session ? "resume" : "start";
457
520
  const session = entry.session || entry.learning_continuity?.newSession || (remote ? null : adapterFor(entry.harness).newSession());
458
521
  // Extra read-only directories, resolved in one place (lib/config.mjs) from the operator's config list plus the
@@ -463,7 +526,7 @@ export function makeTick(deps) {
463
526
  let spec = remote ? null : buildRun({ harness: entry.harness, config: runConfig, mode, session, prompt, cwd: entry.worktree, contextPaths });
464
527
  // The host comes from this entry's own checkout, so an enterprise remote asks `gh` for that host's token rather
465
528
  // than sending it a github.com one.
466
- const credential = await resolveCredentialFor({ config, git, credential: ctxCredential }, { worktree: entry.worktree });
529
+ const credential = await resolveCredentialFor({ config, git, hosts: deps.hosts, credential: ctxCredential }, { worktree: entry.worktree, repo: entry.repo });
467
530
  if (!credential.token) throw Object.assign(new Error(credential.why), { code: "NO_CREDENTIAL" });
468
531
  // The identity the session commits as, resolved per run for the reason lib/identity.mjs gives: the daemon is
469
532
  // long-lived and an operator may change theirs under it. This is the path `resume` and every fix round take.
@@ -475,8 +538,8 @@ export function makeTick(deps) {
475
538
  // every checkout made before the hook existed. Found on a live resume: the session dropped the attribution trailer
476
539
  // because the prompt told it to, and `.git/hooks/commit-msg` had never been written, so the half meant to enforce
477
540
  // it was absent. Idempotent and never fatal, so this costs a stat and cannot fail a run.
478
- const hook = git.installHooks?.(entry.worktree);
479
- if (hook && !hook.installed) log.warn(`${id}: the commit-msg hook could not be written into ${entry.worktree} (${hook.why}); the prompt still forbids attribution but nothing enforces it this run`);
541
+ const hook = git.installHooks?.(entry.worktree, publicationPolicyOf(prdFor(id), { sources: entry.sources || [], base: entry.base }));
542
+ if (hook && !hook.installed) log.warn(`${id}: the commit-msg hook could not be written into ${entry.worktree} (${hook.why}); local commit checks are unavailable; coordinator publication checks remain mandatory`);
480
543
  const initialDispatch = remote && !session ? unsubmittedInitialDispatch(entry, execution) : null;
481
544
  const dispatchInput = { operationId: initialDispatch?.operationId ?? `${entry.last?.run || "initial"}:${entry.iteration}:${name}${entry.learning_continuity?.status === "prepared" ? ":fresh" : ""}`, sessionId: entry.session, inputHead: git.head ? await git.head(entry.worktree) : null, workflow: ownership || { state: entry.pr ? STATES.prOpen : STATES.running, reason: null, lastEvent: `recovered ${name} dispatch` } };
482
545
  if (entry.learning_continuity?.status === "prepared") dispatchInput.workflow = { ...dispatchInput.workflow,
@@ -497,6 +560,7 @@ export function makeTick(deps) {
497
560
  if (initialDispatch) save(id, { initial_dispatch: { ...initialDispatch, status: "dispatching" }, reason: "dispatch-intent" });
498
561
  try { job = await learningDispatch({ ...deps, learning }, id, managed, () => {
499
562
  assertDispatchOpen();
563
+ beforeDispatch?.();
500
564
  return session && ["native-session", "successor-task"].includes(execution.capabilities.continuation) ? execution.continue(input) : execution.submit({ ...input, sessionId: null });
501
565
  }); }
502
566
  catch (error) {
@@ -505,16 +569,17 @@ export function makeTick(deps) {
505
569
  }
506
570
  } else job = await learningDispatch({ ...deps, learning }, id, managed, () => {
507
571
  assertDispatchOpen();
508
- return execution.start(name, spec.cmd, spec.args, { cwd: entry.worktree, runtimeSettings: spec.runtimeSettings, env: { ...runEnv(config, credential, identity, entry.worktree), ...spec.env }, execution: { ...dispatchInput, ...(entry.learning_continuity?.status === "prepared" ? { recordedSessionId: session } : {}), ...(managed.learning ? { learning: managed.learning, managedInputDigest: managed.managedInputDigest } : {}) } });
572
+ beforeDispatch?.();
573
+ return execution.start(name, spec.cmd, spec.args, { cwd: entry.worktree, runtimeSettings: spec.runtimeSettings, env: runSessionEnv(spec.env, config, credential, identity, entry.worktree), execution: { ...dispatchInput, ...(entry.learning_continuity?.status === "prepared" ? { recordedSessionId: session } : {}), ...(managed.learning ? { learning: managed.learning, managedInputDigest: managed.managedInputDigest } : {}) } });
509
574
  });
510
575
  return { job, session: job.execution?.sessionId || session, managed };
511
576
  }
512
577
 
513
578
  /** When a head was pushed, from GitHub, or `fallback` when GitHub cannot say. */
514
- const headPushedAt = (entry, sha, fallback) => gh.commitDate(entry.repo, sha).then(d => d || fallback).catch(() => fallback);
579
+ const headPushedAt = (entry, sha, fallback) => host.commitDate(entry.repo, sha).then(d => d || fallback).catch(() => fallback);
515
580
 
516
581
  /** The reviewer clock for this entry and this head. The rule itself is `events.mjs`; `reconcile` uses the same one. */
517
- const askedAt = (entry, pushedAt) => reviewerClock({ config, pushedAt, previousAsk: entry.rereview?.at ?? null, floorAt: now() });
582
+ const askedAt = (entry, pushedAt) => reviewerClock({ config: policyConfig(config, entry), pushedAt, previousAsk: entry.rereview?.at ?? null, floorAt: now() });
518
583
 
519
584
  /**
520
585
  * The round comment: the announcement of a round that has already been counted. Called on the settle tick that
@@ -541,7 +606,7 @@ export function makeTick(deps) {
541
606
  async function postRoundComment(id, entry) {
542
607
  const owed = entry.round_comment;
543
608
  if (!owed) return false;
544
- const body = owed.kind === "round" ? roundComment(config) : owed.kind === "public-text" ? `Review text updated.\n\n${ROUND_MARKER}` : receiptRoundComment();
609
+ const body = owed.kind === "round" ? roundComment(reviewConfigFor(entry)) : owed.kind === "public-text" ? `Review text updated.\n\n${ROUND_MARKER}` : receiptRoundComment();
545
610
  try { await commentOnce(entry, body, ROUND_MARKER, owed.since); }
546
611
  catch (err) {
547
612
  // The session is not re-saved here: the count above already persisted it, so there is nothing a retry could
@@ -589,7 +654,7 @@ export function makeTick(deps) {
589
654
  * (`last.run`) is settled; a leftover run from before a reset or a recovery is ignored. Returns true when the
590
655
  * entry should wait.
591
656
  */
592
- async function settleRun(id, entry) {
657
+ async function settleRun(id, entry, refreshIdentity) {
593
658
  const runner = runnerFor(id);
594
659
  const cur = runner.current();
595
660
  if (!cur) return false;
@@ -600,8 +665,14 @@ export function makeTick(deps) {
600
665
  // leftover process outliving a reset is a separate problem from this entry's state, and it does not get to borrow
601
666
  // this entry's reason.
602
667
  if (entry.last?.run !== cur.id) return false;
603
- if (cur.execution?.quarantined) {
604
- save(id, { last: { ...entry.last, exit: cur.exit ?? 1, event: "late provider result quarantined after stop" } });
668
+ const stoppedByMessage = !entry.dispatch_stopped && entry.control?.action === "message" &&
669
+ entry.control.interrupt === true && entry.control.stop_run === cur.id;
670
+ const interrupted = stoppedByMessage && entry.control.status === "queued";
671
+ if (cur.execution?.quarantined && !interrupted) {
672
+ if (cur.running) return true;
673
+ save(id, { last: { ...entry.last, exit: cur.exit ?? 1, event: "late provider result quarantined after stop" },
674
+ ...(stoppedByMessage ? { state: STATES.needsHuman, reason: "operator-interrupted", network_retry: null } : {}),
675
+ });
605
676
  return false;
606
677
  }
607
678
  if (cur.running) {
@@ -652,13 +723,17 @@ export function makeTick(deps) {
652
723
  if (entry.last?.exit != null) return false;
653
724
 
654
725
  const lines = runner.outcome && runner.sessionFrom ? [] : runner.lines(cur.id);
655
- const outcome = runner.outcome ? runner.outcome(cur.id) : adapterFor(entry.harness).outcome(lines);
726
+ // Intentional local stops quarantine late results too. Settle the owned
727
+ // interruption through the normal PR-discovery path without collecting or
728
+ // trusting that result, so a pre-PR task becomes resumable as well.
729
+ const outcome = cur.execution?.quarantined
730
+ ? { ok: false, finished: true, cost: cur.execution.costUsd ?? null, denials: [] }
731
+ : runner.outcome ? runner.outcome(cur.id) : adapterFor(entry.harness).outcome(lines);
656
732
  const job = runner.settle(cur.id, () => (outcome.ok ? 0 : 1));
657
733
  const session = entry.session || (runner.sessionFrom ? runner.sessionFrom(cur.id) : adapterFor(entry.harness).sessionFrom(lines));
658
- if (runner.collect && outcome.finished && !cur.execution?.collected) await runner.collect();
734
+ if (runner.collect && outcome.finished && !cur.execution?.collected && !cur.execution?.quarantined) await runner.collect();
659
735
  const last = { at: now(), run: cur.id, exit: job.exit, event: entry.last?.event || "start", cost: outcome.cost, denials: outcome.denials };
660
736
 
661
- const interrupted = entry.control?.status === "queued" && entry.control.stop_run === cur.id;
662
737
  if (interrupted && entry.state !== STATES.running) {
663
738
  // An intentional interruption is not a failed review round or a completion.
664
739
  // Account for it once, then let the normal control path resume the saved session.
@@ -706,8 +781,11 @@ export function makeTick(deps) {
706
781
  // streak. Never let old deadlines silently restart a later stopped task.
707
782
  if (entry.network_retry && !recoverableNetwork) entry = save(id, { network_retry: null });
708
783
 
784
+ // Process exit and stop accounting above are local. Host-dependent settlement
785
+ // below needs a verified actor, but an identity outage must not delay a stop.
786
+ await refreshIdentity();
709
787
  if (entry.state === STATES.running) {
710
- const prs = await gh.prListByHead(entry.repo, entry.branch);
788
+ const prs = await host.prListByHead(entry.repo, entry.branch);
711
789
  let pr = (prs || []).find(p => !p.isCrossRepository);
712
790
  if (!pr) {
713
791
  if (await scheduleNetworkRecovery()) return false;
@@ -727,7 +805,17 @@ export function makeTick(deps) {
727
805
  const left = tree && (tree.modified || tree.untracked)
728
806
  ? `; ${[tree.modified && `${tree.modified} modified`, tree.untracked && `${tree.untracked} untracked`].filter(Boolean).join(" and ")} file${tree.modified + tree.untracked === 1 ? "" : "s"} are uncommitted in ${entry.worktree}`
729
807
  : "";
730
- await needsHuman(id, "no-pr", { session, last: { ...last, event: `${last.event}${left}` }, ...(tree ? { tree } : {}) });
808
+ const failed = !outcome.finished || !outcome.ok || job.exit !== 0;
809
+ await needsHuman(id, failed ? "harness-error" : "no-pr", {
810
+ session, last: { ...last, event: `${last.event}${left}`,
811
+ ...(failed ? { error: outcome.finished ? "Worker failed before opening a PR; inspect the owned run log for the provider or tool failure" : "Worker ended without a terminal task result; inspect the owned run log" } : {}) },
812
+ ...(tree ? { tree } : {}),
813
+ });
814
+ return false;
815
+ }
816
+ const identityProblem = observedIdentityProblem(entry, pr);
817
+ if (identityProblem) {
818
+ await needsHuman(id, "pr-identity", { session, last: { ...last, event: identityProblem } });
731
819
  return false;
732
820
  }
733
821
  // The identity block is a precondition of leaving `running`, not a best-effort extra. It is what reconciliation
@@ -752,7 +840,7 @@ export function makeTick(deps) {
752
840
  try {
753
841
  const identity = recoveryIdentity(entry, session);
754
842
  if (normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body) {
755
- pr = await gh.setRecoveryMetadata(entry.repo, pr.number, identity, { taskId: id });
843
+ pr = await host.setRecoveryMetadata(entry.repo, pr.number, identity, { taskId: id });
756
844
  }
757
845
  }
758
846
  catch (err) {
@@ -777,7 +865,9 @@ export function makeTick(deps) {
777
865
  }
778
866
 
779
867
  // A fix run ended. Progress is the head moving or the receipt changing; anything else is a stop.
780
- const pr = await gh.prView(entry.repo, entry.pr);
868
+ const pr = await host.prView(entry.repo, entry.pr);
869
+ const identityProblem = observedIdentityProblem(entry, pr);
870
+ if (identityProblem) { await needsHuman(id, "pr-identity", { session, last: { ...last, event: identityProblem } }); return false; }
781
871
  const pushed = pr.headRefOid !== entry.cursor.fixed_sha;
782
872
  const hash = receiptHash(pr.body);
783
873
  const rewrote = !pushed && hash !== null && !receiptHashes(pr.body).includes(entry.cursor.receipt_hash);
@@ -875,7 +965,7 @@ export function makeTick(deps) {
875
965
  * again next tick and dispatched then; the ones already handled are recognised by key and skipped.
876
966
  */
877
967
  async function dispatch(id, entry, pr, spec, deferredDispatches = null) {
878
- const { name, events, denials = null, summary, since, publicFix = null } = spec;
968
+ const { name, events, denials = null, summary, since, publicFix = null, reviewKey = null } = spec;
879
969
  if (entry.dispatch_stopped) return;
880
970
  const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
881
971
  if (health && !health.ok) {
@@ -924,9 +1014,16 @@ export function makeTick(deps) {
924
1014
  // would undo the record that was just made.
925
1015
  if (merged?.merged) entry = state.get(id) ?? entry;
926
1016
  const fix = { repo: entry.repo, pr: entry.pr, headSha: pr.headRefOid, events, receipt: receiptFor(id), denials, merged: merged?.merged ? merged : null };
1017
+ const previousHarness = entry.execution_handoff?.from ?? (entry.execution?.provider !== entry.harness ? entry.execution?.provider : null);
1018
+ // Recovery from a lost registry rebuilds the entry from the PR block, so
1019
+ // neither of those fields survives. The durable ledger epoch still proves
1020
+ // this is a cross-harness takeover and calls for the same context prompt.
1021
+ const handoff = !entry.session && (previousHarness || runnerFor(id).ledgerEpoch > 0)
1022
+ ? `The operator switched this PR from ${previousHarness || "a previous harness"} to ${entry.harness}. This is a new provider session; the previous conversation is unavailable. Before editing, read the approved brief${entry.prd ? ` at ${entry.prd}` : " if one is recorded"}, its carried files, the PR and review threads, and the local checkout's status and unpushed commits. Preserve existing work and review unresolved feedback. The brief's original harness field records its starting route; this explicit PR reset selected ${entry.harness} for subsequent work.`
1023
+ : null;
927
1024
  const prompt = entry.session
928
- ? fixPrompt({ ...fix, verify: verifyFor(id) })
929
- : `${registerPrompt({ repo: entry.repo, pr: entry.pr })}\n\n${fixPrompt({ ...fix, verify: [] })}`;
1025
+ ? fixPrompt({ host: (deps.hosts ?? hosts).select(entry).adapter.prompts, ...fix, verify: verifyFor(id) })
1026
+ : `${registerPrompt({ host: (deps.hosts ?? hosts).select(entry).adapter.prompts, repo: entry.repo, pr: entry.pr })}${handoff ? `\n\n${handoff}` : ""}\n\n${fixPrompt({ host: (deps.hosts ?? hosts).select(entry).adapter.prompts, ...fix, verify: [] })}`;
930
1027
  // The invariant: a spawned run must be recorded before anything else can fail. Everything that can throw goes
931
1028
  // above the spawn, and the state that owns the run is saved immediately below it with no await in between. A
932
1029
  // process running that the registry does not know about is the worst state this function can leave: `settleRun`
@@ -956,9 +1053,16 @@ export function makeTick(deps) {
956
1053
  let started;
957
1054
  const ownership = { state: STATES.prOpen, reason: null, public_fix: publicFix, lastEvent: summary,
958
1055
  cursor: { ...advanceCursor(entry.cursor, since, events.filter(e => e.id != null && sameInstant(e.at, since)).map(eventKey)), fixed_sha: pr.headRefOid, receipt_hash: receiptHash(pr.body), ...(events.some(e => e.kind === "check") ? { checks_sha: pr.headRefOid } : {}) } };
959
- try { started = await startRun(id, entry, { name, prompt, ownership, paths: events.map(e => e.path).filter(Boolean) }); }
1056
+ try { started = await startRun(id, entry, { name, prompt, ownership, paths: events.map(e => e.path).filter(Boolean),
1057
+ beforeDispatch: reviewKey ? () => {
1058
+ const current = state.get(id);
1059
+ if (current.firstPartyReview?.key !== reviewKey) throw new Error("review findings changed before dispatch; re-evaluate the current input");
1060
+ save(id, reviewDispositionPatch(id, current, reviewKey, { status: "dispatch-intent", at: now(), round: entry.iteration + 1 }));
1061
+ } : null }); }
960
1062
  catch (e) {
1063
+ if (e.code === "PR_IDENTITY") return;
961
1064
  if (e.dispatchNotSent === true) {
1065
+ if (reviewKey) save(id, reviewDispositionPatch(id, state.get(id), reviewKey, { status: "pending", round: null }));
962
1066
  await needsHuman(id, "dispatch-refused", { last: { ...entry.last, at: now(), event: "managed dispatch refused before any provider request; repair prerequisites and explicitly resume" } });
963
1067
  return;
964
1068
  }
@@ -966,7 +1070,10 @@ export function makeTick(deps) {
966
1070
  // holding it: `settleRun` now ignores such a run rather than waiting on it, which is right, but it means the
967
1071
  // dispatch can arrive while the runner is still busy. That is a wait, not an error, and it resolves when the
968
1072
  // leftover ends. The cursor is not moved, so the same feedback is dispatched on the tick that finds it free.
969
- if (e.code === "BUSY") { log.info(`${id}: ${e.message}; dispatching ${summary} once it is free`); return; }
1073
+ if (e.code === "BUSY") {
1074
+ if (reviewKey) save(id, reviewDispositionPatch(id, state.get(id), reviewKey, { status: "pending", round: null }));
1075
+ log.info(`${id}: ${e.message}; dispatching ${summary} once it is free`); return;
1076
+ }
970
1077
  if (e.code === "PROVIDER_UNAVAILABLE") { log.info(`${id}: dispatch waiting for its provider/account health`); return; }
971
1078
  // No credential is an operator problem, not a transient one: retrying every tick would spend nothing but would
972
1079
  // also never resolve, and the entry would sit `pr-open` with feedback nobody is acting on. Stop it and say so.
@@ -988,6 +1095,7 @@ export function makeTick(deps) {
988
1095
  const dispatchedChecks = events.some(e => e.kind === "check");
989
1096
  state.update(id, current => ({
990
1097
  ...learningDeliveryPatch(current, managed, job), updated_at: now(),
1098
+ ...reviewDispositionPatch(id, current, reviewKey, { status: "dispatched", at: now(), run: job.id }),
991
1099
  state: STATES.prOpen, session, brief_changes: null, public_fix: publicFix,
992
1100
  // The cursor and the keys handled at it move together: everything dispatched in the boundary second is
993
1101
  // remembered, so an event sharing that second is still read next tick but never sent a second time.
@@ -1000,6 +1108,11 @@ export function makeTick(deps) {
1000
1108
 
1001
1109
  /** A fix round, or a stop when the round budget is spent. */
1002
1110
  async function fixOrStop(id, entry, pr, spec, deferredDispatches = null) {
1111
+ if (entry.reviewPolicy?.version === 1 && entry.state === STATES.ready) {
1112
+ try { await reopen(id, entry); entry = state.get(id); }
1113
+ catch (error) { await retryFailed(id, state.get(id), READY_LABEL_REMOVAL, error, { action: "readiness invalidated; retrying label removal" }); return; }
1114
+ }
1115
+ if (needsFirstParty(entry.reviewPolicy) && deps.reviewService?.cancel) await deps.reviewService.cancel(id, "author feedback dispatched; prior review input invalidated");
1003
1116
  if (entry.iteration >= config.max_iterations) { await needsHuman(id, "iterations", { last: { at: now(), event: spec.summary } }); return; }
1004
1117
  await dispatch(id, entry, pr, spec, deferredDispatches);
1005
1118
  }
@@ -1009,7 +1122,9 @@ export function makeTick(deps) {
1009
1122
  * before any workflow is queued. A young head with nothing reported is not green, it is not reported yet.
1010
1123
  */
1011
1124
  async function checksSettled(entry, pr) {
1012
- if ((pr.statusCheckRollup || []).length) return isGreen(pr.statusCheckRollup);
1125
+ const required = reviewConfigFor(entry).required_checks;
1126
+ if (required.length || (pr.statusCheckRollup || []).length) return isGreen(pr.statusCheckRollup, required);
1127
+ if (entry.reviewPolicy?.version === 1) return entry.reviewPolicy.zeroChecks === true;
1013
1128
  const pushedAt = await headPushedAt(entry, pr.headRefOid, entry.cursor.since);
1014
1129
  return clock.now() - Date.parse(pushedAt) > minutes(config.checks_grace_minutes);
1015
1130
  }
@@ -1019,7 +1134,7 @@ export function makeTick(deps) {
1019
1134
  // changed head/receipt/review cannot convert an unqualified delivery to done.
1020
1135
  async function terminalCompletion(entry, pr) {
1021
1136
  if (!entry.task?.completion) return {};
1022
- const terminal = { adapter: "github-pull-request", state: pr.state, repository: entry.repo, pr: entry.pr,
1137
+ const terminal = { adapter: completionAdapter(entry.repo, deps.hosts ?? hosts), state: pr.state, repository: entry.repo, pr: entry.pr,
1023
1138
  head: pr.headRefOid, base: pr.baseRefName || null, observedAt: now(), mergedAt: pr.mergedAt || null, mergeCommit: pr.mergeCommit?.oid || null };
1024
1139
  const evidence = { ...entry.completionEvidence, terminal };
1025
1140
  if (pr.state === "MERGED") {
@@ -1029,15 +1144,14 @@ export function makeTick(deps) {
1029
1144
  if (entry.state === STATES.ready && !entry.dispatch_stopped && delivery?.gate?.allow === true && !delivery.invalidatedAt &&
1030
1145
  delivery.head === pr.headRefOid && delivery.base === pr.baseRefName && delivery.receiptHash === receiptHash(pr.body) &&
1031
1146
  Number.isFinite(readyAt) && Number.isFinite(mergedAt) && mergedAt >= readyAt && terminal.mergeCommit) {
1032
- const [reviews, inline, comments] = await Promise.all([
1033
- gh.reviews(entry.repo, entry.pr),
1034
- gh.inlineComments(entry.repo, entry.pr, fetchFrom(earlier(entry.rereview.at, entry.public_since))),
1035
- gh.issueComments(entry.repo, entry.pr, fetchFrom(earlier(entry.rereview.at, entry.public_since))),
1036
- ]);
1037
- const verdict = reviewerVerdict({ reviews, inline, comments, config, trust, sinceIso: entry.rereview.at, headSha: pr.headRefOid, dispatched: entry.cursor.dispatched });
1038
- const pending = deriveEvents({ pr, reviews, inline, comments, entry, trust }).events;
1147
+ const [reviews, inline, comments] = await readFeedback(host, entry.repo, pr, fetchFrom(earlier(entry.rereview.at, entry.public_since)), fetchFrom(earlier(entry.rereview.at, entry.public_since)));
1148
+ const external = await externalVerdict(entry, { reviews, inline, comments, config: reviewConfigFor(entry), trust: trustFor(entry), sinceIso: entry.rereview.at, headSha: pr.headRefOid, dispatched: entry.cursor.dispatched });
1149
+ const verdict = reviewVerdict(entry.reviewPolicy, external, entry.firstPartyReview, reviewBinding(pr, entry.reviewPolicy));
1150
+ const pending = deriveEvents({ pr, reviews, inline, comments, entry, trust: trustFor(entry) }).events;
1039
1151
  const publicIssues = [...publicPrViolations(pr), ...publicCommentViolations(ownPublicComments(entry, [...comments, ...inline, ...reviews.map(r => ({ ...r, public_kind: "review" }))]))];
1040
- qualified = await checksSettled(entry, pr) && ["clear", "none"].includes(verdict.verdict) && !pending.length && !publicIssues.length;
1152
+ qualified = await checksSettled(entry, pr) && ["clear", "none"].includes(verdict.verdict) && !pending.length && !publicIssues.length &&
1153
+ !requiredFeedback({ reviews, inline, trust: trustFor(entry), headSha: pr.headRefOid }).length &&
1154
+ (!entry.reviewPolicy || entry.reviewPolicy.version === 0 || entry.reviewBinding === reviewBinding(pr, entry.reviewPolicy));
1041
1155
  reason = qualified ? null : "Integration was observed after checks, reviewer feedback or public delivery evidence changed.";
1042
1156
  }
1043
1157
  evidence.integration = { ...terminal, qualified, reason };
@@ -1116,8 +1230,13 @@ export function makeTick(deps) {
1116
1230
  const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
1117
1231
  if (health && !health.ok) return true; // The one-hour deadline still bounds an unavailable probe.
1118
1232
  if (entry.pr) {
1119
- const pr = await gh.prView(entry.repo, entry.pr);
1120
- if (pr.state !== "OPEN") { save(id, { state: STATES.done, network_retry: null }); return true; }
1233
+ const pr = await host.prView(entry.repo, entry.pr);
1234
+ if (pr.state !== "OPEN") {
1235
+ const problem = await terminalIdentityProblem(host, entry, pr);
1236
+ if (problem) await needsHuman(id, "pr-identity", { network_retry: null, last: { at: now(), event: problem } });
1237
+ else save(id, { state: STATES.done, activity_at: now(), network_retry: null, ...await terminalCompletion(entry, pr) });
1238
+ return true;
1239
+ }
1121
1240
  }
1122
1241
  // Fence before any await that might submit input. A crash in this interval
1123
1242
  // requires inspection; guessing whether a paid run started is unsafe.
@@ -1146,12 +1265,33 @@ export function makeTick(deps) {
1146
1265
  return true;
1147
1266
  }
1148
1267
 
1149
- async function tickEntry(id, entry, { skipReconciliation = false, deferredDispatches = null, settleOnly = false } = {}) {
1268
+ async function tickEntry(id, entry, options = {}) {
1269
+ return withReviewState(deps, id, () => tickEntryOwned(id, state.get(id) || entry, options));
1270
+ }
1271
+
1272
+ async function tickEntryOwned(id, entry, { skipReconciliation = false, deferredDispatches = null, settleOnly = false } = {}) {
1273
+ // Retain the head belonging to the saved binding even if settleRun moves
1274
+ // the author cursor before this tick reaches review invalidation.
1275
+ const reviewedHead = entry.reviewHead ?? entry.cursor?.fixed_sha;
1276
+ await deps.reviewService?.expire?.(id);
1277
+ if (entry.review_label_pending && entry.pr) {
1278
+ try { await host.removeLabel(entry.repo, entry.pr, READY_LABEL); entry = save(id, { review_label_pending: false }); }
1279
+ catch (error) { await retryFailed(id, entry, READY_LABEL_REMOVAL, error, { action: "readiness invalidated; retrying label removal" }); return; }
1280
+ }
1281
+ (deps.hosts ?? hosts).select(entry);
1282
+ let identityRefreshed = false;
1283
+ const refreshIdentity = async () => {
1284
+ if (identityRefreshed) return;
1285
+ if (host.userFor) ownLogins.set(entry.repo, await host.userFor(entry.repo));
1286
+ identityRefreshed = true;
1287
+ };
1150
1288
  if (completed(id, entry)) { await learningDelivered({ ...deps, learning }, id); return; }
1151
1289
  if (!skipReconciliation) await learningDelivered({ ...deps, learning }, id);
1152
- if (await cancelControl(deps, id)) return;
1290
+ if (await cancelControl(deps, id)) { await deps.reviewService?.cancel?.(id, "task cancelled"); return; }
1153
1291
  if (!settleOnly && await recoverControl(deps, id)) return;
1154
1292
  const execution = runnerFor(id);
1293
+ if (execution.ledgerEpoch > 0) reserveExecutionEpoch({ root: execution.root, taskId: id,
1294
+ provider: entry.harness, account: execution.account, epoch: execution.ledgerEpoch, pr: entry.pr });
1155
1295
  if (execution.reconcile) {
1156
1296
  if (!skipReconciliation) await execution.reconcile({ force: startupReconciliation.delete(id) });
1157
1297
  const pending = execution.current();
@@ -1176,13 +1316,16 @@ export function makeTick(deps) {
1176
1316
  }
1177
1317
  }
1178
1318
  if (entry.state === STATES.done) { if (!settleOnly) await applyControl(deps, id); return; }
1319
+ // Do not migrate a record whose provider result is still contradictory or
1320
+ // unowned. That recovery boundary must remain a strictly read-only failure.
1321
+ if (!entry.reviewPolicy) entry = save(id, { reviewPolicy: legacyReviewPolicy(reviewConfigFor(entry)) });
1179
1322
  if (!entry.public_since) entry = save(id, { public_since: floorSecond(now()) });
1180
1323
  if (interruptControl(deps, id)) {
1181
1324
  await needsHuman(id, "run-stop-unverified");
1182
1325
  return;
1183
1326
  }
1184
1327
  entry = state.get(id);
1185
- if (await settleRun(id, entry)) return;
1328
+ if (await settleRun(id, entry, refreshIdentity)) return;
1186
1329
  if (completed(id, state.get(id))) return;
1187
1330
  // Phase-bounded live campaigns reuse settlement/publication, but must not
1188
1331
  // authorize another paid review or queued resume in the same observation.
@@ -1192,10 +1335,17 @@ export function makeTick(deps) {
1192
1335
  if (entry && await recoverNetwork(id, entry)) return;
1193
1336
  if (!entry || entry.state === STATES.running || entry.state === STATES.done || !entry.pr) return;
1194
1337
  if (deps.executionFor && entry.review_next_at && clock.now() < entry.review_next_at && !controlPending(entry.control)) return;
1338
+ await refreshIdentity();
1195
1339
  if (deps.executionFor) entry = save(id, { review_next_at: clock.now() + config.poll_seconds * 1000 });
1196
1340
 
1197
- let pr = await gh.prView(entry.repo, entry.pr);
1198
- if (pr.state === "MERGED" || pr.state === "CLOSED") { save(id, { state: STATES.done, ...await terminalCompletion(entry, pr) }); log.info(`${id}: ${pr.state.toLowerCase()}`); return; }
1341
+ let pr = await host.prView(entry.repo, entry.pr);
1342
+ const identityProblem = observedIdentityProblem(entry, pr);
1343
+ if (identityProblem) { await needsHuman(id, "pr-identity", { last: { at: now(), event: identityProblem } }); return; }
1344
+ if (pr.state === "MERGED" || pr.state === "CLOSED") {
1345
+ const problem = await terminalIdentityProblem(host, entry, pr);
1346
+ if (problem) { await needsHuman(id, "pr-identity", { last: { at: now(), event: problem } }); return; }
1347
+ save(id, { state: STATES.done, activity_at: now(), ...await terminalCompletion(entry, pr) }); log.info(`${id}: ${pr.state.toLowerCase()}`); return;
1348
+ }
1199
1349
  // Regular means reviewable, not complete. Registration adopts the same policy as start. Recover only legacy
1200
1350
  // draft-specific stops; no other needs-human reason is cleared by changing this GitHub presentation state.
1201
1351
  if (entry.state === STATES.needsHuman && entry.reason === "draft") {
@@ -1216,7 +1366,7 @@ export function makeTick(deps) {
1216
1366
  if (entry.prd && normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body) {
1217
1367
  const beforeReceipt = receiptHash(pr.body);
1218
1368
  const sameReceipt = receiptHashes(pr.body).includes(entry.cursor.receipt_hash);
1219
- pr = await gh.setRecoveryMetadata(entry.repo, entry.pr, identity, { taskId: id });
1369
+ pr = await host.setRecoveryMetadata(entry.repo, entry.pr, identity, { taskId: id });
1220
1370
  if (sameReceipt && receiptHash(pr.body) === beforeReceipt) entry = save(id, { cursor: { ...entry.cursor, receipt_hash: beforeReceipt } });
1221
1371
  }
1222
1372
  if (entry.retry?.op === PUBLIC_METADATA) entry = retryOk(id, entry);
@@ -1225,7 +1375,7 @@ export function makeTick(deps) {
1225
1375
  return;
1226
1376
  }
1227
1377
  if (pr.isDraft) {
1228
- try { await gh.markReady(entry.repo, entry.pr); }
1378
+ try { await host.markReady(entry.repo, entry.pr); }
1229
1379
  catch (err) {
1230
1380
  await retryFailed(id, entry, REGULAR_PR, err, { action: "retrying regular PR conversion next tick" });
1231
1381
  return;
@@ -1281,11 +1431,7 @@ export function makeTick(deps) {
1281
1431
  // `updated_at` and stamps to the second, so a fetch starting exactly at the cursor can leave out the items
1282
1432
  // `cursor.dispatched` exists to re-examine. Overlap here, deduplicate in `deriveEvents`: reading an item a
1283
1433
  // second time costs nothing, never reading it again is the bug.
1284
- const [reviews, inline, comments] = await Promise.all([
1285
- gh.reviews(entry.repo, entry.pr),
1286
- gh.inlineComments(entry.repo, entry.pr, fetchFrom(inlineSince)),
1287
- gh.issueComments(entry.repo, entry.pr, fetchFrom(earlier(entry.cursor.since, entry.public_since))),
1288
- ]);
1434
+ const [reviews, inline, comments] = await readFeedback(host, entry.repo, pr, fetchFrom(inlineSince), fetchFrom(earlier(entry.cursor.since, entry.public_since)));
1289
1435
  if (learning && (!observationSelection || observationSelection.has(id)) && observationQueue.length < 100) {
1290
1436
  try {
1291
1437
  const repo = learning.identityFor(entry.repo);
@@ -1298,7 +1444,7 @@ export function makeTick(deps) {
1298
1444
  await fixOrStop(id, entry, pr, { name: "public-text", events: [], denials: publicCorrection(publicViolations), summary: `public text correction: ${publicViolations.join("; ")}`, since: entry.cursor.since, publicFix: publicReviewHash(pr, ownComments) }, deferredDispatches);
1299
1445
  return;
1300
1446
  }
1301
- const { events, newest } = deriveEvents({ pr, reviews, inline, comments, entry, trust });
1447
+ const { events, newest } = deriveEvents({ pr, reviews, inline, comments, entry, trust: trustFor(entry) });
1302
1448
 
1303
1449
  if (events.length) {
1304
1450
  const summary = events.map(e => e.kind === "check" ? `check ${e.name}` : `${e.kind} ${e.id ?? ""} by ${e.author}`).join(", ");
@@ -1313,9 +1459,105 @@ export function makeTick(deps) {
1313
1459
  const changed = await headChanged(id, entry, pr);
1314
1460
  if (changed === "declined") return;
1315
1461
  if (changed) entry = changed;
1316
- const judge = entry.state === STATES.prOpen || Boolean(entry.task?.completion && entry.completionEvidence?.delivery);
1317
- const verdict = judge ? reviewerVerdict({ reviews, inline, comments, config, trust, sinceIso: entry.rereview.at, headSha: pr.headRefOid, dispatched: entry.cursor.dispatched }) : null;
1462
+ const judge = entry.state === STATES.prOpen || entry.state === STATES.ready || Boolean(entry.task?.completion && entry.completionEvidence?.delivery);
1463
+ const binding = reviewBinding(pr, entry.reviewPolicy);
1464
+ if (entry.reviewPolicy.version === 1 && entry.reviewBinding && entry.reviewBinding !== binding) {
1465
+ const headMoved = reviewedHead && reviewedHead !== pr.headRefOid;
1466
+ entry = save(id, { reviewBinding: binding, reviewHead: pr.headRefOid, firstPartyReview: null, review_readiness: null,
1467
+ ...(entry.completionEvidence?.delivery ? { completionEvidence: { ...entry.completionEvidence, delivery: { ...entry.completionEvidence.delivery, invalidatedAt: now(), invalidationReason: "review input changed" } } } : {}),
1468
+ reviewHistory: [...(entry.reviewHistory || []), ...(entry.firstPartyReview ? [entry.firstPartyReview] : [])],
1469
+ rereview: headMoved ? entry.rereview : { at: now(), nudged: false } });
1470
+ if (entry.state === STATES.ready) {
1471
+ await reopen(id, entry);
1472
+ entry = save(id, { state: STATES.prOpen, gate_skipped: null });
1473
+ }
1474
+ // A new head already has a push-based window from headChanged or round
1475
+ // settlement. Same-head evidence edits need a new answer after this observation.
1476
+ if (!headMoved && needsExternal(entry.reviewPolicy)) {
1477
+ await commentOnce(entry, roundComment(reviewConfigFor(entry)), ROUND_MARKER, entry.rereview.at);
1478
+ }
1479
+ if (!headMoved) return;
1480
+ }
1481
+ if (!entry.reviewBinding || entry.reviewPolicy.version === 1 && !entry.reviewHead) entry = save(id, {
1482
+ reviewBinding: binding, ...(entry.reviewPolicy.version === 1 ? { reviewHead: pr.headRefOid } : {}),
1483
+ });
1484
+ const external = judge ? await externalVerdict(entry, { reviews, inline, comments, config: reviewConfigFor(entry), trust: trustFor(entry), sinceIso: entry.rereview.at, headSha: pr.headRefOid, dispatched: entry.cursor.dispatched }) : null;
1485
+ const blockers = requiredFeedback({ reviews, inline, trust: trustFor(entry), headSha: pr.headRefOid });
1486
+ if (blockers.length) {
1487
+ await needsHuman(id, "reviewer-blocking", { last: { at: now(), event: `Resolve required/unbound feedback on ${pr.url}: ${blockers.map(b => b.url || b.eventId).join(", ")}` } });
1488
+ return;
1489
+ }
1318
1490
  const green = judge && await checksSettled(entry, pr);
1491
+ if (judge && entry.reviewPolicy.version === 1 && !(pr.statusCheckRollup || []).length && !entry.reviewPolicy.zeroChecks) {
1492
+ await needsHuman(id, "missing-ci", { last: { at: now(), event: "No checks reported; explicit review.zero_checks policy is required for a repository with zero required checks" } }); return;
1493
+ }
1494
+ let publicationDiff = null;
1495
+ const checkPublication = async ({ force = false } = {}) => {
1496
+ let violations, policy, publicationBinding;
1497
+ try {
1498
+ policy = publicationPolicyOf(prdFor(id), { sources: entry.sources || [], base: entry.base });
1499
+ publicationBinding = createHash("sha256").update(JSON.stringify([PUBLICATION_POLICY_VERSION, entry.repo, entry.pr, pr.headRefOid, pr.baseRefOid, pr.baseRefName, policy])).digest("hex");
1500
+ if (!force && entry.publication_check?.binding === publicationBinding) return true;
1501
+ publicationDiff = await host.prDiff(entry.repo, entry.pr);
1502
+ violations = workArtifactViolations(gitDiffChangedPaths(publicationDiff), policy);
1503
+ // prDiff is a live PR endpoint. Do not cache a diff fetched while either
1504
+ // branch moved against the review identity observed at the start of the tick.
1505
+ const current = await host.prView(entry.repo, entry.pr);
1506
+ if (!current || current.state !== "OPEN" || current.headRefOid !== pr.headRefOid || current.baseRefOid !== pr.baseRefOid || current.baseRefName !== pr.baseRefName) throw new Error("PR head/base changed while reading publication paths");
1507
+ } catch (error) {
1508
+ await retryFailed(id, entry, COMPLETION_GATE, new Error(`publication diff unreadable: ${error.message}`), { action: "retrying the publication check next tick" });
1509
+ return false;
1510
+ }
1511
+ if (violations.length) {
1512
+ await fixOrStop(id, entry, pr, { name: "publication-cleanup", events: [], denials: workArtifactError(violations), summary: `remove ${violations.length} task work artifact(s) from the PR`, since: readAt }, deferredDispatches);
1513
+ return false;
1514
+ }
1515
+ entry = save(id, { publication_check: { binding: publicationBinding, checkedAt: now() } });
1516
+ return true;
1517
+ };
1518
+ // Reject scratch before expensive evidence capture/review. Persist only a
1519
+ // clean head/base/policy binding, never diff bytes; review polls reuse it.
1520
+ if (judge && green && !pr.isDraft && !await checkPublication()) return;
1521
+ if (judge && green && !pr.isDraft && needsFirstParty(entry.reviewPolicy) && external.verdict !== "blocking") {
1522
+ try {
1523
+ const divergence = divergenceFor(id, entry), reason = divergenceReason(divergence);
1524
+ if (reason) { await needsHuman(id, reason, { prd_divergence: { at: now(), why: divergence.why, diff: divergence.diff } }); return; }
1525
+ if (divergence.verdict === "same" && divergence.repoText !== readPrd(id)) {
1526
+ retakePrdSnapshot({ log, writePrd, state }, { id, divergence });
1527
+ prds.set(id, divergence.repo); entry = state.get(id);
1528
+ }
1529
+ if (!deps.reviewService) throw new Error("qualified first-party reviewer service is unavailable; restore the selected route");
1530
+ const record = await deps.reviewService.reconcile(id, entry, pr);
1531
+ entry = save(id, { firstPartyReview: record });
1532
+ if (entry.retry?.op === REVIEW_CAPTURE) entry = retryOk(id, entry);
1533
+ if (record.status === "failed" || record.status === "incomplete") { await needsHuman(id, record.status === "incomplete" ? "review-incomplete" : "first-party-review-failed", { last: { at: now(), event: record.error } }); return; }
1534
+ if (record.status === "completed" && record.result.verdict === "findings") {
1535
+ const previous = (entry.reviewHistory || []).findLast(r => r.key === record.key);
1536
+ const previouslyDispatched = ["dispatched", "dispatch-intent"].includes(previous?.disposition?.status);
1537
+ if (previouslyDispatched || ["dispatched", "dispatch-intent"].includes(record.disposition?.status) || needsExternal(entry.reviewPolicy) && external.verdict === "clear") {
1538
+ await needsHuman(id, previouslyDispatched || record.disposition ? "repeated-review-findings" : "review-disagreement"); return;
1539
+ }
1540
+ const exhausted = entry.iteration >= config.max_iterations;
1541
+ const disposition = { status: exhausted ? "rounds-exhausted" : "pending", at: now(), round: null, findingsDigest: reviewDigest(record.result.findings) };
1542
+ // Pre-dispatch waits remain retryable. The shared launch boundary
1543
+ // records durable intent immediately before provider I/O or local start.
1544
+ entry = save(id, reviewDispositionPatch(id, entry, record.key, disposition));
1545
+ await fixOrStop(id, entry, pr, { name: "fix", events: [], denials: findingsPrompt(record), summary: "first-party review findings", since: readAt, reviewKey: record.key }, deferredDispatches);
1546
+ return;
1547
+ }
1548
+ } catch (error) {
1549
+ if (error.retryableReviewCapture === true) {
1550
+ await retryFailed(id, state.get(id), REVIEW_CAPTURE, error, { action: "retrying the current PR evidence read next tick" }); return;
1551
+ }
1552
+ await needsHuman(id, ["REVIEW_INCOMPLETE", "BB_INCOMPLETE", "BB_OVERSIZED"].includes(error.code) ? "review-incomplete" : "first-party-review-failed", { firstPartyReview: { status: "incomplete", binding, error: error.message, errorCode: error.code }, last: { at: now(), event: error.message } }); return;
1553
+ }
1554
+ }
1555
+ const verdict = judge ? reviewVerdict(entry.reviewPolicy, external, entry.firstPartyReview, binding) : null;
1556
+ if (entry.state === STATES.ready && entry.reviewPolicy.version === 1 && (!green || verdict.verdict !== "clear")) {
1557
+ try { await reopen(id, entry); }
1558
+ catch (error) { await retryFailed(id, state.get(id), READY_LABEL_REMOVAL, error, { action: "readiness invalidated; retrying label removal" }); }
1559
+ return;
1560
+ }
1319
1561
  if (entry.state === STATES.ready && entry.task?.completion && entry.completionEvidence?.delivery) {
1320
1562
  const delivery = entry.completionEvidence?.delivery;
1321
1563
  const bindingChanged = !delivery || delivery.invalidatedAt || delivery.head !== pr.headRefOid || delivery.base !== pr.baseRefName || delivery.receiptHash !== receiptHash(pr.body);
@@ -1333,7 +1575,7 @@ export function makeTick(deps) {
1333
1575
  }
1334
1576
  }
1335
1577
  if (entry.state === STATES.ready && hasLegacyStatusLabels(pr.labels)) {
1336
- try { await gh.addLabel(entry.repo, entry.pr, READY_LABEL); }
1578
+ try { await host.addLabel(entry.repo, entry.pr, READY_LABEL); }
1337
1579
  catch (err) {
1338
1580
  await retryFailed(id, entry, READY_LABEL_WRITE, err, { action: "retrying the neutral ready label next tick" });
1339
1581
  return;
@@ -1382,13 +1624,19 @@ export function makeTick(deps) {
1382
1624
  } else if (entry.prd_divergence) {
1383
1625
  entry = save(id, { prd_divergence: null });
1384
1626
  }
1627
+ // The immutable path proof is reusable; the optional content scan still
1628
+ // needs diff text if this tick did not fetch it before review.
1629
+ if (!await checkPublication({ force: Boolean(config.deliverable?.scan_output && publicationDiff === null) })) return;
1385
1630
  // The completion gate: the last step before a human is asked to look. Only for entries with a PRD.
1386
1631
  const prd = config.deliverable ? prdFor(id) : null;
1387
1632
  // An entry with no snapshot reaches ready on the checks and the reviewer alone, with nothing about what the PR
1388
1633
  // was supposed to deliver ever read. That used to be one line in `errors.log` and nothing anywhere else:
1389
1634
  // `status`, `why`, the ntfy message and the PR all said what they say for a PR that passed the gate. So it is
1390
1635
  // carried on the entry, where the three of them can say it.
1391
- const gateSkipped = config.deliverable && !prd ? "no PRD snapshot" : null;
1636
+ const gateSkipped = !config.deliverable ? "completion gate disabled" : !prd ? "no PRD snapshot" : null;
1637
+ if (entry.reviewPolicy.version === 1 && (!config.deliverable || !prd)) {
1638
+ await needsHuman(id, "completion-gate-skipped", { gate_skipped: !config.deliverable ? "completion gate disabled" : "no PRD snapshot" }); return;
1639
+ }
1392
1640
  let completionGate = null;
1393
1641
  if (gateSkipped) log.warn(`${id}: ${gateSkipped}, completion gate skipped`);
1394
1642
  if (prd) {
@@ -1396,21 +1644,10 @@ export function makeTick(deps) {
1396
1644
  // The output scan needs the whole diff. A diff that cannot be read is not an empty diff: `addedLines(null)`
1397
1645
  // is the empty string, which passes the blocked-pattern scan trivially, so a transient `gh pr diff` failure
1398
1646
  // would let a PR through the very check it is meant to face. A diff too large to hold is the same fail-open
1399
- // one step further on, and `gh.prDiff` throws for it rather than returning a prefix that looks whole. No
1647
+ // one step further on, and `host.prDiff` throws for it rather than returning a prefix that looks whole. No
1400
1648
  // decision either way: retry with the shared bound, then stop with the cause if it still cannot be read.
1401
1649
  let diff = null;
1402
- if (config.deliverable.scan_output) {
1403
- let raw;
1404
- try { raw = await gh.prDiff(entry.repo, entry.pr); }
1405
- catch (e) { raw = e; }
1406
- // Anything that is not diff text is unreadable, a thrown error and an absent diff alike, so no future
1407
- // caller can reintroduce the fail-open by handing the scan a value that merely looks empty.
1408
- if (typeof raw !== "string") {
1409
- await retryFailed(id, entry, COMPLETION_GATE, new Error(`diff unreadable: ${raw instanceof Error ? raw.message : `gh pr diff returned ${raw}`}`), { action: "reviewer clear; retrying the output scan next tick" });
1410
- return;
1411
- }
1412
- diff = addedLines(raw);
1413
- }
1650
+ if (config.deliverable.scan_output) diff = addedLines(publicationDiff);
1414
1651
  // `framework` is the harness that did the work, carried as self-reported provenance in the decision's
1415
1652
  // context so a machine running two harnesses can tell their decisions apart afterwards.
1416
1653
  const g = await gate({ taskId: id, prBody: pr.body, criteria, checksGreen: true, diff, framework: entry.harness, limits: config.deliverable, apiKey: aportApiKey, fetch, log });
@@ -1440,7 +1677,7 @@ export function makeTick(deps) {
1440
1677
  // the other three, because every readiness condition had already passed: `status` said the entry was waiting on
1441
1678
  // the reviewer, which the reviewer had already answered, and the completion gate was re-evaluated (and, with a
1442
1679
  // hosted evaluator, re-paid for) on every poll for a decision that was already made.
1443
- try { await gh.addLabel(entry.repo, entry.pr, READY_LABEL); }
1680
+ try { await host.addLabel(entry.repo, entry.pr, READY_LABEL); }
1444
1681
  catch (err) {
1445
1682
  await retryFailed(id, entry, READY_LABEL_WRITE, err, { action: "staying in pr-open and trying again next tick" });
1446
1683
  return;
@@ -1449,9 +1686,10 @@ export function makeTick(deps) {
1449
1686
  // entry's label, and a stale count there would outrank the ready row in `status` and hide the one thing the
1450
1687
  // operator is being asked to act on.
1451
1688
  entry = retryOk(id, entry);
1452
- const e = save(id, { state: STATES.ready, gate_skipped: gateSkipped, retry: null,
1689
+ const e = save(id, { state: STATES.ready, activity_at: now(), gate_skipped: gateSkipped, retry: null,
1690
+ review_readiness: { binding, note: verdict.note, verdict: verdict.verdict, checks: (pr.statusCheckRollup || []).length ? "settled" : entry.reviewPolicy.version === 1 ? "explicit zero-check policy" : "no checks reported (legacy grace policy)", completion: completionGate ? "accepted" : "skipped" },
1453
1691
  ...(entry.task?.completion && completionGate ? { completionEvidence: { delivery: {
1454
- adapter: "github-pull-request", repository: entry.repo, pr: entry.pr, head: pr.headRefOid,
1692
+ adapter: completionAdapter(entry.repo, deps.hosts ?? hosts), repository: entry.repo, pr: entry.pr, head: pr.headRefOid,
1455
1693
  base: pr.baseRefName || entry.base || null, receiptHash: receiptHash(pr.body), observedAt: now(),
1456
1694
  gate: completionGate, checks: { settled: true, count: (pr.statusCheckRollup || []).length }, reviewer: { verdict: verdict.verdict },
1457
1695
  } } } : {}),
@@ -1494,11 +1732,11 @@ export function makeTick(deps) {
1494
1732
  // that has just asked. The two markers stay separate because they answer different questions on different
1495
1733
  // passes: a round announces a push, a nudge chases a reviewer that has said nothing since one. `nudged` stays
1496
1734
  // false here, so a reviewer that really does go quiet is still chased on the next tick.
1497
- if (verdict.verdict === "waiting" && entry.rereview.at && !asked) {
1735
+ if (verdict.verdict === "waiting" && needsExternal(entry.reviewPolicy) && entry.rereview.at && !asked) {
1498
1736
  const waited = clock.now() - Date.parse(entry.rereview.at);
1499
1737
  if (waited > hours(config.stall_hours)) { await needsHuman(id, "reviewer-silent"); return; }
1500
1738
  if (waited > minutes(config.rereview_minutes) && !entry.rereview.nudged) {
1501
- const posted = await commentOnce(entry, nudgeComment(config), NUDGE_MARKER, entry.rereview.at);
1739
+ const posted = await commentOnce(entry, nudgeComment(reviewConfigFor(entry)), NUDGE_MARKER, entry.rereview.at);
1502
1740
  save(id, { rereview: { ...entry.rereview, nudged: true } });
1503
1741
  log.info(`${id}: ${posted ? "nudged reviewer" : "nudge already posted"}`);
1504
1742
  }
@@ -1532,12 +1770,18 @@ export function makeTick(deps) {
1532
1770
  }
1533
1771
  };
1534
1772
  await Promise.all(Array.from({ length: Math.min(config.execution?.concurrency || CONCURRENCY, entries.length) }, recoverWorker));
1773
+ const entryPolls = new Map(entries.map(([id]) => {
1774
+ let finish;
1775
+ const done = new Promise(resolve => { finish = resolve; });
1776
+ return [id, { done, finish }];
1777
+ }));
1535
1778
  const worker = async () => {
1536
1779
  for (let next = queue.shift(); next; next = queue.shift()) {
1537
1780
  const [id, entry] = next;
1538
- if (recovered.has(id)) { results.push({ id, ok: true }); continue; }
1781
+ if (recovered.has(id)) { results.push({ id, ok: true }); entryPolls.get(id).finish(); continue; }
1539
1782
  try { await tickEntry(id, state.get(id) || entry, { skipReconciliation: Boolean(deps.executionFor), deferredDispatches }); results.push({ id, ok: true }); }
1540
1783
  catch (e) { log.error(`${id}: ${e.message}`); results.push({ id, ok: false, error: e.message }); }
1784
+ finally { entryPolls.get(id).finish(); }
1541
1785
  }
1542
1786
  };
1543
1787
  const progressWorker = async () => {
@@ -1563,10 +1807,38 @@ export function makeTick(deps) {
1563
1807
  }
1564
1808
  };
1565
1809
  const concurrency = Math.min(config.execution?.concurrency || CONCURRENCY, entries.length);
1566
- // Independent bounded lanes: provider I/O cannot occupy every GitHub worker.
1810
+ // Local reviewer progress does not need another Git-host snapshot per
1811
+ // piece. Advance only previously running captures between host polls.
1812
+ // A terminal result wakes the ordinary host lane; only that lane can clear
1813
+ // readiness after rereading the current PR binding.
1814
+ const reviewQueue = entries.filter(([, entry]) => entry.firstPartyReview?.status === "running" && entry.review_next_at > clock.now());
1815
+ const reviewWorker = async () => {
1816
+ for (let next = reviewQueue.shift(); next; next = reviewQueue.shift()) {
1817
+ const [id] = next;
1818
+ try {
1819
+ if (!deps.reviewService?.progress) continue;
1820
+ // The same entry's quick between-poll checks must release its lock;
1821
+ // other entries and the provider lane can remain in flight.
1822
+ await entryPolls.get(id).done;
1823
+ await withReviewState(deps, id, async () => {
1824
+ const entry = state.get(id);
1825
+ if (![STATES.prOpen, STATES.ready].includes(entry?.state) || controlPending(entry.control) || entry.firstPartyReview?.status !== "running" || runnerFor(id).current()?.running) return;
1826
+ const record = await deps.reviewService.progress(id, entry);
1827
+ if (record) save(id, { firstPartyReview: record, ...(record.status !== "running" ? { review_next_at: 0 } : {}) });
1828
+ });
1829
+ } catch (error) {
1830
+ if (state.get(id)?.firstPartyReview?.status === "running") save(id, { review_next_at: 0 });
1831
+ log.warn(`${id}: reviewer progress failed (${error.code || "review failed"})`);
1832
+ }
1833
+ }
1834
+ };
1835
+ // Independent bounded lanes: slow host/provider I/O must not delay
1836
+ // settlement of a captured local review. Per-task review locks still
1837
+ // exclude simultaneous decisions; only the host lane can clear readiness.
1567
1838
  await Promise.all([
1568
1839
  ...Array.from({ length: concurrency }, worker),
1569
1840
  ...(deps.executionFor ? Array.from({ length: concurrency }, progressWorker) : []),
1841
+ ...Array.from({ length: Math.min(concurrency, reviewQueue.length) }, reviewWorker),
1570
1842
  ]);
1571
1843
  // Pending health cannot occupy review workers. Retry these dispatches once
1572
1844
  // in this tick after their existing scoped probe has saved its observation.
@@ -1575,10 +1847,13 @@ export function makeTick(deps) {
1575
1847
  const { id, entry, pr, spec, refreshed } = next;
1576
1848
  try {
1577
1849
  await refreshed;
1578
- const current = state.get(id);
1579
- if (!current || completed(id, current) || ![STATES.prOpen, STATES.ready].includes(current.state) || current.pr !== entry.pr ||
1580
- current.harness !== entry.harness || current.last?.run !== entry.last?.run || JSON.stringify(current.cursor) !== JSON.stringify(entry.cursor)) continue;
1581
- await fixOrStop(id, current, pr, spec);
1850
+ await withReviewState(deps, id, async () => {
1851
+ const current = state.get(id);
1852
+ if (!current || completed(id, current) || ![STATES.prOpen, STATES.ready].includes(current.state) || current.pr !== entry.pr ||
1853
+ current.harness !== entry.harness || current.last?.run !== entry.last?.run || JSON.stringify(current.cursor) !== JSON.stringify(entry.cursor) ||
1854
+ spec.reviewKey && current.firstPartyReview?.key !== spec.reviewKey) return;
1855
+ await fixOrStop(id, current, pr, spec);
1856
+ });
1582
1857
  } catch (e) {
1583
1858
  log.error(`${id}: ${e.message}`);
1584
1859
  Object.assign(results.find(result => result.id === id), { ok: false, error: e.message });