diff --git a/.changeset/eager-tunas-swim.md b/.changeset/eager-tunas-swim.md new file mode 100644 index 000000000..5a77fc331 --- /dev/null +++ b/.changeset/eager-tunas-swim.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1991 +--- +**`/gsd-quick` no longer halts with a stale-base worktree mismatch** — the worktree executor now degrades to sequential execution when its fork base has diverged from origin/HEAD, instead of spawning a worktree guaranteed to fail the base-mismatch guard. diff --git a/.changeset/steady-ibex-run.md b/.changeset/steady-ibex-run.md new file mode 100644 index 000000000..bf1b37a3b --- /dev/null +++ b/.changeset/steady-ibex-run.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2049 +--- +**Skill-bearing capabilities now surface correctly on flat command-layout installs** — on an install using the flat `commands/gsd-.md` source layout (e.g. a Claude Code local project install with no `commands/gsd/` subdir), every skill-bearing capability (`nyquist`, `code-review`, `security`, `ui`, `mempalace`, `ai-integration`, `profile-pipeline`) was silently reported `surfaced:false`/`enabled:false`/`active:false`, so their loop hooks (`verify:post`, `execute:post`, etc.) never fired even with the corresponding `workflow.*` toggle on. The skill-manifest resolver now detects the flat layout and produces the same stems the nested `commands/gsd/*.md` loader does. (#1858) diff --git a/docs/how-to/fix-worktree-base-mismatch.md b/docs/how-to/fix-worktree-base-mismatch.md index 16b445198..e442d26ff 100644 --- a/docs/how-to/fix-worktree-base-mismatch.md +++ b/docs/how-to/fix-worktree-base-mismatch.md @@ -1,16 +1,16 @@ # How to fix the worktree base-mismatch (exit 42) error -**Goal:** Understand why `/gsd-execute-phase` halts with `FATAL: worktree base mismatch` / exit 42 when your branch is ahead of the default branch, and choose the right fix to restore normal — or parallel — execution. +**Goal:** Understand why `/gsd-execute-phase` or `/gsd-quick` halts with `FATAL: worktree base mismatch` / exit 42 when your branch is ahead of the default branch, and choose the right fix to restore normal — or parallel — execution. -**Prerequisites:** GSD Core is installed and you have an active project. You have run `/gsd-execute-phase` and either seen the exit-42 error or the one-line `⚠ Worktree base mismatch` warning. +**Prerequisites:** GSD Core is installed and you have an active project. You have run `/gsd-execute-phase` or `/gsd-quick` and either seen the exit-42 error or the one-line `⚠ Worktree base mismatch` warning. --- ## What you will see -When you run `/gsd-execute-phase` on a branch that is ahead of the repository's default branch (for example, an unmerged milestone branch, a long-lived feature branch, or a branch with commits not yet in `origin/HEAD`), you may see one of two messages: +When you run `/gsd-execute-phase` or `/gsd-quick` on a branch that is ahead of the repository's default branch (for example, an unmerged milestone branch, a long-lived feature branch, or a branch with commits not yet in `origin/HEAD`), you may see one of two messages: -**Automatic-degrade warning (phase still completes):** +**Automatic-degrade warning (phase or quick task still completes):** ``` ⚠ Worktree base mismatch: HEAD (abc12345) differs from origin/HEAD (def67890). @@ -19,7 +19,7 @@ To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683. ``` -The phase runs to completion sequentially; nothing is blocked. This is the runtime mitigation. +The phase or quick task runs to completion sequentially; nothing is blocked. This is the runtime mitigation (`/gsd-execute-phase`: #683/#1369; `/gsd-quick`: #1941). **Exit-42 halt (older installs or misconfigured environments):** diff --git a/gsd-core/workflows/quick.md b/gsd-core/workflows/quick.md index 40668ff58..fc9f98756 100644 --- a/gsd-core/workflows/quick.md +++ b/gsd-core/workflows/quick.md @@ -671,6 +671,26 @@ fi **Step 6: Spawn executor** +Auto-degrade to sequential if HEAD has diverged from the worktree fork base (#1941, mirrors +execute-phase's #683/#1369 guard). Claude Code's `isolation="worktree"` forks new worktrees from +`origin/HEAD`, not the live local HEAD. If a prior quick task in this session (or the Step 5.6 +pre-dispatch plan commit above) advanced local HEAD without an intervening `git push`, +`origin/HEAD` stays pinned to a stale ancestor and the executor's `worktree_branch_check` guard +halts with a base-mismatch fatal — potentially many commits behind, not just one. Run this check +immediately before capturing `EXPECTED_BASE` so it reflects the most current local state. + +```bash +if [ "$RUNTIME" = "claude" ] && [ "${USE_WORKTREES:-true}" != "false" ]; then + _QUICK_SHOULD_DEGRADE=$(gsd_run query worktree.base-check --pick shouldDegrade 2>/dev/null || true) + if [ "$_QUICK_SHOULD_DEGRADE" = "true" ]; then + _QUICK_DEGRADE_MSG=$(gsd_run query worktree.base-check --pick message 2>/dev/null || true) + [ -n "$_QUICK_DEGRADE_MSG" ] && printf '%s\n' "$_QUICK_DEGRADE_MSG" >&2 + echo "⚠ [#1941] Worktree fork base diverged from orchestrator HEAD — auto-degrading to sequential mode for this quick task to avoid a base-mismatch halt." >&2 + USE_WORKTREES=false + fi +fi +``` + Capture current HEAD before spawning (used for worktree branch check): ```bash EXPECTED_BASE=$(git rev-parse HEAD) diff --git a/src/capability-state.cts b/src/capability-state.cts index 42fad2b9c..d148d6db7 100644 --- a/src/capability-state.cts +++ b/src/capability-state.cts @@ -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 } = installProfilesMod; +const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires, parseCallsAgents } = installProfilesMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import surfaceMod = require('./surface.cjs'); @@ -384,22 +384,84 @@ function _loadInstalledSkillsManifest(configDir: string): Map return manifest; } +/** + * #1858 — Build a skill dependency manifest from a FLAT commands/gsd-.md + * source layout (the Claude local project install shape, where the `gsd-` + * prefix is baked into each filename at the commands/ level and there is no + * commands/gsd/ subdir). Strips the `gsd-` prefix so stems match the nested + * loader's output (gsd-validate-phase.md → validate-phase, same as nested + * validate-phase.md). + * + * Map shape is identical to loadSkillsManifest: each stem maps to its + * `requires` deps (parsed via the same shared parseRequires) and carries a + * companion `_calls_agents_` key (parsed via parseCallsAgents) so the + * flat and nested paths cannot drift. + * + * Returns an empty Map when the parent directory does not exist or contains + * 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 { + const manifest = new Map(); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(commandsParentDir, { withFileTypes: true }); + } catch { + return manifest; + } + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.startsWith('gsd-')) continue; + if (!entry.name.endsWith('.md')) continue; + // Strip 'gsd-' prefix (4 chars) and '.md' suffix (3 chars) → stem. + const stem = entry.name.slice(4, -3); + if (!stem) continue; + // Mirror loadSkillsManifest's try/catch structure exactly: wrap read + + // parse + set together so an unreadable file OR a thrown parser degrades + // both keys to [] (parity; closes the latent catch-scope drift a reviewer + // flagged — both parsers are non-throwing today, but the structural + // match future-proofs the "identical Map shape" contract). + try { + const content = fs.readFileSync(path.join(commandsParentDir, entry.name), 'utf8'); + manifest.set(stem, parseRequires(content)); + manifest.set(`_calls_agents_${stem}`, parseCallsAgents(content)); + } catch { + manifest.set(stem, []); + manifest.set(`_calls_agents_${stem}`, []); + } + } + return manifest; +} + /** * Resolve the skill dependency manifest for capability-state resolution. * - * Resolution order (fixes #1160 — installed-runtime capability surface): - * 1. If commandsGsdDir exists, load from source (repo-checkout behavior). - * 2. Otherwise, fall back to installed skills at configDir/skills/gsd-[stem]/SKILL.md. + * Resolution order: + * 1. If commandsGsdDir exists, load from the nested source layout + * (repo-checkout behavior: /commands/gsd/*.md). + * 2. #1858 — otherwise, if the flat source layout is present (gsd-.md + * files in dirname(commandsGsdDir)), load from there. This is the Claude + * local project install shape where commands/gsd/ does not exist but + * commands/gsd-.md files do. + * 3. #1160 — otherwise, fall back to installed skills at + * configDir/skills/gsd-[stem]/SKILL.md. * - * In an installed runtime the commands/gsd source tree is absent; only the - * skills/ layout exists. Returning an empty manifest caused resolveSurface to - * materialise the full-sentinel to an empty Set, making every capability appear - * unsurfaced even when the skill was physically installed. + * In an installed runtime both source trees are absent; only the skills/ + * layout exists. Returning an empty manifest caused resolveSurface to + * materialise the full-sentinel to an empty Set, making every skill-bearing + * capability appear unsurfaced even when the skill was physically installed + * (#1160) or authored as a flat command file (#1858). */ function _resolveManifest(commandsGsdDir: string, configDir: string): Map { if (fs.existsSync(commandsGsdDir)) { return loadSkillsManifest(commandsGsdDir); } + // #1858: flat source layout — gsd-.md files at dirname(commandsGsdDir). + // Only claim the flat branch when it actually has gsd-*.md files; otherwise + // fall through to the installed-skills branch (a commands/ dir with no gsd + // files must not shadow an installed skills/ tree). + const flat = _loadFlatCommandsGsdManifest(path.dirname(commandsGsdDir)); + if (flat.size > 0) return flat; return _loadInstalledSkillsManifest(configDir); } @@ -653,6 +715,7 @@ export = { // Exported for tests _resolveCommandsGsdDir, _loadInstalledSkillsManifest, + _loadFlatCommandsGsdManifest, _resolveManifest, _isSafePropKey, }; diff --git a/src/install-profiles.cts b/src/install-profiles.cts index 90674c367..474327ae5 100644 --- a/src/install-profiles.cts +++ b/src/install-profiles.cts @@ -875,6 +875,7 @@ export = { writeActiveProfile, // Shared internals parseRequires, + parseCallsAgents, cleanupStagedSkills, // Back-compat / deprecated MINIMAL_SKILL_ALLOWLIST, diff --git a/tests/capability-state.test.cjs b/tests/capability-state.test.cjs index bd49f73ba..4ab47fecc 100644 --- a/tests/capability-state.test.cjs +++ b/tests/capability-state.test.cjs @@ -23,6 +23,7 @@ const { isCapabilityActive, _isSafePropKey, _loadInstalledSkillsManifest, + _loadFlatCommandsGsdManifest, _resolveManifest, } = require('../gsd-core/bin/lib/capability-state.cjs'); @@ -824,6 +825,223 @@ describe('cmdCapabilityState — end-to-end via gsd-tools CLI', () => { // FAIL before the fix and PASS after, regardless of whether commands/gsd // happens to exist in the current checkout. +describe('regressions: flat commands/gsd-.md layout (#1858)', () => { + // Flat command layout (Claude local project install shape): skills live at + // /commands/gsd-.md — the gsd- prefix is baked into the filename + // and there is NO commands/gsd/ subdir. _resolveManifest must detect this + // layout, strip the gsd- prefix, and produce the same stems the nested + // loader (commands/gsd/.md) would, or every skill-bearing capability + // is silently reported surfaced:false / enabled:false / active:false. + function makeFlatCommandMd(stem, requires) { + const req = requires ? `requires: [${requires.join(', ')}]` : 'requires: [phase]'; + return [ + '---', + `name: gsd:${stem}`, + `description: ${stem} skill`, + 'argument-hint: "[phase number]"', + 'allowed-tools:', + ' - Read', + req, + '---', + 'Execute end-to-end.', + ].join('\n') + '\n'; + } + + // ── Unit tests for _loadFlatCommandsGsdManifest ───────────────────────────── + + test('_loadFlatCommandsGsdManifest: returns empty map when parent dir absent', () => { + const missing = path.join(os.tmpdir(), 'cap-flat-missing-' + Date.now()); + const manifest = _loadFlatCommandsGsdManifest(missing); + assert.ok(manifest instanceof Map, 'should return a Map'); + assert.strictEqual(manifest.size, 0, 'should be empty when parent dir absent'); + }); + + test('_loadFlatCommandsGsdManifest: scans gsd-.md and strips the gsd- prefix', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-flat-scan-')); + try { + fs.writeFileSync(path.join(tmpDir, 'gsd-validate-phase.md'), makeFlatCommandMd('validate-phase'), 'utf8'); + fs.writeFileSync(path.join(tmpDir, 'gsd-secure-phase.md'), makeFlatCommandMd('secure-phase'), 'utf8'); + // Non-gsd file must be ignored + fs.writeFileSync(path.join(tmpDir, 'random-doc.md'), '# not a skill\n', 'utf8'); + // Non-markdown gsd file must be ignored + fs.writeFileSync(path.join(tmpDir, 'gsd-notskill.txt'), 'nope\n', 'utf8'); + + const manifest = _loadFlatCommandsGsdManifest(tmpDir); + assert.ok(manifest.has('validate-phase'), 'flat gsd-validate-phase.md -> stem validate-phase'); + assert.ok(manifest.has('secure-phase'), 'flat gsd-secure-phase.md -> stem secure-phase'); + assert.ok(!manifest.has('gsd-validate-phase'), 'must NOT keep the gsd- prefix on the stem'); + assert.ok(!manifest.has('random-doc'), 'non-gsd file must be ignored'); + assert.ok(!manifest.has('notskill'), 'non-.md gsd file must be ignored'); + } finally { + cleanup(tmpDir); + } + }); + + test('_loadFlatCommandsGsdManifest: parses requires via shared parseRequires (no drift)', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-flat-req-')); + try { + fs.writeFileSync( + path.join(tmpDir, 'gsd-my-skill.md'), + makeFlatCommandMd('my-skill', ['dep-a', 'dep-b']), + 'utf8', + ); + const manifest = _loadFlatCommandsGsdManifest(tmpDir); + assert.deepStrictEqual(manifest.get('my-skill'), ['dep-a', 'dep-b']); + } finally { + cleanup(tmpDir); + } + }); + + test('_loadFlatCommandsGsdManifest: companion _calls_agents_ key present (parity with nested loader)', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-flat-agents-')); + try { + fs.writeFileSync(path.join(tmpDir, 'gsd-validate-phase.md'), makeFlatCommandMd('validate-phase'), 'utf8'); + const manifest = _loadFlatCommandsGsdManifest(tmpDir); + assert.ok(manifest.has('_calls_agents_validate-phase'), + 'flat loader must emit the companion _calls_agents_ key (same Map shape as loadSkillsManifest)'); + } finally { + cleanup(tmpDir); + } + }); + + // Low-1 (review): boundary — empty stem (gsd-.md) skipped, single-char stem kept. + test('_loadFlatCommandsGsdManifest: skips gsd-.md (empty stem) and keeps single-char stem (slice boundary)', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-flat-edge-')); + try { + fs.writeFileSync(path.join(tmpDir, 'gsd-.md'), makeFlatCommandMd(''), 'utf8'); + fs.writeFileSync(path.join(tmpDir, 'gsd-x.md'), makeFlatCommandMd('x'), 'utf8'); + const manifest = _loadFlatCommandsGsdManifest(tmpDir); + assert.ok(!manifest.has(''), 'gsd-.md must NOT register an empty-string stem'); + assert.ok(!manifest.has('_calls_agents_'), 'no companion key for an empty stem'); + assert.ok(manifest.has('x'), 'gsd-x.md -> single-char stem "x" (slice(4,-3) boundary)'); + } finally { + cleanup(tmpDir); + } + }); + + // Low-2 (review): unreadable file degrades both keys to [] (parity with nested + // loader's catch). POSIX-only AND must not run as root — root bypasses POSIX + // read permission bits, so chmod 0o000 would NOT make the file unreadable and + // the test would assert [] against the real parsed deps (false failure). + // Skip on win32 (DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT) and when getuid()==0. + const _skipUnreadable = process.platform === 'win32' || (typeof process.getuid === 'function' && process.getuid() === 0); + test('_loadFlatCommandsGsdManifest: unreadable file degrades to empty deps + agents (parity)', { skip: _skipUnreadable }, () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-flat-unread-')); + let madeUnreadable = false; + try { + const skillPath = path.join(tmpDir, 'gsd-secure.md'); + fs.writeFileSync(skillPath, '---\nname: gsd:secure\nrequires: [phase]\n---\nbody\n', { mode: 0o644 }); + fs.chmodSync(skillPath, 0o000); + madeUnreadable = true; + const manifest = _loadFlatCommandsGsdManifest(tmpDir); + assert.deepStrictEqual(manifest.get('secure'), [], 'unreadable file -> empty requires (parity with nested catch)'); + assert.deepStrictEqual(manifest.get('_calls_agents_secure'), [], 'unreadable file -> empty agents (parity with nested catch)'); + } finally { + // Restore writability so cleanup() can rm the tmp tree. + if (madeUnreadable) { + try { fs.chmodSync(path.join(tmpDir, 'gsd-secure.md'), 0o644); } catch { /* best effort */ } + } + cleanup(tmpDir); + } + }); + + // ── _resolveManifest picks the flat branch when nested is absent ──────────── + + test('_resolveManifest: detects flat commands/gsd-.md layout when nested commands/gsd/ is absent (#1858)', () => { + const tmpRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-flat-repo-')); + try { + // Flat source layout: /commands/gsd-.md + // NO commands/gsd/ subdir, NO skills/ dir. + const commandsDir = path.join(tmpRepo, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, 'gsd-validate-phase.md'), makeFlatCommandMd('validate-phase'), 'utf8'); + fs.writeFileSync(path.join(commandsDir, 'gsd-secure-phase.md'), makeFlatCommandMd('secure-phase'), 'utf8'); + + // commandsGsdDir = /commands/gsd (nested — does NOT exist). + // dirname(commandsGsdDir) = /commands (where the flat files live). + const commandsGsdDir = path.join(commandsDir, 'gsd'); + // configDir = a separate empty tmp dir (no skills/ → installed fallback empty). + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-flat-cfg-')); + try { + const manifest = _resolveManifest(commandsGsdDir, configDir); + assert.ok(manifest.has('validate-phase'), + 'flat layout must populate validate-phase stem (was empty pre-fix → all skill caps unsurfaced)'); + assert.ok(manifest.has('secure-phase'), 'flat layout must populate secure-phase stem'); + assert.ok(!manifest.has('gsd-validate-phase'), 'stem must have gsd- prefix stripped'); + } finally { + cleanup(configDir); + } + } finally { + cleanup(tmpRepo); + } + }); + + test('_resolveManifest: flat branch does NOT shadow a populated installed skills dir when no flat files exist', () => { + // Precedence: nested > flat-source > installed. If the flat parent dir has + // NO gsd-*.md files, the flat loader returns an empty Map and _resolveManifest + // must fall through to the installed-skills branch (not return empty). + const tmpRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-precedence-')); + try { + const commandsDir = path.join(tmpRepo, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + // No gsd-*.md files in commands/ — flat loader yields empty. + // Installed skills present under configDir: + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-precedence-cfg-')); + try { + const secureDir = path.join(configDir, 'skills', 'gsd-secure-phase'); + fs.mkdirSync(secureDir, { recursive: true }); + fs.writeFileSync(path.join(secureDir, 'SKILL.md'), + '---\nname: gsd:secure-phase\nrequires: [phase]\n---\nbody\n', 'utf8'); + + const commandsGsdDir = path.join(commandsDir, 'gsd'); // nested absent + const manifest = _resolveManifest(commandsGsdDir, configDir); + assert.ok(manifest.has('secure-phase'), + 'when flat dir is empty, installed-skills fallback must still work (precedence flat > installed only when flat non-empty)'); + } finally { + cleanup(configDir); + } + } finally { + cleanup(tmpRepo); + } + }); + + // ── Parity: flat loader produces the same stems as the nested loader ──────── + // (DEFECT.GENERATIVE-FIX — guards against silent divergence between the two + // parallel manifest-builders.) + + test('flat loader and nested loader produce identical stems for the same command set (parity)', () => { + const realCommandsGsdDir = path.resolve(__dirname, '..', 'commands', 'gsd'); + if (!fs.existsSync(realCommandsGsdDir)) return; // skip outside a repo checkout + // Build a flat mirror of the real nested commands/gsd/.md as + // commands/gsd-.md in a temp dir, then compare stem sets. + const tmpFlat = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-parity-flat-')); + try { + const nested = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest(realCommandsGsdDir); + for (const [stem] of nested) { + if (stem.startsWith('_calls_agents_')) continue; + const src = path.join(realCommandsGsdDir, stem + '.md'); + if (!fs.existsSync(src)) continue; + fs.writeFileSync(path.join(tmpFlat, 'gsd-' + stem + '.md'), fs.readFileSync(src, 'utf8'), 'utf8'); + } + const flat = _loadFlatCommandsGsdManifest(tmpFlat); + const nestedStems = [...nested.keys()].filter((k) => !k.startsWith('_calls_agents_')).sort(); + const flatStems = [...flat.keys()].filter((k) => !k.startsWith('_calls_agents_')).sort(); + assert.deepStrictEqual(flatStems, nestedStems, + 'flat loader stem set must match nested loader stem set for the real command tree'); + // Nit-2 (review): also compare _calls_agents_ VALUES, not just the + // stem set — proves the shared parseCallsAgents output is identical. + for (const stem of flatStems) { + assert.deepStrictEqual(flat.get(`_calls_agents_${stem}`), nested.get(`_calls_agents_${stem}`), + `agent refs for stem "${stem}" must match between flat and nested loaders`); + assert.deepStrictEqual(flat.get(stem), nested.get(stem), + `requires for stem "${stem}" must match between flat and nested loaders`); + } + } finally { + cleanup(tmpFlat); + } + }); +}); + describe('regressions: installed-runtime capability surface (#1160)', () => { // Minimal valid SKILL.md content (frontmatter only — matches what install emits) function makeSkillMd(stem) { diff --git a/tests/fix-1941-quick-worktree-stale-base.test.cjs b/tests/fix-1941-quick-worktree-stale-base.test.cjs new file mode 100644 index 000000000..0b59589dd --- /dev/null +++ b/tests/fix-1941-quick-worktree-stale-base.test.cjs @@ -0,0 +1,76 @@ +// allow-test-rule: source-text-is-the-product #1941 +// Workflow .md files are the installed AI instructions — their text IS what the runtime +// loads. Testing text content tests the deployed contract. Per CONTRIBUTING.md exception matrix. + +/** + * Regression tests for bug #1941: /gsd-quick worktree executor forks from a stale base — + * up to many commits behind, not just the one-commit gap #1265 already covers. + * + * Root cause: Claude Code's isolation="worktree" forks new worktrees from origin/HEAD, not + * the live local HEAD. When prior local commits (e.g. earlier quick tasks in the same + * session, or this task's own Step 5.6 pre-dispatch plan commit) advance local HEAD without + * an intervening `git push`, origin/HEAD stays pinned to a stale ancestor and the executor's + * worktree_branch_check guard halts with a base-mismatch fatal. The fix ports the + * worktree.base-check auto-degrade pattern already used by execute-phase (#683/#1369) into + * quick.md's single-dispatch path. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md'); + +describe('quick: pre-dispatch worktree base re-check (#1941)', () => { + test('workflow file exists', () => { + assert.ok(fs.existsSync(WORKFLOW_PATH), 'workflows/quick.md should exist'); + }); + + test('Step 6 runs worktree.base-check before capturing EXPECTED_BASE', () => { + const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + const step6Idx = content.indexOf('**Step 6: Spawn executor**'); + const baseCheckIdx = content.indexOf('worktree.base-check', step6Idx); + const expectedBaseIdx = content.indexOf('EXPECTED_BASE=$(git rev-parse HEAD)', step6Idx); + assert.ok(step6Idx !== -1, '"Step 6: Spawn executor" must exist in quick.md'); + assert.ok(baseCheckIdx !== -1, 'worktree.base-check must be invoked within Step 6'); + assert.ok(expectedBaseIdx !== -1, 'EXPECTED_BASE capture must exist within Step 6'); + assert.ok( + baseCheckIdx < expectedBaseIdx, + 'worktree.base-check must run BEFORE EXPECTED_BASE is captured so the degrade decision reflects the most current local HEAD' + ); + }); + + test('degrade check references #1941 for traceability', () => { + const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + assert.ok(content.includes('#1941'), 'quick.md must reference #1941'); + }); + + test('degrade check sets USE_WORKTREES=false when shouldDegrade is true', () => { + const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + const baseCheckIdx = content.indexOf('worktree.base-check'); + const block = content.slice(baseCheckIdx, baseCheckIdx + 600); + assert.ok( + block.includes('shouldDegrade') && block.includes('USE_WORKTREES=false'), + 'degrade check must override USE_WORKTREES=false when shouldDegrade is true' + ); + }); + + test('degrade check guards on RUNTIME=claude (worktree isolation is Claude Code-specific)', () => { + const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + const baseCheckIdx = content.indexOf('worktree.base-check'); + const block = content.slice(Math.max(0, baseCheckIdx - 200), baseCheckIdx + 200); + assert.ok( + block.includes('RUNTIME') && (block.includes('"claude"') || block.includes("'claude'")), + 'degrade check must guard on RUNTIME=claude' + ); + }); + + test('degrade check names origin/HEAD as the stale fork base', () => { + const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + const step6Idx = content.indexOf('**Step 6: Spawn executor**'); + const nextSection = content.indexOf('\n---', step6Idx); + const section = content.slice(step6Idx, nextSection === -1 ? undefined : nextSection); + assert.ok(section.includes('origin/HEAD'), 'Step 6 must name origin/HEAD as the stale fork base'); + }); +}); diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 828b5aa55..ff545c7c6 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -274,7 +274,7 @@ "gsd-core/workflows/pr-branch.md": "dc5598ae8accdecd", "gsd-core/workflows/profile-user.md": "355af92ac285567f", "gsd-core/workflows/progress.md": "79a11ce798082054", - "gsd-core/workflows/quick.md": "a6aa362881f4e5da", + "gsd-core/workflows/quick.md": "35582887917ef938", "gsd-core/workflows/reapply-patches.md": "4dcd6117d0a507ca", "gsd-core/workflows/remove-phase.md": "23b9eb0858a2535e", "gsd-core/workflows/remove-workspace.md": "d0bd7e0601138798", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index b11068a30..fb173c13c 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -344,7 +344,7 @@ "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "14263db831230142", "gsd-core/workflows/progress.md": "893aa3c36983f74b", - "gsd-core/workflows/quick.md": "3bafc0a59cc90d26", + "gsd-core/workflows/quick.md": "5790ceb09aa685be", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 61fab429f..60f0eb61f 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -273,7 +273,7 @@ "gsd-core/workflows/pr-branch.md": "ab157cd8e49621dd", "gsd-core/workflows/profile-user.md": "ff3820a27731ceb8", "gsd-core/workflows/progress.md": "bd1ecf9207331bda", - "gsd-core/workflows/quick.md": "bd56a6313d5ed43c", + "gsd-core/workflows/quick.md": "da83b1a15b7f1bf7", "gsd-core/workflows/reapply-patches.md": "ba9406b60f2c4041", "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "f3ab3a88a7e9e1ed", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index 57b75c397..4bc06f184 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -277,7 +277,7 @@ "gsd-core/workflows/pr-branch.md": "9923878a4f6a2d91", "gsd-core/workflows/profile-user.md": "26f74db0a7fcd268", "gsd-core/workflows/progress.md": "9f326d63afb4b76b", - "gsd-core/workflows/quick.md": "10051a9b41d5d210", + "gsd-core/workflows/quick.md": "68a9dfcd53f0859f", "gsd-core/workflows/reapply-patches.md": "eb4272145a117904", "gsd-core/workflows/remove-phase.md": "e336350f8113a328", "gsd-core/workflows/remove-workspace.md": "e685dfbd736dfd90", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index ff6963ad3..578f0fb21 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -344,7 +344,7 @@ "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "4fa910d15dea5695", "gsd-core/workflows/progress.md": "893aa3c36983f74b", - "gsd-core/workflows/quick.md": "5e7dcdd864513b87", + "gsd-core/workflows/quick.md": "9033dbe58443af36", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 7e334a502..994283b9b 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -309,7 +309,7 @@ "gsd-core/workflows/pr-branch.md": "d13e1cc81de40896", "gsd-core/workflows/profile-user.md": "05828c8cc61ef384", "gsd-core/workflows/progress.md": "7bedc431bb55edb8", - "gsd-core/workflows/quick.md": "85fb48e52f0b3725", + "gsd-core/workflows/quick.md": "3e7686705da2af19", "gsd-core/workflows/reapply-patches.md": "26297b84736e66a4", "gsd-core/workflows/remove-phase.md": "9ee0fddd11a0d9d4", "gsd-core/workflows/remove-workspace.md": "19d7465aaa50cb62", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index 608acbaad..dc09143bc 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -275,7 +275,7 @@ "gsd-core/workflows/pr-branch.md": "2833905f119b5722", "gsd-core/workflows/profile-user.md": "5cc032206c99ef71", "gsd-core/workflows/progress.md": "6b9a84a43dc55af5", - "gsd-core/workflows/quick.md": "89c34e07f1bf6f53", + "gsd-core/workflows/quick.md": "7ddfe17f048541ec", "gsd-core/workflows/reapply-patches.md": "8fd59e24b486f180", "gsd-core/workflows/remove-phase.md": "e262654e319d1bc4", "gsd-core/workflows/remove-workspace.md": "ceddfeef5f2d6754", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index 01081c29a..b71bb0b86 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -344,7 +344,7 @@ "gsd-core/workflows/pr-branch.md": "c67d90c65da47168", "gsd-core/workflows/profile-user.md": "8c943983241260b5", "gsd-core/workflows/progress.md": "65aabee5e8a6dd82", - "gsd-core/workflows/quick.md": "db98fdbe79d68ffc", + "gsd-core/workflows/quick.md": "762256cf6d177c06", "gsd-core/workflows/reapply-patches.md": "ba9406b60f2c4041", "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "433affcd1a200826", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 6101532d4..f7f49191e 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -274,7 +274,7 @@ "gsd-core/workflows/pr-branch.md": "ecabd55e4eabf229", "gsd-core/workflows/profile-user.md": "de5030437226cf2c", "gsd-core/workflows/progress.md": "f18db000584d9cb1", - "gsd-core/workflows/quick.md": "66b26d6fe2bd4d71", + "gsd-core/workflows/quick.md": "5044ce4e7512e174", "gsd-core/workflows/reapply-patches.md": "158083a310859594", "gsd-core/workflows/remove-phase.md": "fce799aae3ab2715", "gsd-core/workflows/remove-workspace.md": "8facde381657dd71", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index 5d890290e..f6cef1739 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -344,7 +344,7 @@ "gsd-core/workflows/pr-branch.md": "ab157cd8e49621dd", "gsd-core/workflows/profile-user.md": "203ebe3f8f3876a8", "gsd-core/workflows/progress.md": "9381c59676ccb937", - "gsd-core/workflows/quick.md": "11ba83f6350462fb", + "gsd-core/workflows/quick.md": "fd7461e5a92fe450", "gsd-core/workflows/reapply-patches.md": "becf9728cdb124c4", "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "fc83f362a2d0a1b7", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 38a6bf88a..61ef75522 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -310,7 +310,7 @@ "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "5abfae83739fa978", "gsd-core/workflows/progress.md": "893aa3c36983f74b", - "gsd-core/workflows/quick.md": "d722803b72217377", + "gsd-core/workflows/quick.md": "e265e01cfe117215", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 2cd9274fd..7da30507c 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -344,7 +344,7 @@ "gsd-core/workflows/pr-branch.md": "929b7cb0c99c7b9e", "gsd-core/workflows/profile-user.md": "248d59a31948e0ed", "gsd-core/workflows/progress.md": "8fc3404087f50b95", - "gsd-core/workflows/quick.md": "3866a5259e8912d8", + "gsd-core/workflows/quick.md": "10cc8ddd4bc3f0ee", "gsd-core/workflows/reapply-patches.md": "a0e9b53f90abceb2", "gsd-core/workflows/remove-phase.md": "dea4661e8f89596f", "gsd-core/workflows/remove-workspace.md": "446847e71aa52504", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 7d69d0606..a75c2e6db 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -274,7 +274,7 @@ "gsd-core/workflows/pr-branch.md": "cef0f65b16d500b4", "gsd-core/workflows/profile-user.md": "263c0693563d98da", "gsd-core/workflows/progress.md": "3b1b2142a74af85c", - "gsd-core/workflows/quick.md": "518740e30dcd4da3", + "gsd-core/workflows/quick.md": "0a9a7dacc73f8e53", "gsd-core/workflows/reapply-patches.md": "de0ee8acfe7245b2", "gsd-core/workflows/remove-phase.md": "e8ae4fbbfac700f0", "gsd-core/workflows/remove-workspace.md": "4ac64de862dc650e", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index fffc076bb..b1f651209 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -274,7 +274,7 @@ "gsd-core/workflows/pr-branch.md": "79fd55b88ea2db9c", "gsd-core/workflows/profile-user.md": "672821e6b1266645", "gsd-core/workflows/progress.md": "94768f835b0b8908", - "gsd-core/workflows/quick.md": "8ac5f3919f754499", + "gsd-core/workflows/quick.md": "9bbf907e39688638", "gsd-core/workflows/reapply-patches.md": "21b38c374f19fd78", "gsd-core/workflows/remove-phase.md": "a46c2fe853bf4e86", "gsd-core/workflows/remove-workspace.md": "ae0e1c6d4438d663", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 93396735f..08164b70d 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -274,7 +274,7 @@ "gsd-core/workflows/pr-branch.md": "acd59f915d018ad4", "gsd-core/workflows/profile-user.md": "c4313672b81b5bcd", "gsd-core/workflows/progress.md": "18813a345bd2343a", - "gsd-core/workflows/quick.md": "0fcad72e7dbe99c9", + "gsd-core/workflows/quick.md": "3acd391cc0c3813a", "gsd-core/workflows/reapply-patches.md": "d449a23d3acf6379", "gsd-core/workflows/remove-phase.md": "e7a6af429b36e77b", "gsd-core/workflows/remove-workspace.md": "b5e60fbb33b3e33a", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index d506e7cd6..c46db6812 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -344,7 +344,7 @@ "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "e23bea0a69c0bb4b", "gsd-core/workflows/progress.md": "893aa3c36983f74b", - "gsd-core/workflows/quick.md": "1bc59c4d97fed876", + "gsd-core/workflows/quick.md": "5747c2d8a37affbb", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index c5f07afdb..29fcbfed7 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -58,7 +58,7 @@ "pr-branch.md": 15963, "profile-user.md": 21246, "progress.md": 30599, - "quick.md": 49183, + "quick.md": 50452, "reapply-patches.md": 20312, "remove-phase.md": 8513, "remove-workspace.md": 7551,