diff --git a/bin/gstack-review-dispatch b/bin/gstack-review-dispatch index 42c3df4d1..06b9414fb 100755 --- a/bin/gstack-review-dispatch +++ b/bin/gstack-review-dispatch @@ -16,10 +16,11 @@ * lib/review-pass.ts (the staged manifest) and lib/capy-review-pass.js (the Capy program that runs it). */ import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; -import { BRIEF_ROLES, BriefError, briefFile, checkReviewedTree, checkoutRoot, checkoutTree, promptHash, renderBrief, sectionTexts, withWorkspace, type BriefRole, type Slots, type TreeCheck } from '../lib/review-brief'; +import { BRIEF_ROLES, BriefError, briefFile, checkReviewedTree, checkoutRoot, checkoutTree, promptHash, renderBrief, sectionTexts, sliceDiff, sliceNote, withWorkspace, type BriefRole, type Slots, type TreeCheck } from '../lib/review-brief'; import { cleanMarkers, collect, mayBeUnavailable, parseFindings, readJsonl, readLedger, readerKey, recordJudgment, recordSent, appendEvent, verify, type JudgedLine, type MergedLine, type VerifyRow } from '../lib/review-ledger'; import { buildManifest, chunkCount, chunkOf, manifestPath, markReader, readManifest, renderStage, writeManifest, STAGES, type Extra, type Manifest, type PassReader, type ReaderState, type Stage } from '../lib/review-pass'; import type { Dispatch } from '../lib/review-depth'; @@ -132,8 +133,9 @@ function render(): number { if (diff !== undefined) { fs.mkdirSync(out, { recursive: true, mode: 0o700 }); const f = path.join(path.resolve(out), 'outside-voice.diff'); - fs.writeFileSync(f, diff, { mode: 0o600 }); - diffFile = { diff_file: f, diff_bytes: String(Buffer.byteLength(diff)), diff_sha256: createHash('sha256').update(diff).digest('hex') }; + const s = sliceDiff(diff, REVIEW_DIFF_MAX_BYTES); + fs.writeFileSync(f, s.text, { mode: 0o600 }); + diffFile = { diff_file: f, diff_bytes: String(s.bytes), diff_sha256: createHash('sha256').update(s.text).digest('hex'), ...(s.sliced ? { diff_slice: sliceNote(s, REVIEW_DIFF_MAX_BYTES) } : {}) }; } const slots: Slots = { name, diff_base: diffBase, exclude: flag('--exclude'), stack: flag('--stack'), test_fw: flag('--test-fw'), @@ -186,7 +188,7 @@ function ceiling(context: string, base: string, outDir: string, readers: Readonl const at = r.diff_base ?? diffBase; const d = selectionOf(at, r.exclude, r.keep); if (!d.ok) return { exit: EXIT.fail, line: unreadableLine(context, r.reader, d, at, base, outDir) }; - if (d.bytes > REVIEW_DIFF_MAX_BYTES) return { exit: EXIT.refused, line: tooLargeLine(context, r.reader, d, ceilingContext(process.cwd(), base, known)).line }; + if (d.bytes > REVIEW_DIFF_MAX_BYTES && r.reader !== 'outside-voice') return { exit: EXIT.refused, line: tooLargeLine(context, r.reader, d, ceilingContext(process.cwd(), base, known)).line }; } return null; } diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index c2aa27a7f..2fda0afcb 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -49,7 +49,7 @@ import { } from '../lib/ship-receipt'; import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs'; import { comparePath, contextPacket, PACKET_SECTION_CAP, readScopedReview, touchedPaths } from '../lib/ship-patch'; -import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptExclude, receiptReviewAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; +import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, platformReceiptDepth, receiptExclude, receiptReviewAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose'; import { resolveStateRoot } from '../lib/state-root'; import { readPause, receiptBaseRed } from '../lib/base-red'; @@ -99,6 +99,9 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate (the digest= on gstack-review-dispatch verify's REVIEW_EXCLUDE_RECORD: line, which the reviewers' briefs name; none without that line): must equal the digest HEAD has under the base policy's review_exclude_paths, else RECEIPT_INVALID field=review_exclude and nothing is written (rerun the review; write alone cannot clear it); recorded as review_exclude. Carry and the queue compare that digest. + --platform-review (NEXT: platform's PLATFORM_REVIEW: id): records review_depth platform with capy-ai[bot]'s review at HEAD; + refused (RECEIPT_INVALID) unless the base policy has review.platform on, HEAD needs light review of a docs or tests diff with + no numeric, results or prereg claims, and that review is on the PR at HEAD. review_depth.mode records lean, panel or platform. --review-targeted ({classes, rules, brief_sha, status, findings, raised_by}: gstack-review-dispatch verify --targeted-out); recorded as review_targeted bound to the receipt's product_patch_id. Without it the receipt has no review_targeted, which the queue reports as legacy. @@ -222,7 +225,13 @@ function cmdWrite(rest: string[], json: boolean): number { if (bodyFile && !bodyText.split(/\r?\n/).some(l => l.trim() === line)) return refuse(`${bodyFile} does not carry the exception line verbatim: ${JSON.stringify(line)}`); } const review = receiptReviewAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head); - const reviewDepth = review.depth; + let reviewDepth = review.depth; + const platformId = flag(rest, '--platform-review'); + if (platformId !== undefined) { + const p = platformReceiptDepth(repoRoot, base, reviewDepth, headSha ?? head, platformId, flag(rest, '--pr')); + if (!p.ok) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${p.message}; nothing written`) + '\n'); return EXIT.fail; } + reviewDepth = p.depth; + } if (review.exclude.state === 'refused') { for (const l of review.exclude.lines) process.stderr.write(`${l}; nothing written\n`); return EXIT.refused; } const excludeNow = review.exclude.state === 'active' ? review.exclude.record : null; const excludeGiven = flag(rest, '--review-exclude') ?? 'none'; diff --git a/changelog.d/gsta258-c-review-depth.md b/changelog.d/gsta258-c-review-depth.md new file mode 100644 index 000000000..34bc4d15b --- /dev/null +++ b/changelog.d/gsta258-c-review-depth.md @@ -0,0 +1,13 @@ +**/ship's review now scales to risk: risk-class code keeps the full panel, everything else gets one strong reader, and a repo with platform review can skip /ship's readers on plain docs (GSTA-258 build C).** Across 153 reviewed units, docs and tests PRs produced no proven review blocker, and non-risk code got most of its proven blockers from the native adversarial pass. Risk-class code (money, auth, secrets, security, user data, eval scorers and the merge checks) found 33 of its 110 proven blockers in a second or third pass, so it keeps the full panel and a full second round. + +### Added +- **A `review` key in `.gstack/ship-policy.json`**, read from the base branch only (needs `min_gstack_version` at the release that adds it): `panel` (`risk` by default, `off` or `all`), `reader_model` (`lane` by default), `platform` (`off` by default) and `risk_paths` (globs of code paths that count as risk class `policy`). +- **`gstack-review-plan` prints `REVIEW_MODE:`** and, under `review.platform on`, `PLATFORM_REVIEW:`. `DISPATCH:` lines name the reader's `model=` and `classes=`. New flags: `--fix-checks ` and `--pr `. +- **The platform depth.** Under `review.platform on`, a docs or tests diff with no numeric, results or prereg claims, whose PR carries capy-ai[bot]'s review at the exact head, plans `NEXT: platform` with a planned `SKIP:` for every reader. Step 11.5 accepts it, and `gstack-ship-receipt write --platform-review ` records `review_depth: platform` with that review's id and head. It never applies to runtime code, and never where platform review is off. +- **New risk classes**: `eval` (scorer, judge and grader paths) and `policy` (`review.risk_paths`). A docs line that can print a credential (an `Authorization:` header, a bearer value, a key-shaped literal, `mcp get`) is security risk (gbrain#8417). +- **The receipt records `review_depth.mode`** (lean, panel or platform). + +### Changed +- **Non-risk code gets one native adversarial reader** at the lane's reasoning, carrying the code checklists and the Red Team checklist, in place of the batched specialist plus the native pass. One fix-check follows a fixed blocker; below-floor fixes ship as notes. `review.panel all` restores the old plan and loop everywhere. +- **Fewer false risk triggers** (harness-evals#231): committed `*.sqlite` and other binary data files count as data, never code; JSON, YAML and other config files are judged by their path, so a `lease_usd` value no longer means money; analysis scripts under `docs/`, `results/` or `benchmarks/` are judged by their path. +- The outside voice reads its diff from a file the brief names with its sha256, instead of a diff pasted into the prompt. Past 2,000,000 bytes it reads a path-scoped slice whose brief lists the files it left out, instead of stopping the pass. diff --git a/docs/review-policy.md b/docs/review-policy.md index 8bb8093e5..2bffa673e 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -179,6 +179,38 @@ with its effort and a shell-quoted `DIFF_CMD:` (plus `DIFF_EXCLUDE: none` reads everything), one `SKIP:` per reviewer left out, and `--json` with the same fields. A plan that fails closed to full exits 0; a diff git cannot list exits 1. +### Who reads (R65, the base policy's `review` key) + +The depth says how much review a diff needs; the base branch's `.gstack/ship-policy.json` +`review` key says who does it (`review.panel`, default `risk`). `gstack-review-plan` +prints `REVIEW_MODE: panel=

reader_model= platform=` +and, under `review.platform on`, `PLATFORM_REVIEW: reviewer=capy-ai[bot] head=` +or `PLATFORM_REVIEW: none ()`. + +| Diff | Pass 1 | After fixes (never more than 2 rounds) | +|---|---|---| +| Risk class: gate class, an R51 class (money, auth, secret, security, data), `eval` (scorer, judge and grader paths) or `policy` (`review.risk_paths`) | The full-depth plan as printed: the batched specialist, Red Team, native adversarial, outside voice and the targeted reader | A fixed blocker re-runs the printed plan on the fixed head as round 2; below-floor fixes get one fix-check | +| Non-risk code | One native adversarial reader (`DISPATCH: adversarial-native single`) at the lane's reasoning, on `review.reader_model` (`lane` by default), carrying the code checklists and the Red Team checklist; it also carries the targeted brief when the diff has a targeted class | A fixed blocker or a gate-class edit gets one fix-check on the touched hunks; below-floor fixes ship as notes | +| Docs and tests | The core read; the outside voice only when the diff publishes numbers | One fix-check after a fixed blocker | +| Docs or tests with no numeric, results or prereg claims, under `review.platform on`, with capy-ai[bot]'s review on the PR at HEAD | No reader: `NEXT: platform` and a planned `SKIP:` for every reader, the core read included | none | + +`review.panel all` keeps today's panel and loop on every class; `review.panel off` gives +risk class one merged reader too (`DISPATCH: adversarial-native merged`: the native +adversarial brief, the gate-class checklists, the Red Team checklist and the targeted +brief, whose ledger record is the receipt's `review_targeted`). The risk classes come +from paths and changed lines, never from a recorded `review_depth` (FOUN-64). Two +content rules: data and config files (JSON, YAML, TOML, CSV and the like) and analysis +scripts under `docs/`, `results/`, `benchmarks/`, `analysis/` or `notebooks/` are +judged by their path, so a `lease_usd` value or an `os.path.join(sys.argv[1], …)` in a +stats script names no class (HARN-1, harness-evals#231), and binary data such as +`*.sqlite` is data wherever it sits; a docs line that can print a credential (an +`Authorization:` header, a bearer or key-shaped value, an environment key set to a value, +a get or show command for auth config such as `mcp get`) is security (gbrain#8417). The +outside voice reads its diff from a file the brief names with its sha256; past +2,000,000 bytes it reads a path-scoped slice whose brief lists the files left out. +`--fix-checks ` tells the lean loop how many fix-checks ran, so the owner's grant +adds one fix-check after the first and never a second. + **Classes** are unchanged. A mixed diff takes its most restrictive class: **docs** (markdown no agent or gate reads), **tests** (test files and fixtures), **code** (everything else, including every `SKILL.md` and template, markdown in a skill @@ -468,9 +500,13 @@ specialist stats they record). The summary line appends `fix_checks=`, loop prints the old line. Beside the counters, `write` records `review_depth` (the depth, runtime lines, -rules hash, base and registry commits, the gate scan bound to the head and the risk classes) and -the queue refuses a receipt below the depth its head needs -([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). A stop the owner +rules hash, base and registry commits, the gate scan bound to the head, the risk classes and +`mode`: lean, panel or platform) and the queue refuses a receipt below the depth its head needs +([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). After `NEXT: platform`, +`write --platform-review ` records `review_depth: platform` with `platform_review` +(capy-ai[bot]'s review id, reviewer and head); write and the queue accept it only under +`review.platform on`, on a docs or tests diff with no numeric, results or prereg claims, +with the review at that exact head, so a new head needs `/ship` again. A stop the owner accepted after a fix-check edit goes in `review_loop_exception`, never in prose under `REVIEW_LOOP:`. Step 16 takes the same record: `gstack-ship-release finish --review-loop-exception ` routes the diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index 1fa58bb1b..358c99b8d 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -114,7 +114,7 @@ export interface ShipReceipt { head_tree?: string; product_patch_id?: string; /** The gstack that wrote the receipt, and the review depth the reviewed head needs, recomputed at write (lib/ship-receipt-depth.ts receiptDepthAtHead). */ gstack_version?: string; - review_depth?: { depth: 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[]; risk?: string[] }; + review_depth?: { depth: 'platform' | 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[]; risk?: string[]; mode?: 'lean' | 'panel' | 'platform'; platform_review?: { id: string; head: string; reviewer: string } }; /** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */ review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check: { head: string; findings: number; files?: string[] } }; /** The targeted pass on money, gate, auth and secret code (lib/review-targeted.ts), bound to the receipt's product_patch_id; `done` only with its output collected. */ @@ -247,7 +247,7 @@ export const ARTIFACT_SCHEMAS = { carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } }, head_tree: str, product_patch_id: str, gstack_version: str, base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: str } }, - review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList, risk: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data'] } } } }, + review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['platform', 'light', 'standard', 'full'] }, mode: { enum: ['lean', 'panel', 'platform'] }, platform_review: { type: 'object', required: ['id', 'head', 'reviewer'], properties: { id: str, head: str, reviewer: str } }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList, risk: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data', 'eval', 'policy'] } } } }, review_targeted: { type: 'object', required: ['classes', 'rules', 'brief_sha', 'product_patch_id', 'status', 'findings', 'raised_by'], properties: { classes: { type: 'array', items: { enum: ['money', 'gate', 'auth', 'secret'] } }, rules: str, brief_sha: str, product_patch_id: str, status: { enum: ['done', 'failed', 'not-run'] }, findings: { type: 'integer', minimum: 0 }, raised_by: str } }, review_exclude: { type: 'object', required: ['source', 'patterns', 'applied', 'excluded', 'excluded_bytes', 'kept', 'gate_tagged', 'patterns_sha256', 'list_sha256', 'digest'], properties: { source: str, patterns: strList, applied: strList, excluded: { type: 'integer', minimum: 0 }, excluded_bytes: { type: 'integer', minimum: 0 }, kept: { type: 'integer', minimum: 0 }, gate_tagged: strList, patterns_sha256: str, list_sha256: str, digest: str } }, review_loop_exception: { type: 'object', required: ['accepted_by', 'date', 'via', 'reason', 'fix_check'], properties: { accepted_by: str, date: str, via: str, reason: str, fix_check: { type: 'object', required: ['head', 'findings'], properties: { head: str, findings: { type: 'integer', minimum: 0 }, files: strList } } } }, diff --git a/lib/review-brief.ts b/lib/review-brief.ts index 7fe0aa7f8..698715c1d 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -37,6 +37,26 @@ export function promptHash(text: string): string { export class BriefError extends Error {} +/** + * The outside voice's diff within a byte cap (FOUN-32 §3): whole per-file sections in diff order while they fit, so a reader past + * the cap still starts on a path-scoped slice and the brief names what it left out, instead of failing to start on a 3-4 MB brief. + */ +export function sliceDiff(text: string, cap: number): { text: string; sliced: boolean; files: number; kept: number; bytes: number; total: number; left: string[] } { + const total = Buffer.byteLength(text); + const starts = [...text.matchAll(/^diff --git a\/(\S+)/gm)]; + if (total <= cap || !starts.length) return { text, sliced: false, files: starts.length, kept: starts.length, bytes: total, total, left: [] }; + let out = '', bytes = 0; + const left: string[] = []; + starts.forEach((m, k) => { + const section = text.slice(m.index!, k + 1 < starts.length ? starts[k + 1]!.index! : text.length); + const n = Buffer.byteLength(section); + if (bytes + n <= cap) { out += section; bytes += n; } else left.push(m[1]!); + }); + return { text: out, sliced: true, files: starts.length, kept: starts.length - left.length, bytes, total, left }; +} +/** The brief's line for a sliced diff: what was read and what was left out (at most 50 paths named). */ +export const sliceNote = (s: ReturnType, cap: number) => `This is a path-scoped slice: ${s.kept} of ${s.files} files, ${s.bytes} of ${s.total} bytes (the cap is ${cap} bytes). Not in the slice, so not reviewed here: ${s.left.slice(0, 50).join(', ')}${s.left.length > 50 ? ` and ${s.left.length - 50} more` : ''}.`; + /** The text strictly between start and end, end searched after start; both anchors are required. */ function between(text: string, start: string, end: string, what: string): string { const i = text.indexOf(start); @@ -147,7 +167,7 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string { const filled = fill(t.outside, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role); const body = slots.policy_exclude ? filled.split('git diff "$DIFF_BASE".').join(`git diff "$DIFF_BASE" -- . ${slots.policy_exclude}${slots.keep ? ` and git diff "$DIFF_BASE" -- ${slots.keep}` : ''}.`) : filled; const head = [body, '', ...(slots.disclosure ? [slots.disclosure, ''] : []), `## Branch diff (git diff ${diffBase})`]; - if (slots.diff_file) return [...head, `The supplied branch diff is the file ${slots.diff_file} (${need(slots, 'diff_bytes')} bytes, sha256 ${need(slots, 'diff_sha256')}). Read it from disk; it is the whole diff under review.`].join('\n'); + if (slots.diff_file) return [...head, `The supplied branch diff is the file ${slots.diff_file} (${need(slots, 'diff_bytes')} bytes, sha256 ${need(slots, 'diff_sha256')}). Read it from disk; it is ${slots.diff_slice ? 'the part of the diff under review' : 'the whole diff under review'}.`, ...(slots.diff_slice ? [slots.diff_slice] : [])].join('\n'); return [...head, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n'); } case 'targeted': { diff --git a/lib/review-depth.ts b/lib/review-depth.ts index 2373e81fe..7dfb5814c 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -63,8 +63,8 @@ export const isGatePath = (p: string) => GATE_PATH.some(r => r.test(p)); export const isAgentProse = (p: string) => AGENT_PROSE.some(r => r.test(p)); export const isDataConfig = (p: string) => DATA_CONFIG.some(r => r.test(p)); -/** Binary data and media (HARN-1: committed sqlite ledgers git cannot count): data by extension wherever they sit, never code, consumed or not. */ -export const BINARY_DATA_PATH = /\.(sqlite3?|db|duckdb|parquet|arrow|feather|avro|orc|npy|npz|h5|hdf5|pdf|mp3|mp4|mov|wav|webm)$/i; +/** Binary data, media and run logs (HARN-1: committed sqlite ledgers, trial logs): data by path wherever they sit, never code, consumed or not. */ +export const BINARY_DATA_PATH = /\.(sqlite3?|db|duckdb|parquet|arrow|feather|avro|orc|npy|npz|h5|hdf5|pdf|mp3|mp4|mov|wav|webm|log|out)$|(^|\/)logs\/[^/.]+$/i; /** Data files: JSON, CSV, TSV and raster images. YAML and TOML are never data here: deploy configs and API specs stay code. An SVG is data only as a drawing (svgDrawing); executable SVG stays code. */ export const DATA_PATH = /\.(json|jsonl|ndjson|csv|tsv|png|jpe?g|gif|webp|bmp|ico|avif)$/i; /** Data-shaped files tools read by convention, with no code naming them: always code. */ @@ -626,6 +626,8 @@ export function planReview(i: PlanInput): ReviewPlan { */ /** Risk class read from the paths and lines (gate class, an R51 or review.risk_paths class, a classifier error), or a floor above light (FOUN-64 item 7: never the recorded review_depth). */ const riskClass = (c: ClassResult, depth: DepthResult) => c.class === 'gate' || c.risk.length > 0 || c.errors.length > 0 || depth.depth !== 'light'; +/** Whether a light diff may take the platform depth (FOUN-64 item 2): docs or tests only, no numeric claims, no results, prereg, eval or checker path. The receipt and the queue ask the same. */ +export const platformFits = (paths: readonly string[], runtimeLines: number, numbers: NumbersResult) => runtimeLines === 0 && !numbers.publishes && !paths.some(p => CLAIM_PATH.test(p)); /** Paths that carry results or preregistration claims: never the platform depth (FOUN-64 item 2). */ const CLAIM_PATH = /(^|\/|[-_.])(results?|prereg|preregistration|benchmarks?|evals?|scorers?|checkers?)([-_./]|$)/i; @@ -639,7 +641,7 @@ function leanPlan(i: PlanInput, c: ClassResult, depth: DepthResult, next: NextSt dispatches.push({ role, name, effort, exclude: x ? [...exclude, ...x.specs] : exclude, ...extra, ...(x?.keep.length ? { keep: [...x.keep] } : {}) }); const plan = (n: NextStep, mode: ReviewMode): ReviewPlan => ({ ...depth, stepDepth: depth.depth, depthReasons: depth.reasons, reasons: c.reasons, next: n, numbers: i.numbers, dispatches, skipped, mode }); const pr = sh.platformReview; - const platformFit = !risky && depth.runtimeLines === 0 && !i.numbers.publishes && !i.paths.some(p => CLAIM_PATH.test(p)); + const platformFit = !risky && platformFits(i.paths, depth.runtimeLines, i.numbers); if (next.step === 'full' && platformFit && sh.platform === 'on' && pr && pr.head === sh.head) { const why = `planned skip: platform review ${pr.id} by ${pr.reviewer} at ${pr.head.slice(0, 12)}`; for (const r of ['core checklist', 'specialists', 'red-team', 'adversarial-native', 'outside-voice', 'targeted']) skipped.push(`${r} (${why})`); diff --git a/lib/review-pass.ts b/lib/review-pass.ts index 0b68dd81a..6a7a2a0fc 100644 --- a/lib/review-pass.ts +++ b/lib/review-pass.ts @@ -14,8 +14,8 @@ import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { BriefError, briefFile, checkoutRoot, checkoutTree, promptHash, renderBrief, withWorkspace, type BriefRole, type SectionTexts, type Slots } from './review-brief'; -import { disclosureDigest } from './review-exclude'; +import { BriefError, briefFile, checkoutRoot, checkoutTree, promptHash, renderBrief, sliceDiff, sliceNote, withWorkspace, type BriefRole, type SectionTexts, type Slots } from './review-brief'; +import { REVIEW_DIFF_MAX_BYTES, disclosureDigest } from './review-exclude'; import { appendEvent, readerKey } from './review-ledger'; import { diffCommand, type Dispatch } from './review-depth'; import { TARGETED_RULES, parseClassList, targetedBriefSha, targetedPatchId } from './review-targeted'; @@ -181,11 +181,13 @@ export function renderStage(m: Manifest, stage: Stage, t: SectionTexts, diffFor: const specialists = m.readers.reduce((n, x) => n + (x.role === 'specialist' ? 1 : x.role === 'specialist-batched' ? (x.checklists ?? []).filter(c => c !== 'red-team').length : 0), 0); Object.assign(slots, { checklist: path.join(m.checklist_root, 'review/specialists/red-team.md'), findings, specialists: String(specialists) }); } + let slice: Record = {}; if (role === 'outside-voice') { - const diff = diffFor(m.diff_base, r); + const s = sliceDiff(diffFor(m.diff_base, r), REVIEW_DIFF_MAX_BYTES); const diffFile = path.join(briefs, 'outside-voice.diff'); - fs.writeFileSync(diffFile, diff, { mode: 0o600 }); - Object.assign(slots, { diff_file: diffFile, diff_bytes: String(Buffer.byteLength(diff)), diff_sha256: createHash('sha256').update(diff).digest('hex') }); + fs.writeFileSync(diffFile, s.text, { mode: 0o600 }); + Object.assign(slots, { diff_file: diffFile, diff_bytes: String(s.bytes), diff_sha256: createHash('sha256').update(s.text).digest('hex'), ...(s.sliced ? { diff_slice: sliceNote(s, REVIEW_DIFF_MAX_BYTES) } : {}) }); + if (s.sliced) slice = { diff_slice: { kept: s.kept, files: s.files, bytes: s.bytes, total: s.total } }; } if (role === 'fix-check') { const read = (f: string | undefined, what: string) => { if (!f) return undefined; try { return fs.readFileSync(f, 'utf8'); } catch { throw new BriefError(`cannot read the fix-check ${what} ${f}`); } }; @@ -205,7 +207,7 @@ export function renderStage(m: Manifest, stage: Stage, t: SectionTexts, diffFor: fs.writeFileSync(file, text, { mode: 0o600 }); const sha = promptHash(text); const bytes = Buffer.byteLength(text); - appendEvent(m.ledger, { event: 'rendered', reader: r.reader, role, name: r.name ?? null, file, sha256: sha, bytes, pass: m.pass, stage, workdir, tree, ...(findingsSha ? { findings_sha256: findingsSha } : {}), ...targeted, ...(disclosureDigest(m.disclosure) ? { exclude: disclosureDigest(m.disclosure) } : {}) }); + appendEvent(m.ledger, { event: 'rendered', reader: r.reader, role, name: r.name ?? null, file, sha256: sha, bytes, pass: m.pass, stage, workdir, tree, ...(findingsSha ? { findings_sha256: findingsSha } : {}), ...targeted, ...slice, ...(disclosureDigest(m.disclosure) ? { exclude: disclosureDigest(m.disclosure) } : {}) }); Object.assign(r, { file, sha256: sha, bytes, chunks: chunkCount(text), state: 'rendered', tree, ...(findingsSha ? { findings_sha256: findingsSha } : {}) }); done.push(r); } diff --git a/lib/ship-receipt-depth.ts b/lib/ship-receipt-depth.ts index 0b3fd6517..0258a6cd9 100644 --- a/lib/ship-receipt-depth.ts +++ b/lib/ship-receipt-depth.ts @@ -25,7 +25,9 @@ import { collectCommit } from './review-collect'; import { reviewExclude, type Exclusion } from './review-exclude'; import { isUnavailable } from './ship-policy'; import { loadPolicyAt } from './ship-policy-at'; -import { depthOfCommitInput, gateScanNeeded, type Depth } from './review-depth'; +import { depthOfCommitInput, gateScanNeeded, platformFits, scanNumbers, type Depth } from './review-depth'; +import { platformReviewAt } from './review-platform'; +import { reviewPolicy, type ReviewPolicy } from './ship-policy-review'; import { TARGETED_CLASSES, classList, targetedClasses, type TargetedClass } from './review-targeted'; import { runScriptSync } from './script-invocation'; import { bindReceipt, type ReceiptBindingCode } from './ship-receipt'; @@ -33,7 +35,7 @@ import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs'; export type ReceiptDepth = NonNullable; export type ReviewLoopException = NonNullable; -const RANK: Record = { light: 0, standard: 1, full: 2 }; +const RANK: Record = { platform: -1, light: 0, standard: 1, full: 2 }; const text = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(v); const sha = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-f]{40}$/.test(v); const BIN_DIR = path.join(import.meta.dir, '..', 'bin'); @@ -70,8 +72,15 @@ function exclusionAt(root: string, ref: string, baseSha: string | null, head: st return reviewExclude({ root, baseSha, ref, mergeBase: c.mergeBase, head, perPath: c.perPath, added: c.added, classRoot: c.root ?? [], gateTagged, context }); } -/** Whether the base policy declares patterns, so the gate scan's per-path tags are worth reading. */ -const declaresExclude = (root: string, ref: string, sha: string | null) => { if (!sha) return false; const l = loadPolicyAt(root, sha, ref); return !isUnavailable(l) && l.policy.review_exclude_paths.length > 0; }; +/** The base policy's review_exclude_paths and review key (lib/ship-policy-review.ts), defaults when it cannot be read. */ +function basePolicy(root: string, ref: string, sha: string | null): { declares: boolean; review: ReviewPolicy } { + const l = sha ? loadPolicyAt(root, sha, ref) : null; + const p = l && !isUnavailable(l) ? l.policy : null; + return { declares: !!p && p.review_exclude_paths.length > 0, review: reviewPolicy(p) }; +} +const riskPathsOf = (r: ReviewPolicy) => (r.risk_paths.length ? { riskPaths: r.risk_paths } : {}); +/** The mode the plan ran for this depth under the base policy's review.panel (lib/review-depth.ts planReview). */ +const modeFor = (depth: Depth, r: ReviewPolicy): 'lean' | 'panel' => (r.panel === 'all' || (r.panel === 'risk' && depth !== 'light') ? 'panel' : 'lean'); /** * The depth record for `head`: the range from git objects (collectCommit) and @@ -93,7 +102,7 @@ export function receiptReviewAtHead(repoRoot: string, base: string, binDir: stri const sha = baseSha.ok ? baseSha.out : (git(repoRoot, ['rev-parse', '--verify', '--quiet', `${branch}^{commit}`]).out || null); const c = collectCommit(repoRoot, baseRev, head); const scope = scopeOfRange(repoRoot, binDir, baseRev, head); - const declares = declaresExclude(repoRoot, ref, sha); + const { declares, review } = basePolicy(repoRoot, ref, sha); let tagged: number | null = 0; let paths: Set | null = declares ? new Set() : null; if (declares || gateScanNeeded(c.paths, c.root)) { @@ -104,8 +113,25 @@ export function receiptReviewAtHead(repoRoot: string, base: string, binDir: stri paths = declares && tagged !== null ? taggedPaths(g?.stdout ?? '') : null; } const exclude = exclusionAt(repoRoot, ref, sha, head, c, paths, 'gstack-ship-receipt'); - const d = depthOfCommitInput({ ...c, scope, gateTagged: tagged, ...(exclude.state === 'active' ? { excluded: exclude.excluded } : {}) }); - return { depth: { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseRev, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12), risk: d.risk }, exclude }; + const d = depthOfCommitInput({ ...c, scope, gateTagged: tagged, ...riskPathsOf(review), ...(exclude.state === 'active' ? { excluded: exclude.excluded } : {}) }); + return { depth: { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseRev, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12), risk: d.risk, mode: modeFor(d.depth, review) }, exclude }; +} + +/** + * `write --platform-review ` (design C6, FOUN-63): the receipt says honestly that no /ship reader ran because the platform + * review read this head. Allowed only under the base policy's review.platform on, on a light docs or tests diff with no numeric, + * results or prereg claims, with capy-ai[bot]'s review on the PR at this exact head. + */ +export function platformReceiptDepth(repoRoot: string, base: string, depth: ReceiptDepth, head: string, id: string, pr?: string): { ok: true; depth: ReceiptDepth } | { ok: false; message: string } { + const branch = base.replace(/^origin\//, ''); + const baseSha = baseCommit(repoRoot, `origin/${branch}`); + const { review } = basePolicy(repoRoot, baseSha.ok ? `origin/${branch}` : branch, baseSha.ok ? baseSha.out : null); + if (review.platform !== 'on') return { ok: false, message: `--platform-review needs review.platform on in ${branch}'s .gstack/ship-policy.json; with platform review off at least one /ship reader runs` }; + const c = collectCommit(repoRoot, depth.base, head); + if (depth.depth !== 'light' || !platformFits(c.paths, depth.runtime_lines, scanNumbers(c.added))) return { ok: false, message: `head ${head.slice(0, 12)} needs ${depth.depth} review (${depth.class} class, ${depth.runtime_lines} runtime lines): the platform depth covers only docs or tests diffs with no numeric, results or prereg claims` }; + const found = platformReviewAt(repoRoot, head, { pr, id }); + if (!found.ok) return { ok: false, message: `--platform-review ${id}: ${found.reason}` }; + return { ok: true, depth: { ...depth, depth: 'platform', mode: 'platform', platform_review: found.review } }; } /** The paths of the hunks a `gstack-gate-diff --format jsonl` run tagged. */ @@ -113,8 +139,8 @@ export function taggedPaths(jsonl: string): Set { return new Set(jsonl.split('\n').slice(1).flatMap(l => { try { const h = JSON.parse(l) as { path?: string; tags?: unknown[] }; return h.path && h.tags?.length ? [h.path] : []; } catch { return []; } })); } -export const depthBelow = (have: Depth, need: Depth) => RANK[have] < RANK[need]; -export const deeper = (a: Depth, b: Depth): Depth => (RANK[a] >= RANK[b] ? a : b); +export const depthBelow = (have: Depth | 'platform', need: Depth | 'platform') => RANK[have] < RANK[need]; +export const deeper = (a: D, b: D): D => (RANK[a] >= RANK[b] ? a : b); /** `write --review-loop-exception` input and the queue's check: who accepted the stop, when, where, why, and the fix-check it followed. */ export function parseReviewLoopException(value: unknown): { ok: true; exception: ReviewLoopException } | { ok: false; message: string } { @@ -244,10 +270,18 @@ export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceip const { baseSha, c, scope, x } = at ?? headInputs(root, baseRef, head, r); const tagged = boundScan(r, head); const scanBound = tagged !== null; - const need = depthOfCommitInput({ ...c, scope, gateTagged: tagged, ...(x.state === 'active' ? { excluded: x.excluded } : {}) }); + const { review } = basePolicy(root, baseRef, baseSha.ok ? baseSha.out : null); + const need = depthOfCommitInput({ ...c, scope, gateTagged: tagged, ...riskPathsOf(review), ...(x.state === 'active' ? { excluded: x.excluded } : {}) }); const have = rd?.depth; const inputs = `base@${short(baseSha.ok ? baseSha.out : null)},registry@${short(c.registry.sha)},gate_scan@${scanBound ? short(rd!.gate_scan.head) : 'missing'}`; const line = `REVIEW_DEPTH: receipt=${have ?? 'none'} required=${need.depth} runtime_lines=${need.runtimeLines} class=${need.class} reasons=${need.reasons.join(',')} inputs=${inputs} rounds=${r.review_loop?.rounds ?? (r.review_loop ? r.review_loop.passes + (r.review_loop.fix_checks ?? 0) : 'none')}`; + if (have === 'platform') { + const pr = rd!.platform_review; + const fits = need.depth === 'light' && platformFits(c.paths, need.runtimeLines, scanNumbers(c.added)); + const ok = review.platform === 'on' && fits && !!pr && pr.head === head.toLowerCase(); + if (ok) return { ok: true, line: `${line} platform_review=${pr!.id}@${pr!.head.slice(0, 12)}` }; + return { ok: false, line, message: `the receipt records the platform depth, but ${review.platform !== 'on' ? `the base policy has review.platform ${review.platform}` : !fits ? `head ${head.slice(0, 12)} needs ${need.depth} review of a diff the platform depth does not cover` : `its platform review is not at head ${head.slice(0, 12)}`}; the receipt is unchanged; run /ship on ${head.slice(0, 12)}` }; + } if (have && !depthBelow(have, need.depth)) return { ok: true, line }; const why = have ? `was reviewed at ${have} depth` : 'records no review_depth though its writer records one'; const errs = need.errors.map(e => `${e.code} (${e.input})`).join('; '); diff --git a/scripts/review-dispatch-compare.ts b/scripts/review-dispatch-compare.ts index efe817232..815ffc6a0 100755 --- a/scripts/review-dispatch-compare.ts +++ b/scripts/review-dispatch-compare.ts @@ -81,7 +81,7 @@ function newShip(f: Fixture, sc: Scenario): { calls: Call[]; ending: string } { let adversarialRan = false; for (let i = 0; i < 6; i++) { const plan = planReview({ ...base(f), history: h, criticalFound: sc === 'blocker' && i === 0, adversarialRan }); - if (plan.next.step === 'done' || plan.next.step === 'stop') { ending = plan.next.step; break; } + if (plan.next.step === 'done' || plan.next.step === 'stop' || plan.next.step === 'platform') { ending = plan.next.step; break; } const diffBytes = plan.next.step === 'fix-check' ? f.diff_bytes * FIX_CHECK_FRACTION : h.fullPasses === 0 ? f.diff_bytes : f.diff_bytes * RANGE_FRACTION; for (const d of plan.dispatches) calls.push({ role: d.role, name: d.name, effort: d.effort, diffBytes: d.role === 'outside-voice' ? f.diff_bytes : diffBytes }); adversarialRan ||= plan.dispatches.some(d => d.role === 'adversarial-native'); diff --git a/ship/SKILL.md b/ship/SKILL.md index 90ce90ede..81e8ab8b9 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -495,7 +495,8 @@ repeat Step 2 (decisions included) on the merged content, then Step 4. 1. **Select the two reviews.** Run `~/.claude/skills/gstack/bin/gstack-review-read`. Select this invocation's final Step 9 record (`skill:"review"`, `via:"ship"`, or a last 3.5 `review-fix-check`) and Step 11 native record (`skill:"adversarial-review"`; - none after `ADVERSARIAL: skipped`). Match each to its saved + none after `ADVERSARIAL: skipped` or `NEXT: platform`, whose Step 9 row is the planned skip: + carry its `PLATFORM_REVIEW:` id to Step 16's receipt write as `--platform-review `). Match each to its saved handle, original token and source; reject outside-provider or older records. 2. **Compare their content.** All three snapshots must match: the native record's `wtree` (its `review_binding.state` `verified`), the Step 9 record's diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 68483a085..abc1e7047 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -218,7 +218,8 @@ repeat Step 2 (decisions included) on the merged content, then Step 4. 1. **Select the two reviews.** Run `~/.claude/skills/gstack/bin/gstack-review-read`. Select this invocation's final Step 9 record (`skill:"review"`, `via:"ship"`, or a last 3.5 `review-fix-check`) and Step 11 native record (`skill:"adversarial-review"`; - none after `ADVERSARIAL: skipped`). Match each to its saved + none after `ADVERSARIAL: skipped` or `NEXT: platform`, whose Step 9 row is the planned skip: + carry its `PLATFORM_REVIEW:` id to Step 16's receipt write as `--platform-review `). Match each to its saved handle, original token and source; reject outside-provider or older records. 2. **Compare their content.** All three snapshots must match: the native record's `wtree` (its `review_binding.state` `verified`), the Step 9 record's diff --git a/ship/sections/adversarial.md b/ship/sections/adversarial.md index 937c5eb00..224feafcc 100644 --- a/ship/sections/adversarial.md +++ b/ship/sections/adversarial.md @@ -4,7 +4,9 @@ `DISPATCH: adversarial-native` and the outside voice only on `DISPATCH: outside-voice`. Without the native line (light depth at runtime 0; above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`; -with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH: targeted`, also run the targeted reader +with neither line, take decision 3 of Finish the adversarial phase. Render the native pass with +`--checklists` and `--classes` from its `checklists=` and `classes=`, on its `model=`; with `classes=` its +`verify --targeted-out` record is the targeted pass. On `DISPATCH: targeted`, also run the targeted reader (`gstack-review-dispatch render --role targeted --classes --base --diff-base `, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out .gstack/tmp/targeted.json` feeds the receipt's `--review-targeted`. Under a token cap, reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:`) and add `--reservation ` to the `--start adversarial-review` call below. diff --git a/ship/sections/adversarial.md.tmpl b/ship/sections/adversarial.md.tmpl index b4b739e0b..8ba9c7925 100644 --- a/ship/sections/adversarial.md.tmpl +++ b/ship/sections/adversarial.md.tmpl @@ -2,7 +2,9 @@ `DISPATCH: adversarial-native` and the outside voice only on `DISPATCH: outside-voice`. Without the native line (light depth at runtime 0; above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`; -with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH: targeted`, also run the targeted reader +with neither line, take decision 3 of Finish the adversarial phase. Render the native pass with +`--checklists` and `--classes` from its `checklists=` and `classes=`, on its `model=`; with `classes=` its +`verify --targeted-out` record is the targeted pass. On `DISPATCH: targeted`, also run the targeted reader (`gstack-review-dispatch render --role targeted --classes --base --diff-base `, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out .gstack/tmp/targeted.json` feeds the receipt's `--review-targeted`. Under a token cap, reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:`) and add `--reservation ` to the `--start adversarial-review` call below. diff --git a/ship/sections/review-army.md b/ship/sections/review-army.md index 76abde9ed..edf83b67e 100644 --- a/ship/sections/review-army.md +++ b/ship/sections/review-army.md @@ -27,6 +27,10 @@ family (`full` is the lane's reasoning, `medium` reasoning medium, `light` low; host that cannot set it runs its default) and reading its `DIFF_CMD:`, in place of 9.1's and 9.2's own selection rules; `specialist batched` is one subagent reading every checklist its `checklists=` names (light adds Red Team), tagging each finding with its checklist. +`adversarial-native single` or `merged` is Step 11's native pass carrying its `checklists=` +(and `classes=`) on its `model=` (`lane`: yours; `cross-family`: the outside voice's). +`NEXT: platform` dispatches nothing: skip the core read, log item 6's row with +`"depth":"platform"` and the `PLATFORM_REVIEW:` id, then continue to Step 10. A `SKIP:` reviewer logs `{"dispatched":false,"reason":"scope"}`. Audits run at `AUDIT_EFFORT:`. The core checklist, gate scan and QA always run. Item 6's row adds `"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`, `"depth"`, @@ -822,9 +826,9 @@ Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan - with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`, `--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers `, -`--depth-floor ` when given, `--grant ` once the owner's grant of one scoped fix-check is +`--depth-floor ` when given, `--fix-checks ` after a fix-check, `--grant ` once the owner's grant of one scoped fix-check is in `gstack-approvals` naming `review_loop_grant` and the stop head), `--range-base $PASS_HEAD` and `--pass1-depth `; -its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. `voices` 4: Step 11 runs before the stop. +its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4, `platform` 4. `voices` 4: Step 11 runs before the stop. `scoped` is 3 too. A full pass 1 always gets a full pass 2. Other scopes apply decisions 2–4 in order. diff --git a/ship/sections/review-army.md.tmpl b/ship/sections/review-army.md.tmpl index c7d1e33d1..4e49ecd26 100644 --- a/ship/sections/review-army.md.tmpl +++ b/ship/sections/review-army.md.tmpl @@ -25,6 +25,10 @@ family (`full` is the lane's reasoning, `medium` reasoning medium, `light` low; host that cannot set it runs its default) and reading its `DIFF_CMD:`, in place of 9.1's and 9.2's own selection rules; `specialist batched` is one subagent reading every checklist its `checklists=` names (light adds Red Team), tagging each finding with its checklist. +`adversarial-native single` or `merged` is Step 11's native pass carrying its `checklists=` +(and `classes=`) on its `model=` (`lane`: yours; `cross-family`: the outside voice's). +`NEXT: platform` dispatches nothing: skip the core read, log item 6's row with +`"depth":"platform"` and the `PLATFORM_REVIEW:` id, then continue to Step 10. A `SKIP:` reviewer logs `{"dispatched":false,"reason":"scope"}`. Audits run at `AUDIT_EFFORT:`. The core checklist, gate scan and QA always run. Item 6's row adds `"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`, `"depth"`, @@ -231,9 +235,9 @@ Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan - with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`, `--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers `, -`--depth-floor ` when given, `--grant ` once the owner's grant of one scoped fix-check is +`--depth-floor ` when given, `--fix-checks ` after a fix-check, `--grant ` once the owner's grant of one scoped fix-check is in `gstack-approvals` naming `review_loop_grant` and the stop head), `--range-base $PASS_HEAD` and `--pass1-depth `; -its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. `voices` 4: Step 11 runs before the stop. +its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4, `platform` 4. `voices` 4: Step 11 runs before the stop. `scoped` is 3 too. A full pass 1 always gets a full pass 2. Other scopes apply decisions 2–4 in order. diff --git a/test/review-depth-lean.test.ts b/test/review-depth-lean.test.ts index 5065f7632..60cdbf40e 100644 --- a/test/review-depth-lean.test.ts +++ b/test/review-depth-lean.test.ts @@ -3,7 +3,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { classifyDiff, nextStep, planReview, scanNumbers, type PlanInput, type ReviewShape } from '../lib/review-depth'; import { matchRisk } from '../lib/review-risk'; -import { renderBrief, sectionTexts } from '../lib/review-brief'; +import { renderBrief, sectionTexts, sliceDiff, sliceNote } from '../lib/review-brief'; import { validatePolicy, DEFAULT_POLICY, REVIEW_POLICY_KEY_VERSION } from '../lib/ship-policy'; const ROOT = path.join(import.meta.dir, '..'); @@ -19,6 +19,12 @@ describe('HARN-1 false triggers (harness-evals#231 on 1.93.7.0): data, config an const c = classifyDiff({ paths: [sqlite, 'README.md'], gateTagged: 0, perPath: [{ path: sqlite, lines: 0, binary: true }, { path: 'README.md', lines: 3, binary: false }], consumed: [] }); expect([c.class, c.errors.map(e => e.code), c.risk]).toEqual(['docs', [], []]); }); + test('run logs are data too: *.log, *.out and extensionless files under logs/ never name money or security (subagent replay at f6a7eba)', () => { + const logs = ['docs/b/results/cc-installer.plan.log', 'docs/b/results/trials/t1/r1/agent.out', 'docs/b/results/trials/t1/r1/logs/ready']; + const c = classifyDiff({ paths: logs, gateTagged: 0, added: Object.fromEntries(logs.map(p => [p, ['spend_cap_usd=5 fetch(url) rm -rf /tmp/x']])) }); + expect([c.class, c.risk]).toEqual(['docs', []]); + expect(classifyDiff({ paths: ['src/logs/rotate.ts'], gateTagged: 0 }).class).toBe('code'); + }); test('a sqlite file is data even when a script names it', () => { const c = classifyDiff({ paths: [sqlite, 'scripts/load.py'], gateTagged: 0, perPath: [{ path: sqlite, lines: 0, binary: true }, { path: 'scripts/load.py', lines: 4, binary: false }], consumed: [sqlite] }); expect([c.class, c.errors.map(e => e.code), c.runtimeLines]).toEqual(['code', [], 4]); @@ -169,6 +175,14 @@ describe('C7 merged reader brief and the outside voice reading its diff from dis expect(text).toContain('/x/testing.md, /x/red-team.md'); expect(text).toMatch(/money/); }); + test('past the cap the outside voice reads a path-scoped slice that names what it left out (FOUN-32 §3)', () => { + const sec = (p: string, n: number) => `diff --git a/${p} b/${p}\n+${'x'.repeat(n)}\n`; + const s = sliceDiff(sec('a.ts', 10) + sec('big.json', 500) + sec('b.ts', 10), 100); + expect([s.sliced, s.kept, s.files, s.left]).toEqual([true, 2, 3, ['big.json']]); + expect(s.text).toContain('b/b.ts'); + expect(sliceNote(s, 100)).toMatch(/2 of 3 files.*big\.json/); + expect(sliceDiff(sec('a.ts', 10), 100).sliced).toBe(false); + }); test('the outside voice gets a path and a hash, not the pasted diff', () => { const text = renderBrief('outside-voice', { diff_base: sha, diff_file: '/tmp/o/outside-voice.diff', diff_sha256: 'd'.repeat(64), diff_bytes: '1234' }, t); expect(text).toContain('/tmp/o/outside-voice.diff'); diff --git a/test/ship-receipt-platform.test.ts b/test/ship-receipt-platform.test.ts new file mode 100644 index 000000000..c84181b53 --- /dev/null +++ b/test/ship-receipt-platform.test.ts @@ -0,0 +1,70 @@ +import { describe, test, expect, beforeAll } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { platformReceiptDepth, receiptDepthVerdict, receiptReviewAtHead } from '../lib/ship-receipt-depth'; +import { REVIEW_POLICY_KEY_VERSION } from '../lib/ship-policy'; +import type { ShipReceipt } from '../lib/headless-artifacts'; + +const BIN = path.join(import.meta.dir, '..', 'bin'); +const git = (cwd: string, ...a: string[]) => { const r = spawnSync('git', a, { cwd, encoding: 'utf8', timeout: 30_000 }); if (r.status !== 0) throw new Error(r.stderr); return r.stdout.trim(); }; + +function repo(policy: object, files: Record): { dir: string; head: string } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plat-')); + git(dir, 'init', '-q', '-b', 'main'); + git(dir, 'config', 'user.email', 't@example.com'); git(dir, 'config', 'user.name', 't'); git(dir, 'config', 'commit.gpgsign', 'false'); + fs.mkdirSync(path.join(dir, '.gstack')); + fs.writeFileSync(path.join(dir, '.gstack/ship-policy.json'), JSON.stringify({ min_gstack_version: REVIEW_POLICY_KEY_VERSION, ...policy })); + fs.writeFileSync(path.join(dir, 'README.md'), '# x\n'); + fs.mkdirSync(path.join(dir, 'src')); fs.writeFileSync(path.join(dir, 'src/a.ts'), 'export const a = 1;\n'); + git(dir, 'add', '.'); git(dir, 'commit', '-qm', 'base'); + git(dir, 'update-ref', 'refs/remotes/origin/main', 'HEAD'); + git(dir, 'checkout', '-qb', 'feat'); + for (const [f, c] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, f)), { recursive: true }); fs.writeFileSync(path.join(dir, f), c); } + git(dir, 'add', '.'); git(dir, 'commit', '-qm', 'change'); + return { dir, head: git(dir, 'rev-parse', 'HEAD') }; +} + +let fakeBin = ''; +const withGh = (head: string, fn: () => T): T => { + const prev = process.env.PATH, prevHead = process.env.FAKE_REVIEW_HEAD; + process.env.PATH = fakeBin + path.delimiter + prev; process.env.FAKE_REVIEW_HEAD = head; + try { return fn(); } finally { process.env.PATH = prev; if (prevHead === undefined) delete process.env.FAKE_REVIEW_HEAD; else process.env.FAKE_REVIEW_HEAD = prevHead; } +}; +beforeAll(() => { + fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'fakegh-')); + fs.writeFileSync(path.join(fakeBin, 'gh'), '#!/bin/sh\ncase "$1" in\n pr) echo \'{"number":7}\' ;;\n api) echo "[[{\\"id\\":99,\\"user\\":{\\"login\\":\\"capy-ai[bot]\\"},\\"commit_id\\":\\"$FAKE_REVIEW_HEAD\\",\\"state\\":\\"COMMENTED\\"}]]" ;;\nesac\n', { mode: 0o755 }); +}); + +describe('C6 receipt: review_depth platform, honest and bound to the exact head (FOUN-63)', () => { + test('a docs diff under review.platform on with capy-ai[bot]\'s review at HEAD records platform, and the queue accepts it at that head only', () => { + const { dir, head } = repo({ review: { platform: 'on' } }, { 'docs/howto.md': 'Run the installer.\n' }); + const d = receiptReviewAtHead(dir, 'main', BIN, head).depth; + expect([d.depth, d.mode]).toEqual(['light', 'lean']); + const p = withGh(head, () => platformReceiptDepth(dir, 'main', d, head, '99')); + expect(p.ok && [p.depth.depth, p.depth.mode, p.depth.platform_review]).toEqual(['platform', 'platform', { id: '99', head, reviewer: 'capy-ai[bot]' }]); + const r = { gstack_version: '1.94.0.0', review_depth: (p as any).depth } as ShipReceipt; + expect(receiptDepthVerdict(dir, 'origin/main', r, head)).toMatchObject({ ok: true, line: expect.stringContaining('platform_review=99@') }); + const moved = { ...r, review_depth: { ...r.review_depth!, platform_review: { id: '99', head: 'b'.repeat(40), reviewer: 'capy-ai[bot]' } } } as ShipReceipt; + expect(receiptDepthVerdict(dir, 'origin/main', moved, head).ok).toBe(false); + }); + test('refused: platform review off, runtime code, a results path, a review at another head', () => { + const off = repo({}, { 'docs/howto.md': 'x\n' }); + const dOff = receiptReviewAtHead(off.dir, 'main', BIN, off.head).depth; + expect(withGh(off.head, () => platformReceiptDepth(off.dir, 'main', dOff, off.head, '99'))).toMatchObject({ ok: false, message: expect.stringContaining('review.platform on') }); + for (const files of [{ 'src/b.ts': 'export const b = 2;\n' }, { 'docs/results/summary.md': 'Ran it.\n' }]) { + const { dir, head } = repo({ review: { platform: 'on' } }, files); + const d = receiptReviewAtHead(dir, 'main', BIN, head).depth; + expect(withGh(head, () => platformReceiptDepth(dir, 'main', d, head, '99')).ok).toBe(false); + } + const { dir, head } = repo({ review: { platform: 'on' } }, { 'docs/howto.md': 'x\n' }); + const d = receiptReviewAtHead(dir, 'main', BIN, head).depth; + expect(withGh('c'.repeat(40), () => platformReceiptDepth(dir, 'main', d, head, '99'))).toMatchObject({ ok: false, message: expect.stringContaining('capy-ai[bot]') }); + }); + test('the receipt records the risk_paths class and the panel mode the base policy gives', () => { + const { dir, head } = repo({ review: { risk_paths: ['src/balance/**'] } }, { 'src/balance/x.ts': 'export const x = 1;\n' }); + const d = receiptReviewAtHead(dir, 'main', BIN, head).depth; + expect([d.depth, d.risk, d.mode]).toEqual(['full', ['policy'], 'panel']); + }); +});