feat(#774): emit service_tier/model_verbosity in Codex agent TOML + agents/openai.yaml skill chip (#828)
* feat(#774): emit service_tier/model_verbosity in Codex agent TOML + agents/openai.yaml skill chip - Add service_tier = "flex" and model_verbosity = "low" to the Codex ConfigProfile TOML for light-tier agents (gsd-research-synthesizer, gsd-codebase-mapper, gsd-plan-checker, and 8 others identified via AGENT_DEFAULT_TIERS). Field names/values verified against Codex schema (profile_toml.rs / config_types.rs Verbosity enum). Non-light agents are unaffected. - Add generateCodexSkillMetadataYaml() and writeCodexSkillMetadataFiles(): after installRuntimeArtifacts, iterate every gsd-* skill directory, read the short-description already emitted in the SKILL.md frontmatter by convertClaudeCommandToCodexSkill, and write agents/openai.yaml with interface.display_name and interface.short_description for the Codex TUI skill picker chip. - yamlQuote (JSON.stringify) handles all YAML-unsafe chars. - User-owned gsd-dev-preferences dir is never overwritten. - Errors per-skill are swallowed so a bad SKILL.md can't abort install. - agents/openai.yaml is covered by the snapshot/rollback system and manifest hash (writeManifest hashes skill dirs recursively). - Uninstall symmetry: _removeGsdEntries removes whole gsd-* dirs. - 21 new tests in codex-config.test.cjs covering service_tier/verbosity TOML emission, YAML generation (round-trip via js-yaml), and writeCodexSkillMetadataFiles including an e2e integration test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#774): correct docs-lint coverage — proper changeset format + USER-GUIDE entry Rewrite the changeset fragment from old @opengsd/gsd-core:patch format to the required type:/pr: schema so the docs-lint parser can consume it. Add a new "Codex skill picker and agent scheduling (#774)" section to docs/USER-GUIDE.md describing the flex-tier scheduling and /skills TUI chip enrichments — both are user-visible and belong in docs rather than behind a docs-exempt marker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const jsYaml = require('js-yaml');
|
||||
|
||||
// #2153 follow-up: ensure hooks/dist/ exists before any install integration
|
||||
// test runs. The Codex install path copies hook files from hooks/dist/, which
|
||||
@@ -44,6 +45,8 @@ const {
|
||||
convertClaudeAgentToCodexAgent,
|
||||
convertClaudeCommandToCodexSkill,
|
||||
generateCodexAgentToml,
|
||||
generateCodexSkillMetadataYaml,
|
||||
writeCodexSkillMetadataFiles,
|
||||
generateCodexConfigBlock,
|
||||
stripGsdFromCodexConfig,
|
||||
migrateCodexHooksMapFormat,
|
||||
@@ -401,6 +404,74 @@ tools: Read, Grep, Glob
|
||||
assert.ok(instrIdx !== -1, 'developer_instructions present');
|
||||
assert.ok(modelIdx < instrIdx, 'model field must appear before developer_instructions');
|
||||
});
|
||||
|
||||
// ─── #774: service_tier / model_verbosity for light-tier agents ───────────────
|
||||
|
||||
test('emits service_tier="flex" and model_verbosity="low" for light-tier agents (#774)', () => {
|
||||
// gsd-plan-checker has routingTier:"light" in model-catalog.json
|
||||
const lightAgent = `---
|
||||
name: gsd-plan-checker
|
||||
description: Checks plans quickly
|
||||
tools: Read, Grep
|
||||
---
|
||||
|
||||
<role>You check plans.</role>`;
|
||||
const result = generateCodexAgentToml('gsd-plan-checker', lightAgent);
|
||||
assert.ok(result.includes('service_tier = "flex"'), 'light-tier agent must have service_tier = "flex"');
|
||||
assert.ok(result.includes('model_verbosity = "low"'), 'light-tier agent must have model_verbosity = "low"');
|
||||
});
|
||||
|
||||
test('does not emit service_tier or model_verbosity for standard-tier agents (#774)', () => {
|
||||
// gsd-executor has routingTier:"standard" in model-catalog.json
|
||||
const result = generateCodexAgentToml('gsd-executor', sampleAgent);
|
||||
assert.ok(!result.includes('service_tier'), 'standard-tier agent must not have service_tier');
|
||||
assert.ok(!result.includes('model_verbosity'), 'standard-tier agent must not have model_verbosity');
|
||||
});
|
||||
|
||||
test('does not emit service_tier or model_verbosity for heavy-tier agents (#774)', () => {
|
||||
// gsd-planner has routingTier:"heavy" in model-catalog.json
|
||||
const heavyAgent = `---
|
||||
name: gsd-planner
|
||||
description: Creates plans
|
||||
tools: Read, Write, Edit
|
||||
---
|
||||
|
||||
<role>You plan.</role>`;
|
||||
const result = generateCodexAgentToml('gsd-planner', heavyAgent);
|
||||
assert.ok(!result.includes('service_tier'), 'heavy-tier agent must not have service_tier');
|
||||
assert.ok(!result.includes('model_verbosity'), 'heavy-tier agent must not have model_verbosity');
|
||||
});
|
||||
|
||||
test('service_tier and model_verbosity appear before developer_instructions (#774)', () => {
|
||||
const lightAgent = `---
|
||||
name: gsd-plan-checker
|
||||
description: Checks plans
|
||||
---
|
||||
|
||||
<role>You check plans.</role>`;
|
||||
const result = generateCodexAgentToml('gsd-plan-checker', lightAgent);
|
||||
const stIdx = result.indexOf('service_tier = "flex"');
|
||||
const mvIdx = result.indexOf('model_verbosity = "low"');
|
||||
const instrIdx = result.indexOf("developer_instructions = '''");
|
||||
assert.ok(stIdx !== -1, 'service_tier present');
|
||||
assert.ok(mvIdx !== -1, 'model_verbosity present');
|
||||
assert.ok(instrIdx !== -1, 'developer_instructions present');
|
||||
assert.ok(stIdx < instrIdx, 'service_tier must appear before developer_instructions');
|
||||
assert.ok(mvIdx < instrIdx, 'model_verbosity must appear before developer_instructions');
|
||||
});
|
||||
|
||||
test('emitted TOML is parseable and contains correct field values for light-tier agents (#774)', () => {
|
||||
const lightAgent = `---
|
||||
name: gsd-codebase-mapper
|
||||
description: Maps the codebase
|
||||
---
|
||||
|
||||
<role>You map the codebase.</role>`;
|
||||
const toml = generateCodexAgentToml('gsd-codebase-mapper', lightAgent);
|
||||
const parsed = parseTomlToObject(toml);
|
||||
assert.strictEqual(parsed.service_tier, 'flex', 'service_tier must parse to "flex"');
|
||||
assert.strictEqual(parsed.model_verbosity, 'low', 'model_verbosity must parse to "low"');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CODEX_AGENT_SANDBOX mapping ────────────────────────────────────────────────
|
||||
@@ -2302,3 +2373,215 @@ describe('Codex uninstall symmetry for hook-enabled configs', () => {
|
||||
assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #774: generateCodexSkillMetadataYaml ────────────────────────────────────────
|
||||
|
||||
describe('generateCodexSkillMetadataYaml', () => {
|
||||
test('emits valid parseable YAML with interface section (#774)', () => {
|
||||
const yaml = generateCodexSkillMetadataYaml('gsd-plan-phase', 'Plan and structure the next development phase.');
|
||||
// Must start with interface: and be parseable YAML
|
||||
assert.ok(yaml.startsWith('interface:'), 'must start with interface: key');
|
||||
const parsed = jsYaml.load(yaml);
|
||||
assert.ok(parsed && typeof parsed === 'object', 'must be parseable YAML object');
|
||||
assert.ok(parsed.interface, 'must have interface key');
|
||||
assert.ok('display_name' in parsed.interface, 'must include display_name');
|
||||
assert.ok('short_description' in parsed.interface, 'must include short_description');
|
||||
});
|
||||
|
||||
test('strips gsd- prefix from display_name and converts hyphens to spaces (#774)', () => {
|
||||
const yaml = generateCodexSkillMetadataYaml('gsd-plan-phase', 'Plan and structure the next development phase.');
|
||||
const parsed = jsYaml.load(yaml);
|
||||
assert.strictEqual(parsed.interface.display_name, 'plan phase',
|
||||
'display_name must be "plan phase" (gsd- stripped, hyphens→spaces)');
|
||||
});
|
||||
|
||||
test('embeds the short_description text as decoded string (#774)', () => {
|
||||
const desc = 'Run GSD workflow gsd-test.';
|
||||
const yaml = generateCodexSkillMetadataYaml('gsd-test', desc);
|
||||
const parsed = jsYaml.load(yaml);
|
||||
assert.strictEqual(parsed.interface.short_description, desc,
|
||||
'short_description must round-trip through YAML correctly');
|
||||
});
|
||||
|
||||
test('escapes double-quotes in description so parsed value is correct (#774)', () => {
|
||||
const desc = 'Run "special" workflow.';
|
||||
const yaml = generateCodexSkillMetadataYaml('gsd-test', desc);
|
||||
const parsed = jsYaml.load(yaml);
|
||||
assert.strictEqual(parsed.interface.short_description, desc,
|
||||
'double-quotes in description must round-trip correctly through YAML');
|
||||
});
|
||||
|
||||
test('escapes backslashes in description so parsed value is correct (#774)', () => {
|
||||
const desc = 'Run C:\\path\\to workflow.';
|
||||
const yaml = generateCodexSkillMetadataYaml('gsd-test', desc);
|
||||
const parsed = jsYaml.load(yaml);
|
||||
assert.strictEqual(parsed.interface.short_description, desc,
|
||||
'backslashes in description must round-trip correctly through YAML');
|
||||
});
|
||||
|
||||
test('output ends with a newline (#774)', () => {
|
||||
const yaml = generateCodexSkillMetadataYaml('gsd-test', 'Test skill.');
|
||||
assert.ok(yaml.endsWith('\n'), 'output must end with a newline');
|
||||
});
|
||||
|
||||
test('works for skill without gsd- prefix (#774)', () => {
|
||||
const yaml = generateCodexSkillMetadataYaml('my-skill', 'A custom skill.');
|
||||
const parsed = jsYaml.load(yaml);
|
||||
assert.strictEqual(parsed.interface.display_name, 'my skill',
|
||||
'display_name must convert hyphens to spaces even without gsd- prefix');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #774: writeCodexSkillMetadataFiles ─────────────────────────────────────────
|
||||
|
||||
describe('writeCodexSkillMetadataFiles', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-skill-meta-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('writes agents/openai.yaml for each gsd-* skill directory (#774)', () => {
|
||||
// Set up a synthetic skills directory with two gsd-* skill dirs
|
||||
const skills = [
|
||||
{ name: 'gsd-plan-phase', desc: 'Plan and structure the next development phase.' },
|
||||
{ name: 'gsd-execute-phase', desc: 'Execute the current phase.' },
|
||||
];
|
||||
for (const { name, desc } of skills) {
|
||||
const skillDir = path.join(tmpDir, name);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
const skillMd = `---\nname: ${name}\ndescription: "${desc}"\nmetadata:\n short-description: "${desc}"\n---\n\nBody text.\n`;
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillMd);
|
||||
}
|
||||
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
|
||||
for (const { name, desc } of skills) {
|
||||
const yamlPath = path.join(tmpDir, name, 'agents', 'openai.yaml');
|
||||
assert.ok(fs.existsSync(yamlPath), `agents/openai.yaml must exist for ${name}`);
|
||||
const content = fs.readFileSync(yamlPath, 'utf8');
|
||||
// Verify it's parseable YAML with the correct structure
|
||||
const parsed = jsYaml.load(content);
|
||||
assert.ok(parsed && parsed.interface, `${name}/agents/openai.yaml must parse to object with interface:`);
|
||||
assert.ok('display_name' in parsed.interface, `${name}/agents/openai.yaml must have display_name`);
|
||||
assert.strictEqual(parsed.interface.short_description, desc,
|
||||
`${name}/agents/openai.yaml short_description must match source description`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores non-gsd directories (#774)', () => {
|
||||
// Non-gsd-* dir should not get agents/openai.yaml
|
||||
const nonGsdDir = path.join(tmpDir, 'custom-skill');
|
||||
fs.mkdirSync(nonGsdDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nonGsdDir, 'SKILL.md'), '---\nname: custom\n---\nBody.\n');
|
||||
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(nonGsdDir, 'agents', 'openai.yaml')),
|
||||
'non-gsd-* dirs must not get agents/openai.yaml');
|
||||
});
|
||||
|
||||
test('does not overwrite user-owned gsd-dev-preferences/agents/openai.yaml (#774)', () => {
|
||||
// gsd-dev-preferences is user-owned and must never be modified by GSD install
|
||||
const userOwnedDir = path.join(tmpDir, 'gsd-dev-preferences');
|
||||
const agentsSubdir = path.join(userOwnedDir, 'agents');
|
||||
fs.mkdirSync(agentsSubdir, { recursive: true });
|
||||
fs.writeFileSync(path.join(userOwnedDir, 'SKILL.md'), '---\nname: gsd-dev-preferences\ndescription: "User pref"\n---\nBody.\n');
|
||||
const userYaml = 'interface:\n display_name: "my preferences"\n short_description: "User-authored"\n';
|
||||
fs.writeFileSync(path.join(agentsSubdir, 'openai.yaml'), userYaml);
|
||||
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
|
||||
// User-authored file must remain unchanged
|
||||
const after = fs.readFileSync(path.join(agentsSubdir, 'openai.yaml'), 'utf8');
|
||||
assert.strictEqual(after, userYaml, 'user-owned gsd-dev-preferences/agents/openai.yaml must not be overwritten');
|
||||
});
|
||||
|
||||
test('does not create agents/openai.yaml for gsd-dev-preferences if absent (#774)', () => {
|
||||
// Even if gsd-dev-preferences exists without an openai.yaml, we must not create one
|
||||
const userOwnedDir = path.join(tmpDir, 'gsd-dev-preferences');
|
||||
fs.mkdirSync(userOwnedDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(userOwnedDir, 'SKILL.md'), '---\nname: gsd-dev-preferences\n---\nBody.\n');
|
||||
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(userOwnedDir, 'agents', 'openai.yaml')),
|
||||
'gsd-dev-preferences must not get agents/openai.yaml even if it was absent');
|
||||
});
|
||||
|
||||
test('is a no-op when skillsDir does not exist (#774)', () => {
|
||||
// Should not throw when the directory doesn't exist
|
||||
assert.doesNotThrow(() => {
|
||||
writeCodexSkillMetadataFiles(path.join(tmpDir, 'nonexistent'));
|
||||
}, 'must not throw when skillsDir does not exist');
|
||||
});
|
||||
|
||||
test('skips skill dirs with missing SKILL.md without throwing (#774)', () => {
|
||||
// Create a gsd-* dir with no SKILL.md — should fail open
|
||||
const emptySkillDir = path.join(tmpDir, 'gsd-empty');
|
||||
fs.mkdirSync(emptySkillDir, { recursive: true });
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
}, 'must not throw when SKILL.md is missing');
|
||||
});
|
||||
|
||||
test('display_name in agents/openai.yaml has gsd- prefix stripped (#774)', () => {
|
||||
const skillDir = path.join(tmpDir, 'gsd-plan-phase');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
const skillMd = `---\nname: gsd-plan-phase\ndescription: "Plan the phase."\nmetadata:\n short-description: "Plan the phase."\n---\n\nBody.\n`;
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillMd);
|
||||
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
|
||||
const yamlContent = fs.readFileSync(path.join(tmpDir, 'gsd-plan-phase', 'agents', 'openai.yaml'), 'utf8');
|
||||
const parsed = jsYaml.load(yamlContent);
|
||||
assert.strictEqual(parsed.interface.display_name, 'plan phase',
|
||||
'display_name must have gsd- stripped and hyphens→spaces');
|
||||
});
|
||||
|
||||
test('correctly unescapes YAML-quoted short-description from SKILL.md (#774)', () => {
|
||||
// convertClaudeCommandToCodexSkill emits double-quoted YAML for descriptions.
|
||||
// Verify that escape sequences like \" in the YAML source round-trip to literal " in output.
|
||||
const skillDir = path.join(tmpDir, 'gsd-test-esc');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
// SKILL.md has a YAML-escaped double-quote in short-description
|
||||
const skillMd = '---\nname: gsd-test-esc\ndescription: "Normal"\nmetadata:\n short-description: "Run \\"special\\" workflow."\n---\n\nBody.\n';
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillMd);
|
||||
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
|
||||
const yamlContent = fs.readFileSync(path.join(tmpDir, 'gsd-test-esc', 'agents', 'openai.yaml'), 'utf8');
|
||||
const parsed = jsYaml.load(yamlContent);
|
||||
assert.strictEqual(parsed.interface.short_description, 'Run "special" workflow.',
|
||||
'YAML-escaped quotes in SKILL.md must round-trip to literal quotes in agents/openai.yaml');
|
||||
});
|
||||
|
||||
test('Codex install emits agents/openai.yaml for each skill (#774)', () => {
|
||||
// Integration test: run a full Codex install and verify agents/openai.yaml is written.
|
||||
// Use runCodexInstall so CODEX_HOME is saved and restored correctly even if it was
|
||||
// already set in the environment before this test ran.
|
||||
const codexHome = path.join(tmpDir, 'codex-home');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
runCodexInstall(codexHome);
|
||||
const skillsDir = path.join(codexHome, 'skills');
|
||||
// Assert that the install actually created a skills directory
|
||||
assert.ok(fs.existsSync(skillsDir), 'Codex install must create a skills/ directory');
|
||||
const gsdSkillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
||||
// At least some skills should be present
|
||||
assert.ok(gsdSkillDirs.length > 0, 'install must create at least one gsd-* skill directory');
|
||||
// Each skill directory must have agents/openai.yaml with valid YAML
|
||||
for (const skillEntry of gsdSkillDirs) {
|
||||
const yamlPath = path.join(skillsDir, skillEntry.name, 'agents', 'openai.yaml');
|
||||
assert.ok(fs.existsSync(yamlPath), `${skillEntry.name}/agents/openai.yaml must exist after install`);
|
||||
const content = fs.readFileSync(yamlPath, 'utf8');
|
||||
const parsed = jsYaml.load(content);
|
||||
assert.ok(parsed && parsed.interface, `${skillEntry.name}/agents/openai.yaml must parse to object with interface:`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user