@open-agent-toolkit/cli 0.2.79 → 0.3.1
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-phase-implementer.md +5 -5
- package/assets/agents/oat-reviewer.md +17 -15
- package/assets/bundle-metadata.json +1 -1
- package/assets/docs/cli-utilities/workflow-gates.md +1 -1
- package/assets/docs/workflows/projects/artifacts.md +2 -2
- package/assets/docs/workflows/projects/autonomy.md +1 -1
- package/assets/docs/workflows/projects/implementation-execution.md +4 -4
- package/assets/docs/workflows/projects/lifecycle.md +1 -1
- package/assets/docs/workflows/projects/reviews.md +11 -11
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/oat-agent-instructions-analyze/SKILL.md +3 -3
- package/assets/skills/oat-docs-analyze/SKILL.md +3 -3
- package/assets/skills/oat-project-autonomous/SKILL.md +2 -2
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +14 -14
- package/assets/skills/oat-project-design/SKILL.md +2 -2
- package/assets/skills/oat-project-discover/SKILL.md +2 -2
- package/assets/skills/oat-project-document/SKILL.md +1 -1
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +14 -14
- package/assets/skills/oat-project-implement/SKILL.md +2 -2
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +1 -1
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +14 -14
- package/assets/skills/oat-project-implement/references/phase-execution.md +3 -3
- package/assets/skills/oat-project-implement/references/plan-and-resume.md +2 -2
- package/assets/skills/oat-project-import-plan/SKILL.md +2 -2
- package/assets/skills/oat-project-lite/SKILL.md +1 -1
- package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +14 -14
- package/assets/skills/oat-project-plan/SKILL.md +2 -2
- package/assets/skills/oat-project-plan-writing/SKILL.md +4 -4
- package/assets/skills/oat-project-pr-final/SKILL.md +1 -1
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +14 -14
- package/assets/skills/oat-project-quick-start/SKILL.md +2 -2
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +14 -14
- package/assets/skills/oat-project-review-provide/SKILL.md +14 -14
- package/assets/skills/oat-project-review-provide-remote/SKILL.md +10 -10
- package/assets/skills/oat-project-review-receive/SKILL.md +64 -45
- package/assets/skills/oat-project-review-receive-remote/SKILL.md +6 -6
- package/assets/skills/oat-project-revise/SKILL.md +2 -2
- package/assets/skills/oat-review-provide/SKILL.md +2 -2
- package/assets/skills/oat-review-provide/references/review-artifact-template.md +3 -3
- package/assets/skills/oat-review-provide-remote/SKILL.md +11 -11
- package/assets/skills/oat-review-receive/SKILL.md +18 -17
- package/assets/skills/oat-review-receive-remote/SKILL.md +13 -13
- package/assets/skills/oat-wave-execute/SKILL.md +2 -2
- package/assets/templates/plan.md +1 -1
- package/dist/commands/gate/__fixtures__/fake-runtime.mjs +4 -4
- package/dist/commands/gate/index.d.ts.map +1 -1
- package/dist/commands/gate/index.js +20 -11
- package/dist/commands/gate/review-verdict.d.ts +2 -2
- package/dist/commands/gate/review-verdict.d.ts.map +1 -1
- package/dist/commands/gate/review-verdict.js +245 -57
- package/dist/review-remote/body-builder.d.ts +3 -3
- package/dist/review-remote/body-builder.d.ts.map +1 -1
- package/dist/review-remote/body-builder.js +12 -12
- package/dist/review-remote/reviewer-dispatch.d.ts +2 -2
- package/dist/review-remote/reviewer-dispatch.d.ts.map +1 -1
- package/dist/review-remote/reviewer-dispatch.js +15 -7
- package/package.json +2 -2
|
@@ -2,17 +2,12 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { getFrontmatterBlock } from '../shared/frontmatter.js';
|
|
4
4
|
import YAML from 'yaml';
|
|
5
|
-
const SEVERITIES = [
|
|
6
|
-
'critical',
|
|
7
|
-
'important',
|
|
8
|
-
'medium',
|
|
9
|
-
'minor',
|
|
10
|
-
];
|
|
5
|
+
const SEVERITIES = ['critical', 'high', 'medium', 'low'];
|
|
11
6
|
const FRONTMATTER_COUNT_KEYS = {
|
|
12
7
|
critical: ['oat_review_critical_count', 'critical'],
|
|
13
|
-
|
|
8
|
+
high: ['oat_review_high_count', 'high'],
|
|
14
9
|
medium: ['oat_review_medium_count', 'medium'],
|
|
15
|
-
|
|
10
|
+
low: ['oat_review_low_count', 'low'],
|
|
16
11
|
};
|
|
17
12
|
function artifactContentSignature(content) {
|
|
18
13
|
return createHash('sha256').update(content).digest('hex');
|
|
@@ -59,6 +54,9 @@ function readGateInvocation(frontmatter) {
|
|
|
59
54
|
};
|
|
60
55
|
}
|
|
61
56
|
function parseCountValue(value) {
|
|
57
|
+
if (typeof value === 'string' && value.trim().length === 0) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
62
60
|
const numberValue = typeof value === 'number'
|
|
63
61
|
? value
|
|
64
62
|
: typeof value === 'string'
|
|
@@ -69,40 +67,58 @@ function parseCountValue(value) {
|
|
|
69
67
|
}
|
|
70
68
|
return numberValue;
|
|
71
69
|
}
|
|
72
|
-
function readFrontmatterCounts(frontmatter) {
|
|
70
|
+
function readFrontmatterCounts(frontmatter, artifactPath) {
|
|
71
|
+
const hasNestedCounts = Object.hasOwn(frontmatter, 'oat_review_counts');
|
|
72
|
+
if (hasNestedCounts &&
|
|
73
|
+
(typeof frontmatter['oat_review_counts'] !== 'object' ||
|
|
74
|
+
frontmatter['oat_review_counts'] === null ||
|
|
75
|
+
Array.isArray(frontmatter['oat_review_counts']))) {
|
|
76
|
+
throw new Error(`Review artifact at ${artifactPath} declares an invalid oat_review_counts block; it must be an object of non-negative integer counts.`);
|
|
77
|
+
}
|
|
73
78
|
const nestedCounts = typeof frontmatter['oat_review_counts'] === 'object' &&
|
|
74
79
|
frontmatter['oat_review_counts'] !== null &&
|
|
75
80
|
!Array.isArray(frontmatter['oat_review_counts'])
|
|
76
81
|
? frontmatter['oat_review_counts']
|
|
77
82
|
: null;
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
minor: 0,
|
|
83
|
-
};
|
|
83
|
+
const parsed = new Map();
|
|
84
|
+
// Validate every supplied alias before deciding whether the block is
|
|
85
|
+
// complete. A partial block must not be able to hide a conflicting pair and
|
|
86
|
+
// fall through to a summary line that reports fewer findings.
|
|
84
87
|
for (const severity of SEVERITIES) {
|
|
85
88
|
const candidateValues = [
|
|
86
|
-
...FRONTMATTER_COUNT_KEYS[severity].
|
|
89
|
+
...FRONTMATTER_COUNT_KEYS[severity].flatMap((key) => Object.hasOwn(frontmatter, key) ? [frontmatter[key]] : []),
|
|
87
90
|
...(nestedCounts
|
|
88
|
-
? FRONTMATTER_COUNT_KEYS[severity].
|
|
91
|
+
? FRONTMATTER_COUNT_KEYS[severity].flatMap((key) => Object.hasOwn(nestedCounts, key) ? [nestedCounts[key]] : [])
|
|
89
92
|
: []),
|
|
90
|
-
]
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
];
|
|
94
|
+
const hasInvalidValue = candidateValues.some((value) => parseCountValue(value) === null);
|
|
95
|
+
if (hasInvalidValue) {
|
|
96
|
+
throw new Error(`Review artifact at ${artifactPath} declares an invalid ${severity} count; counts must be non-negative integers.`);
|
|
93
97
|
}
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
const distinct = [
|
|
99
|
+
...new Set(candidateValues
|
|
100
|
+
.map((value) => parseCountValue(value))
|
|
101
|
+
.filter((value) => value !== null)),
|
|
102
|
+
];
|
|
103
|
+
if (distinct.length > 1) {
|
|
104
|
+
throw new Error(`Review artifact at ${artifactPath} declares conflicting ${severity} counts (${distinct.join(', ')}); resolve the frontmatter before the gate can evaluate the review.`);
|
|
105
|
+
}
|
|
106
|
+
if (distinct.length === 1) {
|
|
107
|
+
parsed.set(severity, distinct[0]);
|
|
102
108
|
}
|
|
103
|
-
counts[severity] = parsedCount;
|
|
104
109
|
}
|
|
105
|
-
|
|
110
|
+
if (parsed.size === 0) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
counts: {
|
|
115
|
+
critical: parsed.get('critical') ?? 0,
|
|
116
|
+
high: parsed.get('high') ?? 0,
|
|
117
|
+
medium: parsed.get('medium') ?? 0,
|
|
118
|
+
low: parsed.get('low') ?? 0,
|
|
119
|
+
},
|
|
120
|
+
tiers: [...parsed.keys()],
|
|
121
|
+
};
|
|
106
122
|
}
|
|
107
123
|
function sectionContentIsEmpty(content) {
|
|
108
124
|
const cleaned = content
|
|
@@ -113,6 +129,17 @@ function sectionContentIsEmpty(content) {
|
|
|
113
129
|
.trim();
|
|
114
130
|
return cleaned.length === 0 || /^none\.?$/i.test(cleaned);
|
|
115
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Count the findings a severity section declares.
|
|
134
|
+
*
|
|
135
|
+
* Only top-level markdown list items count. Prose shapes are deliberately not
|
|
136
|
+
* counted — a bold lead-in paragraph, a wrapped continuation line, and a nested
|
|
137
|
+
* detail bullet are indistinguishable from a finding title without parsing the
|
|
138
|
+
* prose, so counting them would miscount and double-count. The cost of that
|
|
139
|
+
* choice is that a reviewer who writes findings as paragraphs gets a zero
|
|
140
|
+
* tally, so every diagnostic built on this count must name the list-item
|
|
141
|
+
* requirement (see `FINDING_LIST_ITEM_CONTRACT_HINT`).
|
|
142
|
+
*/
|
|
116
143
|
function countFindingsInSection(content) {
|
|
117
144
|
if (sectionContentIsEmpty(content)) {
|
|
118
145
|
return 0;
|
|
@@ -146,15 +173,25 @@ function markdownLines(content) {
|
|
|
146
173
|
return lines;
|
|
147
174
|
}
|
|
148
175
|
function fenceMarker(line) {
|
|
149
|
-
const match = line.match(/^\s*(`{3,}|~{3,})
|
|
176
|
+
const match = line.match(/^\s*(`{3,}|~{3,})(.*)$/);
|
|
150
177
|
if (!match?.[1]) {
|
|
151
178
|
return null;
|
|
152
179
|
}
|
|
153
180
|
return {
|
|
154
181
|
marker: match[1].startsWith('`') ? '`' : '~',
|
|
155
182
|
length: match[1].length,
|
|
183
|
+
trailing: match[2] ?? '',
|
|
156
184
|
};
|
|
157
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Lines outside fenced code blocks.
|
|
188
|
+
*
|
|
189
|
+
* An opening fence may carry an info string (```ts), but a closing fence may
|
|
190
|
+
* not: a marker run followed by other text is fence content, not a delimiter.
|
|
191
|
+
* Treating such a line as a closer ends the fence early and exposes quoted
|
|
192
|
+
* examples to the count scans, which is the failure the fence-awareness is
|
|
193
|
+
* there to prevent.
|
|
194
|
+
*/
|
|
158
195
|
function linesOutsideFences(content) {
|
|
159
196
|
const outsideFenceLines = [];
|
|
160
197
|
let activeFence = null;
|
|
@@ -165,7 +202,8 @@ function linesOutsideFences(content) {
|
|
|
165
202
|
activeFence = marker;
|
|
166
203
|
}
|
|
167
204
|
else if (marker.marker === activeFence.marker &&
|
|
168
|
-
marker.length >= activeFence.length
|
|
205
|
+
marker.length >= activeFence.length &&
|
|
206
|
+
marker.trailing.trim() === '') {
|
|
169
207
|
activeFence = null;
|
|
170
208
|
}
|
|
171
209
|
continue;
|
|
@@ -211,39 +249,114 @@ function missingSeverityHeadings(severityHeadings) {
|
|
|
211
249
|
const seenSeverities = new Set(severityHeadings.map((heading) => heading.severity));
|
|
212
250
|
return SEVERITIES.filter((severity) => !seenSeverities.has(severity));
|
|
213
251
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
252
|
+
const FINDINGS_SUMMARY_PATTERN = /^Findings by severity:\s*(\d+)\s+critical,\s*(\d+)\s+high,\s*(\d+)\s+medium,\s*(\d+)\s+low\s*$/i;
|
|
253
|
+
/**
|
|
254
|
+
* Read the canonical count line. Only lines outside fenced code blocks count,
|
|
255
|
+
* so a quoted example cannot be mistaken for the artifact's own counts, and two
|
|
256
|
+
* disagreeing count lines are rejected rather than silently resolved in favor
|
|
257
|
+
* of whichever appears first.
|
|
258
|
+
*/
|
|
259
|
+
function parseFindingsSummaryCounts(content, artifactPath) {
|
|
260
|
+
const found = [];
|
|
261
|
+
for (const line of linesOutsideFences(content)) {
|
|
262
|
+
const match = FINDINGS_SUMMARY_PATTERN.exec(line.text.trim());
|
|
263
|
+
if (!match) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
found.push({
|
|
267
|
+
critical: Number.parseInt(match[1] ?? '0', 10),
|
|
268
|
+
high: Number.parseInt(match[2] ?? '0', 10),
|
|
269
|
+
medium: Number.parseInt(match[3] ?? '0', 10),
|
|
270
|
+
low: Number.parseInt(match[4] ?? '0', 10),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (found.length === 0) {
|
|
217
274
|
return null;
|
|
218
275
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
};
|
|
276
|
+
const distinct = new Map(found.map((counts) => [JSON.stringify(counts), counts]));
|
|
277
|
+
if (distinct.size > 1) {
|
|
278
|
+
throw new Error(`Review artifact at ${artifactPath} contains ${found.length} conflicting "Findings by severity" count lines; keep exactly one so the gate can evaluate the review.`);
|
|
279
|
+
}
|
|
280
|
+
return found[0];
|
|
225
281
|
}
|
|
226
|
-
|
|
282
|
+
/**
|
|
283
|
+
* Tier spellings retired by the severity rename. They are no longer read, and
|
|
284
|
+
* detecting them fails the artifact with a migration error rather than letting
|
|
285
|
+
* it parse. A retired heading that parsed alongside canonical ones would have
|
|
286
|
+
* its findings attributed to whichever canonical section precedes it, which can
|
|
287
|
+
* move a finding below the blocking threshold.
|
|
288
|
+
*/
|
|
289
|
+
function describeLegacySeverityUsage(content, frontmatter) {
|
|
290
|
+
const found = new Set();
|
|
291
|
+
const findingsSection = findFindingsSection(content);
|
|
292
|
+
for (const line of linesOutsideFences(findingsSection?.content ?? '')) {
|
|
293
|
+
if (/^#{1,6}\s+Important\s*#*\s*$/i.test(line.text)) {
|
|
294
|
+
found.add('a `### Important` heading');
|
|
295
|
+
}
|
|
296
|
+
if (/^#{1,6}\s+Minor\s*#*\s*$/i.test(line.text)) {
|
|
297
|
+
found.add('a `### Minor` heading');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const line of linesOutsideFences(content)) {
|
|
301
|
+
if (/^Findings:\s*\d+\s+critical,/i.test(line.text.trim())) {
|
|
302
|
+
found.add('a legacy `Findings:` count line');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const nested = typeof frontmatter['oat_review_counts'] === 'object' &&
|
|
306
|
+
frontmatter['oat_review_counts'] !== null &&
|
|
307
|
+
!Array.isArray(frontmatter['oat_review_counts'])
|
|
308
|
+
? frontmatter['oat_review_counts']
|
|
309
|
+
: {};
|
|
310
|
+
for (const key of [...Object.keys(frontmatter), ...Object.keys(nested)]) {
|
|
311
|
+
if (/^(oat_review_)?(important|minor)(_count)?$/.test(key)) {
|
|
312
|
+
found.add(`the \`${key}\` count key`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return [...found];
|
|
316
|
+
}
|
|
317
|
+
function legacySeverityError(artifactPath, legacyUsage) {
|
|
318
|
+
if (legacyUsage.length === 0) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
return new Error(`Review artifact at ${artifactPath} uses retired severity tiers (${legacyUsage.join(', ')}). The tiers are now Critical, High, Medium, and Low: rename \`### Important\` to \`### High\` and \`### Minor\` to \`### Low\`, and \`oat_review_important_count\`/\`oat_review_minor_count\` to \`oat_review_high_count\`/\`oat_review_low_count\`, or re-run the review. If the reviewer that wrote this artifact still emits the retired tiers, its installed instructions are stale: run \`oat tools update\` to refresh the installed tools and their provider projections.`);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Per-tier counts for every severity heading the body actually has.
|
|
325
|
+
*
|
|
326
|
+
* Deliberately tolerant: it counts what is present without requiring the full
|
|
327
|
+
* canonical heading set, so it can cross-check explicit counts without changing
|
|
328
|
+
* how a body-only artifact is validated.
|
|
329
|
+
*/
|
|
330
|
+
function tallySeveritySections(content) {
|
|
227
331
|
const findingsSection = findFindingsSection(content);
|
|
228
332
|
const severityHeadings = findSeverityHeadings(content);
|
|
229
333
|
if (severityHeadings.length === 0) {
|
|
230
334
|
return null;
|
|
231
335
|
}
|
|
232
|
-
const missingSeverities = missingSeverityHeadings(severityHeadings);
|
|
233
|
-
if (missingSeverities.length > 0) {
|
|
234
|
-
throw new Error(`Review artifact at ${artifactPath} has an incomplete Findings section; expected headings for Critical, Important, Medium, and Minor. Missing: ${missingSeverities.join(', ')}.`);
|
|
235
|
-
}
|
|
236
336
|
const counts = {
|
|
237
337
|
critical: 0,
|
|
238
|
-
|
|
338
|
+
high: 0,
|
|
239
339
|
medium: 0,
|
|
240
|
-
|
|
340
|
+
low: 0,
|
|
241
341
|
};
|
|
242
342
|
for (const [offset, heading] of severityHeadings.entries()) {
|
|
243
343
|
const nextHeading = severityHeadings[offset + 1];
|
|
244
344
|
const sectionStart = heading.index + heading.headingLength;
|
|
245
345
|
const sectionEnd = nextHeading?.index ?? findingsSection?.end ?? content.length;
|
|
246
|
-
|
|
346
|
+
// Accumulate rather than assign: a repeated heading for one tier must not
|
|
347
|
+
// erase findings already counted under an earlier occurrence.
|
|
348
|
+
counts[heading.severity] += countFindingsInSection(content.slice(sectionStart, sectionEnd));
|
|
349
|
+
}
|
|
350
|
+
return counts;
|
|
351
|
+
}
|
|
352
|
+
function parseFindingsSectionCounts(content, artifactPath) {
|
|
353
|
+
const counts = tallySeveritySections(content);
|
|
354
|
+
if (counts === null) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
const missingSeverities = missingSeverityHeadings(findSeverityHeadings(content));
|
|
358
|
+
if (missingSeverities.length > 0) {
|
|
359
|
+
throw new Error(`Review artifact at ${artifactPath} has an incomplete Findings section; expected headings for ${SEVERITIES.map(severityDisplayName).join(', ')}. Missing: ${missingSeverities.join(', ')}.`);
|
|
247
360
|
}
|
|
248
361
|
return counts;
|
|
249
362
|
}
|
|
@@ -263,7 +376,7 @@ function parseFrontmatterObject(frontmatter, artifactPath) {
|
|
|
263
376
|
}
|
|
264
377
|
}
|
|
265
378
|
function hasBlockingFindings(counts) {
|
|
266
|
-
return counts.critical > 0 || counts.
|
|
379
|
+
return counts.critical > 0 || counts.high > 0;
|
|
267
380
|
}
|
|
268
381
|
function insertionTextForSeverity(severity) {
|
|
269
382
|
return `\n### ${severityDisplayName(severity)}\n\nNone\n`;
|
|
@@ -307,16 +420,83 @@ async function normalizeMissingEmptySeveritySections(artifactPath, content, coun
|
|
|
307
420
|
persisted: insertedSeverities.length > 0 && persist,
|
|
308
421
|
};
|
|
309
422
|
}
|
|
310
|
-
|
|
311
|
-
|
|
423
|
+
/**
|
|
424
|
+
* The one fact a reviewer cannot recover from the raw contradiction report.
|
|
425
|
+
*
|
|
426
|
+
* A section whose findings are written as bold paragraphs tallies zero, so the
|
|
427
|
+
* gate reports a contradiction that looks like a counting mistake. Without this
|
|
428
|
+
* sentence the reviewer has no way to learn that the shape of the finding, not
|
|
429
|
+
* the arithmetic, is what the parser objected to.
|
|
430
|
+
*/
|
|
431
|
+
const FINDING_LIST_ITEM_CONTRACT_HINT = 'Only markdown list items count as findings in a Findings section: a line starting with `- `, `* `, `+ `, or `1. `. A finding written as a bold paragraph, a heading, or any other prose shape counts as zero, which is the usual reason a section tallies lower than the declared counts. Rewrite each finding as a list item, or correct the declared counts to match.';
|
|
432
|
+
function formatCounts(counts) {
|
|
433
|
+
return SEVERITIES.map((severity) => `${counts[severity]} ${severity}`).join(', ');
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Fail closed when two count sources disagree.
|
|
437
|
+
*
|
|
438
|
+
* No single source may win by precedence: a stale all-zero frontmatter block or
|
|
439
|
+
* count line would otherwise mask real findings written in the body, and the
|
|
440
|
+
* gate would pass a review that clearly reports a blocking finding.
|
|
441
|
+
*/
|
|
442
|
+
function assertAgreeingCounts(artifactPath, left, right, tiers = SEVERITIES) {
|
|
443
|
+
const differing = tiers.filter((severity) => left.counts[severity] !== right.counts[severity]);
|
|
444
|
+
if (differing.length === 0) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const sectionsInvolved = left.fromSections === true || right.fromSections === true;
|
|
448
|
+
throw new Error(`Review artifact at ${artifactPath} contradicts itself about finding counts (${differing.join(', ')}): declared counts are ${formatCounts(left.counts)} (${left.name}) but ${formatCounts(right.counts)} (${right.name}). Reconcile them before the gate can evaluate the review.${sectionsInvolved ? ` ${FINDING_LIST_ITEM_CONTRACT_HINT}` : ''}`);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Resolve the artifact's counts, cross-checking every source that is present.
|
|
452
|
+
*
|
|
453
|
+
* Returns `null` when only the body carries counts, so the caller keeps the
|
|
454
|
+
* completeness-checked body path for body-only artifacts.
|
|
455
|
+
*/
|
|
456
|
+
function resolveCounts(content, frontmatter, artifactPath) {
|
|
457
|
+
const explicitSources = [];
|
|
458
|
+
const frontmatterCounts = readFrontmatterCounts(frontmatter, artifactPath);
|
|
312
459
|
if (frontmatterCounts) {
|
|
313
|
-
|
|
460
|
+
explicitSources.push({
|
|
461
|
+
name: 'the frontmatter count fields',
|
|
462
|
+
counts: frontmatterCounts.counts,
|
|
463
|
+
tiers: frontmatterCounts.tiers,
|
|
464
|
+
});
|
|
314
465
|
}
|
|
315
|
-
const summaryCounts = parseFindingsSummaryCounts(content);
|
|
466
|
+
const summaryCounts = parseFindingsSummaryCounts(content, artifactPath);
|
|
316
467
|
if (summaryCounts) {
|
|
317
|
-
|
|
468
|
+
explicitSources.push({
|
|
469
|
+
name: 'the "Findings by severity" count line',
|
|
470
|
+
counts: summaryCounts,
|
|
471
|
+
tiers: SEVERITIES,
|
|
472
|
+
});
|
|
318
473
|
}
|
|
319
|
-
|
|
474
|
+
const [primarySource, ...otherSources] = explicitSources;
|
|
475
|
+
for (const source of otherSources) {
|
|
476
|
+
const comparableTiers = primarySource.tiers.filter((severity) => source.tiers.includes(severity));
|
|
477
|
+
assertAgreeingCounts(artifactPath, primarySource, source, comparableTiers);
|
|
478
|
+
}
|
|
479
|
+
const bodyCounts = tallySeveritySections(content);
|
|
480
|
+
if (bodyCounts) {
|
|
481
|
+
// Only tiers the body actually has a heading for are comparable. A missing
|
|
482
|
+
// heading is not a claim of zero findings, it is a malformed body, and the
|
|
483
|
+
// completeness and normalization checks own that diagnosis.
|
|
484
|
+
const bodyTiers = [
|
|
485
|
+
...new Set(findSeverityHeadings(content).map((heading) => heading.severity)),
|
|
486
|
+
];
|
|
487
|
+
const bodySource = {
|
|
488
|
+
name: 'the Findings sections',
|
|
489
|
+
counts: bodyCounts,
|
|
490
|
+
tiers: bodyTiers,
|
|
491
|
+
fromSections: true,
|
|
492
|
+
};
|
|
493
|
+
for (const source of explicitSources) {
|
|
494
|
+
const comparableTiers = source.tiers.filter((severity) => bodyTiers.includes(severity));
|
|
495
|
+
assertAgreeingCounts(artifactPath, source, bodySource, comparableTiers);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return (explicitSources.find((source) => source.tiers.length === SEVERITIES.length)
|
|
499
|
+
?.counts ?? null);
|
|
320
500
|
}
|
|
321
501
|
export async function parseReviewGateVerdict(artifactPath, options = {}) {
|
|
322
502
|
let content;
|
|
@@ -340,7 +520,15 @@ export async function parseReviewGateVerdict(artifactPath, options = {}) {
|
|
|
340
520
|
const frontmatter = frontmatterBlock
|
|
341
521
|
? parseFrontmatterObject(frontmatterBlock, artifactPath)
|
|
342
522
|
: {};
|
|
343
|
-
|
|
523
|
+
// Retired tiers fail the artifact outright rather than being ignored, and do
|
|
524
|
+
// so before normalization can rewrite the file. Ignoring them is not safe: a
|
|
525
|
+
// retired heading that parses alongside canonical ones has its findings
|
|
526
|
+
// attributed to whichever canonical section precedes it.
|
|
527
|
+
const retiredTierError = legacySeverityError(artifactPath, describeLegacySeverityUsage(content, frontmatter));
|
|
528
|
+
if (retiredTierError) {
|
|
529
|
+
throw retiredTierError;
|
|
530
|
+
}
|
|
531
|
+
let counts = resolveCounts(content, frontmatter, artifactPath);
|
|
344
532
|
let insertedSeverities = [];
|
|
345
533
|
let normalizationPersisted = false;
|
|
346
534
|
if (counts && options.normalizeMissingEmptySeveritySections) {
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The body is the durable handoff to `*-receive-remote`: a leading
|
|
7
7
|
* HTML-comment marker block (parsed back by {@link parseMarkerBlock}) followed
|
|
8
|
-
* by human-readable prose (summary, severity counts, optional
|
|
8
|
+
* by human-readable prose (summary, severity counts, optional low-fix nudge,
|
|
9
9
|
* optional verification commands).
|
|
10
10
|
*/
|
|
11
11
|
import { type ReviewInvocation } from './marker-parser.js';
|
|
12
12
|
export type ReviewVerdict = 'REQUEST_CHANGES' | 'COMMENT';
|
|
13
|
-
export type FindingSeverity = 'critical' | '
|
|
13
|
+
export type FindingSeverity = 'critical' | 'high' | 'medium' | 'low';
|
|
14
14
|
/** Minimal finding shape the builder needs — only severity is required. */
|
|
15
15
|
export interface BuilderFinding {
|
|
16
16
|
severity: FindingSeverity;
|
|
@@ -73,7 +73,7 @@ interface GateBuildInput {
|
|
|
73
73
|
export type BuildInput = BuildInputBase & (LifecycleBuildInput | GateBuildInput);
|
|
74
74
|
/**
|
|
75
75
|
* Map a finding set to the GitHub review verdict: `REQUEST_CHANGES` when any
|
|
76
|
-
* critical or
|
|
76
|
+
* critical or high finding is present, otherwise `COMMENT` (including the
|
|
77
77
|
* zero-findings clean-review case). Never auto-`APPROVE`.
|
|
78
78
|
*/
|
|
79
79
|
export declare function mapVerdict(findings: BuilderFinding[]): ReviewVerdict;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"body-builder.d.ts","sourceRoot":"","sources":["../../src/review-remote/body-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAE3E,MAAM,MAAM,aAAa,GAAG,iBAAiB,GAAG,SAAS,CAAC;AAE1D,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,
|
|
1
|
+
{"version":3,"file":"body-builder.d.ts","sourceRoot":"","sources":["../../src/review-remote/body-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAE3E,MAAM,MAAM,aAAa,GAAG,iBAAiB,GAAG,SAAS,CAAC;AAE1D,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;AAErE,2EAA2E;AAC3E,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,eAAe,CAAC;CAC3B;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,gBAAgB;IAC/B,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,QAAQ,EAAE,eAAe,CAAC;IAC1B,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;CACd;AAED,UAAU,cAAc;IACtB,oDAAoD;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,2CAA2C;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B;;;;;;;;OAQG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,EAAE,CAAC;IACvC,4EAA4E;IAC5E,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;CACjC;AAED,UAAU,mBAAmB;IAC3B,UAAU,EAAE,OAAO,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC;IAC9C,UAAU,CAAC,EAAE,KAAK,CAAC;CACpB;AAED,UAAU,cAAc;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,UAAU,GAAG,cAAc,GACrC,CAAC,mBAAmB,GAAG,cAAc,CAAC,CAAC;AAEzC;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,QAAQ,EAAE,cAAc,EAAE,GAAG,aAAa,CAKpE;AAgFD;;GAEG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,UAAU,GAAG;IAClD,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,aAAa,CAAC;CACxB,CAkCA"}
|
|
@@ -5,25 +5,25 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The body is the durable handoff to `*-receive-remote`: a leading
|
|
7
7
|
* HTML-comment marker block (parsed back by {@link parseMarkerBlock}) followed
|
|
8
|
-
* by human-readable prose (summary, severity counts, optional
|
|
8
|
+
* by human-readable prose (summary, severity counts, optional low-fix nudge,
|
|
9
9
|
* optional verification commands).
|
|
10
10
|
*/
|
|
11
11
|
import { MARKER_BLOCK_OPEN } from './marker-parser.js';
|
|
12
12
|
/**
|
|
13
13
|
* Map a finding set to the GitHub review verdict: `REQUEST_CHANGES` when any
|
|
14
|
-
* critical or
|
|
14
|
+
* critical or high finding is present, otherwise `COMMENT` (including the
|
|
15
15
|
* zero-findings clean-review case). Never auto-`APPROVE`.
|
|
16
16
|
*/
|
|
17
17
|
export function mapVerdict(findings) {
|
|
18
|
-
const hasBlocking = findings.some((f) => f.severity === 'critical' || f.severity === '
|
|
18
|
+
const hasBlocking = findings.some((f) => f.severity === 'critical' || f.severity === 'high');
|
|
19
19
|
return hasBlocking ? 'REQUEST_CHANGES' : 'COMMENT';
|
|
20
20
|
}
|
|
21
21
|
function countSeverities(findings) {
|
|
22
22
|
const counts = {
|
|
23
23
|
critical: 0,
|
|
24
|
-
|
|
24
|
+
high: 0,
|
|
25
25
|
medium: 0,
|
|
26
|
-
|
|
26
|
+
low: 0,
|
|
27
27
|
};
|
|
28
28
|
for (const f of findings) {
|
|
29
29
|
counts[f.severity] += 1;
|
|
@@ -76,9 +76,9 @@ function buildOutOfDiffSection(findings) {
|
|
|
76
76
|
});
|
|
77
77
|
return ['## Findings outside the PR diff', '', ...entries].join('\n');
|
|
78
78
|
}
|
|
79
|
-
const
|
|
80
|
-
'this cycle rather than tracking them as backlog items —
|
|
81
|
-
'faster to just resolve than to manage.';
|
|
79
|
+
const LOW_FIX_NUDGE = 'Low findings are included inline. We recommend fixing low-severity ' +
|
|
80
|
+
'findings during this cycle rather than tracking them as backlog items — ' +
|
|
81
|
+
'they are usually faster to just resolve than to manage.';
|
|
82
82
|
/**
|
|
83
83
|
* Build the posted-review body and compute its verdict.
|
|
84
84
|
*/
|
|
@@ -92,17 +92,17 @@ export function buildReviewBody(input) {
|
|
|
92
92
|
'## Severity Counts',
|
|
93
93
|
'',
|
|
94
94
|
`- Critical: ${counts.critical}`,
|
|
95
|
-
`-
|
|
95
|
+
`- High: ${counts.high}`,
|
|
96
96
|
`- Medium: ${counts.medium}`,
|
|
97
|
-
`-
|
|
97
|
+
`- Low: ${counts.low}`,
|
|
98
98
|
].join('\n'),
|
|
99
99
|
];
|
|
100
100
|
const outOfDiffSection = buildOutOfDiffSection(input.outOfDiffFindings);
|
|
101
101
|
if (outOfDiffSection !== null) {
|
|
102
102
|
sections.push(outOfDiffSection);
|
|
103
103
|
}
|
|
104
|
-
if (counts.
|
|
105
|
-
sections.push(`## Notes\n\n${
|
|
104
|
+
if (counts.low > 0) {
|
|
105
|
+
sections.push(`## Notes\n\n${LOW_FIX_NUDGE}`);
|
|
106
106
|
}
|
|
107
107
|
if (input.verificationCommands && input.verificationCommands.length > 0) {
|
|
108
108
|
const commands = input.verificationCommands
|
|
@@ -33,10 +33,10 @@ import type { NarrowingResult } from './narrowing.js';
|
|
|
33
33
|
export declare const STRUCTURED_OUTPUT_MODE_FLAG: 'oat_output_mode';
|
|
34
34
|
/** The single accepted value of {@link STRUCTURED_OUTPUT_MODE_FLAG}. */
|
|
35
35
|
export declare const STRUCTURED_OUTPUT_MODE_VALUE: 'structured';
|
|
36
|
-
export type FindingSeverity = 'critical' | '
|
|
36
|
+
export type FindingSeverity = 'critical' | 'high' | 'medium' | 'low';
|
|
37
37
|
/** A single structured finding (see design.md → Data Models). */
|
|
38
38
|
export interface StructuredFinding {
|
|
39
|
-
/** Stable per-dispatch ID with a C/
|
|
39
|
+
/** Stable per-dispatch ID with a C/H/M/L prefix. */
|
|
40
40
|
id: string;
|
|
41
41
|
severity: FindingSeverity;
|
|
42
42
|
title: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reviewer-dispatch.d.ts","sourceRoot":"","sources":["../../src/review-remote/reviewer-dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnD;;;;;GAKG;AACH,eAAO,MAAM,2BAA2B,EAAG,iBAA0B,CAAC;AAEtE,wEAAwE;AACxE,eAAO,MAAM,4BAA4B,EAAG,YAAqB,CAAC;AAElE,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,
|
|
1
|
+
{"version":3,"file":"reviewer-dispatch.d.ts","sourceRoot":"","sources":["../../src/review-remote/reviewer-dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnD;;;;;GAKG;AACH,eAAO,MAAM,2BAA2B,EAAG,iBAA0B,CAAC;AAEtE,wEAAwE;AACxE,eAAO,MAAM,4BAA4B,EAAG,YAAqB,CAAC;AAElE,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;AAgBrE,iEAAiE;AACjE,MAAM,WAAW,iBAAiB;IAChC,oDAAoD;IACpD,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,eAAe,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,0DAA0D;IAC1D,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,wEAAwE;AACxE,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAC9B,qBAAqB,EAAE,MAAM,EAAE,CAAC;CACjC;AAED;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;CACpE;AAED,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC,2CAA2C;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,+CAA+C;IAC/C,KAAK,EAAE,MAAM,CAAC;IACd,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,2EAA2E;IAC3E,mBAAmB,EAAE,MAAM,CAAC;IAC5B,6EAA6E;IAC7E,mBAAmB,EAAE,MAAM,CAAC;IAC5B,8EAA8E;IAC9E,SAAS,EAAE,eAAe,CAAC;CAC5B;AAED,iFAAiF;AACjF,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,qBAAqB,GAC7B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAezB;AAsED;;;;GAIG;AACH,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,OAAO,GAAG,kBAAkB,CA2B7E;AAED;;;;;GAKG;AACH,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,qBAAqB,EAC9B,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,kBAAkB,CAAC,CAK7B"}
|
|
@@ -34,10 +34,16 @@ export const STRUCTURED_OUTPUT_MODE_FLAG = 'oat_output_mode';
|
|
|
34
34
|
export const STRUCTURED_OUTPUT_MODE_VALUE = 'structured';
|
|
35
35
|
const SEVERITIES = new Set([
|
|
36
36
|
'critical',
|
|
37
|
-
'
|
|
37
|
+
'high',
|
|
38
38
|
'medium',
|
|
39
|
-
'
|
|
39
|
+
'low',
|
|
40
40
|
]);
|
|
41
|
+
const SEVERITY_ID_PREFIX = {
|
|
42
|
+
critical: 'C',
|
|
43
|
+
high: 'H',
|
|
44
|
+
medium: 'M',
|
|
45
|
+
low: 'L',
|
|
46
|
+
};
|
|
41
47
|
/** Typed error raised when the reviewer returns a malformed structured shape. */
|
|
42
48
|
export class StructuredFindingsError extends Error {
|
|
43
49
|
constructor(message) {
|
|
@@ -72,12 +78,14 @@ function validateFinding(value, index) {
|
|
|
72
78
|
if (!isObject(value)) {
|
|
73
79
|
throw new StructuredFindingsError(`${at} must be an object.`);
|
|
74
80
|
}
|
|
75
|
-
if (typeof value['id'] !== 'string' || value['id'] === '') {
|
|
76
|
-
throw new StructuredFindingsError(`${at}.id must be a non-empty string.`);
|
|
77
|
-
}
|
|
78
81
|
if (typeof value['severity'] !== 'string' ||
|
|
79
82
|
!SEVERITIES.has(value['severity'])) {
|
|
80
|
-
throw new StructuredFindingsError(`${at}.severity must be one of critical|
|
|
83
|
+
throw new StructuredFindingsError(`${at}.severity must be one of critical|high|medium|low.`);
|
|
84
|
+
}
|
|
85
|
+
const severity = value['severity'];
|
|
86
|
+
const expectedIdPattern = new RegExp(`^${SEVERITY_ID_PREFIX[severity]}[1-9]\\d*$`);
|
|
87
|
+
if (typeof value['id'] !== 'string' || !expectedIdPattern.test(value['id'])) {
|
|
88
|
+
throw new StructuredFindingsError(`${at}.id must use the ${SEVERITY_ID_PREFIX[severity]} prefix for ${severity} severity followed by a positive integer.`);
|
|
81
89
|
}
|
|
82
90
|
if (typeof value['title'] !== 'string') {
|
|
83
91
|
throw new StructuredFindingsError(`${at}.title must be a string.`);
|
|
@@ -104,7 +112,7 @@ function validateFinding(value, index) {
|
|
|
104
112
|
}
|
|
105
113
|
return {
|
|
106
114
|
id: value['id'],
|
|
107
|
-
severity
|
|
115
|
+
severity,
|
|
108
116
|
title: value['title'],
|
|
109
117
|
file: fileSet ? file : null,
|
|
110
118
|
line: lineSet ? line : null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-agent-toolkit/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Open Agent Toolkit CLI",
|
|
6
6
|
"homepage": "https://github.com/voxmedia/open-agent-toolkit/tree/main/packages/cli",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"ora": "^9.0.0",
|
|
35
35
|
"yaml": "2.8.2",
|
|
36
36
|
"zod": "^3.25.76",
|
|
37
|
-
"@open-agent-toolkit/control-plane": "0.
|
|
37
|
+
"@open-agent-toolkit/control-plane": "0.3.1"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/node": "^22.10.0",
|