diff --git a/tools/subagent-prompt-prefix.test.mjs b/tools/subagent-prompt-prefix.test.mjs new file mode 100644 index 00000000..e3f2469a --- /dev/null +++ b/tools/subagent-prompt-prefix.test.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * Tests for tools/subagent-prompt-prefix.mjs — PreToolUse Task git-safety header inject. + * + * Per Pravila §15.1 — hook injects cwd/branch/HEAD/worktree-root into каждый Task-prompt. + * FAIL-OPEN: any error → return {continue: true} без модификации. + * + * Spec: docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md §4 + */ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const HOOK_PATH = join(__dirname, 'subagent-prompt-prefix.mjs'); + +function runHook(stdinJson) { + return spawnSync('node', [HOOK_PATH], { + input: JSON.stringify(stdinJson), + encoding: 'utf8', + timeout: 5000, + }); +} + +test('hook injects SUBAGENT GIT-SAFETY HEADER into Task prompt', () => { + const result = runHook({ + tool_name: 'Task', + tool_input: { prompt: 'do something' }, + }); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + const out = JSON.parse(result.stdout); + assert.ok(out.hookSpecificOutput, 'has hookSpecificOutput'); + assert.equal(out.hookSpecificOutput.hookEventName, 'PreToolUse'); + assert.equal(out.hookSpecificOutput.permissionDecision, 'allow'); + const newPrompt = out.hookSpecificOutput.updatedInput.prompt; + assert.match(newPrompt, /=== SUBAGENT GIT-SAFETY HEADER/); + assert.match(newPrompt, /=== END SUBAGENT GIT-SAFETY HEADER ===/); + assert.match(newPrompt, /do something/, 'preserves original prompt'); +}); + +test('hook injects real cwd, branch, HEAD values', () => { + const result = runHook({ + tool_name: 'Task', + tool_input: { prompt: 'noop' }, + }); + assert.equal(result.status, 0); + const out = JSON.parse(result.stdout); + const newPrompt = out.hookSpecificOutput.updatedInput.prompt; + // cwd — absolute path + assert.match(newPrompt, /Working directory \(cwd\): [A-Za-z]:[\\/]|Working directory \(cwd\): \//); + // branch — non-empty + assert.match(newPrompt, /Branch \(git branch --show-current\): \S+/); + // HEAD — 40-char SHA + assert.match(newPrompt, /Parent commit \(git rev-parse HEAD\): [0-9a-f]{40}/); +}); + +test('hook passes through non-Task tools without modification', () => { + const result = runHook({ + tool_name: 'Edit', + tool_input: { file_path: '/foo', old_string: 'a', new_string: 'b' }, + }); + assert.equal(result.status, 0); + const out = JSON.parse(result.stdout); + // Non-Task → either {continue: true} OR no updatedInput + if (out.hookSpecificOutput) { + assert.equal(out.hookSpecificOutput.updatedInput, undefined); + } else { + assert.equal(out.continue, true); + } +}); + +test('hook fail-open on malformed stdin', () => { + const result = spawnSync('node', [HOOK_PATH], { + input: 'not-json', + encoding: 'utf8', + timeout: 5000, + }); + assert.equal(result.status, 0, `should not crash; stderr: ${result.stderr}`); + // Either {continue: true} or empty output — both acceptable fail-open +}); + +test('hook fail-open when git not available', () => { + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify({ tool_name: 'Task', tool_input: { prompt: 'x' } }), + encoding: 'utf8', + timeout: 5000, + env: { ...process.env, PATH: '' }, // strip PATH → git not found + }); + assert.equal(result.status, 0, `should not crash when git missing; stderr: ${result.stderr}`); +});