@uchibeke/gallivant 0.2.1 → 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 (129) hide show
  1. package/README.md +38 -16
  2. package/SECURITY.md +9 -0
  3. package/bin/gallivant.mjs +33 -10
  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 +2 -0
  8. package/docs/architecture.md +11 -3
  9. package/docs/bitbucket-promotion.md +69 -0
  10. package/docs/bitbucket-review.md +330 -0
  11. package/docs/chains.md +30 -1
  12. package/docs/codex-cloud.md +7 -0
  13. package/docs/codex-review.md +50 -0
  14. package/docs/commands.md +65 -5
  15. package/docs/config.md +35 -10
  16. package/docs/first-party-review.md +266 -0
  17. package/docs/git-hosts.md +294 -0
  18. package/docs/guardrails.md +56 -0
  19. package/docs/install.md +18 -8
  20. package/docs/network-recovery.md +9 -0
  21. package/docs/openai-agents.md +2 -1
  22. package/docs/prd.md +23 -2
  23. package/docs/preparation.md +1 -1
  24. package/docs/release-qualification.md +1 -1
  25. package/docs/releasing.md +17 -2
  26. package/docs/task-delivery.md +4 -4
  27. package/docs/task-profiles.md +537 -0
  28. package/docs/tasks.md +59 -197
  29. package/lib/attribution.mjs +7 -3
  30. package/lib/block.mjs +10 -1
  31. package/lib/chains.mjs +101 -30
  32. package/lib/codex_cloud.mjs +2 -1
  33. package/lib/codex_cloud_input.mjs +15 -6
  34. package/lib/codex_cloud_result.mjs +1 -1
  35. package/lib/commands/doctor.mjs +22 -462
  36. package/lib/commands/init.mjs +72 -30
  37. package/lib/commands/register.mjs +25 -11
  38. package/lib/commands/resume.mjs +5 -1
  39. package/lib/commands/review.mjs +66 -0
  40. package/lib/commands/serve.mjs +8 -0
  41. package/lib/commands/start.mjs +30 -11
  42. package/lib/commands/status.mjs +18 -2
  43. package/lib/commands/task.mjs +9 -1
  44. package/lib/commands/up.mjs +19 -11
  45. package/lib/commands/why.mjs +7 -3
  46. package/lib/config.mjs +42 -9
  47. package/lib/context.mjs +23 -18
  48. package/lib/control.mjs +21 -6
  49. package/lib/credential.mjs +20 -14
  50. package/lib/deliverable.mjs +2 -0
  51. package/lib/events.mjs +96 -53
  52. package/lib/execution.mjs +48 -3
  53. package/lib/execution_preparation.mjs +16 -1
  54. package/lib/execution_result.mjs +26 -5
  55. package/lib/first_party_review.mjs +618 -0
  56. package/lib/gh.mjs +144 -56
  57. package/lib/git.mjs +133 -32
  58. package/lib/git_diff.mjs +121 -0
  59. package/lib/harness.mjs +14 -10
  60. package/lib/host_contract.mjs +71 -0
  61. package/lib/host_definitions.mjs +22 -0
  62. package/lib/hosts/bitbucket.mjs +553 -0
  63. package/lib/hosts/bitbucket_contract.mjs +57 -0
  64. package/lib/hosts/bitbucket_credential.mjs +62 -0
  65. package/lib/hosts/bitbucket_review.mjs +44 -0
  66. package/lib/hosts/bitbucket_review_producer.mjs +50 -0
  67. package/lib/hosts/github.mjs +57 -0
  68. package/lib/hosts/github_contract.mjs +27 -0
  69. package/lib/hosts/github_doctor.mjs +482 -0
  70. package/lib/hosts/github_review_summary.mjs +38 -0
  71. package/lib/hosts/transport_doctor.mjs +35 -0
  72. package/lib/hosts.mjs +130 -0
  73. package/lib/learning_qualification.mjs +1 -1
  74. package/lib/notify.mjs +1 -1
  75. package/lib/openai_agents_input.mjs +7 -2
  76. package/lib/openai_agents_sources.mjs +16 -8
  77. package/lib/plugin_package.mjs +13 -1
  78. package/lib/pr_operation.mjs +41 -0
  79. package/lib/prd.mjs +18 -3
  80. package/lib/preparation_codex.mjs +6 -1
  81. package/lib/probe.mjs +6 -4
  82. package/lib/process.mjs +24 -0
  83. package/lib/promotion.mjs +132 -0
  84. package/lib/promotion_git.mjs +63 -0
  85. package/lib/prompt.mjs +16 -13
  86. package/lib/provider_health.mjs +14 -3
  87. package/lib/repository.mjs +68 -0
  88. package/lib/repository_preflight.mjs +11 -0
  89. package/lib/review_evaluation.mjs +25 -0
  90. package/lib/review_evidence.mjs +500 -0
  91. package/lib/review_history.mjs +73 -0
  92. package/lib/review_image.mjs +145 -0
  93. package/lib/review_link.mjs +12 -0
  94. package/lib/review_policy.mjs +132 -0
  95. package/lib/review_policy_contract.mjs +18 -0
  96. package/lib/review_state.mjs +13 -0
  97. package/lib/review_visual.mjs +94 -0
  98. package/lib/runtime-environment.mjs +6 -0
  99. package/lib/runtime_node.mjs +3 -2
  100. package/lib/runtime_state.mjs +39 -5
  101. package/lib/sources/prd.mjs +6 -3
  102. package/lib/state.mjs +137 -24
  103. package/lib/task_completion.mjs +7 -3
  104. package/lib/task_contract.mjs +201 -27
  105. package/lib/task_delivery.mjs +9 -2
  106. package/lib/task_delivery_contract.mjs +13 -9
  107. package/lib/task_delivery_view.mjs +18 -2
  108. package/lib/task_environment.mjs +13 -1
  109. package/lib/task_limits.mjs +13 -0
  110. package/lib/task_model_config.mjs +99 -0
  111. package/lib/task_model_route.mjs +243 -0
  112. package/lib/task_prepare.mjs +21 -8
  113. package/lib/task_profile.mjs +1875 -0
  114. package/lib/task_repository_context.mjs +65 -0
  115. package/lib/tasks.mjs +252 -51
  116. package/lib/tick.mjs +353 -97
  117. package/lib/time.mjs +1 -0
  118. package/lib/tracker/pages.mjs +19 -5
  119. package/lib/tracker/review.mjs +21 -0
  120. package/lib/tracker_process.mjs +8 -1
  121. package/lib/verification_sandbox.mjs +2 -1
  122. package/lib/work_artifacts.mjs +79 -0
  123. package/npm-shrinkwrap.json +12 -3
  124. package/package.json +9 -3
  125. package/templates/plugin/task-delivery.SKILL.md +17 -10
  126. package/templates/plugin/task-intake-contract.json +18 -0
  127. package/templates/plugin/task-profile-requests.mjs +1448 -0
  128. package/templates/tracker/tracker.css +3 -0
  129. 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,12 +17,15 @@
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";
@@ -24,7 +33,8 @@ import { executionRunner, reserveExecutionEpoch, unsubmittedInitialDispatch } fr
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);
@@ -473,7 +526,7 @@ export function makeTick(deps) {
473
526
  let spec = remote ? null : buildRun({ harness: entry.harness, config: runConfig, mode, session, prompt, cwd: entry.worktree, contextPaths });
474
527
  // The host comes from this entry's own checkout, so an enterprise remote asks `gh` for that host's token rather
475
528
  // than sending it a github.com one.
476
- 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 });
477
530
  if (!credential.token) throw Object.assign(new Error(credential.why), { code: "NO_CREDENTIAL" });
478
531
  // The identity the session commits as, resolved per run for the reason lib/identity.mjs gives: the daemon is
479
532
  // long-lived and an operator may change theirs under it. This is the path `resume` and every fix round take.
@@ -485,8 +538,8 @@ export function makeTick(deps) {
485
538
  // every checkout made before the hook existed. Found on a live resume: the session dropped the attribution trailer
486
539
  // because the prompt told it to, and `.git/hooks/commit-msg` had never been written, so the half meant to enforce
487
540
  // it was absent. Idempotent and never fatal, so this costs a stat and cannot fail a run.
488
- const hook = git.installHooks?.(entry.worktree);
489
- 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`);
490
543
  const initialDispatch = remote && !session ? unsubmittedInitialDispatch(entry, execution) : null;
491
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` } };
492
545
  if (entry.learning_continuity?.status === "prepared") dispatchInput.workflow = { ...dispatchInput.workflow,
@@ -507,6 +560,7 @@ export function makeTick(deps) {
507
560
  if (initialDispatch) save(id, { initial_dispatch: { ...initialDispatch, status: "dispatching" }, reason: "dispatch-intent" });
508
561
  try { job = await learningDispatch({ ...deps, learning }, id, managed, () => {
509
562
  assertDispatchOpen();
563
+ beforeDispatch?.();
510
564
  return session && ["native-session", "successor-task"].includes(execution.capabilities.continuation) ? execution.continue(input) : execution.submit({ ...input, sessionId: null });
511
565
  }); }
512
566
  catch (error) {
@@ -515,16 +569,17 @@ export function makeTick(deps) {
515
569
  }
516
570
  } else job = await learningDispatch({ ...deps, learning }, id, managed, () => {
517
571
  assertDispatchOpen();
518
- 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 } : {}) } });
519
574
  });
520
575
  return { job, session: job.execution?.sessionId || session, managed };
521
576
  }
522
577
 
523
578
  /** When a head was pushed, from GitHub, or `fallback` when GitHub cannot say. */
524
- 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);
525
580
 
526
581
  /** The reviewer clock for this entry and this head. The rule itself is `events.mjs`; `reconcile` uses the same one. */
527
- 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() });
528
583
 
529
584
  /**
530
585
  * The round comment: the announcement of a round that has already been counted. Called on the settle tick that
@@ -551,7 +606,7 @@ export function makeTick(deps) {
551
606
  async function postRoundComment(id, entry) {
552
607
  const owed = entry.round_comment;
553
608
  if (!owed) return false;
554
- 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();
555
610
  try { await commentOnce(entry, body, ROUND_MARKER, owed.since); }
556
611
  catch (err) {
557
612
  // The session is not re-saved here: the count above already persisted it, so there is nothing a retry could
@@ -599,7 +654,7 @@ export function makeTick(deps) {
599
654
  * (`last.run`) is settled; a leftover run from before a reset or a recovery is ignored. Returns true when the
600
655
  * entry should wait.
601
656
  */
602
- async function settleRun(id, entry) {
657
+ async function settleRun(id, entry, refreshIdentity) {
603
658
  const runner = runnerFor(id);
604
659
  const cur = runner.current();
605
660
  if (!cur) return false;
@@ -610,8 +665,14 @@ export function makeTick(deps) {
610
665
  // leftover process outliving a reset is a separate problem from this entry's state, and it does not get to borrow
611
666
  // this entry's reason.
612
667
  if (entry.last?.run !== cur.id) return false;
613
- if (cur.execution?.quarantined) {
614
- 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
+ });
615
676
  return false;
616
677
  }
617
678
  if (cur.running) {
@@ -662,13 +723,17 @@ export function makeTick(deps) {
662
723
  if (entry.last?.exit != null) return false;
663
724
 
664
725
  const lines = runner.outcome && runner.sessionFrom ? [] : runner.lines(cur.id);
665
- 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);
666
732
  const job = runner.settle(cur.id, () => (outcome.ok ? 0 : 1));
667
733
  const session = entry.session || (runner.sessionFrom ? runner.sessionFrom(cur.id) : adapterFor(entry.harness).sessionFrom(lines));
668
- 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();
669
735
  const last = { at: now(), run: cur.id, exit: job.exit, event: entry.last?.event || "start", cost: outcome.cost, denials: outcome.denials };
670
736
 
671
- const interrupted = entry.control?.status === "queued" && entry.control.stop_run === cur.id;
672
737
  if (interrupted && entry.state !== STATES.running) {
673
738
  // An intentional interruption is not a failed review round or a completion.
674
739
  // Account for it once, then let the normal control path resume the saved session.
@@ -716,8 +781,11 @@ export function makeTick(deps) {
716
781
  // streak. Never let old deadlines silently restart a later stopped task.
717
782
  if (entry.network_retry && !recoverableNetwork) entry = save(id, { network_retry: null });
718
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();
719
787
  if (entry.state === STATES.running) {
720
- const prs = await gh.prListByHead(entry.repo, entry.branch);
788
+ const prs = await host.prListByHead(entry.repo, entry.branch);
721
789
  let pr = (prs || []).find(p => !p.isCrossRepository);
722
790
  if (!pr) {
723
791
  if (await scheduleNetworkRecovery()) return false;
@@ -737,7 +805,17 @@ export function makeTick(deps) {
737
805
  const left = tree && (tree.modified || tree.untracked)
738
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}`
739
807
  : "";
740
- 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 } });
741
819
  return false;
742
820
  }
743
821
  // The identity block is a precondition of leaving `running`, not a best-effort extra. It is what reconciliation
@@ -762,7 +840,7 @@ export function makeTick(deps) {
762
840
  try {
763
841
  const identity = recoveryIdentity(entry, session);
764
842
  if (normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body) {
765
- pr = await gh.setRecoveryMetadata(entry.repo, pr.number, identity, { taskId: id });
843
+ pr = await host.setRecoveryMetadata(entry.repo, pr.number, identity, { taskId: id });
766
844
  }
767
845
  }
768
846
  catch (err) {
@@ -787,7 +865,9 @@ export function makeTick(deps) {
787
865
  }
788
866
 
789
867
  // A fix run ended. Progress is the head moving or the receipt changing; anything else is a stop.
790
- 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; }
791
871
  const pushed = pr.headRefOid !== entry.cursor.fixed_sha;
792
872
  const hash = receiptHash(pr.body);
793
873
  const rewrote = !pushed && hash !== null && !receiptHashes(pr.body).includes(entry.cursor.receipt_hash);
@@ -885,7 +965,7 @@ export function makeTick(deps) {
885
965
  * again next tick and dispatched then; the ones already handled are recognised by key and skipped.
886
966
  */
887
967
  async function dispatch(id, entry, pr, spec, deferredDispatches = null) {
888
- const { name, events, denials = null, summary, since, publicFix = null } = spec;
968
+ const { name, events, denials = null, summary, since, publicFix = null, reviewKey = null } = spec;
889
969
  if (entry.dispatch_stopped) return;
890
970
  const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
891
971
  if (health && !health.ok) {
@@ -942,8 +1022,8 @@ export function makeTick(deps) {
942
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.`
943
1023
  : null;
944
1024
  const prompt = entry.session
945
- ? fixPrompt({ ...fix, verify: verifyFor(id) })
946
- : `${registerPrompt({ repo: entry.repo, pr: entry.pr })}${handoff ? `\n\n${handoff}` : ""}\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: [] })}`;
947
1027
  // The invariant: a spawned run must be recorded before anything else can fail. Everything that can throw goes
948
1028
  // above the spawn, and the state that owns the run is saved immediately below it with no await in between. A
949
1029
  // process running that the registry does not know about is the worst state this function can leave: `settleRun`
@@ -973,9 +1053,16 @@ export function makeTick(deps) {
973
1053
  let started;
974
1054
  const ownership = { state: STATES.prOpen, reason: null, public_fix: publicFix, lastEvent: summary,
975
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 } : {}) } };
976
- 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 }); }
977
1062
  catch (e) {
1063
+ if (e.code === "PR_IDENTITY") return;
978
1064
  if (e.dispatchNotSent === true) {
1065
+ if (reviewKey) save(id, reviewDispositionPatch(id, state.get(id), reviewKey, { status: "pending", round: null }));
979
1066
  await needsHuman(id, "dispatch-refused", { last: { ...entry.last, at: now(), event: "managed dispatch refused before any provider request; repair prerequisites and explicitly resume" } });
980
1067
  return;
981
1068
  }
@@ -983,7 +1070,10 @@ export function makeTick(deps) {
983
1070
  // holding it: `settleRun` now ignores such a run rather than waiting on it, which is right, but it means the
984
1071
  // dispatch can arrive while the runner is still busy. That is a wait, not an error, and it resolves when the
985
1072
  // leftover ends. The cursor is not moved, so the same feedback is dispatched on the tick that finds it free.
986
- 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
+ }
987
1077
  if (e.code === "PROVIDER_UNAVAILABLE") { log.info(`${id}: dispatch waiting for its provider/account health`); return; }
988
1078
  // No credential is an operator problem, not a transient one: retrying every tick would spend nothing but would
989
1079
  // also never resolve, and the entry would sit `pr-open` with feedback nobody is acting on. Stop it and say so.
@@ -1005,6 +1095,7 @@ export function makeTick(deps) {
1005
1095
  const dispatchedChecks = events.some(e => e.kind === "check");
1006
1096
  state.update(id, current => ({
1007
1097
  ...learningDeliveryPatch(current, managed, job), updated_at: now(),
1098
+ ...reviewDispositionPatch(id, current, reviewKey, { status: "dispatched", at: now(), run: job.id }),
1008
1099
  state: STATES.prOpen, session, brief_changes: null, public_fix: publicFix,
1009
1100
  // The cursor and the keys handled at it move together: everything dispatched in the boundary second is
1010
1101
  // remembered, so an event sharing that second is still read next tick but never sent a second time.
@@ -1017,6 +1108,11 @@ export function makeTick(deps) {
1017
1108
 
1018
1109
  /** A fix round, or a stop when the round budget is spent. */
1019
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");
1020
1116
  if (entry.iteration >= config.max_iterations) { await needsHuman(id, "iterations", { last: { at: now(), event: spec.summary } }); return; }
1021
1117
  await dispatch(id, entry, pr, spec, deferredDispatches);
1022
1118
  }
@@ -1026,7 +1122,9 @@ export function makeTick(deps) {
1026
1122
  * before any workflow is queued. A young head with nothing reported is not green, it is not reported yet.
1027
1123
  */
1028
1124
  async function checksSettled(entry, pr) {
1029
- 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;
1030
1128
  const pushedAt = await headPushedAt(entry, pr.headRefOid, entry.cursor.since);
1031
1129
  return clock.now() - Date.parse(pushedAt) > minutes(config.checks_grace_minutes);
1032
1130
  }
@@ -1036,7 +1134,7 @@ export function makeTick(deps) {
1036
1134
  // changed head/receipt/review cannot convert an unqualified delivery to done.
1037
1135
  async function terminalCompletion(entry, pr) {
1038
1136
  if (!entry.task?.completion) return {};
1039
- 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,
1040
1138
  head: pr.headRefOid, base: pr.baseRefName || null, observedAt: now(), mergedAt: pr.mergedAt || null, mergeCommit: pr.mergeCommit?.oid || null };
1041
1139
  const evidence = { ...entry.completionEvidence, terminal };
1042
1140
  if (pr.state === "MERGED") {
@@ -1046,15 +1144,14 @@ export function makeTick(deps) {
1046
1144
  if (entry.state === STATES.ready && !entry.dispatch_stopped && delivery?.gate?.allow === true && !delivery.invalidatedAt &&
1047
1145
  delivery.head === pr.headRefOid && delivery.base === pr.baseRefName && delivery.receiptHash === receiptHash(pr.body) &&
1048
1146
  Number.isFinite(readyAt) && Number.isFinite(mergedAt) && mergedAt >= readyAt && terminal.mergeCommit) {
1049
- const [reviews, inline, comments] = await Promise.all([
1050
- gh.reviews(entry.repo, entry.pr),
1051
- gh.inlineComments(entry.repo, entry.pr, fetchFrom(earlier(entry.rereview.at, entry.public_since))),
1052
- gh.issueComments(entry.repo, entry.pr, fetchFrom(earlier(entry.rereview.at, entry.public_since))),
1053
- ]);
1054
- const verdict = reviewerVerdict({ reviews, inline, comments, config, trust, sinceIso: entry.rereview.at, headSha: pr.headRefOid, dispatched: entry.cursor.dispatched });
1055
- 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;
1056
1151
  const publicIssues = [...publicPrViolations(pr), ...publicCommentViolations(ownPublicComments(entry, [...comments, ...inline, ...reviews.map(r => ({ ...r, public_kind: "review" }))]))];
1057
- 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));
1058
1155
  reason = qualified ? null : "Integration was observed after checks, reviewer feedback or public delivery evidence changed.";
1059
1156
  }
1060
1157
  evidence.integration = { ...terminal, qualified, reason };
@@ -1133,8 +1230,13 @@ export function makeTick(deps) {
1133
1230
  const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
1134
1231
  if (health && !health.ok) return true; // The one-hour deadline still bounds an unavailable probe.
1135
1232
  if (entry.pr) {
1136
- const pr = await gh.prView(entry.repo, entry.pr);
1137
- 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
+ }
1138
1240
  }
1139
1241
  // Fence before any await that might submit input. A crash in this interval
1140
1242
  // requires inspection; guessing whether a paid run started is unsafe.
@@ -1163,10 +1265,29 @@ export function makeTick(deps) {
1163
1265
  return true;
1164
1266
  }
1165
1267
 
1166
- 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
+ };
1167
1288
  if (completed(id, entry)) { await learningDelivered({ ...deps, learning }, id); return; }
1168
1289
  if (!skipReconciliation) await learningDelivered({ ...deps, learning }, id);
1169
- if (await cancelControl(deps, id)) return;
1290
+ if (await cancelControl(deps, id)) { await deps.reviewService?.cancel?.(id, "task cancelled"); return; }
1170
1291
  if (!settleOnly && await recoverControl(deps, id)) return;
1171
1292
  const execution = runnerFor(id);
1172
1293
  if (execution.ledgerEpoch > 0) reserveExecutionEpoch({ root: execution.root, taskId: id,
@@ -1195,13 +1316,16 @@ export function makeTick(deps) {
1195
1316
  }
1196
1317
  }
1197
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)) });
1198
1322
  if (!entry.public_since) entry = save(id, { public_since: floorSecond(now()) });
1199
1323
  if (interruptControl(deps, id)) {
1200
1324
  await needsHuman(id, "run-stop-unverified");
1201
1325
  return;
1202
1326
  }
1203
1327
  entry = state.get(id);
1204
- if (await settleRun(id, entry)) return;
1328
+ if (await settleRun(id, entry, refreshIdentity)) return;
1205
1329
  if (completed(id, state.get(id))) return;
1206
1330
  // Phase-bounded live campaigns reuse settlement/publication, but must not
1207
1331
  // authorize another paid review or queued resume in the same observation.
@@ -1211,10 +1335,17 @@ export function makeTick(deps) {
1211
1335
  if (entry && await recoverNetwork(id, entry)) return;
1212
1336
  if (!entry || entry.state === STATES.running || entry.state === STATES.done || !entry.pr) return;
1213
1337
  if (deps.executionFor && entry.review_next_at && clock.now() < entry.review_next_at && !controlPending(entry.control)) return;
1338
+ await refreshIdentity();
1214
1339
  if (deps.executionFor) entry = save(id, { review_next_at: clock.now() + config.poll_seconds * 1000 });
1215
1340
 
1216
- let pr = await gh.prView(entry.repo, entry.pr);
1217
- 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
+ }
1218
1349
  // Regular means reviewable, not complete. Registration adopts the same policy as start. Recover only legacy
1219
1350
  // draft-specific stops; no other needs-human reason is cleared by changing this GitHub presentation state.
1220
1351
  if (entry.state === STATES.needsHuman && entry.reason === "draft") {
@@ -1235,7 +1366,7 @@ export function makeTick(deps) {
1235
1366
  if (entry.prd && normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body) {
1236
1367
  const beforeReceipt = receiptHash(pr.body);
1237
1368
  const sameReceipt = receiptHashes(pr.body).includes(entry.cursor.receipt_hash);
1238
- pr = await gh.setRecoveryMetadata(entry.repo, entry.pr, identity, { taskId: id });
1369
+ pr = await host.setRecoveryMetadata(entry.repo, entry.pr, identity, { taskId: id });
1239
1370
  if (sameReceipt && receiptHash(pr.body) === beforeReceipt) entry = save(id, { cursor: { ...entry.cursor, receipt_hash: beforeReceipt } });
1240
1371
  }
1241
1372
  if (entry.retry?.op === PUBLIC_METADATA) entry = retryOk(id, entry);
@@ -1244,7 +1375,7 @@ export function makeTick(deps) {
1244
1375
  return;
1245
1376
  }
1246
1377
  if (pr.isDraft) {
1247
- try { await gh.markReady(entry.repo, entry.pr); }
1378
+ try { await host.markReady(entry.repo, entry.pr); }
1248
1379
  catch (err) {
1249
1380
  await retryFailed(id, entry, REGULAR_PR, err, { action: "retrying regular PR conversion next tick" });
1250
1381
  return;
@@ -1300,11 +1431,7 @@ export function makeTick(deps) {
1300
1431
  // `updated_at` and stamps to the second, so a fetch starting exactly at the cursor can leave out the items
1301
1432
  // `cursor.dispatched` exists to re-examine. Overlap here, deduplicate in `deriveEvents`: reading an item a
1302
1433
  // second time costs nothing, never reading it again is the bug.
1303
- const [reviews, inline, comments] = await Promise.all([
1304
- gh.reviews(entry.repo, entry.pr),
1305
- gh.inlineComments(entry.repo, entry.pr, fetchFrom(inlineSince)),
1306
- gh.issueComments(entry.repo, entry.pr, fetchFrom(earlier(entry.cursor.since, entry.public_since))),
1307
- ]);
1434
+ const [reviews, inline, comments] = await readFeedback(host, entry.repo, pr, fetchFrom(inlineSince), fetchFrom(earlier(entry.cursor.since, entry.public_since)));
1308
1435
  if (learning && (!observationSelection || observationSelection.has(id)) && observationQueue.length < 100) {
1309
1436
  try {
1310
1437
  const repo = learning.identityFor(entry.repo);
@@ -1317,7 +1444,7 @@ export function makeTick(deps) {
1317
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);
1318
1445
  return;
1319
1446
  }
1320
- const { events, newest } = deriveEvents({ pr, reviews, inline, comments, entry, trust });
1447
+ const { events, newest } = deriveEvents({ pr, reviews, inline, comments, entry, trust: trustFor(entry) });
1321
1448
 
1322
1449
  if (events.length) {
1323
1450
  const summary = events.map(e => e.kind === "check" ? `check ${e.name}` : `${e.kind} ${e.id ?? ""} by ${e.author}`).join(", ");
@@ -1332,9 +1459,105 @@ export function makeTick(deps) {
1332
1459
  const changed = await headChanged(id, entry, pr);
1333
1460
  if (changed === "declined") return;
1334
1461
  if (changed) entry = changed;
1335
- const judge = entry.state === STATES.prOpen || Boolean(entry.task?.completion && entry.completionEvidence?.delivery);
1336
- 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
+ }
1337
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
+ }
1338
1561
  if (entry.state === STATES.ready && entry.task?.completion && entry.completionEvidence?.delivery) {
1339
1562
  const delivery = entry.completionEvidence?.delivery;
1340
1563
  const bindingChanged = !delivery || delivery.invalidatedAt || delivery.head !== pr.headRefOid || delivery.base !== pr.baseRefName || delivery.receiptHash !== receiptHash(pr.body);
@@ -1352,7 +1575,7 @@ export function makeTick(deps) {
1352
1575
  }
1353
1576
  }
1354
1577
  if (entry.state === STATES.ready && hasLegacyStatusLabels(pr.labels)) {
1355
- try { await gh.addLabel(entry.repo, entry.pr, READY_LABEL); }
1578
+ try { await host.addLabel(entry.repo, entry.pr, READY_LABEL); }
1356
1579
  catch (err) {
1357
1580
  await retryFailed(id, entry, READY_LABEL_WRITE, err, { action: "retrying the neutral ready label next tick" });
1358
1581
  return;
@@ -1401,13 +1624,19 @@ export function makeTick(deps) {
1401
1624
  } else if (entry.prd_divergence) {
1402
1625
  entry = save(id, { prd_divergence: null });
1403
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;
1404
1630
  // The completion gate: the last step before a human is asked to look. Only for entries with a PRD.
1405
1631
  const prd = config.deliverable ? prdFor(id) : null;
1406
1632
  // An entry with no snapshot reaches ready on the checks and the reviewer alone, with nothing about what the PR
1407
1633
  // was supposed to deliver ever read. That used to be one line in `errors.log` and nothing anywhere else:
1408
1634
  // `status`, `why`, the ntfy message and the PR all said what they say for a PR that passed the gate. So it is
1409
1635
  // carried on the entry, where the three of them can say it.
1410
- 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
+ }
1411
1640
  let completionGate = null;
1412
1641
  if (gateSkipped) log.warn(`${id}: ${gateSkipped}, completion gate skipped`);
1413
1642
  if (prd) {
@@ -1415,21 +1644,10 @@ export function makeTick(deps) {
1415
1644
  // The output scan needs the whole diff. A diff that cannot be read is not an empty diff: `addedLines(null)`
1416
1645
  // is the empty string, which passes the blocked-pattern scan trivially, so a transient `gh pr diff` failure
1417
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
1418
- // 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
1419
1648
  // decision either way: retry with the shared bound, then stop with the cause if it still cannot be read.
1420
1649
  let diff = null;
1421
- if (config.deliverable.scan_output) {
1422
- let raw;
1423
- try { raw = await gh.prDiff(entry.repo, entry.pr); }
1424
- catch (e) { raw = e; }
1425
- // Anything that is not diff text is unreadable, a thrown error and an absent diff alike, so no future
1426
- // caller can reintroduce the fail-open by handing the scan a value that merely looks empty.
1427
- if (typeof raw !== "string") {
1428
- 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" });
1429
- return;
1430
- }
1431
- diff = addedLines(raw);
1432
- }
1650
+ if (config.deliverable.scan_output) diff = addedLines(publicationDiff);
1433
1651
  // `framework` is the harness that did the work, carried as self-reported provenance in the decision's
1434
1652
  // context so a machine running two harnesses can tell their decisions apart afterwards.
1435
1653
  const g = await gate({ taskId: id, prBody: pr.body, criteria, checksGreen: true, diff, framework: entry.harness, limits: config.deliverable, apiKey: aportApiKey, fetch, log });
@@ -1459,7 +1677,7 @@ export function makeTick(deps) {
1459
1677
  // the other three, because every readiness condition had already passed: `status` said the entry was waiting on
1460
1678
  // the reviewer, which the reviewer had already answered, and the completion gate was re-evaluated (and, with a
1461
1679
  // hosted evaluator, re-paid for) on every poll for a decision that was already made.
1462
- try { await gh.addLabel(entry.repo, entry.pr, READY_LABEL); }
1680
+ try { await host.addLabel(entry.repo, entry.pr, READY_LABEL); }
1463
1681
  catch (err) {
1464
1682
  await retryFailed(id, entry, READY_LABEL_WRITE, err, { action: "staying in pr-open and trying again next tick" });
1465
1683
  return;
@@ -1468,9 +1686,10 @@ export function makeTick(deps) {
1468
1686
  // entry's label, and a stale count there would outrank the ready row in `status` and hide the one thing the
1469
1687
  // operator is being asked to act on.
1470
1688
  entry = retryOk(id, entry);
1471
- 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" },
1472
1691
  ...(entry.task?.completion && completionGate ? { completionEvidence: { delivery: {
1473
- 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,
1474
1693
  base: pr.baseRefName || entry.base || null, receiptHash: receiptHash(pr.body), observedAt: now(),
1475
1694
  gate: completionGate, checks: { settled: true, count: (pr.statusCheckRollup || []).length }, reviewer: { verdict: verdict.verdict },
1476
1695
  } } } : {}),
@@ -1513,11 +1732,11 @@ export function makeTick(deps) {
1513
1732
  // that has just asked. The two markers stay separate because they answer different questions on different
1514
1733
  // passes: a round announces a push, a nudge chases a reviewer that has said nothing since one. `nudged` stays
1515
1734
  // false here, so a reviewer that really does go quiet is still chased on the next tick.
1516
- if (verdict.verdict === "waiting" && entry.rereview.at && !asked) {
1735
+ if (verdict.verdict === "waiting" && needsExternal(entry.reviewPolicy) && entry.rereview.at && !asked) {
1517
1736
  const waited = clock.now() - Date.parse(entry.rereview.at);
1518
1737
  if (waited > hours(config.stall_hours)) { await needsHuman(id, "reviewer-silent"); return; }
1519
1738
  if (waited > minutes(config.rereview_minutes) && !entry.rereview.nudged) {
1520
- 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);
1521
1740
  save(id, { rereview: { ...entry.rereview, nudged: true } });
1522
1741
  log.info(`${id}: ${posted ? "nudged reviewer" : "nudge already posted"}`);
1523
1742
  }
@@ -1551,12 +1770,18 @@ export function makeTick(deps) {
1551
1770
  }
1552
1771
  };
1553
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
+ }));
1554
1778
  const worker = async () => {
1555
1779
  for (let next = queue.shift(); next; next = queue.shift()) {
1556
1780
  const [id, entry] = next;
1557
- 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; }
1558
1782
  try { await tickEntry(id, state.get(id) || entry, { skipReconciliation: Boolean(deps.executionFor), deferredDispatches }); results.push({ id, ok: true }); }
1559
1783
  catch (e) { log.error(`${id}: ${e.message}`); results.push({ id, ok: false, error: e.message }); }
1784
+ finally { entryPolls.get(id).finish(); }
1560
1785
  }
1561
1786
  };
1562
1787
  const progressWorker = async () => {
@@ -1582,10 +1807,38 @@ export function makeTick(deps) {
1582
1807
  }
1583
1808
  };
1584
1809
  const concurrency = Math.min(config.execution?.concurrency || CONCURRENCY, entries.length);
1585
- // 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.
1586
1838
  await Promise.all([
1587
1839
  ...Array.from({ length: concurrency }, worker),
1588
1840
  ...(deps.executionFor ? Array.from({ length: concurrency }, progressWorker) : []),
1841
+ ...Array.from({ length: Math.min(concurrency, reviewQueue.length) }, reviewWorker),
1589
1842
  ]);
1590
1843
  // Pending health cannot occupy review workers. Retry these dispatches once
1591
1844
  // in this tick after their existing scoped probe has saved its observation.
@@ -1594,10 +1847,13 @@ export function makeTick(deps) {
1594
1847
  const { id, entry, pr, spec, refreshed } = next;
1595
1848
  try {
1596
1849
  await refreshed;
1597
- const current = state.get(id);
1598
- if (!current || completed(id, current) || ![STATES.prOpen, STATES.ready].includes(current.state) || current.pr !== entry.pr ||
1599
- current.harness !== entry.harness || current.last?.run !== entry.last?.run || JSON.stringify(current.cursor) !== JSON.stringify(entry.cursor)) continue;
1600
- 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
+ });
1601
1857
  } catch (e) {
1602
1858
  log.error(`${id}: ${e.message}`);
1603
1859
  Object.assign(results.find(result => result.id === id), { ok: false, error: e.message });