mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* feat(plan-design-review): skill diet (GSTA-134 pilot 1) SKILL.md 66,063 -> 56,209 B (claude host); review-sections.md 52,243 -> 50,183 B. Cut model-known prose (cognitive patterns, verbose designer prose, duplicated formatting rules); UX principles kept as a five-line Krug summary after the blind judge showed wayfinding loss without it. Section layout unchanged: autoplan's methodology snapshot requires review-sections.md as the only indexed section. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(office-hours): skill diet (GSTA-134 pilot 2) Moves the Phase 3.5 Codex second opinion and the Phase 4.25 visual design (designer mockups / HTML sketch) out of the eager SKILL.md into on-demand sections behind STOP pointers; the CODEX_SECOND_OPINION, DESIGN_MOCKUP and DESIGN_SKETCH resolvers are untouched. Claude-host SKILL.md 73,248 -> 43,885 bytes. skill-diet before/after (Opus, 9 tasks x 3): success 26/27 -> 27/27, median tokens per successful run 170,197 -> 124,941; blind judge after 10, before 5 of 15. YC pitch, flattery and promo text unchanged (sections byte-identical to main). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(design-review): skill diet (GSTA-134 pilot 3) Moves the outside voices and the test-framework bootstrap into on-demand sections (the bootstrap now loads at Phase 8e.5, its only consumer: a JS-behavior fix that needs a regression test with no known test command), replaces the UX principles block with a Krug summary, and tightens the DESIGN_METHODOLOGY prose (design-review is its only consumer). Shared resolvers TEST_BOOTSTRAP and DESIGN_OUTSIDE_VOICES are untouched. Claude-host SKILL.md 115,947 -> 84,869 bytes. skill-diet before/after (Opus, 12 tasks x 3): success 32/36 -> 34/36, median tokens per successful run 326,662 -> 257,819; blind judge after 9, before 5, tie 1. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: re-pin size, context and carve guards for the GSTA-134 skill diets Every pin moves down except office-hours' parity maxSizeRatio (1.1796 -> 1.1921, GStack Minder's call as merge-queue owner): its union grew 1,662 B because the second-opinion question now sits in the skeleton while the untouched CODEX_SECOND_OPINION resolver keeps its own copy. design-review leaves the size allowlist on every host. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: drop the stale <affected-url> placeholder after the design-review Phase 8d diet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: design-review checklist headers count the items they list after the diet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: design-review report path and UX_PRINCIPLES consumers after the skill diets Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: CHANGELOG [Unreleased] for the GSTA-134 skill diets Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
167 lines
8.7 KiB
TypeScript
167 lines
8.7 KiB
TypeScript
/**
|
|
* Tripwire: design skills read the paths `$D` printed for this round, never a
|
|
* fixed variant name or a directory glob, because `$D` never overwrites and a
|
|
* second round lands at bumped names (variant-A-2.png). Runs on the generated
|
|
* SKILL.md files (with their sections) and the resolver sources.
|
|
*/
|
|
|
|
import { describe, expect, test } from 'bun:test';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
|
|
/** The one reasoned exception, anchored on content rather than a line number. */
|
|
const ALLOWLIST = [{
|
|
file: 'design-html/SKILL.md',
|
|
line: '_VARIANTS=$(ls -t "$GSTACK_STATE_ROOT/projects/$SLUG/designs/"*/variant-*.png 2>/dev/null | head -1)',
|
|
reason: 'cross-session discovery probe: design-html runs no generator, it only detects that earlier rounds exist; its approved image comes from approved.json',
|
|
}];
|
|
|
|
const GENERATOR = /(?:\$D|"\$D")\s+(generate|variants|iterate|evolve)\b/;
|
|
const CONSUMER = /^\s*(?:\$D|"\$D")\s+(compare|check)\b/m;
|
|
|
|
function flowFiles(): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
|
|
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
const skill = `${entry.name}/SKILL.md`;
|
|
if (fs.existsSync(path.join(ROOT, skill))) out.push(skill);
|
|
const sections = `${entry.name}/sections`;
|
|
if (fs.existsSync(path.join(ROOT, sections))) {
|
|
for (const f of fs.readdirSync(path.join(ROOT, sections))) if (f.endsWith('.md')) out.push(`${sections}/${f}`);
|
|
}
|
|
}
|
|
for (const f of fs.readdirSync(path.join(ROOT, 'scripts/resolvers'))) if (f.endsWith('.ts')) out.push(`scripts/resolvers/${f}`);
|
|
return out;
|
|
}
|
|
|
|
function bashLines(text: string): string[] {
|
|
return [...text.matchAll(/```bash\n([\s\S]*?)```/g)].flatMap(m => m[1].split('\n'));
|
|
}
|
|
|
|
function argAfter(line: string, flag: string): string | null {
|
|
const m = line.match(new RegExp(`${flag}\\s+("([^"]+)"|(\\S+))`));
|
|
return m ? (m[2] ?? m[3]) : null;
|
|
}
|
|
|
|
/** Concrete bans only; returns one message per violation. */
|
|
export function scanForBannedReads(file: string, text: string): string[] {
|
|
const violations: string[] = [];
|
|
const allowed = new Set(ALLOWLIST.filter(a => a.file === file).map(a => a.line.trim()));
|
|
const outputs = new Set<string>();
|
|
for (const line of text.split('\n')) {
|
|
if (GENERATOR.test(line)) {
|
|
const out = argAfter(line, '--output');
|
|
if (out) outputs.add(out);
|
|
}
|
|
}
|
|
text.split('\n').forEach((line, i) => {
|
|
const where = `${file}:${i + 1}`;
|
|
if (allowed.has(line.trim())) return;
|
|
if (/variant-[A-Z]\.png/.test(line)) violations.push(`${where} fixed variant name: ${line.trim()}`);
|
|
if (/variant-\*\.png/.test(line)) violations.push(`${where} variant glob: ${line.trim()}`);
|
|
if (/variant-\{letter\}/.test(line) && !/gstack-design-claim/.test(line)) violations.push(`${where} variant-{letter} placeholder read: ${line.trim()}`);
|
|
const copy = line.match(/(?:^|[\s;&|(`])(?:cp|mv)\s+(.*)$/);
|
|
if (copy && (/variant|responsive-|outputPath/.test(copy[1]) || [...outputs].some(o => copy[1].includes(o)))) {
|
|
violations.push(`${where} cp/mv of a $D output (publish with gstack-design-claim): ${line.trim()}`);
|
|
}
|
|
const image = /\$D\s+check\b/.test(line) ? argAfter(line, '--image') : /\$D\s+verify\b/.test(line) ? argAfter(line, '--mockup') : null;
|
|
if (image && outputs.has(image)) violations.push(`${where} reads the requested --output instead of the printed path: ${line.trim()}`);
|
|
});
|
|
return violations;
|
|
}
|
|
|
|
const files = flowFiles();
|
|
const read = (f: string) => fs.readFileSync(path.join(ROOT, f), 'utf8');
|
|
|
|
describe('design printed-paths tripwire', () => {
|
|
// Value: protects=no skill reads a fixed variant name, glob or requested --output after $D bumps a name;
|
|
// fails_when=a template reintroduces variant-A.png, variant-*.png, a cp of a $D output or a literal check path; why_new=#1529; seam=none
|
|
test('generated skills and resolvers read only printed paths', () => {
|
|
const violations = files.flatMap(f => scanForBannedReads(f, read(f)));
|
|
expect(violations).toEqual([]);
|
|
});
|
|
|
|
// Value: protects=the allowlist cannot rot into a blanket pass; fails_when=the probe line changes and the entry no longer anchors;
|
|
// why_new=content-anchored allowlist; seam=none
|
|
test('the allowlist entry still anchors to real content', () => {
|
|
for (const a of ALLOWLIST) {
|
|
expect(read(a.file).split('\n').map(l => l.trim())).toContain(a.line);
|
|
expect(a.reason.length).toBeGreaterThan(20);
|
|
}
|
|
});
|
|
|
|
// Value: protects=the scanner actually catches each banned form; fails_when=a regex is loosened until it matches nothing;
|
|
// why_new=negative controls for the tripwire; seam=none
|
|
test('fixtures with each banned form fail', () => {
|
|
const cases = [
|
|
'$D compare --images "$_DESIGN_DIR/variant-A.png,$_DESIGN_DIR/variant-B.png"',
|
|
'_IMAGES=$(ls "$_DESIGN_DIR"/variant-*.png)',
|
|
'5. Quality check: {$D path} check --image {_DESIGN_DIR}/variant-{letter}.png',
|
|
'cp /tmp/variant-B.png "$_DESIGN_DIR/"',
|
|
'$D generate --brief "x" --output "$DIR/target.png"\n$D verify --mockup "$DIR/target.png" --screenshot after.png',
|
|
'$D generate --brief "x" --output "$DIR/target.png"\n$D check --image "$DIR/target.png" --brief x',
|
|
];
|
|
for (const c of cases) expect(scanForBannedReads('fixture.md', c).length, c).toBeGreaterThan(0);
|
|
expect(scanForBannedReads('fixture.md', 'FINAL=$(gstack-design-claim "<outputPath>" "{dir}/variant-{letter}.png")')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('design generating flows', () => {
|
|
const flows: Record<string, string[]> = {
|
|
'office-hours': ['office-hours/sections/visual-design.md'],
|
|
'plan-design-review': ['plan-design-review/SKILL.md'],
|
|
'design-consultation': ['design-consultation/sections/proposal-and-preview.md'],
|
|
'design-shotgun': ['design-shotgun/SKILL.md'],
|
|
};
|
|
|
|
for (const [skill, sources] of Object.entries(flows)) {
|
|
// Value: protects=round accounting (with the zero-saved stop) comes before the first board or check in each flow;
|
|
// fails_when=a template moves the board or check above the accounting marker or drops the stop; why_new=CEO accounting position; seam=none
|
|
test(`${skill}: round accounting precedes the first consumer and stops on zero saved`, () => {
|
|
const text = sources.map(read).join('\n');
|
|
const accounting = text.indexOf('<!-- design:round-accounting -->');
|
|
const board = text.indexOf('<!-- design:board -->');
|
|
const consumer = text.search(CONSUMER);
|
|
expect(accounting).toBeGreaterThan(-1);
|
|
expect(board).toBeGreaterThan(accounting);
|
|
expect(consumer).toBeGreaterThan(accounting);
|
|
const span = text.slice(accounting, Math.min(board, consumer));
|
|
expect(span).toMatch(/stop/i);
|
|
expect(span).toMatch(/nothing was saved|saves nothing|zero/i);
|
|
});
|
|
}
|
|
|
|
// Value: protects=generating steps capture JSON and the exit code instead of dying under set -e;
|
|
// fails_when=a bare `$D generate ...` line returns; why_new=eng template exit handling; seam=none
|
|
test('every generating bash line captures stdout and the exit code', () => {
|
|
const bare = files.filter(f => f.endsWith('.md')).flatMap(f => bashLines(read(f))
|
|
.filter(l => GENERATOR.test(l) && !/=\$\(.*\);\s*_?RC=\$\?/.test(l))
|
|
.map(l => `${f}: ${l.trim()}`));
|
|
expect(bare).toEqual([]);
|
|
});
|
|
|
|
// Value: protects=every approval writer records approved_path so a bumped round resolves to the right image;
|
|
// fails_when=a writer goes back to approved_variant only; why_new=approved-image identity; seam=none
|
|
test('every approval-record writer stores approved_path', () => {
|
|
const writers = files.flatMap(f => read(f).split('\n')
|
|
.filter(l => /approved_variant/.test(l) && (/>\s*"\$_DESIGN_DIR\/approved\.json"/.test(l) || /Write of `\$_DESIGN_DIR\/approved\.json`/.test(l)))
|
|
.map(l => ({ f, l })));
|
|
expect(writers.length).toBeGreaterThanOrEqual(3);
|
|
for (const { f, l } of writers) expect(l, f).toContain('approved_path');
|
|
});
|
|
|
|
// Value: protects=shotgun generation stages in a fresh mktemp dir and publishes through the no-clobber helper;
|
|
// fails_when=the shared /tmp/variant file and cp return; why_new=eng staging requirement; seam=none
|
|
test('design-shotgun stages per run and publishes with gstack-design-claim', () => {
|
|
const shotgun = read('design-shotgun/SKILL.md');
|
|
const prompt = shotgun.slice(shotgun.indexOf('### Step 3c'), shotgun.indexOf('### Step 3d'));
|
|
// INV-3: the per-run staging dir honors TMPDIR.
|
|
expect(prompt).toContain('mktemp -d "${TMPDIR:-/tmp}/');
|
|
expect(prompt).toMatch(/gstack-design-claim "<saved path>"/);
|
|
expect(prompt).not.toMatch(/\/tmp\/variant-/);
|
|
expect(prompt).toMatch(/every published path/);
|
|
});
|
|
});
|