From e3546d9ac4f00c8deeba1c378994f21866c018e9 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 4 Oct 2026 05:44:03 +0000 Subject: [PATCH] fix(plan-ceo-review): Step 0E mode handoff runs gstack-ceo-mode-handoff so the selected mode is printed, not only composed --- bin/gstack-ceo-mode-handoff | 42 ++++++++ plan-ceo-review/SKILL.md | 2 +- plan-ceo-review/SKILL.md.tmpl | 2 +- scripts/typecheck-test-baseline.json | 1 - test/ceo-mode-handoff.test.ts | 116 +++++++++++++++++++++ test/ceo-mode-preference-al.test.ts | 3 +- test/helpers/ceo-mode-option.ts | 5 + test/helpers/native-auto-decide.ts | 51 +++++++++ test/helpers/touchfiles-data.ts | 4 +- test/skill-ceo-section-ordering.test.ts | 2 +- test/skill-e2e-third-party-actions.test.ts | 2 +- 11 files changed, 222 insertions(+), 8 deletions(-) create mode 100755 bin/gstack-ceo-mode-handoff create mode 100644 test/ceo-mode-handoff.test.ts diff --git a/bin/gstack-ceo-mode-handoff b/bin/gstack-ceo-mode-handoff new file mode 100755 index 000000000..393f4886a --- /dev/null +++ b/bin/gstack-ceo-mode-handoff @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# gstack-ceo-mode-handoff — print /plan-ceo-review's Step 0E mode handoff line. +# +# Usage: gstack-ceo-mode-handoff [--auto] [--decisions ""] +# MODE: HOLD SCOPE | SCOPE EXPANSION | SELECTIVE EXPANSION | SCOPE REDUCTION +# (underscores accepted for spaces) +# --auto the mode came from a saved AUTO_DECIDE preference +# --decisions governing approved rows; defaults to "none" +# +# The handoff used to be chat-only, and the model often composed it in its +# reasoning without ever sending it. Running this prints the line as a tool +# result, so the selected mode is observable before the review continues. +set -euo pipefail + +mode="${1:-}" +[ "$#" -gt 0 ] && shift +auto=0 +decisions="none" +while [ "$#" -gt 0 ]; do + case "$1" in + --auto) auto=1; shift ;; + --decisions) + [ "$#" -ge 2 ] || { echo "gstack-ceo-mode-handoff: --decisions needs a value" >&2; exit 2; } + decisions="$2"; shift 2 ;; + *) echo "gstack-ceo-mode-handoff: unknown argument: $1" >&2; exit 2 ;; + esac +done + +mode="${mode//_/ }" +case "$mode" in + "HOLD SCOPE"|"SCOPE EXPANSION"|"SELECTIVE EXPANSION"|"SCOPE REDUCTION") ;; + *) echo "gstack-ceo-mode-handoff: unknown mode: ${mode:-}" >&2; exit 2 ;; +esac +decisions="${decisions//$'\n'/ }" +decisions="${decisions%.}" +[ -n "${decisions// /}" ] || decisions="none" + +if [ "$auto" = 1 ]; then + echo "Auto-decided review mode → $mode (your preference). Change with /plan-tune. Approved decisions: $decisions." +else + echo "Mode: $mode; approved decisions: $decisions." +fi diff --git a/plan-ceo-review/SKILL.md b/plan-ceo-review/SKILL.md index ed339797d..7f0427db4 100644 --- a/plan-ceo-review/SKILL.md +++ b/plan-ceo-review/SKILL.md @@ -975,7 +975,7 @@ Follow the preamble's session rules; `CONDUCTOR_SESSION: true` changes transport wins. When `QUESTION_TUNING: true`, include ``. These modes differ in kind, not coverage; do NOT score completeness. -4. **Mode handoff:** After selection, send brief chat before tools or further questions: the mode's application and rationale; every governing approved row's ID, answer reference and accepted scope. Keep rows separate. Begin with the exact matching line below: +4. **Mode handoff:** After selection, before other tools or further questions, run `~/.claude/skills/gstack/bin/gstack-ceo-mode-handoff "" --decisions ""` (add `--auto` for `plan-ceo-review-mode: AUTO_DECIDE`). It prints the matching line below. Then send brief chat beginning with that line: the mode's application and rationale; every governing approved row's ID, answer reference and accepted scope. Keep rows separate. - `plan-ceo-review-mode: AUTO_DECIDE`: `Auto-decided review mode → (your preference). Change with /plan-tune. Approved decisions: . .` - Other selections: `Mode: ; approved decisions: . .` diff --git a/plan-ceo-review/SKILL.md.tmpl b/plan-ceo-review/SKILL.md.tmpl index 3116473fe..7d83703fd 100644 --- a/plan-ceo-review/SKILL.md.tmpl +++ b/plan-ceo-review/SKILL.md.tmpl @@ -407,7 +407,7 @@ Follow the preamble's session rules; `CONDUCTOR_SESSION: true` changes transport wins. When `QUESTION_TUNING: true`, include ``. These modes differ in kind, not coverage; do NOT score completeness. -4. **Mode handoff:** After selection, send brief chat before tools or further questions: the mode's application and rationale; every governing approved row's ID, answer reference and accepted scope. Keep rows separate. Begin with the exact matching line below: +4. **Mode handoff:** After selection, before other tools or further questions, run `~/.claude/skills/gstack/bin/gstack-ceo-mode-handoff "" --decisions ""` (add `--auto` for `plan-ceo-review-mode: AUTO_DECIDE`). It prints the matching line below. Then send brief chat beginning with that line: the mode's application and rationale; every governing approved row's ID, answer reference and accepted scope. Keep rows separate. - `plan-ceo-review-mode: AUTO_DECIDE`: `Auto-decided review mode → (your preference). Change with /plan-tune. Approved decisions: . .` - Other selections: `Mode: ; approved decisions: . .` diff --git a/scripts/typecheck-test-baseline.json b/scripts/typecheck-test-baseline.json index 1f93a7abf..4103da14e 100644 --- a/scripts/typecheck-test-baseline.json +++ b/scripts/typecheck-test-baseline.json @@ -480,7 +480,6 @@ "test/skill-e2e-plan-decision-classification.test.ts\tTS2769\tNo overload matches this call. The last overload gave the following error. Argument of type 'number | undefined' is not assignable to parameter of type 'number'. Type 'undefined' is not assignable to type 'number'.": 1, "test/skill-e2e-shared-libs-paths.test.ts\tTS2769\tNo overload matches this call. The last overload gave the following error. Argument of type 'unknown' is not assignable to parameter of type 'string'.": 1, "test/skill-e2e-ship-docsync.test.ts\tTS2345\tArgument of type 'EvalCollector | null' is not assignable to parameter of type 'EvalCollector'. Type 'null' is not assignable to type 'EvalCollector'.": 9, - "test/skill-e2e-third-party-actions.test.ts\tTS2345\tArgument of type '{ maxTurns: 8; allowedTools: readonly ['Read', 'Bash']; timeout: 240000; runId: string; env: { PATH: string; }; prompt: string; workingDirectory: string; testName: string; }' is not assignable to parameter of type '{ prompt: string; workingDirectory: string; maxTurns?: number | undefined; appendSystemPrompt?: string | undefined; completionReserveMs?: number | undefined; allowedTools?: string[] | undefined; ... 9 more ...; nativeLifecycle?: { ...; } | undefined; }'. Types of property 'allowedTools' are incompatible. The type 'readonly [\"Read\", \"Bash\"]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.": 5, "test/skill-routing-e2e.test.ts\tTS2345\tArgument of type '\"e2e-routing\"' is not assignable to parameter of type '\"e2e\" | \"llm-judge\"'.": 1, "test/strict-output.test.ts\tTS2739\tType '{ failedTests: number; unhandledBetweenTests: number; terminalFileCounts: number[]; }' is missing the following properties from type 'BunTestOutputSummary': terminalTestCounts, skippedTests, passedTests": 5, "test/test-free-shards.test.ts\tTS2345\tArgument of type 'number | ReadableStream> | undefined' is not assignable to parameter of type 'BodyInit | null | undefined'. Type 'number' is not assignable to type 'BodyInit | null | undefined'.": 2, diff --git a/test/ceo-mode-handoff.test.ts b/test/ceo-mode-handoff.test.ts new file mode 100644 index 000000000..55946191a --- /dev/null +++ b/test/ceo-mode-handoff.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { ceoModeHandoffs, findNativeAutoDecision } from './helpers/native-auto-decide'; +import { hasNativePostAnswerCeoPosture } from './helpers/ceo-mode-option'; +import { readPlanCountTranscript, type NativePublicToolEvent } from './helpers/plan-count-transcript'; +import expansionCapture from './fixtures/ceo-expansion-auq-ac.json'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-ceo-mode-handoff'); +const CMD = '"$HOME/.claude/skills/gstack/bin/gstack-ceo-mode-handoff"'; +const AUTO_LINE = 'Auto-decided review mode → HOLD SCOPE (your preference). Change with /plan-tune. Approved decisions: none.'; + +function run(...args: string[]) { + return spawnSync(BIN, args, { encoding: 'utf8', timeout: 10_000 }); +} + +function handoff(sessionId: string, at: number, command: string, content: string, id = 'toolu_handoff'): NativePublicToolEvent[] { + return [ + { sessionId, toolUseId: id, kind: 'use', name: 'Bash', timestamp: new Date(at).toISOString(), input: { command } }, + { sessionId, toolUseId: id, kind: 'result', timestamp: new Date(at + 500).toISOString(), content, isError: false }, + ]; +} + +describe('gstack-ceo-mode-handoff', () => { + test('prints the exact Step 0E handoff line for asked and auto-decided modes', () => { + expect(run('HOLD_SCOPE', '--auto').stdout).toBe(`${AUTO_LINE}\n`); + expect(run('SCOPE EXPANSION', '--decisions', 'D1 (A): keep the CLI.').stdout) + .toBe('Mode: SCOPE EXPANSION; approved decisions: D1 (A): keep the CLI.\n'); + expect(run('SCOPE REDUCTION', '--decisions', ' ').stdout).toBe('Mode: SCOPE REDUCTION; approved decisions: none.\n'); + }); + + test('rejects unknown modes and arguments without printing a handoff', () => { + for (const args of [['BIG'], [], ['HOLD SCOPE', '--decisions'], ['HOLD SCOPE', '--force']]) { + const result = run(...args); + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + } + }); +}); + +describe('native handoff evidence', () => { + const at = Date.parse('2026-10-04T06:00:00.000Z'); + + test('accepts only a successful literal invocation whose output matches its arguments', () => { + expect(ceoModeHandoffs(handoff('s', at, `${CMD} "HOLD SCOPE" --auto`, AUTO_LINE), 's')) + .toMatchObject([{ option: 'HOLD SCOPE', auto: true, line: AUTO_LINE }]); + const rejected: Array<[string, string, (events: NativePublicToolEvent[]) => void]> = [ + ['wrong output', `${CMD} "HOLD SCOPE" --auto`, events => { events[1]!.content = AUTO_LINE.replace('HOLD SCOPE', 'SCOPE EXPANSION'); }], + ['missing --auto', `${CMD} "HOLD SCOPE"`, () => {}], + ['failed result', `${CMD} "HOLD SCOPE" --auto`, events => { events[1]!.isError = true; }], + ['echoed line', `echo "${AUTO_LINE}"`, () => {}], + ['chained command', `${CMD} "HOLD SCOPE" --auto; true`, () => {}], + ['relative path', 'bin/gstack-ceo-mode-handoff "HOLD SCOPE" --auto', () => {}], + ['foreign session', `${CMD} "HOLD SCOPE" --auto`, events => { events[1]!.sessionId = 'other'; }], + ['result before use', `${CMD} "HOLD SCOPE" --auto`, events => { events[1]!.timestamp = new Date(at - 1).toISOString(); }], + ]; + for (const [name, command, mutate] of rejected) { + const events = handoff('s', at, command, AUTO_LINE); + mutate(events); + expect(ceoModeHandoffs(events, 's'), name).toEqual([]); + } + }); + + test('an auto-decided handoff stands in for the unsent annotation, unless later withdrawn', () => { + const captured = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/native-auto-decide-ag.json'), 'utf8')); + const attempt = () => { + const f = structuredClone(captured.attempts[0]); + // Census 37179171083 shape: the decision was logged but the line never reached chat. + const message = f.transcript.assistantMessages.find((m: any) => m.text.includes('Auto-decided')); + message.text = 'Continuing with the HOLD SCOPE review.'; + return f; + }; + const verdict = (f: any) => findNativeAutoDecision(f.transcript, f.tools, f.options); + const unsent = attempt(); + expect(verdict(unsent)).toBeNull(); + const sessionId = unsent.options.sessionId; + const after = Date.parse('2026-09-10T02:19:00.000Z'); + const sent = attempt(); + sent.tools.push(...handoff(sessionId, after, `${CMD} "HOLD SCOPE" --auto`, AUTO_LINE)); + expect(verdict(sent)).toMatchObject({ option: 'HOLD SCOPE', annotation: AUTO_LINE }); + const asked = attempt(); + asked.tools.push(...handoff(sessionId, after, `${CMD} "HOLD SCOPE"`, 'Mode: HOLD SCOPE; approved decisions: none.')); + expect(verdict(asked)).toBeNull(); + const withdrawn = attempt(); + withdrawn.tools.push(...handoff(sessionId, after, `${CMD} "HOLD SCOPE" --auto`, AUTO_LINE)); + withdrawn.transcript.assistantMessages.push({ sessionId, timestamp: '2026-09-10T02:19:05.000Z', text: 'Correction: I withdraw this auto-decision.' }); + expect(verdict(withdrawn)).toBeNull(); + }); + + test('an answered mode handoff establishes that mode posture, and only that mode', () => { + const records = structuredClone(expansionCapture.records); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ceo-mode-handoff-')); + const project = path.join(root, 'projects', 'fixture'); + fs.mkdirSync(project, { recursive: true }); + fs.writeFileSync(path.join(project, `${records[0]!.sessionId}.jsonl`), records.map(r => JSON.stringify(r)).join('\n') + '\n'); + let transcript; + try { transcript = readPlanCountTranscript(root, records[0]!.cwd, () => {}); } + finally { fs.rmSync(root, { recursive: true, force: true }); } + const posture = /\b(expansion|10x|delight|dream|cathedral|opt[\s-]?in)\b/i; + const selectedAt = Date.parse(expansionCapture.provenance.testSelectionLowerBound.at); + const mode = transcript.calls.find(call => call.toolUseId === expansionCapture.modeToolUseId)!; + const answeredAt = Date.parse(mode.answeredAt!); + const check = (events: NativePublicToolEvent[]) => + hasNativePostAnswerCeoPosture(transcript!, 'SCOPE EXPANSION', posture, selectedAt, events); + expect(check([])).toBe(false); + const line = 'Mode: SCOPE EXPANSION; approved decisions: none.'; + expect(check(handoff(mode.sessionId, answeredAt + 1000, `${CMD} "SCOPE EXPANSION"`, line))).toBe(true); + expect(check(handoff(mode.sessionId, answeredAt - 5000, `${CMD} "SCOPE EXPANSION"`, line))).toBe(false); + expect(check(handoff(mode.sessionId, answeredAt + 1000, `${CMD} "HOLD SCOPE"`, 'Mode: HOLD SCOPE; approved decisions: none.'))).toBe(false); + expect(check(handoff(mode.sessionId, answeredAt + 1000, `${CMD} "SCOPE EXPANSION" --auto`, + AUTO_LINE.replace('HOLD SCOPE', 'SCOPE EXPANSION')))).toBe(false); + }); +}); diff --git a/test/ceo-mode-preference-al.test.ts b/test/ceo-mode-preference-al.test.ts index b4e1e2d3f..e2f10a14e 100644 --- a/test/ceo-mode-preference-al.test.ts +++ b/test/ceo-mode-preference-al.test.ts @@ -73,7 +73,8 @@ test('source and both isolated host renders bind the shared check, marker and lo expect(s.indexOf('3. Resolve that recommendation')).toBeGreaterThanOrEqual(0); expect(s.indexOf('4. **Mode handoff:**')).toBeGreaterThan(s.indexOf('3. Resolve that recommendation')); expect(handoff).toContain('After selection'); - expect(handoff).toContain('send brief chat before tools or further questions'); + expect(handoff).toMatch(/before other tools or further questions, run `[^`]*\/bin\/gstack-ceo-mode-handoff /); + expect(handoff).toContain('Then send brief chat beginning with that line'); expect(s.slice(0,s.indexOf('4. **Mode handoff:**'))).not.toMatch(/\blog (?:with|that ID)\b/); expect(handoff.indexOf('Record mode provenance after the handoff')).toBeGreaterThan(handoff.indexOf('- Other selections:')); expect(handoff.indexOf("Follow the selected mode's route:")).toBeGreaterThan(handoff.indexOf('Record mode provenance after the handoff')); diff --git a/test/helpers/ceo-mode-option.ts b/test/helpers/ceo-mode-option.ts index cd2a0ad00..3652aa0ed 100644 --- a/test/helpers/ceo-mode-option.ts +++ b/test/helpers/ceo-mode-option.ts @@ -13,6 +13,7 @@ import { type AskUserQuestionFingerprint, } from './claude-pty-runner'; import type { NativePlanQuestionCall, NativePublicToolEvent, PlanCountTranscript } from './plan-count-transcript'; +import { ceoModeHandoffs } from './native-auto-decide'; type CeoMode = 'HOLD SCOPE' | 'SCOPE EXPANSION' | 'SELECTIVE EXPANSION' | 'SCOPE REDUCTION'; @@ -655,6 +656,10 @@ export function hasNativePostAnswerCeoPosture( const selected = nativeCeoModeAnswer(transcript, targetMode, selectionStartedAt); if (!selected) return false; const answeredAt = Date.parse(selected.answeredAt!); + // The handoff helper prints the answered mode as a tool result; it applies + // the selected posture exactly as the same line sent as chat would. + if (ceoModeHandoffs(publicTools, selected.sessionId).some(h => !h.auto && h.option === targetMode && + Date.parse(h.timestamp) > answeredAt && Date.parse(h.timestamp) <= Date.now() && posture.test(h.line))) return true; return transcript.assistantMessages.some(message => { if (message.sessionId !== selected.sessionId || Date.parse(message.timestamp) <= answeredAt) return false; return hasNativePostureProse(message.text, posture) || diff --git a/test/helpers/native-auto-decide.ts b/test/helpers/native-auto-decide.ts index 87d0b02a4..6975f4a20 100644 --- a/test/helpers/native-auto-decide.ts +++ b/test/helpers/native-auto-decide.ts @@ -229,6 +229,34 @@ const modeNames = ['HOLD SCOPE', 'SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'SCOP const modeValue = (value: unknown) => typeof value === 'string' && modeNames.includes(value.replaceAll('_', ' ')) ? value.replaceAll('_', ' ') : null; +export interface CeoModeHandoff { sessionId: string; toolUseId: string; timestamp: string; option: string; auto: boolean; line: string } + +/** Successful `gstack-ceo-mode-handoff` runs whose single output line is exactly what that invocation prints. */ +export function ceoModeHandoffs(tools: ReadonlyArray, sessionId: string): CeoModeHandoff[] { + return tools.flatMap(use => { + if (use.kind !== 'use' || use.name !== 'Bash' || use.sessionId !== sessionId || !use.toolUseId || + typeof use.input?.command !== 'string') return []; + const args = cliArgs((use.input.command as string).trim(), 'gstack-ceo-mode-handoff'); + const option = modeValue(args?.[0]); + if (!args || !option) return []; + let auto = false, decisions = 'none'; + for (let i = 1; i < args.length; i++) { + if (args[i] === '--auto' && !auto) auto = true; + else if (args[i] === '--decisions' && i + 1 < args.length) decisions = args[++i]!.replace(/\n/g, ' ').replace(/\.$/, '') || 'none'; + else return []; + } + if (!decisions.trim()) decisions = 'none'; + const line = auto + ? `Auto-decided review mode → ${option} (your preference). Change with /plan-tune. Approved decisions: ${decisions}.` + : `Mode: ${option}; approved decisions: ${decisions}.`; + const results = tools.filter(e => e.kind === 'result' && e.sessionId === sessionId && e.toolUseId === use.toolUseId); + const result = results.length === 1 ? results[0]! : null; + if (!result || result.isError !== false || typeof result.content !== 'string' || result.content.trim() !== line || + !(Date.parse(result.timestamp) >= Date.parse(use.timestamp))) return []; + return [{ sessionId, toolUseId: use.toolUseId, timestamp: result.timestamp, option, auto, line }]; + }); +} + function currentModeStatement(text: string, questionSummary?: string): { option: string; statement: string } | null { const prose = publicProse(text); const lines = prose.split('\n'); @@ -286,6 +314,17 @@ function structuredModeDecision(transcript: PlanCountTranscript, tools: NativePu }); if (starts.length !== 1) return null; const start = starts[0]!, questionId = `${opts.skillName}-mode`; + // The handoff helper's printed AUTO_DECIDE line is the current declaration + // when it follows the decision record and nothing later withdraws or changes it. + const autoHandoff = (after: number, option: string, summary: string) => { + const handoff = ceoModeHandoffs(owned, opts.sessionId).find(h => h.auto && h.option === option && + timely(h.timestamp) && time(h.timestamp) >= after); + if (!handoff) return null; + const later = current.filter(m => time(m.timestamp) >= time(handoff.timestamp)); + if (withdrawn(later.map(m => m.text).join('\n\n'), option, summary) || + later.some(m => { const other = currentModeStatement(m.text, summary)?.option; return other !== undefined && other !== option; })) return null; + return { sessionId: opts.sessionId, timestamp: handoff.timestamp, option, annotation: handoff.line }; + }; // Opt-in fixture evidence bypasses no failed shell ACK: the owned file is // the completed write. The preamble, record and current public declaration // must all agree in this native session, after invocation and before now. @@ -298,6 +337,8 @@ function structuredModeDecision(transcript: PlanCountTranscript, tools: NativePu row.auto_decided === true && modeValue(row.user_choice) && modeValue(row.user_choice) === modeValue(row.recommended) && typeof row.question_summary === 'string' && row.question_summary.trim() && loggedAt >= time(start.result.timestamp) && loggedAt <= opts.now) { + const handoff = autoHandoff(loggedAt, modeValue(row.user_choice)!, row.question_summary); + if (handoff) return { ...handoff, summary: row.question_summary, preambleToolUseId: start.use.toolUseId, stateRecord: row }; for (const message of current) { const declared = currentModeStatement(message.text, row.question_summary); if (time(message.timestamp) < loggedAt || !declared || declared.option !== modeValue(row.user_choice)) continue; @@ -346,6 +387,9 @@ function structuredModeDecision(transcript: PlanCountTranscript, tools: NativePu }); if (logs.length !== 1 || !logs[0]!.result) return null; const logged = logs[0]!; + const handoff = autoHandoff(time(logged.result!.timestamp), modeValue(logged.log.user_choice)!, logged.log.question_summary); + if (handoff) return { ...handoff, summary: logged.log.question_summary, preambleToolUseId: start.use.toolUseId, + preferenceToolUseId: check.use.toolUseId, questionLogToolUseId: logged.use.toolUseId }; for (const message of current) { if (time(message.timestamp) < time(logged.result!.timestamp)) continue; const declared = currentModeStatement(message.text, logged.log.question_summary); @@ -383,6 +427,13 @@ export function findNativeAutoDecision( const messages = transcript.assistantMessages.filter(m => m.sessionId === opts.sessionId); if (messages.some(m => !Number.isFinite(at(m.timestamp)) || at(m.timestamp) > opts.now)) return null; const loadedAt = at(results[0]!.timestamp); + for (const handoff of ceoModeHandoffs(tools, opts.sessionId)) { + if (!handoff.auto || !timely(handoff.timestamp) || at(handoff.timestamp) < loadedAt) continue; + const later = messages.filter(m => at(m.timestamp) >= at(handoff.timestamp)).map(m => m.text).join('\n\n'); + if (withdrawn(later, handoff.option)) continue; + return { sessionId: opts.sessionId, skillToolUseId: use.toolUseId, timestamp: handoff.timestamp, + summary: 'review mode', option: handoff.option, annotation: handoff.line }; + } for (const message of messages) { if (at(message.timestamp) < loadedAt) continue; const match = assertedAnnotation(message.text, opts.skillName); diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index 5d3adefe5..1fd4e0f74 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -317,7 +317,7 @@ export const E2E_TOUCHFILES: Record = { // written a never-ask preference, AUQ should still auto-decide rather than // surfacing the question. Touches the question-tuning + preference // infrastructure plus the resolvers that own the AUTO_DECIDE preamble. - 'auto-decide-preserved': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'lib/claude-public-transcript.ts', + 'auto-decide-preserved': ['bin/gstack-ceo-mode-handoff', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'lib/claude-public-transcript.ts', 'test/fixtures/auto-decide-recommendation-361c.json', @@ -343,7 +343,7 @@ export const E2E_TOUCHFILES: Record = { 'test/helpers/eval-store.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'], - 'plan-ceo-mode-routing': [ + 'plan-ceo-mode-routing': ['bin/gstack-ceo-mode-handoff', 'lib/claude-public-transcript.ts', diff --git a/test/skill-ceo-section-ordering.test.ts b/test/skill-ceo-section-ordering.test.ts index f881c70e8..8b6949ffa 100644 --- a/test/skill-ceo-section-ordering.test.ts +++ b/test/skill-ceo-section-ordering.test.ts @@ -140,7 +140,7 @@ test('CEO mode handoff applies the selected mode before the next question', () = expect(routeStart).toBeGreaterThan(handoffStart); const handoff = selection.slice(handoffStart, routeStart); const instruction = handoff.split('\n')[0]!; - expect(instruction).toMatch(/before tools or further questions/i); + expect(instruction).toMatch(/before other tools or further questions, run `[^`]*gstack-ceo-mode-handoff/i); expect(instruction).toMatch(/chat.*mode.s application and rationale/i); const selectionSteps = compactProse(selection.slice(0, handoffStart)); expect(selectionSteps).toMatch(/explicit choice skips steps 2–3/i); diff --git a/test/skill-e2e-third-party-actions.test.ts b/test/skill-e2e-third-party-actions.test.ts index 6c797fa1b..c638a514b 100644 --- a/test/skill-e2e-third-party-actions.test.ts +++ b/test/skill-e2e-third-party-actions.test.ts @@ -178,7 +178,7 @@ const COMMON = { allowedTools: ['Read', 'Bash'], timeout: 240_000, runId, -} as const; +}; describeIfSelected('third-party-actions consent gate', [ 'tpa-present', 'tpa-absent-linux', 'tpa-broken', 'tpa-absent-darwin', 'tpa-apple-ban',