diff --git a/TODOS.md b/TODOS.md index 58f4234a5..abc4c831c 100644 --- a/TODOS.md +++ b/TODOS.md @@ -101,7 +101,6 @@ The 158 older open issues were re-checked against main 5cb5e1c (v1.91.83.0). Of Each item was decided in the Wave 9 plan and parked with its reason. Credit the reporter or contributor when it is picked up. - **Fold `triggers:` into skill descriptions (#2118)** — `scripts/gen-skill-docs.ts` folds only `voice-triggers:`, and the trimmed description keeps one sentence, so natural-language triggers never reach the listing. Parked because it conflicts with the catalog-trim design and would regenerate every skill. Mechanism when picked up: a `when_to_use:` frontmatter key for the Claude host, built from `triggers:`, deduped, and kept inside the catalog byte budget (Claude Code's 1,536-character combined listing limit); hosts that allowlist name and description get a bounded "Use when…" sentence or nothing. **Effort:** M. **Priority:** P2. -- **Plan-tune qid marker follow-ups (#3442 review notes, filed 2026-11-02)**: `QID_MARKER_RE` in `scripts/qid-marker.ts` drops the whole marker when the `;signal=` tail is malformed (an underscore, a space, an empty key). Match the id first and drop only a bad hint. `question-preference-hook.ts` still looks up the raw marker id, while the log hook lowercases it. Use `parseQidMarker(...).id` in both hooks. Add hinted-marker cases to `test/question-preference-hook.test.ts`. **Effort:** S. **Priority:** P3. - **Stateless, instruction-only host mode (#3061, @m-check1B)** — hosted multi-tenant runtimes have no install, state root or helper binaries, but skill bodies still read and write `$GSTACK_STATE_ROOT`, call `bin/gstack-*` and log telemetry. Deferred to the new-hosts theme below. Requirements kept on record: tenant-owned state (a `capabilities.state: 'none'` host renders host-provided hooks instead of shell steps), named artifact hooks (`ARTIFACT_SAVE`/`ARTIFACT_FIND` per kind, `WEB_RESEARCH`, `SECRET_SCAN`, `USER_PREF`) mapped the way the gbrain resolvers already are, a generator check that fails a stateless render containing `~/.gstack`, `$GSTACK_`, `bin/gstack-*` or `--event-type`, and the `claude` host's output byte-identical. The contributor offered an office-hours overlay plus the generator check as a first PR. **Effort:** L. **Priority:** P3. - **Legacy unprefixed Cursor skill folders (#3084)** — 1.31 Cursor installs created `~/.cursor/skills//SKILL.md` links; 1.91 links `gstack-*` folders and `_prune_stale_generated` in `setup` only looks at `gstack-*` names, so Cursor can load two skills with the same name. `setup` already removes old unprefixed Claude entries; Cursor needs the same owned-link cleanup (remove only links that point into a gstack checkout). Split from #3084's data-loss fix, which Wave 9 Lane A0 ships. **Effort:** S. **Priority:** P3. - **`/retro global` says what a "session" is (#1315, third problem)** — `bin/gstack-global-discover.ts` counts transcript files and rollouts, so cron- or agent-driven repos show hundreds of "sessions" with no interactive work. Annotate the retro output that sessions are tool invocations or files, not distinct human working sessions. Problems 1 and 2 are fixed (v1.91.66.0 and Wave 9 Lane F). **Effort:** S. **Priority:** P3. diff --git a/browse/src/project-slug.ts b/browse/src/project-slug.ts index 2ad34e754..48689a8e4 100644 --- a/browse/src/project-slug.ts +++ b/browse/src/project-slug.ts @@ -4,11 +4,19 @@ * Used by domain-skills (per-project storage) and sidebar prompt-context * injection. Cached after first call — slug is derived from the daemon's * git remote (or env override) and doesn't change between commands. + * + * gstack-slug is read from the checkout this daemon runs from first, then + * from the Claude Code install. Codex, repo-local and other host installs + * have no ~/.claude copy; when no gstack-slug runs, the slug comes from the + * same remote rule (getRemoteSlug) instead of one shared "unknown" bucket + * that would mix every project's domain skills. */ +import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; +import { getRemoteSlug } from './config'; let cachedSlug: string | null = null; @@ -19,14 +27,24 @@ export function getCurrentProjectSlug(): string { cachedSlug = explicit; return explicit; } - try { - const slugBin = path.join(os.homedir(), '.claude/skills/gstack/bin/gstack-slug'); - const out = execSync(slugBin, { encoding: 'utf8', timeout: 2000, windowsHide: true }).trim(); - const m = out.match(/SLUG="?([^"\n]+)"?/); - cachedSlug = m ? m[1]! : (out || 'unknown'); - } catch { - cachedSlug = 'unknown'; + const candidates = [ + path.resolve(import.meta.dir, '../../bin/gstack-slug'), + path.join(os.homedir(), '.claude/skills/gstack/bin/gstack-slug'), + ]; + for (const slugBin of candidates) { + if (!fs.existsSync(slugBin)) continue; + try { + const out = execFileSync(slugBin, [], { encoding: 'utf8', timeout: 2000, windowsHide: true }).trim(); + const m = out.match(/SLUG="?([^"\n]+)"?/); + if (m) { + cachedSlug = m[1]!; + return cachedSlug; + } + } catch { + // next candidate + } } + cachedSlug = getRemoteSlug() || 'unknown'; return cachedSlug; } diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index e0ccb0b4d..eeb28b579 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -461,11 +461,14 @@ export async function handleWriteCommand( case 'scroll': { // Parse --times N and --wait ms flags - const timesIdx = args.indexOf('--times'); - const times = timesIdx >= 0 ? parseInt(args[timesIdx + 1], 10) || 1 : 0; - const waitIdx = args.indexOf('--wait'); - const waitMs = waitIdx >= 0 ? parseInt(args[waitIdx + 1], 10) || 1000 : 1000; - const selector = args.find(a => !a.startsWith('--') && args.indexOf(a) !== timesIdx + 1 && args.indexOf(a) !== waitIdx + 1); + let times = 0; + let waitMs = 1000; + let selector: string | undefined; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--times') times = parseInt(args[++i], 10) || 1; + else if (args[i] === '--wait') waitMs = parseInt(args[++i], 10) || 1000; + else if (!args[i].startsWith('--') && selector === undefined) selector = args[i]; + } if (times > 0) { // Repeated scroll mode diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index 427350c8d..19ac9a730 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -535,7 +535,15 @@ describe('Interaction', () => { test('scroll works', async () => { const result = await handleWriteCommand('scroll', ['footer'], bm); - expect(result).toContain('Scrolled'); + expect(result).toBe('Scrolled footer into view'); + }); + + test('scroll keeps its selector beside --times and --wait', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + expect(await handleWriteCommand('scroll', ['footer', '--times', '2', '--wait', '10'], bm)).toBe('Scrolled 2 times (footer) with 10ms delay'); + expect(await handleWriteCommand('scroll', ['footer', '--times', '2'], bm)).toBe('Scrolled 2 times (footer) with 1000ms delay'); + expect(await handleWriteCommand('scroll', ['--times', '2', 'footer'], bm)).toBe('Scrolled 2 times (footer) with 1000ms delay'); + expect(await handleWriteCommand('scroll', ['--wait', '5', '--times', '2'], bm)).toBe('Scrolled 2 times with 5ms delay'); }); test('viewport changes size', async () => { diff --git a/browse/test/project-slug.test.ts b/browse/test/project-slug.test.ts new file mode 100644 index 000000000..9ca616ed9 --- /dev/null +++ b/browse/test/project-slug.test.ts @@ -0,0 +1,48 @@ +/** + * The daemon's project slug names the per-project domain-skill bucket. An + * install without ~/.claude/skills/gstack (Codex, repo-local, other hosts) + * used to fall back to one shared "unknown" bucket for every project. + */ +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const MODULE = path.resolve(import.meta.dir, '../src/project-slug.ts'); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-project-slug-')); +afterAll(() => fs.rmSync(root, { recursive: true, force: true })); + +function slugIn(cwd: string, extra: Record = {}): string { + const env: Record = { ...(process.env as Record), HOME: path.join(root, 'home'), GSTACK_STATE_ROOT: path.join(root, 'state') }; + delete env.GSTACK_PROJECT_SLUG; + delete env.GSTACK_HOME; + Object.assign(env, extra); + const r = spawnSync(process.execPath, ['-e', `import { getCurrentProjectSlug } from ${JSON.stringify(MODULE)}; console.log(getCurrentProjectSlug());`], { cwd, env, encoding: 'utf8', timeout: 30_000 }); + return (r.stdout ?? '').trim(); +} + +describe('getCurrentProjectSlug', () => { + test('a repository resolves to its remote slug with no ~/.claude install', () => { + const repo = path.join(root, 'repo'); + fs.mkdirSync(repo, { recursive: true }); + spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 30_000 }); + spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/acme/widgets.git'], { cwd: repo, timeout: 30_000 }); + expect(slugIn(repo)).toBe('acme-widgets'); + }); + + test('two projects never share a bucket', () => { + const a = path.join(root, 'alpha'); + const b = path.join(root, 'beta'); + fs.mkdirSync(a, { recursive: true }); + fs.mkdirSync(b, { recursive: true }); + const sa = slugIn(a); + const sb = slugIn(b); + expect(sa).not.toBe('unknown'); + expect(sa).not.toBe(sb); + }); + + test('GSTACK_PROJECT_SLUG wins', () => { + expect(slugIn(root, { GSTACK_PROJECT_SLUG: 'pinned' })).toBe('pinned'); + }); +}); diff --git a/changelog.d/five-small-browse-and-question-hook-fixes.md b/changelog.d/five-small-browse-and-question-hook-fixes.md new file mode 100644 index 000000000..eea4cb7ae --- /dev/null +++ b/changelog.d/five-small-browse-and-question-hook-fixes.md @@ -0,0 +1,5 @@ +- `browse scroll ` brings the element into view again when `--wait` is absent. The flag parser treated the first argument as the `--wait` value and dropped it, so `scroll footer` and `scroll footer --times 2` scrolled to the page bottom and replied "Scrolled to bottom". +- gstack's renderer no longer mistakes Debian and Ubuntu's `/usr/bin/browse`, a link to xdg-open, for gstack's own browser. A `browse` on PATH whose real file has another name is now skipped, so you get the clear "no browser" message instead of a desktop opener launching. +- The browse daemon no longer files every project's domain skills in one shared "unknown" bucket on installs without `~/.claude` (Codex, repo-local and other hosts). It now tries the checkout's own `gstack-slug`, then the Claude install, then the git remote rule, so skills saved in one repository stop showing up in another. Skills saved before this change stay in `~/.gstack/projects/unknown/learnings.jsonl` and no longer appear in a project's list; copy the lines you want into that project's `learnings.jsonl`. +- A question marker with a malformed signal hint, such as ``, keeps its question id in the never-ask hook and the question log. Only the bad hint is dropped, so a never-ask preference still applies and the question log no longer records it as unmarked. +- The never-ask preference hook reads the question id lowercased, as the log hook and stored preferences already do, so a marker written with capitals no longer asks a question you silenced. diff --git a/docs/domain-skills.md b/docs/domain-skills.md index 4076a1e67..0cde33ecb 100644 --- a/docs/domain-skills.md +++ b/docs/domain-skills.md @@ -70,6 +70,11 @@ Skills live in two places: - **Per-project**: `~/.gstack/projects//learnings.jsonl` — same JSONL file the `/learn` skill uses. Domain skills are `type:"domain"` rows. + The daemon takes `` from `$GSTACK_PROJECT_SLUG` when set, else from the + `gstack-slug` in its own checkout, then the Claude Code install's copy, + then the git remote. Rows saved by older builds on installs without + `~/.claude` sit in `~/.gstack/projects/unknown/learnings.jsonl`; copy the + ones you want into the project's file. - **Global**: `~/.gstack/global-domain-skills.jsonl` — only `state:"global"` rows. diff --git a/hosts/claude/hooks/question-preference-hook.ts b/hosts/claude/hooks/question-preference-hook.ts index 0629e0e6d..793d4aade 100644 --- a/hosts/claude/hooks/question-preference-hook.ts +++ b/hosts/claude/hooks/question-preference-hook.ts @@ -51,7 +51,7 @@ import { SPAWNED_ESCAPE_SENTENCE, CONDUCTOR_SPAWNED_DENY_REASON, spawnedByEnv } import { resolveStateRoot } from '../../../lib/state-root'; import { readVersionedSlugCache } from '../../../lib/bin-context'; import { logHookError as sharedLogHookError } from './hook-log'; -import { QID_MARKER_RE as MARKER_RE } from '../../../scripts/qid-marker'; +import { QID_MARKER_RE as MARKER_RE, parseQidMarker } from '../../../scripts/qid-marker'; interface HookStdin { session_id?: string; @@ -403,9 +403,9 @@ async function main(): Promise { const contextNuggets: string[] = []; for (const q of questions) { const qText = q.question || ''; - const marker = qText.match(MARKER_RE); + const marker = parseQidMarker(qText); if (!marker) continue; - const entry = registry[marker[1]]; + const entry = registry[marker.id]; if (!entry?.signal_key) continue; const hits = nuggetsForSignal(memoryNuggets, entry.signal_key); for (const h of hits) { @@ -426,9 +426,9 @@ async function main(): Promise { let fullyAutoDecidable = true; for (const q of questions) { const qText = q.question || ''; - const marker = qText.match(MARKER_RE); + const marker = parseQidMarker(qText); if (!marker) { fullyAutoDecidable = false; break; } - const questionId = marker[1]; + const questionId = marker.id; if ('unresolved' in project) { logHookError(`never-ask not applied to ${questionId}, asking instead: project identity unresolved (${project.unresolved})`); fullyAutoDecidable = false; @@ -446,7 +446,7 @@ async function main(): Promise { // regex pass over the question text; on any failure keep the default // (enforcement still requires an explicit stored preference). try { - if (classifyQuestion({ summary: qText.replace(MARKER_RE, '').trim() }).oneWay) { + if (classifyQuestion({ summary: qText.replace(MARKER_RE, (_m, _id, tail = '') => ` ${tail} `).trim() }).oneWay) { doorType = 'one-way'; } } catch (e) { @@ -515,14 +515,15 @@ async function main(): Promise { for (let i = 0; i < questions.length; i++) { const rawText = questions[i]?.question || ''; const qText = rawText.replace(MARKER_RE, '').trim(); + const doorText = rawText.replace(MARKER_RE, (_m, _id, tail = '') => ` ${tail} `).trim(); const opts = optionLabels(questions[i]?.options || []); - const marker = rawText.match(MARKER_RE); - const entry = marker ? registry[marker[1]] : undefined; + const marker = parseQidMarker(rawText); + const entry = marker ? registry[marker.id] : undefined; let oneWay = entry?.door_type === 'one-way'; if (!entry) { const optText = opts.join(' / '); try { - oneWay = classifyQuestion({ summary: optText ? `${qText} options: ${optText}` : qText }).oneWay; + oneWay = classifyQuestion({ summary: optText ? `${doorText} options: ${optText}` : doorText }).oneWay; } catch (e) { logHookError(`spawned one-way classifier failed: ${(e as Error).message}`); } @@ -537,7 +538,7 @@ async function main(): Promise { // NO trace of a machine-resolved gate. Log every question. const { recommended } = extractRecommended(rawText, opts); logAutoDecided( - marker?.[1] ?? 'unmarked', + marker?.id ?? 'unmarked', qText, recommended ?? 'unrecorded', opts.length, diff --git a/lib/aside-render.ts b/lib/aside-render.ts index d05f7947f..8b9e86ac5 100644 --- a/lib/aside-render.ts +++ b/lib/aside-render.ts @@ -456,7 +456,9 @@ function executable(p: string): string | null { /** * Locate gstack's own browse binary: $GSTACK_BROWSE_BIN → $BROWSE_BIN → * /browse/dist/browse → /browse/bin/find-browse (per root, repo - * then install) → `browse` on PATH. Null when nothing resolves. + * then install) → `browse` on PATH. Null when nothing resolves. A PATH + * `browse` that resolves to a file with another name is skipped: Debian and + * Ubuntu ship /usr/bin/browse as a link to xdg-open, which is not gstack's. */ export function resolveBrowseBin(env: NodeJS.ProcessEnv = process.env, roots: string[] = BROWSE_ROOTS): string | null { const PATH = env.PATH ?? env.Path ?? ''; @@ -474,7 +476,13 @@ export function resolveBrowseBin(env: NodeJS.ProcessEnv = process.env, roots: st const found = r.status === 0 ? executable((r.stdout ?? '').trim()) : null; if (found) return found; } - return Bun.which('browse', { PATH }) ?? null; + const onPath = Bun.which('browse', { PATH }); + if (!onPath) return null; + try { + return path.basename(fs.realpathSync(onPath)).replace(/\.(exe|cmd|bat)$/i, '') === 'browse' ? onPath : null; + } catch { + return null; + } } /** PdfStepOptions (CDP, inches) → the browse `pdf --from-file` payload (Playwright shapes, string lengths). */ diff --git a/scripts/qid-marker.ts b/scripts/qid-marker.ts index bb213d00d..0a97e9f86 100644 --- a/scripts/qid-marker.ts +++ b/scripts/qid-marker.ts @@ -4,10 +4,14 @@ * `` names the question; an unregistered id may add a * signal hint, ``, naming the * SIGNAL_MAP key its answer speaks to. Ids and keys are read lowercase. - * Whether a hint is a real SIGNAL_MAP key is checked where the row is - * logged (bin/gstack-question-log), not here. + * A malformed hint (an underscore, a space, an empty or unknown key name) + * is dropped on its own; the id still names the question. Whether a hint + * is a real SIGNAL_MAP key is checked where the row is logged + * (bin/gstack-question-log), not here. */ -export const QID_MARKER_RE = //i; +export const QID_MARKER_RE = /\n]{0,128}))?>/i; + +const SIGNAL_HINT_RE = /^signal=([a-z0-9-]{1,64})$/i; export interface QidMarker { id: string; @@ -18,9 +22,10 @@ export interface QidMarker { export function parseQidMarker(text: string): QidMarker | undefined { const m = QID_MARKER_RE.exec(text); if (!m) return undefined; + const hint = m[2] === undefined ? undefined : SIGNAL_HINT_RE.exec(m[2])?.[1]; return { id: m[1]!.toLowerCase(), - ...(m[2] ? { signal_key: m[2].toLowerCase() } : {}), + ...(hint ? { signal_key: hint.toLowerCase() } : {}), stripped: text.replace(QID_MARKER_RE, '').trim(), }; } diff --git a/test/aside-render.test.ts b/test/aside-render.test.ts index d9139b356..e63ced73f 100644 --- a/test/aside-render.test.ts +++ b/test/aside-render.test.ts @@ -187,6 +187,21 @@ describe('aside-render: browse fallback — binary resolution', () => { expect(resolveBrowseBin(noPath, [rootC])).toBe(builtB); }); + test.skipIf(process.platform === 'win32')('a PATH browse that links to another program (Debian xdg-open) is not gstack\'s', () => { + const binDir = path.join(home, 'path-bin'); + fs.mkdirSync(binDir, { recursive: true }); + const xdg = fakeBin(binDir, 'xdg-open'); + fs.symlinkSync(xdg, path.join(binDir, 'browse')); + expect(resolveBrowseBin({ PATH: binDir }, [empty])).toBeNull(); + const realDir = path.join(home, 'path-real'); + fakeBin(realDir, 'browse'); + expect(resolveBrowseBin({ PATH: realDir }, [empty])).toBe(path.join(realDir, 'browse')); + const linkDir = path.join(home, 'path-link'); + fs.mkdirSync(linkDir, { recursive: true }); + fs.symlinkSync(builtA, path.join(linkDir, 'browse')); + expect(resolveBrowseBin({ PATH: linkDir }, [empty])).toBe(path.join(linkDir, 'browse')); + }); + test('directories are never "executables"', () => { const rootD = path.join(home, 'd'); fs.mkdirSync(path.join(rootD, 'browse/dist/browse'), { recursive: true }); diff --git a/test/qid-marker.test.ts b/test/qid-marker.test.ts index 41cb3035e..bd4357f78 100644 --- a/test/qid-marker.test.ts +++ b/test/qid-marker.test.ts @@ -19,6 +19,13 @@ describe('parseQidMarker (#3442)', () => { expect(parseQidMarker(' q')).toMatchObject({ id: 'plan-ceo-x', signal_key: 'scope-appetite' }); }); + test('a malformed signal hint drops only the hint; the id still names the question', () => { + expect(parseQidMarker('Q? ')).toEqual({ id: 'plan-x', stripped: 'Q?' }); + expect(parseQidMarker('Q? ')).toEqual({ id: 'plan-x', stripped: 'Q?' }); + expect(parseQidMarker('Q? ')).toEqual({ id: 'plan-x', stripped: 'Q?' }); + expect(parseQidMarker('Q? ')).toEqual({ id: 'plan-x', stripped: 'Q?' }); + }); + test('no marker returns undefined', () => { expect(parseQidMarker('plain question')).toBeUndefined(); }); diff --git a/test/question-preference-hook.test.ts b/test/question-preference-hook.test.ts index ae21aaa36..2125ba87b 100644 --- a/test/question-preference-hook.test.ts +++ b/test/question-preference-hook.test.ts @@ -260,6 +260,43 @@ describe('enforces never-ask preferences', () => { expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('Fix now'); }); + test('a malformed signal hint keeps the marker, so the stored preference still applies', () => { + writeProjectPref('ship-pre-landing-review-fix', 'never-ask'); + const r = runHook({ + session_id: 's5h', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-5h', + tool_input: { + questions: [ + { + question: ' Pre-landing review flagged issue.', + options: ['A) Fix now (recommended)', 'B) Skip'], + }, + ], + }, + }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + }); + + test('an uppercase marker reads the same lowercase id the log hook records', () => { + writeProjectPref('ship-pre-landing-review-fix', 'never-ask'); + const r = runHook({ + session_id: 's5u', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-5u', + tool_input: { + questions: [ + { + question: ' Pre-landing review flagged issue.', + options: ['A) Fix now (recommended)', 'B) Skip'], + }, + ], + }, + }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('ship-pre-landing-review-fix → A) Fix now'); + }); + test('one-way door → pass-through even with never-ask (safety override)', () => { writeProjectPref('ship-test-failure-triage', 'never-ask'); const r = runHook({ @@ -354,6 +391,61 @@ describe('enforces never-ask preferences', () => { expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('plan-tune auto-decide'); }); + + test('destructive text inside a marker tail still reaches the keyword net → pass-through', () => { + writeProjectPref('repo-cleanup', 'never-ask'); + const r = runHook({ + session_id: 's-kw-3', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-kw-3', + tool_input: { + questions: [ + { + question: ' Proceed?', + options: ['A) Yes (recommended)', 'B) No'], + }, + ], + }, + }); + expectPassThrough(r); + }); + + test('a marker tail right against the question text still reaches the keyword net → pass-through', () => { + writeProjectPref('x', 'never-ask'); + const r = runHook({ + session_id: 's-kw-4', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-kw-4', + tool_input: { + questions: [ + { + question: 'delete production database?', + options: ['A) Yes (recommended)', 'B) No'], + }, + ], + }, + }); + expectPassThrough(r); + }); + + test('a marker tail right against benign question text → still deny (control)', () => { + writeProjectPref('x', 'never-ask'); + const r = runHook({ + session_id: 's-kw-5', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-kw-5', + tool_input: { + questions: [ + { + question: 'Reorganize the TODOs file?', + options: ['A) Yes (recommended)', 'B) No'], + }, + ], + }, + }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('plan-tune auto-decide'); + }); }); // ----------------------------------------------------------------------