feat(skills): skill-scoped R56 cuts for /investigate and /review, plus /review blocker proof (GST-1088 lane C, candidate)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 04:03:30 +00:00
1 parent 106fd39446
commit bd24f76ece
26 files changed
+380 -248

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
/**
* R56 cut ledger (GST-1088): the pieces cut from one skill's render in every
* loadout and on every host. modules/cuts.json is the hand-written source;
* test/cut-ledger.test.ts is its ratchet. Each cut is a skill-qualified
* module id (`part:<skill>/<name>`) that `gstack-modules list` shows with
* status cut. A cut with `preamble_piece` is applied by preambleSections()
* for that skill alone; any other cut was removed from the skill's template,
* and the ratchet keeps its `match` text out of every generated SKILL.md.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
export const CUT_LEDGER_FILE = 'modules/cuts.json';
export interface CutEntry {
id: string;
skill: string;
status: 'cut';
/** Where the cut text lived; restoring it starts here. */
source: string;
/** Text that marks the piece in a generated SKILL.md; the ratchet fails when it reappears. */
match: string;
/** PREAMBLE_PIECES id this cut removes from the skill's preamble. */
preamble_piece?: string;
why: string;
evidence: string;
}
export interface CutLedger {
version: 1;
note: string;
cuts: CutEntry[];
/** Always-loaded bytes of <skill>/SKILL.md (frontmatter included) per targeted skill: an exact ratchet. */
pins: Record<string, number>;
/** Who approves a restore or a raised pin. */
approver: string;
}
let cached: CutLedger | undefined;
export function loadCutLedger(root = ROOT): CutLedger {
if (root === ROOT && cached) return cached;
const ledger = JSON.parse(fs.readFileSync(path.join(root, CUT_LEDGER_FILE), 'utf-8')) as CutLedger;
if (root === ROOT) cached = ledger;
return ledger;
}
/** PREAMBLE_PIECES ids cut from this skill's preamble. */
export function preambleCuts(skill: string): Set<string> {
return new Set(loadCutLedger().cuts.filter(c => c.skill === skill && c.preamble_piece).map(c => c.preamble_piece!));
}
+1 -1
View File
@@ -67,7 +67,7 @@ export async function modulesMain(args: string[]): Promise<CliResult> {
return { ...e, source_bytes: bytes.length, source_sha256: crypto.createHash('sha256').update(bytes).digest('hex') };
});
if (rest.includes('--json')) return { code: 0, stdout: JSON.stringify({ loadouts: Object.keys(manifest.loadouts), modules: rows }, null, 2) + '\n', stderr: '' };
const lines = rows.map(r => `${r.id.padEnd(48)} ${r.kind.padEnd(8)} ${r.pinned ? 'pinned' : ' '} ${r.source}`);
const lines = rows.map(r => `${r.id.padEnd(48)} ${r.kind.padEnd(8)} ${r.pinned ? 'pinned' : r.status === 'cut' ? 'cut ' : ' '} ${r.source}`);
return { code: 0, stdout: [`loadouts: ${Object.keys(manifest.loadouts).join(', ')}`, ...lines].join('\n') + '\n', stderr: '' };
}
if (command === 'validate') {
+13 -3
View File
@@ -6,8 +6,10 @@
* (`section:<skill>/<id>`, from <skill>/sections/manifest.json), one model
* overlay (`overlay:<name>`, from model-overlays/) or one preamble piece
* (`preamble:<id>`, from PREAMBLE_PIECES). The catalog is derived from those
* sources; modules/loadouts.json is the only hand-written file and holds the
* rungs, the model-id to rung table, the pinned ids and the loadouts.
* sources; modules/loadouts.json holds the rungs, the model-id to rung table,
* the pinned ids and the loadouts. modules/cuts.json adds one `part:<skill>/<name>`
* entry per R56 cut: status cut, removed from that skill in every loadout, so
* no loadout or spec may name it.
*
* Every error is a ModuleError with a stable code, a fix line and a
* docs/troubleshooting.md anchor. Nothing here ever falls back to `full` on
@@ -19,6 +21,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { discoverTemplates } from './discover-skills';
import { PREAMBLE_MODULE_IDS } from './resolvers/preamble';
import { CUT_LEDGER_FILE, loadCutLedger } from './cut-ledger';
import type { ModuleFilter } from './resolvers/types';
const ROOT = path.resolve(import.meta.dir, '..');
@@ -30,13 +33,15 @@ export const FULL = 'full';
export const IMPLICIT_PINS = ['skill:gstack', 'skill:gstack-upgrade', 'preamble:bash'];
const NAME_RE = /^[a-z0-9-]+$/;
export type ModuleKind = 'skill' | 'section' | 'overlay' | 'preamble';
export type ModuleKind = 'skill' | 'section' | 'overlay' | 'preamble' | 'part';
export interface ModuleEntry {
id: string;
kind: ModuleKind;
/** Repository-relative source file. */
source: string;
pinned: boolean;
/** A `part` entry from modules/cuts.json. */
status?: 'cut';
}
export interface LoadoutDef {
@@ -89,6 +94,7 @@ export function loadLoadouts(root = ROOT): LoadoutsManifest {
export function moduleCatalog(manifest: LoadoutsManifest, root = ROOT): ModuleEntry[] {
const pinned = new Set([...manifest.pinned, ...IMPLICIT_PINS]);
const entries: Omit<ModuleEntry, 'pinned'>[] = [];
const cuts = loadCutLedger(root).cuts;
for (const { tmpl } of discoverTemplates(root)) {
const dir = path.dirname(tmpl);
entries.push({ id: `skill:${dir === '.' ? 'gstack' : dir}`, kind: 'skill', source: tmpl });
@@ -101,6 +107,7 @@ export function moduleCatalog(manifest: LoadoutsManifest, root = ROOT): ModuleEn
entries.push({ id: `overlay:${file.slice(0, -3)}`, kind: 'overlay', source: `model-overlays/${file}` });
}
for (const id of PREAMBLE_MODULE_IDS) entries.push({ id, kind: 'preamble', source: 'scripts/resolvers/preamble.ts' });
for (const cut of cuts) entries.push({ id: cut.id, kind: 'part', source: cut.source, status: 'cut' });
return entries.map(e => ({ ...e, pinned: pinned.has(e.id) }));
}
@@ -134,6 +141,7 @@ export function rungFor(manifest: LoadoutsManifest, raw: string): Rung {
export function validateLoadouts(manifest: LoadoutsManifest, catalog: ModuleEntry[], corpus: string[] = []): string[] {
const problems: string[] = [];
const ids = new Set(catalog.map(e => e.id));
const cut = new Set(catalog.filter(e => e.status === 'cut').map(e => e.id));
const pinned = new Set([...(manifest.pinned ?? []), ...IMPLICIT_PINS]);
if (manifest.version !== 1) problems.push(`version must be 1 (got ${clamp(String(manifest.version))})`);
for (const rung of RUNGS) if (!Array.isArray(manifest.rungs?.[rung])) problems.push(`rungs.${rung} must be a list of model-id patterns`);
@@ -149,6 +157,7 @@ export function validateLoadouts(manifest: LoadoutsManifest, catalog: ModuleEntr
if (def.base !== undefined && !has(def.base)) problems.push(`loadouts.${name}.base names no loadout (${clamp(def.base)})`);
for (const id of [...(def.exclude ?? []), ...(Array.isArray(def.include) ? def.include : [])]) {
if (!ids.has(id)) problems.push(`loadouts.${name} names ${clamp(id)}, which is not a module`);
else if (cut.has(id)) problems.push(`loadouts.${name} names ${id}, which is cut in every loadout (${CUT_LEDGER_FILE})`);
}
for (const id of def.exclude ?? []) {
if (pinned.has(id)) problems.push(`loadouts.${name} excludes pinned ${id}`);
@@ -230,6 +239,7 @@ export function selectModules(input: { loadout?: string; modules?: string }, roo
}
const entry = ids.get(id);
if (!entry) throw new ModuleError('MODULE_UNKNOWN', `${clamp(id)} is not a module`, 'gstack-modules list');
if (entry.status === 'cut') throw new ModuleError('MODULE_SPEC_INVALID', `${id} is cut in every loadout (${CUT_LEDGER_FILE})`, `drop ${edit} from the spec; restoring a cut is a ${CUT_LEDGER_FILE} change`);
if (op === '-' && entry.pinned) {
throw new ModuleError('MODULE_PINNED', `${id} is pinned and can never be excluded`, `drop -${id} from the spec (pinned ids: gstack-modules list --pinned)`);
}
+4 -2
View File
@@ -32,6 +32,8 @@ Verify evidence first, then score every finding (1-10) and apply its display rul
Force its confidence to 4-5: use 4 for appendix-only reporting, or 5 only when
the finding belongs in the main report with the medium-confidence caveat below.
Never invent speculative confidence 7+.
4. **A CRITICAL finding also needs Step 4's Blocker proof:** its floor reason and a
repro, a failing test or a cited line with its consequence. Without both, emit it as INFORMATIONAL.
| Score | Meaning | Display rule |
|-------|---------|-------------|
@@ -43,10 +45,10 @@ Verify evidence first, then score every finding (1-10) and apply its display rul
**Finding format:**
\`[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description\`
\`[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description\`, and a CRITICAL line ends \`Floor: <reason>. Proof: <...>\`
Example:
\`[CRITICAL] (confidence: 9/10) user.rb:42 — SQL injection via string interpolation\`
\`[CRITICAL] (confidence: 9/10) user.rb:42 — SQL injection via string interpolation. Floor: loses data or secrets. Proof: user.rb:42 puts params[:q] into where() -> any visitor reads every row\`
**Calibration learning:** If the user confirms a reported finding scored < 7 is
real, log the corrected pattern as a learning.`;
+1 -1
View File
@@ -127,7 +127,7 @@ reads, not when it saves its result. Include non-ignored untracked source in eac
reviewer's context or read instructions (\`git ls-files --others --exclude-standard\`).
Those files are part of the recorded content too.
Dispatch via the Agent tool with ${FOREGROUND_IF_AVAILABLE} (background is the default since ${CC_BACKGROUND_DEFAULT_SINCE}); findings must arrive before review concludes. ${BACKGROUND_RECOVERY} Fresh context avoids checklist bias, but this is the same harness, not an independent model unless runtime identity proves otherwise.
${isShip ? '' : 'Print exactly `Dispatching adversarial pass: native` before the Agent call. '}Dispatch via the Agent tool with ${FOREGROUND_IF_AVAILABLE} (background is the default since ${CC_BACKGROUND_DEFAULT_SINCE}); findings must arrive before review concludes. ${BACKGROUND_RECOVERY} Fresh context avoids checklist bias, but this is the same harness, not an independent model unless runtime identity proves otherwise.
Subagent prompt:
"${DEFENSIVE_REVIEW_FRAMING}
+5 -2
View File
@@ -7,7 +7,8 @@
* the pre-hoist oracle. {{PREAMBLE}} resolves through ./preamble-hoist.ts,
* which renders tier 1 inline and splits tier 2+ into a per-skill pointer plus
* the shared sections/preamble.md the skill Reads on load. Keep composition
* declarative — no inline logic beyond tier gating.
* declarative — no inline logic beyond tier gating and the skill's cuts in
* modules/cuts.json (scripts/cut-ledger.ts).
*
* Each skill runs independently via `claude -p` (or the host's equivalent).
* The preamble provides: update checks, session
@@ -23,6 +24,7 @@
import type { TemplateContext } from './types';
import { generateModelOverlay } from './model-overlay';
import { generateQuestionTuning } from './question-tuning';
import { preambleCuts } from '../cut-ledger';
// Core bootstrap
import { generatePreambleBash } from './preamble/generate-preamble-bash';
@@ -126,13 +128,14 @@ export function preambleSections(ctx: TemplateContext): string[] {
throw new Error(`Invalid preamble-tier: ${tier} in ${ctx.tmplPath}. Must be 1-4.`);
}
const filter = ctx.modules;
const cut = preambleCuts(ctx.skillName);
const pieceTier = (piece: PreamblePiece): number => {
if (!filter || piece.id === null || filter.pinned.has(`preamble:${piece.id}`)) return tier;
return Math.min(tier, filter.preambleTierMax ?? tier);
};
return PREAMBLE_PIECES
.filter(piece => (piece.when?.(ctx) ?? true)
&& !(piece.id && filter?.excluded.has(`preamble:${piece.id}`))
&& !(piece.id && (filter?.excluded.has(`preamble:${piece.id}`) || cut.has(piece.id)))
&& pieceTier(piece) >= piece.minTier)
.map(piece => piece.render(ctx, pieceTier(piece)))
.filter(s => s && s.trim().length > 0);
+1 -1
View File
@@ -63,7 +63,7 @@ Select specialists from the scope signals above.
1. **Testing** — read \`${ctx.paths.skillRoot}/review/specialists/testing.md\`
2. **Maintainability** — read \`${ctx.paths.skillRoot}/review/specialists/maintainability.md\`
**If DIFF_LINES < 50:** Skip all specialists except State Machines (item 9 decides it). Print: "Small diff ($DIFF_LINES lines) — specialists skipped." Continue to Step ${stepMerge} with the ${isShip ? 'core/design-lite' : 'core'} findings and an empty or State Machines-only specialist list, then the parent's Exploratory QA step and ${nextStep}. Small diffs skip fan-out, never the parent-owned smoke probes or core shared-code checks.
**If DIFF_LINES < 50:** Skip all specialists except State Machines (item 9 decides it). ${isShip ? 'Print: "Small diff ($DIFF_LINES lines) — specialists skipped."' : 'Print the selection line: `Dispatching 0 specialists: (small diff)`, or `Dispatching 1 specialists: State Machines (small diff)` when item 9 keeps it.'} Continue to Step ${stepMerge} with the ${isShip ? 'core/design-lite' : 'core'} findings and an empty or State Machines-only specialist list, then the parent's Exploratory QA step and ${nextStep}. Small diffs skip fan-out, never the parent-owned smoke probes or core shared-code checks.
**Conditional (dispatch when its scope signal is true):**
3. **Security** — if SCOPE_AUTH=true, OR if SCOPE_BACKEND=true AND DIFF_LINES > 100. Read \`${ctx.paths.skillRoot}/review/specialists/security.md\`