* feat(#769): emit context:fork + effort: frontmatter on heavy workflow skills Add `context: fork` and `effort: xhigh` to the three heaviest workflow commands (plan-phase, execute-phase, autonomous) and `effort: low` to the two quick-status commands (progress, stats). On Claude Code, `context: fork` runs the skill in an isolated subagent context window so the main session's context budget is protected. `effort: xhigh` / `effort: low` signal the appropriate token-budget tier to the runtime. Both fields are silently ignored by runtimes that do not recognise them (Gemini, Codex, Cursor, etc.) — no behaviour change outside Claude Code. Update convertClaudeCommandToClaudeSkill in bin/install.js to preserve `context:` and `effort:` when rewriting source command files to SKILL.md for a Claude global install. Add install-suite tests to assert the fields are present in both source commands and the installed SKILL.md output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#769): tighten regex assertions + add execute/plan-phase effort coverage Fix low-severity adversarial finding: tighten test regex patterns from `\s*` to `[ \t]*` so they cannot match across newlines (CRLF parity). Add missing effort: xhigh assertions for gsd-execute-phase and gsd-plan-phase SKILL.md install output to complete the black-box coverage gap. 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:
5
.changeset/graceful-cranes-dart.md
Normal file
5
.changeset/graceful-cranes-dart.md
Normal file
@@ -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.
|
||||
@@ -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 += '---';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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]"
|
||||
context: fork
|
||||
effort: xhigh
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
|
||||
@@ -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 <N>] [--view] [--gaps] [--skip-verify] [--prd <file>] [--ingest <path-or-glob>] [--ingest-format <auto|nygard|madr|narrative>] [--reviews] [--text] [--tdd] [--mvp]"
|
||||
context: fork
|
||||
effort: xhigh
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: gsd:stats
|
||||
description: Display project statistics — phases, plans, requirements, git metrics, and timeline
|
||||
effort: low
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Bash
|
||||
|
||||
@@ -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
|
||||
|
||||
317
tests/enh-769-context-fork-effort.install.test.cjs
Normal file
317
tests/enh-769-context-fork-effort.install.test.cjs
Normal file
@@ -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}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user