@open-agent-toolkit/cli 0.1.35 → 0.1.37

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.
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: oat-project-review-provide
3
- version: 1.3.7
3
+ version: 1.3.8
4
4
  description: Use when the user explicitly asks to review an OAT project — e.g. "review project", "review the project", "run project review", or confirms a previously offered review. Do NOT auto-invoke on completed work alone. Resolves a project review scope and offers before running.
5
5
  disable-model-invocation: false
6
6
  user-invocable: true
7
- allowed-tools: Read, Glob, Grep, Bash(git:*), AskUserQuestion
7
+ allowed-tools: Read, Glob, Grep, Write, Edit, Bash(git:*), Bash(oat:*), Bash(pnpm:*), Bash(mkdir:*), Bash(date:*), Bash(realpath:*), Bash(awk:*), AskUserQuestion
8
8
  ---
9
9
 
10
10
  # Request Review
@@ -639,7 +639,7 @@ oat_generated: true
639
639
  oat_generated_at: { today }
640
640
  oat_review_scope: { scope }
641
641
  oat_review_type: { code|artifact }
642
- oat_review_invocation: { manual|auto }
642
+ oat_review_invocation: { manual|auto|gate }
643
643
  oat_project: { PROJECT_PATH }
644
644
  ---
645
645
 
@@ -655,13 +655,21 @@ oat_project: { PROJECT_PATH }
655
655
 
656
656
  - `manual` (default): Review was manually triggered by the user. `oat-project-review-receive` uses standard disposition behavior (user prompts for triage, minors auto-deferred for non-final scopes).
657
657
  - `auto`: Review was spawned by the auto-review checkpoint trigger in `oat-project-implement`. `oat-project-review-receive` uses relaxed disposition: minors are auto-converted to fix tasks (not deferred), no user prompts for disposition decisions.
658
+ - `gate`: Review was spawned by `oat gate review`. Gate-originated reviews use normal stateful review-provide behavior: write the review artifact, update the `## Reviews` row, and commit review bookkeeping. `oat-project-review-receive` treats gate reviews with the same standard disposition behavior as manual reviews unless a future implementation explicitly designs autonomous receive.
658
659
 
659
- When `oat-project-implement` spawns this skill for auto-review at checkpoints, it passes context indicating auto invocation. Set `oat_review_invocation: auto` in the artifact frontmatter. For all other invocations (user-triggered, fresh session), use `manual`.
660
+ When `oat-project-implement` spawns this skill for auto-review at checkpoints, it passes context indicating auto invocation. Set `oat_review_invocation: auto` in the artifact frontmatter. When `oat gate review` spawns this skill, set `oat_review_invocation: gate`. For all other invocations (user-triggered, fresh session), use `manual`.
661
+
662
+ Gate parsing contract:
663
+
664
+ - Include either the `Findings: {N} critical, {N} important, {N} medium, {N} minor` summary line or the standard `## Findings` section with `### Critical`, `### Important`, `### Medium`, and `### Minor` subsections populated with findings or `None`.
665
+ - Do not omit severity headings merely because a severity has zero findings; `oat gate review` depends on counts or standard Findings sections to determine whether the review blocks.
660
666
 
661
667
  ## Summary
662
668
 
663
669
  {2-3 sentence summary}
664
670
 
671
+ Findings: {N} critical, {N} important, {N} medium, {N} minor
672
+
665
673
  ## Findings
666
674
 
667
675
  ### Critical
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: oat-project-review-receive
3
- version: 1.5.3
3
+ version: 1.5.4
4
4
  description: Use when the user explicitly asks to receive review findings for an OAT project — e.g. "receive review", "process review", "process the project review", or confirms a previously offered review-receive step. Do NOT auto-invoke merely because a review file exists. Resolves the latest review and offers before acting.
5
5
  disable-model-invocation: false
6
6
  user-invocable: true
@@ -274,6 +274,8 @@ Read `oat_review_type` and `oat_review_invocation` from review artifact frontmat
274
274
  - **No user prompts for disposition decisions.** The auto-review path runs fully autonomously.
275
275
  - Genuinely ambiguous findings (e.g., a medium the agent disagrees with) are deferred with a note explaining why, rather than pausing for interactive resolution.
276
276
  - Follow the task-conversion flow in Steps 3-10 with these adjusted defaults.
277
+ - If `oat_review_type == code` AND `oat_review_invocation == gate`:
278
+ - **Gate review mode.** This review was spawned by `oat gate review`, but receive remains standard disposition behavior. Treat `gate` the same as manual for prompts, finding conversion, artifact archival, and bookkeeping unless a future implementation explicitly designs an autonomous receive path.
277
279
  - If `oat_review_type == code` (manual or `oat_review_invocation` absent):
278
280
  - Follow the existing task-conversion flow in Steps 3-10 with standard disposition behavior.
279
281
 
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/gate/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,aAAa,EACnB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAQL,KAAK,UAAU,EAIf,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,KAAK,UAAU,EAEhB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAIL,KAAK,cAAc,EACpB,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,UAAU,uBAAuB;IAC/B,mBAAmB,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,cAAc,CAAC;IAChE,kBAAkB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACrD,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,CAAC,CAAC;IACxD,cAAc,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,kBAAkB,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;IAClE,mBAAmB,EAAE,CACnB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,cAAc,KACnB,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB,cAAc,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IAC/D,eAAe,EAAE,CAAC,aAAa,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9E,sBAAsB,EAAE,CACtB,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,EACrB,GAAG,EAAE,MAAM,CAAC,UAAU,KACnB,OAAO,CAAC,cAAc,CAAC,CAAC;IAC7B,UAAU,EAAE,CACV,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,iBAAiB,KACvB,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC,UAAU,CAAC;CAC/B;AAmCD,UAAU,iBAAiB;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IACvB,OAAO,EAAE,gBAAgB,GAAG,cAAc,GAAG,SAAS,CAAC;IACvD,KAAK,EAAE,QAAQ,GAAG,SAAS,CAAC;CAC7B;AAED,UAAU,gBAAgB;IACxB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,KAAK,kBAAkB,GAAG,cAAc,GAAG,MAAM,CAAC;AAKlD,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,UAAU,CAAC;CACpB;AAmWD,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,EAC9C,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE,kBAAkB,GACxB,kBAAkB,GAAG,IAAI,CAE3B;AA2SD,wBAAgB,iBAAiB,CAC/B,SAAS,GAAE,OAAO,CAAC,uBAAuB,CAAM,GAC/C,OAAO,CAoIT"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/gate/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,aAAa,EACnB,MAAM,sBAAsB,CAAC;AAO9B,OAAO,EAQL,KAAK,UAAU,EAIf,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,KAAK,UAAU,EAEhB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAIL,KAAK,cAAc,EACpB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAOpC,UAAU,uBAAuB;IAC/B,mBAAmB,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,cAAc,CAAC;IAChE,kBAAkB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACrD,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,CAAC,CAAC;IACxD,cAAc,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,kBAAkB,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;IAClE,mBAAmB,EAAE,CACnB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,cAAc,KACnB,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB,cAAc,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IAC/D,eAAe,EAAE,CAAC,aAAa,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9E,sBAAsB,EAAE,CACtB,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,EACrB,GAAG,EAAE,MAAM,CAAC,UAAU,KACnB,OAAO,CAAC,cAAc,CAAC,CAAC;IAC7B,UAAU,EAAE,CACV,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,iBAAiB,KACvB,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC,UAAU,CAAC;CAC/B;AA0CD,UAAU,iBAAiB;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IACvB,OAAO,EAAE,gBAAgB,GAAG,cAAc,GAAG,SAAS,CAAC;IACvD,KAAK,EAAE,QAAQ,GAAG,SAAS,CAAC;CAC7B;AAED,UAAU,gBAAgB;IACxB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,KAAK,kBAAkB,GAAG,cAAc,GAAG,MAAM,CAAC;AAMlD,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,UAAU,CAAC;CACpB;AAyZD,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,EAC9C,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE,kBAAkB,GACxB,kBAAkB,GAAG,IAAI,CAE3B;AA+wBD,wBAAgB,iBAAiB,CAC/B,SAAS,GAAE,OAAO,CAAC,uBAAuB,CAAM,GAC/C,OAAO,CAwKT"}
@@ -1,11 +1,16 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { join } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { readdir, readFile } from 'node:fs/promises';
4
+ import { basename, isAbsolute, join, relative } from 'node:path';
3
5
  import { buildCommandContext, } from '../../app/command-context.js';
6
+ import { getFrontmatterBlock, getFrontmatterField, } from '../shared/frontmatter.js';
4
7
  import { readGlobalOptions } from '../shared/shared.utils.js';
5
8
  import { BUILTIN_EXEC_TARGETS, readOatConfig, readOatLocalConfig, readUserConfig, writeOatConfig, writeOatLocalConfig, writeUserConfig, } from '../../config/oat-config.js';
6
9
  import { resolveEffectiveConfig, resolveExecTargets, resolveGate, } from '../../config/resolve.js';
7
- import { resolveProjectRoot } from '../../fs/paths.js';
10
+ import { dirExists, fileExists } from '../../fs/io.js';
11
+ import { normalizeToPosixPath, resolveProjectRoot } from '../../fs/paths.js';
8
12
  import { Command } from 'commander';
13
+ import { parseReviewGateVerdict, } from './review-verdict.js';
9
14
  const DEFAULT_DEPENDENCIES = {
10
15
  buildCommandContext,
11
16
  resolveProjectRoot,
@@ -29,6 +34,22 @@ const VALID_CROSS_PROVIDER_AVOIDS = [
29
34
  'same-runtime',
30
35
  'none',
31
36
  ];
37
+ const VALID_REVIEW_GATE_THRESHOLDS = [
38
+ 'critical',
39
+ 'important',
40
+ 'medium',
41
+ 'minor',
42
+ ];
43
+ 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.';
44
+ function reviewGateProjectContext(projectPath) {
45
+ return `Resolved OAT project path: ${projectPath}. Run the review for this project path.`;
46
+ }
47
+ function assembleReviewGatePrompt(segments) {
48
+ return segments
49
+ .map((segment) => segment.trim())
50
+ .filter((segment) => segment.length > 0)
51
+ .join('\n\n');
52
+ }
32
53
  async function runChildProcess(command, args, options) {
33
54
  return new Promise((resolve, reject) => {
34
55
  const child = spawn(command, args, {
@@ -90,6 +111,13 @@ function parseCrossProviderAvoid(value) {
90
111
  }
91
112
  return avoid;
92
113
  }
114
+ function parseReviewGateThreshold(value) {
115
+ const threshold = value?.trim() || 'important';
116
+ if (!VALID_REVIEW_GATE_THRESHOLDS.includes(threshold)) {
117
+ throw new Error('--exit-nonzero-on must be one of critical | important | medium | minor.');
118
+ }
119
+ return threshold;
120
+ }
93
121
  function parseOnFailure(value) {
94
122
  if (!value || !VALID_ON_FAILURE.includes(value)) {
95
123
  throw new Error('--on-failure must be one of block | prompt | warn.');
@@ -149,6 +177,15 @@ function parseGateConfig(options) {
149
177
  : {}),
150
178
  };
151
179
  }
180
+ function detectDevBuildGateCommandWarnings(command) {
181
+ const normalized = command.trim();
182
+ if (!/^node\s+(?:"[^"]*\/packages\/cli\/dist\/index\.js"|'[^']*\/packages\/cli\/dist\/index\.js'|\S*\/packages\/cli\/dist\/index\.js)\s+gate(?:\s|$)/.test(normalized)) {
183
+ return [];
184
+ }
185
+ return [
186
+ 'Durable docs/config should reference `oat gate ...`; absolute dev-build paths are reserved for local development of unmerged behavior.',
187
+ ];
188
+ }
152
189
  function parseExecTargetConfig(options) {
153
190
  if (options.disable) {
154
191
  return null;
@@ -299,6 +336,23 @@ async function selectAvailableExecTarget(registry, currentRuntime, avoid, contex
299
336
  }
300
337
  return null;
301
338
  }
339
+ async function resolveSelectedExecTarget(targets, options, context, dependencies) {
340
+ const explicitTarget = options.target?.trim();
341
+ if (explicitTarget) {
342
+ const target = targets[explicitTarget];
343
+ if (!target) {
344
+ throw new Error(`Unknown exec target "${explicitTarget}".`);
345
+ }
346
+ return { id: explicitTarget, target: cloneExecTarget(target) };
347
+ }
348
+ const avoid = parseCrossProviderAvoid(options.avoid);
349
+ const currentRuntime = await resolveCurrentRuntime(options.currentRuntime, context, dependencies);
350
+ const selected = await selectAvailableExecTarget(targets, currentRuntime, avoid, context, dependencies);
351
+ if (!selected) {
352
+ throw new Error(noEligibleTargetMessage(currentRuntime, avoid));
353
+ }
354
+ return selected;
355
+ }
302
356
  async function executeTarget(selected, prompt, context, dependencies) {
303
357
  const [command, baseArgs] = argvHead(selected.target.baseCommand);
304
358
  if (!command) {
@@ -321,6 +375,217 @@ async function executeTarget(selected, prompt, context, dependencies) {
321
375
  function noEligibleTargetMessage(currentRuntime, avoid) {
322
376
  return `No eligible gate exec target found for current runtime "${currentRuntime}" with --avoid ${avoid}. Install or configure an alternate runtime, rerun with --avoid none, or pin a target with --target <id>.`;
323
377
  }
378
+ function normalizeRepoRelativeProjectPath(repoRoot, pathValue) {
379
+ const trimmed = pathValue.trim().replace(/\/+$/, '');
380
+ const withoutState = trimmed.endsWith('/state.md')
381
+ ? trimmed.slice(0, -'/state.md'.length)
382
+ : trimmed;
383
+ const repoRelative = isAbsolute(withoutState)
384
+ ? relative(repoRoot, withoutState)
385
+ : withoutState;
386
+ const normalized = normalizeToPosixPath(repoRelative).replace(/^\.\//, '');
387
+ if (normalized === '..' || normalized.startsWith('../')) {
388
+ throw new Error(`Project path must resolve inside the current repository: ${pathValue}`);
389
+ }
390
+ return normalized;
391
+ }
392
+ async function listProjectCandidates(repoRoot, projectsRoot) {
393
+ const normalizedProjectsRoot = normalizeRepoRelativeProjectPath(repoRoot, projectsRoot);
394
+ const absoluteProjectsRoot = join(repoRoot, normalizedProjectsRoot);
395
+ let scopeEntries;
396
+ try {
397
+ scopeEntries = await readdir(absoluteProjectsRoot, { withFileTypes: true });
398
+ }
399
+ catch (error) {
400
+ if (typeof error === 'object' &&
401
+ error !== null &&
402
+ 'code' in error &&
403
+ error.code === 'ENOENT') {
404
+ return [];
405
+ }
406
+ throw error;
407
+ }
408
+ const candidates = (await Promise.all(scopeEntries
409
+ .filter((entry) => entry.isDirectory())
410
+ .map(async (scopeEntry) => {
411
+ const directProjectPath = normalizeToPosixPath(join(normalizedProjectsRoot, scopeEntry.name));
412
+ if (await fileExists(join(repoRoot, directProjectPath, 'state.md'))) {
413
+ return [directProjectPath];
414
+ }
415
+ const scopePath = join(absoluteProjectsRoot, scopeEntry.name);
416
+ const projectEntries = await readdir(scopePath, {
417
+ withFileTypes: true,
418
+ });
419
+ return Promise.all(projectEntries
420
+ .filter((entry) => entry.isDirectory())
421
+ .map(async (projectEntry) => {
422
+ const projectPath = normalizeToPosixPath(join(normalizedProjectsRoot, scopeEntry.name, projectEntry.name));
423
+ return (await fileExists(join(repoRoot, projectPath, 'state.md')))
424
+ ? projectPath
425
+ : null;
426
+ }));
427
+ })))
428
+ .flat()
429
+ .filter((candidate) => candidate !== null)
430
+ .sort();
431
+ return candidates;
432
+ }
433
+ async function assertProjectPath(repoRoot, projectPath, source) {
434
+ const normalizedPath = normalizeRepoRelativeProjectPath(repoRoot, projectPath);
435
+ const absolutePath = join(repoRoot, normalizedPath);
436
+ if (!(await dirExists(absolutePath)) ||
437
+ !(await fileExists(join(absolutePath, 'state.md')))) {
438
+ throw new Error(`${source} project "${projectPath}" does not resolve to a project directory containing state.md.`);
439
+ }
440
+ return normalizedPath;
441
+ }
442
+ async function resolveExplicitReviewProject(repoRoot, projectsRoot, projectValue) {
443
+ const trimmed = projectValue.trim();
444
+ if (!trimmed) {
445
+ throw new Error('--project must be a non-empty project path or name.');
446
+ }
447
+ const looksLikePath = trimmed.includes('/') || trimmed.startsWith('.') || isAbsolute(trimmed);
448
+ if (looksLikePath) {
449
+ return assertProjectPath(repoRoot, trimmed, '--project');
450
+ }
451
+ const candidates = await listProjectCandidates(repoRoot, projectsRoot);
452
+ const matches = candidates.filter((candidate) => basename(candidate) === trimmed);
453
+ if (matches.length === 1) {
454
+ return matches[0];
455
+ }
456
+ if (matches.length > 1) {
457
+ throw new Error(`Multiple OAT projects match --project "${trimmed}": ${matches.join(', ')}. Pass a full project path instead.`);
458
+ }
459
+ return assertProjectPath(repoRoot, `${projectsRoot}/${trimmed}`, '--project');
460
+ }
461
+ async function resolveReviewProject(options) {
462
+ const projectsRoot = String(options.effective.resolved['projects.root']?.value ??
463
+ options.effective.shared.projects?.root ??
464
+ '.oat/projects/shared');
465
+ if (options.project !== undefined) {
466
+ return resolveExplicitReviewProject(options.repoRoot, projectsRoot, options.project);
467
+ }
468
+ const activeProject = options.effective.local.activeProject?.trim();
469
+ if (activeProject) {
470
+ return assertProjectPath(options.repoRoot, activeProject, 'Active');
471
+ }
472
+ const candidates = await listProjectCandidates(options.repoRoot, projectsRoot);
473
+ if (candidates.length === 0) {
474
+ throw new Error('No OAT project could be resolved for gate review. Set an active project or pass --project <path-or-name>.');
475
+ }
476
+ if (candidates.length > 1) {
477
+ throw new Error(`Multiple OAT projects could be resolved for gate review: ${candidates.join(', ')}. Pass --project <path-or-name>.`);
478
+ }
479
+ return candidates[0];
480
+ }
481
+ function reviewGateLifecycleRank(scope) {
482
+ const normalizedScope = scope.trim().toLowerCase();
483
+ if (normalizedScope === 'final') {
484
+ return Number.MAX_SAFE_INTEGER;
485
+ }
486
+ const phases = [...normalizedScope.matchAll(/p(\d+)/g)].map((match) => Number.parseInt(match[1] ?? '0', 10));
487
+ if (phases.length === 0) {
488
+ return 0;
489
+ }
490
+ const tasks = [...normalizedScope.matchAll(/(?:^|[-_])t(\d+)/g)].map((match) => Number.parseInt(match[1] ?? '0', 10));
491
+ const latestPhase = Math.max(...phases);
492
+ const latestTask = tasks.length > 0 ? Math.max(...tasks) : 9999;
493
+ return latestPhase * 10_000 + latestTask;
494
+ }
495
+ async function readReviewGateArtifactCandidate(repoRoot, relativePath) {
496
+ const content = await readFile(join(repoRoot, relativePath), 'utf8');
497
+ const frontmatter = getFrontmatterBlock(content);
498
+ if (!frontmatter) {
499
+ return null;
500
+ }
501
+ const generatedAt = getFrontmatterField(frontmatter, 'oat_generated_at');
502
+ if (!generatedAt) {
503
+ return null;
504
+ }
505
+ const generatedTime = Date.parse(generatedAt);
506
+ if (Number.isNaN(generatedTime)) {
507
+ return null;
508
+ }
509
+ const scope = getFrontmatterField(frontmatter, 'oat_review_scope') ?? '';
510
+ return {
511
+ path: relativePath,
512
+ scope,
513
+ generatedAt,
514
+ kind: 'project',
515
+ archived: false,
516
+ actionable: true,
517
+ generatedTime,
518
+ lifecycleRank: reviewGateLifecycleRank(scope),
519
+ signature: createHash('sha256').update(content).digest('hex'),
520
+ };
521
+ }
522
+ function sortReviewGateArtifacts(left, right) {
523
+ if (left.generatedTime !== right.generatedTime) {
524
+ return right.generatedTime - left.generatedTime;
525
+ }
526
+ if (left.lifecycleRank !== right.lifecycleRank) {
527
+ return right.lifecycleRank - left.lifecycleRank;
528
+ }
529
+ return left.path.localeCompare(right.path);
530
+ }
531
+ async function listActiveProjectReviewCandidates(options) {
532
+ const projectPath = normalizeRepoRelativeProjectPath(options.repoRoot, options.projectPath);
533
+ const reviewsDir = `${projectPath}/reviews`;
534
+ let entries;
535
+ try {
536
+ entries = await readdir(join(options.repoRoot, reviewsDir), {
537
+ withFileTypes: true,
538
+ });
539
+ }
540
+ catch (error) {
541
+ if (typeof error === 'object' &&
542
+ error !== null &&
543
+ 'code' in error &&
544
+ error.code === 'ENOENT') {
545
+ return [];
546
+ }
547
+ throw error;
548
+ }
549
+ return (await Promise.all(entries
550
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
551
+ .map((entry) => readReviewGateArtifactCandidate(options.repoRoot, normalizeToPosixPath(join(reviewsDir, entry.name))))))
552
+ .filter((candidate) => candidate !== null)
553
+ .sort(sortReviewGateArtifacts);
554
+ }
555
+ function findProducedReviewArtifact(before, after) {
556
+ const beforeSignatures = new Map(before.map((candidate) => [candidate.path, candidate.signature]));
557
+ return (after.find((candidate) => beforeSignatures.get(candidate.path) !== candidate.signature) ?? null);
558
+ }
559
+ function reviewBlocksAtThreshold(verdict, threshold) {
560
+ if (verdict.counts.critical > 0) {
561
+ return true;
562
+ }
563
+ if (threshold === 'critical') {
564
+ return false;
565
+ }
566
+ if (verdict.counts.important > 0) {
567
+ return true;
568
+ }
569
+ if (threshold === 'important') {
570
+ return false;
571
+ }
572
+ if (verdict.counts.medium > 0) {
573
+ return true;
574
+ }
575
+ if (threshold === 'medium') {
576
+ return false;
577
+ }
578
+ return verdict.counts.minor > 0;
579
+ }
580
+ function writeReviewGateResult(context, payload) {
581
+ if (context.json) {
582
+ context.logger.json(payload);
583
+ return;
584
+ }
585
+ context.logger.info(`Review artifact: ${payload.artifactPath}`);
586
+ context.logger.info(`Verdict: ${payload.status} (critical=${payload.counts.critical}, important=${payload.counts.important}, medium=${payload.counts.medium}, minor=${payload.counts.minor})`);
587
+ context.logger.info(payload.handoff);
588
+ }
324
589
  async function updateConfigLayer(context, layer, dependencies, mutate) {
325
590
  const repoRoot = await dependencies.resolveProjectRoot(context.cwd);
326
591
  const userConfigDir = join(context.home, '.oat');
@@ -352,8 +617,21 @@ async function runGateSet(skillName, options, context, dependencies) {
352
617
  const layer = parseLayer(options.layer);
353
618
  const normalizedSkill = trimRequired(skillName, '<skill>');
354
619
  const gate = parseGateConfig(options);
620
+ const warnings = gate
621
+ ? detectDevBuildGateCommandWarnings(gate.command)
622
+ : [];
355
623
  await updateConfigLayer(context, layer, dependencies, (config) => setSkillGate(config, normalizedSkill, gate));
356
- writeSuccess(context, { layer, skill: normalizedSkill, gate });
624
+ if (!context.json) {
625
+ for (const warning of warnings) {
626
+ context.logger.warn(warning);
627
+ }
628
+ }
629
+ writeSuccess(context, {
630
+ layer,
631
+ skill: normalizedSkill,
632
+ gate,
633
+ ...(warnings.length > 0 ? { warnings } : {}),
634
+ });
357
635
  process.exitCode = 0;
358
636
  }
359
637
  catch (error) {
@@ -401,22 +679,71 @@ async function runCrossProviderExec(prompt, options, context, dependencies) {
401
679
  try {
402
680
  const effective = await readEffectiveConfig(context, dependencies);
403
681
  const targets = resolveExecTargets(effective);
404
- const explicitTarget = options.target?.trim();
405
- if (explicitTarget) {
406
- const target = targets[explicitTarget];
407
- if (!target) {
408
- throw new Error(`Unknown exec target "${explicitTarget}".`);
409
- }
410
- process.exitCode = await executeTarget({ id: explicitTarget, target: cloneExecTarget(target) }, prompt, context, dependencies);
682
+ const selected = await resolveSelectedExecTarget(targets, options, context, dependencies);
683
+ process.exitCode = await executeTarget(selected, prompt, context, dependencies);
684
+ }
685
+ catch (error) {
686
+ writeError(context, error);
687
+ }
688
+ }
689
+ async function runReviewGate(prompt, options, context, dependencies) {
690
+ try {
691
+ const repoRoot = await dependencies.resolveProjectRoot(context.cwd);
692
+ const userConfigDir = join(context.home, '.oat');
693
+ const effective = await dependencies.resolveEffectiveConfig(repoRoot, userConfigDir, dependencies.processEnv);
694
+ const projectPath = await resolveReviewProject({
695
+ repoRoot,
696
+ effective,
697
+ project: options.project,
698
+ });
699
+ const targets = resolveExecTargets(effective);
700
+ const selected = await resolveSelectedExecTarget(targets, options, context, dependencies);
701
+ const threshold = parseReviewGateThreshold(options.exitNonzeroOn);
702
+ const before = await listActiveProjectReviewCandidates({
703
+ repoRoot,
704
+ projectPath,
705
+ });
706
+ const reviewPrompt = assembleReviewGatePrompt([
707
+ REVIEW_GATE_CONTEXT_NOTE,
708
+ reviewGateProjectContext(projectPath),
709
+ ...(options.reviewType?.trim()
710
+ ? [`Review type: ${options.reviewType.trim()}.`]
711
+ : []),
712
+ ...(options.reviewScope?.trim()
713
+ ? [`Review scope: ${options.reviewScope.trim()}.`]
714
+ : []),
715
+ prompt.join(' '),
716
+ ]);
717
+ const childExitCode = await executeTarget(selected, [reviewPrompt], context, dependencies);
718
+ if (childExitCode !== 0) {
719
+ process.exitCode = childExitCode;
411
720
  return;
412
721
  }
413
- const avoid = parseCrossProviderAvoid(options.avoid);
414
- const currentRuntime = await resolveCurrentRuntime(options.currentRuntime, context, dependencies);
415
- const selected = await selectAvailableExecTarget(targets, currentRuntime, avoid, context, dependencies);
416
- if (!selected) {
417
- throw new Error(noEligibleTargetMessage(currentRuntime, avoid));
722
+ const after = await listActiveProjectReviewCandidates({
723
+ repoRoot,
724
+ projectPath,
725
+ });
726
+ const producedArtifact = findProducedReviewArtifact(before, after);
727
+ if (!producedArtifact) {
728
+ throw new Error(`No new review artifact was detected for project ${projectPath}. Ensure the review provider wrote a project review artifact before the gate exits.`);
418
729
  }
419
- process.exitCode = await executeTarget(selected, prompt, context, dependencies);
730
+ const verdict = await parseReviewGateVerdict(join(repoRoot, producedArtifact.path));
731
+ const blocking = reviewBlocksAtThreshold(verdict, threshold);
732
+ const handoff = `Run oat-project-review-receive for ${producedArtifact.path} before treating this gate review as consumed.`;
733
+ writeReviewGateResult(context, {
734
+ status: blocking ? 'blocked' : 'ok',
735
+ target: selected.id,
736
+ project: projectPath,
737
+ artifactPath: producedArtifact.path,
738
+ threshold,
739
+ blocking,
740
+ counts: verdict.counts,
741
+ reviewType: verdict.reviewType,
742
+ scope: verdict.scope,
743
+ invocation: verdict.invocation,
744
+ handoff,
745
+ });
746
+ process.exitCode = blocking ? 1 : 0;
420
747
  }
421
748
  catch (error) {
422
749
  writeError(context, error);
@@ -470,6 +797,21 @@ export function createGateCommand(overrides = {}) {
470
797
  const context = dependencies.buildCommandContext(readGlobalOptions(command));
471
798
  await runCrossProviderExec(prompt, options, context, dependencies);
472
799
  });
800
+ cmd
801
+ .command('review')
802
+ .description('Run a review gate and map review findings to exit status')
803
+ .option('--target <id>', 'Run this exact exec target')
804
+ .option('--avoid <mode>', 'Avoidance mode: same-runtime or none')
805
+ .option('--current-runtime <runtime>', 'Override detected runtime for testing or manual routing')
806
+ .option('--project <path-or-name>', 'Project path or name to review; defaults to the active project')
807
+ .option('--review-scope <scope>', 'Review scope hint for the provider')
808
+ .option('--review-type <type>', 'Review type hint for the provider')
809
+ .option('--exit-nonzero-on <severity>', 'Lowest severity that exits nonzero: critical, important, medium, or minor')
810
+ .argument('<prompt...>', 'Review prompt arguments appended to the target command')
811
+ .action(async (prompt, options, command) => {
812
+ const context = dependencies.buildCommandContext(readGlobalOptions(command));
813
+ await runReviewGate(prompt, options, context, dependencies);
814
+ });
473
815
  const target = new Command('target').description('Manage gate execution targets');
474
816
  target
475
817
  .command('set')
@@ -0,0 +1,15 @@
1
+ export interface ReviewGateVerdict {
2
+ artifactPath: string;
3
+ reviewType: 'code' | 'artifact' | 'unknown';
4
+ scope: string | null;
5
+ invocation: string | null;
6
+ counts: {
7
+ critical: number;
8
+ important: number;
9
+ medium: number;
10
+ minor: number;
11
+ };
12
+ blocking: boolean;
13
+ }
14
+ export declare function parseReviewGateVerdict(artifactPath: string): Promise<ReviewGateVerdict>;
15
+ //# sourceMappingURL=review-verdict.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"review-verdict.d.ts","sourceRoot":"","sources":["../../../src/commands/gate/review-verdict.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAAC;IAC5C,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,MAAM,EAAE;QACN,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;KACf,CAAC;IACF,QAAQ,EAAE,OAAO,CAAC;CACnB;AA2ND,wBAAsB,sBAAsB,CAC1C,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,iBAAiB,CAAC,CAmC5B"}