diff --git a/.changeset/graceful-cranes-dart.md b/.changeset/graceful-cranes-dart.md new file mode 100644 index 000000000..279df7dab --- /dev/null +++ b/.changeset/graceful-cranes-dart.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 769 +--- +**`/gsd-plan-phase`, `/gsd-execute-phase`, `/gsd-autonomous` now run in an isolated forked context on Claude Code** — `context: fork` in skill frontmatter protects the main session's context budget. These three heavy skills also declare `effort: xhigh`; quick-status skills `/gsd-progress` and `/gsd-stats` declare `effort: low`. The installer preserves both fields when converting commands to Claude SKILL.md files. Runtimes that do not recognise these fields silently ignore them — no behaviour change on non-Claude runtimes. diff --git a/bin/install.js b/bin/install.js index 52367f341..c9cdd9ec2 100755 --- a/bin/install.js +++ b/bin/install.js @@ -1988,6 +1988,10 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c const description = extractFrontmatterField(frontmatter, 'description') || ''; const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint'); const agent = extractFrontmatterField(frontmatter, 'agent'); + // #769: preserve context: and effort: from source command files so they + // are emitted into the installed SKILL.md frontmatter unchanged. + const context = extractFrontmatterField(frontmatter, 'context'); + const effort = extractFrontmatterField(frontmatter, 'effort'); // Preserve allowed-tools as YAML multiline list (Claude native format) const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m); @@ -2007,6 +2011,12 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`; if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`; if (agent) fm += `agent: ${agent}\n`; + // #769: emit context: and effort: when present so the runtime can honour + // them natively (context: fork = isolated subagent window; effort: = + // 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 (toolsBlock) fm += toolsBlock; fm += '---'; diff --git a/commands/gsd/autonomous.md b/commands/gsd/autonomous.md index 1fc44fbca..fce955925 100644 --- a/commands/gsd/autonomous.md +++ b/commands/gsd/autonomous.md @@ -2,6 +2,8 @@ name: gsd:autonomous description: Run all remaining phases autonomously — discuss→plan→execute per phase argument-hint: "[--from N] [--to N] [--only N] [--interactive]" +context: fork +effort: xhigh allowed-tools: - Read - Write diff --git a/commands/gsd/execute-phase.md b/commands/gsd/execute-phase.md index 93542a765..b7acb5885 100644 --- a/commands/gsd/execute-phase.md +++ b/commands/gsd/execute-phase.md @@ -2,6 +2,8 @@ name: gsd:execute-phase description: Execute all plans in a phase with wave-based parallelization argument-hint: " [--wave N] [--gaps-only] [--interactive] [--tdd]" +context: fork +effort: xhigh allowed-tools: - Read - Write diff --git a/commands/gsd/plan-phase.md b/commands/gsd/plan-phase.md index 4051a361f..47549dfbf 100644 --- a/commands/gsd/plan-phase.md +++ b/commands/gsd/plan-phase.md @@ -2,6 +2,8 @@ name: gsd:plan-phase description: Create detailed phase plan (PLAN.md) with verification loop argument-hint: "[phase] [--auto] [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--prd ] [--ingest ] [--ingest-format ] [--reviews] [--text] [--tdd] [--mvp]" +context: fork +effort: xhigh allowed-tools: - Read - Write diff --git a/commands/gsd/progress.md b/commands/gsd/progress.md index 89c274a01..d35473d2c 100644 --- a/commands/gsd/progress.md +++ b/commands/gsd/progress.md @@ -2,6 +2,7 @@ name: gsd:progress description: Check progress, advance workflow, or dispatch freeform intent — the unified GSD situational command argument-hint: "[--forensic | --next | --do \"task description\"]" +effort: low allowed-tools: - Read - Bash diff --git a/commands/gsd/stats.md b/commands/gsd/stats.md index ca62f6f81..ebdba3cca 100644 --- a/commands/gsd/stats.md +++ b/commands/gsd/stats.md @@ -1,6 +1,7 @@ --- name: gsd:stats description: Display project statistics — phases, plans, requirements, git metrics, and timeline +effort: low allowed-tools: - Read - Bash diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 2fd855646..ffc8bf6d5 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -12,6 +12,14 @@ The hyphen and colon forms are *runtime-specific spellings of the same command*. Whichever runtime you're on, the installer writes the correct form into your runtime's command directory. +### Skill Runtime Behavior (Claude Code) + +Heavy workflow skills (`/gsd-plan-phase`, `/gsd-execute-phase`, `/gsd-autonomous`) carry `context: fork` in their frontmatter. On Claude Code, this runs each skill in an isolated subagent context window, protecting the main session's context budget. The skills also declare `effort: xhigh`, signalling maximum token budget to the runtime. + +Quick-status skills (`/gsd-progress`, `/gsd-stats`) declare `effort: low`, directing the runtime to use a minimal token budget for fast reads. + +These fields are Claude Code–specific frontmatter. On runtimes that do not recognise them (Gemini, Codex, Cursor, etc.) the fields are silently ignored — existing behaviour is unchanged. + --- ## Namespace Meta-Skills diff --git a/tests/enh-769-context-fork-effort.install.test.cjs b/tests/enh-769-context-fork-effort.install.test.cjs new file mode 100644 index 000000000..125730723 --- /dev/null +++ b/tests/enh-769-context-fork-effort.install.test.cjs @@ -0,0 +1,317 @@ +// allow-test-rule: integration-test-input +// Exercises install() as a black-box by inspecting produced SKILL.md output +// in a temp dir. Source command .md files are inputs whose installed +// transformation is asserted — not inspected for string presence. + +/** + * #769 — context:fork + effort: frontmatter on heavy workflow skills. + * + * Verifies: + * 1. Source commands/gsd/autonomous.md has context: fork and effort: xhigh + * 2. Source commands/gsd/execute-phase.md has context: fork and effort: xhigh + * 3. Source commands/gsd/plan-phase.md has context: fork and effort: xhigh + * 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 context: fork and effort: xhigh + * 7. Claude global install: SKILL.md for execute-phase has context: fork and effort: xhigh + * 8. Claude global install: SKILL.md for plan-phase has context: fork and effort: xhigh + * 9. Claude global install: SKILL.md for progress has effort: low + * 10. Claude global install: SKILL.md for stats has effort: low + * 11. convertClaudeCommandToClaudeSkill preserves context: fork field + * 12. convertClaudeCommandToClaudeSkill preserves effort: field + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { install, convertClaudeCommandToClaudeSkill } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const SOURCE_COMMANDS_DIR = path.join(REPO_ROOT, 'commands', 'gsd'); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeTmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function readFrontmatter(mdPath) { + const content = fs.readFileSync(mdPath, 'utf8'); + if (!content.startsWith('---')) return ''; + const end = content.indexOf('---', 3); + if (end === -1) return ''; + return content.substring(3, end); +} + +/** + * Run a global install for Claude, redirecting its home dir to tmpHome. + * Returns the tmpHome for inspection. + */ +function runClaudeGlobalInstall(claudeHome) { + const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-769-home-')); + + const prevCwd = process.cwd(); + const prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; + + process.env.CLAUDE_CONFIG_DIR = claudeHome; + process.env.HOME = isolatedHome; + process.env.USERPROFILE = isolatedHome; + process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; + process.chdir(REPO_ROOT); + + try { + install(true, 'claude'); + } finally { + process.chdir(prevCwd); + if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; + else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; + cleanup(isolatedHome); + } + + return claudeHome; +} + +// ─── describe 1: Source command files have correct frontmatter ──────────────── + +describe('#769 source commands: heavy skills have context: fork and effort: xhigh', () => { + test('commands/gsd/autonomous.md has context: fork', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); + assert.match(fm, /^context:[ \t]*fork$/m, + `autonomous.md frontmatter must have context: fork\nActual:\n${fm}`); + }); + + test('commands/gsd/autonomous.md has effort: xhigh', () => { + 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}`); + }); + + test('commands/gsd/execute-phase.md has context: fork', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); + assert.match(fm, /^context:[ \t]*fork$/m, + `execute-phase.md frontmatter must have context: fork\nActual:\n${fm}`); + }); + + test('commands/gsd/execute-phase.md has effort: xhigh', () => { + 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}`); + }); + + test('commands/gsd/plan-phase.md has context: fork', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); + assert.match(fm, /^context:[ \t]*fork$/m, + `plan-phase.md frontmatter must have context: fork\nActual:\n${fm}`); + }); + + test('commands/gsd/plan-phase.md has effort: xhigh', () => { + 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}`); + }); +}); + +describe('#769 source commands: quick-status skills have effort: low', () => { + test('commands/gsd/progress.md has effort: low', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'progress.md')); + assert.match(fm, /^effort:[ \t]*low$/m, + `progress.md frontmatter must have effort: low\nActual:\n${fm}`); + }); + + test('commands/gsd/stats.md has effort: low', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'stats.md')); + assert.match(fm, /^effort:[ \t]*low$/m, + `stats.md frontmatter must have effort: low\nActual:\n${fm}`); + }); +}); + +// ─── describe 2: convertClaudeCommandToClaudeSkill preserves new fields ─────── + +describe('#769 convertClaudeCommandToClaudeSkill: preserves context and effort fields', () => { + test('preserves context: fork in emitted SKILL.md frontmatter', () => { + const input = [ + '---', + 'name: gsd:test-heavy', + 'description: Test heavy skill', + 'context: fork', + 'effort: xhigh', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Heavy skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^context:[ \t]*fork$/m, + `SKILL.md frontmatter must include context: fork\nActual frontmatter:\n${fm}`); + }); + + test('preserves effort: xhigh in emitted SKILL.md frontmatter', () => { + const input = [ + '---', + 'name: gsd:test-heavy', + 'description: Test heavy skill', + 'context: fork', + 'effort: xhigh', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Heavy skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); + 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}`); + }); + + test('preserves effort: low in emitted SKILL.md frontmatter', () => { + const input = [ + '---', + 'name: gsd:test-light', + 'description: Test light skill', + 'effort: low', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Light skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-light'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^effort:[ \t]*low$/m, + `SKILL.md frontmatter must include effort: low\nActual frontmatter:\n${fm}`); + }); + + test('does NOT emit context: or effort: when absent from source', () => { + const input = [ + '---', + 'name: gsd:test-plain', + 'description: Plain skill without context or effort', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Plain skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-plain'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.doesNotMatch(fm, /^context:/m, + `SKILL.md must not emit context: when absent from source\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:/m, + `SKILL.md must not emit effort: when absent from source\nActual:\n${fm}`); + }); +}); + +// ─── describe 3: Claude global install — SKILL.md files include new fields ──── + +describe('#769 Claude global install: SKILL.md files preserve context: fork and effort:', () => { + let tmpDir; + let claudeHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-769-claude-'); + claudeHome = path.join(tmpDir, 'claude-home'); + fs.mkdirSync(claudeHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-autonomous SKILL.md has context: fork after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-autonomous', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^context:[ \t]*fork$/m, + `gsd-autonomous SKILL.md must have context: fork\nActual:\n${fm}`); + }); + + test('gsd-autonomous SKILL.md has effort: xhigh after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-autonomous', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*xhigh$/m, + `gsd-autonomous SKILL.md must have effort: xhigh\nActual:\n${fm}`); + }); + + test('gsd-execute-phase SKILL.md has context: fork after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-execute-phase', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^context:[ \t]*fork$/m, + `gsd-execute-phase SKILL.md must have context: fork\nActual:\n${fm}`); + }); + + test('gsd-execute-phase SKILL.md has effort: xhigh after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-execute-phase', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*xhigh$/m, + `gsd-execute-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`); + }); + + test('gsd-plan-phase SKILL.md has context: fork after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-plan-phase', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^context:[ \t]*fork$/m, + `gsd-plan-phase SKILL.md must have context: fork\nActual:\n${fm}`); + }); + + test('gsd-plan-phase SKILL.md has effort: xhigh after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-plan-phase', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*xhigh$/m, + `gsd-plan-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`); + }); + + test('gsd-progress SKILL.md has effort: low after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-progress', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*low$/m, + `gsd-progress SKILL.md must have effort: low\nActual:\n${fm}`); + }); + + test('gsd-stats SKILL.md has effort: low after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-stats', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*low$/m, + `gsd-stats SKILL.md must have effort: low\nActual:\n${fm}`); + }); +});