* 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:
@@ -46,7 +46,7 @@ const { loadConfig } = configLoaderMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installProfilesMod = require('./install-profiles.cjs');
|
||||
const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires, parseCallsAgents } = installProfilesMod;
|
||||
const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires, parseCallsAgents, workflowAgentRefs } = installProfilesMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import surfaceMod = require('./surface.cjs');
|
||||
@@ -401,7 +401,17 @@ function _loadInstalledSkillsManifest(configDir: string): Map<string, string[]>
|
||||
* no gsd-*.md files (so _resolveManifest can use size>0 as the "flat layout
|
||||
* present" signal and fall through to the installed-skills branch otherwise).
|
||||
*/
|
||||
function _loadFlatCommandsGsdManifest(commandsParentDir: string): Map<string, string[]> {
|
||||
function _loadFlatCommandsGsdManifest(commandsParentDir: string, workflowsDir?: string): Map<string, string[]> {
|
||||
// #3798: derive agents from the command body PLUS the workflow files it
|
||||
// references, exactly like loadSkillsManifest does for the nested layout —
|
||||
// the flat (#1858) layout retained the original defect otherwise, and the
|
||||
// parity test in tests/capability-state.test.cjs asserts the two loaders
|
||||
// produce identical _calls_agents_* sets. Default: <pkg>/gsd-core/workflows
|
||||
// one level above the commands dir — the shape of both the repo checkout
|
||||
// (<repo>/commands/) and a flat runtime install (the runtime config dir's
|
||||
// commands/ with the package's gsd-core/ beside it).
|
||||
const flatWorkflowsDir = workflowsDir
|
||||
|| path.resolve(commandsParentDir, '..', 'gsd-core', 'workflows');
|
||||
const manifest = new Map<string, string[]>();
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
@@ -424,7 +434,12 @@ function _loadFlatCommandsGsdManifest(commandsParentDir: string): Map<string, st
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(commandsParentDir, entry.name), 'utf8');
|
||||
manifest.set(stem, parseRequires(content));
|
||||
manifest.set(`_calls_agents_${stem}`, parseCallsAgents(content));
|
||||
manifest.set(`_calls_agents_${stem}`, [
|
||||
...new Set([
|
||||
...parseCallsAgents(content),
|
||||
...workflowAgentRefs(content, flatWorkflowsDir),
|
||||
]),
|
||||
]);
|
||||
} catch {
|
||||
manifest.set(stem, []);
|
||||
manifest.set(`_calls_agents_${stem}`, []);
|
||||
|
||||
@@ -158,10 +158,63 @@ function parseCallsAgents(content: string): string[] {
|
||||
* Also derives calls_agents for each skill by scanning the body text for
|
||||
* `gsd-*` agent name references. Agent stems are stored under the special
|
||||
* key `_calls_agents_<stem>` so they don't conflict with skill stems.
|
||||
*
|
||||
* #3798: command bodies are thin delegators — the actual `subagent_type=`
|
||||
* spawns live in the workflow files each command references
|
||||
* (`@…/workflows/<name>.md`). The agent derivation therefore ALSO reads every
|
||||
* workflow file the command references (plus the workflow's steps/ fragments
|
||||
* when it is split per the progressive-disclosure pattern) and unions their
|
||||
* `gsd-*` tokens into the same `_calls_agents_<stem>` set. Without this,
|
||||
* tiered profiles omitted agents their own installed skills spawn
|
||||
* (gsd-verifier at execute-phase's verify_phase_goal was the filed repro).
|
||||
* Over-inclusion fails safe: a tiered profile installing one extra agent
|
||||
* costs bytes, never a broken spawn.
|
||||
*/
|
||||
const DEFAULT_COMMANDS_DIR = path.resolve(__dirname, '..', '..', '..', 'commands', 'gsd');
|
||||
const DEFAULT_WORKFLOWS_DIR = path.resolve(__dirname, '..', '..', 'workflows');
|
||||
|
||||
function loadSkillsManifest(commandsDir: string = DEFAULT_COMMANDS_DIR): Map<string, string[]> {
|
||||
/**
|
||||
* Collect the `gsd-*` agent tokens from every workflow file a command body
|
||||
* references. References are the `workflows/<name>.md` path suffixes the
|
||||
* delegating commands embed (`@…/gsd-core/workflows/<name>.md`). A
|
||||
* split workflow (workflows/<name>/steps/*.md) contributes its fragments as
|
||||
* well, because the parent dispatches into them and the spawns live there.
|
||||
*/
|
||||
function workflowAgentRefs(content: string, workflowsDir: string): string[] {
|
||||
const refs = content.match(/workflows\/([a-z0-9][a-z0-9-]*)\.md/g) || [];
|
||||
const names = [...new Set(refs.map((r) => r.slice('workflows/'.length)))];
|
||||
const tokens = new Set<string>();
|
||||
for (const name of names) {
|
||||
const direct = path.join(workflowsDir, name);
|
||||
let body: string | null = null;
|
||||
try { body = fs.readFileSync(direct, 'utf8'); } catch { body = null; }
|
||||
if (body === null) continue;
|
||||
for (const tok of parseCallsAgents(body)) tokens.add(tok);
|
||||
// Split workflow: union EVERY fragment under the workflow's directory
|
||||
// (steps/, modes/, templates/…) — the parent dispatches into these per
|
||||
// the progressive-disclosure pattern, and spawns live in all of them
|
||||
// (#3798 review: modes/ carried gsd-advisor-researcher's spawn while the
|
||||
// parent only named it incidentally in prose).
|
||||
const fragDir = path.join(workflowsDir, name.slice(0, -3));
|
||||
const walk = (dir: string): void => {
|
||||
let frags: fs.Dirent[];
|
||||
try { frags = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||||
for (const frag of frags) {
|
||||
const full = path.join(dir, frag.name);
|
||||
if (frag.isDirectory()) { walk(full); continue; }
|
||||
if (!frag.isFile() || !frag.name.endsWith('.md')) continue;
|
||||
try {
|
||||
const fragBody = fs.readFileSync(full, 'utf8');
|
||||
for (const tok of parseCallsAgents(fragBody)) tokens.add(tok);
|
||||
} catch { /* unreadable fragment — skip */ }
|
||||
}
|
||||
};
|
||||
walk(fragDir);
|
||||
}
|
||||
return [...tokens];
|
||||
}
|
||||
|
||||
function loadSkillsManifest(commandsDir: string = DEFAULT_COMMANDS_DIR, workflowsDir: string = DEFAULT_WORKFLOWS_DIR): Map<string, string[]> {
|
||||
const manifest = new Map<string, string[]>();
|
||||
if (!fs.existsSync(commandsDir)) return manifest;
|
||||
const entries = fs.readdirSync(commandsDir, { withFileTypes: true });
|
||||
@@ -172,9 +225,12 @@ function loadSkillsManifest(commandsDir: string = DEFAULT_COMMANDS_DIR): Map<str
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(commandsDir, entry.name), 'utf8');
|
||||
manifest.set(stem, parseRequires(content));
|
||||
// Derive agent references from body text
|
||||
const agentRefs = parseCallsAgents(content);
|
||||
manifest.set(`_calls_agents_${stem}`, agentRefs);
|
||||
// Derive agent references from body text + the workflows it delegates to
|
||||
const agentRefs = [
|
||||
...parseCallsAgents(content),
|
||||
...workflowAgentRefs(content, workflowsDir),
|
||||
];
|
||||
manifest.set(`_calls_agents_${stem}`, [...new Set(agentRefs)]);
|
||||
} catch {
|
||||
manifest.set(stem, []);
|
||||
manifest.set(`_calls_agents_${stem}`, []);
|
||||
@@ -1220,6 +1276,7 @@ export = {
|
||||
// Shared internals
|
||||
parseRequires,
|
||||
parseCallsAgents,
|
||||
workflowAgentRefs,
|
||||
cleanupStagedSkills,
|
||||
// #2322: capability-skill security seams — exported for direct unit-testing
|
||||
// and for surface.cts's prune pass (CAPABILITY_SKILL_MARKER parity).
|
||||
|
||||
Reference in New Issue
Block a user