fix(#1319): use portable Claude skill effort (#1352)

This commit is contained in:
Tom Boucher
2026-06-16 15:30:17 -04:00
committed by GitHub
parent c20d741dc9
commit a0dbf8bbdf
9 changed files with 69 additions and 41 deletions

View File

@@ -0,0 +1,6 @@
---
type: Fixed
pr: 1352
---
**Claude skill installs now avoid rejected `xhigh` effort frontmatter** — heavyweight GSD skills now ship with portable `effort: max`, and the Claude skill converter normalizes any remaining `xhigh` source effort before writing `SKILL.md`. (#1319)

View File

@@ -1796,6 +1796,10 @@ function skillFrontmatterName(skillDirName) {
return skillDirName;
}
function normalizeClaudeSkillEffort(effort) {
return effort === 'xhigh' ? 'max' : effort;
}
/**
* Qwen Code skills accept an optional numeric `priority` frontmatter field.
* Per the Qwen skills spec (qwen-code/docs/users/features/skills.md, verified
@@ -1892,7 +1896,7 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
// token-budget tier). Fields are Claude-specific; unknown frontmatter
// fields are silently ignored by other runtimes (backward-compatible).
if (context) fm += `context: ${context}\n`;
if (effort) fm += `effort: ${effort}\n`;
if (effort) fm += `effort: ${normalizeClaudeSkillEffort(effort)}\n`;
if (toolsBlock) fm += toolsBlock;
fm += '---';

View File

@@ -2,7 +2,7 @@
name: gsd:autonomous
description: Run all remaining phases autonomously — discuss→plan→execute per phase
argument-hint: "[--from N] [--to N] [--only N] [--interactive] [--converge]"
effort: xhigh
effort: max
allowed-tools:
- Read
- Write

View File

@@ -2,7 +2,7 @@
name: gsd:execute-phase
description: Execute all plans in a phase with wave-based parallelization
argument-hint: "<phase-number> [--wave N] [--gaps-only] [--interactive] [--tdd]"
effort: xhigh
effort: max
allowed-tools:
- Read
- Write

View File

@@ -2,7 +2,7 @@
name: gsd:plan-phase
description: Create detailed phase plan (PLAN.md) with verification loop
argument-hint: "[phase] [--auto] [--research] [--skip-research] [--research-phase <N>] [--view] [--gaps] [--skip-verify] [--prd <file>] [--ingest <path-or-glob>] [--ingest-format <auto|nygard|madr|narrative>] [--reviews] [--text] [--tdd] [--mvp]"
effort: xhigh
effort: max
allowed-tools:
- Read
- Write

View File

@@ -14,7 +14,7 @@ The hyphen and colon forms are *runtime-specific spellings of the same command*.
### Skill Runtime Behavior (Claude Code)
Heavy workflow skills (`/gsd-plan-phase`, `/gsd-execute-phase`, `/gsd-autonomous`) declare `effort: xhigh`, signalling maximum token budget to the runtime. These skills are spawning orchestrators — they must run at top level so they retain the `Agent` tool needed to spawn subagents. They do **not** carry `context: fork` (see #921).
Heavy workflow skills (`/gsd-plan-phase`, `/gsd-execute-phase`, `/gsd-autonomous`) declare `effort: max`, signalling maximum token budget to the runtime. These skills are spawning orchestrators — they must run at top level so they retain the `Agent` tool needed to spawn subagents. They do **not** carry `context: fork` (see #921).
Quick-status skills (`/gsd-progress`, `/gsd-stats`) declare `effort: low`, directing the runtime to use a minimal token budget for fast reads.

View File

@@ -92,7 +92,7 @@ Requiring a `/clear` to pick up a config edit would destroy the very continuity
### Effort signals for heavy and light skills
Beyond passive monitoring, GSD uses `effort:` frontmatter to signal the token budget appropriate for each skill. Heavy orchestrator skills (`plan-phase`, `execute-phase`, `autonomous`) declare `effort: xhigh`; quick-status skills (`progress`, `stats`) declare `effort: low`.
Beyond passive monitoring, GSD uses `effort:` frontmatter to signal the token budget appropriate for each skill. Heavy orchestrator skills (`plan-phase`, `execute-phase`, `autonomous`) declare `effort: max`; quick-status skills (`progress`, `stats`) declare `effort: low`.
Note: an earlier version of GSD also applied `context: fork` to these three heavy skills to protect the main session's context budget. This was removed (#921) because `plan-phase`, `execute-phase`, and `autonomous` are **spawning orchestrators** — their core function is to spawn subagents (`gsd-planner`, `gsd-executor`, etc.), and a forked subagent context does not have the `Agent` tool. Context isolation for these skills comes from the subagents they spawn, not from forking the orchestrator itself.

View File

@@ -309,6 +309,10 @@ function skillFrontmatterName(skillDirName) {
return skillDirName;
}
function normalizeClaudeSkillEffort(effort) {
return effort === 'xhigh' ? 'max' : effort;
}
/**
* Qwen Code skills accept an optional numeric `priority` frontmatter field.
* Per the Qwen skills spec (qwen-code/docs/users/features/skills.md, verified
@@ -405,7 +409,7 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
// token-budget tier). Fields are Claude-specific; unknown frontmatter
// fields are silently ignored by other runtimes (backward-compatible).
if (context) fm += `context: ${context}\n`;
if (effort) fm += `effort: ${effort}\n`;
if (effort) fm += `effort: ${normalizeClaudeSkillEffort(effort)}\n`;
if (toolsBlock) fm += toolsBlock;
fm += '---';

View File

@@ -10,23 +10,23 @@
* Context: context:fork was added by #769 to protect context budget, but
* plan-phase, execute-phase, and autonomous are spawning orchestrators — a
* forked subagent has no Agent/Task tool, breaking their core function.
* effort: xhigh is preserved; context: fork is removed from these three.
* effort: max is preserved; context: fork is removed from these three.
* The converter still passes context: fork through if a source file has it
* (for any future leaf skill that legitimately needs isolation).
*
* Verifies:
* 1. Source commands/gsd/autonomous.md does NOT have context: fork, has effort: xhigh
* 2. Source commands/gsd/execute-phase.md does NOT have context: fork, has effort: xhigh
* 3. Source commands/gsd/plan-phase.md does NOT have context: fork, has effort: xhigh
* 1. Source commands/gsd/autonomous.md does NOT have context: fork, has effort: max
* 2. Source commands/gsd/execute-phase.md does NOT have context: fork, has effort: max
* 3. Source commands/gsd/plan-phase.md does NOT have context: fork, has effort: max
* 4. Source commands/gsd/progress.md has effort: low
* 5. Source commands/gsd/stats.md has effort: low
* 6. Claude global install: SKILL.md for autonomous has effort: xhigh, NOT context: fork
* 7. Claude global install: SKILL.md for execute-phase has effort: xhigh, NOT context: fork
* 8. Claude global install: SKILL.md for plan-phase has effort: xhigh, NOT context: fork
* 6. Claude global install: SKILL.md for autonomous has effort: max, NOT context: fork
* 7. Claude global install: SKILL.md for execute-phase has effort: max, NOT context: fork
* 8. Claude global install: SKILL.md for plan-phase has effort: max, NOT context: fork
* 9. Claude global install: SKILL.md for progress has effort: low
* 10. Claude global install: SKILL.md for stats has effort: low
* 11. convertClaudeCommandToClaudeSkill still passes context: fork through (for non-orchestrator skills)
* 12. convertClaudeCommandToClaudeSkill preserves effort: field
* 12. convertClaudeCommandToClaudeSkill emits portable effort: field values
*/
'use strict';
@@ -107,18 +107,20 @@ function runClaudeGlobalInstall(claudeHome) {
// #921/#922: spawning orchestrators must NOT carry context: fork — a forked
// subagent has no Agent/Task tool, making it impossible for orchestrators to
// spawn their required subagents. context: fork is appropriate only for leaf
// skills that do not themselves dispatch agents. effort: xhigh is preserved.
describe('#769/#921 source commands: spawning orchestrators have effort: xhigh but NOT context: fork', () => {
// skills that do not themselves dispatch agents. effort: max is portable across Claude Code models.
describe('#769/#921/#1319 source commands: spawning orchestrators have effort: max but NOT context: fork', () => {
test('commands/gsd/autonomous.md does NOT have context: fork (#921)', () => {
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md'));
assert.doesNotMatch(fm, /^context:[ \t]*fork$/m,
`autonomous.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`);
});
test('commands/gsd/autonomous.md has effort: xhigh', () => {
test('commands/gsd/autonomous.md has effort: max (#1319)', () => {
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md'));
assert.match(fm, /^effort:[ \t]*xhigh$/m,
`autonomous.md frontmatter must have effort: xhigh\nActual:\n${fm}`);
assert.match(fm, /^effort:[ \t]*max$/m,
`autonomous.md frontmatter must have effort: max\nActual:\n${fm}`);
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
`autonomous.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
});
test('commands/gsd/execute-phase.md does NOT have context: fork (#921)', () => {
@@ -127,10 +129,12 @@ describe('#769/#921 source commands: spawning orchestrators have effort: xhigh b
`execute-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`);
});
test('commands/gsd/execute-phase.md has effort: xhigh', () => {
test('commands/gsd/execute-phase.md has effort: max (#1319)', () => {
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md'));
assert.match(fm, /^effort:[ \t]*xhigh$/m,
`execute-phase.md frontmatter must have effort: xhigh\nActual:\n${fm}`);
assert.match(fm, /^effort:[ \t]*max$/m,
`execute-phase.md frontmatter must have effort: max\nActual:\n${fm}`);
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
`execute-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
});
test('commands/gsd/plan-phase.md does NOT have context: fork (#921)', () => {
@@ -139,10 +143,12 @@ describe('#769/#921 source commands: spawning orchestrators have effort: xhigh b
`plan-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`);
});
test('commands/gsd/plan-phase.md has effort: xhigh', () => {
test('commands/gsd/plan-phase.md has effort: max (#1319)', () => {
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md'));
assert.match(fm, /^effort:[ \t]*xhigh$/m,
`plan-phase.md frontmatter must have effort: xhigh\nActual:\n${fm}`);
assert.match(fm, /^effort:[ \t]*max$/m,
`plan-phase.md frontmatter must have effort: max\nActual:\n${fm}`);
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
`plan-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
});
});
@@ -162,7 +168,7 @@ describe('#769 source commands: quick-status skills have effort: low', () => {
// ─── describe 2: convertClaudeCommandToClaudeSkill preserves new fields ───────
describe('#769 convertClaudeCommandToClaudeSkill: preserves context and effort fields', () => {
describe('#769/#1319 convertClaudeCommandToClaudeSkill: preserves context and emits portable effort fields', () => {
test('preserves context: fork in emitted SKILL.md frontmatter', () => {
const input = [
'---',
@@ -186,7 +192,7 @@ describe('#769 convertClaudeCommandToClaudeSkill: preserves context and effort f
`SKILL.md frontmatter must include context: fork\nActual frontmatter:\n${fm}`);
});
test('preserves effort: xhigh in emitted SKILL.md frontmatter', () => {
test('normalizes effort: xhigh to effort: max in emitted SKILL.md frontmatter (#1319)', () => {
const input = [
'---',
'name: gsd:test-heavy',
@@ -205,8 +211,10 @@ describe('#769 convertClaudeCommandToClaudeSkill: preserves context and effort f
const end = result.indexOf('---', 3);
const fm = result.substring(3, end);
assert.match(fm, /^effort:[ \t]*xhigh$/m,
`SKILL.md frontmatter must include effort: xhigh\nActual frontmatter:\n${fm}`);
assert.match(fm, /^effort:[ \t]*max$/m,
`SKILL.md frontmatter must include portable effort: max\nActual frontmatter:\n${fm}`);
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
`SKILL.md frontmatter must not include rejected effort: xhigh (#1319)\nActual frontmatter:\n${fm}`);
});
test('preserves effort: low in emitted SKILL.md frontmatter', () => {
@@ -256,8 +264,8 @@ describe('#769 convertClaudeCommandToClaudeSkill: preserves context and effort f
// ─── describe 3: Claude global install — SKILL.md files include new fields ────
// #921/#922: after install, spawning orchestrators must NOT carry context: fork
// in their emitted SKILL.md. effort: xhigh is still emitted (preserved from source).
describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files have effort: xhigh but NOT context: fork', () => {
// in their emitted SKILL.md. #1319: heavyweight skills must use portable max effort.
describe('#769/#921/#1319 Claude global install: spawning-orchestrator SKILL.md files have effort: max but NOT context: fork', () => {
let tmpDir;
let claudeHome;
@@ -279,12 +287,14 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files
`gsd-autonomous is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`);
});
test('gsd-autonomous SKILL.md has effort: xhigh after global install', () => {
test('gsd-autonomous SKILL.md has effort: max after global install (#1319)', () => {
runClaudeGlobalInstall(claudeHome);
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous');
const fm = readFrontmatter(skillPath);
assert.match(fm, /^effort:[ \t]*xhigh$/m,
`gsd-autonomous SKILL.md must have effort: xhigh\nActual:\n${fm}`);
assert.match(fm, /^effort:[ \t]*max$/m,
`gsd-autonomous SKILL.md must have effort: max\nActual:\n${fm}`);
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
`gsd-autonomous SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
});
test('gsd-execute-phase SKILL.md does NOT have context: fork after global install (#921)', () => {
@@ -295,12 +305,14 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files
`gsd-execute-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`);
});
test('gsd-execute-phase SKILL.md has effort: xhigh after global install', () => {
test('gsd-execute-phase SKILL.md has effort: max after global install (#1319)', () => {
runClaudeGlobalInstall(claudeHome);
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase');
const fm = readFrontmatter(skillPath);
assert.match(fm, /^effort:[ \t]*xhigh$/m,
`gsd-execute-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`);
assert.match(fm, /^effort:[ \t]*max$/m,
`gsd-execute-phase SKILL.md must have effort: max\nActual:\n${fm}`);
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
`gsd-execute-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
});
test('gsd-plan-phase SKILL.md does NOT have context: fork after global install (#921)', () => {
@@ -311,12 +323,14 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files
`gsd-plan-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`);
});
test('gsd-plan-phase SKILL.md has effort: xhigh after global install', () => {
test('gsd-plan-phase SKILL.md has effort: max after global install (#1319)', () => {
runClaudeGlobalInstall(claudeHome);
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase');
const fm = readFrontmatter(skillPath);
assert.match(fm, /^effort:[ \t]*xhigh$/m,
`gsd-plan-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`);
assert.match(fm, /^effort:[ \t]*max$/m,
`gsd-plan-phase SKILL.md must have effort: max\nActual:\n${fm}`);
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
`gsd-plan-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
});
test('gsd-progress SKILL.md has effort: low after global install', () => {