Files
gstack/test/proactive-core-set.test.ts
Garry Tanandcapy-ai[bot] ed107918db v1.92.6.0 feat: proactive core routes only the four core skills, and they carry their trigger phrases (#3770)
* proactive core: the CLAUDE.md offer and descriptions follow core; core skills carry their trigger phrases (#3446, #2118)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (core routing line keeps in-skill handoffs)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: keep document-release's pre-merge cue and the ship catalog-mode pin after the description edits

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: plain sentence break in the fragment

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-11-01 01:25:15 +00:00

191 lines
12 KiB
TypeScript

/**
* The core-set proactive default (GSTA-93 fix 12): the router and every skill's
* preamble carry the same three-way rule, the one-time prompt offers core, all
* and off, and the question registry agrees. Negative controls: no surface
* still describes true as "the default", and the preamble never tells a skill to
* auto-invoke a non-core skill under core.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { frontmatterList } from '../lib/skill-triggers';
import { QUESTIONS } from '../scripts/question-registry';
import { generateUpgradeCheck } from '../scripts/resolvers/preamble/generate-upgrade-check';
const ROOT = path.resolve(import.meta.dir, '..');
const CORE = ['/office-hours', '/review', '/qa', '/ship'];
const NON_CORE_SAMPLE = ['/investigate', '/plan-ceo-review', '/design-review', '/retro'];
// The one place the core block may name non-core skills: the rule that says to name them for the user to type.
const TYPE_IT_RULE = 'under `core`, name `/browse` or `/investigate` for the user to type instead of\ninvoking it.';
const routerTmpl = fs.readFileSync(path.join(ROOT, 'SKILL.md.tmpl'), 'utf-8');
const routerOut = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
const skillStart = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf-8');
describe('proactive core set: the router', () => {
test('the template names core as the default and lists exactly the four core skills', () => {
const block = routerTmpl.slice(routerTmpl.indexOf('If `PROACTIVE` is `core` (default)'), routerTmpl.indexOf('If `PROACTIVE` is `all`'));
expect(block.length).toBeGreaterThan(50);
for (const skill of CORE) expect(block).toContain(`\`${skill}\``);
expect(block).toContain(TYPE_IT_RULE);
for (const skill of NON_CORE_SAMPLE) expect(block.replace(TYPE_IT_RULE, '')).not.toContain(`\`${skill}\``);
expect(block).toContain('A skill the user names is always invoked');
});
test('off and all are spelled with their legacy aliases; true is no longer "the default" (negative control)', () => {
expect(routerTmpl).toContain('If `PROACTIVE` is `false` or `off`');
expect(routerTmpl).toContain('If `PROACTIVE` is `all` (or `true`)');
expect(routerTmpl).not.toMatch(/`PROACTIVE` is `true` \(default\)/);
expect(routerTmpl).toContain('gstack-config set proactive off');
expect(routerTmpl).toContain('gstack-config set proactive all');
expect(routerTmpl).not.toContain('gstack-config set proactive false');
});
test('the generated router carries the same rule', () => {
expect(routerOut).toContain('If `PROACTIVE` is `core` (default)');
for (const skill of CORE) expect(routerOut).toContain(`\`${skill}\``);
});
});
describe('proactive core set: every skill preamble', () => {
const line = generateUpgradeCheck({ paths: { skillRoot: '~/.claude/skills/gstack' } } as never);
test('the steady-state rule names the four core skills under core, scopes the setting to unasked suggestions, carves out only named skills, and keeps the marker the order test reads', () => {
expect(line).toMatch(/^If `PROACTIVE` is `off`/);
expect(line).toContain('`core`: only /office-hours, /review, /qa, /ship');
expect(line).toContain('`all`: any');
expect(line).toContain('suggest no skills unasked, nor ask');
expect(line).toContain('Skills the user names or this one invokes run');
expect(line.split('\n')[0]).not.toContain('true');
for (const skill of NON_CORE_SAMPLE) expect(line).not.toContain(skill);
});
test('a generated core skill and a generated non-core skill both carry it (it is the same preamble)', () => {
for (const skill of ['review', 'investigate']) {
const out = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
expect(out).toContain('`core`: only /office-hours, /review, /qa, /ship');
}
});
});
describe('proactive core set: the one-time prompt and the registry', () => {
test('the prompt offers core (recommended), all and off, and never writes true or false', () => {
const block = skillStart.slice(skillStart.indexOf('_emit_block proactive-prompt'), skillStart.indexOf('.proactive-prompted"\\`', skillStart.indexOf('_emit_block proactive-prompt')));
expect(block).toContain('Keep the core four (recommended)');
expect(block).toContain('gstack-config set proactive core');
expect(block).toContain('gstack-config set proactive all');
expect(block).toContain('gstack-config set proactive off');
expect(block).not.toContain('set proactive true');
expect(block).not.toContain('set proactive false');
expect(block).toContain("Turn it off — I'll type /commands myself");
});
test('the registry entry matches the prompt options', () => {
expect(QUESTIONS['preamble-proactive-behavior'].options).toEqual(['core', 'all', 'off']);
});
test('the first-run and first-loop tips name only core skills (negative control: no /spec or /investigate tip; the off gating is proven by running the script in proactive-skill-start.test.ts)', () => {
const tips = skillStart.slice(skillStart.indexOf('if [ "$_ACTIVATED" = "no" ]; then'), skillStart.indexOf('# Routing injection'));
for (const skill of NON_CORE_SAMPLE.concat(['/spec', '/plan-eng-review'])) expect(tips).not.toContain(skill);
for (const skill of CORE) expect(tips).toContain(skill);
});
});
/**
* Value: protects=every PROACTIVE gate outside the router honors off, the opt-out the
* router and prompt now write, and the router tells a model that an in-skill handoff is
* not a suggestion; fails_when=a template gates on the legacy false only, or the
* carve-out sentence is dropped; why_new=the surfaces table above lists only text that
* names the core four; the office-hours handoff names non-core next steps by design;
* seam=none; no_claim=how a model weighs the two instructions (prose, not executed)
*/
describe('proactive core set: in-skill handoffs', () => {
test('the office-hours next-review handoff honors off as well as false, and its render carries it', () => {
const tmpl = fs.readFileSync(path.join(ROOT, 'office-hours', 'sections', 'design-and-handoff.md.tmpl'), 'utf-8');
const gate = tmpl.match(/\*\*If `PROACTIVE` is [^\n]*do NOT auto-launch/)?.[0] ?? '';
expect(gate).toContain('`false` or `off`');
const out = fs.readFileSync(path.join(ROOT, 'office-hours', 'sections', 'design-and-handoff.md'), 'utf-8');
expect(out).toMatch(/If `PROACTIVE` is `false` or `off`[^\n]*do NOT auto-launch/);
});
test('the router says a step inside a running skill is part of that skill, not a suggestion', () => {
expect(routerTmpl.replace(/\s+/g, ' ')).toContain('a step inside a skill that is already running (a handoff, a review gate, a subagent it dispatches) is part of that skill, not a suggestion');
});
});
/**
* Value: protects=the core four and the three proactive values agree across
* the config header, the default table, the router, the preamble, the one-time
* prompt and the docs; fails_when=the core set or a value name changes on one
* surface only; why_new=the tests above pin the template, preamble and prompt
* options but not the docs, nor the skill names the prompt and header speak;
* seam=none; no_claim=none beyond protects (static text, no model run)
*/
describe('proactive core set: every surface names the same four skills and three values', () => {
const configSrc = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-config'), 'utf-8');
const promptAt = skillStart.indexOf('_emit_block proactive-prompt');
const docsSkills = fs.readFileSync(path.join(ROOT, 'docs', 'skills.md'), 'utf-8');
const docsSkillsAt = docsSkills.indexOf('## Proactive skill suggestions');
const surfaces: Record<string, string> = {
'bin/gstack-config header': configSrc.slice(configSrc.indexOf('# proactive:'), configSrc.indexOf('# routing_declined:')),
'bin/gstack-config lookup_default': configSrc.slice(configSrc.indexOf('lookup_default()')).match(/^\s*proactive\).*$/m)?.[0] ?? '',
'SKILL.md.tmpl core block': routerTmpl.slice(routerTmpl.indexOf('If `PROACTIVE` is `core` (default)'), routerTmpl.indexOf('If `PROACTIVE` is `all`')).replace(TYPE_IT_RULE, ''),
'preamble rule': generateUpgradeCheck({ paths: { skillRoot: '~/.claude/skills/gstack' } } as never),
'gstack-skill-start prompt': skillStart.slice(promptAt, skillStart.indexOf('.proactive-prompted"\\`', promptAt)),
'docs/proactive.md': fs.readFileSync(path.join(ROOT, 'docs', 'proactive.md'), 'utf-8'),
'docs/skills.md section': docsSkills.slice(docsSkillsAt, docsSkills.indexOf('\n## ', docsSkillsAt + 1)),
};
test.each(Object.entries(surfaces))('%s names the core four and no sampled non-core skill', (name, text) => {
expect(text.length, name).toBeGreaterThan(20);
for (const skill of CORE) expect(text, `${name} lacks ${skill}`).toContain(skill);
for (const skill of NON_CORE_SAMPLE) expect(text, `${name} names ${skill}`).not.toContain(skill);
});
test('the docs and the config header spell the three values, and the router names the restore-default command', () => {
for (const name of ['docs/proactive.md', 'docs/skills.md section', 'bin/gstack-config header']) {
for (const value of ['core', 'all', 'off']) expect(surfaces[name], `${name} lacks ${value}`).toMatch(new RegExp(`\\b${value}\\b`));
}
expect(surfaces['docs/proactive.md']).toContain('`true` and `false`');
for (const value of ['all', 'off', 'core']) expect(surfaces['docs/proactive.md']).toContain(`gstack-config set proactive ${value}`);
expect(routerTmpl.replace(/\s+/g, ' ')).toContain('`gstack-config set proactive core` restores the default');
});
});
/**
* Value: protects=the four core skills, the ones gstack routes by plain speech
* under the core default, carry their own `triggers:` phrases where hosts read
* them: at least one in the Claude catalog one-liner, every one in the full
* description other hosts keep, and no core one-liner phrase in another
* skill's one-liner; fails_when=a core lead or description loses its trigger
* phrases, or a trigger phrase also matches another skill's catalog entry;
* why_new=#2118: `triggers:` was metadata no host reads, and no test tied it
* to a description; seam=none; no_claim=how a host ranks or picks skills
* (static text, no model run)
*/
describe('proactive core set: core skills carry their trigger phrases (#2118)', () => {
const norm = (s: string) => s.replace(/\s+/g, ' ').toLowerCase();
const description = (md: string): string => {
const fm = md.slice(0, md.indexOf('\n---', 4));
const block = fm.match(/^description:\s*\|\s*\n((?:[ \t]+.*\n|\n)+)/m)?.[1];
return norm(block ?? fm.match(/^description:\s*(.+)$/m)?.[1]?.replace(/^"|"$/g, '') ?? '');
};
const skillDirs = fs.readdirSync(ROOT).filter(dir => fs.existsSync(path.join(ROOT, dir, 'SKILL.md.tmpl')) && fs.existsSync(path.join(ROOT, dir, 'SKILL.md')));
const catalog = Object.fromEntries(skillDirs.map(dir => [dir, description(fs.readFileSync(path.join(ROOT, dir, 'SKILL.md'), 'utf-8'))]));
test.each(CORE.map(s => s.slice(1)))('%s: every trigger phrase is in its full description, and one is in its catalog one-liner that no other one-liner carries', (skill) => {
const tmpl = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md.tmpl'), 'utf-8');
const triggers = frontmatterList(tmpl, 'triggers').map(norm);
expect(triggers.length).toBeGreaterThan(0);
const full = description(tmpl);
for (const phrase of triggers) expect(full, `${skill} full description lacks "${phrase}"`).toContain(phrase);
const inCatalog = triggers.filter(phrase => catalog[skill].includes(phrase));
expect(inCatalog.length, `${skill} one-liner "${catalog[skill]}" carries none of ${triggers.join(', ')}`).toBeGreaterThan(0);
for (const phrase of inCatalog) {
const others = skillDirs.filter(dir => dir !== skill && catalog[dir].includes(phrase));
expect(others, `"${phrase}" also in`).toEqual([]);
}
});
});