Merge pull request #1338 from gsd-build/fix/worktree-edit-permissions
fix: add permissionMode to worktree agents
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
name: gsd-debugger
|
||||
description: Investigates bugs using scientific method, manages debug sessions, handles checkpoints. Spawned by /gsd:debug orchestrator.
|
||||
tools: Read, Write, Edit, Bash, Grep, Glob, WebSearch
|
||||
permissionMode: acceptEdits
|
||||
color: orange
|
||||
# hooks:
|
||||
# PostToolUse:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name: gsd-executor
|
||||
description: Executes GSD plans with atomic commits, deviation handling, checkpoint protocols, and state management. Spawned by execute-phase orchestrator or execute-plan command.
|
||||
tools: Read, Write, Edit, Bash, Grep, Glob
|
||||
permissionMode: acceptEdits
|
||||
color: yellow
|
||||
# hooks:
|
||||
# PostToolUse:
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
*
|
||||
* UAT Audit:
|
||||
* audit-uat Scan all phases for unresolved UAT/verification items
|
||||
* uat render-checkpoint --file <path> Render the current UAT checkpoint block
|
||||
*
|
||||
* Scaffolding:
|
||||
* scaffold context --phase <N> Create CONTEXT.md template
|
||||
@@ -660,6 +661,18 @@ async function runCommand(command, args, cwd, raw) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'uat': {
|
||||
const subcommand = args[1];
|
||||
const uat = require('./lib/uat.cjs');
|
||||
if (subcommand === 'render-checkpoint') {
|
||||
const options = parseNamedArgs(args, ['file']);
|
||||
uat.cmdRenderCheckpoint(cwd, options, raw);
|
||||
} else {
|
||||
error('Unknown uat subcommand. Available: render-checkpoint');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stats': {
|
||||
const subcommand = args[1] || 'json';
|
||||
commands.cmdStats(cwd, subcommand, raw);
|
||||
|
||||
@@ -223,6 +223,32 @@ function sanitizeForPrompt(text) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize text that will be displayed back to the user.
|
||||
* Removes protocol-like leak markers that should never surface in checkpoints.
|
||||
*
|
||||
* @param {string} text - Text to sanitize
|
||||
* @returns {string} Sanitized text
|
||||
*/
|
||||
function sanitizeForDisplay(text) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
|
||||
let sanitized = sanitizeForPrompt(text);
|
||||
|
||||
const protocolLeakPatterns = [
|
||||
/^\s*(?:assistant|user|system)\s+to=[^:\s]+:[^\n]+$/i,
|
||||
/^\s*(?:assistant|user|system)\s+to=all:[^\n]+$/i,
|
||||
/^\s*<\|(?:assistant|user|system)[^|]*\|>\s*$/i,
|
||||
];
|
||||
|
||||
sanitized = sanitized
|
||||
.split('\n')
|
||||
.filter(line => !protocolLeakPatterns.some(pattern => pattern.test(line)))
|
||||
.join('\n');
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// ─── Shell Safety ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -343,6 +369,7 @@ module.exports = {
|
||||
INJECTION_PATTERNS,
|
||||
scanForInjection,
|
||||
sanitizeForPrompt,
|
||||
sanitizeForDisplay,
|
||||
|
||||
// Shell safety
|
||||
validateShellArg,
|
||||
|
||||
@@ -9,6 +9,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { output, error, getMilestonePhaseFilter, planningDir, toPosixPath } = require('./core.cjs');
|
||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||
const { requireSafePath, sanitizeForDisplay } = require('./security.cjs');
|
||||
|
||||
function cmdAuditUat(cwd, raw) {
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
@@ -90,6 +91,92 @@ function cmdAuditUat(cwd, raw) {
|
||||
output({ results, summary }, raw);
|
||||
}
|
||||
|
||||
function cmdRenderCheckpoint(cwd, options = {}, raw) {
|
||||
const filePath = options.file;
|
||||
if (!filePath) {
|
||||
error('UAT file required: use uat render-checkpoint --file <path>');
|
||||
}
|
||||
|
||||
const resolvedPath = requireSafePath(filePath, cwd, 'UAT file', { allowAbsolute: true });
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
error(`UAT file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(resolvedPath, 'utf-8');
|
||||
const currentTest = parseCurrentTest(content);
|
||||
|
||||
if (currentTest.complete) {
|
||||
error('UAT session is already complete; no pending checkpoint to render');
|
||||
}
|
||||
|
||||
const checkpoint = buildCheckpoint(currentTest);
|
||||
output({
|
||||
file_path: toPosixPath(path.relative(cwd, resolvedPath)),
|
||||
test_number: currentTest.number,
|
||||
test_name: currentTest.name,
|
||||
checkpoint,
|
||||
}, raw, checkpoint);
|
||||
}
|
||||
|
||||
function parseCurrentTest(content) {
|
||||
const currentTestMatch = content.match(/##\s*Current Test\s*(?:\n<!--[\s\S]*?-->)?\n([\s\S]*?)(?=\n##\s|$)/i);
|
||||
if (!currentTestMatch) {
|
||||
error('UAT file is missing a Current Test section');
|
||||
}
|
||||
|
||||
const section = currentTestMatch[1].trimEnd();
|
||||
if (!section.trim()) {
|
||||
error('Current Test section is empty');
|
||||
}
|
||||
|
||||
if (/\[testing complete\]/i.test(section)) {
|
||||
return { complete: true };
|
||||
}
|
||||
|
||||
const numberMatch = section.match(/^number:\s*(\d+)\s*$/m);
|
||||
const nameMatch = section.match(/^name:\s*(.+)\s*$/m);
|
||||
const expectedBlockMatch = section.match(/^expected:\s*\|\n([\s\S]*?)(?=^\w[\w-]*:\s|\Z)/m);
|
||||
const expectedInlineMatch = section.match(/^expected:\s*(.+)\s*$/m);
|
||||
|
||||
if (!numberMatch || !nameMatch || (!expectedBlockMatch && !expectedInlineMatch)) {
|
||||
error('Current Test section is malformed');
|
||||
}
|
||||
|
||||
let expected;
|
||||
if (expectedBlockMatch) {
|
||||
expected = expectedBlockMatch[1]
|
||||
.split('\n')
|
||||
.map(line => line.replace(/^ {2}/, ''))
|
||||
.join('\n')
|
||||
.trim();
|
||||
} else {
|
||||
expected = expectedInlineMatch[1].trim();
|
||||
}
|
||||
|
||||
return {
|
||||
complete: false,
|
||||
number: parseInt(numberMatch[1], 10),
|
||||
name: sanitizeForDisplay(nameMatch[1].trim()),
|
||||
expected: sanitizeForDisplay(expected),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCheckpoint(currentTest) {
|
||||
return [
|
||||
'╔══════════════════════════════════════════════════════════════╗',
|
||||
'║ CHECKPOINT: Verification Required ║',
|
||||
'╚══════════════════════════════════════════════════════════════╝',
|
||||
'',
|
||||
`**Test ${currentTest.number}: ${currentTest.name}**`,
|
||||
'',
|
||||
currentTest.expected,
|
||||
'',
|
||||
'──────────────────────────────────────────────────────────────',
|
||||
'Type `pass` or describe what\'s wrong.',
|
||||
'──────────────────────────────────────────────────────────────',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function parseUatItems(content) {
|
||||
const items = [];
|
||||
// Match test blocks: ### N. Name\nexpected: ...\nresult: ...\n
|
||||
@@ -186,4 +273,9 @@ function categorizeItem(result, reason, blockedBy) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
module.exports = { cmdAuditUat };
|
||||
module.exports = {
|
||||
cmdAuditUat,
|
||||
cmdRenderCheckpoint,
|
||||
parseCurrentTest,
|
||||
buildCheckpoint,
|
||||
};
|
||||
|
||||
@@ -85,10 +85,7 @@ Continue to spawn_agents.
|
||||
<step name="detect_runtime_capabilities">
|
||||
Before spawning agents, detect whether the current runtime supports the `Task` tool for subagent delegation.
|
||||
|
||||
**Runtimes with Task tool:** Claude Code, Cursor, OpenCode (native subagent support via `Task` or `task`)
|
||||
**Runtimes WITHOUT Task tool:** Antigravity, Gemini CLI, Codex, and others
|
||||
|
||||
**How to detect:** Check if you have access to a `Task` or `task` tool (either casing counts). If you do NOT have a Task/task tool (or only have tools like `browser_subagent` which is for web browsing, NOT code analysis):
|
||||
**How to detect:** Check if you have access to a `Task` tool (may be capitalized as `Task` or lowercase as `task` depending on runtime). If you do NOT have a `Task`/`task` tool (or only have tools like `browser_subagent` which is for web browsing, NOT code analysis):
|
||||
|
||||
→ **Skip `spawn_agents` and `collect_confirmations`** — go directly to `sequential_mapping` instead.
|
||||
|
||||
@@ -218,7 +215,7 @@ If any agent failed, note the failure and continue with successful documents.
|
||||
Continue to verify_output.
|
||||
</step>
|
||||
|
||||
<step name="sequential_mapping" condition="Task/task tool is NOT available (e.g. Antigravity, Gemini CLI, Codex)">
|
||||
<step name="sequential_mapping" condition="Task tool is NOT available (e.g. Antigravity, Gemini CLI, Codex)">
|
||||
When the `Task` tool is unavailable, perform codebase mapping sequentially in the current context. This replaces `spawn_agents` and `collect_confirmations`.
|
||||
|
||||
**IMPORTANT:** Do NOT use `browser_subagent`, `Explore`, or any browser-based tool. Use only file system tools (Read, Bash, Write, Grep, Glob, list_dir, view_file, grep_search, or equivalent tools available in your runtime).
|
||||
|
||||
@@ -28,7 +28,7 @@ INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init verify-work "${
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
```
|
||||
|
||||
Parse JSON for: `planner_model`, `checker_model`, `commit_docs`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `has_verification`.
|
||||
Parse JSON for: `planner_model`, `checker_model`, `commit_docs`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `has_verification`, `uat_path`.
|
||||
</step>
|
||||
|
||||
<step name="check_active_session">
|
||||
@@ -186,24 +186,24 @@ Proceed to `present_test`.
|
||||
<step name="present_test">
|
||||
**Present current test to user:**
|
||||
|
||||
Read Current Test section from UAT file.
|
||||
Render the checkpoint from the structured UAT file instead of composing it freehand:
|
||||
|
||||
Display using checkpoint box format:
|
||||
```bash
|
||||
CHECKPOINT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" uat render-checkpoint --file "$uat_path" --raw)
|
||||
if [[ "$CHECKPOINT" == @file:* ]]; then CHECKPOINT=$(cat "${CHECKPOINT#@file:}"); fi
|
||||
```
|
||||
|
||||
Display the returned checkpoint EXACTLY as-is:
|
||||
|
||||
```
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ CHECKPOINT: Verification Required ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
|
||||
**Test {number}: {name}**
|
||||
|
||||
{expected}
|
||||
|
||||
──────────────────────────────────────────────────────────────
|
||||
→ Type "pass" or describe what's wrong
|
||||
──────────────────────────────────────────────────────────────
|
||||
{CHECKPOINT}
|
||||
```
|
||||
|
||||
**Critical response hygiene:**
|
||||
- Your entire response MUST equal `{CHECKPOINT}` byte-for-byte.
|
||||
- Do NOT add commentary before or after the block.
|
||||
- If you notice protocol/meta markers such as `to=all:`, role-routing text, XML system tags, hidden instruction markers, ad copy, or any unrelated suffix, discard the draft and output `{CHECKPOINT}` only.
|
||||
|
||||
Wait for user response (plain text, no AskUserQuestion).
|
||||
</step>
|
||||
|
||||
|
||||
@@ -339,3 +339,56 @@ describe('DISCUSS: discussion log generation', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Worktree Permission Mode (#1334) ───────────────────────────────────────
|
||||
|
||||
describe('PERM: worktree agents have permissionMode: acceptEdits', () => {
|
||||
// Agents spawned with isolation="worktree" need permissionMode: acceptEdits
|
||||
// to avoid per-directory edit permission prompts in the worktree path.
|
||||
// See: anthropics/claude-code#29110, anthropics/claude-code#28041
|
||||
const WORKTREE_AGENTS = ['gsd-executor', 'gsd-debugger'];
|
||||
|
||||
for (const agent of WORKTREE_AGENTS) {
|
||||
test(`${agent} has permissionMode: acceptEdits`, () => {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, agent + '.md'), 'utf-8');
|
||||
const frontmatter = content.split('---')[1] || '';
|
||||
assert.ok(
|
||||
frontmatter.includes('permissionMode: acceptEdits'),
|
||||
`${agent} must have permissionMode: acceptEdits — worktree agents need this to avoid ` +
|
||||
`per-directory edit permission prompts (see #1334)`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('worktree-spawned agents are covered', () => {
|
||||
// Verify that agents referenced with isolation="worktree" in workflows
|
||||
// are included in the WORKTREE_AGENTS list above
|
||||
const dirs = [WORKFLOWS_DIR, COMMANDS_DIR];
|
||||
const worktreeAgentTypes = new Set();
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(path.join(dir, file), 'utf-8');
|
||||
// Find patterns like: subagent_type="gsd-executor" ... isolation="worktree"
|
||||
// These can span multiple lines in Task() calls
|
||||
const taskBlocks = content.match(/Task\([^)]*isolation="worktree"[^)]*\)/gs) || [];
|
||||
for (const block of taskBlocks) {
|
||||
const typeMatch = block.match(/subagent_type="([^"]+)"/);
|
||||
if (typeMatch) {
|
||||
worktreeAgentTypes.add(typeMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentType of worktreeAgentTypes) {
|
||||
assert.ok(
|
||||
WORKTREE_AGENTS.includes(agentType),
|
||||
`${agentType} is spawned with isolation="worktree" but not in WORKTREE_AGENTS list — ` +
|
||||
`add permissionMode: acceptEdits to its frontmatter and update this test`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,6 +136,12 @@ describe('dispatcher error paths', () => {
|
||||
assert.ok(result.error.includes('Unknown todo subcommand'), `Expected "Unknown todo subcommand" in stderr, got: ${result.error}`);
|
||||
});
|
||||
|
||||
test('uat unknown subcommand errors', () => {
|
||||
const result = runGsdTools('uat bogus', tmpDir);
|
||||
assert.strictEqual(result.success, false, 'Should exit non-zero');
|
||||
assert.ok(result.error.includes('Unknown uat subcommand'), `Expected "Unknown uat subcommand" in stderr, got: ${result.error}`);
|
||||
});
|
||||
|
||||
// Unknown subcommand: init
|
||||
test('init unknown workflow errors', () => {
|
||||
const result = runGsdTools('init bogus', tmpDir);
|
||||
|
||||
@@ -951,16 +951,15 @@ describe('cmdInitMapCodebase', () => {
|
||||
assert.strictEqual(output.codebase_dir_exists, true);
|
||||
});
|
||||
|
||||
test('map-codebase workflow lists OpenCode as having Task tool support (#1316)', () => {
|
||||
test('map-codebase workflow does not list OpenCode under runtimes without Task tool (#1316)', () => {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'get-shit-done', 'workflows', 'map-codebase.md'), 'utf8'
|
||||
);
|
||||
// OpenCode must appear in the "with Task tool" line, not the "WITHOUT" line
|
||||
const withLine = workflow.split('\n').find(l => l.includes('Runtimes with Task tool'));
|
||||
const withoutLine = workflow.split('\n').find(l => l.includes('WITHOUT Task tool'));
|
||||
assert.ok(withLine, 'workflow should have a "Runtimes with Task tool" line');
|
||||
assert.ok(withoutLine, 'workflow should have a "WITHOUT Task tool" line');
|
||||
assert.ok(withLine.includes('OpenCode'), 'OpenCode must be listed under runtimes WITH Task tool');
|
||||
// OpenCode must NOT appear in the "WITHOUT Task tool" / "NOT available" condition
|
||||
const withoutLine = workflow.split('\n').find(l =>
|
||||
l.includes('NOT available') || l.includes('WITHOUT Task tool')
|
||||
);
|
||||
assert.ok(withoutLine, 'workflow should have a line about Task tool NOT being available');
|
||||
assert.ok(!withoutLine.includes('OpenCode'), 'OpenCode must NOT be listed under runtimes WITHOUT Task tool');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ const {
|
||||
requireSafePath,
|
||||
scanForInjection,
|
||||
sanitizeForPrompt,
|
||||
sanitizeForDisplay,
|
||||
safeJsonParse,
|
||||
validatePhaseNumber,
|
||||
validateFieldName,
|
||||
@@ -244,6 +245,19 @@ describe('sanitizeForPrompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeForDisplay', () => {
|
||||
test('removes protocol leak lines', () => {
|
||||
const input = 'Visible line\nuser to=all:final code something bad\nAnother line';
|
||||
const result = sanitizeForDisplay(input);
|
||||
assert.equal(result, 'Visible line\nAnother line');
|
||||
});
|
||||
|
||||
test('keeps normal user-facing copy intact', () => {
|
||||
const input = 'Type `pass` or describe what\\\'s wrong.';
|
||||
assert.equal(sanitizeForDisplay(input), input);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Shell Safety ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('validateShellArg', () => {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* GSD Tools Tests - UAT Audit
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
@@ -324,3 +326,83 @@ All checks passed.
|
||||
assert.strictEqual(output.summary.total_files, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uat render-checkpoint', () => {
|
||||
let tmpDir;
|
||||
let uatPath;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test-phase');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
uatPath = path.join(phaseDir, '01-UAT.md');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('renders the current checkpoint as raw output', () => {
|
||||
fs.writeFileSync(uatPath, `---
|
||||
status: testing
|
||||
phase: 01-test-phase
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
number: 2
|
||||
name: Submit form validation
|
||||
expected: |
|
||||
Empty submit keeps controls visible.
|
||||
Validation error copy is shown.
|
||||
awaiting: user response
|
||||
`);
|
||||
|
||||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
|
||||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||||
assert.ok(result.output.includes('**Test 2: Submit form validation**'));
|
||||
assert.ok(result.output.includes('Empty submit keeps controls visible.'));
|
||||
assert.ok(result.output.includes("Type `pass` or describe what's wrong."));
|
||||
});
|
||||
|
||||
test('strips protocol leak lines from current test copy', () => {
|
||||
fs.writeFileSync(uatPath, `---
|
||||
status: testing
|
||||
phase: 01-test-phase
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
number: 6
|
||||
name: Locale copy
|
||||
expected: |
|
||||
English strings render correctly.
|
||||
user to=all:final code 彩票平台招商 pass
|
||||
Chinese strings render correctly.
|
||||
awaiting: user response
|
||||
`);
|
||||
|
||||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
|
||||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||||
assert.ok(!result.output.includes('user to=all:final code'));
|
||||
assert.ok(!result.output.includes('彩票平台'));
|
||||
assert.ok(result.output.includes('English strings render correctly.'));
|
||||
assert.ok(result.output.includes('Chinese strings render correctly.'));
|
||||
});
|
||||
|
||||
test('fails when testing is already complete', () => {
|
||||
fs.writeFileSync(uatPath, `---
|
||||
status: complete
|
||||
phase: 01-test-phase
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
`);
|
||||
|
||||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md'], tmpDir);
|
||||
assert.strictEqual(result.success, false, 'Should fail when no current test exists');
|
||||
assert.ok(result.error.includes('already complete'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user