mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-15 12:57:22 +01:00
Merge branch 'c3922' into capy/gst-1088-after-pin
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # test/fixtures/skill-md-size-allowlist.json # test/helpers/carve-guards.ts
This commit is contained in:
commit
50e38dad1f
27 files changed
+462
-250
No files matched your search
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* R56 cut ledger ratchet (GST-1088 lane C). modules/cuts.json names the pieces
|
||||
* cut from /investigate and /review in every loadout and on every host, and pins
|
||||
* each targeted skill's always-loaded bytes (its Claude SKILL.md, frontmatter
|
||||
* included). This test fails when a cut piece reappears in a generated SKILL.md,
|
||||
* when a pin moves, or when a cut or a review-only change leaks into another skill.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { ALL_HOST_CONFIGS } from '../hosts/index';
|
||||
import { CUT_LEDGER_FILE, loadCutLedger, preambleCuts, type CutLedger } from '../scripts/cut-ledger';
|
||||
import { PREAMBLE_PIECES } from '../scripts/resolvers/preamble';
|
||||
import { SHARED_PREAMBLE_FILE } from '../scripts/resolvers/preamble-hoist';
|
||||
import { discoverTemplates } from '../scripts/discover-skills';
|
||||
import { loadLoadouts, moduleCatalog, selectModules, validateLoadouts } from '../scripts/modules';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const TARGETED = ['investigate', 'review'];
|
||||
/** Text only /review may render: the GST-1088 markers and the blocker-proof rule. */
|
||||
const REVIEW_ONLY = ['Dispatching 0 specialists: (small diff)', 'Dispatching adversarial pass: native', 'Blocker proof'];
|
||||
|
||||
const skillOfDir = (dir: string) => (dir === '.' ? 'gstack' : dir.replace(/^gstack-/, ''));
|
||||
|
||||
/** Every violation of the ledger by these renders (key "host/skill") and Claude SKILL.md byte counts. */
|
||||
export function cutViolations(renders: Record<string, string>, bytes: Record<string, number>, ledger: CutLedger): string[] {
|
||||
const out: string[] = [];
|
||||
for (const [key, text] of Object.entries(renders).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const [host, skill] = key.split('/');
|
||||
for (const cut of ledger.cuts.filter(c => c.skill === skill && text.includes(c.match))) {
|
||||
out.push(`${host}/${skill}/SKILL.md: cut section "${cut.match}" reappeared (${CUT_LEDGER_FILE} ${cut.id}: ${cut.why}). `
|
||||
+ `Fix: remove it from ${cut.source}, then bun run gen:skill-docs. Restoring it instead needs a measurement in the PR and ${ledger.approver}.`);
|
||||
}
|
||||
}
|
||||
for (const [skill, pin] of Object.entries(ledger.pins).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const actual = bytes[skill];
|
||||
if (actual === undefined) { out.push(`pins.${skill} in ${CUT_LEDGER_FILE} names no ${skill}/SKILL.md. Delete the pin.`); continue; }
|
||||
if (actual > pin) {
|
||||
out.push(`${skill}/SKILL.md: ${actual} always-loaded bytes, pinned at ${pin} (+${actual - pin}). `
|
||||
+ `Fix: edit ${skill}/SKILL.md.tmpl, then bun run gen:skill-docs. Re-pin: set pins.${skill} to ${actual} in ${CUT_LEDGER_FILE} with the measurement in the PR body; approver: ${ledger.approver}.`);
|
||||
} else if (actual < pin) {
|
||||
out.push(`${skill}/SKILL.md: ${actual} always-loaded bytes, pinned at ${pin} (-${pin - actual}). Re-pin: lower pins.${skill} to ${actual} in ${CUT_LEDGER_FILE}.`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Each skill's SKILL.md plus its own section files (the shared preamble is the same file for every skill). */
|
||||
function renderAllHosts(outDir: string): Record<string, string> {
|
||||
const render = Bun.spawnSync(
|
||||
['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--out-dir', outDir],
|
||||
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 180_000 },
|
||||
);
|
||||
if (render.exitCode !== 0) throw new Error(`gen-skill-docs --host all --out-dir failed (exit ${render.exitCode}):\n${render.stderr.toString()}`);
|
||||
const renders: Record<string, string> = {};
|
||||
for (const host of ALL_HOST_CONFIGS) {
|
||||
const skillsRoot = host.name === 'claude' ? ROOT : path.join(outDir, host.hostSubdir, 'skills');
|
||||
const dirs = fs.readdirSync(skillsRoot, { withFileTypes: true }).filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
|
||||
if (host.name === 'claude') dirs.push('.');
|
||||
for (const dir of dirs) {
|
||||
const file = path.join(skillsRoot, dir, 'SKILL.md');
|
||||
if (!fs.existsSync(file) || (host.name === 'claude' && !fs.existsSync(`${file}.tmpl`))) continue;
|
||||
const sectionsDir = path.join(skillsRoot, dir, 'sections');
|
||||
const sections = dir !== '.' && fs.existsSync(sectionsDir)
|
||||
? fs.readdirSync(sectionsDir).filter(f => f.endsWith('.md') && f !== SHARED_PREAMBLE_FILE).sort().map(f => fs.readFileSync(path.join(sectionsDir, f), 'utf-8'))
|
||||
: [];
|
||||
renders[`${host.name}/${skillOfDir(dir)}`] = [fs.readFileSync(file, 'utf-8'), ...sections].join('\n');
|
||||
}
|
||||
}
|
||||
return renders;
|
||||
}
|
||||
|
||||
const ledger = loadCutLedger();
|
||||
const claudeBytes = Object.fromEntries(TARGETED.map(s => [s, fs.statSync(path.join(ROOT, s, 'SKILL.md')).size]));
|
||||
|
||||
describe('cut ledger ratchet messages', () => {
|
||||
const fixture: CutLedger = {
|
||||
version: 1, note: '', approver: 'the owner',
|
||||
cuts: [{ id: 'part:x/learn', skill: 'x', status: 'cut', source: 'x/SKILL.md.tmpl', match: '## Learn', why: 'unused', evidence: 'run 1' }],
|
||||
pins: { x: 100, y: 50 },
|
||||
};
|
||||
test('names the file, the section and its entry, the bytes and the fix or re-pin with its approver', () => {
|
||||
expect(cutViolations({ 'codex/x': 'a\n## Learn\n', 'codex/z': '## Learn' }, { x: 101, y: 49 }, fixture)).toEqual([
|
||||
`codex/x/SKILL.md: cut section "## Learn" reappeared (${CUT_LEDGER_FILE} part:x/learn: unused). Fix: remove it from x/SKILL.md.tmpl, then bun run gen:skill-docs. Restoring it instead needs a measurement in the PR and the owner.`,
|
||||
`x/SKILL.md: 101 always-loaded bytes, pinned at 100 (+1). Fix: edit x/SKILL.md.tmpl, then bun run gen:skill-docs. Re-pin: set pins.x to 101 in ${CUT_LEDGER_FILE} with the measurement in the PR body; approver: the owner.`,
|
||||
`y/SKILL.md: 49 always-loaded bytes, pinned at 50 (-1). Re-pin: lower pins.y to 49 in ${CUT_LEDGER_FILE}.`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modules/cuts.json', () => {
|
||||
test('cuts and pins cover exactly /investigate and /review', () => {
|
||||
expect([...new Set(ledger.cuts.map(c => c.skill))].sort()).toEqual(TARGETED);
|
||||
expect(Object.keys(ledger.pins).sort()).toEqual(TARGETED);
|
||||
});
|
||||
|
||||
test('each cut is a skill-qualified id with a real source, preamble piece and match', () => {
|
||||
const pieces = new Set(PREAMBLE_PIECES.map(p => p.id));
|
||||
const others = discoverTemplates(ROOT).map(t => path.join(ROOT, path.dirname(t.tmpl), 'SKILL.md'))
|
||||
.filter(f => !TARGETED.some(s => f.endsWith(`/${s}/SKILL.md`)) && fs.existsSync(f)).map(f => fs.readFileSync(f, 'utf-8'));
|
||||
expect(new Set(ledger.cuts.map(c => c.id)).size).toBe(ledger.cuts.length);
|
||||
for (const cut of ledger.cuts) {
|
||||
expect(cut.id).toMatch(new RegExp(`^part:${cut.skill}/[a-z0-9-]+$`));
|
||||
expect(cut.status).toBe('cut');
|
||||
expect(fs.existsSync(path.join(ROOT, cut.source)), `${cut.id} source ${cut.source}`).toBe(true);
|
||||
if (cut.preamble_piece) expect(pieces.has(cut.preamble_piece), `${cut.id} preamble_piece`).toBe(true);
|
||||
expect(others.some(text => text.includes(cut.match)), `${cut.id} match "${cut.match}" appears in no other skill, so it guards nothing`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('a preamble cut applies to its own skill alone', () => {
|
||||
for (const { tmpl } of discoverTemplates(ROOT)) {
|
||||
const skill = skillOfDir(path.dirname(tmpl));
|
||||
expect([...preambleCuts(skill)]).toEqual(ledger.cuts.filter(c => c.skill === skill && c.preamble_piece).map(c => c.preamble_piece!));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('module catalog lists cuts', () => {
|
||||
const manifest = loadLoadouts();
|
||||
test('every cut id is a catalog entry with status cut', () => {
|
||||
const catalog = moduleCatalog(manifest);
|
||||
for (const cut of ledger.cuts) expect(catalog.find(e => e.id === cut.id)).toMatchObject({ kind: 'part', status: 'cut', pinned: false });
|
||||
});
|
||||
test('a loadout or a --modules spec cannot name a cut, since it is cut in every loadout', () => {
|
||||
const id = ledger.cuts[0].id;
|
||||
const bad = { ...manifest, loadouts: { ...manifest.loadouts, odd: { base: 'full', include: [id] } } };
|
||||
expect(validateLoadouts(bad, moduleCatalog(manifest))).toContain(`loadouts.odd names ${id}, which is cut in every loadout (${CUT_LEDGER_FILE})`);
|
||||
expect(() => selectModules({ modules: `full,+${id}` })).toThrow(/cut in every loadout/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cut ledger against every host render', () => {
|
||||
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cut-ledger-'));
|
||||
afterAll(() => fs.rmSync(outDir, { recursive: true, force: true }));
|
||||
const renders = renderAllHosts(outDir);
|
||||
|
||||
test('no cut piece reappears in its skill or its sections, and each pin matches its SKILL.md', () => {
|
||||
for (const host of ALL_HOST_CONFIGS) for (const skill of TARGETED) expect(renders[`${host.name}/${skill}`], `${host.name}/${skill}`).toBeDefined();
|
||||
const violations = cutViolations(renders, claudeBytes, ledger);
|
||||
if (violations.length) throw new Error(`${violations.length} cut ledger violation(s):\n ${violations.join('\n ')}`);
|
||||
});
|
||||
|
||||
test('a preamble cut never comes back through the shared preamble file, on any host', () => {
|
||||
const leaks: string[] = [];
|
||||
for (const host of ALL_HOST_CONFIGS) for (const cut of ledger.cuts.filter(c => c.preamble_piece)) {
|
||||
const file = host.name === 'claude'
|
||||
? path.join(ROOT, cut.skill, 'sections', SHARED_PREAMBLE_FILE)
|
||||
: path.join(outDir, host.hostSubdir, 'skills', `gstack-${cut.skill}`, 'sections', SHARED_PREAMBLE_FILE);
|
||||
if (fs.existsSync(file) && fs.readFileSync(file, 'utf-8').includes(cut.match)) leaks.push(`${host.name}: ${cut.id} "${cut.match}" in ${path.relative(outDir, file)}`);
|
||||
}
|
||||
expect(leaks).toEqual([]);
|
||||
});
|
||||
|
||||
test('review-only GST-1088 text renders in /review alone, on every host', () => {
|
||||
const leaks = Object.entries(renders).filter(([key]) => !key.endsWith('/review'))
|
||||
.flatMap(([key, text]) => REVIEW_ONLY.filter(s => text.includes(s)).map(s => `${key}: "${s}"`));
|
||||
expect(leaks).toEqual([]);
|
||||
expect(REVIEW_ONLY.filter(s => !renders['claude/review'].includes(s))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/** Every file under dir (relative path to bytes), skipping the targeted skills' own trees. */
|
||||
function readOtherSkills(dir: string): Map<string, Buffer> {
|
||||
const out = new Map<string, Buffer>();
|
||||
for (const rel of fs.readdirSync(dir, { recursive: true, withFileTypes: false }) as string[]) {
|
||||
const file = path.join(dir, rel);
|
||||
if (fs.statSync(file).isFile()) out.set(rel, fs.readFileSync(file));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const targetedPath = new RegExp(`(^|/)(gstack-)?(${TARGETED.join('|')})/`);
|
||||
|
||||
describe('the ledger reaches only /investigate and /review', () => {
|
||||
test('every other skill renders byte-identical on every host with modules/cuts.json and with an empty ledger', () => {
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cut-scope-'));
|
||||
try {
|
||||
const copy = path.join(work, 'checkout');
|
||||
const listed = Bun.spawnSync(['git', 'ls-files', '-z'], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 60_000 });
|
||||
if (listed.exitCode !== 0) throw new Error(`git ls-files failed: ${listed.stderr.toString()}`);
|
||||
for (const rel of listed.stdout.toString().split('\0').filter(Boolean)) {
|
||||
const source = path.join(ROOT, rel);
|
||||
if (!fs.existsSync(source) || !fs.lstatSync(source).isFile()) continue;
|
||||
fs.mkdirSync(path.dirname(path.join(copy, rel)), { recursive: true });
|
||||
fs.copyFileSync(source, path.join(copy, rel));
|
||||
}
|
||||
fs.symlinkSync(path.join(ROOT, 'node_modules'), path.join(copy, 'node_modules'));
|
||||
fs.writeFileSync(path.join(copy, CUT_LEDGER_FILE), JSON.stringify({ ...ledger, cuts: [], pins: {} }, null, 2) + '\n');
|
||||
|
||||
const out = path.join(work, 'out');
|
||||
const [withCuts, empty] = [ROOT, copy].map(cwd => {
|
||||
const render = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--out-dir', out],
|
||||
{ cwd, stdout: 'pipe', stderr: 'pipe', timeout: 180_000 });
|
||||
if (render.exitCode !== 0) throw new Error(`gen-skill-docs in ${cwd} failed (exit ${render.exitCode}):\n${render.stderr.toString()}`);
|
||||
const tree = readOtherSkills(out);
|
||||
fs.rmSync(out, { recursive: true, force: true });
|
||||
return tree;
|
||||
});
|
||||
|
||||
expect(withCuts.get('review/SKILL.md')?.equals(empty.get('review/SKILL.md')!), 'the empty ledger must change /review, or this test proves nothing').toBe(false);
|
||||
const differs = [...new Set([...withCuts.keys(), ...empty.keys()])].filter(rel => !targetedPath.test(rel)).sort()
|
||||
.filter(rel => !withCuts.get(rel)?.equals(empty.get(rel) ?? Buffer.alloc(0)) || !empty.has(rel));
|
||||
expect(differs, `${CUT_LEDGER_FILE} changed files outside /investigate and /review`).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
}, 600_000);
|
||||
});
|
||||
@@ -326,6 +326,7 @@ describe('parity with the measured prototype', () => {
|
||||
/** Files the shipped function reaches beyond the prototype, each with the reviewed edge class behind it. */
|
||||
const REVIEWED_SUPERSET: Record<string, string> = {
|
||||
setup: './setup, $ROOT/setup and join(..., "setup") exec edges (plan A2-2); setup is a full-gate file, so the closure stops there',
|
||||
'scripts/cut-ledger.ts': 'GST-1088: scripts/resolvers/preamble.ts imports it (the R56 cut ledger), so it reaches every consumer of the preamble',
|
||||
'lib/bun-isolation.ts': 'GSTA-56: every bun launcher (script-invocation, restamp, qa-deadline, the hooks) imports it, so it reaches every consumer of those launchers',
|
||||
'lib/ship-receipt-docs.ts': 'bin/gstack-ship-receipt imports it (the docs audit record and doc caps), so it reaches every consumer of the receipt binary',
|
||||
'lib/ship-receipt-prose.ts': 'bin/gstack-ship-receipt imports it (read --check-prose), so it reaches every consumer of the receipt binary',
|
||||
|
||||
-1
@@ -67,7 +67,6 @@
|
||||
"## Step 2: Read the checklist",
|
||||
"## Step 2.5: Check for review bot comments",
|
||||
"## Step 3: Get the diff",
|
||||
"## Step 3.4: Workspace-aware queue status (advisory)",
|
||||
"## Step 3.5: Diff scans",
|
||||
"## Step 3.6: Gather review context",
|
||||
"## Step 4: Critical pass (core review)",
|
||||
|
||||
+16
-11
@@ -10,9 +10,15 @@ import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runCapturedCommand } from './helpers/sync-command-capture';
|
||||
import { between, expectAbsent, expectMentions, expectOrdered, expectTokens } from './helpers/prompt-structure';
|
||||
import { loadCutLedger } from '../scripts/cut-ledger';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const MAX_SKILL_DESCRIPTION_LENGTH = 1024;
|
||||
/** GST-1088: R56 cuts (modules/cuts.json) remove learnings blocks from /investigate and /review; their absence is test/cut-ledger.test.ts's job. */
|
||||
const CUT_IDS = new Set(loadCutLedger().cuts.map(c => c.id));
|
||||
const notCut = (skill: string, part: string) => !CUT_IDS.has(`part:${skill}/${part}`);
|
||||
const SEARCH_SAMPLE = notCut('review', 'learnings-search') ? 'review' : 'office-hours';
|
||||
const LOG_SAMPLE = notCut('review', 'capture-learnings') ? 'review' : 'retro';
|
||||
|
||||
// Carved-skill aware (v2 plan T9): ship is now a skeleton SKILL.md + sections/*.md.
|
||||
// Read the union so assertions about content that MOVED into a section still pass.
|
||||
@@ -375,8 +381,7 @@ describe('gen-skill-docs', () => {
|
||||
});
|
||||
|
||||
test('generated SKILL.md with LEARNINGS_LOG contains operational type', () => {
|
||||
// Check a skill that has LEARNINGS_LOG (e.g., review)
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, LOG_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('operational');
|
||||
});
|
||||
|
||||
@@ -3456,7 +3461,7 @@ describe('Factory generation (--host factory)', () => {
|
||||
});
|
||||
|
||||
test('Factory preamble uses .factory paths', () => {
|
||||
const content = fs.readFileSync(path.join(FACTORY_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(FACTORY_DIR, `gstack-${SEARCH_SAMPLE}`, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('GSTACK_ROOT');
|
||||
expect(content).toContain('_r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack');
|
||||
expect(content).toContain('$GSTACK_BIN/gstack-config');
|
||||
@@ -4302,7 +4307,7 @@ describe('codex commands must not use inline $(git rev-parse --show-toplevel) fo
|
||||
// ─── Learnings + Confidence Resolver Tests ─────────────────────
|
||||
|
||||
describe('LEARNINGS_SEARCH resolver', () => {
|
||||
const SEARCH_SKILLS = ['review', 'ship', 'plan-eng-review', 'investigate', 'office-hours', 'plan-ceo-review'];
|
||||
const SEARCH_SKILLS = ['review', 'ship', 'plan-eng-review', 'investigate', 'office-hours', 'plan-ceo-review'].filter(s => notCut(s, 'learnings-search'));
|
||||
|
||||
for (const skill of SEARCH_SKILLS) {
|
||||
test(`${skill} generated SKILL.md contains learnings search`, () => {
|
||||
@@ -4313,13 +4318,13 @@ describe('LEARNINGS_SEARCH resolver', () => {
|
||||
}
|
||||
|
||||
test('learnings search includes cross-project config check', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, SEARCH_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('cross_project_learnings');
|
||||
expect(content).toContain('--cross-project');
|
||||
});
|
||||
|
||||
test('learnings search includes AskUserQuestion for first-time cross-project opt-in', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, SEARCH_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Enable cross-project learnings');
|
||||
expect(content).toContain('project-scoped only');
|
||||
});
|
||||
@@ -4349,13 +4354,13 @@ describe('LEARNINGS_SEARCH resolver', () => {
|
||||
});
|
||||
|
||||
test('learnings search mentions prior learning applied display format', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, SEARCH_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Prior learning applied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LEARNINGS_LOG resolver', () => {
|
||||
const LOG_SKILLS = ['review', 'retro', 'investigate'];
|
||||
const LOG_SKILLS = ['review', 'retro', 'investigate'].filter(s => notCut(s, 'capture-learnings'));
|
||||
|
||||
for (const skill of LOG_SKILLS) {
|
||||
test(`${skill} generated SKILL.md contains learnings log`, () => {
|
||||
@@ -4366,21 +4371,21 @@ describe('LEARNINGS_LOG resolver', () => {
|
||||
}
|
||||
|
||||
test('learnings log documents all type values', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, LOG_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
for (const type of ['pattern', 'pitfall', 'preference', 'architecture', 'tool']) {
|
||||
expect(content).toContain(type);
|
||||
}
|
||||
});
|
||||
|
||||
test('learnings log documents all source values', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, LOG_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
for (const source of ['observed', 'user-stated', 'inferred', 'cross-model']) {
|
||||
expect(content).toContain(source);
|
||||
}
|
||||
});
|
||||
|
||||
test('learnings log includes files field for staleness detection', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, LOG_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('"files"');
|
||||
expect(content).toContain('staleness detection');
|
||||
});
|
||||
|
||||
@@ -11,14 +11,18 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { runGeneration } from '../scripts/gen-skill-docs';
|
||||
import { loadCutLedger } from '../scripts/cut-ledger';
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-learnings-callers-'));
|
||||
const out = path.join(tmp, 'render');
|
||||
const BUN_MISSING = 'gstack-learnings-search: bun not found on PATH, so learnings could not be read. Fix: install Bun (https://bun.sh), then re-run ./setup.';
|
||||
|
||||
const CALLERS: Array<{ name: string; file: (host: string) => string; hosts: string[] }> = [
|
||||
{ name: 'Prior Learnings (review)', file: h => (h === 'claude' ? 'review/SKILL.md' : '.agents/skills/gstack-review/SKILL.md'), hosts: ['claude', 'codex'] },
|
||||
{ name: 'investigate prior learnings', file: h => (h === 'claude' ? 'investigate/SKILL.md' : '.agents/skills/gstack-investigate/SKILL.md'), hosts: ['claude', 'codex'] },
|
||||
/** modules/cuts.json ids; a caller whose `cut` is listed must render no search fence at all. */
|
||||
const CUT = new Set(loadCutLedger().cuts.map(c => c.id));
|
||||
|
||||
const CALLERS: Array<{ name: string; file: (host: string) => string; hosts: string[]; cut?: string }> = [
|
||||
{ name: 'Prior Learnings (review)', file: h => (h === 'claude' ? 'review/SKILL.md' : '.agents/skills/gstack-review/SKILL.md'), hosts: ['claude', 'codex'], cut: 'part:review/learnings-search' },
|
||||
{ name: 'investigate prior learnings', file: h => (h === 'claude' ? 'investigate/SKILL.md' : '.agents/skills/gstack-investigate/SKILL.md'), hosts: ['claude', 'codex'], cut: 'part:investigate/learnings-search' },
|
||||
{ name: 'qa fix-loop search', file: h => (h === 'claude' ? 'qa/SKILL.md' : '.agents/skills/gstack-qa/SKILL.md'), hosts: ['claude', 'codex'] },
|
||||
{ name: 'ship adversarial search', file: h => (h === 'claude' ? 'ship/sections/adversarial.md' : '.agents/skills/gstack-ship/sections/adversarial.md'), hosts: ['claude', 'codex'] },
|
||||
{ name: 'review army specialist search', file: () => 'review/sections/review-army.md', hosts: ['claude'] },
|
||||
@@ -50,6 +54,10 @@ describe('B5: learnings callers report a failed search', () => {
|
||||
for (const host of caller.hosts) {
|
||||
test(`${host}: ${caller.name}`, () => {
|
||||
const fences = searchFences(caller.file(host));
|
||||
if (caller.cut && CUT.has(caller.cut)) {
|
||||
expect(fences).toEqual([]);
|
||||
return;
|
||||
}
|
||||
expect(fences.length).toBeGreaterThan(0);
|
||||
for (const fence of fences) {
|
||||
const missing = run(fence, host, `echo "${BUN_MISSING}" >&2\necho "second line" >&2\nexit 127`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { execSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { loadCutLedger } from '../scripts/cut-ledger';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin');
|
||||
@@ -137,8 +138,14 @@ describe('gstack-learnings-log', () => {
|
||||
// Caller contract: investigate/SKILL.md.tmpl must emit type:"investigation"
|
||||
// verbatim. Guards against the template drifting to an invalid type and
|
||||
// silently breaking the log path. See codex review finding for #1423.
|
||||
// While modules/cuts.json cuts part:investigate/investigation-log (GST-1088), the template
|
||||
// must carry no learnings log at all; restoring the cut restores the contract.
|
||||
test('investigate template emits type:"investigation" verbatim (caller contract)', () => {
|
||||
const tmpl = fs.readFileSync(path.join(ROOT, 'investigate/SKILL.md.tmpl'), 'utf-8');
|
||||
if (loadCutLedger().cuts.some(c => c.id === 'part:investigate/investigation-log')) {
|
||||
expect(tmpl).not.toContain('gstack-learnings-log');
|
||||
return;
|
||||
}
|
||||
// The invocation line must include "type":"investigation" exactly.
|
||||
expect(tmpl).toContain('"type":"investigation"');
|
||||
});
|
||||
|
||||
@@ -186,8 +186,8 @@ describe('safe classification (A1-2)', () => {
|
||||
describe('derived dependencies of this checkout', () => {
|
||||
test('a bin script a skill template invokes selects that skill\'s cases', () => {
|
||||
const consumers = real.e2e.get('bin/gstack-next-version') ?? new Set();
|
||||
expect(consumers.has('review-sql-injection')).toBe(true);
|
||||
expect(real.trace('review-sql-injection', 'bin/gstack-next-version')?.[0]).toBe('review/SKILL.md.tmpl');
|
||||
expect(consumers.has('ship-docsync-missing-marker')).toBe(true);
|
||||
expect(real.trace('ship-docsync-missing-marker', 'bin/gstack-next-version')?.[0]).toBe('ship/SKILL.md.tmpl');
|
||||
});
|
||||
|
||||
test('a compiled design source reaches exactly the cases whose closure names the design binary', () => {
|
||||
@@ -301,7 +301,7 @@ describe('deleted files (paths absent from the head tree)', () => {
|
||||
expect(result.mode).toBe('dependents');
|
||||
expect(result.e2e).toContain('review-sql-injection');
|
||||
expect(result.derivedFiles[0]?.file).toBe('bin/gstack-next-version');
|
||||
expect(real.referencers('bin/gstack-next-version')).toContain('review/SKILL.md.tmpl');
|
||||
expect(real.referencers('bin/gstack-next-version')).toContain('ship/SKILL.md.tmpl');
|
||||
});
|
||||
|
||||
test('a file the PR adds is read from the checkout under review, not from the selector\'s own tree (an older install)', () => {
|
||||
|
||||
@@ -222,11 +222,13 @@ describe('ship Step 9 instructions', () => {
|
||||
|
||||
describe('review Step 3 and Step 5.8 instructions', () => {
|
||||
const skill = fs.readFileSync(path.join(ROOT, 'review/SKILL.md'), 'utf8');
|
||||
const step3 = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.4:'));
|
||||
const step3 = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.5:'));
|
||||
const step58 = skill.slice(skill.indexOf('## Step 5.8: Persist Eng Review result'), skill.indexOf('### Report the final review'));
|
||||
const flat3 = step3.replace(/\s+/g, ' '), flat58 = step58.replace(/\s+/g, ' ');
|
||||
|
||||
test('the first pass starts full and only the pass after an edited pass narrows its DIFF_BASE, never Step 4.8 or other base uses', () => {
|
||||
expect(skill.indexOf('## Step 3.5:')).toBeGreaterThan(skill.indexOf('## Step 3: Get the diff'));
|
||||
expect(skill.indexOf('## Step 3: Get the diff')).toBeGreaterThanOrEqual(0);
|
||||
const block = step3.slice(step3.indexOf('```bash'), step3.indexOf('```', step3.indexOf('```bash') + 7));
|
||||
expect(block).toContain('gstack-review-log --start review\ngit diff "$DIFF_BASE"');
|
||||
expect(block).not.toContain('--fix-range');
|
||||
|
||||
@@ -109,7 +109,7 @@ test('review emits scope check after the plan audit and before the checklist', (
|
||||
test('review composes confidence-tagged findings into one final report with explicit incomplete coverage', () => {
|
||||
const flat = skill.replace(/\s+/g, ' ');
|
||||
expect(flat).toContain('CRITICAL/INFORMATIONAL');
|
||||
const report = flat.slice(flat.indexOf('### Report the final review'), flat.indexOf('{{LEARNINGS_LOG}}'));
|
||||
const report = flat.slice(flat.indexOf('### Report the final review'), flat.indexOf('## Important Rules'));
|
||||
expect(report).toMatch(/one final report/i);
|
||||
expect(report).toMatch(/unresolved non-advisory defects/i);
|
||||
expect(report).toMatch(/[Ss]tate INCOMPLETE if `COMPLETED` is false/);
|
||||
@@ -167,7 +167,7 @@ test('caller QA runs charter and setup after resource loading and has a severity
|
||||
test('review prepares context and deduplicates before classifying findings', () => {
|
||||
const positions = [
|
||||
'## Step 3.5: Diff scans', '## Step 3.6: Gather review context',
|
||||
'{{LEARNINGS_SEARCH}}', '{{ASIDE_RESEARCH}}', '## Step 4: Critical pass',
|
||||
'{{ASIDE_RESEARCH}}', '## Step 4: Critical pass',
|
||||
'## Step 5: Fix-First Review', '{{CROSS_REVIEW_DEDUP}}',
|
||||
'**Keep decisions through fix cycles:**', '### Step 5a: Classify each finding',
|
||||
].map(marker => skill.indexOf(marker));
|
||||
@@ -237,7 +237,7 @@ test('review section index follows the actual pre-fix execution order', () => {
|
||||
});
|
||||
|
||||
test('review finalization ownership: initialize invocation state and capture the core token before reading', () => {
|
||||
const start = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.4:'));
|
||||
const start = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.5:'));
|
||||
expect(start).toContain('CYCLES=0');
|
||||
expect(skill.match(/CYCLES=0/g)).toHaveLength(1);
|
||||
expect(start).toContain('gstack-review-log --start review\ngit diff "$DIFF_BASE"');
|
||||
@@ -350,7 +350,7 @@ test('review scope notes remain provisional until the plan section emits the onl
|
||||
// order, neither pins where the check is emitted; seam=none; no_claim=the judged clarity of the sentences.
|
||||
test('the final Scope Check is emitted once, in Step 3 after the full diff read, and every owner names that point', () => {
|
||||
const ctx: TemplateContext = { skillName: 'review', tmplPath: 'review/SKILL.md.tmpl', host: 'claude', paths: HOST_PATHS.claude };
|
||||
const step3 = between(skill, '## Step 3: Get the diff', '## Step 3.4:');
|
||||
const step3 = between(skill, '## Step 3: Get the diff', '## Step 3.5:');
|
||||
expectOrdered(step3, ['git diff "$DIFF_BASE"', "5. First pass only: emit Step 1.5's final Scope Check; `Diff read` records this read."], 'step3');
|
||||
expect(skill.split("emit Step 1.5's final Scope Check")).toHaveLength(2);
|
||||
const audit = generatePlanCompletionAuditReview(ctx);
|
||||
@@ -398,7 +398,7 @@ test('review confidence uses its severity labels without an undefined P0 excepti
|
||||
});
|
||||
|
||||
test('review names the lifecycle and record owners before using their persistence rules', () => {
|
||||
const start = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.4:')).replace(/\s+/g, ' ');
|
||||
const start = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.5:')).replace(/\s+/g, ' ');
|
||||
expect(start).toContain('REVIEW_START / PASS_START |');
|
||||
expect(start).toContain('`review_binding` |');
|
||||
expect(start).toContain('`snapshot_covered_paths` |');
|
||||
@@ -673,7 +673,7 @@ test('review persists gate identity fields, counts open findings and reports one
|
||||
['`open`', 'count', '`issues_found`'],
|
||||
['logger', 'stamps', '`actor`'],
|
||||
], 'review record');
|
||||
const report = compact(between(skill, '### Report the final review', '{{LEARNINGS_LOG}}'));
|
||||
const report = compact(between(skill, '### Report the final review', '## Important Rules'));
|
||||
expectOrdered(report, ['`Gate edits: none detected (', '`Gate edits: N listed, M read, K findings —', '`Gate edits: partial (', '`Gate edits: UNAVAILABLE —'], 'final report');
|
||||
expectMentions(report, [['unavailable', 'never', 'none detected']], 'final report');
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ function fixture(copy: boolean) {
|
||||
const shims = path.join(temp, 'shims');
|
||||
// Real setup, generator, resolver and migration code; only dependency
|
||||
// installation and binary compilation are stubbed to keep this test free.
|
||||
for (const dir of ['scripts', 'hosts', 'lib', 'model-overlays', 'browse/src', 'design/src', 'openclaw/templates', 'qa']) {
|
||||
for (const dir of ['scripts', 'hosts', 'lib', 'model-overlays', 'modules', 'browse/src', 'design/src', 'openclaw/templates', 'qa']) {
|
||||
fs.cpSync(path.join(ROOT, dir), path.join(root, dir), { recursive: true });
|
||||
}
|
||||
fs.copyFileSync(path.join(ROOT, 'setup'), path.join(root, 'setup'));
|
||||
|
||||
Reference in new issue
Block a user