c14fb72e84
Closes Stream H Task 6 (H4). Retires the manual approval-write workaround
the controller used throughout Stream H Tasks 1-5.
Two changes:
1. Pure module tools/askuser-answer-parser.mjs gains toApprovalRecord(answer, opts)
exporter that detects a git verb in the user's free-form answer and returns
a Stream B-compatible {type:'approve_git_operation', command, ts} record
(matches loadApprovedGitOps reader format in shell-content-rules.mjs:125).
Returns null for non-git answers and for stop/abort/cancel keywords.
2. New PostToolUse(AskUserQuestion) wrapper tools/enforce-askuser-answer-parser.mjs
reads each question/answer pair, calls toApprovalRecord, appends matching
records to ~/.claude/runtime/askuser-decisions-<sess>.jsonl. Fail-open
observability — never blocks AskUserQuestion.
Regression: vitest tools 1742/1742 GREEN (was 1731; +5 toApprovalRecord tests
under "toApprovalRecord (Stream H Task 6 — schema sync)" including non-string
guard, +6 wrapper-hook tests under "enforce-askuser-answer-parser wrapper
(Stream H Task 6)" including missing session_id fail-open guard).
DEFERRED: settings.json registration (matcher "AskUserQuestion", PostToolUse,
fail-open, timeout 2000ms) — batched with H5/H6/H7/H8 hook activations at end
of Phase H-α/H-β. Hook code is fully implemented and unit-tested; activation
pending settings.json update.
Stream H Task 6 of 11. Plan: docs/superpowers/plans/2026-05-30-router-gate-v4-stream-H.md
81 lines
3.3 KiB
JavaScript
81 lines
3.3 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { processEvent } from './enforce-askuser-answer-parser.mjs';
|
|
|
|
function tmpRuntimeDir() {
|
|
return mkdtempSync(join(tmpdir(), 'askuser-decisions-test-'));
|
|
}
|
|
|
|
describe('enforce-askuser-answer-parser wrapper (Stream H Task 6)', () => {
|
|
it('appends approve_git_operation record for git-pattern answer', () => {
|
|
const dir = tmpRuntimeDir();
|
|
const event = {
|
|
session_id: 'sess-abc',
|
|
tool_input: { questions: [{ question: 'разрешить?' }] },
|
|
tool_response: { answers: { 'разрешить?': 'подтверди git push origin main' } },
|
|
};
|
|
processEvent(event, { runtimeDir: dir, nowMs: 1700000000000 });
|
|
const path = join(dir, 'askuser-decisions-sess-abc.jsonl');
|
|
expect(existsSync(path)).toBe(true);
|
|
const lines = readFileSync(path, 'utf-8').split(/\r?\n/).filter(Boolean);
|
|
expect(lines.length).toBe(1);
|
|
const rec = JSON.parse(lines[0]);
|
|
expect(rec).toMatchObject({ type: 'approve_git_operation', command: 'git push origin main', ts: 1700000000000 });
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('appends nothing for non-git answer', () => {
|
|
const dir = tmpRuntimeDir();
|
|
const event = {
|
|
session_id: 'sess-def',
|
|
tool_input: { questions: [{ question: 'continue?' }] },
|
|
tool_response: { answers: { 'continue?': 'yes' } },
|
|
};
|
|
processEvent(event, { runtimeDir: dir });
|
|
const path = join(dir, 'askuser-decisions-sess-def.jsonl');
|
|
expect(existsSync(path)).toBe(false);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('appends multiple records across multiple answers', () => {
|
|
const dir = tmpRuntimeDir();
|
|
const event = {
|
|
session_id: 'sess-multi',
|
|
tool_input: { questions: [{ question: 'A?' }, { question: 'B?' }] },
|
|
tool_response: { answers: { 'A?': 'git push origin main', 'B?': 'git add tools/x.mjs' } },
|
|
};
|
|
processEvent(event, { runtimeDir: dir, nowMs: 1700000000000 });
|
|
const path = join(dir, 'askuser-decisions-sess-multi.jsonl');
|
|
const lines = readFileSync(path, 'utf-8').split(/\r?\n/).filter(Boolean);
|
|
expect(lines.length).toBe(2);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('fail-open: missing tool_response does not throw', () => {
|
|
const dir = tmpRuntimeDir();
|
|
expect(() => processEvent({ session_id: 's' }, { runtimeDir: dir })).not.toThrow();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('fail-open: missing answer key does not throw', () => {
|
|
const dir = tmpRuntimeDir();
|
|
expect(() => processEvent({
|
|
session_id: 's',
|
|
tool_input: { questions: [{ question: 'X?' }] },
|
|
tool_response: { answers: {} },
|
|
}, { runtimeDir: dir })).not.toThrow();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('fail-open: missing session_id does not throw and does not write', () => {
|
|
const dir = tmpRuntimeDir();
|
|
expect(() => processEvent({
|
|
tool_input: { questions: [{ question: 'X?' }] },
|
|
tool_response: { answers: { 'X?': 'git push origin main' } },
|
|
}, { runtimeDir: dir })).not.toThrow();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
});
|