diff --git a/README.md b/README.md index dd3b584..2f3954e 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,13 @@ you — that heuristic matches code and documentation placeholders too often to writes a committable `.claude/promoted.lock.json` and uploads a content-addressed bundle; an unchanged re-promotion uploads nothing. +Keep a prompt out of the bundle with `--exclude-prompt `, repeatable. This exists for the +case where the command that _drives_ promotion lives in the project being promoted: with `HOME` +pointed at that project, its `.claude/commands/` is the prompts directory, so without the flag such +a command ships itself into every bundle. Excluding the entry prompt is refused outright, and an +exclusion that matches nothing warns — an unmatched exclusion would otherwise ship the prompt you +meant to omit. + Dispatch it by adding one field to any prompt leaf: ```json diff --git a/harness/src/promote.ts b/harness/src/promote.ts index c76307f..3cbd06f 100644 --- a/harness/src/promote.ts +++ b/harness/src/promote.ts @@ -11,6 +11,8 @@ export interface PromoteArgs { mode: PromoteMode; sandboxImage: string; deny: string[]; + /** Prompt names to keep out of the bundle; see BuildBundleInput.excludePrompts. */ + excludePrompts: string[]; dryRun: boolean; project?: string; } @@ -22,6 +24,7 @@ export function parsePromoteArgs(argv: string[]): PromoteArgs { // Matches deploy/knative/setup-k8s.sh:30 so the checked-in inventory (Task 14) resolves. sandboxImage: 'ghcr.io/rossoctl/serverless-harness-sandbox:latest', deny: [], + excludePrompts: [], dryRun: false, }; for (let i = 0; i < argv.length; i++) { @@ -36,14 +39,24 @@ export function parsePromoteArgs(argv: string[]): PromoteArgs { i++; } else if (flag === '--sandbox-image') ((args.sandboxImage = value ?? ''), i++); else if (flag === '--deny') (args.deny.push(value ?? ''), i++); - else if (flag === '--project') { + else if (flag === '--exclude-prompt') { + // Unlike --deny, reject an empty value: a silently-empty exclusion matches no prompt and + // ships the one the caller meant to omit. + if (!value) throw new Error('--exclude-prompt requires a prompt name'); + args.excludePrompts.push(value); + i++; + } else if (flag === '--project') { if (!value) throw new Error('--project requires a directory'); args.project = value; i++; } else if (flag === '--dry-run') args.dryRun = true; else throw new Error(`unknown flag: ${flag}`); } - if (!args.entry) throw new Error('usage: sh promote --entry [--mode attended]'); + if (!args.entry) { + throw new Error( + 'usage: promote --entry [--mode attended] [--exclude-prompt ]', + ); + } return args; } @@ -206,6 +219,7 @@ export function promoteInputs(opts: { entry: opts.args.entry, mode: opts.args.mode, userDenyList: opts.args.deny, + excludePrompts: opts.args.excludePrompts, sandboxImage: opts.args.sandboxImage, ...(opts.inventory ? { inventory: opts.inventory } : {}), versions: opts.versions, diff --git a/harness/test/promote.test.ts b/harness/test/promote.test.ts index 71ec3de..8520a68 100644 --- a/harness/test/promote.test.ts +++ b/harness/test/promote.test.ts @@ -31,6 +31,29 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }); }); +describe('parsePromoteArgs --exclude-prompt', () => { + it('collects repeated exclusions in order', () => { + const a = parsePromoteArgs([ + '--entry', + 'go', + '--exclude-prompt', + 'promote', + '--exclude-prompt', + 'scratch', + ]); + expect(a.excludePrompts).toEqual(['promote', 'scratch']); + }); + + it('requires a value, rather than silently excluding the empty name', () => { + // Asserts the specific message, not just any error mentioning the flag: while the flag was + // unknown, `unknown flag: --exclude-prompt` also matched /--exclude-prompt/ and the test + // passed without the feature existing. + expect(() => parsePromoteArgs(['--entry', 'go', '--exclude-prompt'])).toThrow( + /--exclude-prompt requires a prompt name/, + ); + }); +}); + describe('parsePromoteArgs', () => { it('requires an entry', () => { expect(() => parsePromoteArgs([])).toThrow(/--entry/); @@ -45,6 +68,7 @@ describe('parsePromoteArgs', () => { mode: 'unattended', sandboxImage: 'ghcr.io/rossoctl/serverless-harness-sandbox:latest', deny: [], + excludePrompts: [], dryRun: false, }); }); diff --git a/packages/config-bundle/src/build.ts b/packages/config-bundle/src/build.ts index b8ed030..753f1c6 100644 --- a/packages/config-bundle/src/build.ts +++ b/packages/config-bundle/src/build.ts @@ -10,6 +10,7 @@ import { checkMemoryLinks, checkNamespacedPrompts, checkSiblingPaths, + checkExcludedPrompts, } from './preflight.js'; import { resolveSkills } from './resolve.js'; import { blockingSecrets, scanEntriesForSecrets, SecretScanError } from './secret-scan.js'; @@ -119,13 +120,20 @@ export function buildBundle(input: BuildBundleInput): BuildResult { memoryPaths.push(path); } + const excluded = new Set(input.excludePrompts ?? []); + const excludedSeen = new Set(); const promptNames: string[] = []; for (const name of markdownFiles(input.promptsDir)) { + const promptName = name.replace(/\.md$/, ''); + if (excluded.has(promptName)) { + excludedSeen.add(promptName); + continue; + } entries.push({ path: `prompts/${name}`, content: readFileSync(join(input.promptsDir!, name)), }); - promptNames.push(name.replace(/\.md$/, '')); + promptNames.push(promptName); } const fragments = [ @@ -179,9 +187,12 @@ export function buildBundle(input: BuildBundleInput): BuildResult { ...checkSiblingPaths(classification.travels), ...checkMemoryLinks(memoryIndex, memoryNames), ...checkBinaries(binaries, input.inventory), - ...checkEntry(input.entry, promptNames), + // Suppressed when the entry is what was excluded: `entry_excluded` already names the cause, + // and `unknown_entry` would report the symptom of it as a second, independent-looking error. + ...(excluded.has(input.entry) ? [] : checkEntry(input.entry, promptNames)), ...checkInteraction(classification), ...checkNamespacedPrompts(namespacedPromptDirs(input.promptsDir)), + ...checkExcludedPrompts(input.entry, input.excludePrompts ?? [], excludedSeen), ]; const tar = canonicalTar([ diff --git a/packages/config-bundle/src/preflight.ts b/packages/config-bundle/src/preflight.ts index ee30ddd..25b6769 100644 --- a/packages/config-bundle/src/preflight.ts +++ b/packages/config-bundle/src/preflight.ts @@ -219,6 +219,52 @@ export function checkNamespacedPrompts(namespacedDirs: string[]): PreflightFindi })); } +/** + * Account for every `--exclude-prompt`, in both directions. + * + * Excluding the entry is an **error**: the bundle would otherwise build happily and then fail as + * `unknown_entry`, which names the symptom rather than the cause the user typed. + * + * An exclusion matching nothing is a **warning**, because that is what a typo looks like — and a + * typo here fails in the worst way available, by shipping the very prompt the user asked to keep + * out. Both directions are reported so an omission is never silent. + */ +export function checkExcludedPrompts( + entry: string, + requested: string[], + actuallyExcluded: Set, +): PreflightFinding[] { + const findings: PreflightFinding[] = []; + for (const name of requested) { + if (name === entry) { + findings.push({ + severity: 'error', + code: 'entry_excluded', + message: + `--exclude-prompt '${name}' names the entry prompt, so the bundle would carry no entry; ` + + `choose a different entry or drop the exclusion`, + }); + continue; + } + if (actuallyExcluded.has(name)) { + findings.push({ + severity: 'warn', + code: 'prompt_excluded', + message: `prompt '${name}' was excluded by --exclude-prompt and is not in the bundle`, + }); + } else { + findings.push({ + severity: 'warn', + code: 'prompt_exclude_unmatched', + message: + `--exclude-prompt '${name}' matched no prompt, so nothing was excluded for it ` + + `(check the spelling: an unmatched exclusion ships the prompt you meant to omit)`, + }); + } + } + return findings; +} + export function hasErrors(findings: PreflightFinding[]): boolean { return findings.some((f) => f.severity === 'error'); } diff --git a/packages/config-bundle/src/types.ts b/packages/config-bundle/src/types.ts index 13428f2..60cb018 100644 --- a/packages/config-bundle/src/types.ts +++ b/packages/config-bundle/src/types.ts @@ -115,6 +115,14 @@ export interface BuildBundleInput { entry: string; mode: PromoteMode; userDenyList?: string[]; + /** + * Prompt names (no `.md`) to leave out of the bundle. + * + * Exists so a slash command that drives promotion can live in the project it promotes without + * shipping itself: with `HOME` pointed at the project, `promptsDir` IS that project's + * `.claude/commands/`, and every markdown file in it otherwise travels. + */ + excludePrompts?: string[]; sandboxImage: string; /** Commands the sandbox image provides; undefined ⇒ cannot verify. */ inventory?: string[]; diff --git a/packages/config-bundle/test/build.test.ts b/packages/config-bundle/test/build.test.ts index 4c25863..0fee9ff 100644 --- a/packages/config-bundle/test/build.test.ts +++ b/packages/config-bundle/test/build.test.ts @@ -148,3 +148,54 @@ describe('buildBundle', () => { expect(r.findings.some((f) => f.code === 'namespaced_prompt_skipped')).toBe(false); }); }); + +describe('buildBundle --exclude-prompt', () => { + // Local to this suite: adding these to the shared fixture would change promptNames for every + // other test, which is collateral damage rather than coverage. + beforeEach(() => { + write('prompts/promote.md', 'promote this project'); + write('prompts/helper.md', 'a second prompt'); + }); + + it('omits an excluded prompt from the bundle while keeping the others', () => { + const paths = untar(buildBundle({ ...baseInput(), excludePrompts: ['promote'] }).tar).map( + (e) => e.path, + ); + expect(paths).not.toContain('prompts/promote.md'); + expect(paths).toContain('prompts/go.md'); + expect(paths).toContain('prompts/helper.md'); + }); + + it('records each exclusion as a warning, so a silent omission is impossible', () => { + const r = buildBundle({ ...baseInput(), excludePrompts: ['promote'] }); + const f = r.findings.find((x) => x.code === 'prompt_excluded'); + expect(f?.severity).toBe('warn'); + expect(f?.message).toContain('promote'); + }); + + it('warns when an exclusion matches nothing, which is how a typo surfaces', () => { + const r = buildBundle({ ...baseInput(), excludePrompts: ['promotte'] }); + const f = r.findings.find((x) => x.code === 'prompt_exclude_unmatched'); + expect(f?.severity).toBe('warn'); + expect(f?.message).toContain('promotte'); + }); + + it('refuses to exclude the entry prompt instead of failing later as unknown_entry', () => { + const r = buildBundle({ ...baseInput(), entry: 'go', excludePrompts: ['go'] }); + const f = r.findings.find((x) => x.code === 'entry_excluded'); + expect(f?.severity).toBe('error'); + expect(r.findings.some((x) => x.code === 'unknown_entry')).toBe(false); + }); + + it('changes the digest, because the bundle content changed', () => { + const withAll = buildBundle(baseInput()).digest; + const without = buildBundle({ ...baseInput(), excludePrompts: ['promote'] }).digest; + expect(without).not.toBe(withAll); + }); + + it('is a no-op when no exclusions are given', () => { + expect(buildBundle({ ...baseInput(), excludePrompts: [] }).digest).toBe( + buildBundle(baseInput()).digest, + ); + }); +});