mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
* 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:
1 parent
86a488b135
commit
5209e9262d
73 files changed
+410
-124
No files matched your search
@@ -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},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in new issue
Block a user