Merge remote-tracking branch 'origin/main' into capy/gsta-95-bun-config-windows

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

# Conflicts:
#	CHANGELOG.md
#	bin/gstack-question-log
This commit is contained in:
garrytan committed 2026-10-25 23:58:12 +00:00
commit d77948c6cb
20 files changed
+537 -35

No files matched your search

+1 -1
View File
@@ -29,7 +29,7 @@
"test/helpers/eval-budgets.ts": {"blob":"667d0e09b20fe5d7366f57b77b5fb9b04baa37ef","since":6},
"test/helpers/eval-store.ts": {"blob":"a50ea2c28f508ad9f35ea9353dd2f502ee0dcfc2","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
"test/helpers/hermetic-env.ts": {"blob":"cc072895461dc52ea317d1beff72121342420d30","since":6},
"test/helpers/llm-judge.ts": {"blob":"d0bc6bbbe9f478603d11a1ece95e66c812ad34e6","since":6},
"test/helpers/llm-judge.ts": {"blob":"a3177665b662932ce485ccc976c2b36a1531a0bb","since":6,"nonBehavioral":"callJudge rewrites only the message of a JSON parse failure (names the judge output, stop reason, length and the retained llm-judge-response-parse-error event); it still throws a SyntaxError before any score is parsed, the request is unchanged, and no case's pass or fail changes"},
"test/helpers/paid-test-set.ts": {"blob":"ff9bfe43c43ec2bdd11114f7b8ec7c5736116f75","since":6},
"test/helpers/pty-screen.ts": {"blob":"4f77569f9ce11f9cee9f347216e468cd7c956820","since":6},
"test/helpers/pty/auq.ts": {"blob":"16e5ea2bb5b9f28f44808a1a09024dbd3e6f28c4","since":6},
+83
View File
@@ -223,6 +223,89 @@ export const SIGNAL_MAP: Record<string, Record<string, DimensionDelta[]>> = {
},
};
/**
* Label cues: option key → leading words of UI labels that mean that key.
*
* The AskUserQuestion hook only sees the label the user clicked ("A) Fix it
* now (recommended)"), never a stable key, while SIGNAL_MAP is keyed by the
* registry's option keys (#2623). resolveOptionKey() maps a label back to a
* key when bin/gstack-question-log records a hook event. Every key also
* matches its own words ("fix-now" → "fix now"), so only phrasings beyond
* that need an entry here. Cues are
* lowercase words separated by single spaces and match at the start of the
* normalized label; candidates are limited to the question's own registered
* options, and the longest cue wins.
*/
const OPTION_LABEL_CUES: Record<string, readonly string[]> = {
accept: ['yes', 'approve', 'agree', 'add', 'include', 'apply', 'adopt', 'enable', 'go ahead', 'do it', 'proceed', 'run', 'create', 'merge', 'roll back', 'migrate', 'open', 'promote', 'reorganize', 'polish'],
reject: ['no', 'disagree', 'decline', 'cancel', 'abort', 'dont', 'do not', 'keep current', 'keep as is', 'keep it as is'],
skip: ['no', 'no thanks', 'not now', 'dont', 'do not', 'pass', 'drop', 'ignore', 'leave it', 'ship as is', 'ship it as is'],
defer: ['later', 'fix later', 'add to todos', 'add to todo', 'add a todo', 'todo', 'postpone', 'backlog', 'park', 'track it'],
'fix-now': ['fix', 'fix it', 'fix them', 'fix before shipping'],
'fix-anyway': ['fix', 'fix it'],
investigate: ['dig in', 'look into', 'debug', 'diagnose', 'root cause'],
'ack-and-ship': ['ack', 'acknowledge', 'ship', 'ship anyway', 'ship it'],
'accept-risk': ['accept', 'accept the risk', 'live with it'],
'false-positive': ['not a bug', 'mark false positive', 'mark as false positive', 'mark it false positive'],
'add-test': ['add', 'add tests', 'add the test', 'write', 'write the test', 'test it'],
expand: ['scope expansion'],
selective: ['cherry pick', 'selective expansion'],
hold: ['keep', 'keep scope', 'hold scope'],
reduce: ['cut', 'trim', 'shrink', 'scope down', 'scope reduction'],
revise: ['edit', 'change', 'rework'],
restart: ['start over', 'redo'],
'build-now': ['build', 'build it now', 'include in this pr'],
'keep-local': ['keep'],
'keep-vendored': ['keep'],
'yes-upgrade': ['yes', 'upgrade'],
'always-auto': ['always'],
'not-now': ['later', 'snooze'],
'never-ask': ['never', 'disable'],
};
function normalizeOptionLabel(label: string): string {
return label
.toLowerCase()
.replace(/\s*\(recommended\)\s*$/, '')
.replace(/['\u2019]/g, '')
.replace(/^\s*(?:option\s+)?[a-z0-9]\s*(?:[).:]|-(?=\s))\s+/, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
/**
* Map a chosen UI label to the registered option key of its question.
*
* Returns the key when exactly one of the question's registered options
* matches with the longest cue; undefined for unregistered ids, questions
* without options, unmatched labels and ties, so an unclear answer stays
* unmapped instead of nudging the wrong dimension.
*/
export function resolveOptionKey(question_id: string, choice: string): string | undefined {
const options = (QUESTIONS as Record<string, QuestionDef>)[question_id]?.options as string[] | undefined;
if (!options?.length) return undefined;
if (options.includes(choice)) return choice;
const label = normalizeOptionLabel(choice);
if (!label) return undefined;
let best: string | undefined;
let bestWords = 0;
let tie = false;
for (const key of options) {
for (const cue of [key.replace(/-/g, ' '), ...(OPTION_LABEL_CUES[key] ?? [])]) {
if (label !== cue && !label.startsWith(cue + ' ')) continue;
const words = cue.split(' ').length;
if (words > bestWords) {
best = key;
bestWords = words;
tie = false;
} else if (words === bestWords && best !== key) {
tie = true;
}
}
}
return tie ? undefined : best;
}
/**
* Apply a user choice for a question to the running dimension totals.
*