From 51edee991580b779c71911f83ee5667744feaa73 Mon Sep 17 00:00:00 2001 From: xingzihai Date: Fri, 16 Oct 2026 00:43:11 +0000 Subject: [PATCH] feat(gen-skill-docs): add --plan-mode=off to omit the plan-mode preamble (#3168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated skills ship a ~1.8 KB "## Plan Mode Safe Operations" + "## Skill Invocation During Plan Mode" block on every skill, for hosts and users who never enter plan mode. This adds a --plan-mode=off flag to gen-skill-docs that drops that block at gen time. Default builds (planMode undefined or true) keep the block byte-identically. The "PLAN MODE EXCEPTION — ALWAYS RUN" markers inside skill bodies still apply when the flag is off. --- scripts/gen-skill-docs.ts | 12 ++- .../preamble/generate-completion-status.ts | 1 + scripts/resolvers/types.ts | 10 ++- test/plan-mode-off-build.test.ts | 87 +++++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 test/plan-mode-off-build.test.ts diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index e4718441e..56e56b277 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -51,6 +51,7 @@ export interface GenerationOptions { explainLevel?: 'default' | 'terse'; respectDetection?: boolean; log?: (message: string) => void; + planMode?: boolean; } interface RenderOptions { @@ -62,6 +63,7 @@ interface RenderOptions { catalogMode: 'trim' | 'full'; explainLevel: 'default' | 'terse'; gbrainDetected: boolean; + planMode: boolean; } export interface GeneratedArtifact { @@ -129,6 +131,11 @@ function parseGenerationArgs(args: string[]): GenerationOptions { if (explainLevel !== 'default' && explainLevel !== 'terse') { throw new Error(`Unknown explain level: ${explainLevel}. Use 'default' or 'terse'.`); } + const planModeValue = value('--plan-mode'); + if (planModeValue !== undefined && planModeValue !== 'on' && planModeValue !== 'off') { + throw new Error(`Unknown plan mode: ${planModeValue}. Use 'on' or 'off'.`); + } + const planMode = planModeValue === 'off' ? false : true; const outDir = value('--out-dir'); const linkRoot = value('--link-root'); const disabledValue = value('--disabled-skills'); @@ -142,7 +149,7 @@ function parseGenerationArgs(args: string[]): GenerationOptions { // Swap-in callers use --link-root for the FINAL serving path (#2692). // Direct --out-dir callers retain their existing links into the render. return { - host, model, catalogMode, explainLevel, + host, model, catalogMode, explainLevel, planMode, dryRun: args.includes('--dry-run'), respectDetection: args.includes('--respect-detection'), outputRoot: outDir === undefined ? ROOT : path.resolve(outDir), @@ -734,7 +741,7 @@ function buildContext( const interactive = interactiveMatch ? interactiveMatch[1] === 'true' : undefined; return { skillName, tmplPath, benefitsFrom, host, paths: HOST_PATHS[host], - preambleTier, model: options.model ?? getHostConfig(host).defaultModel, interactive, explainLevel: options.explainLevel, installRoot: options.installRoot, + preambleTier, model: options.model ?? getHostConfig(host).defaultModel, interactive, explainLevel: options.explainLevel, installRoot: options.installRoot, planMode: options.planMode, }; } @@ -967,6 +974,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise {}); diff --git a/scripts/resolvers/preamble/generate-completion-status.ts b/scripts/resolvers/preamble/generate-completion-status.ts index ab5b645a3..0848d1b6c 100644 --- a/scripts/resolvers/preamble/generate-completion-status.ts +++ b/scripts/resolvers/preamble/generate-completion-status.ts @@ -20,6 +20,7 @@ import type { TemplateContext } from '../types'; * the full rationale. */ export function generatePlanModeInfo(ctx: TemplateContext): string { + if (ctx.planMode === false) return ''; const entryOrder = ctx.skillName === 'plan-eng-review' ? 'starting from the Scope gate, then follow its Startup sequence' : 'starting from the Scope gate (current target announcement or answered question), then preamble and Step 0'; diff --git a/scripts/resolvers/types.ts b/scripts/resolvers/types.ts index 245f622fd..201349333 100644 --- a/scripts/resolvers/types.ts +++ b/scripts/resolvers/types.ts @@ -116,7 +116,15 @@ export interface TemplateContext { * root a per-install render serves (`gen-skill-docs --install-root`). * null/undefined = the host's default root (committed render, unchanged bytes). */ - installRoot?: string | null; + installRoot?: string | null; + /** + * Plan-mode preamble block. Defaults to true (block ships). + * false (opt-in via `--plan-mode=off`): the "## Plan Mode Safe Operations" + + * "## Skill Invocation During Plan Mode" section is dropped from the + * preamble. "PLAN MODE EXCEPTION — ALWAYS RUN" markers inside skill bodies + * still apply. + */ + planMode?: boolean; } /** Resolver function signature. args is populated for parameterized placeholders like {{INVOKE_SKILL:name}}. */ diff --git a/test/plan-mode-off-build.test.ts b/test/plan-mode-off-build.test.ts new file mode 100644 index 000000000..7217a4abb --- /dev/null +++ b/test/plan-mode-off-build.test.ts @@ -0,0 +1,87 @@ +/** + * Unit tests for the --plan-mode=off flag (#3168). + * + * `--plan-mode=off` drops the "## Plan Mode Safe Operations" + + * "## Skill Invocation During Plan Mode" preamble block from generated + * skills, for hosts and users who never enter plan mode. Default builds + * (planMode undefined or true) keep the block byte-identically. + * + * Pure-function tests against the resolvers — fast, free, no subprocess. + */ + +import { describe, test, expect } from 'bun:test'; +import type { TemplateContext } from '../scripts/resolvers/types'; +import { generatePlanModeInfo, generateCompletionStatus } from '../scripts/resolvers/preamble/generate-completion-status'; +import { generatePreamble } from '../scripts/resolvers/preamble'; + +function makeCtx(planMode?: boolean, tier: number = 4): TemplateContext { + return { + skillName: 'test-skill', + tmplPath: '/tmp/test/SKILL.md.tmpl', + host: 'claude', + paths: { + skillRoot: '~/.claude/skills/gstack', + localSkillRoot: '.claude/skills', + binDir: '~/.claude/skills/gstack/bin', + browseDir: '~/.claude/skills/gstack/browse/dist', + designDir: '~/.claude/skills/gstack/design/dist', + makePdfDir: '~/.claude/skills/gstack/make-pdf/dist', + }, + preambleTier: tier, + planMode, + }; +} + +describe('plan-mode off — generatePlanModeInfo', () => { + test('planMode undefined: emits both plan-mode sections (default behavior)', () => { + const out = generatePlanModeInfo(makeCtx(undefined)); + expect(out).toContain('## Plan Mode Safe Operations'); + expect(out).toContain('## Skill Invocation During Plan Mode'); + }); + + test('planMode true: emits both plan-mode sections', () => { + const out = generatePlanModeInfo(makeCtx(true)); + expect(out).toContain('## Plan Mode Safe Operations'); + expect(out).toContain('## Skill Invocation During Plan Mode'); + }); + + test('planMode false: returns empty string', () => { + expect(generatePlanModeInfo(makeCtx(false))).toBe(''); + }); + + test('planMode false: no plan-mode heading survives', () => { + const out = generatePlanModeInfo(makeCtx(false)); + expect(out).not.toContain('Plan Mode Safe Operations'); + expect(out).not.toContain('Skill Invocation During Plan Mode'); + }); +}); + +describe('plan-mode off — generatePreamble integration', () => { + test('default tier-2 preamble includes the plan-mode block', () => { + const out = generatePreamble(makeCtx(undefined, 2)); + expect(out).toContain('## Plan Mode Safe Operations'); + expect(out).toContain('## Skill Invocation During Plan Mode'); + }); + + test('planMode false tier-2 preamble drops the plan-mode block', () => { + const out = generatePreamble(makeCtx(false, 2)); + expect(out).not.toContain('## Plan Mode Safe Operations'); + expect(out).not.toContain('## Skill Invocation During Plan Mode'); + }); + + test('planMode false preamble is measurably smaller than default', () => { + const defaultLen = generatePreamble(makeCtx(undefined, 2)).length; + const offLen = generatePreamble(makeCtx(false, 2)).length; + // The plan-mode block is ~1.8 KB; assert at least 1 KB saved. + expect(defaultLen - offLen).toBeGreaterThan(1024); + }); + + test('planMode undefined preamble is byte-identical to planMode true', () => { + expect(generatePreamble(makeCtx(undefined, 2))).toBe(generatePreamble(makeCtx(true, 2))); + }); + + test('planMode false: PLAN MODE EXCEPTION markers inside skill bodies still apply (completion status keeps its telemetry exception)', () => { + const out = generateCompletionStatus(makeCtx(false)); + expect(out).toContain('PLAN MODE EXCEPTION — ALWAYS RUN'); + }); +});