mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 11:10:13 +01:00
train S3: merge #3909 at cc9b9b9a29 v1.94.18.0
This commit is contained in:
commit
c5f47ef481
19 files changed
+521
-39
No files matched your search
+9
-9
@@ -6,52 +6,52 @@
|
||||
"agy/gstack-plan-design-review": 109107,
|
||||
"agy/gstack-plan-devex-review": 114948,
|
||||
"agy/gstack-plan-eng-review": 143581,
|
||||
"agy/gstack-review": 104182,
|
||||
"agy/gstack-review": 104488,
|
||||
"codex/gstack-autoplan": 108400,
|
||||
"codex/gstack-plan-design-review": 110464,
|
||||
"codex/gstack-plan-devex-review": 116456,
|
||||
"codex/gstack-plan-eng-review": 144746,
|
||||
"codex/gstack-review": 103705,
|
||||
"codex/gstack-review": 104011,
|
||||
"copilot/gstack-autoplan": 109100,
|
||||
"copilot/gstack-plan-design-review": 109373,
|
||||
"copilot/gstack-plan-devex-review": 114410,
|
||||
"copilot/gstack-plan-eng-review": 143746,
|
||||
"copilot/gstack-review": 103763,
|
||||
"copilot/gstack-review": 104069,
|
||||
"cursor/gstack-autoplan": 108332,
|
||||
"cursor/gstack-plan-design-review": 107915,
|
||||
"cursor/gstack-plan-devex-review": 113680,
|
||||
"cursor/gstack-plan-eng-review": 142336,
|
||||
"cursor/gstack-review": 122053,
|
||||
"cursor/gstack-review": 122541,
|
||||
"factory/gstack-autoplan": 110610,
|
||||
"factory/gstack-plan-design-review": 111013,
|
||||
"factory/gstack-plan-devex-review": 117043,
|
||||
"factory/gstack-plan-eng-review": 145444,
|
||||
"factory/gstack-review": 125826,
|
||||
"factory/gstack-review": 126314,
|
||||
"gbrain/gstack-autoplan": 108739,
|
||||
"gbrain/gstack-plan-eng-review": 124963,
|
||||
"gemini/gstack-autoplan": 108890,
|
||||
"gemini/gstack-plan-design-review": 109201,
|
||||
"gemini/gstack-plan-devex-review": 114238,
|
||||
"gemini/gstack-plan-eng-review": 143579,
|
||||
"gemini/gstack-review": 103585,
|
||||
"gemini/gstack-review": 103891,
|
||||
"hermes/gstack-autoplan": 108742,
|
||||
"hermes/gstack-plan-eng-review": 124999,
|
||||
"kiro/gstack-autoplan": 108106,
|
||||
"kiro/gstack-plan-design-review": 107761,
|
||||
"kiro/gstack-plan-devex-review": 113532,
|
||||
"kiro/gstack-plan-eng-review": 142198,
|
||||
"kiro/gstack-review": 121877,
|
||||
"kiro/gstack-review": 122365,
|
||||
"openclaw/gstack-autoplan": 108562,
|
||||
"openclaw/gstack-plan-eng-review": 123478,
|
||||
"opencode/gstack-autoplan": 109081,
|
||||
"opencode/gstack-plan-design-review": 109229,
|
||||
"opencode/gstack-plan-devex-review": 114288,
|
||||
"opencode/gstack-plan-eng-review": 143605,
|
||||
"opencode/gstack-review": 122675,
|
||||
"opencode/gstack-review": 123163,
|
||||
"slate/gstack-autoplan": 108219,
|
||||
"slate/gstack-plan-design-review": 107838,
|
||||
"slate/gstack-plan-devex-review": 113606,
|
||||
"slate/gstack-plan-eng-review": 142267,
|
||||
"slate/gstack-review": 121965
|
||||
"slate/gstack-review": 122453
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,7 @@ describe('GSTA-135: registered skills get used', () => {
|
||||
}
|
||||
const oc = register(opts(d, 'openclaw', { mode: 'all', skillsDir: path.join(d, 'oc') }));
|
||||
const review = fs.readFileSync(path.join(oc.dest, 'gstack-review', 'SKILL.md'), 'utf8');
|
||||
expect(descriptionOf(review)[0]).not.toMatch(/^Use /);
|
||||
expect(descriptionOf(review)[0]).not.toMatch(/^Use for: /);
|
||||
}, 120_000);
|
||||
|
||||
test('OpenClaw hybrid is the default: the task skills listed directly, the router for the rest; mode switches refresh and uninstall cleanly', () => {
|
||||
|
||||
@@ -490,10 +490,10 @@ do not launch the downstream skill or open a browser.`,
|
||||
gateAfterStop: undefined, // operational multi-STOP skill, like ship
|
||||
},
|
||||
behavioral: 'plan',
|
||||
maxSkeletonBytes: 59_073, // GSTA-96 hoist (2026-10-26): lowered from 80_900 by the shared preamble that moved to sections/preamble.md, keeping the pre-hoist headroom of 319 bytes; measured 58,754. Before: Shared-code identity/skip/action rules + critical-severity validation; measured 74,493 (2026-09-17). + v1.91.12.0 merge of #2999 (review clarity repairs: await reads, research alongside dispatch, /review deadline and setup authority, findings sources) with #3002 (guarded state-root lines, plan-check checkpoints); each fit alone; measured 74,881 (2026-10-01). + Oct 8 wave: shared voice directive bans "load-bearing" and adds the reply-language rule, offset by tightening the bounded closer (net +17 bytes per tier-2+ skill); measured 74,884 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W2): Step 3.5 Diff scans with the shared GATE_SCAN_BLOCK, the Step 4 carry, the four-option `review-gate-disposition` question in 5c, gate record fields in 5.8 and the Gate edits report line; measured 80,054 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): Step 4's enum-only paragraph becomes the sideways sweep with the forced-CRITICAL list, plus the flake-fix number sentence in the Gate Integrity paragraph; measured skeleton 80,812 (2026-10-10).
|
||||
maxSkeletonBytes: 59_774, // GST-1088 lane B (raised from 59,073 to measured, no headroom): the contract: frontmatter block and the F1 "Done means" list rendered from it at the top of the body, plus widened triggers; F1 is a gate-rev-2 recommendation on the plan Garry approved with "do it" on gate rev 1 (2026-11-03), contingent on his gate-rev-2 answer, and lane C's diet is expected to bring /review back under 59,073; measured 59,774. Before: GSTA-96 hoist (2026-10-26): lowered from 80_900 by the shared preamble that moved to sections/preamble.md, keeping the pre-hoist headroom of 319 bytes; measured 58,754. Before: Shared-code identity/skip/action rules + critical-severity validation; measured 74,493 (2026-09-17). + v1.91.12.0 merge of #2999 (review clarity repairs: await reads, research alongside dispatch, /review deadline and setup authority, findings sources) with #3002 (guarded state-root lines, plan-check checkpoints); each fit alone; measured 74,881 (2026-10-01). + Oct 8 wave: shared voice directive bans "load-bearing" and adds the reply-language rule, offset by tightening the bounded closer (net +17 bytes per tier-2+ skill); measured 74,884 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W2): Step 3.5 Diff scans with the shared GATE_SCAN_BLOCK, the Step 4 carry, the four-option `review-gate-disposition` question in 5c, gate record fields in 5.8 and the Gate edits report line; measured 80,054 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): Step 4's enum-only paragraph becomes the sideways sweep with the forced-CRITICAL list, plus the flake-fix number sentence in the Gate Integrity paragraph; measured skeleton 80,812 (2026-10-10).
|
||||
minUnionBytes: 89_000, // Phase 4 wave 1; measured union 93,357
|
||||
mustContain: ['confidence', 'P1', 'P2', 'Review Army', 'adversarial'],
|
||||
maxSizeRatio: 1.3180, // + GSTA-167 variant G part 1 (plan approved by Garry 2026-10-30, autoplan-20261030161754 "Do it", which names about +0.3k prompt tokens per voice): the native adversarial and outside-voice prompts carry the blocking floor and the Severity and Blocks lines, +537 bytes; measured 1.3180 (2026-10-30). Before: 1.3136. GSTA-96 hoist merged with GSTA-92 phase 2 (2026-10-26): both owner-approved raises together, measured 142,550 / 108,523 = 1.3135. Before: Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 132,199 / 108,523 = 1.2182 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 132,258 / 108,523 = 1.2187 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 132,233 / 108,523 = 1.2185 (2026-10-07). Merged with the guard area: measured 132,522 / 108,523 = 1.2211 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 133,902 / 108,523 = 1.2339 (2026-10-08). + gate integrity W2 (above); measured 1.282 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): sideways sweep and flake-fix number in Step 4; `Diff read` and `Undeclared behavior changes` in the review-mode Scope Check (plan-completion section); measured 140,497 / 108,523 = 1.2946 (2026-10-10). + harness lane E (2026-10-26): the lead-judgment stage in the finding merge and the `cause` anchor in the specialist prompts, shared with /ship through scripts/resolvers/review-army.ts; measured 141,949 / 108,523 = 1.3080.
|
||||
maxSizeRatio: 1.3261, // + GST-1088 lane B (raised from 1.3180 to measured, no headroom): the contract: frontmatter block and the F1 "Done means" list, contingent on Garry's gate-rev-2 answer; lane C's diet is expected to bring /review back under 1.3180; measured 143,907 / 108,523 = 1.3261. Before: + GSTA-167 variant G part 1 (plan approved by Garry 2026-10-30, autoplan-20261030161754 "Do it", which names about +0.3k prompt tokens per voice): the native adversarial and outside-voice prompts carry the blocking floor and the Severity and Blocks lines, +537 bytes; measured 1.3180 (2026-10-30). Before: 1.3136. GSTA-96 hoist merged with GSTA-92 phase 2 (2026-10-26): both owner-approved raises together, measured 142,550 / 108,523 = 1.3135. Before: Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 132,199 / 108,523 = 1.2182 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 132,258 / 108,523 = 1.2187 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 132,233 / 108,523 = 1.2185 (2026-10-07). Merged with the guard area: measured 132,522 / 108,523 = 1.2211 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 133,902 / 108,523 = 1.2339 (2026-10-08). + gate integrity W2 (above); measured 1.282 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): sideways sweep and flake-fix number in Step 4; `Diff read` and `Undeclared behavior changes` in the review-mode Scope Check (plan-completion section); measured 140,497 / 108,523 = 1.2946 (2026-10-10). + harness lane E (2026-10-26): the lead-judgment stage in the finding merge and the `cause` anchor in the specialist prompts, shared with /ship through scripts/resolvers/review-army.ts; measured 141,949 / 108,523 = 1.3080.
|
||||
},
|
||||
codex: {
|
||||
skill: 'codex',
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { sentences } from '../lib/skill-triggers';
|
||||
import { CONTRACT_CHECKER_SHIPPED, contractFromTemplate, generateContractDoneMeans, parseSkillContract, renderDoneMeans } from '../scripts/resolvers/contract-done-means';
|
||||
import { HOST_PATHS } from '../scripts/resolvers/types';
|
||||
|
||||
/**
|
||||
* GST-1088 discovery targets: each says when to use it in the part of its
|
||||
* description every host shows, and every contract block matches the frozen
|
||||
* v1 schema, so a malformed block fails here instead of failing open in the
|
||||
* Stop check.
|
||||
*/
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const SKILLS = ['investigate', 'review', 'qa', 'verify'];
|
||||
// splitCatalogDescription cuts a longer Claude lead with "...", which drops the end of the when-clause.
|
||||
const LEAD_CAP = 200;
|
||||
// test/catalog-budget.test.ts PER_SKILL_BYTE_CAP: name + Claude description bytes.
|
||||
const CATALOG_ENTRY_CAP = 260;
|
||||
// Codex, Gemini, Copilot and agy reject a longer description (hosts/*.ts descriptionLimit).
|
||||
const DESCRIPTION_CAP = 1024;
|
||||
const TRIGGER_CAP = 32;
|
||||
const BT = '\u0060';
|
||||
|
||||
const fix = (skill: string) => 'Fix: edit ' + skill + '/SKILL.md.tmpl, then bun run gen:skill-docs';
|
||||
const read = (rel: string) => readFileSync(join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
function frontmatter(rel: string): Record<string, unknown> {
|
||||
const m = read(rel).match(/^---\n([\s\S]*?)\n---\n/);
|
||||
if (!m) throw new Error(rel + ': no frontmatter');
|
||||
return Bun.YAML.parse(m[1]) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const body = (rel: string) => read(rel).replace(/^---\n[\s\S]*?\n---\n/, '');
|
||||
const triggersOf = (skill: string) => (frontmatter(skill + '/SKILL.md.tmpl').triggers ?? []) as string[];
|
||||
|
||||
describe('discovery-target descriptions (GST-1088)', () => {
|
||||
for (const skill of SKILLS) {
|
||||
const tmpl = skill + '/SKILL.md.tmpl';
|
||||
|
||||
test(skill + ': the template description opens with a "Use when ..." sentence', () => {
|
||||
const first = sentences(String(frontmatter(tmpl).description ?? ''))[0] ?? '';
|
||||
expect(/^Use when\b/.test(first), tmpl + ': the first sentence is "' + first.slice(0, 80) + '", not a "Use when ..." clause. ' + fix(skill)).toBe(true);
|
||||
});
|
||||
|
||||
test(skill + ': the Claude catalog entry keeps the when-clause within its caps', () => {
|
||||
const rel = skill + '/SKILL.md';
|
||||
const fm = frontmatter(rel);
|
||||
const description = String(fm.description ?? '');
|
||||
const lead = description.replace(/\s*\(gstack\)\s*$/, '');
|
||||
expect(/^Use when\b/.test(lead), rel + ': the catalog description "' + lead.slice(0, 80) + '" does not start with "Use when". ' + fix(skill)).toBe(true);
|
||||
expect(lead.length, rel + ': the catalog lead is ' + lead.length + ' chars against the ' + LEAD_CAP + '-char cap. ' + fix(skill)).toBeLessThanOrEqual(LEAD_CAP);
|
||||
const entry = Buffer.byteLength(String(fm.name)) + Buffer.byteLength(description);
|
||||
expect(entry, rel + ': name + description is ' + entry + ' bytes against the ' + CATALOG_ENTRY_CAP + '-byte cap. ' + fix(skill)).toBeLessThanOrEqual(CATALOG_ENTRY_CAP);
|
||||
});
|
||||
|
||||
test(skill + ': the full description fits every host limit', () => {
|
||||
const full = String(frontmatter(tmpl).description ?? '').trim();
|
||||
expect(full.length, tmpl + ': the description is ' + full.length + ' chars against the ' + DESCRIPTION_CAP + '-char cap. ' + fix(skill)).toBeLessThanOrEqual(DESCRIPTION_CAP);
|
||||
});
|
||||
|
||||
test(skill + ': triggers are short and distinct', () => {
|
||||
const own = triggersOf(skill);
|
||||
expect(own.length, tmpl + ': no triggers. ' + fix(skill)).toBeGreaterThan(0);
|
||||
expect(new Set(own).size, tmpl + ': duplicate triggers. ' + fix(skill)).toBe(own.length);
|
||||
for (const t of own) {
|
||||
expect(t.length, tmpl + ': trigger "' + t + '" is ' + t.length + ' chars against the ' + TRIGGER_CAP + '-char cap. ' + fix(skill)).toBeLessThanOrEqual(TRIGGER_CAP);
|
||||
expect(t, tmpl + ': trigger "' + t + '" must be lowercase. ' + fix(skill)).toBe(t.toLowerCase());
|
||||
for (const other of SKILLS.filter(s => s !== skill)) {
|
||||
const clash = triggersOf(other).find(o => o.includes(t) || t.includes(o));
|
||||
expect(clash, tmpl + ': trigger "' + t + '" overlaps ' + other + "'s \"" + clash + '". ' + fix(skill)).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const PROMPTS: Array<[string, string]> = [
|
||||
['this test fails, fix it', 'investigate'],
|
||||
['why is this broken?', 'investigate'],
|
||||
['the build is broken, here is the stack trace', 'investigate'],
|
||||
['review this PR', 'review'],
|
||||
['can you look over my diff', 'review'],
|
||||
['review my changes before I merge', 'review'],
|
||||
['test the site and fix what you find', 'qa'],
|
||||
['can you qa test this page', 'qa'],
|
||||
['run the checks and make it green', 'verify'],
|
||||
['verify my change', 'verify'],
|
||||
];
|
||||
for (const [prompt, want] of PROMPTS) {
|
||||
test('untold prompt "' + prompt + '" carries a ' + want + ' trigger and no other target trigger', () => {
|
||||
const p = prompt.toLowerCase();
|
||||
const hits = SKILLS.filter(s => triggersOf(s).some(t => p.includes(t)));
|
||||
expect(hits, '"' + prompt + '" matches [' + hits.join(', ') + '], want [' + want + ']. ' + fix(want)).toEqual([want]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('contract blocks (frozen v1 schema)', () => {
|
||||
const templates = readdirSync(ROOT, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && existsSync(join(ROOT, d.name, 'SKILL.md.tmpl')))
|
||||
.map(d => d.name)
|
||||
.sort();
|
||||
|
||||
test('investigate and review declare contracts', () => {
|
||||
for (const skill of ['investigate', 'review']) {
|
||||
const tmpl = skill + '/SKILL.md.tmpl';
|
||||
expect(contractFromTemplate(read(tmpl), tmpl), tmpl + ': no contract block. ' + fix(skill)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
for (const skill of templates) {
|
||||
const tmpl = skill + '/SKILL.md.tmpl';
|
||||
const contract = contractFromTemplate(read(tmpl), tmpl);
|
||||
if (!contract) {
|
||||
test(skill + ': no contract, no Done-means placeholder', () => {
|
||||
expect(read(tmpl).includes('{{CONTRACT_DONE_MEANS}}'), tmpl + ': {{CONTRACT_DONE_MEANS}} without a contract block. ' + fix(skill)).toBe(false);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
test(skill + ': the body carries the Done-means placeholder and the render carries the list', () => {
|
||||
expect(body(tmpl).includes('{{CONTRACT_DONE_MEANS}}'), tmpl + ': a contract block needs {{CONTRACT_DONE_MEANS}} at the top of the body. ' + fix(skill)).toBe(true);
|
||||
expect(read(skill + '/SKILL.md').includes(renderDoneMeans(contract)), skill + '/SKILL.md: the Done-means list is stale. ' + fix(skill)).toBe(true);
|
||||
});
|
||||
|
||||
test(skill + ': every contract marker appears in the skill text', () => {
|
||||
const sectionDir = join(ROOT, skill, 'sections');
|
||||
const sections = existsSync(sectionDir)
|
||||
? readdirSync(sectionDir).filter(f => f.endsWith('.md')).map(f => read(skill + '/sections/' + f))
|
||||
: [];
|
||||
const text = [body(tmpl), ...sections].join('\n');
|
||||
for (const item of contract.items) {
|
||||
const markers = item.kind === 'report_labels' ? [item.heading, ...item.labels.map(l => l + ':')]
|
||||
: item.kind === 'selected_specialists_dispatched' ? [item.marker]
|
||||
: item.kind === 'agent_dispatch' ? [item.match]
|
||||
: [];
|
||||
for (const marker of markers) {
|
||||
expect(text.includes(marker), tmpl + ': contract item ' + item.id + ' needs "' + marker + '", which the skill text never prints. ' + fix(skill)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('contract-done-means renderer', () => {
|
||||
const all = parseSkillContract({
|
||||
version: 1,
|
||||
items: [
|
||||
{ id: 'debug-report', kind: 'report_labels', heading: 'DEBUG REPORT', labels: ['Symptom', 'Root cause', 'Status'] },
|
||||
{ id: 'check-after-last-edit', kind: 'check_after_last_edit' },
|
||||
{ id: 'specialists-dispatched', kind: 'selected_specialists_dispatched', marker: 'Dispatching N specialists:' },
|
||||
{ id: 'adversarial-pass', kind: 'agent_dispatch', match: 'chaos engineer' },
|
||||
],
|
||||
}, 'fixture/SKILL.md.tmpl');
|
||||
const out = renderDoneMeans(all, true);
|
||||
|
||||
test('names each item kind in plain words', () => {
|
||||
expect(out).toContain('## Done means');
|
||||
expect(out).toContain('- ' + BT + 'debug-report' + BT + ': a ' + BT + 'DEBUG REPORT' + BT + ' with Symptom, Root cause and Status filled in (' + BT + 'none: <why>' + BT + " where the skill's own stop rule leaves one empty).");
|
||||
expect(out).toContain('- ' + BT + 'check-after-last-edit' + BT + ': a test or repro command run after your last edit.');
|
||||
expect(out).toContain('- ' + BT + 'specialists-dispatched' + BT + ': the specialist selection printed (' + BT + 'Dispatching N specialists:' + BT + ' ...), then one Agent dispatch per specialist it names (none when a small diff skips them).');
|
||||
expect(out).toContain('- ' + BT + 'adversarial-pass' + BT + ': an Agent dispatch for the adversarial pass, its prompt including "chaos engineer".');
|
||||
});
|
||||
|
||||
test('points to check and waive in the routing-and-contracts doc, never the off switch', () => {
|
||||
expect(out).toContain(BT + 'gstack-skill-contract check' + BT);
|
||||
expect(out).toContain(BT + 'gstack-skill-contract waive <item> --reason "<why>"' + BT);
|
||||
expect(out).toContain('docs/skill-routing-and-contracts.md');
|
||||
expect(out).not.toContain('skill_contracts');
|
||||
});
|
||||
|
||||
const one = (item: Record<string, unknown>) => ({ version: 1, items: [item] });
|
||||
const MALFORMED: Array<[string, unknown, RegExp]> = [
|
||||
['not a mapping', 'yes', /must be a mapping/],
|
||||
['wrong version', { version: 2, items: [{ id: 'a', kind: 'check_after_last_edit' }] }, /version must be 1/],
|
||||
['no items', { version: 1, items: [] }, /items must be a non-empty list/],
|
||||
['unknown top-level key', { version: 1, items: [{ id: 'a', kind: 'check_after_last_edit' }], extra: 1 }, /unknown key "extra"/],
|
||||
['unknown kind', one({ id: 'a', kind: 'vibes' }), /kind "vibes" is not one of/],
|
||||
['bad id', one({ id: 'Debug Report', kind: 'check_after_last_edit' }), /id must be lowercase kebab-case/],
|
||||
['duplicate id', { version: 1, items: [{ id: 'a', kind: 'check_after_last_edit' }, { id: 'a', kind: 'agent_dispatch', match: 'x' }] }, /duplicate id "a"/],
|
||||
['labels missing', one({ id: 'a', kind: 'report_labels', heading: 'R' }), /labels must be a non-empty list/],
|
||||
['placeholder label', one({ id: 'a', kind: 'report_labels', heading: 'R', labels: ['[what the user observed]'] }), /placeholder/],
|
||||
['heading missing', one({ id: 'a', kind: 'report_labels', labels: ['Symptom'] }), /heading must be a non-empty string/],
|
||||
['marker missing', one({ id: 'a', kind: 'selected_specialists_dispatched' }), /marker must be a non-empty string/],
|
||||
['match missing', one({ id: 'a', kind: 'agent_dispatch' }), /match must be a non-empty string/],
|
||||
['field from another kind', one({ id: 'a', kind: 'check_after_last_edit', match: 'x' }), /unknown key "match"/],
|
||||
];
|
||||
for (const [name, value, want] of MALFORMED) {
|
||||
test('a malformed block fails loudly: ' + name, () => {
|
||||
let message = '';
|
||||
try { parseSkillContract(value, 'demo/SKILL.md.tmpl'); } catch (e) { message = (e as Error).message; }
|
||||
expect(message).toMatch(want);
|
||||
expect(message).toContain('CONTRACT_INVALID demo/SKILL.md.tmpl');
|
||||
expect(message).toContain('Fix: edit demo/SKILL.md.tmpl, then bun run gen:skill-docs');
|
||||
});
|
||||
}
|
||||
|
||||
test('check and waive appear only once bin/gstack-skill-contract ships', () => {
|
||||
const shipped = existsSync(join(ROOT, 'bin', 'gstack-skill-contract'));
|
||||
expect(CONTRACT_CHECKER_SHIPPED, 'CONTRACT_CHECKER_SHIPPED in scripts/resolvers/contract-done-means.ts must be ' + shipped + ' while bin/gstack-skill-contract ' + (shipped ? 'exists' : 'is missing') + '. Fix: set it, then bun run gen:skill-docs').toBe(shipped);
|
||||
for (const skill of ['investigate', 'review']) {
|
||||
expect(read(skill + '/SKILL.md').includes('gstack-skill-contract'), skill + '/SKILL.md names gstack-skill-contract while bin/gstack-skill-contract ' + (shipped ? 'exists' : 'is missing') + '. Fix: bun run gen:skill-docs').toBe(shipped);
|
||||
}
|
||||
if (shipped) expect(existsSync(join(ROOT, 'docs', 'skill-routing-and-contracts.md')), 'docs/skill-routing-and-contracts.md is missing').toBe(true);
|
||||
expect(renderDoneMeans(all, false)).not.toContain('gstack-skill-contract');
|
||||
});
|
||||
|
||||
test('a host that suppresses, or a loadout that excludes, the step an item checks leaves that item out', () => {
|
||||
const render = (host: string, excluded: string[] = []) => generateContractDoneMeans({ skillName: 'review', tmplPath: join(ROOT, 'review', 'SKILL.md.tmpl'), host, paths: HOST_PATHS[host], modules: excluded.length ? { name: 'test', excluded: new Set(excluded) } : undefined } as unknown as Parameters<typeof generateContractDoneMeans>[0]);
|
||||
const ids = (host: string, excluded: string[] = []) => [...render(host, excluded).matchAll(/^- \u0060([a-z-]+)\u0060:/gm)].map(m => m[1]);
|
||||
expect(ids('claude', ['section:review/review-army', 'section:review/adversarial'])).toEqual(['review-report']);
|
||||
expect(ids('claude')).toEqual(['specialists-dispatched', 'adversarial-pass', 'review-report']);
|
||||
expect(ids('codex')).toEqual(['adversarial-pass', 'review-report']);
|
||||
expect(ids('hermes')).toEqual(['review-report']);
|
||||
expect(renderDoneMeans({ version: 1, items: [] })).toBe('');
|
||||
});
|
||||
|
||||
test('a placeholder with no contract block fails the render', () => {
|
||||
expect(() => contractFromTemplate('---\nname: demo\n---\n{{CONTRACT_DONE_MEANS}}\n', 'demo/SKILL.md.tmpl', { required: true }))
|
||||
.toThrow(/CONTRACT_INVALID demo\/SKILL.md.tmpl: \{\{CONTRACT_DONE_MEANS\}\} needs a contract block/);
|
||||
});
|
||||
|
||||
test('invalid frontmatter YAML fails the render', () => {
|
||||
expect(() => contractFromTemplate('---\nname: demo\ncontract: [unclosed\n---\n', 'demo/SKILL.md.tmpl'))
|
||||
.toThrow(/CONTRACT_INVALID demo\/SKILL.md.tmpl: frontmatter is not valid YAML/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user