diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index 07da8c4e..e331bd44 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -27,6 +27,7 @@ const VALID_CONFIG_KEYS = new Set([ 'workflow.code_review_depth', 'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template', 'planning.commit_docs', 'planning.search_gitignored', + 'workflow.cross_ai_execution', 'workflow.cross_ai_command', 'workflow.cross_ai_timeout', 'workflow.subagent_timeout', 'hooks.context_warnings', 'features.thinking_partner', diff --git a/get-shit-done/templates/config.json b/get-shit-done/templates/config.json index a3f69929..18e7ea42 100644 --- a/get-shit-done/templates/config.json +++ b/get-shit-done/templates/config.json @@ -11,7 +11,10 @@ "security_asvs_level": 1, "security_block_on": "high", "discuss_mode": "discuss", - "research_before_questions": false + "research_before_questions": false, + "cross_ai_execution": false, + "cross_ai_command": "", + "cross_ai_timeout": 300 }, "planning": { "commit_docs": true, diff --git a/get-shit-done/workflows/execute-phase.md b/get-shit-done/workflows/execute-phase.md index 70cf3945..8f49afb7 100644 --- a/get-shit-done/workflows/execute-phase.md +++ b/get-shit-done/workflows/execute-phase.md @@ -57,6 +57,8 @@ Parse `$ARGUMENTS` before loading any context: - First positional token → `PHASE_ARG` - Optional `--wave N` → `WAVE_FILTER` - Optional `--gaps-only` keeps its current meaning +- Optional `--cross-ai` → `CROSS_AI_FORCE=true` (force all plans through cross-AI execution) +- Optional `--no-cross-ai` → `CROSS_AI_DISABLED=true` (disable cross-AI for this run, overrides config and frontmatter) If `--wave` is absent, preserve the current behavior of executing all incomplete waves in the phase. @@ -243,6 +245,77 @@ Report: ``` + +**Optional step 2.5 — Delegate plans to an external AI runtime.** + +This step runs after plan discovery and before normal wave execution. It identifies plans +that should be delegated to an external AI command and executes them via stdin-based prompt +delivery. Plans handled here are removed from the execute_waves plan list so the normal +executor skips them. + +**Activation logic:** + +1. If `CROSS_AI_DISABLED` is true (`--no-cross-ai` flag): skip this step entirely. +2. If `CROSS_AI_FORCE` is true (`--cross-ai` flag): mark ALL incomplete plans for cross-AI execution. +3. Otherwise: check each plan's frontmatter for `cross_ai: true` AND verify config + `workflow.cross_ai_execution` is `true`. Plans matching both conditions are marked for cross-AI. + +```bash +CROSS_AI_ENABLED=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get workflow.cross_ai_execution --default false 2>/dev/null) +CROSS_AI_CMD=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get workflow.cross_ai_command --default "" 2>/dev/null) +CROSS_AI_TIMEOUT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get workflow.cross_ai_timeout --default 300 2>/dev/null) +``` + +**If no plans are marked for cross-AI:** Skip to execute_waves. + +**If plans are marked but `cross_ai_command` is empty:** Error — tell user to set +`workflow.cross_ai_command` via `gsd-tools.cjs config-set workflow.cross_ai_command ""`. + +**For each cross-AI plan (sequentially):** + +1. **Construct the task prompt** from the plan file: + - Extract `` and `` sections from the PLAN.md + - Append PROJECT.md context (project name, description, tech stack) + - Format as a self-contained execution prompt + +2. **Check for dirty working tree before execution:** + ```bash + if ! git diff --quiet HEAD 2>/dev/null; then + echo "WARNING: dirty working tree detected — the external AI command may produce uncommitted changes that conflict with existing modifications" + fi + ``` + +3. **Run the external command** from the project root, writing the prompt to stdin. + Never shell-interpolate the prompt — always pipe via stdin to prevent injection: + ```bash + echo "$TASK_PROMPT" | timeout "${CROSS_AI_TIMEOUT}s" ${CROSS_AI_CMD} > "$CANDIDATE_SUMMARY" 2>"$ERROR_LOG" + EXIT_CODE=$? + ``` + +4. **Evaluate the result:** + + **Success (exit 0 + valid summary):** + - Read `$CANDIDATE_SUMMARY` and validate it contains meaningful content + (not empty, has at least a heading and description — a valid SUMMARY.md structure) + - Write it as the plan's SUMMARY.md file + - Update STATE.md plan status to complete + - Update ROADMAP.md progress + - Mark plan as handled — skip it in execute_waves + + **Failure (non-zero exit or invalid summary):** + - Display the error output and exit code + - Warn: "The external command may have left uncommitted changes or partial edits + in the working tree. Review `git status` and `git diff` before proceeding." + - Offer three choices: + - **retry** — run the same plan through cross-AI again + - **skip** — fall back to normal executor for this plan (re-add to execute_waves list) + - **abort** — stop execution entirely, preserve state for resume + +5. **After all cross-AI plans processed:** Remove successfully handled plans from the + incomplete plan list so execute_waves skips them. Any skipped-to-fallback plans remain + in the list for normal executor processing. + + Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZATION=true`, sequential if `false`. diff --git a/tests/cross-ai-execution.test.cjs b/tests/cross-ai-execution.test.cjs new file mode 100644 index 00000000..2870ed10 --- /dev/null +++ b/tests/cross-ai-execution.test.cjs @@ -0,0 +1,173 @@ +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const CONFIG_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'config.cjs'); +const EXECUTE_PHASE_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'execute-phase.md'); +const CONFIG_TEMPLATE_PATH = path.join(__dirname, '..', 'get-shit-done', 'templates', 'config.json'); + +describe('cross-AI execution', () => { + + describe('config keys', () => { + test('workflow.cross_ai_execution is in VALID_CONFIG_KEYS', () => { + const { VALID_CONFIG_KEYS } = require(CONFIG_PATH); + assert.ok(VALID_CONFIG_KEYS.has('workflow.cross_ai_execution'), + 'VALID_CONFIG_KEYS must include workflow.cross_ai_execution'); + }); + + test('workflow.cross_ai_command is in VALID_CONFIG_KEYS', () => { + const { VALID_CONFIG_KEYS } = require(CONFIG_PATH); + assert.ok(VALID_CONFIG_KEYS.has('workflow.cross_ai_command'), + 'VALID_CONFIG_KEYS must include workflow.cross_ai_command'); + }); + + test('workflow.cross_ai_timeout is in VALID_CONFIG_KEYS', () => { + const { VALID_CONFIG_KEYS } = require(CONFIG_PATH); + assert.ok(VALID_CONFIG_KEYS.has('workflow.cross_ai_timeout'), + 'VALID_CONFIG_KEYS must include workflow.cross_ai_timeout'); + }); + }); + + describe('config template defaults', () => { + test('config template has cross_ai_execution default', () => { + const template = JSON.parse(fs.readFileSync(CONFIG_TEMPLATE_PATH, 'utf-8')); + assert.strictEqual(template.workflow.cross_ai_execution, false, + 'cross_ai_execution should default to false'); + }); + + test('config template has cross_ai_command default', () => { + const template = JSON.parse(fs.readFileSync(CONFIG_TEMPLATE_PATH, 'utf-8')); + assert.strictEqual(template.workflow.cross_ai_command, '', + 'cross_ai_command should default to empty string'); + }); + + test('config template has cross_ai_timeout default', () => { + const template = JSON.parse(fs.readFileSync(CONFIG_TEMPLATE_PATH, 'utf-8')); + assert.strictEqual(template.workflow.cross_ai_timeout, 300, + 'cross_ai_timeout should default to 300 seconds'); + }); + }); + + describe('execute-phase.md cross-AI step', () => { + let content; + + test('execute-phase.md has a cross-AI execution step', () => { + content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8'); + assert.ok(content.includes(''), + 'execute-phase.md must have a step named cross_ai_delegation'); + }); + + test('cross-AI step appears between discover_and_group_plans and execute_waves', () => { + content = content || fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8'); + const discoverIdx = content.indexOf(''); + const crossAiIdx = content.indexOf(''); + const executeIdx = content.indexOf(''); + assert.ok(discoverIdx < crossAiIdx, 'cross_ai_delegation must come after discover_and_group_plans'); + assert.ok(crossAiIdx < executeIdx, 'cross_ai_delegation must come before execute_waves'); + }); + + test('cross-AI step handles --cross-ai flag', () => { + content = content || fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8'); + assert.ok(content.includes('--cross-ai'), + 'execute-phase.md must reference --cross-ai flag'); + }); + + test('cross-AI step handles --no-cross-ai flag', () => { + content = content || fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8'); + assert.ok(content.includes('--no-cross-ai'), + 'execute-phase.md must reference --no-cross-ai flag'); + }); + + test('cross-AI step uses stdin-based prompt delivery', () => { + content = content || fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8'); + // The step must describe piping prompt via stdin, not shell interpolation + assert.ok(content.includes('stdin'), + 'cross-AI step must describe stdin-based prompt delivery'); + }); + + test('cross-AI step validates summary output', () => { + content = content || fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8'); + // The step must describe validating the captured summary + const crossAiSection = content.substring( + content.indexOf(''), + content.indexOf('', content.indexOf('')) + ''.length + ); + assert.ok( + crossAiSection.includes('SUMMARY') && crossAiSection.includes('valid'), + 'cross-AI step must validate the summary output' + ); + }); + + test('cross-AI step warns about dirty working tree', () => { + content = content || fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8'); + const crossAiSection = content.substring( + content.indexOf(''), + content.indexOf('', content.indexOf('')) + ''.length + ); + assert.ok( + crossAiSection.includes('dirty') || crossAiSection.includes('uncommitted') || crossAiSection.includes('working tree'), + 'cross-AI step must warn about dirty/uncommitted changes from external command' + ); + }); + + test('cross-AI step reads cross_ai_command from config', () => { + content = content || fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8'); + const crossAiSection = content.substring( + content.indexOf(''), + content.indexOf('', content.indexOf('')) + ''.length + ); + assert.ok( + crossAiSection.includes('cross_ai_command'), + 'cross-AI step must read cross_ai_command from config' + ); + }); + + test('cross-AI step reads cross_ai_timeout from config', () => { + content = content || fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8'); + const crossAiSection = content.substring( + content.indexOf(''), + content.indexOf('', content.indexOf('')) + ''.length + ); + assert.ok( + crossAiSection.includes('cross_ai_timeout'), + 'cross-AI step must read cross_ai_timeout from config' + ); + }); + + test('cross-AI step handles failure with retry/skip/abort', () => { + content = content || fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8'); + const crossAiSection = content.substring( + content.indexOf(''), + content.indexOf('', content.indexOf('')) + ''.length + ); + assert.ok(crossAiSection.includes('retry'), 'cross-AI step must offer retry on failure'); + assert.ok(crossAiSection.includes('skip'), 'cross-AI step must offer skip on failure'); + assert.ok(crossAiSection.includes('abort'), 'cross-AI step must offer abort on failure'); + }); + + test('cross-AI step skips normal executor for handled plans', () => { + content = content || fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8'); + const crossAiSection = content.substring( + content.indexOf(''), + content.indexOf('', content.indexOf('')) + ''.length + ); + assert.ok( + crossAiSection.includes('skip') && (crossAiSection.includes('executor') || crossAiSection.includes('execute_waves')), + 'cross-AI step must describe skipping normal executor for cross-AI handled plans' + ); + }); + + test('parse_args step includes --cross-ai and --no-cross-ai', () => { + content = content || fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8'); + const parseArgsSection = content.substring( + content.indexOf('', content.indexOf(''.length + ); + assert.ok(parseArgsSection.includes('--cross-ai'), + 'parse_args step must parse --cross-ai flag'); + assert.ok(parseArgsSection.includes('--no-cross-ai'), + 'parse_args step must parse --no-cross-ai flag'); + }); + }); +});