codex-orchestrator 2.0.6 → 2.0.8

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 (46) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +8 -4
  3. package/dist/src/v2/checked-change.d.ts +1 -1
  4. package/dist/src/v2/checked-change.d.ts.map +1 -1
  5. package/dist/src/v2/checked-change.js +3 -2
  6. package/dist/src/v2/checked-change.js.map +1 -1
  7. package/dist/src/v2/code-review-report.d.ts +1 -2
  8. package/dist/src/v2/code-review-report.d.ts.map +1 -1
  9. package/dist/src/v2/code-review-report.js +6 -11
  10. package/dist/src/v2/code-review-report.js.map +1 -1
  11. package/dist/src/v2/containment.d.ts +0 -52
  12. package/dist/src/v2/containment.d.ts.map +1 -1
  13. package/dist/src/v2/containment.js +1 -195
  14. package/dist/src/v2/containment.js.map +1 -1
  15. package/dist/src/v2/direct-delivery.d.ts +11 -2
  16. package/dist/src/v2/direct-delivery.d.ts.map +1 -1
  17. package/dist/src/v2/direct-delivery.js +62 -17
  18. package/dist/src/v2/direct-delivery.js.map +1 -1
  19. package/dist/src/v2/implementation-reviewer.d.ts +1 -1
  20. package/dist/src/v2/implementation-reviewer.d.ts.map +1 -1
  21. package/dist/src/v2/implementation-reviewer.js +1 -2
  22. package/dist/src/v2/implementation-reviewer.js.map +1 -1
  23. package/dist/src/v2/issue-check-policy.d.ts +17 -0
  24. package/dist/src/v2/issue-check-policy.d.ts.map +1 -0
  25. package/dist/src/v2/issue-check-policy.js +133 -0
  26. package/dist/src/v2/issue-check-policy.js.map +1 -0
  27. package/dist/src/v2/proof-report.d.ts +1 -1
  28. package/dist/src/v2/proof-report.d.ts.map +1 -1
  29. package/dist/src/v2/proof-report.js +6 -4
  30. package/dist/src/v2/proof-report.js.map +1 -1
  31. package/dist/src/v2/run-issue.d.ts +15 -1
  32. package/dist/src/v2/run-issue.d.ts.map +1 -1
  33. package/dist/src/v2/run-issue.js +374 -69
  34. package/dist/src/v2/run-issue.js.map +1 -1
  35. package/dist/src/v2/run-store.d.ts +14 -1
  36. package/dist/src/v2/run-store.d.ts.map +1 -1
  37. package/dist/src/v2/run-store.js +18 -3
  38. package/dist/src/v2/run-store.js.map +1 -1
  39. package/dist/src/v2/runtime.d.ts +5 -0
  40. package/dist/src/v2/runtime.d.ts.map +1 -1
  41. package/dist/src/v2/runtime.js +86 -23
  42. package/dist/src/v2/runtime.js.map +1 -1
  43. package/docs/deep-dive.md +21 -11
  44. package/internal-workflow/manifest.json +1 -1
  45. package/internal-workflow/schemas/proof-report-v1.json +1 -1
  46. package/package.json +1 -2
@@ -3,8 +3,9 @@ import { checkedChangePayloadSha256, } from './checked-change.js';
3
3
  import { parseAgentAutoConfig } from './config.js';
4
4
  import { canonicalJson, sha256 } from './containment.js';
5
5
  import { validateImplementationReport } from './implementation-report.js';
6
- import { acceptApprovedDirectReview, acceptNeedsWorkDirectReview, beginDirectReviewRepair, createInitialDirectReview, directReviewClosureRequestSha256, directReviewTargetFingerprint, launchDirectReviewInvocation, prepareDirectReviewClosure, prepareDirectReviewInvocation, projectTerminalDirectReview, } from './direct-delivery.js';
6
+ import { acceptApprovedDirectReview, acceptNeedsWorkDirectReview, beginDirectReviewRepair, canRecoverTerminalDirectReviewReport, createInitialDirectReview, directReviewClosureRequestSha256, directReviewTargetFingerprint, launchDirectReviewInvocation, MAX_DIRECT_REVIEW_REPORT_REPAIRS, prepareDirectReviewClosure, prepareDirectReviewInvocation, projectTerminalDirectReview, recoverTerminalDirectReviewReport, } from './direct-delivery.js';
7
7
  import { ProofLaunchAuthorizationError, ProofQuiescenceError } from './acceptance-proof.js';
8
+ import { CheckProcessQuiescenceError, resolveIssueCheckPolicy } from './issue-check-policy.js';
8
9
  import { RouteInitializationUnrecoverableError, WorkflowGenerationUnrecoverableError, } from './run-store.js';
9
10
  import { initialRouteExecution, } from './route-coordinator.js';
10
11
  import { downstreamLifecycleForRoute, validateRouteTransition, validateTrustedAnswerResumeTransition, } from './route-decision.js';
@@ -30,56 +31,88 @@ export class RunIssue {
30
31
  let observation = await this.readIssue(issueNumber);
31
32
  if (!observation || observation.state !== 'OPEN')
32
33
  return { result: await this.publicationDiverged(active, 'claim-issue-missing') };
34
+ const body = claimComment(runId, issueNumber, branchName);
35
+ const marker = body.split('\n')[0];
36
+ const commentIntent = { kind: 'comment', issueNumber, marker, bodySha256: sha256(body) };
37
+ // Compatibility with runs persisted by the former label-first claim order.
38
+ // If the label effect is already visible, confirm it. Otherwise establish
39
+ // the trusted claim comment before granting the running status label.
33
40
  if (active.record.intent?.kind === 'claim-labels') {
34
- if (!sameStrings(observation.labels, expectedLabels)) {
41
+ if (sameStrings(observation.labels, expectedLabels)) {
42
+ active = await this.confirmEffect(active);
43
+ }
44
+ else if (!this.hasTrustedClaim(observation, active.record)) {
35
45
  const labels = new Set(observation.labels);
36
46
  if (!labels.has(config.github.labels.auto.name)
37
47
  || labels.has(config.github.labels.blocked.name)
38
- || labels.has(config.github.labels.review.name)) {
48
+ || labels.has(config.github.labels.review.name)
49
+ || labels.has(config.github.labels.waitingHuman.name)) {
39
50
  return { result: await this.publicationDiverged(active, 'claim-labels-diverged') };
40
51
  }
52
+ active = await this.persist(active, { intent: commentIntent });
53
+ }
54
+ }
55
+ if (!active.record.intent) {
56
+ observation = await this.readIssue(issueNumber);
57
+ if (!observation || observation.state !== 'OPEN')
58
+ return { result: await this.publicationDiverged(active, 'claim-issue-missing') };
59
+ if (!this.hasTrustedClaim(observation, active.record)) {
60
+ active = await this.persist(active, { intent: commentIntent });
61
+ }
62
+ }
63
+ if (active.record.intent?.kind === 'comment') {
64
+ if (active.record.intent.marker !== marker || active.record.intent.bodySha256 !== sha256(body)) {
65
+ return { result: await this.publicationDiverged(active, 'claim-comment-intent-diverged') };
66
+ }
67
+ observation = await this.readIssue(issueNumber);
68
+ let comments = observation ? commentsWithMarker(observation, marker) : [];
69
+ if (comments.some((comment) => comment.body !== body) || comments.length > 1) {
70
+ return { result: await this.publicationDiverged(active, 'claim-comment-diverged') };
71
+ }
72
+ if (comments.length === 0) {
41
73
  try {
42
- await this.dependencies.issues.setLabels(issueNumber, expectedLabels);
74
+ await this.dependencies.issues.postComment(issueNumber, body);
43
75
  }
44
76
  catch {
45
- return { result: await this.invokedFailure(active, 'claim-labels-delivery-unknown') };
77
+ return { result: await this.invokedFailure(active, 'claim-comment-delivery-unknown') };
46
78
  }
47
79
  observation = await this.readIssue(issueNumber);
80
+ comments = observation ? commentsWithMarker(observation, marker) : [];
48
81
  }
49
- if (!observation || !sameStrings(observation.labels, expectedLabels)) {
50
- return { result: await this.publicationDiverged(active, 'claim-labels-observation-diverged') };
82
+ if (comments.length !== 1 || comments[0].body !== body
83
+ || !['OWNER', 'MEMBER', 'COLLABORATOR'].includes(comments[0].authorAssociation)) {
84
+ return { result: await this.publicationDiverged(active, 'claim-comment-observation-diverged') };
51
85
  }
52
86
  active = await this.confirmEffect(active);
53
87
  }
54
- else if (!sameStrings(observation.labels, expectedLabels)) {
55
- return { result: await this.publicationDiverged(active, 'claim-labels-missing-before-comment') };
88
+ observation = await this.readIssue(issueNumber);
89
+ if (!observation || observation.state !== 'OPEN' || !this.hasTrustedClaim(observation, active.record)) {
90
+ return { result: await this.publicationDiverged(active, 'claim-comment-observation-diverged') };
91
+ }
92
+ if (sameStrings(observation.labels, expectedLabels))
93
+ return { active };
94
+ const labels = new Set(observation.labels);
95
+ if (!labels.has(config.github.labels.auto.name)
96
+ || labels.has(config.github.labels.blocked.name)
97
+ || labels.has(config.github.labels.review.name)
98
+ || labels.has(config.github.labels.waitingHuman.name)) {
99
+ return { result: await this.publicationDiverged(active, 'claim-labels-diverged') };
56
100
  }
57
- const body = claimComment(runId, issueNumber, branchName);
58
- const marker = body.split('\n')[0];
59
101
  if (!active.record.intent) {
60
- active = await this.persist(active, { intent: { kind: 'comment', issueNumber, marker, bodySha256: sha256(body) } });
102
+ active = await this.persist(active, { intent: { kind: 'claim-labels', issueNumber, expected: expectedLabels } });
61
103
  }
62
- if (active.record.intent?.kind !== 'comment' || active.record.intent.marker !== marker || active.record.intent.bodySha256 !== sha256(body)) {
63
- return { result: await this.publicationDiverged(active, 'claim-comment-intent-diverged') };
104
+ if (active.record.intent?.kind !== 'claim-labels' || !sameStrings(active.record.intent.expected, expectedLabels)) {
105
+ return { result: await this.publicationDiverged(active, 'claim-labels-intent-diverged') };
64
106
  }
65
- observation = await this.readIssue(issueNumber);
66
- let comments = observation ? commentsWithMarker(observation, marker) : [];
67
- if (comments.some((comment) => comment.body !== body) || comments.length > 1) {
68
- return { result: await this.publicationDiverged(active, 'claim-comment-diverged') };
107
+ try {
108
+ await this.dependencies.issues.setLabels(issueNumber, expectedLabels);
69
109
  }
70
- if (comments.length === 0) {
71
- try {
72
- await this.dependencies.issues.postComment(issueNumber, body);
73
- }
74
- catch {
75
- return { result: await this.invokedFailure(active, 'claim-comment-delivery-unknown') };
76
- }
77
- observation = await this.readIssue(issueNumber);
78
- comments = observation ? commentsWithMarker(observation, marker) : [];
110
+ catch {
111
+ return { result: await this.invokedFailure(active, 'claim-labels-delivery-unknown') };
79
112
  }
80
- if (comments.length !== 1 || comments[0].body !== body
81
- || !['OWNER', 'MEMBER', 'COLLABORATOR'].includes(comments[0].authorAssociation)) {
82
- return { result: await this.publicationDiverged(active, 'claim-comment-observation-diverged') };
113
+ observation = await this.readIssue(issueNumber);
114
+ if (!observation || !sameStrings(observation.labels, expectedLabels) || !this.hasTrustedClaim(observation, active.record)) {
115
+ return { result: await this.publicationDiverged(active, 'claim-labels-observation-diverged') };
83
116
  }
84
117
  active = await this.confirmEffect(active);
85
118
  return { active };
@@ -706,17 +739,6 @@ export class RunIssue {
706
739
  return await this.preClaimInternal('config-changed-during-owner-acquire', input.issueNumber);
707
740
  }
708
741
  const config = confirmedConfig.config;
709
- try {
710
- await this.dependencies.validateContainment(config);
711
- }
712
- catch {
713
- const evidence = await this.dependencies.writeEvidence({
714
- runId: `issue-${input.issueNumber}`,
715
- code: 'containment-certificate-invalid',
716
- summary: 'Containment certification is unavailable or stale.',
717
- });
718
- return { status: 'blocked', kind: 'safety', resumable: true, evidencePath: evidence.path };
719
- }
720
742
  if (this.signal.aborted)
721
743
  return await this.preClaimCancelled(input.issueNumber);
722
744
  let issue;
@@ -753,15 +775,33 @@ export class RunIssue {
753
775
  if (active.record.intent?.kind === 'review-blocked-labels') {
754
776
  return await this.blockReviewFeedback(active, active.record.intent.blockKind, active.record.intent.evidenceCode);
755
777
  }
778
+ if (active.record.intent?.kind === 'blocked-labels') {
779
+ return await this.publishBlockedTerminal(active, {
780
+ status: 'blocked',
781
+ kind: active.record.intent.blockKind,
782
+ resumable: active.record.intent.resumable,
783
+ }, active.record.intent.evidenceCode);
784
+ }
785
+ if (existing.terminalOutcome?.status === 'blocked'
786
+ && issue?.state === 'OPEN'
787
+ && blockedLabelProjection(issue.labels, config).status !== 'settled') {
788
+ return await this.reconcilePersistedBlockedTerminal(active, issue, existing.terminalOutcome);
789
+ }
756
790
  if (existing.terminalOutcome) {
757
- if (existing.terminalOutcome.status !== 'review-ready')
758
- return publicOutcome(existing.terminalOutcome);
759
- const continuation = await this.continueReviewReady(active, targetRoot, config);
760
- if ('result' in continuation)
761
- return continuation.result;
762
- active = continuation.active;
763
- issueSnapshot = structuredClone(active.record.issueSnapshot);
764
- frozenCriteria = structuredClone(active.record.frozenCriteria);
791
+ if (existing.terminalOutcome.status !== 'review-ready') {
792
+ const recovered = await this.recoverTerminalReviewReport(active, issue, config, frozenCriteria);
793
+ if (!recovered)
794
+ return publicOutcome(existing.terminalOutcome);
795
+ active = recovered;
796
+ }
797
+ else {
798
+ const continuation = await this.continueReviewReady(active, targetRoot, config);
799
+ if ('result' in continuation)
800
+ return continuation.result;
801
+ active = continuation.active;
802
+ issueSnapshot = structuredClone(active.record.issueSnapshot);
803
+ frozenCriteria = structuredClone(active.record.frozenCriteria);
804
+ }
765
805
  }
766
806
  if (active.record.reviewFeedback?.phase === 'frozen') {
767
807
  const activation = await this.resumeActivatedReviewFeedback(active, targetRoot, config);
@@ -896,11 +936,13 @@ export class RunIssue {
896
936
  worktreePath = resolve(targetRoot, config.runner.workspaceRoot, `issue-${input.issueNumber}`);
897
937
  baseSha = await this.dependencies.git.getBaseSha({ targetRoot, baseBranch: config.github.baseBranch });
898
938
  assertGitSha(baseSha, 'baseSha');
899
- const runningLabels = sortedUnique([config.github.labels.auto.name, config.github.labels.running.name]);
939
+ const claimBody = claimComment(runId, input.issueNumber, branchName);
900
940
  active = await this.createRun({
901
941
  runId, issueNumber: input.issueNumber, canonicalRepository, baseSha, branchName, worktreePath,
902
942
  issueSnapshot, frozenCriteria, config,
903
- intent: { kind: 'claim-labels', issueNumber: input.issueNumber, expected: runningLabels },
943
+ intent: {
944
+ kind: 'comment', issueNumber: input.issueNumber, marker: claimBody.split('\n')[0], bodySha256: sha256(claimBody),
945
+ },
904
946
  });
905
947
  const claim = await this.reconcileClaim(active, config);
906
948
  if ('result' in claim)
@@ -927,6 +969,13 @@ export class RunIssue {
927
969
  if (active.record.lifecycle === 'checking' && !active.record.directReview && active.record.routeReceipt?.route === 'direct') {
928
970
  return await this.terminal(active, { status: 'internal-error', code: 'direct-review-state-missing' });
929
971
  }
972
+ if (active.record.lifecycle === 'implementing' && active.record.routeReceipt?.route === 'direct'
973
+ && !active.record.directReview) {
974
+ const baseline = await this.captureBaselineChecks(active, config);
975
+ if ('status' in baseline)
976
+ return baseline;
977
+ active = baseline.active;
978
+ }
930
979
  attemptLoop: while (true) {
931
980
  if (!await this.authorized(active, config)) {
932
981
  return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true });
@@ -1204,23 +1253,50 @@ export class RunIssue {
1204
1253
  });
1205
1254
  }
1206
1255
  }
1207
- for (const [id, command] of Object.entries(config.checks)) {
1208
- if (active.record.checks.some((check) => check.id === id && check.status === 'passed'))
1256
+ let checkPolicy;
1257
+ try {
1258
+ checkPolicy = resolveIssueCheckPolicy(active.record.issueSnapshot.body, config.checks);
1259
+ }
1260
+ catch (error) {
1261
+ return await this.invokedFailure(active, 'issue-verification-invalid', error instanceof Error ? error.message : undefined);
1262
+ }
1263
+ const configuredChecks = Object.entries(checkPolicy.checks);
1264
+ const reusableChecks = active.record.checks.filter((check) => configuredChecks.some(([id, command]) => check.id === id && check.command === command));
1265
+ if (reusableChecks.length !== active.record.checks.length) {
1266
+ active = await this.persist(active, {
1267
+ checks: reusableChecks,
1268
+ checkedChangeSha256: undefined,
1269
+ proofId: undefined,
1270
+ proofReceipt: undefined,
1271
+ });
1272
+ }
1273
+ for (const [id, command] of configuredChecks) {
1274
+ if (active.record.checks.some((check) => check.id === id
1275
+ && check.command === command
1276
+ && (check.status === 'passed' || check.status === 'unchanged-failure')))
1209
1277
  continue;
1210
1278
  if (this.signal.aborted)
1211
1279
  return await this.terminal(active, { status: 'cancelled' });
1212
1280
  let check;
1213
1281
  try {
1214
- check = await this.dependencies.checks.run({ id, command, cwd: worktreePath, signal: this.signal });
1282
+ check = await this.dependencies.checks.run({ id, command, source: checkPolicy.source, cwd: worktreePath, phase: 'changed', signal: this.signal });
1215
1283
  }
1216
- catch {
1217
- return await this.terminal(active, { status: 'internal-error', code: 'configured-check-execution-failed' });
1284
+ catch (error) {
1285
+ if (error instanceof CheckProcessQuiescenceError) {
1286
+ return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'check-process-quiescence-unconfirmed');
1287
+ }
1288
+ return await this.invokedFailure(active, 'configured-check-execution-failed', error instanceof Error ? error.message : 'The check process did not start or settle. Retry the same run.');
1218
1289
  }
1219
1290
  if (this.signal.aborted)
1220
1291
  return await this.terminal(active, { status: 'cancelled' });
1221
- const row = { id, command, status: check.status, outputSha256: sha256(check.output) };
1292
+ const outputSha256 = check.outputSha256;
1293
+ const baseline = active.record.baselineChecks?.find((candidate) => candidate.id === id && candidate.command === command);
1294
+ const status = check.status === 'failed' && baseline?.status === 'failed' && baseline.outputSha256 === outputSha256
1295
+ ? 'unchanged-failure'
1296
+ : check.status;
1297
+ const row = { id, command, status, outputSha256 };
1222
1298
  active = await this.persist(active, { checks: [...active.record.checks, row] });
1223
- if (check.status !== 'passed') {
1299
+ if (status === 'failed') {
1224
1300
  if (this.repairBudgetExhausted(active, config.runner.maxCycles)) {
1225
1301
  return active.record.reviewFeedback?.activeBatch
1226
1302
  ? await this.blockReviewFeedback(active, 'exhausted', 'review-feedback-check-repair-exhausted')
@@ -1272,6 +1348,9 @@ export class RunIssue {
1272
1348
  || reviewedStageBinding.contentSha256 !== proofContentSha256) {
1273
1349
  return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'checked-change-review-binding-drift');
1274
1350
  }
1351
+ if (!sameCheckPolicy(active.record.checks, checkPolicy.checks)) {
1352
+ return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'configured-check-policy-drift');
1353
+ }
1275
1354
  const payload = {
1276
1355
  version: 1,
1277
1356
  canonicalRepository,
@@ -1285,8 +1364,11 @@ export class RunIssue {
1285
1364
  untrackedContentSha256: freshness.untrackedContentSha256,
1286
1365
  worktreeIdentity: freshness.worktreeIdentity,
1287
1366
  changedFiles: proofChangedFiles,
1288
- checks: active.record.checks.map((check) => ({ ...check, status: 'passed' })),
1289
- checkPolicySha256: sha256(canonicalJson(config.checks)),
1367
+ checks: active.record.checks.map((check) => ({
1368
+ ...check,
1369
+ status: check.status === 'unchanged-failure' ? 'unchanged-failure' : 'passed',
1370
+ })),
1371
+ checkPolicySha256: sha256(canonicalJson(checkPolicy.checks)),
1290
1372
  packageVersion: active.record.packageVersion,
1291
1373
  proofSchemaVersion: 1,
1292
1374
  };
@@ -1800,7 +1882,7 @@ export class RunIssue {
1800
1882
  packageVersion: workflow.receipt.packageVersion,
1801
1883
  workflowGeneration: structuredClone(workflow.receipt),
1802
1884
  skillHashes: structuredClone(workflow.skillHashes),
1803
- checks: [], createdAt: now, updatedAt: now,
1885
+ baselineChecks: [], checks: [], createdAt: now, updatedAt: now,
1804
1886
  };
1805
1887
  const saved = await this.dependencies.runRecords.compareAndSwap(state.generation, {
1806
1888
  schema: 'codex-orchestrator.agent-auto-state', version: 2, runs: [...state.runs, record],
@@ -1842,7 +1924,10 @@ export class RunIssue {
1842
1924
  if (issue.state !== 'OPEN')
1843
1925
  return false;
1844
1926
  const labels = new Set(issue.labels);
1845
- if (!labels.has(config.github.labels.auto.name) || !labels.has(config.github.labels.running.name))
1927
+ if (!labels.has(config.github.labels.auto.name)
1928
+ || labels.has(config.github.labels.blocked.name)
1929
+ || labels.has(config.github.labels.review.name)
1930
+ || labels.has(config.github.labels.waitingHuman.name))
1846
1931
  return false;
1847
1932
  return this.hasTrustedClaim(issue, record);
1848
1933
  }
@@ -1957,9 +2042,7 @@ export class RunIssue {
1957
2042
  affectedDefectIds: [...directReview.review.affectedDefectIds],
1958
2043
  fixedRepairFindings: directReview.repairFindings.filter((finding) => finding.status === 'fixed')
1959
2044
  .map((finding) => ({ id: finding.id, affectedContracts: [...finding.affectedContracts] })),
1960
- mandatoryCoverage: directReview.stage === 'review-closure'
1961
- ? [...directReview.review.coverage]
1962
- : ['acceptance-criteria', 'correctness', 'test-quality'],
2045
+ reviewFocus: ['acceptance-criteria', 'correctness', 'test-quality'],
1963
2046
  workflowGeneration: structuredClone(active.record.workflowGeneration),
1964
2047
  repairOnly: reportRepair !== undefined,
1965
2048
  originalReportSha256: reportRepair?.originalReportSha256 ?? null,
@@ -2029,14 +2112,17 @@ export class RunIssue {
2029
2112
  const current = active.record.directReview;
2030
2113
  if (!current)
2031
2114
  return this.terminal(active, { status: 'internal-error', code: 'direct-review-result-orphaned' });
2032
- if (current.review.reportRepairs >= 1 || reportRepair) {
2033
- return this.terminal(active, { status: 'internal-error', code: 'direct-review-report-malformed' });
2115
+ if (current.review.reportRepairs >= MAX_DIRECT_REVIEW_REPORT_REPAIRS) {
2116
+ return this.terminal(active, { status: 'internal-error', code: 'direct-review-report-malformed' }, 'direct-review-report-malformed');
2034
2117
  }
2035
2118
  const { invocation: _invocation, ...withoutInvocation } = structuredClone(current);
2036
2119
  active = await this.persist(active, {
2037
2120
  directReview: {
2038
2121
  ...withoutInvocation,
2039
- review: { ...withoutInvocation.review, reportRepairs: 1 },
2122
+ review: {
2123
+ ...withoutInvocation.review,
2124
+ reportRepairs: (withoutInvocation.review.reportRepairs + 1),
2125
+ },
2040
2126
  },
2041
2127
  });
2042
2128
  reportRepair = {
@@ -2111,6 +2197,100 @@ export class RunIssue {
2111
2197
  }
2112
2198
  return this.persist(starting, { directReview: withoutInvocation });
2113
2199
  }
2200
+ async captureBaselineChecks(starting, config) {
2201
+ let active = starting;
2202
+ if ((await this.dependencies.git.listChangedFiles(active.record.worktreePath)).length !== 0)
2203
+ return { active };
2204
+ let checkPolicy;
2205
+ try {
2206
+ checkPolicy = resolveIssueCheckPolicy(active.record.issueSnapshot.body, config.checks);
2207
+ }
2208
+ catch (error) {
2209
+ active = await this.persist(active, { lifecycle: 'routed' });
2210
+ return this.invokedFailure(active, 'issue-verification-invalid', error instanceof Error ? error.message : undefined);
2211
+ }
2212
+ const configuredChecks = Object.entries(checkPolicy.checks);
2213
+ const reusableBaselineChecks = (active.record.baselineChecks ?? []).filter((check) => configuredChecks.some(([id, command]) => check.id === id && check.command === command));
2214
+ if (reusableBaselineChecks.length !== (active.record.baselineChecks ?? []).length) {
2215
+ active = await this.persist(active, { baselineChecks: reusableBaselineChecks });
2216
+ }
2217
+ for (const [id, command] of configuredChecks) {
2218
+ if (active.record.baselineChecks?.some((check) => check.id === id && check.command === command))
2219
+ continue;
2220
+ if (this.signal.aborted)
2221
+ return this.terminal(active, { status: 'cancelled' });
2222
+ let check;
2223
+ try {
2224
+ check = await this.dependencies.checks.run({
2225
+ id, command, source: checkPolicy.source,
2226
+ cwd: active.record.worktreePath,
2227
+ phase: 'baseline',
2228
+ signal: this.signal,
2229
+ });
2230
+ }
2231
+ catch (error) {
2232
+ if (error instanceof CheckProcessQuiescenceError) {
2233
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'check-process-quiescence-unconfirmed');
2234
+ }
2235
+ active = await this.persist(active, { lifecycle: 'routed' });
2236
+ return this.invokedFailure(active, 'configured-check-execution-failed', error instanceof Error ? error.message : 'The baseline check process did not start or settle. Retry the same run.');
2237
+ }
2238
+ if ((await this.dependencies.git.listChangedFiles(active.record.worktreePath)).length !== 0) {
2239
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'baseline-check-mutated-worktree');
2240
+ }
2241
+ active = await this.persist(active, {
2242
+ baselineChecks: [
2243
+ ...(active.record.baselineChecks ?? []),
2244
+ { id, command, status: check.status, outputSha256: check.outputSha256 },
2245
+ ],
2246
+ });
2247
+ }
2248
+ return { active };
2249
+ }
2250
+ async recoverTerminalReviewReport(active, issue, config, frozenCriteria) {
2251
+ const directReview = active.record.directReview;
2252
+ const allowLegacyMalformed = directReview?.terminalCode === undefined
2253
+ && active.record.outcomeEvidenceId === `evidence:${active.record.runId}:direct-review-report-malformed`;
2254
+ if (!issue || !directReview || active.record.intent || active.record.process
2255
+ || active.record.terminalOutcome?.status !== 'internal-error'
2256
+ || directReview.status !== 'terminal' || directReview.terminalOutcome?.status !== 'internal-error'
2257
+ || !canRecoverTerminalDirectReviewReport(directReview, { allowLegacyMalformed })
2258
+ || !this.isAuthorizedIssue(issue, active.record, config))
2259
+ return undefined;
2260
+ let worktree;
2261
+ try {
2262
+ worktree = await this.dependencies.git.inspectWorktree({
2263
+ worktreePath: active.record.worktreePath,
2264
+ branchName: active.record.branchName,
2265
+ baseSha: active.record.baseSha,
2266
+ });
2267
+ }
2268
+ catch {
2269
+ return undefined;
2270
+ }
2271
+ if (worktree !== 'matching'
2272
+ || await this.dependencies.git.getHead(active.record.worktreePath) !== expectedImplementationHead(active.record))
2273
+ return undefined;
2274
+ const changedFiles = await this.dependencies.git.listChangedFiles(active.record.worktreePath);
2275
+ if (changedFiles.length === 0 || !active.record.routeReceipt)
2276
+ return undefined;
2277
+ const targetFingerprint = directReviewTargetFingerprint({
2278
+ snapshot: await this.dependencies.git.snapshot(active.record.worktreePath),
2279
+ changedFiles,
2280
+ routeDecisionSha256: active.record.routeReceipt.decisionSha256,
2281
+ workflowGenerationHash: active.record.workflowGeneration.generationHash,
2282
+ cycle: active.record.cycle,
2283
+ frozenCriteria,
2284
+ });
2285
+ if (targetFingerprint !== directReview.targetFingerprint)
2286
+ return undefined;
2287
+ return this.persist(active, {
2288
+ lifecycle: 'implementing',
2289
+ directReview: recoverTerminalDirectReviewReport(directReview, { allowLegacyMalformed }),
2290
+ terminalOutcome: undefined,
2291
+ outcomeEvidenceId: undefined,
2292
+ });
2293
+ }
2114
2294
  async startNextCycle(active, findings, sources) {
2115
2295
  if (active.record.directReview?.status === 'clear') {
2116
2296
  const fallbackProvenance = active.record.lifecycle === 'proving' ? 'proof' : 'check';
@@ -2252,6 +2432,101 @@ export class RunIssue {
2252
2432
  if (active.record.reviewFeedback?.activeBatch && outcome.status !== 'review-ready') {
2253
2433
  return this.blockReviewFeedback(active, outcome.status === 'blocked' && outcome.kind === 'exhausted' ? 'exhausted' : 'safety', evidenceCode);
2254
2434
  }
2435
+ if (outcome.status === 'blocked') {
2436
+ return this.publishBlockedTerminal(active, outcome, evidenceCode);
2437
+ }
2438
+ return this.persistTerminal(active, outcome, evidenceCode, retainIntent);
2439
+ }
2440
+ async publishBlockedTerminal(starting, outcome, evidenceCode) {
2441
+ let active = starting;
2442
+ const autoBlocked = [active.config.github.labels.auto.name, active.config.github.labels.blocked.name].sort();
2443
+ const blockedOnly = [active.config.github.labels.blocked.name];
2444
+ let expected = autoBlocked;
2445
+ const existing = active.record.intent;
2446
+ if (!existing) {
2447
+ active = await this.persist(active, { intent: {
2448
+ kind: 'blocked-labels',
2449
+ issueNumber: active.record.issueNumber,
2450
+ expected,
2451
+ blockKind: outcome.kind,
2452
+ resumable: outcome.resumable,
2453
+ evidenceCode,
2454
+ } });
2455
+ }
2456
+ else if (existing.kind !== 'blocked-labels'
2457
+ || existing.issueNumber !== active.record.issueNumber
2458
+ || existing.blockKind !== outcome.kind
2459
+ || existing.resumable !== outcome.resumable
2460
+ || existing.evidenceCode !== evidenceCode) {
2461
+ return this.invokedFailure(active, 'blocked-labels-intent-diverged');
2462
+ }
2463
+ else {
2464
+ expected = existing.expected;
2465
+ if (!sameStrings(expected, autoBlocked) && !sameStrings(expected, blockedOnly) && expected.length !== 0) {
2466
+ return this.invokedFailure(active, 'blocked-labels-intent-diverged');
2467
+ }
2468
+ }
2469
+ let issue = await this.readIssue(active.record.issueNumber);
2470
+ if (!issue)
2471
+ return this.invokedFailure(active, 'blocked-labels-issue-missing');
2472
+ let projection = blockedLabelProjection(issue.labels, active.config);
2473
+ if (issue.state === 'OPEN' && projection.status === 'transition') {
2474
+ if (!this.dependencies.issues.transitionToBlocked) {
2475
+ return this.invokedFailure(active, 'blocked-labels-transition-unavailable');
2476
+ }
2477
+ try {
2478
+ await this.dependencies.issues.transitionToBlocked(active.record.issueNumber, blockedLabelPolicy(active.config));
2479
+ }
2480
+ catch {
2481
+ return this.invokedFailure(active, 'blocked-labels-delivery-unknown');
2482
+ }
2483
+ issue = await this.readIssue(active.record.issueNumber);
2484
+ projection = issue ? blockedLabelProjection(issue.labels, active.config) : { status: 'diverged' };
2485
+ }
2486
+ if (!issue)
2487
+ return this.invokedFailure(active, 'blocked-labels-observation-missing');
2488
+ if (issue.state === 'OPEN') {
2489
+ if (projection.status !== 'settled') {
2490
+ return this.invokedFailure(active, 'blocked-labels-observation-diverged');
2491
+ }
2492
+ if (!sameStrings(expected, projection.expected)) {
2493
+ expected = projection.expected;
2494
+ const intent = active.record.intent;
2495
+ if (intent?.kind !== 'blocked-labels')
2496
+ return this.invokedFailure(active, 'blocked-labels-intent-diverged');
2497
+ try {
2498
+ active = await this.persist(active, { intent: { ...intent, expected } });
2499
+ }
2500
+ catch {
2501
+ throw new PostEffectStateError(active);
2502
+ }
2503
+ }
2504
+ }
2505
+ try {
2506
+ return await this.persistTerminal(active, outcome, evidenceCode, false);
2507
+ }
2508
+ catch {
2509
+ throw new PostEffectStateError(active);
2510
+ }
2511
+ }
2512
+ async reconcilePersistedBlockedTerminal(active, issue, outcome) {
2513
+ if (!this.dependencies.issues.transitionToBlocked) {
2514
+ return this.invokedFailure(active, 'blocked-labels-transition-unavailable');
2515
+ }
2516
+ try {
2517
+ await this.dependencies.issues.transitionToBlocked(active.record.issueNumber, blockedLabelPolicy(active.config));
2518
+ }
2519
+ catch {
2520
+ return this.invokedFailure(active, 'blocked-labels-delivery-unknown');
2521
+ }
2522
+ const observation = await this.readIssue(active.record.issueNumber);
2523
+ if (!observation || observation.state !== 'OPEN'
2524
+ || blockedLabelProjection(observation.labels, active.config).status !== 'settled') {
2525
+ return this.invokedFailure(active, 'blocked-labels-observation-diverged');
2526
+ }
2527
+ return publicOutcome(outcome);
2528
+ }
2529
+ async persistTerminal(active, outcome, evidenceCode, retainIntent) {
2255
2530
  const evidence = await this.dependencies.writeEvidence({ runId: active.record.runId, code: evidenceCode, summary: outcome.status });
2256
2531
  const terminalOutcome = { ...outcome, evidencePath: evidence.path };
2257
2532
  const changes = {
@@ -2263,7 +2538,7 @@ export class RunIssue {
2263
2538
  if (outcome.status !== 'review-ready' && active.record.directReview && active.record.directReview.status !== 'terminal') {
2264
2539
  changes.directReview = projectTerminalDirectReview(active.record.directReview, outcome.status === 'blocked'
2265
2540
  ? { status: 'blocked', kind: outcome.kind }
2266
- : { status: outcome.status });
2541
+ : { status: outcome.status }, outcome.status === 'internal-error' ? outcome.code : undefined);
2267
2542
  }
2268
2543
  if (active.record.waitingHuman && (active.record.lifecycle === 'waiting-human' || active.record.waitingHuman.phase === 'resumed')) {
2269
2544
  if (active.record.waitingHuman.phase === 'resumed') {
@@ -2503,6 +2778,36 @@ function sameStrings(left, right) {
2503
2778
  const b = [...right].sort();
2504
2779
  return a.length === b.length && a.every((value, index) => value === b[index]);
2505
2780
  }
2781
+ function blockedLabelPolicy(config) {
2782
+ return {
2783
+ auto: config.github.labels.auto.name,
2784
+ running: config.github.labels.running.name,
2785
+ blocked: config.github.labels.blocked.name,
2786
+ review: config.github.labels.review.name,
2787
+ waitingHuman: config.github.labels.waitingHuman.name,
2788
+ };
2789
+ }
2790
+ function blockedLabelProjection(labels, config) {
2791
+ const policy = blockedLabelPolicy(config);
2792
+ const present = new Set(labels);
2793
+ if (present.has(policy.review) || present.has(policy.waitingHuman))
2794
+ return { status: 'diverged' };
2795
+ const auto = present.has(policy.auto);
2796
+ const running = present.has(policy.running);
2797
+ const blocked = present.has(policy.blocked);
2798
+ if (running || (auto && !blocked))
2799
+ return { status: 'transition' };
2800
+ if (blocked)
2801
+ return { status: 'settled', expected: auto ? [policy.auto, policy.blocked].sort() : [policy.blocked] };
2802
+ return { status: 'settled', expected: [] };
2803
+ }
2804
+ function sameCheckPolicy(checks, policy) {
2805
+ const expected = Object.entries(policy).sort(([left], [right]) => left.localeCompare(right));
2806
+ const actual = checks.map((check) => [check.id, check.command])
2807
+ .sort(([left], [right]) => left.localeCompare(right));
2808
+ return actual.length === expected.length
2809
+ && actual.every(([id, command], index) => id === expected[index]?.[0] && command === expected[index]?.[1]);
2810
+ }
2506
2811
  function sameFreshness(left, right) {
2507
2812
  return left.headSha === right.headSha
2508
2813
  && left.indexTreeSha === right.indexTreeSha