mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 17:04:27 +01:00
Outside-review consent (codex_reviews) is enforced in gstack-codex-probe run-with-timeout, gstack-claude-code, gstack-outside-voice run and the autoplan runner (not_run / consent_missing, exit 3; #3102). Codex reasoning effort resolves GSTACK_CODEX_EFFORT, then codex_effort, then each gate's default inside run-with-timeout --effort (D1-A; #3098, closes #2934). README and telemetry privacy disclosures corrected (#1081, #1080, #1150). Co-authored-by: Lockyer <135391289+Lockyer228@users.noreply.github.com> Co-authored-by: Lubos Buracinsky <159481718+lubosxyz@users.noreply.github.com>
203 lines
14 KiB
TypeScript
203 lines
14 KiB
TypeScript
import { expect, test } from 'bun:test';
|
|
import { readFileSync } from 'node:fs';
|
|
import { ALL_HOST_CONFIGS } from '../hosts';
|
|
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
|
|
import { generateDesignOutsideVoices, generateOverusedFonts, generateDesignShotgunLoop, generateTasteProfile, generateDesignMdCheck, generateDesignSetup } from '../scripts/resolvers/design';
|
|
import { generateBrowseFallback } from '../scripts/resolvers/browse';
|
|
import { generateAsideSetup, generateAsideResearch } from '../scripts/resolvers/aside';
|
|
import { outsideVoiceInvocation } from '../scripts/resolvers/outside-voice';
|
|
import { validateOutsideReview } from '../lib/outside-review-result';
|
|
import { expectMentions, expectTokens } from './helpers/prompt-structure';
|
|
|
|
const context = (host: string, skillName = 'design-consultation'): TemplateContext => ({ host, skillName, tmplPath: `${skillName}/SKILL.md.tmpl`, paths: HOST_PATHS[host] });
|
|
for (const { name: host } of ALL_HOST_CONFIGS) {
|
|
test(`${host}: proposal prompt requests the same completion marker that dispatch validates`, () => {
|
|
const text = generateDesignOutsideVoices(context(host));
|
|
const prompt = text.match(/"(Given this product context, propose a complete design direction:[\s\S]*?)"\n/)!;
|
|
expect(prompt).not.toBeNull();
|
|
expect(prompt[1]).toContain('Recommendation: <direction> because <product-specific reason>');
|
|
expect(validateOutsideReview('Recommendation: use a compact triage table because operators compare many incident rows.', 'proposal').completed).toBe(true);
|
|
expect(validateOutsideReview('A compact table sounds nice.', 'review').completed).toBe(false);
|
|
const preparation = outsideVoiceInvocation(context(host), { timeoutMs: 300000, purpose: 'design-direction' });
|
|
expect(preparation).toContain('missing Recommendation marker');
|
|
expect(preparation).not.toMatch(/severity|no.findings|clean\/PASS/i);
|
|
expect(preparation.includes('Claude Code has no tools, git or path access')).toBe(host === 'codex');
|
|
expect(preparation).toContain('including actual plan/spec/source');
|
|
expect(text).toContain('outside_status="unavailable"');
|
|
expect(text).toContain('otherwise \"none\"');
|
|
expect(text).toContain('| User declined both (one record) | skipped | none | skipped |');
|
|
expect(text).toContain('| Native subagent completed | clean or issues_found | in-host | actual');
|
|
expect(text).toContain('substitute its shell-quoted absolute path for the literal <prepared-prompt-file>');
|
|
expect(text).toContain('Do not choose a direction here');
|
|
expect(text).not.toContain('continuing with primary review');
|
|
expect(text).not.toContain('[single-model]');
|
|
});
|
|
}
|
|
|
|
test('creative wording leaves the review and scoring gates intact', () => {
|
|
const ctx = context('codex');
|
|
expect(outsideVoiceInvocation(ctx, { timeoutMs: 300000 })).toContain('explicit no-findings rationale');
|
|
expect(outsideVoiceInvocation(ctx, { timeoutMs: 300000, gate: 'structured' })).toContain('severity-tagged findings');
|
|
expect(outsideVoiceInvocation(ctx, { timeoutMs: 300000, gate: 'spec' })).toContain('SCORE: N');
|
|
});
|
|
|
|
test('preview paths retain verified fonts and select their own token source', () => {
|
|
const section = readFileSync(new URL('../design-consultation/sections/proposal-and-preview.md.tmpl', import.meta.url), 'utf8');
|
|
expectMentions(section, [['does not', 'verification', 'competitive']], 'section');
|
|
expectMentions(section, [['until', 'verified', 'preview']], 'section');
|
|
expect(section).toContain("For Path B, use the approved HTML preview's CSS values");
|
|
expect(section).toContain('Only Path A invokes `$D extract`');
|
|
expect(section).not.toContain('## Approved Design Direction');
|
|
expect(section).toContain('approved mockup paths/tokens into Phase 6\'s "## Proposed DESIGN.md" plan section');
|
|
expectMentions(section, [['approval', 'q-final', 'governs']], 'section');
|
|
expect(section).toContain('Only A permits the writes below');
|
|
expect(section).toContain('Prepare the complete DESIGN.md contents below');
|
|
expect(section).toContain('show the exact CLAUDE.md guidance');
|
|
expect(generateOverusedFonts(context('claude'))).toContain('font-verification fallback');
|
|
expect(generateOverusedFonts(context('claude', 'design-shotgun'))).not.toContain('font-verification fallback');
|
|
const loop = generateDesignShotgunLoop(context('claude'));
|
|
expect(loop).toContain('a PID is not readiness');
|
|
});
|
|
|
|
|
|
test('consultation drafts before independent dispatch and compares completed input at Q2', () => {
|
|
const root = readFileSync(new URL('../design-consultation/SKILL.md.tmpl', import.meta.url), 'utf8');
|
|
const section = readFileSync(new URL('../design-consultation/sections/proposal-and-preview.md.tmpl', import.meta.url), 'utf8');
|
|
expect(root).not.toContain('{{DESIGN_OUTSIDE_VOICES}}');
|
|
expect(root).not.toContain('Draft your own direction');
|
|
expect(root.indexOf('{{SECTION:proposal-and-preview}}')).toBeGreaterThan(root.indexOf('## Phase 2: Research'));
|
|
const ordered = ['### Your Design Knowledge', '**Choosing faces:', '{{OVERUSED_FONTS}}', '{{DESIGN_SLOP_BULLETS}}', 'Draft your own direction', '{{DESIGN_OUTSIDE_VOICES}}', '**AskUserQuestion Q2'];
|
|
for (let i = 0; i < ordered.length; i++) {
|
|
expect(section.indexOf(ordered[i])).toBeGreaterThan(i === 0 ? -1 : section.indexOf(ordered[i - 1]));
|
|
}
|
|
const question = section.slice(section.indexOf('**AskUserQuestion Q2'), section.indexOf('## Phase 4'));
|
|
expect(question).toContain('completed/unavailable/skipped voices');
|
|
expect(question).toContain('omit comparisons if none completed');
|
|
expectMentions(section, [['do not', 'agreement', 'proposal']], 'section');
|
|
expectMentions(section, [['before', 'suggested', 'adopting']], 'section');
|
|
expect(section).toContain('official Google Fonts/Fontshare listing');
|
|
expect(section).toContain('label old proposals stale');
|
|
});
|
|
|
|
test('consultation opt-in reads the stored consent, probes the CLI and rechecks its spawn', () => {
|
|
const text = generateDesignOutsideVoices(context('claude'));
|
|
const accepted = text.indexOf('**If accepted:**');
|
|
const availability = text.indexOf('**Check Codex availability:**');
|
|
const invocation = text.indexOf('1. **Codex design voice**');
|
|
expect(availability).toBeGreaterThan(accepted);
|
|
expect(invocation).toBeGreaterThan(availability);
|
|
const preflight = text.slice(availability, invocation);
|
|
expect(preflight).not.toContain('_OUTSIDE_CFG=enabled');
|
|
expect(preflight).toContain('get --stored codex_reviews');
|
|
expect(preflight).toContain('CODEX_MODE: disabled');
|
|
expect(preflight).toContain('CODEX_MODE: under_current_harness');
|
|
expect(preflight).toContain('exit 78');
|
|
expect(text.slice(invocation)).toContain('exit 78');
|
|
});
|
|
|
|
test('optional browser research has one unavailable branch and reuses its readiness probe', () => {
|
|
const ctx = context('claude');
|
|
const fallback = generateBrowseFallback(ctx);
|
|
expectMentions(fallback, [['do not', 'offer', 'build']], 'fallback');
|
|
expect(fallback).not.toContain('OK to proceed?');
|
|
const qaFallback = generateBrowseFallback(context('claude', 'qa'));
|
|
expectTokens(qaFallback, ['**Browser access decision**'], 'qaFallback');
|
|
expectMentions(qaFallback, [['no', 'cookie-import', 'authority']], 'qaFallback');
|
|
expect(qaFallback).not.toContain('OK to proceed?');
|
|
expect(generateBrowseFallback(context('claude', 'browse'))).toContain('OK to proceed?');
|
|
const root = readFileSync(new URL('../design-consultation/SKILL.md.tmpl', import.meta.url), 'utf8');
|
|
expectMentions(root, [['do not', 'build', 'offer']], 'root');
|
|
expect(root).toContain('count its retained `sessions` entries');
|
|
const research = generateAsideResearch(ctx);
|
|
expect(research).toContain('Reuse the Phase 0 BROWSER SETUP result');
|
|
expect((generateAsideSetup(ctx) + research).match(/console\.log\("ASIDE_READY /g)).toHaveLength(1);
|
|
expectMentions(research.toLowerCase(), [['do not', 'read-only', 'anything']], 'research.toLowerCase()');
|
|
expectMentions(research, [['before', 'sanitize', 'machine']], 'research');
|
|
});
|
|
|
|
test('existing-system choices reach their matching final format without early writes', () => {
|
|
const root = readFileSync(new URL('../design-consultation/SKILL.md.tmpl', import.meta.url), 'utf8');
|
|
const section = readFileSync(new URL('../design-consultation/sections/proposal-and-preview.md.tmpl', import.meta.url), 'utf8');
|
|
expect(root).toContain('**Cancel:** STOP the skill now, with no file changes or further probes');
|
|
expect(root).toContain('**Update:** carry the existing decisions into Q1 as constraints');
|
|
expect(root).toContain('**Start fresh:** set aside prior visual choices');
|
|
expectMentions(root, [['wait', 'conversion', 'q-final']], 'root');
|
|
const format = generateDesignMdCheck(context('claude'));
|
|
expect(format).toContain('convert`, without `--write`');
|
|
expectMentions(format, [['approval', 'q-final', 'outside']], 'format');
|
|
expect(format).toContain('In plan mode, record the chosen format in Proposed DESIGN.md instead');
|
|
expectMentions(section, [['never', 'validation', 'convert']], 'section');
|
|
expectMentions(section, [['approval', 'invalidates', 'subsequent']], 'section');
|
|
expect(section).toContain('E) Skip the preview — proceed to Phase 6\'s Q-final, not straight to writing');
|
|
});
|
|
|
|
test.each(ALL_HOST_CONFIGS.map(({ name }) => name))('%s: only Update with DESIGN.md enters the entire format-check block', host => {
|
|
const root = readFileSync(new URL('../design-consultation/SKILL.md.tmpl', import.meta.url), 'utf8');
|
|
const format = generateDesignMdCheck(context(host));
|
|
const gate = format.indexOf('**Update-only gate:**');
|
|
const command = format.indexOf('```bash');
|
|
const end = format.indexOf('**End of Update-only format check.**');
|
|
expect(gate).toBeGreaterThan(-1);
|
|
expect(command).toBeGreaterThan(gate);
|
|
expect(end).toBeGreaterThan(format.indexOf('**A) Convert**'));
|
|
expect(format.slice(gate, command)).toContain('Only **Update** with DESIGN.md enters this block (command and all result branches)');
|
|
expect(format.slice(gate, command)).toContain('**Start fresh**, **No existing file**, or a lone design-system.md: skip to **Gather product context from the codebase**');
|
|
expect(format.slice(gate, command)).toContain('**Cancel** has already stopped the skill');
|
|
expect(root.indexOf('**Gather product context from the codebase:**')).toBeGreaterThan(root.indexOf('{{DESIGN_MD_CHECK}}'));
|
|
expect(root).toContain('**Cancel:** STOP the skill now, with no file changes or further probes');
|
|
expect(generateDesignMdCheck(context(host, 'design-review'), ['calibrate'])).not.toContain('Update-only');
|
|
});
|
|
|
|
test('design command guidance carries session, extraction and quality-check side effects', () => {
|
|
const setup = generateDesignSetup(context('claude'));
|
|
const section = readFileSync(new URL('../design-consultation/sections/proposal-and-preview.md.tmpl', import.meta.url), 'utf8');
|
|
expect(setup).toContain('$D extract --image /absolute/path.png');
|
|
expect(setup).toContain('automatically update DESIGN.md');
|
|
expect(setup).toContain('`variants` returns `paths` but creates no session');
|
|
expect(section).toContain('`pass: false` means regenerate');
|
|
expect(section).toContain('`pass: true` with an unavailable/skipped warning is missing automated coverage');
|
|
expectMentions(section, [['only', 'non-repository', 'directory']], 'section');
|
|
expect(section).toContain('Empty arrays, an "Unable to extract" mood or command failure');
|
|
for (const command of (section + generateDesignShotgunLoop(context('claude'))).matchAll(/\$D iterate[^`\n]+/g)) {
|
|
expect(command[0]).toContain('--session');
|
|
}
|
|
});
|
|
|
|
test('board feedback distinguishes sessionless regeneration, final choice and missing input', () => {
|
|
const loop = generateDesignShotgunLoop(context('claude'));
|
|
const examples = [...loop.matchAll(/```json\n([\s\S]*?)```/g)].map(match => JSON.parse(match[1]));
|
|
expect(examples.find(value => value.regenerated === false)).toMatchObject({ preferred: 'A' });
|
|
expect(examples.find(value => value.regenerated === true)).toMatchObject({ regenerateAction: 'more_like_B' });
|
|
expect(loop).toContain('it does not emit a required `remixSpec`');
|
|
expect(loop).toContain('Archive this round\'s feedback files');
|
|
expectMentions(loop, [['never', 'approval', 'missing']], 'loop');
|
|
});
|
|
|
|
test('taste context has defined count and bounded legacy and malformed-profile fallbacks', () => {
|
|
const text = generateTasteProfile(context('claude'));
|
|
expect(text).not.toContain('SESSION_COUNT');
|
|
expect(text).not.toContain('head -200');
|
|
expectMentions(text, [['not', 'retained', 'sessions']], 'text');
|
|
expect(text).toContain('malformed/unreadable uses the legacy fallback');
|
|
const legacy = text.slice(text.indexOf('**Legacy fallback:**'), text.indexOf('**Conflict handling:**'));
|
|
expect(legacy).toContain('$GSTACK_STATE_ROOT/projects/$SLUG/designs/');
|
|
expect(legacy).toContain('approved.json');
|
|
expect(legacy).not.toContain('~/.gstack');
|
|
expect(text).toContain('Read the five newest');
|
|
expect(text).toContain('No usable files: continue without a taste profile');
|
|
expectMentions(text, [['never', 'fonts/colors', 'variant']], 'text');
|
|
expectMentions(text, [['do not', 'rewrite', 'reading']], 'text');
|
|
});
|
|
|
|
test('board fallback names the same launch command and does not poll a failed server', () => {
|
|
const text = generateDesignShotgunLoop(context('claude'));
|
|
expect(text).toContain('$D compare --images');
|
|
expectMentions(text, [['no', 'readiness', 'nonzero']], 'text');
|
|
expect(text).not.toContain('$D serve');
|
|
expect(text).not.toContain('POLLING FALLBACK');
|
|
expectTokens(text, ['`BOARD_URL`'], 'text');
|
|
const fallback = text.slice(text.indexOf('**SERVER FALLBACK:**'), text.indexOf('**After receiving feedback'));
|
|
expect(fallback).not.toContain('In that case');
|
|
expect(fallback).not.toContain('means the daemon is serving');
|
|
});
|