@open-agent-toolkit/cli 0.2.59 → 0.2.63

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 (120) hide show
  1. package/assets/agents/oat-phase-implementer.md +27 -4
  2. package/assets/agents/oat-reviewer.md +25 -14
  3. package/assets/bundle-metadata.json +1 -1
  4. package/assets/docs/cli-utilities/config-and-local-state.md +1 -0
  5. package/assets/docs/cli-utilities/configuration.md +10 -8
  6. package/assets/docs/cli-utilities/project-log.md +16 -0
  7. package/assets/docs/cli-utilities/workflow-gates.md +127 -19
  8. package/assets/docs/provider-sync/commands.md +3 -0
  9. package/assets/docs/provider-sync/instruction-sync.md +107 -0
  10. package/assets/docs/reference/cli-reference.md +26 -9
  11. package/assets/docs/reference/oat-directory-structure.md +33 -31
  12. package/assets/docs/workflows/ideas/index.md +1 -1
  13. package/assets/docs/workflows/index.md +1 -0
  14. package/assets/docs/workflows/projects/artifacts.md +24 -3
  15. package/assets/docs/workflows/projects/hill-checkpoints.md +21 -0
  16. package/assets/docs/workflows/projects/implementation-execution.md +5 -0
  17. package/assets/docs/workflows/projects/lifecycle.md +169 -7
  18. package/assets/docs/workflows/projects/picking-up-projects.md +14 -0
  19. package/assets/docs/workflows/projects/pr-flow.md +2 -0
  20. package/assets/docs/workflows/projects/reviews.md +1 -1
  21. package/assets/docs/workflows/projects/state-machine.md +20 -0
  22. package/assets/docs/workflows/skills/explainer-kit.md +10 -4
  23. package/assets/docs/workflows/skills/index.md +2 -1
  24. package/assets/docs/workflows/skills/repo-improve.md +15 -1
  25. package/assets/public-package-versions.json +4 -4
  26. package/assets/skills/oat-brainstorm/SKILL.md +61 -12
  27. package/assets/skills/oat-docs/SKILL.md +13 -8
  28. package/assets/skills/oat-doctor/SKILL.md +3 -3
  29. package/assets/skills/oat-explainer-kit/SKILL.md +14 -1
  30. package/assets/skills/oat-explainer-kit/references/config-contract.md +12 -0
  31. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +57 -10
  32. package/assets/skills/oat-explainer-kit/scripts/check-terminal-outcome.mjs +28 -5
  33. package/assets/skills/oat-explainer-kit/scripts/probe-recap-seams.mjs +371 -0
  34. package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +123 -1
  35. package/assets/skills/oat-pjm-review-backlog/SKILL.md +2 -2
  36. package/assets/skills/oat-project-autonomous/SKILL.md +23 -9
  37. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +164 -150
  38. package/assets/skills/oat-project-capture/SKILL.md +2 -2
  39. package/assets/skills/oat-project-complete/SKILL.md +134 -4
  40. package/assets/skills/oat-project-discover/SKILL.md +6 -5
  41. package/assets/skills/oat-project-document/SKILL.md +11 -1
  42. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +164 -150
  43. package/assets/skills/oat-project-implement/SKILL.md +3 -3
  44. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +144 -21
  45. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +164 -150
  46. package/assets/skills/oat-project-implement/references/phase-execution.md +16 -1
  47. package/assets/skills/oat-project-implement/references/plan-and-resume.md +19 -1
  48. package/assets/skills/oat-project-import-plan/SKILL.md +47 -3
  49. package/assets/skills/oat-project-lite/SKILL.md +467 -0
  50. package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +331 -0
  51. package/assets/skills/oat-project-next/SKILL.md +75 -16
  52. package/assets/skills/oat-project-plan/SKILL.md +44 -6
  53. package/assets/skills/oat-project-plan-writing/SKILL.md +6 -3
  54. package/assets/skills/oat-project-pr-final/SKILL.md +33 -7
  55. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +164 -150
  56. package/assets/skills/oat-project-pr-progress/SKILL.md +3 -3
  57. package/assets/skills/oat-project-progress/SKILL.md +27 -5
  58. package/assets/skills/oat-project-promote-spec-driven/SKILL.md +3 -1
  59. package/assets/skills/oat-project-quick-start/SKILL.md +264 -4
  60. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +164 -150
  61. package/assets/skills/oat-project-review-provide/SKILL.md +25 -6
  62. package/assets/skills/oat-project-summary/SKILL.md +61 -26
  63. package/assets/skills/oat-repo-improve/SKILL.md +40 -5
  64. package/assets/skills/oat-repo-improve/references/plan-template.md +84 -7
  65. package/assets/templates/plan-lite.md +165 -0
  66. package/assets/templates/plan.md +1 -1
  67. package/assets/templates/state.md +1 -1
  68. package/dist/commands/config/index.d.ts +1 -0
  69. package/dist/commands/config/index.d.ts.map +1 -1
  70. package/dist/commands/config/index.js +401 -18
  71. package/dist/commands/gate/index.d.ts +3 -1
  72. package/dist/commands/gate/index.d.ts.map +1 -1
  73. package/dist/commands/gate/index.js +699 -211
  74. package/dist/commands/init/tools/shared/skill-script-references.d.ts +126 -0
  75. package/dist/commands/init/tools/shared/skill-script-references.d.ts.map +1 -0
  76. package/dist/commands/init/tools/shared/skill-script-references.js +311 -0
  77. package/dist/commands/instructions/instructions.types.d.ts +63 -0
  78. package/dist/commands/instructions/instructions.types.d.ts.map +1 -1
  79. package/dist/commands/instructions/instructions.utils.d.ts +51 -2
  80. package/dist/commands/instructions/instructions.utils.d.ts.map +1 -1
  81. package/dist/commands/instructions/instructions.utils.js +201 -4
  82. package/dist/commands/instructions/sync/sync.d.ts.map +1 -1
  83. package/dist/commands/instructions/sync/sync.js +12 -1
  84. package/dist/commands/instructions/validate/validate.d.ts.map +1 -1
  85. package/dist/commands/instructions/validate/validate.js +12 -1
  86. package/dist/commands/project/index.d.ts.map +1 -1
  87. package/dist/commands/project/index.js +2 -0
  88. package/dist/commands/project/log/append.d.ts +195 -0
  89. package/dist/commands/project/log/append.d.ts.map +1 -1
  90. package/dist/commands/project/log/append.js +1034 -11
  91. package/dist/commands/project/new/index.d.ts +3 -2
  92. package/dist/commands/project/new/index.d.ts.map +1 -1
  93. package/dist/commands/project/new/index.js +2 -1
  94. package/dist/commands/project/new/scaffold.d.ts +5 -3
  95. package/dist/commands/project/new/scaffold.d.ts.map +1 -1
  96. package/dist/commands/project/new/scaffold.js +38 -10
  97. package/dist/commands/project/promote/index.d.ts +2 -0
  98. package/dist/commands/project/promote/index.d.ts.map +1 -0
  99. package/dist/commands/project/promote/index.js +1 -0
  100. package/dist/commands/project/promote/promote.d.ts +47 -0
  101. package/dist/commands/project/promote/promote.d.ts.map +1 -0
  102. package/dist/commands/project/promote/promote.js +322 -0
  103. package/dist/commands/project/split/run.d.ts.map +1 -1
  104. package/dist/commands/project/split/run.js +8 -1
  105. package/dist/commands/project/validate-plan/index.d.ts.map +1 -1
  106. package/dist/commands/project/validate-plan/index.js +25 -1
  107. package/dist/commands/project/validate-plan/validate-plan.d.ts +8 -0
  108. package/dist/commands/project/validate-plan/validate-plan.d.ts.map +1 -1
  109. package/dist/commands/project/validate-plan/validate-plan.js +49 -0
  110. package/dist/commands/shared/frontmatter.d.ts +1 -1
  111. package/dist/commands/shared/frontmatter.d.ts.map +1 -1
  112. package/dist/commands/shared/frontmatter.js +1 -0
  113. package/dist/commands/state/generate.d.ts.map +1 -1
  114. package/dist/commands/state/generate.js +23 -0
  115. package/dist/commands/tools/shared/pack-manifest.d.ts.map +1 -1
  116. package/dist/commands/tools/shared/pack-manifest.js +2 -0
  117. package/dist/config/oat-config.d.ts +41 -0
  118. package/dist/config/oat-config.d.ts.map +1 -1
  119. package/dist/config/oat-config.js +111 -0
  120. package/package.json +2 -2
@@ -2,9 +2,9 @@ import { execFileSync } from 'node:child_process';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
- import { basename, isAbsolute, join, relative } from 'node:path';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, } from 'node:path';
6
6
  import { buildCommandContext, } from '../../app/command-context.js';
7
- import { appendProjectLog } from '../project/log/append.js';
7
+ import { appendProjectLog, commitProjectLog, committedProjectLogIdentityState, GATE_RECEIPTS_DIRNAME, PROJECT_LOG_FILENAME, } from '../project/log/append.js';
8
8
  import { getFrontmatterBlock, parseFrontmatterScalarFields, parseGeneratedTime, parseSkillGateOverrides, SKILL_GATE_OVERRIDE_DISABLED, SKILL_GATE_OVERRIDE_SOURCE, } from '../shared/frontmatter.js';
9
9
  import { readGlobalOptions } from '../shared/shared.utils.js';
10
10
  import { parseJsonConfig } from '../../config/json.js';
@@ -45,6 +45,10 @@ const DEFAULT_DEPENDENCIES = {
45
45
  processEnv: process.env,
46
46
  writeGateRunMarker,
47
47
  removeGateRunMarker,
48
+ writeGateProjectLogReceipt,
49
+ sleep: async (ms) => {
50
+ await new Promise((settle) => setTimeout(settle, ms));
51
+ },
48
52
  writeDiagnostic: (message) => process.stderr.write(message),
49
53
  };
50
54
  const VALID_ON_FAILURE = ['block', 'prompt', 'warn'];
@@ -91,6 +95,58 @@ async function writeGateRunMarker(path, marker, warn) {
91
95
  return false;
92
96
  }
93
97
  }
98
+ /**
99
+ * Writes a gate partial-finalization receipt.
100
+ *
101
+ * Injected like `writeGateRunMarker`, but deliberately not stored beside it:
102
+ * the run marker lives in `tmpdir()` because it dies with the run, while a
103
+ * receipt has to survive into the later process that finishes the
104
+ * finalization, so it lives under the project it belongs to.
105
+ */
106
+ async function writeGateProjectLogReceipt(path, receipt, warn) {
107
+ try {
108
+ await mkdir(dirname(path), { recursive: true });
109
+ await writeFile(path, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
110
+ }
111
+ catch (error) {
112
+ const detail = error instanceof Error ? error.message : String(error);
113
+ warn(`Unable to write gate project log receipt ${path}: ${detail}`);
114
+ return false;
115
+ }
116
+ warnWhenReceiptIsTracked(path, warn);
117
+ return true;
118
+ }
119
+ /**
120
+ * Warns when the receipt just written is not ignored by git.
121
+ *
122
+ * "Untracked" is a property of the repository's ignore rules, not of this
123
+ * module: a repository that relocates `projects.root`, or drops the shipped
124
+ * repository-wide gate-receipts ignore rule, gets a receipt that the next
125
+ * `git add -A` would commit — the exact dirty worktree finalization exists to
126
+ * prevent. The
127
+ * receipt is still written, because losing the finalization would be worse
128
+ * than a tracked file; the operator is told instead. A repository this path
129
+ * cannot interrogate stays silent rather than warning speculatively.
130
+ */
131
+ function warnWhenReceiptIsTracked(path, warn) {
132
+ try {
133
+ execFileSync('git', ['check-ignore', '--quiet', '--', path], {
134
+ cwd: dirname(path),
135
+ stdio: ['ignore', 'ignore', 'ignore'],
136
+ });
137
+ }
138
+ catch (error) {
139
+ // Exit 1 means "not ignored"; anything else (not a repository, git
140
+ // missing) is not evidence either way.
141
+ const status = error && typeof error === 'object' && 'status' in error
142
+ ? error.status
143
+ : undefined;
144
+ if (status !== 1) {
145
+ return;
146
+ }
147
+ warn(`Gate project log receipt ${path} is not ignored by git. Add a gate-receipts ignore rule for this projects root, or the receipt will be committed by the next repository-wide add.`);
148
+ }
149
+ }
94
150
  async function removeGateRunMarker(path, warn) {
95
151
  try {
96
152
  await rm(path);
@@ -1485,10 +1541,31 @@ function formatGateActivityEvidenceDiagnostic(evidence, now = Date.now()) {
1485
1541
  : '';
1486
1542
  return `Activity evidence: ${transcriptScope} metadata ${baselineState}; observed ${observedAgoMs}ms ago; ${lastChangeTiming}.${attributionWarning}`;
1487
1543
  }
1544
+ /**
1545
+ * A stable, routable code for the post-selection failure: the thrown error's
1546
+ * own `code` when it has one, otherwise its constructor name.
1547
+ */
1548
+ function postSelectionFailureCode(error) {
1549
+ const code = error?.code;
1550
+ if (typeof code === 'string' && code.trim().length > 0) {
1551
+ return code;
1552
+ }
1553
+ if (typeof code === 'number') {
1554
+ return String(code);
1555
+ }
1556
+ if (error instanceof Error) {
1557
+ return error.constructor.name;
1558
+ }
1559
+ return 'UnknownError';
1560
+ }
1488
1561
  function writeReviewGateUnexpectedFailure(context, payload) {
1489
1562
  const message = payload.error instanceof Error
1490
1563
  ? payload.error.message
1491
1564
  : String(payload.error);
1565
+ // When a committed artifact was found but did not survive re-validation, the
1566
+ // eligibility cause is the routable code; the thrown sub-step still names
1567
+ // what failed.
1568
+ const code = payload.ineligibility ?? postSelectionFailureCode(payload.error);
1492
1569
  if (context.json) {
1493
1570
  context.logger.json({
1494
1571
  status: 'review_failed',
@@ -1499,11 +1576,12 @@ function writeReviewGateUnexpectedFailure(context, payload) {
1499
1576
  projectResolutionSource: payload.projectResolutionSource,
1500
1577
  gateInvocation: payload.gateInvocation,
1501
1578
  dispatchReport: payload.dispatchReport,
1579
+ postSelection: { step: payload.step, code },
1502
1580
  message,
1503
1581
  });
1504
1582
  return;
1505
1583
  }
1506
- context.logger.error(`Review failed after target selection for ${payload.target}: ${message}`);
1584
+ context.logger.error(`Review failed after target selection for ${payload.target}: ${message} (post-selection step: ${payload.step})`);
1507
1585
  }
1508
1586
  function writeReviewGateArtifactValidationFailure(context, payload) {
1509
1587
  if (context.json) {
@@ -1595,74 +1673,216 @@ function writeReviewGateTargetingFailure(context, payload) {
1595
1673
  context.logger.error(`Expected project=${payload.corroboration.expectedProject} run=${payload.gateInvocation.runId}; actual containing_project=${payload.corroboration.actual.containingProject ?? 'missing'} artifact_project=${payload.corroboration.actual.artifactProject ?? 'missing'} run_matches=${payload.corroboration.actual.matchingArtifactPaths.join(',') || 'none'}.`);
1596
1674
  context.logger.error('This targeting failure is not receive-eligible and must be corrected before severity or invocation remediation.');
1597
1675
  }
1676
+ const GATE_PROJECT_LOG_PRODUCER = 'oat gate review';
1677
+ const GATE_PROJECT_LOG_COMMIT_MESSAGE = 'chore(oat): record gate review in project log';
1598
1678
  /**
1599
1679
  * Commits `project-log.md` after this gate run appends to it.
1600
1680
  *
1601
- * The log is tracked, so an uncommitted append leaves the worktree dirty for
1602
- * whatever runs next — including a dispatched subagent whose preflight requires
1603
- * a clean tree. The commit is pathspec-scoped to the log alone so unrelated
1604
- * working-tree changes are never swept in.
1681
+ * The retry, the index-lock classification, and the log mutation itself belong
1682
+ * to the `oat project log` module (DR-260718), so this is a thin gate-side
1683
+ * wrapper that supplies the gate's commit message and its injected sleep. The
1684
+ * recovery entry point calls the same implementation, so a finalization
1685
+ * completed later is the finalization the gate would have made.
1605
1686
  *
1606
- * Scope note: this commits the whole log file, so a log that was already dirty
1607
- * before the gate ran is committed along with this run's entry. That is
1608
- * deliberate — leaving the earlier append uncommitted would reproduce the dirty
1609
- * tree this exists to prevent — but it does mean the commit is not always
1610
- * exactly one entry.
1611
- *
1612
- * Never throws: git failures are reported to the caller, which degrades to a
1613
- * diagnostic rather than altering the gate's exit status. On failure the index
1614
- * is restored so a partially staged log is not left behind.
1687
+ * Never throws and never removes an index lock: git failures are reported to
1688
+ * the caller, which degrades to a diagnostic rather than altering the gate's
1689
+ * exit status.
1615
1690
  */
1616
- function commitReviewGateProjectLog(repoRoot, logPath) {
1617
- const run = (args) => execFileSync('git', args, {
1618
- cwd: repoRoot,
1619
- encoding: 'utf8',
1620
- // Capture stderr rather than inheriting it so skip/failure probes do not
1621
- // leak raw `git fatal:` lines into gate output.
1622
- stdio: ['ignore', 'pipe', 'pipe'],
1623
- }).trim();
1691
+ async function commitReviewGateProjectLog(repoRoot, logPath, identity, sleep) {
1692
+ return commitProjectLog({
1693
+ repoRoot,
1694
+ logPath,
1695
+ message: GATE_PROJECT_LOG_COMMIT_MESSAGE,
1696
+ identity,
1697
+ }, { sleep });
1698
+ }
1699
+ function resolveGateProjectPath(repoRoot, project) {
1700
+ return isAbsolute(project) ? project : join(repoRoot, project);
1701
+ }
1702
+ function resolveWorktreeRoot(repoRoot) {
1624
1703
  try {
1625
- run(['rev-parse', '--is-inside-work-tree']);
1704
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
1705
+ cwd: repoRoot,
1706
+ encoding: 'utf8',
1707
+ stdio: ['ignore', 'pipe', 'pipe'],
1708
+ }).trim();
1626
1709
  }
1627
1710
  catch {
1628
- return { committed: false };
1711
+ return repoRoot;
1629
1712
  }
1630
- let staged = false;
1713
+ }
1714
+ /**
1715
+ * Quotes a value for the copy-pasteable recovery command. Anything outside the
1716
+ * safe set is single-quoted so a body full of spaces and `=` tokens survives a
1717
+ * shell round trip verbatim.
1718
+ */
1719
+ function shellQuote(value) {
1720
+ return /^[\w@%+=:,./-]+$/.test(value)
1721
+ ? value
1722
+ : `'${value.replaceAll("'", `'\\''`)}'`;
1723
+ }
1724
+ function composeGateProjectLogRecoveryCommand(input) {
1725
+ return [
1726
+ 'oat project log append',
1727
+ `--project ${shellQuote(input.projectPath)}`,
1728
+ '--structural',
1729
+ `--producer ${shellQuote(input.producer)}`,
1730
+ `--ref ${shellQuote(input.ref)}`,
1731
+ `--body ${shellQuote(input.body)}`,
1732
+ `--idempotency-key ${shellQuote(input.runId)}`,
1733
+ '--commit',
1734
+ ].join(' ');
1735
+ }
1736
+ /**
1737
+ * Warns once per pending gate project-log receipt at the start of a run.
1738
+ *
1739
+ * A receipt is `stale` only once the *committed* log carries its run id: the
1740
+ * append landed and was committed, so the recovery command will observe
1741
+ * `already-appended` and clear the receipt. Anything else is `pending`
1742
+ * finalization work.
1743
+ *
1744
+ * Reading the working tree instead is what made this wrong. A run whose commit
1745
+ * retries were exhausted leaves its entry in the working tree and nowhere else
1746
+ * — that is precisely the state the receipt exists to describe — so a
1747
+ * working-tree reader calls every unfinished finalization finished, and the
1748
+ * receipt that would have completed it is dismissed as leftovers.
1749
+ *
1750
+ * The staleness question is answered by the log module, so the gate never reads
1751
+ * `project-log.md` itself.
1752
+ */
1753
+ async function warnPendingGateProjectLogReceipts(options) {
1754
+ const projectDir = resolveGateProjectPath(options.repoRoot, options.project);
1755
+ const receiptsDir = join(projectDir, GATE_RECEIPTS_DIRNAME);
1756
+ // The staleness question is about the log of the project being gated. A
1757
+ // receipt that travelled with a copied project directory records the other
1758
+ // tree's absolute path, and reading that would make the verdict a statement
1759
+ // about a log this run does not own.
1760
+ const projectLogPath = join(projectDir, PROJECT_LOG_FILENAME);
1761
+ let entries;
1631
1762
  try {
1632
- if (run(['status', '--porcelain', '--', logPath]).length === 0) {
1633
- return { committed: false };
1634
- }
1635
- run(['add', '--', logPath]);
1636
- staged = true;
1637
- run([
1638
- 'commit',
1639
- '-m',
1640
- 'chore(oat): record gate review in project log',
1641
- '--',
1642
- logPath,
1643
- ]);
1644
- return { committed: true };
1763
+ entries = await readdir(receiptsDir);
1645
1764
  }
1646
- catch (error) {
1647
- if (staged) {
1648
- try {
1649
- // A failed commit (hook, signing, identity) would otherwise leave the
1650
- // log staged, which is a worse state than the dirty tree we started in.
1651
- run(['reset', '--quiet', '--', logPath]);
1652
- }
1653
- catch {
1654
- // Best effort: the reported commit failure already tells the caller the
1655
- // log needs attention.
1656
- }
1765
+ catch {
1766
+ return;
1767
+ }
1768
+ for (const name of entries
1769
+ .filter((entry) => entry.endsWith('.json'))
1770
+ .sort()) {
1771
+ const receiptPath = join(receiptsDir, name);
1772
+ let receipt;
1773
+ try {
1774
+ receipt = JSON.parse(await readFile(receiptPath, 'utf8'));
1657
1775
  }
1658
- const stderr = error && typeof error === 'object' && 'stderr' in error
1659
- ? error.stderr
1776
+ catch {
1777
+ continue;
1778
+ }
1779
+ const foreignLogPath = typeof receipt.logPath === 'string' &&
1780
+ resolve(receipt.logPath) !== resolve(projectLogPath)
1781
+ ? receipt.logPath
1660
1782
  : undefined;
1661
- const message = (stderr != null ? stderr.toString().trim() : '') ||
1662
- (error instanceof Error ? error.message : String(error));
1663
- return { committed: false, error: message };
1783
+ // A receipt that names another tree's log cannot be shown to be stale
1784
+ // here, so it stays pending and the disagreement is reported.
1785
+ //
1786
+ // `present` is required, not merely "not absent": a log the committed tree
1787
+ // cannot answer for leaves the work pending, because an unfinished
1788
+ // finalization that is reported as stale is one nobody will finish.
1789
+ const stale = foreignLogPath === undefined &&
1790
+ (await committedProjectLogIdentityState(options.repoRoot, projectLogPath, receipt.runId, receipt.body)) === 'present';
1791
+ const state = stale ? 'stale' : 'pending';
1792
+ if (options.context.json) {
1793
+ options.dependencies.writeDiagnostic(`${JSON.stringify({
1794
+ type: 'gate-project-log-receipt-pending',
1795
+ state,
1796
+ project: options.project,
1797
+ receiptPath,
1798
+ logPath: projectLogPath,
1799
+ ...(foreignLogPath !== undefined
1800
+ ? { recordedLogPath: foreignLogPath }
1801
+ : {}),
1802
+ runId: receipt.runId,
1803
+ recovery: receipt.recovery?.command,
1804
+ })}\n`);
1805
+ continue;
1806
+ }
1807
+ options.context.logger.warn(`Warning: a ${state} gate project log receipt exists at ${receiptPath}${foreignLogPath !== undefined
1808
+ ? ` (it records another tree's log at ${foreignLogPath})`
1809
+ : ''}. Complete it with: ${receipt.recovery?.command ?? 'oat project log append --commit'}`);
1664
1810
  }
1665
1811
  }
1812
+ /**
1813
+ * Emits the durable partial-finalization receipt and its diagnostic.
1814
+ *
1815
+ * Reached only when the retry budget is exhausted against an index lock: the
1816
+ * entry is in the log but not in a commit, so a later process must finish the
1817
+ * job. The receipt carries the exact recovery command, and the diagnostic
1818
+ * prints it verbatim so an operator or orchestrator can run it without
1819
+ * reconstructing any of the gate's state.
1820
+ */
1821
+ async function emitGateProjectLogPartialFinalization(options) {
1822
+ const { commit, context, dependencies, finalization } = options;
1823
+ const projectPath = resolveGateProjectPath(finalization.repoRoot, finalization.project);
1824
+ const receiptPath = join(projectPath, GATE_RECEIPTS_DIRNAME, `${finalization.runId}.json`);
1825
+ const command = composeGateProjectLogRecoveryCommand({
1826
+ projectPath,
1827
+ producer: GATE_PROJECT_LOG_PRODUCER,
1828
+ ref: finalization.ref,
1829
+ body: options.body,
1830
+ runId: finalization.runId,
1831
+ });
1832
+ const receipt = {
1833
+ runId: finalization.runId,
1834
+ project: finalization.project,
1835
+ projectPath,
1836
+ worktreeRoot: resolveWorktreeRoot(finalization.repoRoot),
1837
+ logPath: options.logPath,
1838
+ artifactPath: finalization.artifactPath ?? null,
1839
+ artifactSignature: finalization.artifactSignature ?? null,
1840
+ appendStatus: options.appendStatus,
1841
+ commitStatus: commit.outcome,
1842
+ lockClass: commit.lockClass ?? null,
1843
+ attempts: commit.attempts,
1844
+ producer: GATE_PROJECT_LOG_PRODUCER,
1845
+ ref: finalization.ref,
1846
+ body: options.body,
1847
+ recovery: { command },
1848
+ };
1849
+ const written = await dependencies.writeGateProjectLogReceipt(receiptPath, receipt,
1850
+ // `logger.warn` is suppressed in JSON mode, and a receipt warning is
1851
+ // exactly what automation needs to see, so route it the same way the
1852
+ // finalization diagnostics go.
1853
+ (message) => {
1854
+ if (context.json) {
1855
+ dependencies.writeDiagnostic(`${JSON.stringify({
1856
+ type: 'gate-project-log-receipt-warning',
1857
+ project: finalization.project,
1858
+ receiptPath,
1859
+ message,
1860
+ })}\n`);
1861
+ return;
1862
+ }
1863
+ context.logger.warn(message);
1864
+ });
1865
+ if (!written) {
1866
+ return;
1867
+ }
1868
+ if (context.json) {
1869
+ dependencies.writeDiagnostic(`${JSON.stringify({
1870
+ type: 'gate-project-log-partial-finalization',
1871
+ project: finalization.project,
1872
+ receiptPath,
1873
+ logPath: options.logPath,
1874
+ lockClass: receipt.lockClass,
1875
+ attempts: receipt.attempts,
1876
+ recovery: command,
1877
+ })}\n`);
1878
+ return;
1879
+ }
1880
+ context.logger.warn(`Warning: the oat gate review project log entry is appended but not committed (${receipt.commitStatus === 'entry-missing-after-commit'
1881
+ ? 'the committed log does not carry this run'
1882
+ : receipt.commitStatus === 'commit-unverified'
1883
+ ? 'the committed log could not be read back'
1884
+ : (receipt.lockClass ?? 'unknown')} after ${receipt.attempts} attempts). Receipt: ${receiptPath}. Complete it with: ${command}`);
1885
+ }
1666
1886
  async function finalizeReviewGateProjectLog(context, dependencies, finalization) {
1667
1887
  const findings = finalization.counts
1668
1888
  ? ` findings=critical:${finalization.counts.critical},important:${finalization.counts.important},medium:${finalization.counts.medium},minor:${finalization.counts.minor}`
@@ -1670,7 +1890,11 @@ async function finalizeReviewGateProjectLog(context, dependencies, finalization)
1670
1890
  const artifact = finalization.artifactPath
1671
1891
  ? ` artifact=${finalization.artifactPath}`
1672
1892
  : '';
1673
- const body = `target=${finalization.target} threshold=${finalization.threshold}${findings} exit=${finalization.exitCode} status=${finalization.status}${artifact}`;
1893
+ // The run id is the finalization's stable event identity: it makes the entry
1894
+ // recognizable to a retry or to a later recovery, so neither ever appends a
1895
+ // second entry for the same run. It is an additive token on the one-line
1896
+ // structural body, which no consumer parses.
1897
+ const body = `target=${finalization.target} threshold=${finalization.threshold}${findings} exit=${finalization.exitCode} status=${finalization.status}${artifact} run=${finalization.runId}`;
1674
1898
  // Finalization runs after the JSON envelope is emitted, and `logger.warn` is
1675
1899
  // suppressed in JSON mode. Automation would otherwise get no signal that the
1676
1900
  // project log is still dirty, so route these through the diagnostic channel.
@@ -1695,15 +1919,37 @@ async function finalizeReviewGateProjectLog(context, dependencies, finalization)
1695
1919
  home: finalization.home,
1696
1920
  project: finalization.project,
1697
1921
  structural: true,
1698
- producer: 'oat gate review',
1922
+ producer: GATE_PROJECT_LOG_PRODUCER,
1699
1923
  ref: finalization.ref,
1700
1924
  body,
1925
+ idempotencyKey: finalization.runId,
1701
1926
  });
1702
- if (result.status === 'appended') {
1703
- const commit = commitReviewGateProjectLog(finalization.repoRoot, result.logPath);
1704
- if (commit.error != null) {
1705
- report('gate-project-log-commit-failed', commit.error, result.logPath);
1706
- }
1927
+ if (result.status === 'skipped') {
1928
+ return;
1929
+ }
1930
+ // `already-appended` is success: this run's entry is in the log, so the
1931
+ // only work left is the commit.
1932
+ const commit = await commitReviewGateProjectLog(finalization.repoRoot, result.logPath, { key: finalization.runId, body }, dependencies.sleep);
1933
+ if (commit.error != null) {
1934
+ report('gate-project-log-commit-failed', commit.error, result.logPath);
1935
+ }
1936
+ // Both dispositions leave the run's entry uncommitted, and both are
1937
+ // finished by the same idempotent recovery command: the lock case never
1938
+ // reached a commit, and the missing-entry case reached one that did not
1939
+ // carry this run. Either way the durable receipt is what carries the work
1940
+ // into the later process that completes it.
1941
+ if (commit.outcome === 'blocked-by-index-lock' ||
1942
+ commit.outcome === 'entry-missing-after-commit' ||
1943
+ commit.outcome === 'commit-unverified') {
1944
+ await emitGateProjectLogPartialFinalization({
1945
+ context,
1946
+ dependencies,
1947
+ finalization,
1948
+ appendStatus: result.status,
1949
+ logPath: result.logPath,
1950
+ body,
1951
+ commit,
1952
+ });
1707
1953
  }
1708
1954
  }
1709
1955
  catch (error) {
@@ -1958,6 +2204,216 @@ async function runCrossProviderExec(prompt, options, context, dependencies) {
1958
2204
  writeError(context, error);
1959
2205
  }
1960
2206
  }
2207
+ /**
2208
+ * Collects the result envelope before any of it reaches the real logger.
2209
+ *
2210
+ * `writeReviewGateResult` makes several human-mode calls, so a failure partway
2211
+ * through would otherwise leave a half-printed success followed by the
2212
+ * `review_failed` line. Buffering keeps the recovery emission all-or-nothing.
2213
+ */
2214
+ function createBufferedResultContext(context) {
2215
+ const pending = [];
2216
+ return {
2217
+ context: {
2218
+ ...context,
2219
+ logger: {
2220
+ ...context.logger,
2221
+ debug: (message) => {
2222
+ pending.push(() => context.logger.debug(message));
2223
+ },
2224
+ info: (message) => {
2225
+ pending.push(() => context.logger.info(message));
2226
+ },
2227
+ warn: (message) => {
2228
+ pending.push(() => context.logger.warn(message));
2229
+ },
2230
+ error: (message) => {
2231
+ pending.push(() => context.logger.error(message));
2232
+ },
2233
+ success: (message) => {
2234
+ pending.push(() => context.logger.success(message));
2235
+ },
2236
+ json: (payload) => {
2237
+ pending.push(() => context.logger.json(payload));
2238
+ },
2239
+ },
2240
+ },
2241
+ flush: () => {
2242
+ for (const emit of pending) {
2243
+ emit();
2244
+ }
2245
+ pending.length = 0;
2246
+ },
2247
+ };
2248
+ }
2249
+ /**
2250
+ * The single eligibility pipeline for a selected review artifact: project
2251
+ * containment, timestamp validity, verdict parsing from the immutable
2252
+ * snapshot, gate-invocation corroboration, the gate invocation marker, and the
2253
+ * blocking threshold.
2254
+ *
2255
+ * Both the normal path and post-selection recovery call this function so that
2256
+ * a recovered artifact can never skip a check the normal path applies. It
2257
+ * never re-reads `snapshot.path`, never re-dispatches a reviewer, and never
2258
+ * writes an envelope itself: an ineligible artifact returns the exact existing
2259
+ * failure writer for its caller to invoke.
2260
+ */
2261
+ async function disposeValidatedReviewArtifact(snapshot, identity, dependencies, hooks = {}) {
2262
+ hooks.onStep?.('artifact-validation');
2263
+ const outsideReviewProject = snapshot.containingProject !== identity.project;
2264
+ if (outsideReviewProject ||
2265
+ (identity.projectResolutionSource === 'declared' &&
2266
+ identity.targetCorroboration.project !== 'matched')) {
2267
+ const message = outsideReviewProject
2268
+ ? 'Review artifact was written outside the resolved review project.'
2269
+ : identity.targetCorroboration.project === 'missing'
2270
+ ? 'Review artifact is missing oat_project for the explicitly declared project.'
2271
+ : 'Review artifact project identity does not match the explicitly declared project.';
2272
+ return {
2273
+ eligible: false,
2274
+ status: 'targeting_correlation_failed',
2275
+ cause: outsideReviewProject
2276
+ ? 'artifact_outside_review_project'
2277
+ : identity.targetCorroboration.project === 'missing'
2278
+ ? 'declared_project_identity_missing'
2279
+ : 'declared_project_identity_mismatched',
2280
+ writeEnvelope: (context) => {
2281
+ writeReviewGateTargetingFailure(context, {
2282
+ runId: identity.runId,
2283
+ target: identity.target,
2284
+ project: identity.project,
2285
+ projectResolutionSource: identity.projectResolutionSource,
2286
+ artifactPath: snapshot.path,
2287
+ generatedAt: snapshot.generatedAt,
2288
+ message,
2289
+ gateInvocation: identity.gateInvocation,
2290
+ dispatchReport: identity.dispatchReport,
2291
+ corroboration: identity.targetCorroboration,
2292
+ });
2293
+ },
2294
+ };
2295
+ }
2296
+ const generatedAt = snapshot.generatedAt;
2297
+ if (!generatedAt || !Number.isFinite(snapshot.generatedTime)) {
2298
+ return {
2299
+ eligible: false,
2300
+ status: 'artifact_validation_failed',
2301
+ cause: 'artifact_generated_at_invalid',
2302
+ writeEnvelope: (context) => {
2303
+ writeReviewGateArtifactValidationFailure(context, {
2304
+ runId: identity.runId,
2305
+ target: identity.target,
2306
+ project: identity.project,
2307
+ projectResolutionSource: identity.projectResolutionSource,
2308
+ artifactPath: snapshot.path,
2309
+ generatedAt: snapshot.generatedAt,
2310
+ message: 'Review artifact oat_generated_at is missing or is not a valid timestamp.',
2311
+ recovery: `Set oat_generated_at to a valid timestamp in ${snapshot.path}, then rerun the gate. Invoke oat-project-review-receive only after the gate returns a receive-eligible result.`,
2312
+ gateInvocation: identity.gateInvocation,
2313
+ dispatchReport: identity.dispatchReport,
2314
+ corroboration: corroborateGateInvocation(identity.gateInvocation, undefined, identity.targetCorroboration),
2315
+ });
2316
+ },
2317
+ };
2318
+ }
2319
+ hooks.onStep?.('verdict-parse');
2320
+ let verdict;
2321
+ try {
2322
+ verdict = await dependencies.parseReviewGateVerdict(join(identity.repoRoot, snapshot.path), {
2323
+ normalizeMissingEmptySeveritySections: true,
2324
+ artifactSnapshot: {
2325
+ content: snapshot.content,
2326
+ signature: snapshot.signature,
2327
+ },
2328
+ });
2329
+ }
2330
+ catch (error) {
2331
+ const detail = error instanceof Error ? error.message : String(error);
2332
+ return {
2333
+ eligible: false,
2334
+ status: 'artifact_validation_failed',
2335
+ cause: 'artifact_verdict_unparsable',
2336
+ writeEnvelope: (context) => {
2337
+ writeReviewGateArtifactValidationFailure(context, {
2338
+ runId: identity.runId,
2339
+ target: identity.target,
2340
+ project: identity.project,
2341
+ projectResolutionSource: identity.projectResolutionSource,
2342
+ artifactPath: snapshot.path,
2343
+ generatedAt: snapshot.generatedAt,
2344
+ message: detail,
2345
+ recovery: `The review artifact was created at ${snapshot.path} but could not be consumed. Fix the artifact format, then rerun the gate to revalidate it. Invoke oat-project-review-receive only after the gate returns a receive-eligible result; if the only issue is a missing zero-count severity heading, rerun the gate to normalize the same artifact instead of creating a new review version.`,
2346
+ gateInvocation: identity.gateInvocation,
2347
+ dispatchReport: identity.dispatchReport,
2348
+ corroboration: corroborateGateInvocation(identity.gateInvocation, undefined, identity.targetCorroboration),
2349
+ });
2350
+ },
2351
+ };
2352
+ }
2353
+ hooks.onVerdict?.(verdict);
2354
+ hooks.onStep?.('invocation-corroboration');
2355
+ const corroboration = corroborateGateInvocation(identity.gateInvocation, verdict.gateInvocation, identity.targetCorroboration);
2356
+ if (corroboration.run !== 'matched' ||
2357
+ corroboration.invocation !== 'matched') {
2358
+ const missing = corroboration.run === 'missing' || corroboration.invocation === 'missing';
2359
+ return {
2360
+ eligible: false,
2361
+ status: 'artifact_validation_failed',
2362
+ cause: missing
2363
+ ? 'gate_invocation_metadata_missing'
2364
+ : 'gate_invocation_metadata_mismatched',
2365
+ verdict,
2366
+ writeEnvelope: (context) => {
2367
+ writeReviewGateArtifactValidationFailure(context, {
2368
+ runId: identity.runId,
2369
+ target: identity.target,
2370
+ project: identity.project,
2371
+ projectResolutionSource: identity.projectResolutionSource,
2372
+ artifactPath: snapshot.path,
2373
+ generatedAt: snapshot.generatedAt,
2374
+ message: missing
2375
+ ? 'Review artifact invocation metadata is missing required gate-owned values.'
2376
+ : 'Review artifact invocation metadata does not match the gate-owned configured invocation.',
2377
+ recovery: `Copy the exact gate invocation fields from the review prompt into ${snapshot.path}, then run oat-project-review-receive only after the artifact validates.`,
2378
+ gateInvocation: identity.gateInvocation,
2379
+ dispatchReport: identity.dispatchReport,
2380
+ corroboration,
2381
+ });
2382
+ },
2383
+ };
2384
+ }
2385
+ if (verdict.invocation !== 'gate') {
2386
+ return {
2387
+ eligible: false,
2388
+ status: 'artifact_validation_failed',
2389
+ cause: 'gate_invocation_marker_missing',
2390
+ verdict,
2391
+ writeEnvelope: (context) => {
2392
+ writeReviewGateArtifactValidationFailure(context, {
2393
+ runId: identity.runId,
2394
+ target: identity.target,
2395
+ project: identity.project,
2396
+ projectResolutionSource: identity.projectResolutionSource,
2397
+ artifactPath: snapshot.path,
2398
+ generatedAt: snapshot.generatedAt,
2399
+ message: 'Review artifact is missing the required gate invocation marker `oat_review_invocation: gate`.',
2400
+ recovery: `Set oat_review_invocation: gate in ${snapshot.path}, then run oat-project-review-receive only after the artifact validates.`,
2401
+ gateInvocation: identity.gateInvocation,
2402
+ dispatchReport: identity.dispatchReport,
2403
+ corroboration,
2404
+ });
2405
+ },
2406
+ };
2407
+ }
2408
+ hooks.onStep?.('verdict-disposition');
2409
+ return {
2410
+ eligible: true,
2411
+ verdict,
2412
+ blocking: reviewBlocksAtThreshold(verdict, identity.threshold),
2413
+ corroboration,
2414
+ generatedAt,
2415
+ };
2416
+ }
1961
2417
  async function runReviewGate(prompt, options, context, dependencies) {
1962
2418
  const runId = randomUUID();
1963
2419
  let runMarkerPath;
@@ -2009,6 +2465,12 @@ async function runReviewGate(prompt, options, context, dependencies) {
2009
2465
  target: selected.id,
2010
2466
  gateInvocation,
2011
2467
  dispatchReport,
2468
+ step: 'target-dispatch',
2469
+ };
2470
+ const markPostSelectionStep = (step) => {
2471
+ if (postSelectionContext) {
2472
+ postSelectionContext.step = step;
2473
+ }
2012
2474
  };
2013
2475
  const threshold = parseReviewGateThreshold(options.exitNonzeroOn);
2014
2476
  projectLogFinalization = {
@@ -2020,12 +2482,21 @@ async function runReviewGate(prompt, options, context, dependencies) {
2020
2482
  threshold,
2021
2483
  status: 'review_failed',
2022
2484
  exitCode: 1,
2485
+ runId,
2023
2486
  };
2487
+ await warnPendingGateProjectLogReceipts({
2488
+ context,
2489
+ dependencies,
2490
+ repoRoot,
2491
+ project: projectPath,
2492
+ });
2493
+ markPostSelectionStep('artifact-scan');
2024
2494
  const before = await listReviewGateArtifactCandidates({
2025
2495
  repoRoot,
2026
2496
  effective,
2027
2497
  reviewProject,
2028
2498
  });
2499
+ markPostSelectionStep('target-dispatch');
2029
2500
  const reviewPrompt = assembleReviewGatePrompt([
2030
2501
  REVIEW_GATE_CONTEXT_NOTE,
2031
2502
  reviewGateProjectContext(reviewProject),
@@ -2115,20 +2586,22 @@ async function runReviewGate(prompt, options, context, dependencies) {
2115
2586
  process.exitCode = 1;
2116
2587
  return true;
2117
2588
  };
2589
+ markPostSelectionStep('artifact-scan');
2118
2590
  const after = await listReviewGateArtifactCandidates({
2119
2591
  repoRoot,
2120
2592
  effective,
2121
2593
  reviewProject,
2122
2594
  });
2595
+ markPostSelectionStep('artifact-correlation');
2123
2596
  const artifactResolution = resolveRunCorrelatedReviewArtifact({
2124
2597
  runId,
2125
2598
  before,
2126
2599
  after,
2127
2600
  });
2128
2601
  if (projectLogFinalization) {
2129
- projectLogFinalization.artifactPath =
2130
- artifactResolution.artifact?.path ??
2131
- artifactResolution.diagnosticArtifact?.path;
2602
+ const correlated = artifactResolution.artifact ?? artifactResolution.diagnosticArtifact;
2603
+ projectLogFinalization.artifactPath = correlated?.path;
2604
+ projectLogFinalization.artifactSignature = correlated?.signature;
2132
2605
  }
2133
2606
  if (!artifactResolution.artifact && writeRefusalFailure()) {
2134
2607
  return;
@@ -2236,166 +2709,60 @@ async function runReviewGate(prompt, options, context, dependencies) {
2236
2709
  process.exitCode = 1;
2237
2710
  return;
2238
2711
  }
2239
- if (producedArtifact.containingProject !== reviewProject.path ||
2240
- (reviewProject.source === 'declared' &&
2241
- initialTargetCorroboration.project !== 'matched')) {
2242
- if (writeRefusalFailure()) {
2243
- return;
2244
- }
2245
- if (projectLogFinalization) {
2246
- projectLogFinalization.status = 'targeting_correlation_failed';
2247
- projectLogFinalization.exitCode = 1;
2248
- }
2249
- writeReviewGateTargetingFailure(context, {
2250
- runId,
2251
- target: selected.id,
2252
- project: projectPath,
2253
- projectResolutionSource: reviewProject.source,
2254
- artifactPath: producedArtifact.path,
2255
- generatedAt: producedArtifact.generatedAt,
2256
- message: producedArtifact.containingProject !== reviewProject.path
2257
- ? 'Review artifact was written outside the resolved review project.'
2258
- : initialTargetCorroboration.project === 'missing'
2259
- ? 'Review artifact is missing oat_project for the explicitly declared project.'
2260
- : 'Review artifact project identity does not match the explicitly declared project.',
2261
- gateInvocation,
2262
- dispatchReport,
2263
- corroboration: initialTargetCorroboration,
2264
- });
2265
- process.exitCode = 1;
2266
- return;
2267
- }
2268
- if (!producedArtifact.generatedAt ||
2269
- !Number.isFinite(producedArtifact.generatedTime)) {
2270
- if (writeRefusalFailure()) {
2271
- return;
2272
- }
2273
- if (projectLogFinalization) {
2274
- projectLogFinalization.status = 'artifact_validation_failed';
2275
- projectLogFinalization.exitCode = 1;
2276
- }
2277
- writeReviewGateArtifactValidationFailure(context, {
2278
- runId,
2279
- target: selected.id,
2280
- project: projectPath,
2281
- projectResolutionSource: reviewProject.source,
2282
- artifactPath: producedArtifact.path,
2283
- generatedAt: producedArtifact.generatedAt,
2284
- message: 'Review artifact oat_generated_at is missing or is not a valid timestamp.',
2285
- recovery: `Set oat_generated_at to a valid timestamp in ${producedArtifact.path}, then rerun the gate. Invoke oat-project-review-receive only after the gate returns a receive-eligible result.`,
2286
- gateInvocation,
2287
- dispatchReport,
2288
- corroboration: corroborateGateInvocation(gateInvocation, undefined, initialTargetCorroboration),
2289
- });
2290
- process.exitCode = 1;
2291
- return;
2292
- }
2293
- let verdict;
2294
- try {
2295
- verdict = await dependencies.parseReviewGateVerdict(join(repoRoot, producedArtifact.path), {
2296
- normalizeMissingEmptySeveritySections: true,
2297
- artifactSnapshot: {
2298
- content: producedArtifact.content,
2299
- signature: producedArtifact.signature,
2300
- },
2301
- });
2302
- }
2303
- catch (error) {
2304
- if (writeRefusalFailure()) {
2305
- return;
2306
- }
2307
- const detail = error instanceof Error ? error.message : String(error);
2308
- if (projectLogFinalization) {
2309
- projectLogFinalization.status = 'artifact_validation_failed';
2310
- projectLogFinalization.exitCode = 1;
2311
- }
2312
- writeReviewGateArtifactValidationFailure(context, {
2313
- runId,
2314
- target: selected.id,
2315
- project: projectPath,
2316
- projectResolutionSource: reviewProject.source,
2317
- artifactPath: producedArtifact.path,
2318
- generatedAt: producedArtifact.generatedAt,
2319
- message: detail,
2320
- recovery: `The review artifact was created at ${producedArtifact.path} but could not be consumed. Fix the artifact format, then rerun the gate to revalidate it. Invoke oat-project-review-receive only after the gate returns a receive-eligible result; if the only issue is a missing zero-count severity heading, rerun the gate to normalize the same artifact instead of creating a new review version.`,
2321
- gateInvocation,
2322
- dispatchReport,
2323
- corroboration: corroborateGateInvocation(gateInvocation, undefined, initialTargetCorroboration),
2324
- });
2325
- process.exitCode = 1;
2326
- return;
2327
- }
2328
- if (projectLogFinalization) {
2329
- projectLogFinalization.counts = verdict.counts;
2330
- }
2331
- const targetCorroboration = initialTargetCorroboration;
2332
- const corroboration = corroborateGateInvocation(gateInvocation, verdict.gateInvocation, targetCorroboration);
2333
- if (corroboration.run !== 'matched' ||
2334
- corroboration.invocation !== 'matched') {
2335
- if (writeRefusalFailure()) {
2336
- return;
2337
- }
2338
- const missing = corroboration.run === 'missing' ||
2339
- corroboration.invocation === 'missing';
2340
- if (projectLogFinalization) {
2341
- projectLogFinalization.status = 'artifact_validation_failed';
2342
- projectLogFinalization.exitCode = 1;
2712
+ const artifactSnapshot = {
2713
+ path: producedArtifact.path,
2714
+ generatedAt: producedArtifact.generatedAt,
2715
+ generatedTime: producedArtifact.generatedTime,
2716
+ containingProject: producedArtifact.containingProject,
2717
+ content: producedArtifact.content,
2718
+ signature: producedArtifact.signature,
2719
+ };
2720
+ const dispositionIdentity = {
2721
+ runId,
2722
+ repoRoot,
2723
+ target: selected.id,
2724
+ project: projectPath,
2725
+ projectResolutionSource: reviewProject.source,
2726
+ gateInvocation,
2727
+ dispatchReport,
2728
+ targetCorroboration: initialTargetCorroboration,
2729
+ threshold,
2730
+ };
2731
+ if (postSelectionContext) {
2732
+ postSelectionContext.artifact = artifactSnapshot;
2733
+ postSelectionContext.dispositionIdentity = dispositionIdentity;
2734
+ postSelectionContext.diversity = selected.diversity;
2735
+ if (childResult.timedOut) {
2736
+ postSelectionContext.lateCompletion = true;
2343
2737
  }
2344
- writeReviewGateArtifactValidationFailure(context, {
2345
- runId,
2346
- target: selected.id,
2347
- project: projectPath,
2348
- projectResolutionSource: reviewProject.source,
2349
- artifactPath: producedArtifact.path,
2350
- generatedAt: producedArtifact.generatedAt,
2351
- message: missing
2352
- ? 'Review artifact invocation metadata is missing required gate-owned values.'
2353
- : 'Review artifact invocation metadata does not match the gate-owned configured invocation.',
2354
- recovery: `Copy the exact gate invocation fields from the review prompt into ${producedArtifact.path}, then run oat-project-review-receive only after the artifact validates.`,
2355
- gateInvocation,
2356
- dispatchReport,
2357
- corroboration,
2358
- });
2359
- process.exitCode = 1;
2360
- return;
2361
2738
  }
2362
- if (verdict.invocation !== 'gate') {
2739
+ const disposition = await disposeValidatedReviewArtifact(artifactSnapshot, dispositionIdentity, dependencies, {
2740
+ onStep: markPostSelectionStep,
2741
+ onVerdict: (parsed) => {
2742
+ if (projectLogFinalization) {
2743
+ projectLogFinalization.counts = parsed.counts;
2744
+ }
2745
+ },
2746
+ });
2747
+ if (!disposition.eligible) {
2363
2748
  if (writeRefusalFailure()) {
2364
2749
  return;
2365
2750
  }
2366
2751
  if (projectLogFinalization) {
2367
- projectLogFinalization.status = 'artifact_validation_failed';
2752
+ projectLogFinalization.status = disposition.status;
2368
2753
  projectLogFinalization.exitCode = 1;
2369
2754
  }
2370
- writeReviewGateArtifactValidationFailure(context, {
2371
- runId,
2372
- target: selected.id,
2373
- project: projectPath,
2374
- projectResolutionSource: reviewProject.source,
2375
- artifactPath: producedArtifact.path,
2376
- generatedAt: producedArtifact.generatedAt,
2377
- message: 'Review artifact is missing the required gate invocation marker `oat_review_invocation: gate`.',
2378
- recovery: `Set oat_review_invocation: gate in ${producedArtifact.path}, then run oat-project-review-receive only after the artifact validates.`,
2379
- gateInvocation,
2380
- dispatchReport,
2381
- corroboration,
2382
- });
2755
+ disposition.writeEnvelope(context);
2383
2756
  process.exitCode = 1;
2384
2757
  return;
2385
2758
  }
2386
- const blocking = reviewBlocksAtThreshold(verdict, threshold);
2759
+ const { verdict, blocking, corroboration, generatedAt } = disposition;
2387
2760
  const handoff = buildReviewGateHandoff({
2388
2761
  artifactPath: producedArtifact.path,
2389
2762
  verdict,
2390
2763
  threshold,
2391
2764
  blocking,
2392
2765
  });
2393
- if (projectLogFinalization) {
2394
- projectLogFinalization.status = blocking ? 'blocked' : 'ok';
2395
- projectLogFinalization.exitCode = blocking ? 1 : 0;
2396
- projectLogFinalization.counts = verdict.counts;
2397
- projectLogFinalization.artifactPath = producedArtifact.path;
2398
- }
2399
2766
  writeReviewGateResult(context, {
2400
2767
  status: blocking ? 'blocked' : 'ok',
2401
2768
  runId,
@@ -2403,7 +2770,7 @@ async function runReviewGate(prompt, options, context, dependencies) {
2403
2770
  project: projectPath,
2404
2771
  projectResolutionSource: reviewProject.source,
2405
2772
  artifactPath: producedArtifact.path,
2406
- generatedAt: producedArtifact.generatedAt,
2773
+ generatedAt,
2407
2774
  threshold,
2408
2775
  blocking,
2409
2776
  counts: verdict.counts,
@@ -2418,12 +2785,133 @@ async function runReviewGate(prompt, options, context, dependencies) {
2418
2785
  corroboration,
2419
2786
  ...(childResult.timedOut ? { lateCompletion: true } : {}),
2420
2787
  });
2788
+ // Record the disposition only once the envelope is written, so a failed
2789
+ // emission cannot leave the log claiming a result no caller received.
2790
+ if (projectLogFinalization) {
2791
+ projectLogFinalization.status = blocking ? 'blocked' : 'ok';
2792
+ projectLogFinalization.exitCode = blocking ? 1 : 0;
2793
+ projectLogFinalization.counts = verdict.counts;
2794
+ projectLogFinalization.artifactPath = producedArtifact.path;
2795
+ projectLogFinalization.artifactSignature = producedArtifact.signature;
2796
+ }
2421
2797
  process.exitCode = blocking ? 1 : 0;
2422
2798
  }
2423
2799
  catch (error) {
2424
2800
  if (postSelectionContext) {
2801
+ const snapshot = postSelectionContext.artifact;
2802
+ const identity = postSelectionContext.dispositionIdentity;
2803
+ let ineligibility;
2804
+ if (snapshot && identity) {
2805
+ // Recovery re-validates the already-committed snapshot through the same
2806
+ // eligibility function the normal path uses, rather than re-parsing
2807
+ // whatever the artifact path now holds, and never re-dispatches the
2808
+ // reviewer. The path is still read to assert the artifact is present
2809
+ // and unchanged, so a deleted or rewritten artifact fails closed.
2810
+ let disposition;
2811
+ try {
2812
+ disposition = await disposeValidatedReviewArtifact(snapshot, identity, dependencies);
2813
+ }
2814
+ catch (revalidationError) {
2815
+ // The recovery attempt itself failed. Report that distinctly instead
2816
+ // of letting the envelope blame the transient error that triggered
2817
+ // recovery, and leave a diagnostic behind for the same reason.
2818
+ disposition = undefined;
2819
+ ineligibility = 'recovery_revalidation_failed';
2820
+ try {
2821
+ dependencies.writeDiagnostic(`${JSON.stringify({
2822
+ type: 'gate-recovery-failed',
2823
+ runId: identity.runId,
2824
+ target: identity.target,
2825
+ project: identity.project,
2826
+ step: postSelectionContext.step,
2827
+ message: revalidationError instanceof Error
2828
+ ? revalidationError.message
2829
+ : String(revalidationError),
2830
+ })}\n`);
2831
+ }
2832
+ catch {
2833
+ // Diagnostics are best effort and must never mask the terminal
2834
+ // review_failed envelope written below.
2835
+ }
2836
+ }
2837
+ if (disposition?.eligible) {
2838
+ // Emission is the last thing that can fail. Build the human-mode
2839
+ // lines into a buffer first so a mid-construction failure cannot
2840
+ // half-print a recovered result and then print `review_failed` after
2841
+ // it, and keep the project-log status at its pre-recovery
2842
+ // `review_failed` value until the envelope is actually written.
2843
+ let recoveryEmitted = false;
2844
+ try {
2845
+ const recoveredHandoff = buildReviewGateHandoff({
2846
+ artifactPath: snapshot.path,
2847
+ verdict: disposition.verdict,
2848
+ threshold: identity.threshold,
2849
+ blocking: disposition.blocking,
2850
+ });
2851
+ const buffered = createBufferedResultContext(context);
2852
+ writeReviewGateResult(buffered.context, {
2853
+ status: disposition.blocking ? 'blocked' : 'ok',
2854
+ runId: identity.runId,
2855
+ target: identity.target,
2856
+ project: identity.project,
2857
+ projectResolutionSource: identity.projectResolutionSource,
2858
+ artifactPath: snapshot.path,
2859
+ generatedAt: disposition.generatedAt,
2860
+ threshold: identity.threshold,
2861
+ blocking: disposition.blocking,
2862
+ counts: disposition.verdict.counts,
2863
+ reviewType: disposition.verdict.reviewType,
2864
+ scope: disposition.verdict.scope,
2865
+ invocation: disposition.verdict.invocation,
2866
+ normalization: disposition.verdict.normalization,
2867
+ handoff: recoveredHandoff,
2868
+ diversity: postSelectionContext.diversity,
2869
+ gateInvocation: identity.gateInvocation,
2870
+ dispatchReport: identity.dispatchReport,
2871
+ corroboration: disposition.corroboration,
2872
+ ...(postSelectionContext.lateCompletion
2873
+ ? { lateCompletion: true }
2874
+ : {}),
2875
+ postSelectionRecovery: true,
2876
+ });
2877
+ buffered.flush();
2878
+ recoveryEmitted = true;
2879
+ }
2880
+ catch {
2881
+ recoveryEmitted = false;
2882
+ }
2883
+ if (recoveryEmitted) {
2884
+ if (projectLogFinalization) {
2885
+ projectLogFinalization.status = disposition.blocking
2886
+ ? 'blocked'
2887
+ : 'ok';
2888
+ projectLogFinalization.exitCode = disposition.blocking ? 1 : 0;
2889
+ projectLogFinalization.counts = disposition.verdict.counts;
2890
+ projectLogFinalization.artifactPath = snapshot.path;
2891
+ projectLogFinalization.artifactSignature = snapshot.signature;
2892
+ }
2893
+ process.exitCode = disposition.blocking ? 1 : 0;
2894
+ return;
2895
+ }
2896
+ }
2897
+ if (disposition && !disposition.eligible) {
2898
+ ineligibility = disposition.cause;
2899
+ }
2900
+ }
2901
+ // The terminal envelope is review_failed, so the log must say so even if
2902
+ // an earlier disposition had already been staged.
2903
+ if (projectLogFinalization) {
2904
+ projectLogFinalization.status = 'review_failed';
2905
+ projectLogFinalization.exitCode = 1;
2906
+ }
2425
2907
  writeReviewGateUnexpectedFailure(context, {
2426
- ...postSelectionContext,
2908
+ project: postSelectionContext.project,
2909
+ projectResolutionSource: postSelectionContext.projectResolutionSource,
2910
+ target: postSelectionContext.target,
2911
+ gateInvocation: postSelectionContext.gateInvocation,
2912
+ dispatchReport: postSelectionContext.dispatchReport,
2913
+ step: postSelectionContext.step,
2914
+ ...(ineligibility ? { ineligibility } : {}),
2427
2915
  error,
2428
2916
  });
2429
2917
  process.exitCode = 1;