mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-31 15:29:39 +02:00
a6fb31726c
* feat(preamble): add "Handling 5+ options — split, never drop" rule Agents repeatedly hit Conductor's 4-option AskUserQuestion cap and silently drop one option to fit, shrinking the user's decision space. This rule names the bug and gives two compliant shapes: batch into ≤4-groups (for coherent alternatives) or split into N sequential per-option calls (for independent scope items, default). Inline preamble subsection is ~15 lines (rule + buckets + pointer). Full reference with worked examples, Hold/dependency semantics, and final-summary validation lives in docs/askuserquestion-split.md. The agent loads the docs file on demand when N>4. Per-option call shape: D<N>.k header, ELI10, Recommendation, kind-note (no completeness score — decision actions, not coverage), Include / Defer / Cut / Hold buckets. Hold stops the chain immediately; the final D<N>.final call validates dependencies and confirms the assembled scope. question_ids: <skill>-split-<option-slug> (kebab-case ASCII, ≤64 chars). Also fixes orphan "12. " prefix on the existing CJK rule. Tier-2+ skills inherit via the existing resolver. SKILL.md regenerated for all 41 affected skills + 3 golden fixtures. Net diff per SKILL.md: ~34 lines (vs ~110 for the full inline version). 6 tests pin the inline contract (4-option cap, buckets, D-numbering, docs pointer, runtime AUTO_DECIDE gate reference, orphan 12 regression). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(question-pref): runtime AUTO_DECIDE carve-out for *-split-* ids Split chains (per-option AskUserQuestion calls emitted by the new "Handling 5+ options" rule) must never be silently auto-approved via /plan-tune preferences. The user's option set is sacred. Layer 1 (mechanism): unique <skill>-split-<option-slug> ids prevent cross-option preference leakage. Layer 2 (this commit): the runtime checker `gstack-question-preference --check` detects any id matching *-split-* and forces ASK_NORMALLY even when never-ask or ask-only-for-one-way preferences exist for that exact id. An explanatory note tells the user their preference was bypassed and why. 7 tests pin the carve-out: no-pref baseline, never-ask override, explanatory note text, ask-only-for-one-way override, always-ask (no note), non-split id containing "split" word (negative case for regex specificity), multi-skill split id formats. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): split-overflow regression for /plan-ceo-review Periodic-tier E2E test that catches the original failure mode the user complained about: 5+ options for ONE decision must split into N sequential AskUserQuestion calls, not drop one to fit Conductor's 4-option cap. Fixture: 5 independent chat-platform integration candidates (Slack/Discord/Teams/Telegram/Mattermost), each carrying its own include/defer/cut decision. Floor = 4 review-phase AUQs (standard [N-1] tolerance band). Pre-fix "drop to 4 + 1 dropped" fails this floor. Wired into test/helpers/touchfiles.ts: tier periodic, depends on plan-ceo-review/**, the new preamble subsection, the question-pref binary (for the carve-out), and the runner helper. touchfiles.test.ts expected count bumped 21 → 22 to account for the new entry. Cost: ~$0.30/run when EVALS_TIER=periodic. Skips silently otherwise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: post-merge regen + rebase size-budget baseline to v1.47.0.0 After merging origin/main (v1.45 → v1.47), three things needed cleanup: 1. spec/SKILL.md (main's new skill) regenerated to include our split-vs-drop preamble subsection — same mechanical regen as the other 41 tier-2+ skills. 2. Three golden ship fixtures refreshed to capture main's GSTACK_PLAN_MODE block + /spec routing entry + jargon-list.json refactor. 3. docs/skills.md — added /spec table row that main's PR (#1698/#1733) shipped without. Pre-existing failure on main; this PR catches and fixes. Also rebased test/skill-size-budget.test.ts from v1.44.1 → v1.47.0.0 baseline. Main's v1.46 (catalog tokens trim) + v1.47 (/spec skill) pushed the v1.44.1 anchor past the 5% ratchet to ×1.059 — pre-existing failure on main. This PR captures a fresh parity-baseline-v1.47.0.0.json and re-anchors the test there. Historical v1.44.1.json and v1.46.0.0.json retained in test/fixtures/ for reference. Our subsection contributes ~0.1% of the post-rebase corpus. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v1.48.0.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
6.0 KiB
TypeScript
164 lines
6.0 KiB
TypeScript
/**
|
|
* AskUserQuestion Format resolver — gate-tier assertions on the generated
|
|
* Pros/Cons format directive block.
|
|
*
|
|
* v1.7.0.0 introduces Pros/Cons decision-brief formatting:
|
|
* - D<N> numbered header
|
|
* - ELI10 paragraph
|
|
* - Stakes-if-we-pick-wrong line
|
|
* - Recommendation line (mandatory, even for neutral posture)
|
|
* - Pros/Cons block with ✅/❌ per option, min 2 pros + 1 con, ≥40 char bullets
|
|
* - Net: synthesis line
|
|
*
|
|
* This test pins the format contract so a future edit to the resolver
|
|
* can't silently drop a rule. If the resolver stops emitting one of
|
|
* these tokens, bun test catches it in milliseconds instead of waiting
|
|
* for the weekly periodic eval to notice.
|
|
*/
|
|
import { describe, test, expect } from 'bun:test';
|
|
import type { TemplateContext } from '../scripts/resolvers/types';
|
|
import { HOST_PATHS } from '../scripts/resolvers/types';
|
|
import { generateAskUserFormat } from '../scripts/resolvers/preamble/generate-ask-user-format';
|
|
|
|
function makeCtx(): TemplateContext {
|
|
return {
|
|
skillName: 'test-skill',
|
|
tmplPath: 'test.tmpl',
|
|
host: 'claude',
|
|
paths: HOST_PATHS.claude,
|
|
preambleTier: 2,
|
|
};
|
|
}
|
|
|
|
describe('generateAskUserFormat — v1.7.0.0 Pros/Cons format', () => {
|
|
const out = generateAskUserFormat(makeCtx());
|
|
|
|
test('includes AskUserQuestion Format header', () => {
|
|
expect(out).toContain('## AskUserQuestion Format');
|
|
});
|
|
|
|
test('documents D-numbered header requirement', () => {
|
|
expect(out).toContain('D<N>');
|
|
expect(out).toMatch(/first question in a skill invocation is `D1`/i);
|
|
});
|
|
|
|
test('documents ELI10 requirement', () => {
|
|
expect(out).toContain('ELI10');
|
|
expect(out).toMatch(/plain English.*16-year-old/);
|
|
});
|
|
|
|
test('documents Stakes-if-we-pick-wrong line', () => {
|
|
expect(out).toContain('Stakes if we pick wrong');
|
|
});
|
|
|
|
test('documents mandatory Recommendation line', () => {
|
|
expect(out).toContain('Recommendation: <choice>');
|
|
expect(out).toMatch(/Recommendation.*ALWAYS|Recommendation \(ALWAYS\)/);
|
|
});
|
|
|
|
test('documents Pros / cons block header', () => {
|
|
expect(out).toContain('Pros / cons:');
|
|
});
|
|
|
|
test('documents ✅ pro markers with min count + min length rule', () => {
|
|
expect(out).toContain('✅');
|
|
expect(out).toMatch(/[Mm]inimum 2 pros/);
|
|
expect(out).toMatch(/40 characters|≥40 chars/);
|
|
});
|
|
|
|
test('documents ❌ con markers with min count rule', () => {
|
|
expect(out).toContain('❌');
|
|
expect(out).toMatch(/1 con per option|minimum.*1 con/i);
|
|
});
|
|
|
|
test('documents hard-stop escape with exact phrase', () => {
|
|
// "No cons — this is a hard-stop choice" may span a line break in the
|
|
// rendered resolver text; match across whitespace collapses.
|
|
expect(out).toMatch(/No cons\s+—\s+this is a\s+hard-stop choice/);
|
|
});
|
|
|
|
test('documents neutral-posture escape preserving (recommended) label', () => {
|
|
// CT1 resolution: (recommended) label STAYS on default option to preserve
|
|
// AUTO_DECIDE contract. Neutrality expressed in prose only.
|
|
expect(out).toMatch(/taste call/i);
|
|
// `s` flag makes . match newlines — the label + STAYS phrase spans a line break
|
|
expect(out).toMatch(/\(recommended\)[\s\S]*STAYS|STAYS[\s\S]*\(recommended\)/);
|
|
expect(out).toMatch(/AUTO_DECIDE/);
|
|
});
|
|
|
|
test('documents Net line for closing synthesis', () => {
|
|
expect(out).toMatch(/^Net:/m);
|
|
expect(out).toMatch(/synthesis|tradeoff/i);
|
|
});
|
|
|
|
test('documents Completeness scoring rules (coverage vs kind)', () => {
|
|
expect(out).toContain('Completeness');
|
|
expect(out).toMatch(/10 = complete/);
|
|
expect(out).toMatch(/options differ in kind, not coverage/);
|
|
});
|
|
|
|
test('documents tool_use mandate (rule 11)', () => {
|
|
expect(out).toMatch(/tool_use/);
|
|
// "not a question" spans a newline in the rendered text
|
|
expect(out).toMatch(/not a[\s\S]*question|not[\s\S]*interactive/i);
|
|
});
|
|
|
|
test('includes self-check before emitting', () => {
|
|
expect(out).toContain('Self-check before emitting');
|
|
expect(out).toMatch(/D<N> header present/);
|
|
expect(out).toMatch(/Net line closes/);
|
|
});
|
|
|
|
test('documents D-numbering as model-level not runtime state', () => {
|
|
// Codex finding #4 caveat: D-numbering is a prompt wish, not a system
|
|
// guarantee. TemplateContext has no counter. This check pins the caveat.
|
|
expect(out).toMatch(/model-level instruction|not a runtime counter|count your own/i);
|
|
});
|
|
|
|
test('per-skill override guidance preserved', () => {
|
|
expect(out).toMatch(/Per-skill instructions may add/);
|
|
});
|
|
});
|
|
|
|
describe('generateAskUserFormat — 5+ option split rule (slim inline + docs pointer)', () => {
|
|
const out = generateAskUserFormat(makeCtx());
|
|
|
|
// 5 highest-signal pins. The full rule lives in
|
|
// docs/askuserquestion-split.md; this contract only checks what the
|
|
// inline subsection MUST surface so the agent can act without
|
|
// reading the docs file for routine 5-option splits.
|
|
|
|
test('forbids dropping options to fit the 4-option cap', () => {
|
|
expect(out).toMatch(/caps every call at \*\*4 options\*\*/);
|
|
expect(out).toMatch(/NEVER\s+drop, merge, or silently defer/);
|
|
});
|
|
|
|
test('names the Include / Defer / Cut / Hold buckets', () => {
|
|
expect(out).toMatch(/A\) Include/);
|
|
expect(out).toMatch(/B\) Defer/);
|
|
expect(out).toMatch(/C\) Cut/);
|
|
expect(out).toMatch(/D\) Hold/);
|
|
});
|
|
|
|
test('specifies D<N>.k child numbering and D<N>.final summary', () => {
|
|
expect(out).toContain('D<N>.k');
|
|
expect(out).toContain('D<N>.final');
|
|
});
|
|
|
|
test('AUTO_DECIDE is gated at runtime, not just collision-resistance', () => {
|
|
expect(out).toContain('bin/gstack-question-preference');
|
|
expect(out).toContain('*-split-*');
|
|
expect(out).toContain('never AUTO_DECIDE-eligible');
|
|
});
|
|
|
|
test('points to docs/askuserquestion-split.md for the full rule', () => {
|
|
expect(out).toContain('docs/askuserquestion-split.md');
|
|
expect(out).toMatch(/Read on demand when N>4/);
|
|
});
|
|
|
|
test('regression: orphan "12." prefix removed from CJK rule', () => {
|
|
expect(out).not.toContain('12. **Non-ASCII');
|
|
expect(out).toContain('**Non-ASCII characters');
|
|
});
|
|
});
|