@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.
- package/assets/agents/oat-reviewer.md +22 -2
- package/assets/docs/cli-utilities/config-and-local-state.md +14 -5
- package/assets/docs/cli-utilities/configuration.md +1 -1
- package/assets/docs/cli-utilities/workflow-gates.md +123 -22
- package/assets/docs/contributing/skills.md +7 -3
- package/assets/docs/reference/cli-reference.md +4 -3
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/oat-project-implement/SKILL.md +7 -3
- package/assets/skills/oat-project-import-plan/SKILL.md +53 -9
- package/assets/skills/oat-project-plan/SKILL.md +7 -3
- package/assets/skills/oat-project-quick-start/SKILL.md +37 -9
- package/assets/skills/oat-project-review-provide/SKILL.md +12 -4
- package/assets/skills/oat-project-review-receive/SKILL.md +3 -1
- package/dist/commands/gate/index.d.ts.map +1 -1
- package/dist/commands/gate/index.js +358 -16
- package/dist/commands/gate/review-verdict.d.ts +15 -0
- package/dist/commands/gate/review-verdict.d.ts.map +1 -0
- package/dist/commands/gate/review-verdict.js +180 -0
- package/dist/config/oat-config.js +1 -1
- package/package.json +2 -2
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: oat-project-review-provide
|
|
3
|
-
version: 1.3.
|
|
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
|
+
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":"
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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"}
|