From 975312ef3f74743ebbd8fb4dff04a14f1fd653bb Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 26 May 2026 22:27:51 -0700 Subject: [PATCH] feat(question-pref): runtime AUTO_DECIDE carve-out for *-split-* ids Split chains (per-option AskUserQuestion calls emitted by the new "Handling 5+ options" rule) must never be silently auto-approved via /plan-tune preferences. The user's option set is sacred. Layer 1 (mechanism): unique -split- ids prevent cross-option preference leakage. Layer 2 (this commit): the runtime checker `gstack-question-preference --check` detects any id matching *-split-* and forces ASK_NORMALLY even when never-ask or ask-only-for-one-way preferences exist for that exact id. An explanatory note tells the user their preference was bypassed and why. 7 tests pin the carve-out: no-pref baseline, never-ask override, explanatory note text, ask-only-for-one-way override, always-ask (no note), non-split id containing "split" word (negative case for regex specificity), multi-skill split id formats. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/gstack-question-preference | 15 +++++++ test/gstack-question-preference.test.ts | 59 +++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/bin/gstack-question-preference b/bin/gstack-question-preference index b660742e3..b8c5665af 100755 --- a/bin/gstack-question-preference +++ b/bin/gstack-question-preference @@ -68,6 +68,21 @@ do_check() { return; } + // Split-chain carve-out: per-option calls in N-option splits emit + // question_ids of the form -split-. These are + // NEVER AUTO_DECIDE-eligible regardless of stored preferences — the + // whole point of splitting is restoring user sovereignty over the + // option set. See scripts/resolvers/preamble/generate-ask-user-format.ts + // \"Handling 5+ options — split, never drop\" for the surrounding + // mechanism that generates these ids. + if (/-split-/.test(qid)) { + console.log('ASK_NORMALLY'); + if (pref === 'never-ask' || pref === 'ask-only-for-one-way') { + console.log('NOTE: split-chain per-option calls always ASK_NORMALLY; your ' + pref + ' preference does not apply to options inside a sequential split.'); + } + return; + } + switch (pref) { case 'never-ask': console.log('AUTO_DECIDE'); diff --git a/test/gstack-question-preference.test.ts b/test/gstack-question-preference.test.ts index 629319aef..863cd9e74 100644 --- a/test/gstack-question-preference.test.ts +++ b/test/gstack-question-preference.test.ts @@ -103,6 +103,65 @@ describe('--check with preferences set', () => { }); }); +// Split-chain carve-out: question_ids matching -split- +// must always ASK_NORMALLY regardless of stored preferences. +// See scripts/resolvers/preamble/generate-ask-user-format.ts +// "Handling 5+ options — split, never drop" for the surrounding mechanism. +describe('--check split-chain carve-out (*-split-* always ASK_NORMALLY)', () => { + function setPref(id: string, pref: string) { + return run('--write', JSON.stringify({ question_id: id, preference: pref, source: 'plan-tune' })); + } + + test('split-id without preference → ASK_NORMALLY', () => { + const r = run('--check', 'plan-ceo-review-split-e4-detect-mappings'); + expect(r.stdout.trim()).toContain('ASK_NORMALLY'); + }); + + test('split-id + never-ask → ASK_NORMALLY (carve-out overrides preference)', () => { + setPref('plan-ceo-review-split-e4-detect-mappings', 'never-ask'); + const r = run('--check', 'plan-ceo-review-split-e4-detect-mappings'); + expect(r.stdout).toContain('ASK_NORMALLY'); + expect(r.stdout).not.toContain('AUTO_DECIDE'); + }); + + test('split-id + never-ask → emits explanatory note', () => { + setPref('plan-ceo-review-split-e4-detect-mappings', 'never-ask'); + const r = run('--check', 'plan-ceo-review-split-e4-detect-mappings'); + expect(r.stdout).toContain('split-chain per-option calls always ASK_NORMALLY'); + expect(r.stdout).toContain('never-ask'); + }); + + test('split-id + ask-only-for-one-way → ASK_NORMALLY (carve-out overrides preference)', () => { + setPref('ship-split-version-bump', 'ask-only-for-one-way'); + const r = run('--check', 'ship-split-version-bump'); + expect(r.stdout).toContain('ASK_NORMALLY'); + expect(r.stdout).not.toContain('AUTO_DECIDE'); + }); + + test('split-id + always-ask → ASK_NORMALLY (no note since preference agrees)', () => { + setPref('plan-eng-review-split-add-test', 'always-ask'); + const r = run('--check', 'plan-eng-review-split-add-test'); + expect(r.stdout.trim()).toContain('ASK_NORMALLY'); + expect(r.stdout).not.toContain('does not apply'); + }); + + test('non-split id that just happens to contain "split" word is NOT carved out', () => { + // The carve-out matches `-split-` (kebab-cased), not the substring "split". + // A question id like `qa-splitscreen-test` (hypothetical) would not match. + // Verify by using a never-ask pref that should fire AUTO_DECIDE. + setPref('qa-splitscreen-test', 'never-ask'); + const r = run('--check', 'qa-splitscreen-test'); + expect(r.stdout.trim()).toContain('AUTO_DECIDE'); + }); + + test('multiple split-id formats: skill-split-anything matches', () => { + setPref('autoplan-split-ceo-finding-7', 'never-ask'); + const r = run('--check', 'autoplan-split-ceo-finding-7'); + expect(r.stdout).toContain('ASK_NORMALLY'); + expect(r.stdout).not.toContain('AUTO_DECIDE'); + }); +}); + // ----------------------------------------------------------------------- // --write // -----------------------------------------------------------------------