diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 9eb1e1fe2..d551a9545 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -352,31 +352,26 @@ describe('gen-skill-docs', () => { }); // #2001: update_check: false silences the binary but the upgrade-handling - // instruction prose used to ship unconditionally. Every skill that carries - // the runtime config-echo cluster must (a) echo UPDATE_CHECK so the - // instruction layer can read it, and (b) gate the UPGRADE_AVAILABLE / - // JUST_UPGRADED prose on it — the same echo-then-gate convention every other - // flag (PROACTIVE, SKILL_PREFIX, EXPLAIN_LEVEL, QUESTION_TUNING) follows. - test('update_check opt-out gates preamble echo and upgrade-handling prose (issue #2001)', () => { - // The config-echo cluster moved into gstack-skill-start: the flag must be - // read and echoed there so the render's instruction layer can act on it. + // instruction prose used to ship unconditionally. Token-reduction Phase 2 + // made the gate STRUCTURAL: the prose left the renders entirely (absence is + // pinned by test/onboarding-moved-literals.test.ts) and now emits from + // gstack-skill-start's instruction layer ONLY when the update-check binary + // produced output — and that binary silences itself on update_check=false. + // Opted-out installs can never see the prose, by construction. + test('update_check opt-out gates the update binary and upgrade-flow emission (issue #2001)', () => { + // The config-echo cluster lives in gstack-skill-start: the flag is still + // read and echoed as a STATUS line for the model. expect(SKILL_START_SCRIPT, 'script must read update_check config').toContain('_UPDATE_CHECK=$('); expect(SKILL_START_SCRIPT, 'script must echo UPDATE_CHECK').toContain('echo "UPDATE_CHECK: $_UPDATE_CHECK"'); - // Whenever a preamble-carrying render ships the upgrade-handling prose, it - // must gate on the echoed flag — same echo-then-gate convention as - // PROACTIVE/SKILL_PREFIX. (gstack-upgrade itself is out of scope: it has - // no preamble fence and handling UPGRADE_AVAILABLE is its whole job.) - let checked = 0; - for (const skill of CLAUDE_GENERATED_SKILLS) { - const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8'); - if (!content.includes('gstack-skill-start')) continue; - if (!content.includes('UPGRADE_AVAILABLE ')) continue; - checked++; - expect(content, `${skill.dir} upgrade prose must gate on UPDATE_CHECK`) - .toContain('If `UPDATE_CHECK` is `"false"`'); - } - // Guard against the scope filter silently matching nothing. - expect(checked).toBeGreaterThan(0); + // Gate half 1: the update-check binary exits silently when opted out. + const updateCheck = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-update-check'), 'utf-8'); + expect(updateCheck, 'binary must read update_check config').toContain('get update_check'); + expect(updateCheck, 'binary must exit silently on update_check=false') + .toMatch(/if \[ "\$_UC" = "false" \]; then\n\s*exit 0/); + // Gate half 2: the upgrade-flow instruction block emits only when the + // binary emitted something (empty when opted out, cached, or up to date). + expect(SKILL_START_SCRIPT, 'upgrade-flow must be gated on update-check output') + .toMatch(/if \[ -n "\$_UPD" \]; then\n\s*_emit_block upgrade-flow/); }); test('tier 2+ skills contain ELI10 simplification rules (AskUserQuestion format)', () => { @@ -800,7 +795,11 @@ describe('REVIEW_DASHBOARD resolver', () => { } test('plan-ceo-review chaining mentions eng and design reviews', () => { - const content = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8'); + // Carved skill: the chaining prose lives in sections/*.md. (It used to + // pass against the skeleton only because the preamble's routing-injection + // rules incidentally named these skills — that prose moved into + // bin/gstack-skill-start in token-reduction Phase 2.) + const content = readSkillUnion('plan-ceo-review'); expect(content).toContain('/plan-eng-review'); expect(content).toContain('/plan-design-review'); }); @@ -1587,57 +1586,66 @@ describe('parameterized resolver support', () => { // --- Preamble routing injection tests --- -describe('preamble routing injection', () => { - const shipContent = readShipUnion(); +describe('preamble routing injection (bin/gstack-skill-start emission layer)', () => { + // Token-reduction Phase 2: the routing-injection prose left the rendered + // preamble entirely — bin/gstack-skill-start probes, gates, and emits the + // whole flow as a GSTACK_INSTRUCTION block (with the AUQ, the routing rules + // to append, and the decline ack all INSIDE the block). Absence from the + // renders is pinned by test/onboarding-moved-literals.test.ts (tombstone); + // this suite pins the gate structure and the emitted block's content. + const routingBlock = (() => { + const start = SKILL_START_SCRIPT.indexOf('_emit_block routing-injection'); + expect(start).toBeGreaterThan(0); + return SKILL_START_SCRIPT.slice(start, SKILL_START_SCRIPT.indexOf('\nEOI', start)); + })(); test('routing probe checks CLAUDE.md and AGENTS.md (now in gstack-skill-start)', () => { // #2500: the probe iterates CLAUDE.md AND AGENTS.md — non-Claude hosts - // route skills via AGENTS.md, the cross-harness convention file. The bash - // moved into gstack-skill-start; the render acts on the echoed HAS_ROUTING. + // route skills via AGENTS.md, the cross-harness convention file. expect(SKILL_START_SCRIPT).toContain('for _RF in CLAUDE.md AGENTS.md'); expect(SKILL_START_SCRIPT).toContain('grep -q "## Skill routing" "$_RF"'); expect(SKILL_START_SCRIPT).toContain('echo "HAS_ROUTING: $_HAS_ROUTING"'); - expect(shipContent).toContain('HAS_ROUTING'); }); - test('preamble bash reads routing_declined config', () => { - expect(shipContent).toContain('routing_declined'); - expect(shipContent).toContain('ROUTING_DECLINED'); + test('script reads and echoes routing_declined config', () => { + expect(SKILL_START_SCRIPT).toMatch(/_ROUTING_DECLINED=\$\("\$_BIN\/gstack-config" get routing_declined/); + expect(SKILL_START_SCRIPT).toContain('echo "ROUTING_DECLINED: $_ROUTING_DECLINED"'); }); - test('preamble includes routing injection AskUserQuestion', () => { - expect(shipContent).toContain('Add routing rules to CLAUDE.md'); - expect(shipContent).toContain("I'll invoke skills manually"); + test('emitted block carries the routing injection AskUserQuestion', () => { + expect(routingBlock).toContain('Add routing rules to CLAUDE.md'); + expect(routingBlock).toContain("I'll invoke skills manually"); }); - test('routing injection respects prior decline', () => { - expect(shipContent).toContain('ROUTING_DECLINED'); - expect(shipContent).toMatch(/routing_declined.*true/); + test('routing injection respects prior decline (gate + in-block ack)', () => { + expect(SKILL_START_SCRIPT).toContain('[ "$_ROUTING_DECLINED" = "false" ]'); + expect(routingBlock).toMatch(/routing_declined.*true/); + expect(routingBlock).toContain('re-enable with `__BIN__/gstack-config set routing_declined false`'); }); test('routing injection only fires when all conditions met', () => { // Must be: HAS_ROUTING=no AND ROUTING_DECLINED=false AND PROACTIVE_PROMPTED=yes - expect(shipContent).toContain('HAS_ROUTING'); - expect(shipContent).toContain('ROUTING_DECLINED'); - expect(shipContent).toContain('PROACTIVE_PROMPTED'); + expect(SKILL_START_SCRIPT).toContain( + 'if [ "$_HAS_ROUTING" = "no" ] && [ "$_ROUTING_DECLINED" = "false" ] && [ "$_PROACTIVE_PROMPTED" = "yes" ]; then', + ); }); test('routing section content includes key routing rules', () => { - expect(shipContent).toContain('invoke /office-hours'); - expect(shipContent).toContain('invoke /investigate'); - expect(shipContent).toContain('invoke /ship'); - expect(shipContent).toContain('invoke /qa'); + expect(routingBlock).toContain('invoke /office-hours'); + expect(routingBlock).toContain('invoke /investigate'); + expect(routingBlock).toContain('invoke /ship'); + expect(routingBlock).toContain('invoke /qa'); }); test('routing section uses renamed checkpoint skills (not stale /checkpoint)', () => { - expect(shipContent).toContain('invoke /context-save'); - expect(shipContent).toContain('invoke /context-restore'); - expect(shipContent).not.toContain('invoke checkpoint'); + expect(routingBlock).toContain('invoke /context-save'); + expect(routingBlock).toContain('invoke /context-restore'); + expect(routingBlock).not.toContain('invoke checkpoint'); }); test('routing section uses soft "when in doubt" policy, not hard "ALWAYS invoke"', () => { - expect(shipContent).toContain('When in doubt, invoke the skill'); - expect(shipContent).not.toContain('Do NOT answer directly'); + expect(routingBlock).toContain('When in doubt, invoke the skill'); + expect(routingBlock).not.toContain('Do NOT answer directly'); }); }); @@ -1980,8 +1988,16 @@ describe('Codex generation (--host codex)', () => { const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8'); expect(content).toContain('GSTACK_ROOT'); expect(content).toContain('$_ROOT/.agents/skills/gstack'); - expect(content).toContain('$GSTACK_BIN/gstack-config'); - expect(content).toContain('$GSTACK_ROOT/gstack-upgrade/SKILL.md'); + // Phase 1/2: config reads moved into gstack-skill-start — the fence itself + // is the bin asset the preamble must resolve through $GSTACK_BIN, and the + // question-preference runtime call still resolves the same way. + expect(content).toContain('$GSTACK_BIN/gstack-skill-start'); + expect(content).toContain('$GSTACK_BIN/gstack-question-preference'); + // The upgrade-skill doc reference moved into the script's upgrade-flow + // block, resolved $0-relative ($_ROOT_DIR) — host-neutral by construction, + // so the Codex render no longer needs its own copy. + expect(SKILL_START_SCRIPT).toContain('$_ROOT_DIR/gstack-upgrade/SKILL.md'); + expect(SKILL_START_SCRIPT).toContain('_ROOT_DIR=$(dirname "$_BIN")'); expect(content).not.toContain('~/.codex/skills/gstack/bin/gstack-config get telemetry'); }); @@ -2859,13 +2875,19 @@ describe('telemetry', () => { expect(content).toContain('TEL_START'); }); - test('generated SKILL.md contains telemetry opt-in prompt', () => { - const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8'); - expect(content).toContain('.telemetry-prompted'); - expect(content).toContain('Help gstack get better'); - expect(content).toContain('gstack-config set telemetry community'); - expect(content).toContain('gstack-config set telemetry anonymous'); - expect(content).toContain('gstack-config set telemetry off'); + test('telemetry opt-in prompt lives in gstack-skill-start (marker-gated emit)', () => { + // Token-reduction Phase 2: the one-time consent prompt left the renders + // (absence pinned by test/onboarding-moved-literals.test.ts); the script + // gates it on the marker files and emits it as a GSTACK_INSTRUCTION block + // with all three config-set outcomes and the ack INSIDE the block. + expect(SKILL_START_SCRIPT).toContain( + 'if [ "$_TEL_PROMPTED" = "no" ] && [ "$_LAKE_SEEN" = "yes" ]; then', + ); + expect(SKILL_START_SCRIPT).toContain('_emit_block telemetry-prompt'); + expect(SKILL_START_SCRIPT).toContain('gstack-config set telemetry community'); + expect(SKILL_START_SCRIPT).toContain('gstack-config set telemetry anonymous'); + expect(SKILL_START_SCRIPT).toContain('gstack-config set telemetry off'); + expect(SKILL_START_SCRIPT).toContain('touch "$_GH/.telemetry-prompted"'); }); test('generated SKILL.md contains telemetry epilogue (one gstack-skill-end call)', () => { @@ -3436,12 +3458,16 @@ describe('plan-mode-info resolver (handshake-replacement)', () => { ); test('plan-mode-info is wired BEFORE generateUpgradeCheck in preamble', () => { + // Token-reduction Phase 2: generateUpgradeCheck's render output is now + // ONLY the steady-state PROACTIVE-false + SKILL_PREFIX rules (the + // UPGRADE_AVAILABLE prose emits from bin/gstack-skill-start at runtime), + // so those rules are the resolver's order marker. const content = fs.readFileSync( path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8', ); const planModeIdx = content.indexOf(PLAN_MODE_INFO_MARKER); - const upgradeIdx = content.indexOf('UPGRADE_AVAILABLE'); + const upgradeIdx = content.indexOf('If `PROACTIVE` is `"false"`'); expect(planModeIdx).toBeGreaterThan(0); expect(upgradeIdx).toBeGreaterThan(0); expect(planModeIdx).toBeLessThan(upgradeIdx); diff --git a/test/gstack-skill-start.test.ts b/test/gstack-skill-start.test.ts index 8a7c6f75f..6e2876b41 100644 --- a/test/gstack-skill-start.test.ts +++ b/test/gstack-skill-start.test.ts @@ -52,7 +52,17 @@ afterAll(() => { fs.rmSync(tmpGstackHome, { recursive: true, force: true }); }); -/** Extract the `KEY:` literals the rendered prose tells the model to read. */ +/** + * The STATUS-key contract. Post-Phase-2 these split into two consumers: + * keys the rendered prose still interprets directly (SESSION_KIND, + * CONDUCTOR_SESSION, SESSION_ID/TEL_START, EXPLAIN_LEVEL, QUESTION_TUNING, + * PROACTIVE, SKILL_PREFIX, REPO_MODE, CHECKPOINT_*, GSTACK_PLAN_MODE, + * ARTIFACTS_SYNC, ...) and keys the script's OWN emission gates consume + * (ACTIVATED, FIRST_TASK, LAKE_INTRO, TEL_PROMPTED, PROACTIVE_PROMPTED, + * HAS_ROUTING, ROUTING_DECLINED, VENDORED_GSTACK, ...). Both classes stay in + * the emitted contract: the echoes are the debugging surface for the gates, + * and prose in older installed renders may still read them. + */ const PROSE_REFERENCED_KEYS = [ 'SKILL_START_PROTO', 'BRANCH', @@ -139,8 +149,16 @@ describe('gstack-skill-start behavior', () => { cwd: tmpHome, env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: tmpGstackHome }, }); - expect(out).not.toContain('GSTACK_INSTRUCTION_BEGIN'); + // The poisoned marker must be neutralized... + expect(out).not.toContain('GSTACK_INSTRUCTION_BEGIN: evil'); expect(out).toContain('GSTACK-INSTRUCTION-(stripped)'); + // ...while the script's OWN emission layer (Phase 2) stays intact: every + // legitimate block header carries the SESSION_ID this run minted — the + // binding the fence prose enforces (F4/OV4). + const sid = out.match(/^SESSION_ID: (\S+)$/m)?.[1]; + expect(sid).toBeTruthy(); + const headers = out.match(/^GSTACK_INSTRUCTION_BEGIN: .*$/gm) ?? []; + for (const h of headers) expect(h.endsWith(` ${sid}`)).toBe(true); } finally { fs.rmSync(fakeBin, { recursive: true, force: true }); } diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index 4085c706b..65ebcc171 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -31,8 +31,10 @@ export const E2E_TOUCHFILES: Record = { 'hermetic-sentinel': ['test/helpers/hermetic-env.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-hermetic-canary.test.ts', 'lib/conductor-env-shim.ts'], // P4 first-run scaffold (activation lift) — the detection binary end-to-end - // through the real runner, plus the preamble wiring that gates + maps it. - 'first-task-scaffold': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'bin/gstack-first-task-detect', 'scripts/resolvers/preamble/generate-first-run-guidance.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'test/skill-e2e-first-task-scaffold.test.ts', 'test/helpers/session-runner.ts'], + // through the real runner, plus the script wiring that gates + maps it + // (token-reduction Phase 2: generate-first-run-guidance.ts was deleted; the + // gate + token→tip map live in bin/gstack-skill-start's emission layer). + 'first-task-scaffold': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'bin/gstack-first-task-detect', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'test/skill-e2e-first-task-scaffold.test.ts', 'test/helpers/session-runner.ts'], // SKILL.md setup + preamble (depend on ROOT SKILL.md + gen-skill-docs) 'skillmd-setup-discovery': ['SKILL.md', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts'], diff --git a/test/onboarding-moved-literals.test.ts b/test/onboarding-moved-literals.test.ts new file mode 100644 index 000000000..6efb4ec73 --- /dev/null +++ b/test/onboarding-moved-literals.test.ts @@ -0,0 +1,85 @@ +/** + * Onboarding tombstone (token-reduction Phase 2, plan F5). + * + * The one-time onboarding/consent prose moved from the preamble generators + * into bin/gstack-skill-start's instruction-emission layer. This guard pins + * the move in BOTH directions, mustMoveToSection-style: + * - every moved flow's distinctive literal LIVES in the script, and + * - it is ABSENT from every generated SKILL.md (a generator regression that + * re-inlines the text fails here, not in a token bill six releases later). + * + * Literals are chosen to be distinctive to the onboarding prompts (not plain + * English that legitimately appears elsewhere in skill bodies). + */ +import { describe, test, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SCRIPT = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf-8'); + +/** flow id -> distinctive literal that must live in the script only. */ +const MOVED: Record = { + 'lake-intro': 'https://garryslist.org/posts/boil-the-ocean', + 'telemetry-prompt': 'Help gstack get better! (recommended)', + 'proactive-prompt': "Turn it off — I'll type /commands myself", + 'first-run-tip': 'Fresh repo — shape it first with', + 'first-loop-tip': 'gstack pays off when you complete one loop', + 'routing-injection': 'Add routing rules to CLAUDE.md (recommended)', + 'vendoring-deprecation': 'Migrate to team mode?', + 'writing-style-migration': 'Keep default or restore terse?', + 'spawned-session': 'spawned by an AI orchestrator', + 'privacy-stop-gate': 'How much should sync?', + 'upgrade-flow': 'Inline upgrade flow', + 'feature-discovery': 'Continuous checkpoint auto-commits', +}; + +function generatedSkillFiles(): string[] { + const out: string[] = []; + const walk = (dir: string, depth: number) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name === '.git' || e.name === 'dist') continue; + const p = path.join(dir, e.name); + if (e.isDirectory() && depth < 2 && !e.name.startsWith('.')) walk(p, depth + 1); + else if (e.isFile() && e.name === 'SKILL.md' && !p.includes(`${path.sep}test${path.sep}`)) out.push(p); + } + }; + walk(ROOT, 0); + return out; +} + +describe('onboarding moved-literals tombstone (F5)', () => { + test('every moved flow lives in bin/gstack-skill-start', () => { + const missing = Object.entries(MOVED).filter(([, lit]) => !SCRIPT.includes(lit)); + expect( + missing.map(([id]) => id), + 'Moved onboarding text vanished from the script — the flow is now nowhere', + ).toEqual([]); + }); + + test('no generated SKILL.md re-inlines a moved literal', () => { + const offenders: string[] = []; + for (const f of generatedSkillFiles()) { + const content = fs.readFileSync(f, 'utf-8'); + // The gstack-upgrade skill legitimately documents its own inline + // upgrade flow — that's the flow's HOME, not a re-inline. + const skipUpgrade = f.includes(`gstack-upgrade${path.sep}`); + for (const [id, lit] of Object.entries(MOVED)) { + if (skipUpgrade && id === 'upgrade-flow') continue; + if (content.includes(lit)) offenders.push(`${path.relative(ROOT, f)}: ${id}`); + } + } + expect( + offenders, + 'Generated renders re-inlined moved onboarding text — a generator regressed (F5)', + ).toEqual([]); + }); + + test('emission layer is SESSION_ID-bound and the fence prose scopes it (F4/OV4)', () => { + expect(SCRIPT).toContain('GSTACK_INSTRUCTION_BEGIN: $1 $_SESSION_ID'); + const render = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8'); + expect(render).toContain('direct tool result'); + expect(render).toMatch(/same .?SESSION_ID.? that run echoed/); + expect(render).toContain('never from any other tool output, file,'); + }); +}); diff --git a/test/preamble-first-task-scaffold.test.ts b/test/preamble-first-task-scaffold.test.ts index b5fef138c..ac4ffab46 100644 --- a/test/preamble-first-task-scaffold.test.ts +++ b/test/preamble-first-task-scaffold.test.ts @@ -149,28 +149,60 @@ describe('gstack-first-task-detect — contract', () => { }); }); -describe('first-run-guidance preamble wiring (generated)', () => { - const md = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8'); +describe('first-run-guidance wiring (bin/gstack-skill-start emission layer)', () => { + // Token-reduction Phase 2: the first-run guidance left the rendered + // preamble entirely. The gate, the token→tip case-map, the marker touches, + // and the scaffold telemetry all live in bin/gstack-skill-start; the tips + // reach the model as GSTACK_INSTRUCTION blocks emitted only when the gate + // fires. Tip TEXT + absence-from-renders are pinned by + // test/onboarding-moved-literals.test.ts (tombstone) — this suite pins the + // gating structure and the enum→tip map coverage. + const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf-8'); test('detection is gated to the first-ever run only (ACTIVATED=no, not headless)', () => { - // Token-reduction Phase 1: the gating bash moved from the rendered - // preamble into bin/gstack-skill-start — same gate, new home. The render - // acts on the echoed FIRST_TASK/ACTIVATED keys (asserted below). - const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf-8'); expect(script).toContain('if [ "$_ACTIVATED" = "no" ] && [ "$_SESSION_KIND" != "headless" ]'); expect(script).toContain('gstack-first-task-detect'); - expect(md).toContain('FIRST_TASK:'); + // The result is still echoed as a STATUS line (sanitized passthrough). + expect(script).toContain("printf 'FIRST_TASK: %s\\n' \"$_FIRST_TASK\""); }); - test('emits the unified first-run guidance section branching on ACTIVATED', () => { - expect(md).toContain('## First-run guidance (one-time)'); - expect(md).toContain('`ACTIVATED` is `no`'); // P4 scaffold branch - expect(md).toContain('`ACTIVATED` is `yes` AND `FIRST_LOOP_SHOWN` is `no`'); // P3 tip branch + test('emission layer branches on ACTIVATED then FIRST_LOOP_SHOWN', () => { + // P4 scaffold branch (first-ever run) … + expect(script).toContain('if [ "$_ACTIVATED" = "no" ]; then'); + expect(script).toContain('_emit_block first-run-tip'); + // … then the P3 loop tip fires exactly once on a later run. + expect(script).toContain('elif [ "$_FIRST_LOOP_SHOWN" = "no" ]; then'); + expect(script).toContain('_emit_block first-loop-tip'); }); - test('marks activated + logs the scaffold telemetry only on the shown path', () => { - expect(md).toContain('first_task_scaffold_shown'); - expect(md).toContain('touch ~/.gstack/.activated'); - expect(md).toContain('touch ~/.gstack/.first-loop-tip-shown'); + test('token→tip case-map covers every tip-bearing enum bucket (nongit excluded)', () => { + // The detector's whole enum must map to a tip (nongit intentionally maps + // to no tip — no block emits, but activation is still marked). A bucket + // added to the detector without a case arm would silently show nothing. + const caseStart = script.indexOf('case "$_FIRST_TASK" in'); + expect(caseStart).toBeGreaterThan(0); + const caseBody = script.slice(caseStart, script.indexOf('esac', caseStart)); + for (const token of ENUM) { + if (token === 'nongit') continue; + expect(caseBody, `case-map missing enum bucket: ${token}`).toContain(token); + } + expect(caseBody).not.toContain('nongit'); + }); + + test('script marks activated + logs scaffold telemetry AT EMIT (display-only tips)', () => { + // Phase 2 OV6: the model no longer runs these — the script does, when it + // emits the block. Telemetry fires only on the shown path (a tip was + // actually emitted); activation is marked regardless so detection never + // re-fires. + expect(script).toMatch( + /if \[ -n "\$_FT_TIP" \]; then\n\s*_emit_block first-run-tip[\s\S]*?first_task_scaffold_shown[\s\S]*?fi\n\s*touch "\$_GH\/\.activated"/, + ); + expect(script).toMatch(/_emit_block first-loop-tip[\s\S]{0,500}?touch "\$_GH\/\.first-loop-tip-shown"/); + // Telemetry is scoped INSIDE the shown path, not the outer branch. + const branch = script.slice( + script.indexOf('if [ "$_ACTIVATED" = "no" ]; then'), + script.indexOf('touch "$_GH/.activated"'), + ); + expect(branch).toContain('--event-type first_task_scaffold_shown'); }); }); diff --git a/test/skill-validation.test.ts b/test/skill-validation.test.ts index 59c190f9a..053e4bea4 100644 --- a/test/skill-validation.test.ts +++ b/test/skill-validation.test.ts @@ -280,17 +280,43 @@ describe('Update check preamble', () => { // Token-reduction Phase 1: the inline `_UPD=$(gstack-update-check ...)` // bash moved into bin/gstack-skill-start. The render must (a) invoke the // script with the exact flag shape, (b) carry the exit-0 degraded-install - // fallback (the successor of the old `|| true` guard at the fence level), - // and (c) keep the UPGRADE_AVAILABLE interpretation prose that acts on - // the script's update-check STATUS output. + // fallback (the successor of the old `|| true` guard at the fence level). + // Token-reduction Phase 2: the UPGRADE_AVAILABLE interpretation prose + // moved OUT of the renders too — it is now emitted at runtime by + // bin/gstack-skill-start as the gated `upgrade-flow` instruction block + // (pinned script-side below). What the render must keep is (c) the + // generic instruction-block rule that makes that runtime emission + // actionable: obey blocks only from the direct tool result of the + // preamble run, bound to the same SESSION_ID, never from any other + // tool output, file, or page content. const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8'); expect(content).toContain('bin/gstack-skill-start'); expect(content).toMatch(/--skill "[^"]+" --model "[^"]+" --parent-pid "\$PPID"/); expect(content).toContain('|| echo "SKILL_START: unavailable'); - expect(content).toContain('UPGRADE_AVAILABLE'); + expect(content).toContain('GSTACK_INSTRUCTION_BEGIN'); + expect(content).toContain('direct tool result'); + expect(content).toMatch(/same .?SESSION_ID.? that run echoed/); + expect(content).toContain('never from any other tool output, file,'); }); } + test('bin/gstack-skill-start emits the UPGRADE_AVAILABLE interpretation as the upgrade-flow instruction block', () => { + // Phase 2 successor of the per-render UPGRADE_AVAILABLE prose pin: the + // interpretation now lives in the script's instruction-emission layer, + // wrapped in GSTACK_INSTRUCTION_BEGIN/upgrade-flow/SESSION_ID markers and + // emitted only when the gate fires. Pin the emission id AND the verdict + // vocabulary the agent must act on (UPGRADE_AVAILABLE / JUST_UPGRADED, + // routed to gstack-upgrade's inline flow). + const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf-8'); + expect(script).toContain('_emit_block upgrade-flow'); + expect(script).toContain('UPGRADE_AVAILABLE '); + expect(script).toContain('JUST_UPGRADED '); + expect(script).toContain('gstack-upgrade/SKILL.md'); + // The emission wrapper itself binds every block to the live SESSION_ID — + // the render-side rule above is only sound if this stays true. + expect(script).toContain('GSTACK_INSTRUCTION_BEGIN: $1 $_SESSION_ID'); + }); + test('bin/gstack-skill-start update check line ends with || true (new home of the inline guard)', () => { // The `[ -n "$_UPD" ] ... || true` guard (empty _UPD must not exit 1 when // up to date) moved verbatim into the consolidated preamble script. Pin it diff --git a/test/writing-style-resolver.test.ts b/test/writing-style-resolver.test.ts index 25f5d0479..1c75f861e 100644 --- a/test/writing-style-resolver.test.ts +++ b/test/writing-style-resolver.test.ts @@ -108,9 +108,19 @@ describe('Writing Style preamble section', () => { expect(out).toContain('AskUserQuestion Format'); }); - test('tier 2+ preamble migration-prompt block appears', () => { - const out = generatePreamble(makeCtx('claude', 2)); - expect(out).toContain('WRITING_STYLE_PENDING'); - expect(out).toMatch(/writing-style-prompt-pending/); + test('migration prompt lives in gstack-skill-start (marker-gated emit)', () => { + // Token-reduction Phase 2: the one-time V0→V1 migration prompt left the + // rendered preamble; bin/gstack-skill-start computes the gate from the + // marker files and emits it as a GSTACK_INSTRUCTION block, with the ack + // (clear pending + set prompted) carried INSIDE the block for the model + // to run after the interaction. The prompt text itself is pinned by + // test/onboarding-moved-literals.test.ts (tombstone). + expect(SKILL_START_SCRIPT).toContain( + 'if [ -f "$_GH/.writing-style-prompt-pending" ] && [ ! -f "$_GH/.writing-style-prompted" ]', + ); + expect(SKILL_START_SCRIPT).toContain('_emit_block writing-style-migration'); + expect(SKILL_START_SCRIPT).toContain( + 'rm -f "$_GH/.writing-style-prompt-pending" && touch "$_GH/.writing-style-prompted"', + ); }); });