mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 09:23:50 +01:00
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807) * ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997) * fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805) * fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847) * feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096) Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com> * fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851) * fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906) * test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910) * chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes * v1.91.14.0: release metadata * test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group * ci: Codex login steps run under bash (the CI container's default sh rejects pipefail) * fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt * fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts * test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call * fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier * test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers) * test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts) * test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep) * fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit * test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently * test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census * fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic * test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output * fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens * test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design) * test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept) * fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files --------- Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
398 lines
22 KiB
TypeScript
398 lines
22 KiB
TypeScript
/** Capture an actual public AskUserQuestion, without reading model transcripts. */
|
||
import * as fs from 'node:fs';
|
||
import * as os from 'node:os';
|
||
import * as path from 'node:path';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { resolveEvalModel } from '../../lib/eval-model';
|
||
import { launchClaudePty, capturePlanCountQuestion, parseNumberedOptions, type ClaudePtySession } from './claude-pty-runner';
|
||
import { API_ERROR_PANEL } from './pty/screen';
|
||
import { buildSeedConfig, isHermeticEnabled } from './hermetic-env';
|
||
import { getProjectEvalDir } from './eval-store';
|
||
import { readFirstPendingQuestionForDisplay, pendingQuestionRecorderStatus } from './plan-count-pending-question';
|
||
import type { NativePlanQuestion, NativePlanQuestionCall } from './plan-count-transcript';
|
||
|
||
export const NATIVE_AUQ_CAPTURE_MS = 240_000;
|
||
// close() reserves at most two seconds for SIGINT and one for SIGKILL.
|
||
const CLEANUP_MS = 3_000;
|
||
|
||
export interface NativeAuqCaptureOptions {
|
||
planDir: string;
|
||
skillName: string;
|
||
scenario: string;
|
||
testName: string;
|
||
runId?: string;
|
||
model?: string;
|
||
}
|
||
|
||
export interface NativeAuqCapture {
|
||
outcome: 'question_captured';
|
||
workflowCompleted: false;
|
||
source: 'pre_tool_use';
|
||
sessionId: string;
|
||
toolUseId: string;
|
||
questionIndex: number;
|
||
question: NativePlanQuestion;
|
||
publicCall: NativePlanQuestionCall;
|
||
text: string;
|
||
artifactDir?: string;
|
||
}
|
||
|
||
/** Neutral separators only: every graded word must come from this one question. */
|
||
export function serializeNativeAuq(question: NativePlanQuestion): string {
|
||
return [question.header, question.question,
|
||
...question.options.map(option => [option.label, option.description].filter(value => value !== undefined).join('\n')),
|
||
].join('\n\n');
|
||
}
|
||
|
||
/** Counting tolerates damaged labels; format capture must reject visible contradictions. */
|
||
function nativeOptionLabelsAgree(screen: string, question: NativePlanQuestion): boolean {
|
||
const rows = screen.replace(/\r+\n?/g, '\n').split('\n');
|
||
const cursorRow = rows.findLastIndex(row => /❯\s*1\./.test(row));
|
||
if (cursorRow < 0) return false;
|
||
const menu = rows.slice(cursorRow);
|
||
const width = (text: string) => Bun.stringWidth(text);
|
||
const left = (text: string, columns: number) => {
|
||
let result = '', used = 0;
|
||
for (const char of text) {
|
||
const next = width(char);
|
||
if (used + next > columns || used >= columns) break;
|
||
result += char; used += next;
|
||
}
|
||
return result;
|
||
};
|
||
// Strip only an actual aligned preview box, not a literal bar inside a label.
|
||
let previewColumn: number | undefined;
|
||
for (let top = 0; top < menu.length; top++) {
|
||
const box = /┌─+┐\s*$/.exec(menu[top]!);
|
||
if (!box || box.index === 0) continue;
|
||
const column = width(menu[top]!.slice(0, box.index));
|
||
const bottom = menu.findIndex((row, i) => i > top &&
|
||
row.slice(left(row,column).length).trim() === box[0].trim().replace('┌','└').replace('┐','┘'));
|
||
if (bottom > top + 1 && menu.slice(top + 1,bottom).every(row =>
|
||
/^│[^\n]*│\s*$/.test(row.slice(left(row,column).length)))) {
|
||
previewColumn = column; break;
|
||
}
|
||
}
|
||
const labelRows = menu.map(row => {
|
||
if (previewColumn === undefined) return row;
|
||
const prefix = left(row,previewColumn), sidebar = row.slice(prefix.length);
|
||
return /^(?:[┌│└]|Notes: press n to add notes\s*$)/.test(sidebar) ? prefix.trimEnd() : row;
|
||
});
|
||
const options = parseNumberedOptions(labelRows.join('\n'));
|
||
const optionRow = /^[ \t]{0,3}(?:❯\s*)?([1-9])\.\s*(.*)$/;
|
||
// The shared parser requires two choices. A single remaining visible choice
|
||
// can still explicitly contradict its indexed native label.
|
||
for (const row of labelRows) {
|
||
const match = optionRow.exec(row);
|
||
if (match && !options.some(option => option.index === Number(match[1]))) {
|
||
options.push({index:Number(match[1]),label:match[2]!.trim()});
|
||
}
|
||
}
|
||
const compact = (text: string) => text.replace(/\s+/g,'');
|
||
const label = (text: string) => compact(question.multiSelect ? text.replace(/^\[[ ✓✔xX]\]\s*/,'') : text);
|
||
return options.every(option => {
|
||
const expected = question.options[option.index - 1];
|
||
if (!expected) return /^(?:Typesomething\.?|Chataboutthis)$/.test(compact(option.label));
|
||
let shown = label(option.label), wanted = compact(expected.label);
|
||
if (!shown || shown === wanted) return true;
|
||
if (/(?:…|\.\.\.)$/.test(shown)) {
|
||
const prefix = shown.replace(/(?:…|\.\.\.)$/,'');
|
||
return !prefix || wanted.startsWith(prefix);
|
||
}
|
||
if (!wanted.startsWith(shown)) return false;
|
||
const start = labelRows.findIndex(row => {
|
||
const match = optionRow.exec(row);
|
||
return match && Number(match[1]) === option.index;
|
||
});
|
||
if (start < 0) return false;
|
||
let last = start;
|
||
for (let i = start + 1; i < labelRows.length && /^[ \t]{4,}\S/.test(labelRows[i]!); i++) {
|
||
const next = compact(labelRows[i]!.trim());
|
||
if (!wanted.startsWith(shown + next)) return false;
|
||
shown += next; last = i;
|
||
if (shown === wanted) return true;
|
||
}
|
||
// Prefix-only equality requires visible clipping at the actual 120-column
|
||
// viewport edge; a shorter complete row such as Keep != Keep current fails.
|
||
return width(menu[last]!.trimEnd()) === 120 && wanted.startsWith(shown);
|
||
});
|
||
}
|
||
|
||
/** The native UI can clip a tall pane's top and elide its tail simultaneously. */
|
||
function clippedElidedNativeAuqIdentity(screen: string, call: NativePlanQuestionCall):
|
||
{indices:number[]; packetBar:boolean} | undefined {
|
||
const visible = screen.replace(/\r+\n?/g, '\n');
|
||
const cursor = [...visible.matchAll(/^❯\s*1\./gm)].at(-1);
|
||
if (!cursor) return undefined;
|
||
// Native panes can leave blank outer margin rows when the header scrolls
|
||
// away. Remove only that margin; internal/unboxed rows remain evidence.
|
||
let before = visible.slice(0,cursor.index).replace(/^(?:[ \t]*\n)+/,'').trimEnd();
|
||
const bar = call.questions.length > 1
|
||
? /^←[^\n]*[☐☒][^\n]*✔[ \t]*Submit[ \t]*→[ \t]*\n/.exec(before) : null;
|
||
if (bar) before = before.slice(bar[0].length).replace(/^(?:[ \t]*\n)+/,'');
|
||
const rows = before.split('\n');
|
||
// Only the actual viewport's boxed question body qualifies. Do not strip a
|
||
// foreign header, quoted output, or prose prefix to manufacture a match.
|
||
if (rows.length < 2 || !/^[ \t]*[│┃](?: |$)/.test(rows[0]!) || /[☐□❯]/.test(before)) return undefined;
|
||
// Once the clipped boxed body starts, every interior row belongs to it.
|
||
// Counting may normalize blank rows; capture cannot discard that contradiction.
|
||
if (rows.some(row => !/^[ \t]*[│┃](?: |$)/.test(row))) return {indices:[],packetBar:!!bar};
|
||
const body = rows.map(row => row.replace(/^[ \t]*[│┃] ?/,'')).join('\n').trimEnd();
|
||
if (!body.endsWith('…')) return undefined;
|
||
const compact = (value: string) => value.replace(/\s+/g,'');
|
||
const fragment = compact(body.slice(0,-1));
|
||
if (fragment.length < 160) return undefined;
|
||
const menu = visible.slice(cursor.index);
|
||
const footer = call.questions.length > 1
|
||
? /(?:^|\n)Enter\s*to\s*select\s*·\s*Tab\/Arrow\s*keys\s*to\s*navigate\s*·\s*Esc\s*to\s*cancel[\s│┃─━└┘]*$/.exec(menu)
|
||
: /(?:^|\n)Enter\s*to\s*select\s*·\s*↑\/↓\s*to\s*navigate\s*·\s*(?:n\s*to\s*add\s*notes\s*·\s*)?Esc\s*to\s*cancel[\s│┃─━└┘]*$/.exec(menu);
|
||
// Once this boxed/elided shape is recognized, an incompatible
|
||
// footer is contradictory evidence, not permission to try another route.
|
||
if (!footer) return {indices:[],packetBar:!!bar};
|
||
// Inspect every displayed row, including an out-of-order control that the
|
||
// shared parser intentionally omits when its contiguous menu ends.
|
||
const options = [...menu.matchAll(/^[ \t]{0,3}(?:❯[ \t]*)?([0-9]+)\.[ \t]*(.*)$/gm)]
|
||
.map(match => ({index:Number(match[1]),label:match[2]!}));
|
||
const matches = call.questions.flatMap((question,questionIndex) => {
|
||
const offered = options.slice(0,question.options.length);
|
||
const controls = options.slice(question.options.length);
|
||
const exactMenu = offered.length === question.options.length && offered.every((option,index) =>
|
||
option.index === index + 1 && compact(option.label) === compact(question.options[index]!.label)) &&
|
||
controls.length <= 2 && controls.every((option,index) =>
|
||
option.index === question.options.length + index + 1 &&
|
||
(index === 0 ? /^Typesomething\.?$/ : /^Chataboutthis$/).test(compact(option.label)));
|
||
if (!exactMenu) return [];
|
||
const native = compact(question.question);
|
||
// A literal terminal ellipsis can also be complete-body/suffix evidence.
|
||
// Count that candidate alongside elided candidates, before either route
|
||
// can select a different member of the same packet.
|
||
if (bar ? native === compact(body) : native.endsWith(compact(body))) return [questionIndex];
|
||
const start = native.indexOf(fragment);
|
||
if (start < 0 || start + fragment.length >= native.length) return [];
|
||
// Repeated visible segments remain ambiguous, not an absent candidate
|
||
// that would let another packet member win by default.
|
||
return native.indexOf(fragment,start + 1) === -1 ? [questionIndex] : [questionIndex,questionIndex];
|
||
});
|
||
return {indices:matches,packetBar:!!bar};
|
||
}
|
||
|
||
/** Project a verified complete body; null rejects contradictory boxed evidence. */
|
||
function unboxCompleteNativeAuqBody(screen: string, call: NativePlanQuestionCall): string | null | undefined {
|
||
// Packets already have their own body projection and unique-tab matching.
|
||
// A singleton header must never provide a new route into a packet.
|
||
if (call.questions.length !== 1) return undefined;
|
||
const visible=screen.replace(/\r+\n?/g,'\n');
|
||
const cursor=[...visible.matchAll(/^❯\s*1\./gm)].at(-1);
|
||
if (!cursor) return undefined;
|
||
const before=visible.slice(0,cursor.index);
|
||
const header=[...before.matchAll(/(?:^|\n)[ \t]*[☐□]([^\n│]*)\n/g)].at(-1);
|
||
if (!header) return undefined;
|
||
const compact=(value:string)=>value.replace(/\s+/g,'');
|
||
const question=call.questions[0]!;
|
||
const bodyStart=header.index+header[0].length;
|
||
const bodySpan=before.slice(bodyStart);
|
||
const rows=bodySpan.replace(/^(?:[ \t]*\n)+/,'').trimEnd().split('\n');
|
||
if (!/^[ \t]*[│┃](?: |$)/.test(rows[0]!)) return undefined;
|
||
if (compact(header[1]!)!==compact(question.header) || rows.some(row=>!/^[ \t]*[│┃](?: |$)/.test(row))) return null;
|
||
// A complete boxed pane must pass this capture-specific check before the
|
||
// broader counting parser. Only a genuinely elided body uses its other route.
|
||
const body=rows.map(row=>row.replace(/^[ \t]*[│┃] ?/,'')).join('\n');
|
||
if (!compact(body) || compact(body)!==compact(question.question)) return body.endsWith('…') ? undefined : null;
|
||
// Ordinary prior public output is outside the pane only when separated by
|
||
// the native horizontal rule. A quoted/fenced pane is not display evidence.
|
||
const prefix=before.slice(0,header.index).trimEnd();
|
||
if (prefix && (!/^[─━]{20,}$/.test(prefix.split('\n').at(-1)!) ||
|
||
/(?:^|\n)[ \t]*(?:>|```)/.test(prefix))) return null;
|
||
// Strip exactly one framing prefix, preserving literal box characters in
|
||
// the actual question. No size threshold: malformed short briefs need grades.
|
||
const menu=visible.slice(cursor.index);
|
||
if (!/(?:^|\n)Enter\s*to\s*select\s*·\s*↑\/↓\s*to\s*navigate\s*·\s*(?:n\s*to\s*add\s*notes\s*·\s*)?Esc\s*to\s*cancel[\s│┃─━└┘]*$/.test(menu)) return null;
|
||
const options=[...menu.matchAll(/^[ \t]{0,3}(?:❯[ \t]*)?([0-9]+)\.[ \t]*(.*)$/gm)]
|
||
.map(match=>({index:Number(match[1]),label:match[2]!}));
|
||
const offered=options.slice(0,question.options.length), controls=options.slice(question.options.length);
|
||
if (offered.length!==question.options.length || offered.some((option,index)=>option.index!==index+1 || !option.label.trim()) ||
|
||
controls.length>2 || controls.some((option,index)=>option.index!==question.options.length+index+1 ||
|
||
!(index===0?/^Typesomething\.?$/:/^Chataboutthis$/).test(compact(option.label))) ||
|
||
!nativeOptionLabelsAgree(visible,question)) return null;
|
||
return visible.slice(0,bodyStart)+bodySpan.replace(/^[ \t]*[│┃] ?/gm,'')+visible.slice(cursor.index);
|
||
}
|
||
|
||
/** A hook payload alone, screen prose, or a different packet is not a capture. */
|
||
export function displayedNativeAuq(screen: string, call: NativePlanQuestionCall | undefined):
|
||
{question: NativePlanQuestion; questionIndex: number} | undefined {
|
||
if (!call || call.answered || call.failed) return undefined;
|
||
const clipped = clippedElidedNativeAuqIdentity(screen,call);
|
||
const uniqueFirst = clipped?.indices.length === 1 && clipped.indices[0] === 0;
|
||
// Question identity must be unique across complete and elided body routes;
|
||
// an early shared match must not bypass a contradictory second candidate.
|
||
if (clipped && !uniqueFirst) return undefined;
|
||
const unboxed=unboxCompleteNativeAuqBody(screen,call);
|
||
if (unboxed===null) return undefined;
|
||
const matched = capturePlanCountQuestion(unboxed ?? screen, new Set(), 0, false, call);
|
||
// Never use the screen-only fallback. The additional native-only branch
|
||
// proves the observed combined clipping mode from this same owned payload.
|
||
// A visible tab bar keeps its existing route; its parsed body above is only
|
||
// additional ambiguity rejection, never a new route to accept another tab.
|
||
if (!matched?.nativeCall && (!uniqueFirst || clipped?.packetBar)) return undefined;
|
||
const questionIndex = matched?.nativeCall ? matched.nativeQuestionIndex ?? 0 : 0;
|
||
if (questionIndex !== 0) return undefined; // A later tab cannot replace the first question.
|
||
const question = call.questions[questionIndex];
|
||
return question && nativeOptionLabelsAgree(screen, question) ? {question, questionIndex} : undefined;
|
||
}
|
||
|
||
/** Read only a public CLI error panel; never inspect private journal blocks. */
|
||
export function nativeAuqPublicError(screen: string): string | undefined {
|
||
const match = new RegExp(`${API_ERROR_PANEL.source}[\\s\\S]*`, 'i').exec(screen);
|
||
return match?.[0].trim().slice(0, 2000);
|
||
}
|
||
|
||
/** The current viewport only, never terminal history or a model transcript. */
|
||
export function nativeAuqViewport(screen: string): {viewport:string; viewportTruncated:boolean} {
|
||
const limit = 16_384;
|
||
return {viewport:screen.slice(-limit), viewportTruncated:screen.length > limit};
|
||
}
|
||
|
||
export async function captureNativeFirstAuq(opts: NativeAuqCaptureOptions): Promise<NativeAuqCapture> {
|
||
const startedAt = Date.now();
|
||
const deadline = startedAt + NATIVE_AUQ_CAPTURE_MS - CLEANUP_MS;
|
||
const cwd = path.resolve(opts.planDir);
|
||
const model = resolveEvalModel('capture', opts.model);
|
||
const sessionId = randomUUID();
|
||
const runId = opts.runId || process.env.EVALS_RUN_ID || `local-${sessionId}`;
|
||
let session: ClaudePtySession | undefined;
|
||
let ownedRoot: string | undefined;
|
||
let artifactDir: string | undefined;
|
||
let artifactRoot: string | undefined;
|
||
let outcome = 'error';
|
||
let diagnostic: string | undefined;
|
||
let captured: NativeAuqCapture | undefined;
|
||
let pendingPublicCall: NativePlanQuestionCall | undefined;
|
||
let viewport: ReturnType<typeof nativeAuqViewport> | undefined;
|
||
let viewportAt: string | undefined;
|
||
let displayMatched = false;
|
||
let pendingRecorder: ReturnType<typeof pendingQuestionRecorderStatus> | undefined;
|
||
|
||
const observeViewport = async (): Promise<string> => {
|
||
const screen = await session!.currentScreen();
|
||
viewport = nativeAuqViewport(screen);
|
||
viewportAt = new Date().toISOString();
|
||
return screen;
|
||
};
|
||
|
||
const fail = (reason: string, detail?: string): never => {
|
||
outcome = reason;
|
||
diagnostic = detail;
|
||
throw new Error(`${opts.testName}: AUQ capture failed (${reason})${detail ? `: ${detail}` : ''}`);
|
||
};
|
||
try {
|
||
if (!isHermeticEnabled()) fail('hermetic_required');
|
||
if (process.env.GSTACK_EVAL_DIR || process.env.EVALS_RUN_ID || opts.runId) {
|
||
const segment = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 120) || 'run';
|
||
// Resolve the optional store inside the operation budget, before launch.
|
||
artifactRoot = path.resolve(process.env.GSTACK_EVAL_DIR || getProjectEvalDir(), 'native-auq', segment(runId));
|
||
}
|
||
// This invocation owns its session/config. Only this fresh recorder's exact
|
||
// public PreToolUse fields are read; its parent JSONL is never opened.
|
||
ownedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-native-auq-'));
|
||
const configDir = path.join(ownedRoot, '.claude');
|
||
const stateDir = path.join(ownedRoot, 'gstack-home');
|
||
fs.mkdirSync(configDir);
|
||
fs.mkdirSync(stateDir);
|
||
fs.writeFileSync(path.join(configDir, '.claude.json'), JSON.stringify(buildSeedConfig({
|
||
apiKey: process.env.ANTHROPIC_API_KEY ?? process.env.GSTACK_ANTHROPIC_API_KEY,
|
||
trustedDirs: [cwd],
|
||
})), {mode:0o600});
|
||
const skillPath = path.join(cwd, opts.skillName, 'SKILL.md');
|
||
const prompt = `The ONLY skill file you may read is this absolute path: ${skillPath}. Do NOT search for, Glob, find, or read any other SKILL.md anywhere — especially nothing under ~/.claude or /Users.
|
||
|
||
Read ${skillPath} and follow its workflow for this scenario:
|
||
|
||
${opts.scenario}
|
||
|
||
Skip any system-audit / environment-setup / codebase-exploration steps. At the first decision requiring user input, ask the user through the AskUserQuestion tool and wait for their answer.`;
|
||
session = await launchClaudePty({
|
||
// Explicit tool approvals suffice. Bypass mode introduces a separate
|
||
// safety-consent screen in a fresh CLI config; never accept that screen.
|
||
cwd, model, seedSkills: false, permissionMode: 'default',
|
||
observeScreen: true, observeSetupQuestions: true,
|
||
timeoutMs: Math.max(1, deadline - Date.now()),
|
||
// Explicit availability as well as approval: no Bash/Agent/Skill or MCP.
|
||
// The positional prompt starts a real interactive turn, without a boot
|
||
// sleep, hypothetical output request, or synthetic answer submission.
|
||
extraArgs: ['--tools', 'Read,Write,AskUserQuestion', '--allowed-tools', 'Read,Write,AskUserQuestion',
|
||
'--session-id', sessionId, prompt],
|
||
env: {CLAUDE_CONFIG_DIR: configDir, GSTACK_HOME: stateDir, GSTACK_HEADLESS: ''},
|
||
});
|
||
if (session.hermeticConfigDir !== configDir || !session.pendingQuestionFile) fail('missing_observer');
|
||
while (Date.now() < deadline) {
|
||
const screen = await observeViewport();
|
||
const call = readFirstPendingQuestionForDisplay(session.pendingQuestionFile, cwd, configDir, startedAt, sessionId);
|
||
if (call) pendingPublicCall = call;
|
||
pendingRecorder = pendingQuestionRecorderStatus(session.pendingQuestionFile, cwd, configDir);
|
||
const publicError = nativeAuqPublicError(screen);
|
||
if (publicError) fail('error_api', publicError);
|
||
if (session.exited()) fail(`exit_code_${session.exitCode() ?? 'unknown'}`);
|
||
if (pendingRecorder.status === 'invalid') fail('invalid_capture', pendingRecorder.reason);
|
||
const displayed = displayedNativeAuq(screen, call);
|
||
if (displayed && call) {
|
||
captured = {outcome:'question_captured', workflowCompleted:false, source:'pre_tool_use',
|
||
sessionId, toolUseId:call.toolUseId, ...displayed, publicCall:call,
|
||
text:serializeNativeAuq(displayed.question)};
|
||
displayMatched = true;
|
||
outcome = captured.outcome;
|
||
break;
|
||
}
|
||
await Bun.sleep(Math.min(50, Math.max(0, deadline - Date.now())));
|
||
}
|
||
if (!captured) {
|
||
// Refresh the final public frame before shutdown; a startup/permission
|
||
// screen is useful timeout evidence even when no AUQ hook ever fired.
|
||
const screen = await observeViewport();
|
||
const call = readFirstPendingQuestionForDisplay(session.pendingQuestionFile, cwd, configDir, startedAt, sessionId);
|
||
if (call) pendingPublicCall = call;
|
||
pendingRecorder = pendingQuestionRecorderStatus(session.pendingQuestionFile, cwd, configDir);
|
||
const publicError = nativeAuqPublicError(screen);
|
||
if (publicError) fail('error_api', publicError);
|
||
fail('timeout');
|
||
}
|
||
} catch (error) {
|
||
if (outcome === 'error') diagnostic = String(error);
|
||
throw error;
|
||
} finally {
|
||
// Deliberate cutoff after a displayed question is not workflow completion.
|
||
let cleanupError: string | undefined;
|
||
let artifactError: string | undefined;
|
||
try { await session?.close(); } catch (error) { cleanupError = String(error); }
|
||
try { if (ownedRoot) fs.rmSync(ownedRoot, {recursive:true, force:true}); }
|
||
catch (error) { cleanupError = [cleanupError, String(error)].filter(Boolean).join('; '); }
|
||
if (captured && cleanupError) outcome = 'cleanup_error';
|
||
// Persist only supported public fields. No terminal history or transcript
|
||
// is retained, and no partial artifact can supply a later invocation.
|
||
try {
|
||
if (artifactRoot) {
|
||
const segment = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 120) || 'run';
|
||
fs.mkdirSync(artifactRoot, {recursive:true, mode:0o700});
|
||
artifactDir = fs.mkdtempSync(path.join(artifactRoot, `${segment(opts.testName)}-`));
|
||
fs.writeFileSync(path.join(artifactDir, 'capture.json'), JSON.stringify({
|
||
...captured, pendingPublicCall, ...viewport, viewportAt, displayMatched, pendingRecorder,
|
||
outcome, workflowCompleted:false, diagnostic, cleanupError, testName:opts.testName,
|
||
skillName:opts.skillName, model, runId, sessionId, cwd,
|
||
// Native display capture does not expose a terminal billing result.
|
||
billing:'unavailable',
|
||
elapsedMs:Date.now() - startedAt, at:new Date().toISOString(),
|
||
}, null, 2) + '\n', {mode:0o600});
|
||
}
|
||
} catch (error) { artifactError = String(error); }
|
||
if (captured && artifactError) outcome = 'artifact_error';
|
||
console.log(`[AUQ-native ${opts.testName}] outcome=${outcome} workflowCompleted=false`
|
||
+ (artifactDir ? ` artifact=${artifactDir}` : '')
|
||
+ (artifactError ? ` artifactError=${artifactError}` : '')
|
||
+ (cleanupError ? ` cleanupError=${cleanupError}` : ''));
|
||
// Keep the original refusal/crash/timeout primary when diagnostics fail.
|
||
if (captured && (cleanupError || artifactError)) {
|
||
throw new Error(`${opts.testName}: AUQ capture failed (${outcome}): ${cleanupError || artifactError}`);
|
||
}
|
||
}
|
||
return {...captured!, artifactDir};
|
||
}
|