Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions scripts/gen-skill-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface GenerationOptions {
explainLevel?: 'default' | 'terse';
respectDetection?: boolean;
log?: (message: string) => void;
planMode?: boolean;
}

interface RenderOptions {
Expand All @@ -62,6 +63,7 @@ interface RenderOptions {
catalogMode: 'trim' | 'full';
explainLevel: 'default' | 'terse';
gbrainDetected: boolean;
planMode: boolean;
}

export interface GeneratedArtifact {
Expand Down Expand Up @@ -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');
Expand All @@ -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),
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -967,6 +974,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
catalogMode: settings.catalogMode ?? 'trim',
explainLevel: settings.explainLevel ?? 'default',
gbrainDetected: loadGbrainOverride(settings.respectDetection ?? false),
planMode: settings.planMode ?? true,
};
const hosts = settings.host === 'all' ? ALL_HOST_NAMES as Host[] : [settings.host ?? 'claude'];
const log = settings.log ?? (() => {});
Expand Down
1 change: 1 addition & 0 deletions scripts/resolvers/preamble/generate-completion-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
10 changes: 9 additions & 1 deletion scripts/resolvers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}. */
Expand Down
87 changes: 87 additions & 0 deletions test/plan-mode-off-build.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});