Skip to content
Merged
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`, 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
Expand Down
18 changes: 16 additions & 2 deletions harness/src/promote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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++) {
Expand All @@ -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 <prompt-name> [--mode attended]');
if (!args.entry) {
throw new Error(
'usage: promote --entry <prompt-name> [--mode attended] [--exclude-prompt <name>]',
);
}
return args;
}

Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions harness/test/promote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand All @@ -45,6 +68,7 @@ describe('parsePromoteArgs', () => {
mode: 'unattended',
sandboxImage: 'ghcr.io/rossoctl/serverless-harness-sandbox:latest',
deny: [],
excludePrompts: [],
dryRun: false,
});
});
Expand Down
15 changes: 13 additions & 2 deletions packages/config-bundle/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -119,13 +120,20 @@ export function buildBundle(input: BuildBundleInput): BuildResult {
memoryPaths.push(path);
}

const excluded = new Set(input.excludePrompts ?? []);
const excludedSeen = new Set<string>();
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 = [
Expand Down Expand Up @@ -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([
Expand Down
46 changes: 46 additions & 0 deletions packages/config-bundle/src/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
): 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');
}
Expand Down
8 changes: 8 additions & 0 deletions packages/config-bundle/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
51 changes: 51 additions & 0 deletions packages/config-bundle/test/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
Loading