great-cto 3.62.2 → 3.63.0
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/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/work.mjs +27 -1
- package/board/packages/board/public/assets/work-view.js +7 -0
- package/board/packages/board/public/index.html +8 -2
- package/board/scripts/lib/cross-model-review.mjs +20 -35
- package/board/scripts/lib/review-contract.mjs +66 -0
- package/board/scripts/lib/second-opinion-for-tree.mjs +11 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "great-cto",
|
|
3
3
|
"description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.63.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Alexander Velikiy",
|
|
7
7
|
"url": "https://hashnode.com/@Greatcto"
|
|
@@ -16,6 +16,30 @@ const date = v => typeof v === 'string' && Number.isFinite(Date.parse(v)) ? v :
|
|
|
16
16
|
const RUN_PHASE = { ready: 'accepted', 'awaiting-gate': 'needs_decision',
|
|
17
17
|
'awaiting-release': 'needs_decision', blocked: 'blocked', 'manual-action': 'blocked' };
|
|
18
18
|
|
|
19
|
+
/** Recorded observations are not current-tree verification or gate authority. */
|
|
20
|
+
function evidenceSections(entry) {
|
|
21
|
+
const events = entry.inspector?.state === 'recorded' ? entry.inspector.events.length : 0;
|
|
22
|
+
const verdicts = entry.evidence.filter(e => e.kind === 'verdict');
|
|
23
|
+
const reported = verdicts.filter(e => e.verifierReported === true).length;
|
|
24
|
+
const publication = entry.publication;
|
|
25
|
+
const published = ['prepared', 'pushed', 'pr-linked'].includes(publication?.state);
|
|
26
|
+
return [
|
|
27
|
+
{ id: 'execution', label: 'Execution', state: events ? 'recorded' : 'unknown',
|
|
28
|
+
detail: events ? `${events} linked activity events in the bounded window. Liveness and task acceptance are not established.${entry.inspector.partial ? ' The activity window is incomplete.' : ''}`
|
|
29
|
+
: entry.inspector?.reason || 'No explicitly linked execution events are available.' },
|
|
30
|
+
{ id: 'checks', label: 'Checks', state: verdicts.length ? 'recorded' : 'unknown',
|
|
31
|
+
detail: verdicts.length ? `In this bounded view: ${verdicts.length} role verdict records; ${reported} verifier-success reports. Test counts and current input bindings are not available in this projection.`
|
|
32
|
+
: 'No task-linked check records are available. Completed roles do not establish verification.' },
|
|
33
|
+
{ id: 'review', label: 'Review', state: 'unknown',
|
|
34
|
+
detail: 'No task-bound review input digest and reviewer provenance are available. Role verdicts alone do not establish independent review.' },
|
|
35
|
+
{ id: 'publication', label: 'Publication', state: published ? 'recorded' : 'unknown',
|
|
36
|
+
detail: published ? `Publication state: ${publication.state}${/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(publication.head || '') ? `; recorded head: ${publication.head}` : ''}. This does not establish merge, release or deployment.`
|
|
37
|
+
: 'No task-linked publication record is available.' },
|
|
38
|
+
{ id: 'runtime', label: 'Runtime', state: 'unknown',
|
|
39
|
+
detail: 'No task-linked runtime probe is available. Release records and successful process exits do not establish deployment health.' },
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
19
43
|
export function projectWork({ projectId, issues = [], codex = { state: 'absent', runs: [] },
|
|
20
44
|
tasks = [], sessions = [], sources = [], activity = { state: 'none', events: [] }, observedAt = new Date().toISOString() }) {
|
|
21
45
|
const entries = [];
|
|
@@ -78,7 +102,8 @@ export function projectWork({ projectId, issues = [], codex = { state: 'absent',
|
|
|
78
102
|
host: t.host, phase: t.phase, nativeState: run?.nativeState || t.phase,
|
|
79
103
|
terminal: ['cancelled', 'completed'].includes(t.phase), updatedAt: t.updatedAt, outcome: t.outcome?.state === 'completed' ? `${t.intent || 'delivery'} outcome explicitly completed` : null, reason: t.reason,
|
|
80
104
|
intent: t.intent || 'delivery', stage: t.stage || null, taskOutcome: t.outcome || null,
|
|
81
|
-
decisions: decisionCapabilities(t).map(d => ({ ...d, id: d.decisionId, label: d.label || 'Host decision' })), evidence: (t.evidence || []).
|
|
105
|
+
decisions: decisionCapabilities(t).map(d => ({ ...d, id: d.decisionId, label: d.label || 'Host decision' })), evidence: (t.evidence || []).slice(0, 200).filter(e => e && text(e.role)).map(e => ({ kind: 'verdict',
|
|
106
|
+
label: `${e.role.slice(0, 80)}: ${text(e.verdict)?.slice(0, 80) || 'not recorded'}`, verifierReported: typeof e.verified === 'boolean' ? e.verified : null })),
|
|
82
107
|
release: run?.release || null, revision: t.revision, metrics: t.metrics,
|
|
83
108
|
inspector: taskInspector([...t.links.runs, ...t.links.sessions], activity),
|
|
84
109
|
publication: t.publication || null,
|
|
@@ -90,6 +115,7 @@ export function projectWork({ projectId, issues = [], codex = { state: 'absent',
|
|
|
90
115
|
command: approveCommand || (enabled ? `great-cto resume --task ${t.taskId} --host ${t.host} --revision ${t.revision}` : null),
|
|
91
116
|
});
|
|
92
117
|
}
|
|
118
|
+
for (const entry of entries) entry.evidenceSections = evidenceSections(entry);
|
|
93
119
|
entries.sort((a, b) => (a.terminal - b.terminal) || (b.decisions.length - a.decisions.length)
|
|
94
120
|
|| (Date.parse(b.updatedAt || '') || 0) - (Date.parse(a.updatedAt || '') || 0) || a.key.localeCompare(b.key));
|
|
95
121
|
const health = sources.some(s => ['degraded', 'unavailable'].includes(s.health)) ? 'degraded' : 'current';
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
+ action + (e.command ? `<pre>${esc(e.command)}</pre>` : '')
|
|
29
29
|
+ (!cap && e.capabilities[0]?.reason ? `<p>${esc(e.capabilities[0].reason)}</p>` : '')
|
|
30
30
|
+ renderInspector(e.inspector)
|
|
31
|
+
+ renderEvidence(e.evidenceSections)
|
|
31
32
|
+ (e.publicationPreview ? `<label>Publication paths (comma-separated) <input data-publication-allow="${esc(e.key)}" value="${esc(e.publicationAllow || '')}" placeholder="src,tests"></label><button type="button" class="gate-btn" data-work-key="${esc(e.key)}" data-work-action="preview_publication">Preview draft PR publication</button><div data-publication-preview="${esc(e.key)}"></div>` : '')
|
|
32
33
|
+ (e.publication ? `<p>PR publication: ${esc(e.publication.state)} · ${esc(e.publication.head || '')}</p>`
|
|
33
34
|
+ (typeof e.publication.url === 'string' && /^https:\/\/github\.com\/[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9][0-9]*$/.test(e.publication.url) ? `<a href="${esc(e.publication.url)}" target="_blank" rel="noopener noreferrer">Open draft PR result</a>` : '')
|
|
@@ -56,6 +57,12 @@
|
|
|
56
57
|
+ (e.durationMs !== null ? ` · ${esc(e.durationMs)} ms` : '') + '</li>').join('') + '</ol>' : '')
|
|
57
58
|
+ '</details>';
|
|
58
59
|
}
|
|
60
|
+
function renderEvidence(sections) {
|
|
61
|
+
if (!Array.isArray(sections)) return '';
|
|
62
|
+
return '<details data-work-detail="evidence"><summary>Task evidence · read-only</summary>'
|
|
63
|
+
+ '<p>Recorded means an observation exists, not that the current code passed a gate. Unknown means evidence is unavailable, not zero or success.</p>'
|
|
64
|
+
+ '<dl>' + sections.map(s => `<dt>${esc(s.label)} · ${s.state === 'recorded' ? 'Recorded' : 'Unknown'}</dt><dd>${esc(s.detail)}</dd>`).join('') + '</dl></details>';
|
|
65
|
+
}
|
|
59
66
|
function publicationCommand(preview) {
|
|
60
67
|
const confirmation = preview.confirmation || { expectedRevision: preview.revision, approval: preview.approval };
|
|
61
68
|
if (!/^[0-9a-f-]{36}$/.test(preview.taskId || '') || !Number.isInteger(confirmation.expectedRevision) || confirmation.expectedRevision < 1
|
|
@@ -3378,6 +3378,8 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3378
3378
|
.work-card h2 { font-size: var(--fs-title-s); }
|
|
3379
3379
|
.work-card p, #work-compose p { margin: 8px 0; font-size: var(--fs-body); }
|
|
3380
3380
|
.work-meta { color: var(--text2); font-size: var(--fs-caption); }
|
|
3381
|
+
.work-card [data-work-detail="evidence"] dt { margin-top: 12px; font-weight: 600; }
|
|
3382
|
+
.work-card [data-work-detail="evidence"] dd { margin: 4px 0 12px; color: var(--text2); font-size: var(--fs-body); overflow-wrap: anywhere; }
|
|
3381
3383
|
.work-warning { color: var(--absent-warn-fg); background: var(--bg-card); border: 1px solid var(--absent-warn-fg); padding: 12px; }
|
|
3382
3384
|
.work-page pre { font-weight: 400; white-space: pre-wrap; overflow-wrap: anywhere; margin: 12px 0; }
|
|
3383
3385
|
#work-compose textarea, #work-compose input, #work-compose select { display: block; width: 100%; margin: 8px 0 12px; padding: 8px; background: var(--bg-muted); color: var(--text); border: 1px solid var(--border-strong); border-radius: 4px; }
|
|
@@ -4224,8 +4226,12 @@ function reviewerCells(t) {
|
|
|
4224
4226
|
if (!so || so.state === 'not-run') codex = `<span class="abs" title="${esc(so?.why || 'second opinion not run')}">— Codex</span>`;
|
|
4225
4227
|
else if (so.state === 'unmeasured') codex = `<span class="abs" title="${esc(so.why)}">· Codex</span>`;
|
|
4226
4228
|
else if (so.state === 'unreadable') codex = `<span class="abs" title="${esc(so.why)}">! Codex</span>`;
|
|
4227
|
-
else if (so.
|
|
4228
|
-
else codex = `<span title="Codex ${esc(so.
|
|
4229
|
+
else if (so.state === 'unreviewable') codex = `<span class="abs" title="${esc(so.why)}">! Codex</span>`;
|
|
4230
|
+
else if (so.state === 'ok' && so.verdict === 'BLOCK') codex = `<span title="Codex BLOCK on ${esc((so.sha || '').slice(0, 8))} — ${esc(String(so.p0 ?? '?'))}×P0"><span class="dot bad"></span> Codex ✗</span>`;
|
|
4231
|
+
// A tick only for a review that passed — never as the fallback for a state
|
|
4232
|
+
// this renderer does not know (an `unreviewable` diff once fell through to ✓).
|
|
4233
|
+
else if (so.state === 'ok' && so.verdict === 'PASS') codex = `<span title="Codex PASS on ${esc((so.sha || '').slice(0, 8))}"><span class="dot ok"></span> Codex ✓</span>`;
|
|
4234
|
+
else codex = `<span class="abs" title="${esc(so.why || `second opinion: ${so.state || 'unknown'}, verdict ${so.verdict ?? 'none'}`)}">? Codex</span>`;
|
|
4229
4235
|
return `<span class="gate-review">${claude}${codex}</span>`;
|
|
4230
4236
|
}
|
|
4231
4237
|
function gateChip(t) {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// scripts/lib/cross-model-review.mjs — cross-model adversarial review (architect-loop R3).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// the diff with a DIFFERENT model via OpenRouter (default openai/gpt-5), flagging
|
|
3
|
+
// Review the supplied diff through the configured second-opinion provider, flagging
|
|
5
4
|
// ONLY correctness / requirement / invariant gaps with file:line — no style. The
|
|
6
5
|
// code-reviewer agent merges these with its own findings for high-stakes changes.
|
|
6
|
+
// Provider/model metadata alone does not establish cross-model independence.
|
|
7
7
|
//
|
|
8
8
|
// Pure (buildReviewPrompt / parseFindings / pickReviewerModel) is unit-tested with
|
|
9
9
|
// no network; the CLI does the live OpenRouter call.
|
|
@@ -21,6 +21,7 @@ import { resolveSecondOpinion, codexReview } from './second-opinion.mjs';
|
|
|
21
21
|
import { principalError } from './provider-exhaustion.mjs';
|
|
22
22
|
import { existsSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
|
+
import { reviewInputBinding, parseLegacyReview } from './review-contract.mjs';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Exit codes. The first version had two: 0 for PASS and 1 for everything else —
|
|
@@ -64,13 +65,14 @@ export function decideProvider({ argv = [], projectMd = '', codex = null, env =
|
|
|
64
65
|
* written before this field existed, and any caller that doesn't supply them,
|
|
65
66
|
* still serializes to the same shape a reader already knows how to parse.
|
|
66
67
|
*/
|
|
67
|
-
export function reviewLogLine({ provider, model, state, verdict, findings, cost, source, error_kind, resets_at, sha, dirty }) {
|
|
68
|
+
export function reviewLogLine({ provider, model, state, verdict, findings, cost, source, error_kind, resets_at, sha, dirty, review_input }) {
|
|
68
69
|
return JSON.stringify({
|
|
69
70
|
ts: new Date().toISOString(), provider, model: model ?? null, state, verdict: verdict ?? null,
|
|
70
71
|
error_kind: error_kind ?? null, resets_at: resets_at ?? null,
|
|
71
72
|
findings: Array.isArray(findings) ? findings.length : null,
|
|
72
73
|
p0: Array.isArray(findings) ? findings.filter((f) => f.severity === 'P0').length : null,
|
|
73
74
|
cost: cost ?? null, source, sha: sha ?? null, dirty: dirty ?? null,
|
|
75
|
+
review_input: review_input ?? null,
|
|
74
76
|
});
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -84,8 +86,8 @@ export function pickReviewerModel(env = process.env) {
|
|
|
84
86
|
/** Build the red-team prompt. Calibrated: correctness/invariant only, file:line, no style. */
|
|
85
87
|
export function buildReviewPrompt({ diff, spec }) {
|
|
86
88
|
const system =
|
|
87
|
-
'You are an adversarial code reviewer
|
|
88
|
-
'
|
|
89
|
+
'You are an adversarial code reviewer. Do not assume your model family differs from the author. ' +
|
|
90
|
+
'Review ONLY for: ' +
|
|
89
91
|
'correctness bugs, violated requirements, broken invariants, security holes, data loss. ' +
|
|
90
92
|
'Do NOT report style, naming, or preferences. Ground every finding in the diff with file:line. ' +
|
|
91
93
|
'Default to silence over a weak finding. ' +
|
|
@@ -102,32 +104,7 @@ export function buildReviewPrompt({ diff, spec }) {
|
|
|
102
104
|
|
|
103
105
|
/** Parse the model's findings + verdict. */
|
|
104
106
|
export function parseFindings(text) {
|
|
105
|
-
|
|
106
|
-
let verdict = null;
|
|
107
|
-
for (const raw of String(text).split('\n')) {
|
|
108
|
-
const line = raw.trim();
|
|
109
|
-
const v = line.match(/^VERDICT:\s*(BLOCK|PASS)/i);
|
|
110
|
-
if (v) { verdict = v[1].toUpperCase(); continue; }
|
|
111
|
-
// <file>:<line> | <SEV> | <issue>
|
|
112
|
-
const m = line.match(/^(.+?):(\d+)\s*\|\s*(P[012])\s*\|\s*(.+)$/i);
|
|
113
|
-
if (m) findings.push({ file: m[1].trim(), line: parseInt(m[2], 10), severity: m[3].toUpperCase(), issue: m[4].trim() });
|
|
114
|
-
}
|
|
115
|
-
// Derive a verdict only in the direction that is safe to be wrong about.
|
|
116
|
-
//
|
|
117
|
-
// This used to answer PASS whenever no `VERDICT:` line was found and no P0
|
|
118
|
-
// was parsed. But an empty body, a refusal, a reply in some other format and
|
|
119
|
-
// a model that genuinely said "looks clean" are the SAME input here — all
|
|
120
|
-
// four leave `verdict` null and `findings` without a P0. Reading any of them
|
|
121
|
-
// as PASS reads all of them as PASS, so a review that could not be read
|
|
122
|
-
// became a review that approved, and the Stop hook downstream ended the turn
|
|
123
|
-
// on it.
|
|
124
|
-
//
|
|
125
|
-
// A derivation that BLOCKS costs a second look. A derivation that PASSES
|
|
126
|
-
// spends the guarantee. So: a P0 with no verdict line still blocks; nothing
|
|
127
|
-
// else is turned into a verdict, and `null` means the model never reached
|
|
128
|
-
// one. Callers must treat null as "no verdict", never as a pass.
|
|
129
|
-
if (!verdict && findings.some(f => f.severity === 'P0')) verdict = 'BLOCK';
|
|
130
|
-
return { findings, verdict };
|
|
107
|
+
return parseLegacyReview(text);
|
|
131
108
|
}
|
|
132
109
|
|
|
133
110
|
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
@@ -178,8 +155,9 @@ async function main(argv) {
|
|
|
178
155
|
const projectMd = existsSync(mdPath) ? readFileSync(mdPath, 'utf8') : '';
|
|
179
156
|
const decision = decideProvider({ argv, projectMd });
|
|
180
157
|
const identity = gitIdentity(root);
|
|
158
|
+
let review_input = null;
|
|
181
159
|
const logPath = join(root, '.great_cto', 'cross-review.log');
|
|
182
|
-
const log = (rec) => { try { mkdirSync(join(root, '.great_cto'), { recursive: true }); appendFileSync(logPath, reviewLogLine({ ...rec, source: decision.source, sha: identity.sha, dirty: identity.dirty }) + '\n'); } catch { /* the log is evidence, not a gate */ } };
|
|
160
|
+
const log = (rec) => { try { mkdirSync(join(root, '.great_cto'), { recursive: true }); appendFileSync(logPath, reviewLogLine({ ...rec, source: decision.source, sha: identity.sha, dirty: identity.dirty, review_input }) + '\n'); } catch { /* the log is evidence, not a gate */ } };
|
|
183
161
|
|
|
184
162
|
// Anything that is not a reviewer reviewing exits SKIPPED — not PASS, and not
|
|
185
163
|
// the BLOCK code either. The line says why, and the log keeps it.
|
|
@@ -190,10 +168,17 @@ async function main(argv) {
|
|
|
190
168
|
}
|
|
191
169
|
|
|
192
170
|
const diff = diffPath === '-' ? readFileSync(0, 'utf8') : readFileSync(diffPath, 'utf8');
|
|
193
|
-
if (!diff.trim()) { console.log('cross-model-review: empty diff, nothing to review.'); process.exit(EXIT.PASS); }
|
|
194
171
|
const specFile = readArg(argv, '--spec');
|
|
195
|
-
const spec = specFile ? readFileSync(specFile, 'utf8')
|
|
196
|
-
|
|
172
|
+
const spec = specFile ? readFileSync(specFile, 'utf8') : null;
|
|
173
|
+
review_input = reviewInputBinding({ diff, spec, sourceSha: identity.sha });
|
|
174
|
+
// A truncated prefix is not a review of the full input. Never silently approve it.
|
|
175
|
+
if (!diff.trim() || diff.length > 24000 || (spec !== null && spec.length > 4000)) {
|
|
176
|
+
const error_kind = !diff.trim() ? 'empty-diff' : 'input-too-large';
|
|
177
|
+
console.log(`cross-model-review: SKIPPED (${error_kind}) — complete review input required.`);
|
|
178
|
+
log({ provider: decision.provider, state: 'unreadable', verdict: null, findings: null, cost: null, error_kind });
|
|
179
|
+
process.exit(EXIT.SKIPPED);
|
|
180
|
+
}
|
|
181
|
+
const prompt = buildReviewPrompt({ diff, spec });
|
|
197
182
|
|
|
198
183
|
let res;
|
|
199
184
|
if (decision.provider === 'codex') {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Host-neutral review evidence. Pure data contract, never gate authority. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export const REVIEW_CONTRACT_VERSION = 1;
|
|
5
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
6
|
+
const digest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
7
|
+
|
|
8
|
+
/** Bind the exact submitted UTF-8 inputs and caller context, without storing bodies. */
|
|
9
|
+
export function reviewInputBinding({ diff, spec = null, taskId = null, sourceSha = null } = {}) {
|
|
10
|
+
if (typeof diff !== 'string' || (spec !== null && typeof spec !== 'string')
|
|
11
|
+
|| (taskId !== null && (typeof taskId !== 'string' || taskId.length > 256))
|
|
12
|
+
|| (sourceSha !== null && !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(sourceSha))) throw Error('invalid review input');
|
|
13
|
+
const binding = { version: REVIEW_CONTRACT_VERSION, taskId, sourceSha,
|
|
14
|
+
diffSha256: hash(diff), diffBytes: Buffer.byteLength(diff),
|
|
15
|
+
specSha256: spec === null ? null : hash(spec), specBytes: spec === null ? null : Buffer.byteLength(spec) };
|
|
16
|
+
return { ...binding, inputDigest: hash(JSON.stringify(binding)) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Normalize trusted adapter metadata plus untrusted reviewer output. */
|
|
20
|
+
export function normalizeReviewResult(options = {}) {
|
|
21
|
+
const { inputDigest, expectedInputDigest, reviewer,
|
|
22
|
+
state = 'ok', verdict = null, findings = null } = options && typeof options === 'object' ? options : {};
|
|
23
|
+
const identity = { host: typeof reviewer?.host === 'string' ? reviewer.host : null,
|
|
24
|
+
model: typeof reviewer?.model === 'string' ? reviewer.model : null };
|
|
25
|
+
const unknown = reason => ({ version: REVIEW_CONTRACT_VERSION, state: 'not-checked',
|
|
26
|
+
verdict: null, reason, inputDigest: digest(inputDigest) ? inputDigest : null,
|
|
27
|
+
reviewer: identity, findings: [] });
|
|
28
|
+
if (!digest(inputDigest) || !digest(expectedInputDigest) || inputDigest !== expectedInputDigest) return unknown('input-mismatch');
|
|
29
|
+
if (state !== 'ok') return unknown('adapter-unavailable');
|
|
30
|
+
if (!['PASS', 'BLOCK'].includes(verdict) || !Array.isArray(findings) || findings.length > 200) return unknown('invalid-result');
|
|
31
|
+
const valid = Array.from(findings).every(f => f && typeof f === 'object'
|
|
32
|
+
&& typeof f.file === 'string' && f.file.length > 0 && f.file.length <= 1024
|
|
33
|
+
&& !/^[\\/]|^[A-Za-z]:|[\\\x00-\x1f]/.test(f.file)
|
|
34
|
+
&& f.file.split('/').every(part => part && part !== '.' && part !== '..')
|
|
35
|
+
&& Number.isSafeInteger(f.line) && f.line > 0
|
|
36
|
+
&& ['P0', 'P1', 'P2'].includes(f.severity)
|
|
37
|
+
&& typeof f.issue === 'string' && f.issue.trim().length > 0 && f.issue.length <= 4000);
|
|
38
|
+
if (!valid) return unknown('invalid-findings');
|
|
39
|
+
const result = findings.map(({ file, line, severity, issue }) => ({ file, line, severity, issue }));
|
|
40
|
+
return { version: REVIEW_CONTRACT_VERSION, state: 'checked',
|
|
41
|
+
verdict: result.some(f => f.severity === 'P0') ? 'BLOCK' : verdict,
|
|
42
|
+
reason: result.some(f => f.severity === 'P0') && verdict === 'PASS' ? 'p0-overrides-pass' : null,
|
|
43
|
+
inputDigest, reviewer: identity, findings: result };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Legacy line protocol adapter; ambiguity never becomes approval. */
|
|
47
|
+
export function parseLegacyReview(text) {
|
|
48
|
+
if (typeof text !== 'string' || Buffer.byteLength(text) > 1024 * 1024) return { findings: [], verdict: null };
|
|
49
|
+
const findings = [], verdicts = []; let malformed = false;
|
|
50
|
+
for (const raw of text.split('\n')) {
|
|
51
|
+
const line = raw.trim(), v = line.match(/^VERDICT:\s*(BLOCK|PASS)$/i);
|
|
52
|
+
if (v) { verdicts.push(v[1].toUpperCase()); continue; }
|
|
53
|
+
const m = line.match(/^(.+?):(\d+)\s*\|\s*(P[012])\s*\|\s*(.+)$/i);
|
|
54
|
+
// Protocol keywords inside a complete finding are diagnostic text, not verdict lines.
|
|
55
|
+
if (m) { findings.push({ file: m[1].trim(), line: Number(m[2]), severity: m[3].toUpperCase(), issue: m[4].trim() }); continue; }
|
|
56
|
+
if (/\bVERDICT\b/i.test(line) || /\|\s*P\d/i.test(line) || /^.+?:[^|]*\|/.test(line) || /\|[^|]*\|/.test(line)) malformed = true;
|
|
57
|
+
}
|
|
58
|
+
// No verdict may conservatively block a concrete P0, never pass.
|
|
59
|
+
const verdict = malformed || verdicts.length > 1 ? null
|
|
60
|
+
: verdicts[0] || (findings.some(f => f.severity === 'P0') ? 'BLOCK' : null);
|
|
61
|
+
const pin = hash('legacy-adapter');
|
|
62
|
+
const normalized = normalizeReviewResult({ inputDigest: pin, expectedInputDigest: pin, verdict, findings });
|
|
63
|
+
// Preserve valid parsed findings for diagnostics when the verdict is missing.
|
|
64
|
+
const validation = normalizeReviewResult({ inputDigest: pin, expectedInputDigest: pin, verdict: 'BLOCK', findings });
|
|
65
|
+
return { findings: validation.findings, verdict: normalized.verdict };
|
|
66
|
+
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// not-run — no second opinion is declared (including a deliberate `none`)
|
|
13
13
|
// unmeasured — declared, but nothing has reviewed THIS tree
|
|
14
14
|
// unreadable — lines exist and none can be paired (they predate the join key)
|
|
15
|
+
// unreviewable — the review refused this tree's input (empty, or over its size
|
|
16
|
+
// limit) — no reviewer read it, and re-running will not change that
|
|
15
17
|
import fs from 'node:fs';
|
|
16
18
|
import path from 'node:path';
|
|
17
19
|
import { execFileSync } from 'node:child_process';
|
|
@@ -48,6 +50,15 @@ export function secondOpinionForTree(c) {
|
|
|
48
50
|
const noVerdict = rows.filter((r) => joins(r) && r.state === 'unreadable');
|
|
49
51
|
if (noVerdict.length) {
|
|
50
52
|
const r = noVerdict[noVerdict.length - 1];
|
|
53
|
+
// A refusal before any reviewer was asked (ADR-030: no review of a truncated
|
|
54
|
+
// prefix) is not an answer. Saying "answered without a verdict" sent the
|
|
55
|
+
// agent to re-run a review that refuses the same input every time.
|
|
56
|
+
if (r.error_kind === 'input-too-large' || r.error_kind === 'empty-diff') {
|
|
57
|
+
return { ...base, state: 'unreviewable', error_kind: r.error_kind, sha: r.sha, ts: r.ts ?? null,
|
|
58
|
+
why: r.error_kind === 'empty-diff'
|
|
59
|
+
? 'the diff was empty — there was nothing to review'
|
|
60
|
+
: `the diff is too large for ${tool} to review whole (over 24 000 characters of diff or 4 000 of spec) — no reviewer read it` };
|
|
61
|
+
}
|
|
51
62
|
return { ...base, state: 'unreadable', sha: r.sha, ts: r.ts ?? null,
|
|
52
63
|
why: `${tool} answered for this tree and the answer carries no verdict — it is not a pass` };
|
|
53
64
|
}
|
package/package.json
CHANGED