diff --git a/package.json b/package.json index 814485af..7cdbd018 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "0.15.8.0", + "version": "0.15.9.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/pair-agent/SKILL.md b/pair-agent/SKILL.md index c2b67a85..1530e531 100644 --- a/pair-agent/SKILL.md +++ b/pair-agent/SKILL.md @@ -7,7 +7,7 @@ description: | Hermes, Codex, Cursor, or any agent that can make HTTP requests. The remote agent gets its own tab with scoped access (read+write by default, admin on request). Use when asked to "pair agent", "connect agent", "share browser", "remote browser", - "let another agent use my browser", or "give browser access". + "let another agent use my browser", or "give browser access". (gstack) Voice triggers (speech-to-text aliases): "pair agent", "connect agent", "share my browser", "remote browser access". allowed-tools: - Bash diff --git a/pair-agent/SKILL.md.tmpl b/pair-agent/SKILL.md.tmpl index b7a92aa2..93c1c595 100644 --- a/pair-agent/SKILL.md.tmpl +++ b/pair-agent/SKILL.md.tmpl @@ -7,7 +7,7 @@ description: | Hermes, Codex, Cursor, or any agent that can make HTTP requests. The remote agent gets its own tab with scoped access (read+write by default, admin on request). Use when asked to "pair agent", "connect agent", "share browser", "remote browser", - "let another agent use my browser", or "give browser access". + "let another agent use my browser", or "give browser access". (gstack) voice-triggers: - "pair agent" - "connect agent" diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 4886ea03..25afbb03 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -443,6 +443,31 @@ artifacts that inform the plan, not code changes: These are read-only in spirit — they inspect the live site, generate visual artifacts, or get independent opinions. They do NOT modify project source files. +## Skill Invocation During Plan Mode + +If a user invokes a skill during plan mode, that invoked skill workflow takes +precedence over generic plan mode behavior until it finishes or the user explicitly +cancels that skill. + +Treat the loaded skill as executable instructions, not reference material. Follow +it step by step. Do not summarize, skip, reorder, or shortcut its steps. + +If the skill says to use AskUserQuestion, do that. Those AskUserQuestion calls +satisfy plan mode's requirement to end turns with AskUserQuestion. + +If the skill reaches a STOP point, stop immediately at that point, ask the required +question if any, and wait for the user's response. Do not continue the workflow +past a STOP point, and do not call ExitPlanMode at that point. + +If the skill includes commands marked "PLAN MODE EXCEPTION — ALWAYS RUN," execute +them. The skill may edit the plan file, and other writes are allowed only if they +are already permitted by Plan Mode Safe Operations or explicitly marked as a plan +mode exception. + +Only call ExitPlanMode after the active skill workflow is complete and there are no +other invoked skill workflows left to run, or if the user explicitly tells you to +cancel the skill or leave plan mode. + ## Plan Status Footer When you are in plan mode and about to call ExitPlanMode: diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 6331b650..e9d56ae5 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -437,6 +437,31 @@ artifacts that inform the plan, not code changes: These are read-only in spirit — they inspect the live site, generate visual artifacts, or get independent opinions. They do NOT modify project source files. +## Skill Invocation During Plan Mode + +If a user invokes a skill during plan mode, that invoked skill workflow takes +precedence over generic plan mode behavior until it finishes or the user explicitly +cancels that skill. + +Treat the loaded skill as executable instructions, not reference material. Follow +it step by step. Do not summarize, skip, reorder, or shortcut its steps. + +If the skill says to use AskUserQuestion, do that. Those AskUserQuestion calls +satisfy plan mode's requirement to end turns with AskUserQuestion. + +If the skill reaches a STOP point, stop immediately at that point, ask the required +question if any, and wait for the user's response. Do not continue the workflow +past a STOP point, and do not call ExitPlanMode at that point. + +If the skill includes commands marked "PLAN MODE EXCEPTION — ALWAYS RUN," execute +them. The skill may edit the plan file, and other writes are allowed only if they +are already permitted by Plan Mode Safe Operations or explicitly marked as a plan +mode exception. + +Only call ExitPlanMode after the active skill workflow is complete and there are no +other invoked skill workflows left to run, or if the user explicitly tells you to +cancel the skill or leave plan mode. + ## Plan Status Footer When you are in plan mode and about to call ExitPlanMode: diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 04dcfd5c..85f6f587 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -439,6 +439,31 @@ artifacts that inform the plan, not code changes: These are read-only in spirit — they inspect the live site, generate visual artifacts, or get independent opinions. They do NOT modify project source files. +## Skill Invocation During Plan Mode + +If a user invokes a skill during plan mode, that invoked skill workflow takes +precedence over generic plan mode behavior until it finishes or the user explicitly +cancels that skill. + +Treat the loaded skill as executable instructions, not reference material. Follow +it step by step. Do not summarize, skip, reorder, or shortcut its steps. + +If the skill says to use AskUserQuestion, do that. Those AskUserQuestion calls +satisfy plan mode's requirement to end turns with AskUserQuestion. + +If the skill reaches a STOP point, stop immediately at that point, ask the required +question if any, and wait for the user's response. Do not continue the workflow +past a STOP point, and do not call ExitPlanMode at that point. + +If the skill includes commands marked "PLAN MODE EXCEPTION — ALWAYS RUN," execute +them. The skill may edit the plan file, and other writes are allowed only if they +are already permitted by Plan Mode Safe Operations or explicitly marked as a plan +mode exception. + +Only call ExitPlanMode after the active skill workflow is complete and there are no +other invoked skill workflows left to run, or if the user explicitly tells you to +cancel the skill or leave plan mode. + ## Plan Status Footer When you are in plan mode and about to call ExitPlanMode: diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 93c2dfc9..de799b5b 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1739,7 +1739,10 @@ describe('Codex generation (--host codex)', () => { test('Claude output unchanged: all Claude skills have zero Codex paths', () => { for (const skill of ALL_SKILLS) { const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8'); - expect(content).not.toContain('~/.codex/'); + // pair-agent legitimately documents how Codex agents store credentials + if (skill.dir !== 'pair-agent') { + expect(content).not.toContain('~/.codex/'); + } // gstack-upgrade legitimately references .agents/skills for cross-platform detection if (skill.dir !== 'gstack-upgrade') { expect(content).not.toContain('.agents/skills'); diff --git a/test/helpers/skill-parser.ts b/test/helpers/skill-parser.ts index 0da19f63..0e3271ba 100644 --- a/test/helpers/skill-parser.ts +++ b/test/helpers/skill-parser.ts @@ -15,6 +15,11 @@ import { parseSnapshotArgs } from '../../browse/src/snapshot'; import * as fs from 'fs'; import * as path from 'path'; +/** CLI-only commands: valid $B invocations that are handled by the CLI, not the server */ +const CLI_COMMANDS = new Set([ + 'status', 'pair-agent', 'tunnel', +]); + export interface BrowseCommand { command: string; args: string[]; @@ -112,7 +117,7 @@ export function validateSkill(skillPath: string): ValidationResult { } for (const cmd of commands) { - if (!ALL_COMMANDS.has(cmd.command)) { + if (!ALL_COMMANDS.has(cmd.command) && !CLI_COMMANDS.has(cmd.command)) { result.invalid.push(cmd); continue; } diff --git a/test/relink.test.ts b/test/relink.test.ts index 70c069df..d0c48f19 100644 --- a/test/relink.test.ts +++ b/test/relink.test.ts @@ -69,8 +69,11 @@ describe('gstack-relink (#578)', () => { // Test 11: prefixed symlinks when skill_prefix=true test('creates gstack-* symlinks when skill_prefix=true', () => { setupMockInstall(['qa', 'ship', 'review']); - // Set config to prefix mode - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`); + // Set config to prefix mode (pass install/skills env so auto-relink uses mock install) + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); // Run relink with env pointing to the mock install const output = run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, @@ -86,7 +89,10 @@ describe('gstack-relink (#578)', () => { // Test 12: flat symlinks when skill_prefix=false test('creates flat symlinks when skill_prefix=false', () => { setupMockInstall(['qa', 'ship', 'review']); - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); const output = run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -103,7 +109,10 @@ describe('gstack-relink (#578)', () => { // The fix: create real directories with SKILL.md symlinks inside. test('unprefixed skills are real directories with SKILL.md symlinks, not dir symlinks', () => { setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review']); - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -127,7 +136,10 @@ describe('gstack-relink (#578)', () => { // Same invariant for prefixed mode test('prefixed skills are real directories with SKILL.md symlinks, not dir symlinks', () => { setupMockInstall(['qa', 'ship']); - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -150,7 +162,10 @@ describe('gstack-relink (#578)', () => { // Verify they start as symlinks expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(true); - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -166,7 +181,10 @@ describe('gstack-relink (#578)', () => { test('first install --no-prefix: only flat names exist, zero gstack-* entries', () => { setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']); // Simulate first install: no saved config, pass --no-prefix equivalent - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -183,7 +201,10 @@ describe('gstack-relink (#578)', () => { // FIRST INSTALL: --prefix must create ONLY gstack-* names, zero flat-name pollution test('first install --prefix: only gstack-* entries exist, zero flat names', () => { setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']); - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -216,7 +237,10 @@ describe('gstack-relink (#578)', () => { test('switching prefix to no-prefix removes all gstack-* entries completely', () => { setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']); // Start in prefix mode - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -225,7 +249,10 @@ describe('gstack-relink (#578)', () => { expect(entries.filter(e => !e.startsWith('gstack-'))).toEqual([]); // Switch to no-prefix - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -241,7 +268,10 @@ describe('gstack-relink (#578)', () => { test('switching no-prefix to prefix removes all flat entries completely', () => { setupMockInstall(['qa', 'ship', 'review', 'gstack-upgrade']); // Start in no-prefix mode - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -250,7 +280,10 @@ describe('gstack-relink (#578)', () => { expect(entries.filter(e => e.startsWith('gstack-') && e !== 'gstack-upgrade')).toEqual([]); // Switch to prefix - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -268,7 +301,10 @@ describe('gstack-relink (#578)', () => { test('cleans up stale symlinks from opposite mode', () => { setupMockInstall(['qa', 'ship']); // Create prefixed symlinks first - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -276,7 +312,10 @@ describe('gstack-relink (#578)', () => { expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true); // Switch to flat mode - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -299,7 +338,10 @@ describe('gstack-relink (#578)', () => { // Test: gstack-upgrade does NOT get double-prefixed test('does not double-prefix gstack-upgrade directory', () => { setupMockInstall(['qa', 'ship', 'gstack-upgrade']); - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -364,8 +406,10 @@ describe('upgrade migrations', () => { fs.symlinkSync(path.join(installDir, 'qa'), path.join(skillsDir, 'qa')); fs.symlinkSync(path.join(installDir, 'ship'), path.join(skillsDir, 'ship')); fs.symlinkSync(path.join(installDir, 'review'), path.join(skillsDir, 'review')); - // Set no-prefix mode - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`); + // Set no-prefix mode (suppress auto-relink so symlinks stay intact for the test) + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, { + GSTACK_SETUP_RUNNING: '1', + }); // Verify old state: symlinks expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(true); @@ -395,7 +439,10 @@ describe('gstack-patch-names (#620/#578)', () => { test('prefix=true patches name: field in SKILL.md', () => { setupMockInstall(['qa', 'ship', 'review']); - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -409,14 +456,20 @@ describe('gstack-patch-names (#620/#578)', () => { test('prefix=false restores name: field in SKILL.md', () => { setupMockInstall(['qa', 'ship']); // First, prefix them - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, }); expect(readSkillName(path.join(installDir, 'qa'))).toBe('gstack-qa'); // Now switch to flat mode - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -428,7 +481,10 @@ describe('gstack-patch-names (#620/#578)', () => { test('gstack-upgrade name: not double-prefixed', () => { setupMockInstall(['qa', 'gstack-upgrade']); - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, @@ -443,7 +499,10 @@ describe('gstack-patch-names (#620/#578)', () => { setupMockInstall(['qa']); // Overwrite qa SKILL.md with no frontmatter fs.writeFileSync(path.join(installDir, 'qa', 'SKILL.md'), '# qa\nSome content.'); - run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); // Should not crash run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { GSTACK_INSTALL_DIR: installDir,