* test(#3798): tiered profiles must install the agents their workflows spawn * fix(#3798): the profile closure follows command references into workflow spawn surfaces * chore(#3798): changeset fragment (pr number backfilled after PR creation) * chore(#3798): backfill changeset PR number (4009) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -1016,14 +1016,18 @@ describe('regressions: flat commands/gsd-<stem>.md layout (#1858)', () => {
|
||||
// commands/gsd-<stem>.md in a temp dir, then compare stem sets.
|
||||
const tmpFlat = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-parity-flat-'));
|
||||
try {
|
||||
const nested = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest(realCommandsGsdDir);
|
||||
// #3798: both loaders now union workflow spawn tokens; give them the
|
||||
// SAME workflows dir so the comparison stays apples-to-apples (the flat
|
||||
// mirror dir carries no workflows of its own).
|
||||
const realWorkflowsDir = path.resolve(__dirname, '..', 'gsd-core', 'workflows');
|
||||
const nested = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest(realCommandsGsdDir, realWorkflowsDir);
|
||||
for (const [stem] of nested) {
|
||||
if (stem.startsWith('_calls_agents_')) continue;
|
||||
const src = path.join(realCommandsGsdDir, stem + '.md');
|
||||
if (!fs.existsSync(src)) continue;
|
||||
fs.writeFileSync(path.join(tmpFlat, 'gsd-' + stem + '.md'), fs.readFileSync(src, 'utf8'), 'utf8');
|
||||
}
|
||||
const flat = _loadFlatCommandsGsdManifest(tmpFlat);
|
||||
const flat = _loadFlatCommandsGsdManifest(tmpFlat, realWorkflowsDir);
|
||||
const nestedStems = [...nested.keys()].filter((k) => !k.startsWith('_calls_agents_')).sort();
|
||||
const flatStems = [...flat.keys()].filter((k) => !k.startsWith('_calls_agents_')).sort();
|
||||
assert.deepStrictEqual(flatStems, nestedStems,
|
||||
|
||||
118
tests/install-profile-workflow-spawns.test.cjs
Normal file
118
tests/install-profile-workflow-spawns.test.cjs
Normal file
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #3798 — tiered profiles must install the agents their own installed skills
|
||||
// spawn.
|
||||
//
|
||||
// resolveProfile() derived the agent set by scanning ONLY the command bodies
|
||||
// (commands/gsd/*.md), which are thin delegators — the actual
|
||||
// `subagent_type="gsd-*"` spawns live in the workflow files those commands
|
||||
// reference. Under --profile=standard that omitted gsd-verifier (spawned by
|
||||
// execute-phase's verify_phase_goal), so phase-goal verification failed at
|
||||
// the point of spawn, AFTER execution work had landed. The manifest's agent
|
||||
// derivation now also reads every referenced workflow body (plus split
|
||||
// workflows' steps/ fragments) and unions their gsd-* tokens.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO = path.join(__dirname, '..');
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
} = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
|
||||
const COMMANDS_DIR = path.join(REPO, 'commands', 'gsd');
|
||||
const WORKFLOWS_DIR = path.join(REPO, 'gsd-core', 'workflows');
|
||||
const AGENTS_DIR = path.join(REPO, 'agents');
|
||||
|
||||
const realAgents = new Set(
|
||||
fs.readdirSync(AGENTS_DIR).filter((f) => f.endsWith('.md')).map((f) => f.slice(0, -3)),
|
||||
);
|
||||
|
||||
function realAgentsFor(mode) {
|
||||
const man = loadSkillsManifest(COMMANDS_DIR, WORKFLOWS_DIR);
|
||||
const resolved = resolveProfile({ modes: [mode], manifest: man });
|
||||
assert.notStrictEqual(resolved.skills, '*', `${mode} must resolve to a tiered profile in this repo`);
|
||||
return { resolved, installed: [...resolved.agents].filter((a) => realAgents.has(a)) };
|
||||
}
|
||||
|
||||
describe('install-profile workflow spawn closure (#3798)', () => {
|
||||
test('#3798: standard profile includes the agents its workflows spawn (gsd-verifier)', () => {
|
||||
const { installed } = realAgentsFor('standard');
|
||||
assert.ok(
|
||||
installed.includes('gsd-verifier'),
|
||||
`gsd-verifier is spawned by execute-phase's verify_phase_goal and must be installed under standard; got: ${installed.join(' ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('#3798: the agent set is a superset of referenced workflows\' spawn tokens', () => {
|
||||
const { resolved, installed } = realAgentsFor('standard');
|
||||
// Independently re-derive the gsd-* tokens in every workflow referenced by
|
||||
// a standard skill, and require each token that names a real agent to be
|
||||
// in the installed set — the property whose absence was the bug.
|
||||
const tokens = new Set();
|
||||
for (const skill of resolved.skills) {
|
||||
const cmd = path.join(COMMANDS_DIR, `${skill}.md`);
|
||||
if (!fs.existsSync(cmd)) continue;
|
||||
const body = fs.readFileSync(cmd, 'utf8');
|
||||
const refs = body.match(/workflows\/([a-z0-9][a-z0-9-]*)\.md/g) || [];
|
||||
for (const ref of refs) {
|
||||
const wf = path.join(WORKFLOWS_DIR, ref.slice('workflows/'.length));
|
||||
try {
|
||||
for (const tok of fs.readFileSync(wf, 'utf8').match(/\bgsd-[a-z][a-z-]*/g) || []) {
|
||||
tokens.add(tok);
|
||||
}
|
||||
} catch { /* workflow file absent — skip */ }
|
||||
}
|
||||
}
|
||||
const missing = [...tokens].filter((t) => realAgents.has(t) && !installed.includes(t));
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`#3798: every real agent named in a referenced workflow must be installed; missing: ${missing.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('#3798 control: the full sentinel is unchanged', () => {
|
||||
const man = loadSkillsManifest(COMMANDS_DIR, WORKFLOWS_DIR);
|
||||
const full = resolveProfile({ modes: ['full'], manifest: man });
|
||||
assert.strictEqual(full.skills, '*');
|
||||
assert.strictEqual(full.agents.size, 0);
|
||||
});
|
||||
|
||||
test('#3798 control: the requires: skill closure is unchanged by the agent derivation', () => {
|
||||
const { resolved } = realAgentsFor('standard');
|
||||
// The skill set must still be exactly the requires: closure — spot-check
|
||||
// a known member and a known non-member of standard's closure.
|
||||
assert.ok(resolved.skills.has('execute-phase'), 'execute-phase is a standard skill');
|
||||
assert.ok(!resolved.skills.has('autonomous'),
|
||||
'autonomous is not in standard\'s requires: closure — the agent derivation must not widen skills');
|
||||
});
|
||||
|
||||
test('#3798: workflow fragments (steps/, modes/) contribute spawn tokens', (t) => {
|
||||
// Fixture workflows dir: a parent workflow referencing nothing itself,
|
||||
// whose modes/ fragment carries the only spawn token — proves the
|
||||
// recursive fragment walk, not just the parent body, feeds the union.
|
||||
const os = require('node:os');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3798-wf-'));
|
||||
t.after(() => cleanup(tmp));
|
||||
const wfDir = path.join(tmp, 'workflows');
|
||||
fs.mkdirSync(path.join(wfDir, 'probe', 'modes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(wfDir, 'probe.md'), '# Probe\n\nNo agents here.\n');
|
||||
fs.writeFileSync(path.join(wfDir, 'probe', 'modes', 'advisor.md'), 'Spawn: subagent_type="gsd-fixture-only-agent"\n');
|
||||
const cmdDir = path.join(tmp, 'commands', 'gsd');
|
||||
fs.mkdirSync(cmdDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(cmdDir, 'probe-skill.md'), '@~/.claude/gsd-core/workflows/probe.md\n');
|
||||
|
||||
const man = loadSkillsManifest(cmdDir, wfDir);
|
||||
assert.ok(
|
||||
(man.get('_calls_agents_probe-skill') || []).includes('gsd-fixture-only-agent'),
|
||||
'#3798: a spawn token living ONLY in a workflow fragment (modes/) must reach the agent set',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user