Files
gstack/test/design-setup-probe.test.ts
Garry Tanandcapy-ai[bot] a14c28aca7 v1.91.289.0 feat: skill diet pilots for plan-design-review, office-hours and design-review (GSTA-134) (#3525)
* 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>
2026-10-28 04:17:22 +00:00

135 lines
7.5 KiB
TypeScript

/**
* B4 (#1076, #1254) and F3: the rendered DESIGN SETUP and taste-profile fences,
* executed with stub binaries.
*
* B4: an executable design binary that dies at launch (SIGKILL from an invalid
* macOS code signature) used to print DESIGN_READY. Now the binary must start
* within 10 seconds; a success is cached by inode + mtime.
* F3: the taste-profile probe double-quoted `~/.claude/...`, so it never found
* gstack-slug and always printed NO_TASTE_PROFILE; a failed slug lookup is now
* reported as TASTE_PROFILE_UNAVAILABLE instead of "no profile".
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawn, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { runGeneration } from '../scripts/gen-skill-docs';
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-probe-'));
const out = path.join(tmp, 'render');
const HOSTS = [{ name: 'claude', dir: (s: string) => path.join(out, s), root: '.claude/skills/gstack' },
{ name: 'codex', dir: (s: string) => path.join(out, '.agents', 'skills', `gstack-${s}`), root: '.codex/skills/gstack' }];
function fenceAfter(file: string, heading: string): string {
const text = fs.readFileSync(file, 'utf8');
return text.slice(text.indexOf(heading)).match(/```bash\n([\s\S]*?)\n```/)![1];
}
/** A HOME with a gstack install whose design binary runs `body`. */
function world(root: string, body: string | null) {
const home = fs.mkdtempSync(path.join(tmp, 'home-'));
const install = path.join(home, root);
const state = path.join(home, 'state');
for (const dir of ['bin', 'lib', 'design/dist']) fs.mkdirSync(path.join(install, dir), { recursive: true });
fs.mkdirSync(state);
fs.writeFileSync(path.join(install, 'bin', 'gstack-paths'), `#!/bin/sh\necho "${state}"\n`, { mode: 0o755 });
if (body !== null) fs.writeFileSync(path.join(install, 'design/dist/design'), `#!/bin/sh\necho launched >> "${home}/launches"\n${body}\n`, { mode: 0o755 });
const cwd = fs.mkdtempSync(path.join(tmp, 'cwd-'));
return { home, install, state, cwd, launches: () => (fs.existsSync(path.join(home, 'launches')) ? fs.readFileSync(path.join(home, 'launches'), 'utf8').split('\n').filter(Boolean).length : 0) };
}
function run(script: string, w: { home: string; cwd: string }, PATH = '/usr/bin:/bin') {
return spawnSync('env', ['-i', `HOME=${w.home}`, `PATH=${PATH}`, 'bash', '-c', script], { cwd: w.cwd, encoding: 'utf8', timeout: 30_000 });
}
beforeAll(async () => {
const r = await runGeneration({ host: 'all', outputRoot: out });
if (r.exitCode !== 0) throw new Error(r.diagnostics.filter(d => d.kind === 'error').map(d => d.message).join('\n'));
});
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
describe('B4: DESIGN_READY requires a binary that starts', () => {
for (const host of HOSTS) {
const setup = () => fenceAfter(path.join(host.dir('design-review'), 'SKILL.md'), '## DESIGN SETUP');
test(`${host.name}: a binary that launches is ready, and the success is cached by inode and mtime`, () => {
const w = world(host.root, 'exit 0');
const first = run(setup(), w);
expect(first.stdout.trim(), first.stderr).toBe(`DESIGN_READY: ${w.install}/design/dist/design`);
expect(run(setup(), w).stdout.trim()).toStartWith('DESIGN_READY: ');
expect(w.launches()).toBe(1);
fs.writeFileSync(path.join(w.install, 'design/dist/design'), `#!/bin/sh\necho launched >> "${w.home}/launches"\nexit 3\n`);
fs.utimesSync(path.join(w.install, 'design/dist/design'), new Date(), new Date(Date.now() + 5_000));
expect(run(setup(), w).stdout.trim()).toBe(`DESIGN_NOT_AVAILABLE: ${w.install}/design/dist/design --version exited 3`);
});
test(`${host.name}: a binary killed at launch is not ready and names the fix`, () => {
const w = world(host.root, 'kill -9 $$');
const r = run(setup(), w);
expect(r.stdout.trim()).toBe(`DESIGN_NOT_AVAILABLE: ${w.install}/design/dist/design --version exited 137 (killed at launch; on macOS usually an invalid code signature). Fix: cd ${w.install} && ./setup`);
expect(fs.existsSync(path.join(w.state, 'design-ready'))).toBe(false);
});
test(`${host.name}: a missing binary says how to install it`, () => {
const w = world(host.root, null);
expect(run(setup(), w).stdout.trim()).toBe(`DESIGN_NOT_AVAILABLE: ${w.install}/design/dist/design is not installed. Fix: cd ${w.install} && ./setup`);
});
}
test('claude: without timeout, gtimeout or perl the launch is not attempted unbounded', () => {
const w = world(HOSTS[0].root, 'exit 0');
const bin = path.join(w.home, 'minbin');
fs.mkdirSync(bin);
for (const tool of ['ls', 'awk', 'stat', 'cat', 'git', 'bash', 'sh', 'env']) {
const found = spawnSync('bash', ['-c', `command -v ${tool}`], { encoding: 'utf8', timeout: 5_000 }).stdout.trim();
if (found) fs.symlinkSync(found, path.join(bin, tool));
}
const r = run(fenceAfter(path.join(HOSTS[0].dir('design-review'), 'SKILL.md'), '## DESIGN SETUP'), w, bin);
expect(r.stdout.trim()).toBe(`DESIGN_NOT_AVAILABLE: no timeout/gtimeout/perl to bound ${w.install}/design/dist/design`);
expect(w.launches()).toBe(0);
});
test('claude: a binary that hangs times out after 10 seconds', async () => {
const w = world(HOSTS[0].root, 'sleep 60');
const script = fenceAfter(path.join(HOSTS[0].dir('design-review'), 'SKILL.md'), '## DESIGN SETUP');
const stdout = await new Promise<string>((resolve, reject) => {
const child = spawn('env', ['-i', `HOME=${w.home}`, 'PATH=/usr/bin:/bin', 'bash', '-c', script], { cwd: w.cwd, timeout: 30_000 });
let text = '';
child.stdout.on('data', d => { text += d; });
child.on('error', reject);
child.on('close', () => resolve(text));
});
expect(stdout.trim()).toBe(`DESIGN_NOT_AVAILABLE: ${w.install}/design/dist/design --version timed out after 10s`);
}, 30_000);
test('office-hours visual exploration uses the same probe', () => {
const w = world(HOSTS[0].root, 'kill -9 $$');
const r = run(fenceAfter(path.join(HOSTS[0].dir('office-hours'), 'sections', 'visual-design.md'), '## Visual Design Exploration'), w);
expect(r.stdout.trim()).toStartWith('DESIGN_NOT_AVAILABLE: ');
expect(r.stdout).toContain('exited 137 (killed at launch');
});
});
describe('F3: the taste profile loads, and a failed slug lookup is not "no profile"', () => {
for (const skill of ['design-shotgun', 'design-consultation']) {
for (const host of HOSTS) {
test(`${host.name} ${skill}`, () => {
const w = world(host.root, 'exit 0');
const fence = fenceAfter(path.join(host.dir(skill), 'SKILL.md'), "Read this project's taste profile:");
fs.mkdirSync(path.join(w.state, 'projects', 'acme'), { recursive: true });
fs.writeFileSync(path.join(w.state, 'projects', 'acme', 'taste-profile.json'), '{"dimensions":{}}');
fs.writeFileSync(path.join(w.install, 'bin', 'gstack-slug'), '#!/bin/sh\necho acme\n', { mode: 0o755 });
const found = run(fence, w);
expect(found.stdout, found.stderr).toContain('TASTE_PROFILE_FOUND');
expect(found.stdout).toContain('{"dimensions":{}}');
fs.writeFileSync(path.join(w.install, 'bin', 'gstack-slug'), '#!/bin/sh\necho "gstack-slug: cannot read state" >&2\nexit 1\n');
const failed = run(fence, w);
expect(failed.stdout.trim()).toBe('TASTE_PROFILE_UNAVAILABLE: could not resolve the project slug (gstack-slug failed). Fix: run ./setup.');
expect(failed.stderr).toContain('gstack-slug: cannot read state');
});
}
}
});