mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 08:54:04 +01:00
* v1.91.79.0 feat(eval-plan): /eval-plan tier 1, lane-check, evidence verify, coordinator contract, owner brief, contributor-PR mode, banned terms (multi-agent wave PR E) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(spec,review): fail closed when the coordinator contract cannot be read; restore the omitted-Army sentence The spec --execute spawn captures `gstack-autoplan contract` before piping the archive, so a lane never starts without the contract; the C9 fixture installs its gstack-like checkout (bin, lib, a contract stub) at every host's global root. Review Step 5.8 keeps the pinned 'without claiming specialist coverage' wording. 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>
150 lines
7.7 KiB
TypeScript
150 lines
7.7 KiB
TypeScript
/** Outside reviews follow harness identity, including lazily loaded workflows. */
|
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
import { mkdtempSync, readFileSync, readdirSync, existsSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import { ALL_HOST_CONFIGS } from '../hosts';
|
|
import { ALL_MODEL_NAMES } from '../scripts/models';
|
|
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
|
|
import { outsideVoiceFor, outsideVoiceCommand } from '../scripts/resolvers/outside-voice';
|
|
import { RESOLVERS } from '../scripts/resolvers';
|
|
import { installOutsideReviewFixture } from './helpers/outside-voice-fixture';
|
|
|
|
const ROOT = resolve(import.meta.dir, '..');
|
|
let output: string;
|
|
|
|
function skillDir(host: typeof ALL_HOST_CONFIGS[number], skill: string): string {
|
|
return host.name === 'claude'
|
|
? join(output, skill === 'gstack' ? '' : skill)
|
|
: join(output, host.hostSubdir, 'skills', skill === 'gstack' ? 'gstack' : `gstack-${skill}`);
|
|
}
|
|
|
|
function readUnion(dir: string): string {
|
|
const sectionDir = join(dir, 'sections');
|
|
return readFileSync(join(dir, 'SKILL.md'), 'utf8')
|
|
+ (existsSync(sectionDir) ? readdirSync(sectionDir).sort()
|
|
.filter(file => file.endsWith('.md'))
|
|
.map(file => '\n' + readFileSync(join(sectionDir, file), 'utf8')).join('') : '');
|
|
}
|
|
|
|
beforeAll(() => {
|
|
output = mkdtempSync(join(tmpdir(), 'gstack-outside-routing-'));
|
|
const generated = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--out-dir', output], {
|
|
cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000,
|
|
});
|
|
if (generated.exitCode !== 0) throw new Error(generated.stderr.toString());
|
|
});
|
|
afterAll(() => { if (output) rmSync(output, { recursive: true, force: true }); });
|
|
|
|
describe('outside-review host and model matrix', () => {
|
|
for (const host of ALL_HOST_CONFIGS) {
|
|
test(`${host.name}: only its own wrapper is excluded`, () => {
|
|
expect(existsSync(join(skillDir(host, 'codex'), 'SKILL.md'))).toBe(host.name !== 'codex');
|
|
expect(existsSync(join(skillDir(host, 'claude-code'), 'SKILL.md'))).toBe(host.name !== 'claude');
|
|
expect(existsSync(join(skillDir(host, 'claude'), 'SKILL.md'))).toBe(false);
|
|
for (const wrapper of ['codex', 'claude-code']) {
|
|
if (!existsSync(join(skillDir(host, wrapper), 'SKILL.md'))) continue;
|
|
const text = readUnion(skillDir(host, wrapper));
|
|
// Keep all three modes reachable, including carved section files.
|
|
expect(text).toMatch(/\breview\b/i);
|
|
expect(text).toMatch(/\bchallenge\b/i);
|
|
expect(text).toMatch(/\bconsult\b/i);
|
|
}
|
|
});
|
|
|
|
test(`${host.name}: every model overlay retains the same provider and routing`, () => {
|
|
const providers = ALL_MODEL_NAMES.map(model => {
|
|
const ctx: TemplateContext = { host: host.name, model, skillName: 'review', tmplPath: 'review/SKILL.md.tmpl', paths: HOST_PATHS[host.name] };
|
|
const voice = outsideVoiceFor(ctx);
|
|
expect(voice.id).toBe(host.name === 'codex' ? 'claude-code' : 'codex');
|
|
expect(voice.skillName).toBe(host.name === 'codex' ? 'claude-code' : 'codex');
|
|
const command = outsideVoiceCommand(ctx, { promptFile: '"$PROMPT_FILE"', timeoutMs: 120_000 });
|
|
expect(command).toContain(host.name === 'codex' ? 'gstack-claude-code' : 'codex');
|
|
if (host.name === 'codex') expect(command).not.toMatch(/\bcodex\s+(?:exec|review)\s/);
|
|
return RESOLVERS.OUTSIDE_VOICE_ROUTING(ctx);
|
|
});
|
|
expect(new Set(providers).size).toBe(1);
|
|
});
|
|
|
|
test(`${host.name}: generic and explicit second-opinion routing is installed`, () => {
|
|
const router = readUnion(skillDir(host, 'gstack'));
|
|
expect(router).toMatch(/second opinion/i);
|
|
expect(router).toContain(host.name === 'codex' ? 'claude-code' : 'codex');
|
|
expect(router).toMatch(/explicit/i);
|
|
expect(router).toMatch(/(?:do not|never|without).*?(?:substitut|switch|replac)|(?:keep|honor|respect).*?(?:provider|request)/i);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('generated automatic review coverage', () => {
|
|
const skills = ['office-hours', 'plan-ceo-review', 'plan-eng-review', 'plan-devex-review', 'plan-design-review', 'design-review', 'design-consultation', 'review', 'ship', 'document-release', 'autoplan', 'spec'];
|
|
for (const skill of skills) {
|
|
test(`Codex /${skill} invokes Claude Code, including lazy sections`, () => {
|
|
const host = ALL_HOST_CONFIGS.find(h => h.name === 'codex')!;
|
|
const text = readUnion(skillDir(host, skill));
|
|
expect(text).toContain('gstack-claude-code');
|
|
// Prose about the CLI is permitted; executable codex review commands are not.
|
|
expect(shellLines(text).filter(line => /\bcodex\s+(?:exec|review)\b/.test(line))).toEqual([]);
|
|
expect(text).toContain('Claude Code');
|
|
});
|
|
}
|
|
|
|
test('Codex still omits Review Army specialist dispatch', () => {
|
|
const host = ALL_HOST_CONFIGS.find(h => h.name === 'codex')!;
|
|
for (const skill of ['review', 'ship']) {
|
|
expect(readUnion(skillDir(host, skill))).not.toContain('Step 4.5: Review Army — Specialist Dispatch');
|
|
}
|
|
});
|
|
});
|
|
|
|
/** Only shell fences count as invocation sites: explanatory prose cannot spawn. */
|
|
function shellLines(text: string): string[] {
|
|
let inShell = false;
|
|
return text.split('\n').filter(line => {
|
|
if (/^```(?:bash|sh|shell)\s*$/.test(line)) { inShell = true; return false; }
|
|
if (/^```\s*$/.test(line)) { inShell = false; return false; }
|
|
return inShell && !/^\s*#/.test(line);
|
|
});
|
|
}
|
|
|
|
function templateFiles(dir: string): string[] {
|
|
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory() && entry.name === 'sections') return templateFiles(full);
|
|
return entry.isFile() && entry.name.endsWith('.tmpl') ? [full] : [];
|
|
});
|
|
}
|
|
|
|
test('automatic workflow templates delegate reviewer commands to shared resolvers', () => {
|
|
const skills = ['office-hours', 'plan-ceo-review', 'plan-eng-review', 'plan-devex-review', 'plan-design-review', 'design-review', 'design-consultation', 'design-shotgun', 'review', 'ship', 'document-release', 'autoplan', 'spec'];
|
|
const violations: string[] = [];
|
|
for (const skill of skills) {
|
|
for (const file of templateFiles(join(ROOT, skill))) {
|
|
for (const line of shellLines(readFileSync(file, 'utf8'))) {
|
|
// /spec's --execute worker implements the filed spec (archive + coordinator contract on stdin); it is not a reviewer.
|
|
if (file === join(ROOT, 'spec', 'sections', 'gate-and-file.md.tmpl') && line.trim() === '{ cat "$ARCHIVE_PATH"; printf \'\\n\\n%s\\n\' "$CONTRACT"; } | (cd "$SPAWN_PATH" && GSTACK_SESSION_KIND=spawned claude -p 2>&1) &') continue;
|
|
if (/\bcodex\s+(?:exec|review)\b|\bclaude\s+(?:-p|--print)\b|\bgstack-claude-code\s+--/.test(line)) {
|
|
violations.push(`${file.slice(ROOT.length + 1)}: ${line.trim()}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
expect(violations).toEqual([]);
|
|
});
|
|
|
|
for (const host of ['claude', 'codex'] as const) {
|
|
test(`${host}: live E2E installs an executable extracted workflow from the actual host render`, () => {
|
|
const repo = mkdtempSync(join(tmpdir(), 'gstack-outside-fixture-'));
|
|
try {
|
|
const dir = installOutsideReviewFixture(output, host, repo, ROOT);
|
|
const text = readFileSync(join(dir, 'SKILL.md'), 'utf8');
|
|
expect(text).toContain('Step 0: Detect platform and base branch');
|
|
expect(text).toContain('Step 3: Get the diff');
|
|
expect(text).toContain('Step 4.8: Adversarial review (always-on)');
|
|
expect(text).toContain(host === 'codex' ? 'gstack-claude-code' : 'codex exec');
|
|
expect(text).not.toContain('## Preamble (run first)');
|
|
expect(text.match(/^name:/gm)).toHaveLength(1);
|
|
} finally { rmSync(repo, { recursive: true, force: true }); }
|
|
});
|
|
}
|