v1.93.6.0 feat: /plan-tune learns from ad-hoc question ids that carry a signal hint (#3442) (#3868)

* feat(plan-tune): signal hints let unregistered question ids move a dimension (#3442)

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

* docs(plan-tune): document signal hints and hinted_events in the design reference (#3442)

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

* chore: changelog and TODOS

gstack: ok 1.92.79.0 for=ship
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: record the boundaries.ts qid-hint widening as non-behavioral (HARNESS_VERSION 6)

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-02 16:20:02 +00:00
1 parent 86a488b135
commit 5209e9262d
73 files changed
+410 -124

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@
"test/helpers/pty-screen.ts": {"blob":"4f77569f9ce11f9cee9f347216e468cd7c956820","since":6},
"test/helpers/pty/auq.ts": {"blob":"16e5ea2bb5b9f28f44808a1a09024dbd3e6f28c4","since":6},
"test/helpers/pty/binary.ts": {"blob":"404d7515076d5c00804f89e23ea69296312c00e1","since":6},
"test/helpers/pty/boundaries.ts": {"blob":"c5520118da36f96f60a3c9720751bb57a669865c","since":6,"nonBehavioral":"Widens the plan-eng-review first-review severity annotation from [P0-3] to [P0-3|CRITICAL|INFORMATIONAL] (#3349). Only a question carrying the new labels can change verdict, and only the changed plan-eng-review prompt asks for them; every case that runs it owns that generated SKILL.md, which this change also edits, so those cases already start their own series. No other case's input or verdict can change."},
"test/helpers/pty/boundaries.ts": {"blob":"c09b69611fb22ddf093adb2baa83434494e5f8fa","since":6,"nonBehavioral":"Widens the two plan-eng-review boundary predicates' qid regex to also accept the optional ;signal=<key> hint suffix (#3442). Only a question carrying that suffix can change verdict, and only the changed question-tuning resolver asks for it; every case that runs a skill with that resolver owns its generated SKILL.md, which this change also edits, so those cases already start their own series. No other case's input or verdict can change."},
"test/helpers/pty/classify.ts": {"blob":"3f2daa12ff6484f96b8d28d910a50231c4bb3ae8","since":6},
"test/helpers/pty/fake-session.ts": {"blob":"554e064ef259b8444280eddfe9b0db93c66a8202","since":6},
"test/helpers/pty/judge.ts": {"blob":"81c5bb4c48ab928aca2f186a1ade9df7169d7fbe","since":6},
+36
View File
@@ -306,6 +306,42 @@ export function resolveOptionKey(question_id: string, choice: string): string |
return tie ? undefined : best;
}
/**
* Map a choice on an unregistered question to an option key of its hinted
* signal (#3442). Only the exact key or the key's own words count, never
* OPTION_LABEL_CUES: a signal's keys mix several questions' options, so a
* cue such as "accept" could map a "yes" to the opposite delta. The longest
* matching key wins; a tie or no match returns undefined.
*/
export function resolveHintedOptionKey(signal_key: string, choice: string): string | undefined {
if (!Object.hasOwn(SIGNAL_MAP, signal_key)) return undefined;
const keys = Object.keys(SIGNAL_MAP[signal_key]!);
if (keys.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 keys) {
const words = key.replace(/-/g, ' ');
if (label !== words && !label.startsWith(words + ' ')) continue;
const n = words.split(' ').length;
if (n > bestWords) {
best = key;
bestWords = n;
tie = false;
} else if (n === bestWords) {
tie = true;
}
}
return tie ? undefined : best;
}
/** One line per signal key with its option keys, as `--signal-keys` prints it. */
export function signalKeyLines(): string[] {
return Object.entries(SIGNAL_MAP).map(([key, byChoice]) => `${key}: ${Object.keys(byChoice).join(' | ')}`);
}
/**
* Validate that every signal_key referenced in the registry has a matching
* entry in SIGNAL_MAP. Called by tests to catch drift.
+26
View File
@@ -0,0 +1,26 @@
/**
* The <gstack-qid:...> marker a skill puts in an asked question (#3442).
*
* `<gstack-qid:{id}>` names the question; an unregistered id may add a
* signal hint, `<gstack-qid:{id};signal={signal_key}>`, 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.
*/
export const QID_MARKER_RE = /<gstack-qid:([a-z0-9-]{1,64})(?:;signal=([a-z0-9-]{1,64}))?>/i;
export interface QidMarker {
id: string;
signal_key?: string;
stripped: string;
}
export function parseQidMarker(text: string): QidMarker | undefined {
const m = QID_MARKER_RE.exec(text);
if (!m) return undefined;
return {
id: m[1]!.toLowerCase(),
...(m[2] ? { signal_key: m[2].toLowerCase() } : {}),
stripped: text.replace(QID_MARKER_RE, '').trim(),
};
}
+2 -2
View File
@@ -27,9 +27,9 @@ export function generateQuestionTuning(ctx: TemplateContext): string {
const registry = `${ctx.paths.skillRoot}/scripts/question-registry.ts`;
return `## Question Tuning (skip entirely if \`QUESTION_TUNING: false\`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose \`question_id\` from \`${registry}\` or \`{skill}-{slug}\`, then run \`${bin}/gstack-question-preference --check "<id>"\`; for an unregistered id, write the question summary to \`.gstack/tmp/qt.txt\` (file-write tool) and append \`--summary-file .gstack/tmp/qt.txt\` (one-way keyword check). \`AUTO_DECIDE\` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." \`ASK_NORMALLY\` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose \`question_id\` from \`${registry}\` or \`{skill}-{slug}\`, then run \`${bin}/gstack-question-preference --check "<id>"\`; for an unregistered id, write the question summary to \`.gstack/tmp/qt.txt\` (file-write tool) and append \`--summary-file .gstack/tmp/qt.txt\` (one-way keyword check); its \`--signal-keys\` prints an optional signal-key hint. \`AUTO_DECIDE\` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." \`ASK_NORMALLY\` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include \`<gstack-qid:{question_id}>\` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include \`<gstack-qid:{question_id}>\` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the \`(recommended)\` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).