mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
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:
1 parent
106fd39446
commit
bd24f76ece
26 files changed
+380
-248
No files matched your search
@@ -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!));
|
||||
}
|
||||
@@ -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
@@ -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)`);
|
||||
}
|
||||
|
||||
@@ -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.`;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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\`
|
||||
|
||||
Reference in new issue
Block a user