mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-08 09:48:20 +01:00
* feat: retire /investigate after a no-gain eval (GST-1717)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(skill-start): core routing offer drops every non-core rule line (GST-1717)
The retired /investigate rule became 'Bugs/errors → fix them directly', which no
longer matches '→ invoke /', so proactive=core leaked it into the offered section.
Match any '- … → ' rule line.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* revert(review): keep review/checklist.md at its no-loss-pinned bytes (GST-1717)
The pin names replay GSTA-224 H1; a reviewer checklist edit merges only after a
passing no-loss replay (crew rule 15). The /investigate flake line stays until a
replayed repin.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: repair free-suite fallout from retiring /investigate (GST-1717)
CI run 42340956269 at 83a8a9a: drop fixtures, ledger rows, allowlist entries and
counts for the retired skill and its deleted paid files; record the harness
change as non-behavioral; the test-of-test detector accepts path.join(ROOT, 'bin');
uncovered-prompt fixtures use a tracked prompt, since an untracked path now
reads as a deleted one.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(relink): back up a retired skill's real SKILL.md before cleaning it (GST-1717)
With the skill's source gone, the customized-file check never ran, so a
banner-carrying local copy of investigate/SKILL.md was deleted instead of moved
to the backup root (red-team review, pass 1).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(qm): restore the review checklist's flake-line rewrite (GST-1717)
review/checklist.md keeps its pinned /investigate line, so the QM edition needs
its replaceText rule back; qm/ stays fresh against the generator.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): name the setup flag break, the relink backup and the pinned checklist line (GST-1717)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(setup,relink): back up a retired skill's real SKILL.md instead of deleting it (GST-1717)
Scoped fix round under grant a-360dd62bd46a (outside-voice blocking finding and
adversarial #1, #4). setup's prune compares a copied host entry with the render
before removing that render, and backs up a copy that differs or whose render is
gone; relink no longer takes the marker's rm -rf for a skill whose source is
gone, so a real SKILL.md is backed up and the user's files stay. setup reports a
failed remove-source of the retired gate hooks on stderr.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pr-profile): a changed prompt counts as deleted only when it is gone from disk (GST-1717)
An untracked prompt file on disk still needs prompt validation (targeted review
finding); harness change recorded as non-behavioral.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): exact backup wording, the hook-removal warning and GST-1744 (GST-1717)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(setup): one backup folder per host for a retired skill's copies (GST-1717)
Under grant a-c95576b8b1f9 (fix-check#1): every copy host moved its
gstack-investigate/SKILL.md to the same backup path, so a later host's copy
overwrote an earlier host's edits. A sandboxed ./setup --host factory run showed
gen-skill-docs removes the retired render before the prune, so every real copy
is backed up; each host now gets its own folder.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): say exactly what setup backs up for a retired skill (GST-1717)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): end the two colon sentences prose-lint flagged (GST-1717)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(retired-skills): name plan-eng-review once behind a pregate-literal waiver (GST-1717)
pregate's literals check read the GSTACK_RETIRED_SKILLS change as an old
value still in the tests. plan-eng-review is still retired; investigate was
appended. The fixture names it once, with the waiver.
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>
191 lines
12 KiB
TypeScript
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 = ['/cso', '/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` 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', 'retro']) {
|
|
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 /cso 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([]);
|
|
}
|
|
});
|
|
});
|