@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
@@ -4,14 +4,16 @@ import { buildCommandContext, } from '../../../app/command-context.js';
4
4
  import { getFrontmatterBlock } from '../../shared/frontmatter.js';
5
5
  import { readGlobalOptions } from '../../shared/shared.utils.js';
6
6
  import { compileDispatchPolicyPreset, } from '../../../config/dispatch-ceiling-preset.js';
7
+ import { normalizeDispatchMatrix } from '../../../config/dispatch-matrix.js';
7
8
  import { getDispatchPolicyChoices, managedDispatchPolicyValueList, renderDispatchPolicyChoicesMarkdown, } from '../../../config/dispatch-policy-options.js';
8
- import { resolveActiveProject, VALID_CLAUDE_DISPATCH_CEILINGS, VALID_CODEX_DISPATCH_CEILINGS, VALID_DISPATCH_MATRIX_TIERS, VALID_MANAGED_DISPATCH_POLICIES, validateDispatchRouteTarget, } from '../../../config/oat-config.js';
9
+ import { resolveActiveProject, VALID_CLAUDE_DISPATCH_CEILINGS, VALID_CODEX_DISPATCH_CEILINGS, VALID_DISPATCH_MATRIX_TIERS, VALID_MANAGED_DISPATCH_POLICIES, isWorkflowDispatchCandidateLadder, isWorkflowDispatchFallbackRoute, toWorkflowDispatchCandidateLadder, validateDispatchRouteTarget, } from '../../../config/oat-config.js';
9
10
  import { resolveEffectiveConfig, } from '../../../config/resolve.js';
10
11
  import { resolveProjectRoot } from '../../../fs/paths.js';
11
12
  import TOML from '@iarna/toml';
12
- import { getCeilingAdapter, } from '../../../providers/ceiling/registry.js';
13
+ import { getCeilingAdapter, isDirectDispatchRoleName, CLAUDE_TIER_ORDER, } from '../../../providers/ceiling/registry.js';
14
+ import { buildDispatchReport, formatDispatchReport, } from '../../../providers/identity/dispatch-report.js';
13
15
  import { classifyModelFamily, } from '../../../providers/identity/family.js';
14
- import { Command } from 'commander';
16
+ import { Command, Option } from 'commander';
15
17
  import YAML from 'yaml';
16
18
  const CODEX_VALUES = [
17
19
  ...VALID_CODEX_DISPATCH_CEILINGS,
@@ -85,6 +87,8 @@ function sourceLabel(source) {
85
87
  return 'environment';
86
88
  case 'project-state':
87
89
  return 'project state';
90
+ case 'invocation':
91
+ return 'explicit invocation';
88
92
  default:
89
93
  return 'none';
90
94
  }
@@ -123,107 +127,64 @@ function invalidProjectPolicyMessage(value) {
123
127
  const actual = typeof value === 'string' ? value : String(value);
124
128
  return `Invalid project dispatch policy "${actual}". Valid managed policies: ${validManagedPolicyList()}. Use mode "inherit" for host defaults.`;
125
129
  }
126
- function normalizeProjectMatrixBareValue(provider, value) {
127
- if (typeof value !== 'string') {
128
- return undefined;
129
- }
130
- const trimmed = value.trim();
131
- if (!trimmed) {
132
- return undefined;
133
- }
134
- const order = providerValueOrder(provider);
135
- if (order && !isValidProviderValue(provider, trimmed)) {
136
- return undefined;
137
- }
138
- return trimmed;
139
- }
140
- function normalizeProjectMatrixRouteTarget(value) {
141
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
142
- return undefined;
143
- }
144
- const record = value;
145
- const target = {};
146
- for (const key of ['harness', 'model', 'effort']) {
147
- const rawValue = record[key];
148
- if (typeof rawValue === 'string' && rawValue.trim()) {
149
- target[key] = rawValue.trim();
150
- }
151
- }
152
- return Object.keys(target).length > 0 ? target : undefined;
130
+ function isProjectMatrixRecord(value) {
131
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
153
132
  }
154
- function normalizeProjectMatrixCell(provider, value) {
155
- const bareValue = normalizeProjectMatrixBareValue(provider, value);
156
- if (bareValue !== undefined) {
157
- return bareValue;
158
- }
159
- if (!Array.isArray(value) || value.length === 0) {
160
- return undefined;
161
- }
162
- const route = [];
163
- for (const entry of value) {
164
- const bareEntry = normalizeProjectMatrixBareValue(provider, entry);
165
- if (bareEntry !== undefined) {
166
- route.push(bareEntry);
133
+ function toProjectMatrixCompatibility(rawMatrix, matrix) {
134
+ const compatibility = {};
135
+ for (const [provider, providerValue] of Object.entries(matrix)) {
136
+ if (typeof providerValue === 'string') {
137
+ compatibility[provider] = providerValue;
167
138
  continue;
168
139
  }
169
- const target = normalizeProjectMatrixRouteTarget(entry);
170
- if (target !== undefined) {
171
- route.push(target);
172
- }
173
- }
174
- return route.length > 0 ? route : undefined;
175
- }
176
- function normalizeProjectMatrixProviderValue(provider, value) {
177
- const bareValue = normalizeProjectMatrixBareValue(provider, value);
178
- if (bareValue !== undefined) {
179
- return bareValue;
180
- }
181
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
182
- return undefined;
183
- }
184
- const tierMap = {};
185
- for (const [tier, rawCell] of Object.entries(value)) {
186
- if (!VALID_DISPATCH_MATRIX_TIERS.includes(tier)) {
187
- continue;
188
- }
189
- const normalized = normalizeProjectMatrixCell(provider, rawCell);
190
- if (normalized !== undefined) {
191
- tierMap[tier] = normalized;
140
+ const rawProvider = isProjectMatrixRecord(rawMatrix[provider])
141
+ ? rawMatrix[provider]
142
+ : {};
143
+ const tiers = {};
144
+ for (const [tier, cell] of Object.entries(providerValue)) {
145
+ const matrixTier = tier;
146
+ const rawCell = rawProvider[matrixTier];
147
+ const ladder = toWorkflowDispatchCandidateLadder(cell);
148
+ const firstCandidate = ladder.candidates[0];
149
+ if (typeof rawCell === 'string' && typeof firstCandidate === 'string') {
150
+ tiers[matrixTier] = firstCandidate;
151
+ continue;
152
+ }
153
+ if (Array.isArray(rawCell) &&
154
+ firstCandidate !== undefined &&
155
+ isWorkflowDispatchFallbackRoute(firstCandidate)) {
156
+ tiers[matrixTier] = firstCandidate.route;
157
+ continue;
158
+ }
159
+ tiers[matrixTier] = cell;
192
160
  }
161
+ compatibility[provider] = tiers;
193
162
  }
194
- return Object.keys(tierMap).length > 0 ? tierMap : undefined;
163
+ return compatibility;
195
164
  }
196
165
  function readProjectDispatchMatrix(value) {
197
166
  if (value === undefined || value === null) {
198
- return { matrix: null, warnings: [] };
199
- }
200
- if (typeof value !== 'object' || Array.isArray(value)) {
201
- return {
202
- matrix: null,
203
- warnings: [
204
- 'Ignoring malformed oat_dispatch_policy.matrix in project state.',
205
- ],
206
- };
207
- }
208
- const matrix = {};
209
- for (const [provider, rawProviderValue] of Object.entries(value)) {
210
- if (!provider.trim()) {
211
- continue;
212
- }
213
- const normalized = normalizeProjectMatrixProviderValue(provider, rawProviderValue);
214
- if (normalized !== undefined) {
215
- matrix[provider] = normalized;
216
- }
217
- }
218
- if (Object.keys(matrix).length === 0) {
167
+ return { matrix: null, compatibilityMatrix: null, warnings: [] };
168
+ }
169
+ const normalized = normalizeDispatchMatrix(value, {
170
+ pathPrefix: 'oat_dispatch_policy.matrix',
171
+ compatibilityMode: 'project-state',
172
+ });
173
+ const matrix = Object.fromEntries(Object.entries(normalized.providers).filter(([provider]) => Boolean(provider.trim())));
174
+ if (!isProjectMatrixRecord(value) || Object.keys(matrix).length === 0) {
219
175
  return {
220
176
  matrix: null,
177
+ compatibilityMatrix: null,
221
178
  warnings: [
222
179
  'Ignoring malformed oat_dispatch_policy.matrix in project state.',
223
180
  ],
224
181
  };
225
182
  }
226
- return { matrix, warnings: [] };
183
+ return {
184
+ matrix,
185
+ compatibilityMatrix: toProjectMatrixCompatibility(value, matrix),
186
+ warnings: [],
187
+ };
227
188
  }
228
189
  function policyTier(policy) {
229
190
  return typeof policy === 'string' &&
@@ -343,6 +304,19 @@ function resolveProviderCellFromValue(provider, providerValue, tier, cellSource,
343
304
  warnings: [],
344
305
  };
345
306
  }
307
+ if (isWorkflowDispatchCandidateLadder(cell)) {
308
+ const ceiling = cell.candidates.at(-1);
309
+ if (ceiling === undefined) {
310
+ return null;
311
+ }
312
+ if (typeof ceiling === 'string') {
313
+ return resolveRouteMatrixCell(provider, [ceiling], escalationLevel, cellSource);
314
+ }
315
+ const route = isWorkflowDispatchFallbackRoute(ceiling)
316
+ ? ceiling.route
317
+ : [ceiling];
318
+ return resolveRouteMatrixCell(provider, route, escalationLevel, cellSource);
319
+ }
346
320
  return resolveRouteMatrixCell(provider, cell, escalationLevel, cellSource);
347
321
  }
348
322
  function resolveProviderMatrixCell(provider, tier, resolvedConfig, projectMatrix, escalationLevel) {
@@ -370,6 +344,214 @@ function resolveProviderMatrixCell(provider, tier, resolvedConfig, projectMatrix
370
344
  }
371
345
  return selected;
372
346
  }
347
+ function resolveProviderMatrixCellDefinition(provider, tier, resolvedConfig, projectMatrix) {
348
+ let selected = null;
349
+ const layers = [
350
+ {
351
+ source: 'user-config',
352
+ providers: resolvedConfig.user.workflow?.dispatchCeiling?.providers,
353
+ },
354
+ {
355
+ source: 'repo-config',
356
+ providers: resolvedConfig.shared.workflow?.dispatchCeiling?.providers,
357
+ },
358
+ {
359
+ source: 'local-config',
360
+ providers: resolvedConfig.local.workflow?.dispatchCeiling?.providers,
361
+ },
362
+ { source: 'project-state', providers: projectMatrix },
363
+ ];
364
+ for (const layer of layers) {
365
+ const providerValue = layer.providers?.[provider];
366
+ if (typeof providerValue === 'string') {
367
+ selected = null;
368
+ continue;
369
+ }
370
+ const cell = providerValue?.[tier];
371
+ if (cell !== undefined) {
372
+ selected = { cell, cellSource: layer.source };
373
+ }
374
+ }
375
+ return selected;
376
+ }
377
+ function candidateRoute(candidate) {
378
+ return isWorkflowDispatchFallbackRoute(candidate)
379
+ ? candidate.route
380
+ : [candidate];
381
+ }
382
+ function candidatePrimaryTarget(provider, tier, candidate) {
383
+ const route = candidateRoute(candidate);
384
+ const entry = route[0];
385
+ if (entry === undefined) {
386
+ throw new Error(`Malformed ${provider} candidate ordering in ${tier}: fallback routes cannot be empty.`);
387
+ }
388
+ if (typeof entry !== 'string') {
389
+ const validation = validateDispatchRouteTarget(provider, entry);
390
+ if (!validation.valid) {
391
+ throw new Error(`Malformed ${provider} candidate ordering in ${tier}: ${validation.reason}`);
392
+ }
393
+ }
394
+ const target = typeof entry === 'string'
395
+ ? routeTargetFromBareValue(provider, entry, 0, route.length)
396
+ : routeTargetFromObject(provider, entry, 0, route.length);
397
+ if (target.model && isDirectDispatchRoleName(target.model)) {
398
+ throw new Error(`Malformed ${provider} candidate ordering in ${tier}: direct dispatch role names are not candidate models.`);
399
+ }
400
+ const targetAdapter = getCeilingAdapter(target.harness);
401
+ if (targetAdapter.mechanism === 'pinned-variant' &&
402
+ (!target.model ||
403
+ !target.effort ||
404
+ !CODEX_VALUES.includes(target.effort))) {
405
+ throw new Error(`Malformed ${provider} candidate ordering in ${tier}: Codex candidates require a model and supported effort.`);
406
+ }
407
+ if (targetAdapter.mechanism === 'model-arg' && !target.model) {
408
+ throw new Error(`Malformed ${provider} candidate ordering in ${tier}: model-argument candidates require a model.`);
409
+ }
410
+ return target;
411
+ }
412
+ function candidateTargetKey(target) {
413
+ return JSON.stringify([
414
+ target.harness,
415
+ target.model ?? null,
416
+ target.effort ?? null,
417
+ ]);
418
+ }
419
+ function assertCandidateOrder(provider, tier, ladder) {
420
+ if (ladder.candidates.length === 0) {
421
+ throw new Error(`Malformed ${provider} candidate ordering in ${tier}: candidates cannot be empty.`);
422
+ }
423
+ const targets = [];
424
+ const seen = new Set();
425
+ let previousClaudeRank = null;
426
+ const codexRanksByModel = new Map();
427
+ for (const candidate of ladder.candidates) {
428
+ const target = candidatePrimaryTarget(provider, tier, candidate);
429
+ const key = candidateTargetKey(target);
430
+ if (seen.has(key)) {
431
+ throw new Error(`Malformed ${provider} candidate ordering in ${tier}: duplicate candidate ${key}.`);
432
+ }
433
+ seen.add(key);
434
+ if (target.harness === 'claude' && target.model) {
435
+ const rank = CLAUDE_TIER_ORDER.indexOf(target.model);
436
+ if (rank < 0) {
437
+ throw new Error(`Malformed ${provider} candidate ordering in ${tier}: unsupported Claude model ${JSON.stringify(target.model)}.`);
438
+ }
439
+ if (previousClaudeRank !== null && rank < previousClaudeRank) {
440
+ throw new Error(`Malformed ${provider} candidate ordering in ${tier}: Claude candidates must be nondecreasing.`);
441
+ }
442
+ previousClaudeRank = rank;
443
+ }
444
+ if (target.harness === 'codex' && target.model && target.effort) {
445
+ const rank = CODEX_VALUES.indexOf(target.effort);
446
+ const previousRank = codexRanksByModel.get(target.model);
447
+ if (previousRank !== undefined && rank < previousRank) {
448
+ throw new Error(`Malformed ${provider} candidate ordering in ${tier}: Codex efforts for ${target.model} must be nondecreasing.`);
449
+ }
450
+ codexRanksByModel.set(target.model, rank);
451
+ }
452
+ targets.push(target);
453
+ }
454
+ return targets;
455
+ }
456
+ function assertTierCeilingsNondecreasing(provider, previous, current, tier) {
457
+ if (!previous || previous.harness !== current.harness) {
458
+ return;
459
+ }
460
+ if (current.harness === 'claude' &&
461
+ previous.model &&
462
+ current.model &&
463
+ CLAUDE_TIER_ORDER.indexOf(current.model) <
464
+ CLAUDE_TIER_ORDER.indexOf(previous.model)) {
465
+ throw new Error(`Malformed ${provider} candidate ordering in ${tier}: named tier ceilings must be nondecreasing.`);
466
+ }
467
+ if (current.harness === 'codex' &&
468
+ previous.model === current.model &&
469
+ previous.effort &&
470
+ current.effort &&
471
+ CODEX_VALUES.indexOf(current.effort) <
472
+ CODEX_VALUES.indexOf(previous.effort)) {
473
+ throw new Error(`Malformed ${provider} candidate ordering in ${tier}: named tier ceilings must be nondecreasing.`);
474
+ }
475
+ }
476
+ function requestedCandidateMatches(provider, requested, target) {
477
+ if (target.harness !== provider || target.model !== requested.model) {
478
+ return false;
479
+ }
480
+ return provider !== 'codex' || target.effort === requested.effort;
481
+ }
482
+ function formatRequestedCandidate(provider, requested) {
483
+ return provider === 'codex'
484
+ ? `${requested.model}/${requested.effort}`
485
+ : requested.model;
486
+ }
487
+ function resolveRequestedMatrixCandidate(provider, requested, ceilingTier, resolvedConfig, projectMatrix, escalationLevel) {
488
+ const ceilingIndex = ceilingTier === null
489
+ ? VALID_DISPATCH_MATRIX_TIERS.length - 1
490
+ : VALID_DISPATCH_MATRIX_TIERS.indexOf(ceilingTier);
491
+ let ceilingCellFound = ceilingTier === null;
492
+ let previousCeiling = null;
493
+ let allowedMatch;
494
+ let foundAboveCeiling = false;
495
+ for (const [tierIndex, tier] of VALID_DISPATCH_MATRIX_TIERS.entries()) {
496
+ const definition = resolveProviderMatrixCellDefinition(provider, tier, resolvedConfig, projectMatrix);
497
+ if (!definition) {
498
+ continue;
499
+ }
500
+ if (tier === ceilingTier) {
501
+ ceilingCellFound = true;
502
+ }
503
+ const ladder = toWorkflowDispatchCandidateLadder(definition.cell);
504
+ const targets = assertCandidateOrder(provider, tier, ladder);
505
+ const currentCeiling = targets.at(-1);
506
+ assertTierCeilingsNondecreasing(provider, previousCeiling, currentCeiling, tier);
507
+ previousCeiling = currentCeiling;
508
+ for (const [candidateIndex, target] of targets.entries()) {
509
+ if (!requestedCandidateMatches(provider, requested, target)) {
510
+ continue;
511
+ }
512
+ const candidate = ladder.candidates[candidateIndex];
513
+ const routeSignature = JSON.stringify(candidateRoute(candidate));
514
+ if (tierIndex > ceilingIndex) {
515
+ foundAboveCeiling = true;
516
+ continue;
517
+ }
518
+ if (allowedMatch && allowedMatch.routeSignature !== routeSignature) {
519
+ throw new Error(`Ambiguous ${provider} candidate ${formatRequestedCandidate(provider, requested)} is configured with multiple routes at or below the ${ceilingTier ?? 'uncapped'} ceiling.`);
520
+ }
521
+ allowedMatch ??= {
522
+ tier,
523
+ candidateIndex,
524
+ candidate,
525
+ cellSource: definition.cellSource,
526
+ routeSignature,
527
+ };
528
+ }
529
+ }
530
+ if (!ceilingCellFound) {
531
+ throw new Error(`Exact candidate selection requires a configured ${ceilingTier} candidate ladder for ${provider}.`);
532
+ }
533
+ if (!allowedMatch) {
534
+ const label = formatRequestedCandidate(provider, requested);
535
+ if (foundAboveCeiling) {
536
+ throw new Error(`${provider} candidate ${label} is above the configured ${ceilingTier} ceiling.`);
537
+ }
538
+ throw new Error(`${provider} candidate ${label} is not present in the configured ${provider} candidate ladders.`);
539
+ }
540
+ const resolution = resolveRouteMatrixCell(provider, candidateRoute(allowedMatch.candidate), escalationLevel, allowedMatch.cellSource);
541
+ if (!resolution || resolution.value === null) {
542
+ throw new Error(`Configured ${provider} candidate ${formatRequestedCandidate(provider, requested)} cannot compile to an exact dispatch target.`);
543
+ }
544
+ return {
545
+ resolution: {
546
+ ...resolution,
547
+ selectionBranch: resolution.selectionBranch === 'escalation-target'
548
+ ? 'escalation-target'
549
+ : 'candidate-requested',
550
+ },
551
+ candidateTier: allowedMatch.tier,
552
+ candidateIndex: allowedMatch.candidateIndex,
553
+ };
554
+ }
373
555
  function readProjectDispatchPolicy(provider, content) {
374
556
  const frontmatter = getFrontmatterBlock(content);
375
557
  if (!frontmatter) {
@@ -394,9 +576,11 @@ function readProjectDispatchPolicy(provider, content) {
394
576
  source: 'project-state',
395
577
  preset: null,
396
578
  matrix: parsedMatrix.matrix,
579
+ matrixCompatibility: parsedMatrix.compatibilityMatrix,
397
580
  cellSource: null,
398
581
  target: null,
399
582
  selectionBranch: 'inherit',
583
+ candidateIndex: null,
400
584
  warnings: parsedMatrix.warnings,
401
585
  };
402
586
  }
@@ -417,9 +601,11 @@ function readProjectDispatchPolicy(provider, content) {
417
601
  source: 'project-state',
418
602
  preset: policyValue,
419
603
  matrix: parsedMatrix.matrix,
604
+ matrixCompatibility: parsedMatrix.compatibilityMatrix,
420
605
  cellSource: null,
421
606
  target: null,
422
607
  selectionBranch: 'prompt-persisted',
608
+ candidateIndex: null,
423
609
  warnings: parsedMatrix.warnings,
424
610
  };
425
611
  }
@@ -444,9 +630,11 @@ function readProjectDispatchPolicy(provider, content) {
444
630
  source: 'project-state',
445
631
  preset: policyValue,
446
632
  matrix: parsedMatrix.matrix,
633
+ matrixCompatibility: parsedMatrix.compatibilityMatrix,
447
634
  cellSource: 'project-state',
448
635
  target: null,
449
636
  selectionBranch: 'prompt-persisted',
637
+ candidateIndex: null,
450
638
  warnings: parsedMatrix.warnings,
451
639
  };
452
640
  }
@@ -477,6 +665,7 @@ function readLegacyProjectDispatchCeiling(provider, parsed) {
477
665
  cellSource: 'project-state',
478
666
  target: null,
479
667
  selectionBranch: 'prompt-persisted',
668
+ candidateIndex: null,
480
669
  warnings: [],
481
670
  };
482
671
  }
@@ -545,9 +734,11 @@ function stripConfigCandidateSource(candidate) {
545
734
  source: candidate.source,
546
735
  preset: candidate.preset,
547
736
  matrix: candidate.matrix,
737
+ matrixCompatibility: candidate.matrixCompatibility,
548
738
  cellSource: candidate.cellSource,
549
739
  target: candidate.target,
550
740
  selectionBranch: candidate.selectionBranch,
741
+ candidateIndex: candidate.candidateIndex,
551
742
  warnings: candidate.warnings,
552
743
  };
553
744
  }
@@ -570,6 +761,7 @@ function readResolvedConfigPolicyCandidate(provider, resolvedConfig) {
570
761
  cellSource: null,
571
762
  target: null,
572
763
  selectionBranch: 'inherit',
764
+ candidateIndex: null,
573
765
  warnings: [],
574
766
  configSource: modeEntry.source,
575
767
  };
@@ -595,6 +787,7 @@ function readResolvedConfigPolicyCandidate(provider, resolvedConfig) {
595
787
  cellSource: null,
596
788
  target: null,
597
789
  selectionBranch: 'prompt-persisted',
790
+ candidateIndex: null,
598
791
  warnings: [],
599
792
  configSource,
600
793
  };
@@ -625,6 +818,7 @@ function readResolvedLegacyConfigCeilingCandidate(provider, resolvedConfig) {
625
818
  cellSource: source,
626
819
  target: null,
627
820
  selectionBranch: 'prompt-persisted',
821
+ candidateIndex: null,
628
822
  warnings: [],
629
823
  configSource: entry.source,
630
824
  };
@@ -645,7 +839,13 @@ function readResolvedConfigCeiling(provider, resolvedConfig) {
645
839
  return stripConfigCandidateSource(winner);
646
840
  }
647
841
  function normalizeRole(value) {
648
- return value === 'reviewer' ? 'reviewer' : 'implementer';
842
+ if (value === undefined) {
843
+ return 'implementer';
844
+ }
845
+ if (value === 'implementer' || value === 'reviewer') {
846
+ return value;
847
+ }
848
+ throw new Error(`Invalid dispatch role ${JSON.stringify(value)}. Expected implementer or reviewer.`);
649
849
  }
650
850
  function providerValueOrder(provider) {
651
851
  if (provider === 'codex') {
@@ -674,6 +874,55 @@ function normalizePreferredValue(provider, value) {
674
874
  }
675
875
  return normalized;
676
876
  }
877
+ function normalizeCeilingTier(value, role) {
878
+ if (value === undefined) {
879
+ return null;
880
+ }
881
+ const normalized = value.trim();
882
+ if (!VALID_DISPATCH_MATRIX_TIERS.includes(normalized)) {
883
+ throw new Error(`Invalid invocation ceiling tier "${normalized}". Valid tiers: ${VALID_DISPATCH_MATRIX_TIERS.join(', ')}.`);
884
+ }
885
+ if (role === 'reviewer') {
886
+ throw new Error('Invocation ceiling tiers are only supported for implementer/fix task dispatch; reviewers use the configured review ceiling.');
887
+ }
888
+ return normalized;
889
+ }
890
+ function normalizeRequestedCandidate(provider, role, options) {
891
+ const model = options.candidateModel?.trim() ?? '';
892
+ const effort = options.candidateEffort?.trim() ?? '';
893
+ if (!model && !effort) {
894
+ return null;
895
+ }
896
+ if (options.preferred?.trim()) {
897
+ throw new Error('Exact candidate flags cannot be combined with --preferred; use one selection path.');
898
+ }
899
+ if (role === 'reviewer') {
900
+ throw new Error('Reviewer candidate requests are not supported; reviewers use the configured review ceiling.');
901
+ }
902
+ if (!model) {
903
+ throw new Error('--candidate-model is required for an exact candidate.');
904
+ }
905
+ if (isDirectDispatchRoleName(model)) {
906
+ throw new Error('Direct dispatch role names are not candidate models; request the configured provider model and effort instead.');
907
+ }
908
+ if (provider === 'codex') {
909
+ if (!effort) {
910
+ throw new Error('--candidate-effort is required for an exact Codex candidate.');
911
+ }
912
+ if (!CODEX_VALUES.includes(effort)) {
913
+ throw new Error(`Invalid Codex candidate effort "${effort}". Valid values: ${CODEX_VALUES.join(', ')}.`);
914
+ }
915
+ return { model, effort };
916
+ }
917
+ if (effort) {
918
+ throw new Error(`--candidate-effort is only valid for Codex; ${provider} candidates use --candidate-model only.`);
919
+ }
920
+ if (provider === 'claude' &&
921
+ !CLAUDE_VALUES.includes(model)) {
922
+ throw new Error(`Invalid Claude candidate model "${model}". Valid values: ${CLAUDE_VALUES.join(', ')}.`);
923
+ }
924
+ return { model };
925
+ }
677
926
  function normalizeEscalationLevel(value) {
678
927
  if (value === undefined) {
679
928
  return 0;
@@ -696,6 +945,11 @@ function selectDispatchValue(provider, role, policy, preferredValue) {
696
945
  policy: policy.policy,
697
946
  cellSource: policy.cellSource,
698
947
  target: policy.target,
948
+ requestedCandidate: policy.requestedCandidate ?? null,
949
+ candidateTier: policy.candidateTier ?? null,
950
+ candidateIndex: policy.candidateIndex,
951
+ ceilingTier: policy.ceilingTier ?? policyTier(policy.policy),
952
+ ceilingTarget: policy.ceilingTarget ?? policy.target,
699
953
  };
700
954
  if (policy.mode === 'inherit') {
701
955
  return {
@@ -709,6 +963,21 @@ function selectDispatchValue(provider, role, policy, preferredValue) {
709
963
  target: null,
710
964
  };
711
965
  }
966
+ if (policy.requestedCandidate) {
967
+ const selectedValue = policy.target
968
+ ? dispatchValueFromRouteTarget(policy.target)
969
+ : null;
970
+ return {
971
+ ...baseSelection,
972
+ preferredValue: null,
973
+ selectedValue,
974
+ capped: false,
975
+ selectionMode: selectedValue ? 'candidate' : 'unresolved',
976
+ selectionBranch: policy.selectionBranch,
977
+ family: selectionFamily(provider, selectedValue, policy.target),
978
+ target: policy.target,
979
+ };
980
+ }
712
981
  if (policy.policy === 'uncapped') {
713
982
  const targetValue = policy.target
714
983
  ? dispatchValueFromRouteTarget(policy.target)
@@ -769,6 +1038,10 @@ function selectDispatchValue(provider, role, policy, preferredValue) {
769
1038
  }
770
1039
  const selectedIndex = Math.min(preferredIndex, ceilingIndex);
771
1040
  const selectedValue = order[selectedIndex];
1041
+ const targetValue = policy.target
1042
+ ? dispatchValueFromRouteTarget(policy.target)
1043
+ : null;
1044
+ const selectedTarget = targetValue === selectedValue ? policy.target : null;
772
1045
  return {
773
1046
  ...baseSelection,
774
1047
  preferredValue,
@@ -776,8 +1049,8 @@ function selectDispatchValue(provider, role, policy, preferredValue) {
776
1049
  capped: preferredIndex > ceilingIndex,
777
1050
  selectionMode: 'capped',
778
1051
  selectionBranch: policy.selectionBranch,
779
- family: selectionFamily(provider, selectedValue, selectedValue === policy.value ? policy.target : null),
780
- target: selectedValue === policy.value ? policy.target : null,
1052
+ family: selectionFamily(provider, selectedValue, selectedTarget),
1053
+ target: selectedTarget,
781
1054
  };
782
1055
  }
783
1056
  function hasCodexVariantDispatchArgs(dispatchArgs) {
@@ -835,6 +1108,11 @@ function buildProviderResolution(provider, policy, role, orchestratorTier, prefe
835
1108
  selection: {
836
1109
  role,
837
1110
  preferredValue,
1111
+ requestedCandidate: null,
1112
+ candidateTier: null,
1113
+ candidateIndex: null,
1114
+ ceilingTier: null,
1115
+ ceilingTarget: null,
838
1116
  selectedValue: null,
839
1117
  capped: false,
840
1118
  selectionMode: 'unresolved',
@@ -933,9 +1211,11 @@ async function resolveDispatchCeiling(context, dependencies, options) {
933
1211
  const providerDefaultEffort = provider === 'codex'
934
1212
  ? await resolveCodexProviderDefaultEffort(repoRoot, context, dependencies)
935
1213
  : 'not-applicable';
1214
+ const requestedCandidate = normalizeRequestedCandidate(provider, role, options);
936
1215
  const preferredValue = normalizePreferredValue(provider, options.preferred);
1216
+ const ceilingTier = normalizeCeilingTier(options.ceilingTier, role);
937
1217
  const escalationLevel = normalizeEscalationLevel(options.escalationLevel);
938
- const resolvedValue = await resolveCeilingValue(provider, resolvedConfig, projectPath, dependencies, escalationLevel, role, preferredValue);
1218
+ const resolvedValue = await resolveCeilingValue(provider, resolvedConfig, projectPath, dependencies, escalationLevel, role, preferredValue, requestedCandidate, ceilingTier);
939
1219
  for (const warning of resolvedValue?.warnings ?? []) {
940
1220
  context.logger.warn(warning);
941
1221
  }
@@ -944,6 +1224,32 @@ async function resolveDispatchCeiling(context, dependencies, options) {
944
1224
  [provider]: providerResolution,
945
1225
  };
946
1226
  if (resolvedValue) {
1227
+ const incompleteManagedPreflight = options.preflight === true &&
1228
+ resolvedValue.mode === 'managed' &&
1229
+ providerResolution.mode === 'advisory' &&
1230
+ providerResolution.selection.target?.crossHarness !== true &&
1231
+ providerResolution.selection.selectionMode !== 'no-review-target';
1232
+ if (incompleteManagedPreflight) {
1233
+ const shouldBlock = options.nonInteractive === true ||
1234
+ isNonInteractiveEnv(dependencies.processEnv) ||
1235
+ (!context.interactive && !context.json);
1236
+ const message = shouldBlock ? blockMessage(provider) : undefined;
1237
+ return {
1238
+ status: shouldBlock ? 'blocked' : 'unresolved',
1239
+ provider,
1240
+ value: resolvedValue.value,
1241
+ policyMode: resolvedValue.mode,
1242
+ policy: resolvedValue.policy,
1243
+ source: resolvedValue.source,
1244
+ preset: resolvedValue.preset,
1245
+ unresolved: true,
1246
+ projectPath,
1247
+ providerDefaultEffort,
1248
+ matrix: resolvedValue.matrixCompatibility ?? resolvedValue.matrix,
1249
+ providers,
1250
+ message,
1251
+ };
1252
+ }
947
1253
  return {
948
1254
  status: 'resolved',
949
1255
  provider,
@@ -955,7 +1261,7 @@ async function resolveDispatchCeiling(context, dependencies, options) {
955
1261
  unresolved: false,
956
1262
  projectPath,
957
1263
  providerDefaultEffort,
958
- matrix: resolvedValue.matrix,
1264
+ matrix: resolvedValue.matrixCompatibility ?? resolvedValue.matrix,
959
1265
  providers,
960
1266
  };
961
1267
  }
@@ -985,16 +1291,63 @@ async function resolveDispatchCeiling(context, dependencies, options) {
985
1291
  * Never reads the preset label for dispatch — the preset is surfaced as
986
1292
  * provenance only.
987
1293
  */
988
- async function resolveCeilingValue(provider, resolvedConfig, projectPath, dependencies, escalationLevel, role, preferredValue) {
1294
+ async function resolveCeilingValue(provider, resolvedConfig, projectPath, dependencies, escalationLevel, role, preferredValue, requestedCandidate, invocationCeilingTier) {
989
1295
  const configCeiling = readResolvedConfigCeiling(provider, resolvedConfig);
990
1296
  const projectCeiling = await resolveProjectStateCeiling(provider, projectPath, dependencies);
991
- const baseCeiling = configCeiling ?? projectCeiling;
1297
+ let baseCeiling = configCeiling ?? projectCeiling;
992
1298
  if (!baseCeiling) {
993
1299
  return null;
994
1300
  }
1301
+ if (invocationCeilingTier) {
1302
+ baseCeiling = {
1303
+ ...baseCeiling,
1304
+ mode: 'managed',
1305
+ policy: invocationCeilingTier,
1306
+ value: compiledPolicyValueForProvider(provider, compileDispatchPolicyPreset(invocationCeilingTier)),
1307
+ source: 'invocation',
1308
+ preset: invocationCeilingTier,
1309
+ matrix: projectCeiling?.matrix ?? baseCeiling.matrix,
1310
+ matrixCompatibility: projectCeiling?.matrixCompatibility ?? baseCeiling.matrixCompatibility,
1311
+ cellSource: null,
1312
+ target: null,
1313
+ selectionBranch: 'prompt-persisted',
1314
+ ceilingTier: invocationCeilingTier,
1315
+ ceilingTarget: null,
1316
+ };
1317
+ }
995
1318
  if (baseCeiling.mode === 'inherit') {
1319
+ if (requestedCandidate) {
1320
+ throw new Error('Exact candidate selection requires a managed dispatch policy and configured candidate ladder.');
1321
+ }
996
1322
  return baseCeiling;
997
1323
  }
1324
+ if (requestedCandidate) {
1325
+ if (baseCeiling.policy === 'legacy-ceiling') {
1326
+ throw new Error('Exact candidate selection requires a configured candidate ladder; legacy scalar dispatch ceilings support --preferred only during migration.');
1327
+ }
1328
+ const ceilingTier = policyTier(baseCeiling.policy);
1329
+ const selected = resolveRequestedMatrixCandidate(provider, requestedCandidate, ceilingTier, resolvedConfig, projectCeiling?.matrix ?? null, escalationLevel);
1330
+ const ceilingCell = ceilingTier
1331
+ ? resolveProviderMatrixCell(provider, ceilingTier, resolvedConfig, projectCeiling?.matrix ?? null, escalationLevel)
1332
+ : null;
1333
+ return {
1334
+ ...baseCeiling,
1335
+ value: ceilingCell?.value ?? baseCeiling.value,
1336
+ cellSource: selected.resolution.cellSource,
1337
+ target: selected.resolution.target,
1338
+ selectionBranch: selected.resolution.selectionBranch,
1339
+ warnings: [
1340
+ ...baseCeiling.warnings,
1341
+ ...(ceilingCell?.warnings ?? []),
1342
+ ...selected.resolution.warnings,
1343
+ ],
1344
+ requestedCandidate,
1345
+ candidateTier: selected.candidateTier,
1346
+ candidateIndex: selected.candidateIndex,
1347
+ ceilingTarget: ceilingCell?.target ?? null,
1348
+ ...(ceilingTier ? { ceilingTier } : {}),
1349
+ };
1350
+ }
998
1351
  if (baseCeiling.policy === 'uncapped') {
999
1352
  const preferredTier = uncappedPreferredTier(provider, role, preferredValue);
1000
1353
  if (preferredTier) {
@@ -1014,6 +1367,30 @@ async function resolveCeilingValue(provider, resolvedConfig, projectPath, depend
1014
1367
  const tier = policyTier(baseCeiling.policy);
1015
1368
  const matrixCell = resolveProviderMatrixCell(provider, tier, resolvedConfig, projectCeiling?.matrix ?? null, escalationLevel);
1016
1369
  if (matrixCell) {
1370
+ if (provider === 'codex' &&
1371
+ role === 'implementer' &&
1372
+ preferredValue !== null &&
1373
+ matrixCell.value !== null) {
1374
+ const order = providerValueOrder(provider);
1375
+ const preferredIndex = order?.indexOf(preferredValue) ?? -1;
1376
+ const ceilingIndex = order?.indexOf(matrixCell.value) ?? -1;
1377
+ if (order && preferredIndex >= 0 && ceilingIndex >= 0) {
1378
+ const selectedValue = order[Math.min(preferredIndex, ceilingIndex)];
1379
+ if (selectedValue !== matrixCell.value) {
1380
+ const selectedTarget = matrixCell.target?.harness === 'codex'
1381
+ ? { ...matrixCell.target, effort: selectedValue }
1382
+ : matrixCell.target;
1383
+ return {
1384
+ ...baseCeiling,
1385
+ value: matrixCell.value,
1386
+ cellSource: matrixCell.cellSource,
1387
+ target: selectedTarget,
1388
+ selectionBranch: matrixCell.selectionBranch,
1389
+ warnings: [...baseCeiling.warnings, ...matrixCell.warnings],
1390
+ };
1391
+ }
1392
+ }
1393
+ }
1017
1394
  return {
1018
1395
  ...baseCeiling,
1019
1396
  value: matrixCell.value,
@@ -1049,6 +1426,15 @@ function writeHumanResolution(context, resolution) {
1049
1426
  if (providerResolution) {
1050
1427
  context.logger.info(`Mode: ${providerResolution.mode} (${providerResolution.mechanism})`);
1051
1428
  context.logger.info(`Selection: ${providerResolution.selection.selectionMode}`);
1429
+ if (providerResolution.selection.requestedCandidate) {
1430
+ const requested = providerResolution.selection.requestedCandidate;
1431
+ context.logger.info(`Requested candidate: model=${requested.model}${requested.effort ? ` effort=${requested.effort}` : ''}`);
1432
+ context.logger.info(`Candidate tier: ${providerResolution.selection.candidateTier ?? 'none'}; ceiling tier: ${providerResolution.selection.ceilingTier ?? 'uncapped'}`);
1433
+ const ceilingTarget = providerResolution.selection.ceilingTarget;
1434
+ if (ceilingTarget) {
1435
+ context.logger.info(`Effective ceiling target: model=${ceilingTarget.model ?? 'none'}${ceilingTarget.effort ? ` effort=${ceilingTarget.effort}` : ''}`);
1436
+ }
1437
+ }
1052
1438
  if (providerResolution.target) {
1053
1439
  const details = [
1054
1440
  `harness=${providerResolution.target.harness}`,
@@ -1100,14 +1486,91 @@ function writeHumanResolution(context, resolution) {
1100
1486
  }
1101
1487
  }
1102
1488
  }
1489
+ function reportRole(action) {
1490
+ if (action === 'review') {
1491
+ return 'reviewer';
1492
+ }
1493
+ return action === 'fix' ? 'fix' : 'implementer';
1494
+ }
1495
+ function reportControl(axis, dispatchArgs, reason) {
1496
+ if (axis.startsWith('selected:')) {
1497
+ return {
1498
+ value: axis.slice('selected:'.length),
1499
+ mechanism: dispatchArgs && 'variant' in dispatchArgs
1500
+ ? 'materialized-role'
1501
+ : 'task-model-argument',
1502
+ reason,
1503
+ };
1504
+ }
1505
+ if (axis === 'inherited') {
1506
+ return { value: null, mechanism: 'host-inherited', reason };
1507
+ }
1508
+ if (axis === 'provider-default') {
1509
+ return { value: null, mechanism: 'provider-default', reason };
1510
+ }
1511
+ if (axis === 'not-applicable') {
1512
+ return { value: null, mechanism: 'not-applicable', reason };
1513
+ }
1514
+ return { value: null, mechanism: 'base-role', reason };
1515
+ }
1516
+ function buildResolutionReport(resolution, options) {
1517
+ const hasScope = options.reportScope !== undefined;
1518
+ const hasAction = options.reportAction !== undefined;
1519
+ if (!hasScope && !hasAction) {
1520
+ return null;
1521
+ }
1522
+ if (!hasScope || !hasAction) {
1523
+ throw new Error('--report-scope and --report-action must be provided together.');
1524
+ }
1525
+ const scope = options.reportScope.trim();
1526
+ if (!scope) {
1527
+ throw new Error('--report-scope must be a non-empty value.');
1528
+ }
1529
+ const action = options.reportAction;
1530
+ const role = reportRole(action);
1531
+ const providerResolution = resolution.providers[resolution.provider];
1532
+ if (!providerResolution) {
1533
+ throw new Error(`Dispatch report resolution is missing provider data for "${resolution.provider}".`);
1534
+ }
1535
+ const expectedResolverRole = role === 'reviewer' ? 'reviewer' : 'implementer';
1536
+ if (providerResolution.selection.role !== expectedResolverRole) {
1537
+ throw new Error(`Invalid dispatch report action/role context: ${action}/${role} requires resolver role ${expectedResolverRole}, received ${providerResolution.selection.role}.`);
1538
+ }
1539
+ return buildDispatchReport({
1540
+ scope,
1541
+ action,
1542
+ role,
1543
+ resolution,
1544
+ requestedControls: {
1545
+ model: reportControl(providerResolution.modelAxis, providerResolution.dispatchArgs, 'Derived from the completed resolver model-axis result.'),
1546
+ effort: reportControl(providerResolution.effortAxis, providerResolution.dispatchArgs, 'Derived from the completed resolver effort-axis result.'),
1547
+ },
1548
+ configuredDefaults: {
1549
+ model: null,
1550
+ modelSource: null,
1551
+ effort: resolution.provider === 'codex' &&
1552
+ resolution.providerDefaultEffort !== 'unknown'
1553
+ ? resolution.providerDefaultEffort
1554
+ : null,
1555
+ effortSource: resolution.provider === 'codex' &&
1556
+ resolution.providerDefaultEffort !== 'unknown'
1557
+ ? 'codex-config'
1558
+ : null,
1559
+ },
1560
+ });
1561
+ }
1103
1562
  async function runDispatchCeilingResolve(context, dependencies, options) {
1104
1563
  try {
1105
1564
  const resolution = await resolveDispatchCeiling(context, dependencies, options);
1565
+ const dispatchReport = buildResolutionReport(resolution, options);
1106
1566
  if (context.json) {
1107
- context.logger.json(resolution);
1567
+ context.logger.json(dispatchReport ? { ...resolution, dispatchReport } : resolution);
1108
1568
  }
1109
1569
  else {
1110
1570
  writeHumanResolution(context, resolution);
1571
+ if (dispatchReport) {
1572
+ context.logger.info(formatDispatchReport(dispatchReport));
1573
+ }
1111
1574
  }
1112
1575
  process.exitCode = resolution.status === 'blocked' ? 1 : 0;
1113
1576
  }
@@ -1159,13 +1622,18 @@ export function createProjectDispatchCeilingCommand(overrides = {}) {
1159
1622
  command.addCommand(new Command('resolve')
1160
1623
  .description('Resolve dispatch policy for a provider')
1161
1624
  .requiredOption('--provider <provider>', 'Provider name: codex, claude, or cursor are enforced; unregistered providers resolve as unsupported advisory')
1162
- .option('--role <role>', 'Dispatch role for variant compilation: implementer (default) or reviewer')
1625
+ .addOption(new Option('--role <role>', 'Dispatch role for variant compilation: implementer (default) or reviewer').choices(['implementer', 'reviewer']))
1163
1626
  .option('--orchestrator-tier <tier>', 'Orchestrator tier, used to flag verify-on-upgrade for above-orchestrator requests')
1164
- .option('--preferred <value>', 'Preferred implementer/fix dispatch value before applying the resolved policy')
1627
+ .option('--preferred <value>', 'Legacy preferred implementer/fix value before applying the resolved policy')
1628
+ .addOption(new Option('--ceiling-tier <tier>', 'Invocation-only named maximum tier; never persists configuration or project state').choices([...VALID_DISPATCH_MATRIX_TIERS]))
1629
+ .option('--candidate-model <model>', 'Exact configured implementer candidate model beneath the named ceiling')
1630
+ .option('--candidate-effort <effort>', 'Exact configured Codex candidate effort paired with --candidate-model')
1165
1631
  .option('--escalation-level <level>', 'Ordered route entry to select; 0 is the floor and higher values advance through escalation targets')
1166
1632
  .option('--project-path <path>', 'Read project-state policy from an explicit project path')
1167
1633
  .option('--preflight', 'Treat unresolved non-interactive resolution as an implementation block')
1168
1634
  .option('--non-interactive', 'Force non-interactive block behavior when the ceiling is unresolved')
1635
+ .option('--report-scope <scope>', 'Include Dispatch Report V1 for the explicit workflow scope')
1636
+ .addOption(new Option('--report-action <action>', 'Dispatch Report V1 action; must match the resolver role').choices(['implementation', 'fix', 'review']))
1169
1637
  .option('--json', 'Output machine-readable JSON')
1170
1638
  .action(async (options, cmd) => {
1171
1639
  const globalOptions = readGlobalOptions(cmd);