mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-10 12:03:59 +02:00
4dfdb7cdc2
* feat(auq): add gstack-session-kind + echo SESSION_KIND in preamble Classifies the session as spawned | headless | interactive from env markers (OPENCLAW_SESSION / GSTACK_HEADLESS / CONDUCTOR_* / CLAUDE_CODE_ENTRYPOINT / CI), defaulting to interactive. Echoed once at skill start alongside BRANCH/REPO_MODE so the AskUserQuestion-failure fallback can branch without a shell-out at failure time. Degrade-safe: empty/error => interactive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(auq): prose fallback when AskUserQuestion fails (interactive sessions) On a genuine AUQ failure (tool absent, or present-but-erroring like Conductor's flaky MCP returning '[Tool result missing due to internal error]'): retry once, then branch on SESSION_KIND — spawned auto-chooses, headless BLOCKs, interactive renders a prose decision brief the user answers by typing a letter. The prose fallback MUST surface the triad: a clear ELI10 of the issue, a per-choice Completeness score, and a recommendation+why (one paragraph per choice). Carves out the [plan-tune auto-decide] denial as NOT a failure, and qualifies the former 'tool_use, not prose' assertions so the rule isn't self-contradicting. Tests pin the triad, the SESSION_KIND branch, the OV2 collision guard, the always-loaded guarantee, and a cross-file invariant on the auto-decide prefix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(auq): default GSTACK_HEADLESS=1 in eval/E2E runners Headless harness runs classify as headless (BLOCK on AUQ failure rather than emit a prose question no one reads). SDK runner uses ambient mutation, not the Options.env object, to avoid breaking the SDK auth pipeline. Interactive-path suites opt out by overriding the env per-run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(auq): defensive PostToolUse error-fallback hook (OV3:B) When an AskUserQuestion call returns an error/missing result, this hook injects additionalContext reminding the model to run the prose fallback for the current SESSION_KIND. It does not render prose itself — it guarantees the reminder fires at the moment of failure instead of relying on the model recalling SESSION_KIND. Inert on success and inert if the platform never invokes PostToolUse on tool errors (unverified — could not force the Conductor MCP error in a harness; see the spike doc). The prompt-level fallback covers the case regardless. Decision logic is unit-tested deterministically; registered in setup beside the existing AUQ hooks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(auq): regenerate SKILL.md for all hosts + refresh ship goldens Regenerated from the resolver changes (gen:skill-docs --host all). Refreshes the byte-exact ship golden fixtures (claude/codex/factory). Spec prose tightened so the cross-cutting preamble addition stays under the 5% per-skill parity ceiling (investigate 4.8%) — guard unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(test): kebab testNames for section-loading E2Es to match TOUCHFILES keys The two section-loading E2E tests used display-form testNames ('/ship section-loading', '/plan-ceo-review section-loading') while every other E2E testName and their E2E_TOUCHFILES keys are kebab. The completeness gate does an exact `name in E2E_TOUCHFILES` check, so it failed (pre-existing on main); diff- based selection also couldn't match them. Align to ship-section-loading / plan-ceo-section-loading. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(test): make external-host freshness checks deterministic The parameterized host smoke + --host all freshness tests assumed an external `gen:skill-docs --host all` had run first (it never does in `bun test`), so which host reported STALE varied by sibling-test timing — flaky. Regenerate the gitignored external host dirs in a beforeAll so the --dry-run check is deterministic. It still catches non-deterministic generation (the real bug class for regenerated outputs); the tracked-claude freshness test runs earlier and is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(parity): headroom for AUQ cross-cutting addition on carved document-release Merging main brought the carve of document-release (smaller skeleton); the AUQ prose-fallback adds ~2KB to every skill's always-loaded preamble, landing document-release at ~5.9% over the pre-carve v1.53.0.0 baseline. Add a per-carve maxSizeRatio override (CARVE_GUARDS single source of truth) and bump only this skill to 1.08. All other skills keep the strict 1.05 ceiling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auq): harden error-fallback hook + harness per adversarial review Codex pre-landing review found three real issues: - The PostToolUse fallback hook shared source 'plan-tune-cathedral' with the question-log hook (same event+matcher); gstack-settings-hook replaces the entry, so it would have clobbered plan-tune capture. Give it its own 'auq-error-fallback' source (separate entry, both run); ALREADY_INSTALLED now requires both sources. - isErrorResponse triggered on any string containing 'internal error'/'is_error', so a real answer or a {"is_error": false} payload could fire the fallback after a successful question. Narrow it to the missing-result sentinel + boolean is_error. - The SDK runner mutated process.env.GSTACK_HEADLESS process-wide (leaked headless into later tests). Removed; GSTACK_HEADLESS=1 now lives in the eval package.json scripts, scoped to the invocation and inherited by the SDK child. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v1.57.2.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
251 lines
9.7 KiB
TypeScript
251 lines
9.7 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 * as fs from 'fs';
|
|
import * as path from 'path';
|
|
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');
|
|
});
|
|
});
|
|
|
|
describe('generateAskUserFormat — runtime-failure prose fallback', () => {
|
|
const out = generateAskUserFormat(makeCtx());
|
|
|
|
test('documents the unavailable/failed subsection', () => {
|
|
expect(out).toMatch(/When AskUserQuestion is unavailable or a call fails/i);
|
|
});
|
|
|
|
test('carves out the auto-decide denial as NOT a failure', () => {
|
|
expect(out).toContain('[plan-tune auto-decide]');
|
|
expect(out).toMatch(/NOT a failure/i);
|
|
// and explicitly: do not fall back to prose on an auto-decide denial
|
|
expect(out).toMatch(/Do NOT[\s\S]{0,40}fall back to prose|never prose/i);
|
|
});
|
|
|
|
test('retries the errored call exactly once before degrading', () => {
|
|
expect(out).toMatch(/retry the SAME call \*\*once\*\*|retry the same call.*once/i);
|
|
// idempotency guard against double-prompting
|
|
expect(out).toMatch(/double-prompt|no answer could have surfaced/i);
|
|
});
|
|
|
|
test('branches on SESSION_KIND: spawned / headless / interactive', () => {
|
|
expect(out).toContain('SESSION_KIND');
|
|
expect(out).toMatch(/`spawned`[\s\S]*auto-choose/);
|
|
expect(out).toMatch(/`headless`[\s\S]*BLOCKED/);
|
|
expect(out).toMatch(/`interactive`[\s\S]*prose fallback/);
|
|
// empty/absent SESSION_KIND degrades to interactive
|
|
expect(out).toMatch(/empty\/absent[\s\S]{0,40}interactive/i);
|
|
});
|
|
|
|
// The mandatory triad the user explicitly required for the plain-text output.
|
|
test('prose fallback mandates the triad: issue ELI10', () => {
|
|
expect(out).toMatch(/ELI10 of the issue itself/i);
|
|
});
|
|
|
|
test('prose fallback mandates the triad: per-choice Completeness score', () => {
|
|
expect(out).toMatch(/Completeness scores per choice/i);
|
|
expect(out).toMatch(/Completeness: X\/10.*EACH choice|on EACH choice/i);
|
|
});
|
|
|
|
test('prose fallback mandates the triad: recommendation + (recommended) marker', () => {
|
|
expect(out).toMatch(/Recommendation: <choice> because/);
|
|
expect(out).toMatch(/\(recommended\)`? marker on that choice/);
|
|
});
|
|
|
|
test('prose fallback is one paragraph per choice, not a bare bullet list', () => {
|
|
expect(out).toMatch(/ONE paragraph per choice/i);
|
|
expect(out).toMatch(/never a bare bullet list/i);
|
|
});
|
|
|
|
test('prose fallback tells the user to reply with a letter, then STOP', () => {
|
|
expect(out).toMatch(/reply with a letter/i);
|
|
expect(out).toMatch(/STOP and wait/i);
|
|
});
|
|
|
|
// OV2: the former "tool_use, not prose" assertions must carry the qualifier so the
|
|
// fallback is not self-contradicting. Guards against the instruction collision
|
|
// silently returning on a future edit.
|
|
test('OV2: the Format line qualifies "not prose" with the fallback exception', () => {
|
|
expect(out).toMatch(/must be sent as tool_use, not prose — unless the documented failure fallback/);
|
|
});
|
|
|
|
test('OV2: the self-check "not writing prose" line carries the fallback qualifier', () => {
|
|
expect(out).toMatch(/not writing prose — unless the documented failure fallback applies/);
|
|
});
|
|
});
|
|
|
|
describe('CQ2 — cross-file invariant: auto-decide prefix matches the hook', () => {
|
|
const out = generateAskUserFormat(makeCtx());
|
|
const hookSrc = fs.readFileSync(
|
|
path.resolve(__dirname, '..', 'hosts', 'claude', 'hooks', 'question-preference-hook.ts'),
|
|
'utf-8',
|
|
);
|
|
|
|
test('the hook actually emits the [plan-tune auto-decide] prefix', () => {
|
|
expect(hookSrc).toContain('[plan-tune auto-decide]');
|
|
});
|
|
|
|
test('the resolver references the exact same prefix the hook emits', () => {
|
|
// If a future edit reworded the hook reason, this catches the drift: the prose
|
|
// fallback would stop recognizing the auto-decide denial as not-a-failure.
|
|
const PREFIX = '[plan-tune auto-decide]';
|
|
expect(hookSrc.includes(PREFIX) && out.includes(PREFIX)).toBe(true);
|
|
});
|
|
});
|