@open-agent-toolkit/cli 0.1.46 → 0.1.50

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 (98) hide show
  1. package/assets/agents/oat-phase-implementer.md +202 -191
  2. package/assets/agents/oat-reviewer.md +11 -1
  3. package/assets/config/dispatch-matrix-recommendation.json +120 -13
  4. package/assets/docs/cli-utilities/configuration.md +213 -113
  5. package/assets/docs/cli-utilities/workflow-gates.md +178 -27
  6. package/assets/docs/contributing/skills.md +14 -8
  7. package/assets/docs/provider-sync/providers.md +46 -8
  8. package/assets/docs/provider-sync/scope-and-surface.md +2 -2
  9. package/assets/docs/reference/cli-reference.md +3 -1
  10. package/assets/docs/reference/oat-directory-structure.md +27 -26
  11. package/assets/docs/workflows/projects/artifacts.md +31 -1
  12. package/assets/docs/workflows/projects/dispatch-ceiling.md +297 -197
  13. package/assets/docs/workflows/projects/implementation-execution.md +306 -249
  14. package/assets/docs/workflows/projects/lifecycle.md +26 -5
  15. package/assets/docs/workflows/projects/reviews.md +27 -2
  16. package/assets/migration/pjm-restructure.md +1 -1
  17. package/assets/public-package-versions.json +4 -4
  18. package/assets/skills/oat-project-implement/SKILL.md +290 -149
  19. package/assets/skills/oat-project-import-plan/SKILL.md +173 -16
  20. package/assets/skills/oat-project-next/SKILL.md +2 -2
  21. package/assets/skills/oat-project-plan/SKILL.md +122 -92
  22. package/assets/skills/oat-project-plan-writing/SKILL.md +246 -15
  23. package/assets/skills/oat-project-quick-start/SKILL.md +157 -107
  24. package/assets/skills/oat-project-review-provide/SKILL.md +110 -22
  25. package/assets/skills/oat-project-review-provide-remote/SKILL.md +28 -1
  26. package/dist/commands/config/index.d.ts +3 -0
  27. package/dist/commands/config/index.d.ts.map +1 -1
  28. package/dist/commands/config/index.js +92 -125
  29. package/dist/commands/doctor/index.d.ts +3 -0
  30. package/dist/commands/doctor/index.d.ts.map +1 -1
  31. package/dist/commands/doctor/index.js +57 -95
  32. package/dist/commands/gate/index.d.ts +7 -1
  33. package/dist/commands/gate/index.d.ts.map +1 -1
  34. package/dist/commands/gate/index.js +615 -67
  35. package/dist/commands/gate/review-verdict.d.ts +16 -0
  36. package/dist/commands/gate/review-verdict.d.ts.map +1 -1
  37. package/dist/commands/gate/review-verdict.js +72 -9
  38. package/dist/commands/project/dispatch-ceiling/index.d.ts.map +1 -1
  39. package/dist/commands/project/dispatch-ceiling/index.js +567 -99
  40. package/dist/commands/providers/codex/materialize.d.ts.map +1 -1
  41. package/dist/commands/providers/codex/materialize.js +6 -1
  42. package/dist/commands/shared/frontmatter.d.ts +4 -0
  43. package/dist/commands/shared/frontmatter.d.ts.map +1 -1
  44. package/dist/commands/shared/frontmatter.js +23 -0
  45. package/dist/commands/status/index.d.ts +1 -0
  46. package/dist/commands/status/index.d.ts.map +1 -1
  47. package/dist/commands/status/index.js +10 -4
  48. package/dist/commands/sync/index.d.ts.map +1 -1
  49. package/dist/commands/sync/index.js +10 -3
  50. package/dist/commands/sync/sync.types.d.ts +1 -0
  51. package/dist/commands/sync/sync.types.d.ts.map +1 -1
  52. package/dist/config/dispatch-matrix.d.ts +59 -0
  53. package/dist/config/dispatch-matrix.d.ts.map +1 -0
  54. package/dist/config/dispatch-matrix.js +264 -0
  55. package/dist/config/oat-config.d.ts +9 -18
  56. package/dist/config/oat-config.d.ts.map +1 -1
  57. package/dist/config/oat-config.js +99 -120
  58. package/dist/config/resolve.d.ts +8 -0
  59. package/dist/config/resolve.d.ts.map +1 -1
  60. package/dist/config/resolve.js +66 -1
  61. package/dist/engine/index.d.ts +1 -1
  62. package/dist/engine/index.d.ts.map +1 -1
  63. package/dist/engine/index.js +1 -1
  64. package/dist/engine/scanner.d.ts +1 -0
  65. package/dist/engine/scanner.d.ts.map +1 -1
  66. package/dist/engine/scanner.js +17 -1
  67. package/dist/fs/paths.d.ts +4 -0
  68. package/dist/fs/paths.d.ts.map +1 -1
  69. package/dist/fs/paths.js +18 -1
  70. package/dist/providers/ceiling/registry.d.ts +1 -0
  71. package/dist/providers/ceiling/registry.d.ts.map +1 -1
  72. package/dist/providers/ceiling/registry.js +12 -5
  73. package/dist/providers/codex/codec/catalog.d.ts +14 -0
  74. package/dist/providers/codex/codec/catalog.d.ts.map +1 -0
  75. package/dist/providers/codex/codec/catalog.js +21 -0
  76. package/dist/providers/codex/codec/materialize.d.ts.map +1 -1
  77. package/dist/providers/codex/codec/materialize.js +6 -5
  78. package/dist/providers/codex/codec/shared.d.ts +19 -0
  79. package/dist/providers/codex/codec/shared.d.ts.map +1 -1
  80. package/dist/providers/codex/codec/shared.js +98 -5
  81. package/dist/providers/codex/codec/sync-extension.d.ts.map +1 -1
  82. package/dist/providers/codex/codec/sync-extension.js +142 -32
  83. package/dist/providers/identity/availability.d.ts +12 -0
  84. package/dist/providers/identity/availability.d.ts.map +1 -1
  85. package/dist/providers/identity/availability.js +72 -29
  86. package/dist/providers/identity/dispatch-report.d.ts +124 -0
  87. package/dist/providers/identity/dispatch-report.d.ts.map +1 -0
  88. package/dist/providers/identity/dispatch-report.js +285 -0
  89. package/dist/providers/identity/dispatch-validation.d.ts +28 -0
  90. package/dist/providers/identity/dispatch-validation.d.ts.map +1 -0
  91. package/dist/providers/identity/dispatch-validation.js +149 -0
  92. package/dist/providers/identity/stamp.d.ts +2 -0
  93. package/dist/providers/identity/stamp.d.ts.map +1 -1
  94. package/dist/providers/identity/stamp.js +7 -1
  95. package/dist/shared/types.d.ts +1 -0
  96. package/dist/shared/types.d.ts.map +1 -1
  97. package/dist/shared/types.js +4 -0
  98. package/package.json +2 -2
@@ -3,16 +3,18 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { readdir, readFile } from 'node:fs/promises';
4
4
  import { basename, isAbsolute, join, relative } from 'node:path';
5
5
  import { buildCommandContext, } from '../../app/command-context.js';
6
- import { getFrontmatterBlock, getFrontmatterField, parseGeneratedTime, } from '../shared/frontmatter.js';
6
+ import { getFrontmatterBlock, parseFrontmatterScalarFields, parseGeneratedTime, } from '../shared/frontmatter.js';
7
7
  import { readGlobalOptions } from '../shared/shared.utils.js';
8
8
  import { BUILTIN_EXEC_TARGETS, readOatConfig, readOatLocalConfig, readUserConfig, writeOatConfig, writeOatLocalConfig, writeUserConfig, } from '../../config/oat-config.js';
9
- import { resolveEffectiveConfig, resolveExecTargets, resolveGate, } from '../../config/resolve.js';
9
+ import { resolveEffectiveConfig, resolveExecTargetViews, resolveExecTargets, resolveGate, } from '../../config/resolve.js';
10
10
  import { dirExists, fileExists } from '../../fs/io.js';
11
- import { normalizeToPosixPath, resolveProjectRoot } from '../../fs/paths.js';
11
+ import { normalizeToPosixPath, resolveProjectRoot, validateRealPathWithinScope, } from '../../fs/paths.js';
12
+ import { buildDispatchReport, } from '../../providers/identity/dispatch-report.js';
12
13
  import { classifyModelFamily, } from '../../providers/identity/family.js';
13
14
  import { resolveIdentityConfidence, } from '../../providers/identity/provenance.js';
14
15
  import { parseDispatchStamps } from '../../providers/identity/stamp.js';
15
16
  import { Command } from 'commander';
17
+ import YAML from 'yaml';
16
18
  import { parseReviewGateVerdict, severityDisplayName, } from './review-verdict.js';
17
19
  const DEFAULT_DEPENDENCIES = {
18
20
  buildCommandContext,
@@ -25,6 +27,7 @@ const DEFAULT_DEPENDENCIES = {
25
27
  writeUserConfig,
26
28
  resolveEffectiveConfig,
27
29
  runProcess: runChildProcess,
30
+ parseReviewGateVerdict,
28
31
  processEnv: process.env,
29
32
  };
30
33
  const VALID_ON_FAILURE = ['block', 'prompt', 'warn'];
@@ -53,8 +56,11 @@ const VALID_IDENTITY_PROVENANCES = [
53
56
  const REVIEW_GATE_CONTEXT_NOTE = 'This review is gate-originated. If you run `oat-project-review-provide`, set `oat_review_invocation: gate` in the review artifact. Write a canonical review artifact with `### Critical`, `### Important`, `### Medium`, and `### Minor` headings in that order, using `None` for empty sections.';
54
57
  const GATE_CHECK_TIMEOUT_MS = 5_000;
55
58
  const GATE_EXEC_TIMEOUT_MS = 10 * 60 * 1_000;
56
- function reviewGateProjectContext(projectPath) {
57
- return `Resolved OAT project path: ${projectPath}. Run the review for this project path.`;
59
+ function reviewGateProjectContext(project) {
60
+ return [
61
+ `Resolved OAT project path: ${project.path}. Run the review for this project path.`,
62
+ `Project resolution source: ${project.source}.`,
63
+ ].join('\n');
58
64
  }
59
65
  function assembleReviewGatePrompt(segments) {
60
66
  return segments
@@ -62,6 +68,130 @@ function assembleReviewGatePrompt(segments) {
62
68
  .filter((segment) => segment.length > 0)
63
69
  .join('\n\n');
64
70
  }
71
+ function normalizedTargetInvocation(target) {
72
+ const invocation = target.invocation;
73
+ return {
74
+ model: invocation?.model ?? 'unknown',
75
+ reasoningEffort: invocation?.reasoningEffort ?? 'unknown',
76
+ source: invocation ? 'exec-target-config' : 'unknown',
77
+ };
78
+ }
79
+ function createGateInvocationMetadata(runId, selected) {
80
+ const configuredInvocation = normalizedTargetInvocation(selected.target);
81
+ const selectedModel = selected.model;
82
+ const configuredModel = selected.target.invocation?.model;
83
+ if (selectedModel &&
84
+ configuredModel !== undefined &&
85
+ configuredModel !== selectedModel) {
86
+ throw new Error(`Exec target "${selected.id}" configures invocation model ${configuredModel}, but selected model ${selectedModel} would be executed.`);
87
+ }
88
+ return Object.freeze({
89
+ runId,
90
+ targetId: selected.id,
91
+ runtime: selected.target.runtime,
92
+ ...configuredInvocation,
93
+ ...(selectedModel
94
+ ? { model: selectedModel, source: 'exec-target-config' }
95
+ : {}),
96
+ });
97
+ }
98
+ function buildGateDispatchReport(invocation, scope) {
99
+ const report = buildDispatchReport({
100
+ scope,
101
+ action: 'review',
102
+ role: 'reviewer',
103
+ resolution: {
104
+ status: 'resolved',
105
+ provider: invocation.runtime,
106
+ value: null,
107
+ policyMode: null,
108
+ policy: null,
109
+ source: null,
110
+ providers: {
111
+ [invocation.runtime]: {
112
+ dispatchArgs: null,
113
+ selection: {
114
+ role: 'reviewer',
115
+ requestedCandidate: null,
116
+ candidateTier: null,
117
+ candidateIndex: null,
118
+ ceilingTier: null,
119
+ ceilingTarget: null,
120
+ selectedValue: null,
121
+ selectionMode: 'gate-invocation',
122
+ selectionBranch: 'gate-configured-invocation',
123
+ target: null,
124
+ cellSource: null,
125
+ },
126
+ },
127
+ },
128
+ },
129
+ requestedControls: {
130
+ model: {
131
+ value: null,
132
+ mechanism: 'base-role',
133
+ reason: 'Configured gate invocation is reported separately from runtime identity.',
134
+ },
135
+ effort: {
136
+ value: null,
137
+ mechanism: 'base-role',
138
+ reason: 'Configured gate invocation is reported separately from runtime identity.',
139
+ },
140
+ },
141
+ configuredDefaults: {
142
+ model: null,
143
+ modelSource: null,
144
+ effort: null,
145
+ effortSource: null,
146
+ },
147
+ gateInvocation: invocation,
148
+ });
149
+ return {
150
+ ...report,
151
+ route: { ...report.route, target: invocation.targetId },
152
+ };
153
+ }
154
+ function gateInvocationPromptContext(invocation) {
155
+ const frontmatter = YAML.stringify({
156
+ oat_gate_run_id: invocation.runId,
157
+ oat_gate_target: invocation.targetId,
158
+ oat_gate_runtime: invocation.runtime,
159
+ oat_invocation_model: invocation.model,
160
+ oat_invocation_reasoning_effort: invocation.reasoningEffort,
161
+ oat_invocation_source: invocation.source,
162
+ }).trimEnd();
163
+ return [
164
+ 'Gate invocation metadata (copy these exact values into the gate review artifact frontmatter):',
165
+ frontmatter,
166
+ ].join('\n');
167
+ }
168
+ function corroborateGateInvocation(expected, actual, targetCorroboration) {
169
+ const invocationFields = [
170
+ ['targetId', expected.targetId, actual?.targetId],
171
+ ['runtime', expected.runtime, actual?.runtime],
172
+ ['model', expected.model, actual?.model],
173
+ ['reasoningEffort', expected.reasoningEffort, actual?.reasoningEffort],
174
+ ['source', expected.source, actual?.source],
175
+ ];
176
+ const invocation = invocationFields.some(([, , actualValue]) => !actualValue)
177
+ ? 'missing'
178
+ : invocationFields.every(([, expectedValue, actualValue]) => expectedValue === actualValue)
179
+ ? 'matched'
180
+ : 'mismatched';
181
+ return {
182
+ run: targetCorroboration.run,
183
+ project: targetCorroboration.project,
184
+ invocation,
185
+ expected: {
186
+ project: targetCorroboration.expectedProject,
187
+ invocation: expected,
188
+ },
189
+ actual: {
190
+ ...targetCorroboration.actual,
191
+ invocation: actual ?? null,
192
+ },
193
+ };
194
+ }
65
195
  async function runChildProcess(command, args, options) {
66
196
  return new Promise((resolve, reject) => {
67
197
  let timedOut = false;
@@ -244,7 +374,26 @@ function parseExecTargetConfig(options) {
244
374
  return {
245
375
  runtime: trimRequired(options.runtime ?? '', '--runtime'),
246
376
  baseCommand: parseArgvJson(baseCommandJson, '--base-command-json'),
247
- priority: parseNumericFlag(options.priority, '--priority', 0),
377
+ ...(options.priority !== undefined
378
+ ? { priority: parseNumericFlag(options.priority, '--priority', 0) }
379
+ : {}),
380
+ ...(options.invocationModel !== undefined ||
381
+ options.invocationReasoningEffort !== undefined
382
+ ? {
383
+ invocation: {
384
+ ...(options.invocationModel !== undefined
385
+ ? {
386
+ model: trimRequired(options.invocationModel, '--invocation-model'),
387
+ }
388
+ : {}),
389
+ ...(options.invocationReasoningEffort !== undefined
390
+ ? {
391
+ reasoningEffort: trimRequired(options.invocationReasoningEffort, '--invocation-reasoning-effort'),
392
+ }
393
+ : {}),
394
+ },
395
+ }
396
+ : {}),
248
397
  ...(options.hostDetectionJson !== undefined
249
398
  ? {
250
399
  hostDetectionCommand: parseOptionalArgvJson(options.hostDetectionJson, '--host-detection-json'),
@@ -286,13 +435,32 @@ function unsetSkillGate(config, skillName) {
286
435
  });
287
436
  }
288
437
  function setExecTarget(config, targetId, target) {
289
- return updateWorkflowGates(config, (gates) => ({
290
- ...gates,
291
- execTargets: {
292
- ...gates.execTargets,
293
- [targetId]: target,
294
- },
295
- }));
438
+ return updateWorkflowGates(config, (gates) => {
439
+ const existing = gates.execTargets?.[targetId];
440
+ const value = target === null
441
+ ? target
442
+ : existing === null || existing === undefined
443
+ ? { priority: 0, ...target }
444
+ : {
445
+ ...existing,
446
+ ...target,
447
+ ...(existing.invocation || target.invocation
448
+ ? {
449
+ invocation: {
450
+ ...existing.invocation,
451
+ ...target.invocation,
452
+ },
453
+ }
454
+ : {}),
455
+ };
456
+ return {
457
+ ...gates,
458
+ execTargets: {
459
+ ...gates.execTargets,
460
+ [targetId]: value,
461
+ },
462
+ };
463
+ });
296
464
  }
297
465
  function unsetExecTarget(config, targetId) {
298
466
  return updateWorkflowGates(config, (gates) => {
@@ -322,6 +490,7 @@ function cloneExecTarget(target) {
322
490
  runtime: target.runtime,
323
491
  baseCommand: [...target.baseCommand],
324
492
  priority: target.priority,
493
+ ...(target.invocation ? { invocation: { ...target.invocation } } : {}),
325
494
  ...(target.models ? { models: [...target.models] } : {}),
326
495
  ...(target.hostDetectionCommand
327
496
  ? { hostDetectionCommand: [...target.hostDetectionCommand] }
@@ -363,17 +532,32 @@ function parseProducerIdentityOption(value) {
363
532
  }
364
533
  return identityFromRecords([{ value: producer, provenance: provenance }], 'flag');
365
534
  }
366
- function identityFromStamps(stamps) {
367
- const identities = stamps
368
- .map((stamp) => identityFromRecords([{ value: stamp.producer, provenance: stamp.provenance }], 'stamp'))
369
- .filter((identity) => identity.diversityClaimable && identity.family !== 'unknown');
370
- const latest = identities.at(-1);
371
- if (!latest) {
535
+ function identityFromStamp(stamp) {
536
+ return identityFromRecords([{ value: stamp.producer, provenance: stamp.provenance }], 'stamp');
537
+ }
538
+ function exactIdentityFromStamp(stamp) {
539
+ const identity = identityFromStamp(stamp);
540
+ return identity.diversityClaimable && identity.family !== 'unknown'
541
+ ? identity
542
+ : unknownProducerIdentity();
543
+ }
544
+ function aggregateIdentityFromStamps(stamps) {
545
+ if (stamps.length === 0) {
372
546
  return unknownProducerIdentity();
373
547
  }
548
+ const identities = stamps.map(identityFromStamp);
549
+ const avoidFamilies = [
550
+ ...new Set(identities.flatMap((identity) => identity.diversityClaimable && identity.family !== 'unknown'
551
+ ? [identity.family]
552
+ : [])),
553
+ ];
374
554
  return {
375
- ...latest,
376
- avoidFamilies: [...new Set(identities.map((identity) => identity.family))],
555
+ ...unknownProducerIdentity(),
556
+ avoidFamilies,
557
+ contributingScopes: [...new Set(stamps.map((stamp) => stamp.scope))],
558
+ contributingStampCount: stamps.length,
559
+ diversityClaimable: avoidFamilies.length > 0,
560
+ source: 'aggregated-stamps',
377
561
  };
378
562
  }
379
563
  function phaseNumber(scope) {
@@ -383,7 +567,7 @@ function phaseNumber(scope) {
383
567
  }
384
568
  function reviewScopeRange(scope) {
385
569
  if (scope === 'final') {
386
- return { start: 1, end: Number.MAX_SAFE_INTEGER };
570
+ return { start: 0, end: Number.MAX_SAFE_INTEGER };
387
571
  }
388
572
  const range = scope.match(/^p(\d+)-p(\d+)$/);
389
573
  if (!range) {
@@ -417,13 +601,15 @@ async function readStampedProducerIdentity(options) {
417
601
  return unknownProducerIdentity();
418
602
  }
419
603
  const stamps = parseDispatchStamps(markdown).filter((candidate) => candidate.role === 'implementer' || candidate.role === 'fix');
420
- const exactStamp = [...stamps]
421
- .reverse()
422
- .find((candidate) => candidate.scope === scope);
423
- if (exactStamp) {
424
- return identityFromStamps([exactStamp]);
604
+ if (!reviewScopeRange(scope)) {
605
+ const exactStamp = [...stamps]
606
+ .reverse()
607
+ .find((candidate) => candidate.scope === scope);
608
+ return exactStamp
609
+ ? exactIdentityFromStamp(exactStamp)
610
+ : unknownProducerIdentity();
425
611
  }
426
- return identityFromStamps(stamps.filter((candidate) => stampInReviewScope(candidate.scope, scope)));
612
+ return aggregateIdentityFromStamps(stamps.filter((candidate) => stampInReviewScope(candidate.scope, scope)));
427
613
  }
428
614
  async function resolveReviewProducerIdentity(options) {
429
615
  if (options.explicit?.trim()) {
@@ -480,7 +666,7 @@ function expandExecTargetCandidates(id, target) {
480
666
  }));
481
667
  }
482
668
  function producerHasKnownFamily(identity) {
483
- return identity.diversityClaimable && identity.avoidFamilies.length > 0;
669
+ return identity.avoidFamilies.length > 0;
484
670
  }
485
671
  function shouldAttemptNoDiverseFallback(avoid) {
486
672
  return avoid === 'same-family';
@@ -493,7 +679,9 @@ function achievedDiversity(selected, producerIdentity) {
493
679
  !producerIdentity.avoidFamilies.includes(selected.family)) {
494
680
  return 'different-family';
495
681
  }
496
- if (selected.model && selected.model !== producerIdentity.value) {
682
+ if (producerIdentity.source !== 'aggregated-stamps' &&
683
+ selected.model &&
684
+ selected.model !== producerIdentity.value) {
497
685
  return 'degraded-to-different-slug';
498
686
  }
499
687
  return 'same-family - no diverse target available';
@@ -523,6 +711,14 @@ function attachDiversityMetadata(selected, avoid, producerIdentity) {
523
711
  family: producerIdentity.family,
524
712
  source: producerIdentity.source,
525
713
  avoidFamilies: producerIdentity.avoidFamilies,
714
+ ...(producerIdentity.contributingScopes
715
+ ? { contributingScopes: producerIdentity.contributingScopes }
716
+ : {}),
717
+ ...(producerIdentity.contributingStampCount === undefined
718
+ ? {}
719
+ : {
720
+ contributingStampCount: producerIdentity.contributingStampCount,
721
+ }),
526
722
  },
527
723
  reviewer: {
528
724
  target: selected.id,
@@ -719,11 +915,21 @@ async function listProjectCandidates(repoRoot, projectsRoot) {
719
915
  async function assertProjectPath(repoRoot, projectPath, source) {
720
916
  const normalizedPath = normalizeRepoRelativeProjectPath(repoRoot, projectPath);
721
917
  const absolutePath = join(repoRoot, normalizedPath);
722
- if (!(await dirExists(absolutePath)) ||
723
- !(await fileExists(join(absolutePath, 'state.md')))) {
918
+ let realProjectPath;
919
+ let canonicalPath;
920
+ try {
921
+ const validated = await validateRealPathWithinScope(absolutePath, repoRoot);
922
+ realProjectPath = validated.realPath;
923
+ canonicalPath = normalizeRepoRelativeProjectPath(validated.realScopeRoot, validated.realPath);
924
+ }
925
+ catch {
926
+ throw new Error(`${source} project "${projectPath}" must resolve inside the current repository through a readable real path.`);
927
+ }
928
+ if (!(await dirExists(realProjectPath)) ||
929
+ !(await fileExists(join(realProjectPath, 'state.md')))) {
724
930
  throw new Error(`${source} project "${projectPath}" does not resolve to a project directory containing state.md.`);
725
931
  }
726
- return normalizedPath;
932
+ return canonicalPath;
727
933
  }
728
934
  async function resolveExplicitReviewProject(repoRoot, projectsRoot, projectValue) {
729
935
  const trimmed = projectValue.trim();
@@ -744,16 +950,25 @@ async function resolveExplicitReviewProject(repoRoot, projectsRoot, projectValue
744
950
  }
745
951
  return assertProjectPath(repoRoot, `${projectsRoot}/${trimmed}`, '--project');
746
952
  }
747
- async function resolveReviewProject(options) {
748
- const projectsRoot = String(options.effective.resolved['projects.root']?.value ??
749
- options.effective.shared.projects?.root ??
953
+ function resolvedProjectsRoot(effective) {
954
+ return String(effective.resolved['projects.root']?.value ??
955
+ effective.shared.projects?.root ??
750
956
  '.oat/projects/shared');
957
+ }
958
+ async function resolveReviewProject(options) {
959
+ const projectsRoot = resolvedProjectsRoot(options.effective);
751
960
  if (options.project !== undefined) {
752
- return resolveExplicitReviewProject(options.repoRoot, projectsRoot, options.project);
961
+ return {
962
+ path: await resolveExplicitReviewProject(options.repoRoot, projectsRoot, options.project),
963
+ source: 'declared',
964
+ };
753
965
  }
754
966
  const activeProject = options.effective.local.activeProject?.trim();
755
967
  if (activeProject) {
756
- return assertProjectPath(options.repoRoot, activeProject, 'Active');
968
+ return {
969
+ path: await assertProjectPath(options.repoRoot, activeProject, 'Active'),
970
+ source: 'active-project',
971
+ };
757
972
  }
758
973
  const candidates = await listProjectCandidates(options.repoRoot, projectsRoot);
759
974
  if (candidates.length === 0) {
@@ -762,7 +977,10 @@ async function resolveReviewProject(options) {
762
977
  if (candidates.length > 1) {
763
978
  throw new Error(`Multiple OAT projects could be resolved for gate review: ${candidates.join(', ')}. Pass --project <path-or-name>.`);
764
979
  }
765
- return candidates[0];
980
+ return {
981
+ path: candidates[0],
982
+ source: 'single-candidate',
983
+ };
766
984
  }
767
985
  function reviewGateLifecycleRank(scope) {
768
986
  const normalizedScope = scope.trim().toLowerCase();
@@ -778,21 +996,27 @@ function reviewGateLifecycleRank(scope) {
778
996
  const latestTask = tasks.length > 0 ? Math.max(...tasks) : 9999;
779
997
  return latestPhase * 10_000 + latestTask;
780
998
  }
781
- async function readReviewGateArtifactCandidate(repoRoot, relativePath) {
999
+ async function readReviewGateArtifactCandidate(repoRoot, containingProject, relativePath) {
782
1000
  const content = await readFile(join(repoRoot, relativePath), 'utf8');
783
1001
  const frontmatter = getFrontmatterBlock(content);
784
1002
  if (!frontmatter) {
785
1003
  return null;
786
1004
  }
787
- const generatedAt = getFrontmatterField(frontmatter, 'oat_generated_at');
788
- if (!generatedAt) {
789
- return null;
790
- }
791
- const generatedTime = parseGeneratedTime(generatedAt);
792
- if (Number.isNaN(generatedTime)) {
793
- return null;
794
- }
795
- const scope = getFrontmatterField(frontmatter, 'oat_review_scope') ?? '';
1005
+ const scalarFields = parseFrontmatterScalarFields(frontmatter, [
1006
+ 'oat_generated_at',
1007
+ 'oat_review_scope',
1008
+ 'oat_gate_run_id',
1009
+ 'oat_project',
1010
+ ]);
1011
+ const frontmatterString = (key) => scalarFields.values[key] ?? null;
1012
+ const generatedAt = frontmatterString('oat_generated_at');
1013
+ const parsedGeneratedTime = generatedAt
1014
+ ? parseGeneratedTime(generatedAt)
1015
+ : Number.NaN;
1016
+ const generatedTime = Number.isNaN(parsedGeneratedTime)
1017
+ ? Number.NEGATIVE_INFINITY
1018
+ : parsedGeneratedTime;
1019
+ const scope = frontmatterString('oat_review_scope') ?? '';
796
1020
  return {
797
1021
  path: relativePath,
798
1022
  scope,
@@ -800,9 +1024,13 @@ async function readReviewGateArtifactCandidate(repoRoot, relativePath) {
800
1024
  kind: 'project',
801
1025
  archived: false,
802
1026
  actionable: true,
1027
+ containingProject,
1028
+ gateRunId: frontmatterString('oat_gate_run_id'),
1029
+ artifactProject: frontmatterString('oat_project'),
803
1030
  generatedTime,
804
1031
  lifecycleRank: reviewGateLifecycleRank(scope),
805
1032
  signature: createHash('sha256').update(content).digest('hex'),
1033
+ content,
806
1034
  };
807
1035
  }
808
1036
  function sortReviewGateArtifacts(left, right) {
@@ -834,14 +1062,77 @@ async function listActiveProjectReviewCandidates(options) {
834
1062
  }
835
1063
  return (await Promise.all(entries
836
1064
  .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
837
- .map((entry) => readReviewGateArtifactCandidate(options.repoRoot, normalizeToPosixPath(join(reviewsDir, entry.name))))))
1065
+ .map((entry) => readReviewGateArtifactCandidate(options.repoRoot, projectPath, normalizeToPosixPath(join(reviewsDir, entry.name))))))
838
1066
  .filter((candidate) => candidate !== null)
839
1067
  .sort(sortReviewGateArtifacts);
840
1068
  }
1069
+ async function listReviewGateArtifactCandidates(options) {
1070
+ const configuredProjects = await listProjectCandidates(options.repoRoot, resolvedProjectsRoot(options.effective));
1071
+ const projectPaths = [
1072
+ ...new Set([...configuredProjects, options.reviewProject.path]),
1073
+ ];
1074
+ const candidates = await Promise.all(projectPaths.map((projectPath) => listActiveProjectReviewCandidates({
1075
+ repoRoot: options.repoRoot,
1076
+ projectPath,
1077
+ })));
1078
+ return candidates.flat().sort(sortReviewGateArtifacts);
1079
+ }
841
1080
  function findProducedReviewArtifact(before, after) {
842
1081
  const beforeSignatures = new Map(before.map((candidate) => [candidate.path, candidate.signature]));
843
1082
  return (after.find((candidate) => beforeSignatures.get(candidate.path) !== candidate.signature) ?? null);
844
1083
  }
1084
+ function resolveRunCorrelatedReviewArtifact(options) {
1085
+ const matches = options.after.filter((candidate) => candidate.gateRunId === options.runId);
1086
+ const artifact = matches.length === 1 ? matches[0] : null;
1087
+ return {
1088
+ artifact,
1089
+ diagnosticArtifact: artifact ?? findProducedReviewArtifact(options.before, options.after),
1090
+ matchingArtifactPaths: matches.map((candidate) => candidate.path).sort(),
1091
+ };
1092
+ }
1093
+ function normalizeArtifactProject(repoRoot, artifactProject) {
1094
+ if (!artifactProject) {
1095
+ return null;
1096
+ }
1097
+ try {
1098
+ const normalized = normalizeRepoRelativeProjectPath(repoRoot, artifactProject);
1099
+ return normalized || null;
1100
+ }
1101
+ catch {
1102
+ return null;
1103
+ }
1104
+ }
1105
+ function corroborateReviewTarget(options) {
1106
+ const actualArtifact = options.artifact ?? options.diagnosticArtifact;
1107
+ const normalizedArtifactProject = normalizeArtifactProject(options.repoRoot, actualArtifact?.artifactProject ?? null);
1108
+ const run = options.matchingArtifactPaths.length > 1
1109
+ ? 'mismatched'
1110
+ : actualArtifact?.gateRunId === options.gateInvocation.runId
1111
+ ? 'matched'
1112
+ : actualArtifact?.gateRunId
1113
+ ? 'mismatched'
1114
+ : 'missing';
1115
+ let project = 'ambient';
1116
+ if (options.reviewProject.source === 'declared') {
1117
+ project = !actualArtifact?.artifactProject
1118
+ ? 'missing'
1119
+ : actualArtifact.containingProject === options.reviewProject.path &&
1120
+ normalizedArtifactProject === options.reviewProject.path
1121
+ ? 'matched'
1122
+ : 'mismatched';
1123
+ }
1124
+ return {
1125
+ run,
1126
+ project,
1127
+ expectedProject: options.reviewProject.path,
1128
+ actual: {
1129
+ containingProject: actualArtifact?.containingProject ?? null,
1130
+ artifactProject: actualArtifact?.artifactProject ?? null,
1131
+ normalizedArtifactProject,
1132
+ matchingArtifactPaths: options.matchingArtifactPaths,
1133
+ },
1134
+ };
1135
+ }
845
1136
  function reviewBlocksAtThreshold(verdict, threshold) {
846
1137
  if (verdict.counts.critical > 0) {
847
1138
  return true;
@@ -890,14 +1181,16 @@ function reviewGateOutcome(payload) {
890
1181
  function writeReviewGateResult(context, payload) {
891
1182
  const outcome = reviewGateOutcome(payload);
892
1183
  if (context.json) {
893
- context.logger.json({ outcome, ...payload });
1184
+ context.logger.json({ outcome, ...payload, receiveEligible: true });
894
1185
  return;
895
1186
  }
896
1187
  if (outcome === 'review_completed_blocking_findings') {
897
1188
  context.logger.info('Review completed and found blocking issues.');
898
1189
  }
899
1190
  else if (outcome === 'review_completed_artifact_normalized_gate_passed') {
900
- context.logger.info('Review completed, artifact was normalized, and gate passed.');
1191
+ context.logger.info(payload.normalization?.persisted
1192
+ ? 'Review completed, artifact was normalized, and gate passed.'
1193
+ : 'Review completed, the immutable artifact snapshot was normalized in memory, and gate passed.');
901
1194
  }
902
1195
  else {
903
1196
  context.logger.info('Review completed and gate passed.');
@@ -905,7 +1198,7 @@ function writeReviewGateResult(context, payload) {
905
1198
  context.logger.info(`Run: ${payload.runId} (generated ${payload.generatedAt})`);
906
1199
  context.logger.info(`Review artifact: ${payload.artifactPath}`);
907
1200
  if (payload.normalization) {
908
- context.logger.info(`Artifact normalized: inserted ${payload.normalization.insertedSeverities.map((severity) => severityDisplayName(severity)).join(', ')} empty Findings section(s).`);
1201
+ context.logger.info(`${payload.normalization.persisted ? 'Artifact normalized' : 'Artifact snapshot normalized in memory; source file unchanged'}: inserted ${payload.normalization.insertedSeverities.map((severity) => severityDisplayName(severity)).join(', ')} empty Findings section(s).`);
909
1202
  }
910
1203
  context.logger.info(`Verdict: ${payload.status} (critical=${payload.counts.critical}, important=${payload.counts.important}, medium=${payload.counts.medium}, minor=${payload.counts.minor})`);
911
1204
  if (payload.diversity) {
@@ -927,6 +1220,12 @@ function writeReviewGateExecutionFailure(context, payload) {
927
1220
  runId: payload.runId,
928
1221
  target: payload.target,
929
1222
  project: payload.project,
1223
+ projectResolutionSource: payload.projectResolutionSource,
1224
+ gateInvocation: payload.gateInvocation,
1225
+ dispatchReport: payload.dispatchReport,
1226
+ ...(payload.corroboration
1227
+ ? { corroboration: payload.corroboration }
1228
+ : {}),
930
1229
  exitCode: payload.exitCode,
931
1230
  timedOut: payload.timedOut ?? false,
932
1231
  ...(payload.timeoutMs !== undefined
@@ -938,6 +1237,26 @@ function writeReviewGateExecutionFailure(context, payload) {
938
1237
  }
939
1238
  context.logger.error(message);
940
1239
  }
1240
+ function writeReviewGateUnexpectedFailure(context, payload) {
1241
+ const message = payload.error instanceof Error
1242
+ ? payload.error.message
1243
+ : String(payload.error);
1244
+ if (context.json) {
1245
+ context.logger.json({
1246
+ status: 'review_failed',
1247
+ outcome: 'unexpected_post_selection_failure',
1248
+ runId: payload.gateInvocation.runId,
1249
+ target: payload.target,
1250
+ project: payload.project,
1251
+ projectResolutionSource: payload.projectResolutionSource,
1252
+ gateInvocation: payload.gateInvocation,
1253
+ dispatchReport: payload.dispatchReport,
1254
+ message,
1255
+ });
1256
+ return;
1257
+ }
1258
+ context.logger.error(`Review failed after target selection for ${payload.target}: ${message}`);
1259
+ }
941
1260
  function writeReviewGateArtifactValidationFailure(context, payload) {
942
1261
  if (context.json) {
943
1262
  context.logger.json({
@@ -946,8 +1265,16 @@ function writeReviewGateArtifactValidationFailure(context, payload) {
946
1265
  runId: payload.runId,
947
1266
  target: payload.target,
948
1267
  project: payload.project,
1268
+ projectResolutionSource: payload.projectResolutionSource,
949
1269
  artifactPath: payload.artifactPath,
950
1270
  generatedAt: payload.generatedAt,
1271
+ gateInvocation: payload.gateInvocation,
1272
+ dispatchReport: payload.dispatchReport,
1273
+ ...(payload.corroboration
1274
+ ? { corroboration: payload.corroboration }
1275
+ : {}),
1276
+ receiveEligible: false,
1277
+ handoff: null,
951
1278
  message: payload.message,
952
1279
  recovery: payload.recovery,
953
1280
  });
@@ -956,6 +1283,44 @@ function writeReviewGateArtifactValidationFailure(context, payload) {
956
1283
  context.logger.error(`Review completed but artifact validation failed: ${payload.message}`);
957
1284
  context.logger.error(payload.recovery);
958
1285
  }
1286
+ function writeReviewGateTargetingFailure(context, payload) {
1287
+ const corroboration = {
1288
+ run: payload.corroboration.run,
1289
+ project: payload.corroboration.project,
1290
+ invocation: 'missing',
1291
+ expected: {
1292
+ project: payload.corroboration.expectedProject,
1293
+ invocation: payload.gateInvocation,
1294
+ },
1295
+ actual: {
1296
+ ...payload.corroboration.actual,
1297
+ invocation: null,
1298
+ },
1299
+ };
1300
+ if (context.json) {
1301
+ context.logger.json({
1302
+ status: 'targeting_correlation_failed',
1303
+ outcome: 'review_completed_targeting_correlation_failed',
1304
+ runId: payload.runId,
1305
+ target: payload.target,
1306
+ project: payload.project,
1307
+ projectResolutionSource: payload.projectResolutionSource,
1308
+ artifactPath: payload.artifactPath,
1309
+ generatedAt: payload.generatedAt,
1310
+ gateInvocation: payload.gateInvocation,
1311
+ dispatchReport: payload.dispatchReport,
1312
+ corroboration,
1313
+ receiveEligible: false,
1314
+ remediable: false,
1315
+ handoff: null,
1316
+ message: payload.message,
1317
+ });
1318
+ return;
1319
+ }
1320
+ context.logger.error(`Review completed but target correlation failed: ${payload.message}`);
1321
+ 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'}.`);
1322
+ context.logger.error('This targeting failure is not receive-eligible and must be corrected before severity or invocation remediation.');
1323
+ }
959
1324
  async function updateConfigLayer(context, layer, dependencies, mutate) {
960
1325
  const repoRoot = await dependencies.resolveProjectRoot(context.cwd);
961
1326
  const userConfigDir = join(context.home, '.oat');
@@ -1045,6 +1410,39 @@ async function runTargetUnset(targetId, options, context, dependencies) {
1045
1410
  writeError(context, error);
1046
1411
  }
1047
1412
  }
1413
+ async function runTargetList(context, dependencies) {
1414
+ try {
1415
+ const views = resolveExecTargetViews(await readEffectiveConfig(context, dependencies));
1416
+ const targets = await Promise.all(Object.entries(views)
1417
+ .sort(([leftId, left], [rightId, right]) => {
1418
+ const priority = right.target.priority - left.target.priority;
1419
+ return priority === 0 ? leftId.localeCompare(rightId) : priority;
1420
+ })
1421
+ .map(async ([id, view]) => {
1422
+ const availabilityCommand = view.target.availabilityCommand;
1423
+ const available = view.enabled &&
1424
+ (!availabilityCommand ||
1425
+ (await checkArgv(availabilityCommand, 'availability', context, dependencies)));
1426
+ const invocation = normalizedTargetInvocation(view.target);
1427
+ return {
1428
+ id,
1429
+ runtime: view.target.runtime,
1430
+ origin: view.origin,
1431
+ explicitlyConfigured: view.explicitlyConfigured,
1432
+ enabled: view.enabled,
1433
+ available,
1434
+ invocation: {
1435
+ ...invocation,
1436
+ },
1437
+ };
1438
+ }));
1439
+ writeSuccess(context, { targets });
1440
+ process.exitCode = 0;
1441
+ }
1442
+ catch (error) {
1443
+ writeError(context, error);
1444
+ }
1445
+ }
1048
1446
  async function runCrossProviderExec(prompt, options, context, dependencies) {
1049
1447
  try {
1050
1448
  const effective = await readEffectiveConfig(context, dependencies);
@@ -1061,15 +1459,17 @@ async function runCrossProviderExec(prompt, options, context, dependencies) {
1061
1459
  }
1062
1460
  async function runReviewGate(prompt, options, context, dependencies) {
1063
1461
  const runId = randomUUID();
1462
+ let postSelectionContext;
1064
1463
  try {
1065
1464
  const repoRoot = await dependencies.resolveProjectRoot(context.cwd);
1066
1465
  const userConfigDir = join(context.home, '.oat');
1067
1466
  const effective = await dependencies.resolveEffectiveConfig(repoRoot, userConfigDir, dependencies.processEnv);
1068
- const projectPath = await resolveReviewProject({
1467
+ const reviewProject = await resolveReviewProject({
1069
1468
  repoRoot,
1070
1469
  effective,
1071
1470
  project: options.project,
1072
1471
  });
1472
+ const projectPath = reviewProject.path;
1073
1473
  const targets = resolveExecTargets(effective);
1074
1474
  const producerIdentity = await resolveReviewProducerIdentity({
1075
1475
  explicit: options.producerIdentity,
@@ -1078,14 +1478,25 @@ async function runReviewGate(prompt, options, context, dependencies) {
1078
1478
  reviewScope: options.reviewScope,
1079
1479
  });
1080
1480
  const selected = await resolveSelectedExecTarget(targets, options, producerIdentity, context, dependencies);
1481
+ const gateInvocation = createGateInvocationMetadata(runId, selected);
1482
+ const dispatchReport = buildGateDispatchReport(gateInvocation, options.reviewScope?.trim() || 'gate-review');
1483
+ postSelectionContext = {
1484
+ project: projectPath,
1485
+ projectResolutionSource: reviewProject.source,
1486
+ target: selected.id,
1487
+ gateInvocation,
1488
+ dispatchReport,
1489
+ };
1081
1490
  const threshold = parseReviewGateThreshold(options.exitNonzeroOn);
1082
- const before = await listActiveProjectReviewCandidates({
1491
+ const before = await listReviewGateArtifactCandidates({
1083
1492
  repoRoot,
1084
- projectPath,
1493
+ effective,
1494
+ reviewProject,
1085
1495
  });
1086
1496
  const reviewPrompt = assembleReviewGatePrompt([
1087
1497
  REVIEW_GATE_CONTEXT_NOTE,
1088
- reviewGateProjectContext(projectPath),
1498
+ reviewGateProjectContext(reviewProject),
1499
+ gateInvocationPromptContext(gateInvocation),
1089
1500
  ...(options.reviewType?.trim()
1090
1501
  ? [`Review type: ${options.reviewType.trim()}.`]
1091
1502
  : []),
@@ -1101,35 +1512,105 @@ async function runReviewGate(prompt, options, context, dependencies) {
1101
1512
  runId,
1102
1513
  target: selected.id,
1103
1514
  project: projectPath,
1515
+ projectResolutionSource: reviewProject.source,
1104
1516
  exitCode: childExitCode,
1105
1517
  timedOut: childResult.timedOut ?? false,
1106
1518
  timeoutMs: resolveGateExecTimeoutMs(dependencies.processEnv),
1519
+ gateInvocation,
1520
+ dispatchReport,
1107
1521
  });
1108
1522
  process.exitCode = childExitCode;
1109
1523
  return;
1110
1524
  }
1111
- const after = await listActiveProjectReviewCandidates({
1525
+ const after = await listReviewGateArtifactCandidates({
1112
1526
  repoRoot,
1113
- projectPath,
1527
+ effective,
1528
+ reviewProject,
1529
+ });
1530
+ const artifactResolution = resolveRunCorrelatedReviewArtifact({
1531
+ runId,
1532
+ before,
1533
+ after,
1534
+ });
1535
+ const initialTargetCorroboration = corroborateReviewTarget({
1536
+ repoRoot,
1537
+ reviewProject,
1538
+ gateInvocation,
1539
+ artifact: artifactResolution.artifact,
1540
+ diagnosticArtifact: artifactResolution.diagnosticArtifact,
1541
+ matchingArtifactPaths: artifactResolution.matchingArtifactPaths,
1114
1542
  });
1115
- const producedArtifact = findProducedReviewArtifact(before, after);
1543
+ const producedArtifact = artifactResolution.artifact;
1116
1544
  if (!producedArtifact) {
1545
+ const diagnosticArtifact = artifactResolution.diagnosticArtifact;
1546
+ const message = artifactResolution.matchingArtifactPaths.length > 1
1547
+ ? `Multiple direct review artifacts carried gate run ID ${runId}.`
1548
+ : initialTargetCorroboration.run === 'mismatched'
1549
+ ? `The changed review artifact did not carry the expected gate run ID ${runId}.`
1550
+ : `No direct active project review artifact carried gate run ID ${runId}.`;
1551
+ writeReviewGateTargetingFailure(context, {
1552
+ runId,
1553
+ target: selected.id,
1554
+ project: projectPath,
1555
+ projectResolutionSource: reviewProject.source,
1556
+ artifactPath: diagnosticArtifact?.path ?? null,
1557
+ generatedAt: diagnosticArtifact?.generatedAt ?? null,
1558
+ message,
1559
+ gateInvocation,
1560
+ dispatchReport,
1561
+ corroboration: initialTargetCorroboration,
1562
+ });
1563
+ process.exitCode = 1;
1564
+ return;
1565
+ }
1566
+ if (producedArtifact.containingProject !== reviewProject.path ||
1567
+ (reviewProject.source === 'declared' &&
1568
+ initialTargetCorroboration.project !== 'matched')) {
1569
+ writeReviewGateTargetingFailure(context, {
1570
+ runId,
1571
+ target: selected.id,
1572
+ project: projectPath,
1573
+ projectResolutionSource: reviewProject.source,
1574
+ artifactPath: producedArtifact.path,
1575
+ generatedAt: producedArtifact.generatedAt,
1576
+ message: producedArtifact.containingProject !== reviewProject.path
1577
+ ? 'Review artifact was written outside the resolved review project.'
1578
+ : initialTargetCorroboration.project === 'missing'
1579
+ ? 'Review artifact is missing oat_project for the explicitly declared project.'
1580
+ : 'Review artifact project identity does not match the explicitly declared project.',
1581
+ gateInvocation,
1582
+ dispatchReport,
1583
+ corroboration: initialTargetCorroboration,
1584
+ });
1585
+ process.exitCode = 1;
1586
+ return;
1587
+ }
1588
+ if (!producedArtifact.generatedAt ||
1589
+ !Number.isFinite(producedArtifact.generatedTime)) {
1117
1590
  writeReviewGateArtifactValidationFailure(context, {
1118
1591
  runId,
1119
1592
  target: selected.id,
1120
1593
  project: projectPath,
1121
- artifactPath: null,
1122
- generatedAt: null,
1123
- message: `No new review artifact was detected for project ${projectPath}.`,
1124
- recovery: 'Ensure the review provider wrote a project review artifact. If it did, fix or attach that artifact and run oat-project-review-receive before treating the review as consumed.',
1594
+ projectResolutionSource: reviewProject.source,
1595
+ artifactPath: producedArtifact.path,
1596
+ generatedAt: producedArtifact.generatedAt,
1597
+ message: 'Review artifact oat_generated_at is missing or is not a valid timestamp.',
1598
+ 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.`,
1599
+ gateInvocation,
1600
+ dispatchReport,
1601
+ corroboration: corroborateGateInvocation(gateInvocation, undefined, initialTargetCorroboration),
1125
1602
  });
1126
1603
  process.exitCode = 1;
1127
1604
  return;
1128
1605
  }
1129
1606
  let verdict;
1130
1607
  try {
1131
- verdict = await parseReviewGateVerdict(join(repoRoot, producedArtifact.path), {
1608
+ verdict = await dependencies.parseReviewGateVerdict(join(repoRoot, producedArtifact.path), {
1132
1609
  normalizeMissingEmptySeveritySections: true,
1610
+ artifactSnapshot: {
1611
+ content: producedArtifact.content,
1612
+ signature: producedArtifact.signature,
1613
+ },
1133
1614
  });
1134
1615
  }
1135
1616
  catch (error) {
@@ -1138,10 +1619,55 @@ async function runReviewGate(prompt, options, context, dependencies) {
1138
1619
  runId,
1139
1620
  target: selected.id,
1140
1621
  project: projectPath,
1622
+ projectResolutionSource: reviewProject.source,
1141
1623
  artifactPath: producedArtifact.path,
1142
1624
  generatedAt: producedArtifact.generatedAt,
1143
1625
  message: detail,
1144
- recovery: `The review artifact was created at ${producedArtifact.path} but could not be consumed. Fix the artifact format, then run oat-project-review-receive for ${producedArtifact.path}; 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.`,
1626
+ 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.`,
1627
+ gateInvocation,
1628
+ dispatchReport,
1629
+ corroboration: corroborateGateInvocation(gateInvocation, undefined, initialTargetCorroboration),
1630
+ });
1631
+ process.exitCode = 1;
1632
+ return;
1633
+ }
1634
+ const targetCorroboration = initialTargetCorroboration;
1635
+ const corroboration = corroborateGateInvocation(gateInvocation, verdict.gateInvocation, targetCorroboration);
1636
+ if (corroboration.run !== 'matched' ||
1637
+ corroboration.invocation !== 'matched') {
1638
+ const missing = corroboration.run === 'missing' ||
1639
+ corroboration.invocation === 'missing';
1640
+ writeReviewGateArtifactValidationFailure(context, {
1641
+ runId,
1642
+ target: selected.id,
1643
+ project: projectPath,
1644
+ projectResolutionSource: reviewProject.source,
1645
+ artifactPath: producedArtifact.path,
1646
+ generatedAt: producedArtifact.generatedAt,
1647
+ message: missing
1648
+ ? 'Review artifact invocation metadata is missing required gate-owned values.'
1649
+ : 'Review artifact invocation metadata does not match the gate-owned configured invocation.',
1650
+ 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.`,
1651
+ gateInvocation,
1652
+ dispatchReport,
1653
+ corroboration,
1654
+ });
1655
+ process.exitCode = 1;
1656
+ return;
1657
+ }
1658
+ if (verdict.invocation !== 'gate') {
1659
+ writeReviewGateArtifactValidationFailure(context, {
1660
+ runId,
1661
+ target: selected.id,
1662
+ project: projectPath,
1663
+ projectResolutionSource: reviewProject.source,
1664
+ artifactPath: producedArtifact.path,
1665
+ generatedAt: producedArtifact.generatedAt,
1666
+ message: 'Review artifact is missing the required gate invocation marker `oat_review_invocation: gate`.',
1667
+ recovery: `Set oat_review_invocation: gate in ${producedArtifact.path}, then run oat-project-review-receive only after the artifact validates.`,
1668
+ gateInvocation,
1669
+ dispatchReport,
1670
+ corroboration,
1145
1671
  });
1146
1672
  process.exitCode = 1;
1147
1673
  return;
@@ -1158,6 +1684,7 @@ async function runReviewGate(prompt, options, context, dependencies) {
1158
1684
  runId,
1159
1685
  target: selected.id,
1160
1686
  project: projectPath,
1687
+ projectResolutionSource: reviewProject.source,
1161
1688
  artifactPath: producedArtifact.path,
1162
1689
  generatedAt: producedArtifact.generatedAt,
1163
1690
  threshold,
@@ -1169,11 +1696,23 @@ async function runReviewGate(prompt, options, context, dependencies) {
1169
1696
  normalization: verdict.normalization,
1170
1697
  handoff,
1171
1698
  diversity: selected.diversity,
1699
+ gateInvocation,
1700
+ dispatchReport,
1701
+ corroboration,
1172
1702
  });
1173
1703
  process.exitCode = blocking ? 1 : 0;
1174
1704
  }
1175
1705
  catch (error) {
1176
- writeError(context, error);
1706
+ if (postSelectionContext) {
1707
+ writeReviewGateUnexpectedFailure(context, {
1708
+ ...postSelectionContext,
1709
+ error,
1710
+ });
1711
+ process.exitCode = 1;
1712
+ }
1713
+ else {
1714
+ writeError(context, error);
1715
+ }
1177
1716
  }
1178
1717
  }
1179
1718
  export function createGateCommand(overrides = {}) {
@@ -1250,6 +1789,8 @@ export function createGateCommand(overrides = {}) {
1250
1789
  .option('--base-command-json <json>', 'JSON argv array for the base command')
1251
1790
  .option('--host-detection-json <json>', 'JSON argv array for host detection')
1252
1791
  .option('--availability-json <json>', 'JSON argv array for availability')
1792
+ .option('--invocation-model <model>', 'Configured invocation model or provider-default')
1793
+ .option('--invocation-reasoning-effort <effort>', 'Configured reasoning effort or provider-default')
1253
1794
  .option('--priority <number>', 'Target priority, higher wins')
1254
1795
  .option('--disable', 'Disable this exec target in the selected layer')
1255
1796
  .option('--layer <layer>', 'Config layer to write: shared, local, or user')
@@ -1257,6 +1798,13 @@ export function createGateCommand(overrides = {}) {
1257
1798
  const context = dependencies.buildCommandContext(readGlobalOptions(command));
1258
1799
  await runTargetSet(targetId, options, context, dependencies);
1259
1800
  });
1801
+ target
1802
+ .command('list')
1803
+ .description('List resolved exec targets and configuration provenance')
1804
+ .action(async (_options, command) => {
1805
+ const context = dependencies.buildCommandContext(readGlobalOptions(command));
1806
+ await runTargetList(context, dependencies);
1807
+ });
1260
1808
  target
1261
1809
  .command('unset')
1262
1810
  .description('Remove an exec target entry from a concrete config layer')