Skip to content

feat(promote): add --exclude-prompt, so a promoting command need not promote itself - #218

Merged
pdettori merged 1 commit into
rossoctl:mainfrom
pdettori:feat/promote-exclude-prompt
Sep 3, 2026
Merged

feat(promote): add --exclude-prompt, so a promoting command need not promote itself#218
pdettori merged 1 commit into
rossoctl:mainfrom
pdettori:feat/promote-exclude-prompt

Conversation

@pdettori

@pdettori pdettori commented Sep 3, 2026

Copy link
Copy Markdown
Member

What this does

Adds --exclude-prompt <name> (repeatable) to promote, so a slash command that drives promotion
can live in the project it promotes without shipping itself.

Why it is needed

promote reads prompts from user scope. With HOME pointed at the project being promoted, that
project's .claude/commands/ is the prompts directory — and build.ts adds every markdown file
there unconditionally, with no exclusion hook (userDenyList reaches only the skill classifier).

That is why the /promote command in #217 had to be installed into real user scope, which in turn
means the authoring session loads the whole of a real ~/.claude (56 travelling skills) while the
promoted run gets one — the local/remote parity gap that sandbox-first authoring (spec §11) exists to
close. With this flag, sandbox placement becomes viable:

promote --entry ship-note --project "$PWD" --exclude-prompt promote

Accounted for in both directions, because the interesting failure is silence

case behaviour why
excludes the entry entry_excluded, error and it suppresses the unknown_entry that fires from the same cause — one report naming what you typed, not two naming a symptom
matches nothing prompt_exclude_unmatched, warn that is what a typo looks like, and a typo here fails in the worst available way: it ships the prompt you asked to omit
matched prompt_excluded, warn an omission should never be silent
empty value rejected at parse time '' would match no prompt and quietly ship it

TDD, and two things it caught

Eight tests, each watched failing first — five buildBundle behaviours, three CLI.

  • A test that passed against the unimplemented feature. requires a value asserted
    /--exclude-prompt/, which unknown flag: --exclude-prompt also matches. Tightened to the
    specific message so it can only pass for the right reason. This is exactly what "watch it fail"
    is for.
  • A fixture change with collateral damage. Adding the extra prompts to the shared build fixture
    broke an unrelated test asserting promptNames is ['go']. The fixture is now scoped to the
    exclusion suite rather than the existing expectation loosened — collateral change is not coverage.

Verification

Checked at the CLI, not only in unit tests:

--exclude-prompt promote    → [prompt_excluded] … not in the bundle              exit 0
--exclude-prompt promotte   → [prompt_exclude_unmatched] … check the spelling    exit 0
--exclude-prompt ship-note  → [entry_excluded] … names the entry prompt          exit 2
--exclude-prompt            → --exclude-prompt requires a prompt name            exit 1

make typecheck, make lint (9 hooks), make test-deploy and pnpm -r test all pass — harness
350, knative-server 209, k8s-sandbox 133, experiments 59, relay 16, work-queue 9.

Follow-up, not in this PR

#217 documents real-user-scope placement for /promote and explains it by the self-promotion
problem this flag removes. Once this lands I will update that PR to offer sandbox placement with
--exclude-prompt promote as the parity-preserving option, and keep user scope as the simpler one.
Branched off main rather than stacked on #217 so the flag can be reviewed and merged on its own.

Assisted-By: Claude Code

…promote itself

Closes the gap left by rossoctl#217. `promote` reads prompts from user scope, so with `HOME` pointed at the
project being promoted its `.claude/commands/` IS the prompts directory -- and build.ts adds every
markdown file there unconditionally, with no exclusion hook (`userDenyList` reaches only the skill
classifier). A slash command that drives promotion therefore could not live in the project it
promotes without shipping itself into every bundle as a prompt template. That forced the command in
rossoctl#217 into real user scope, which in turn means the authoring session loads the whole of a real
~/.claude while the promoted run gets one skill -- the local/remote parity gap that sandbox-first
authoring exists to close.

`--exclude-prompt <name>` is repeatable and accounted for in both directions, because the
interesting failure is silence:

- excluding the **entry** is an error (`entry_excluded`), and it suppresses the `unknown_entry` that
  would otherwise fire from the same cause -- one report naming what the user typed, not two naming
  a symptom;
- an exclusion matching **nothing** warns (`prompt_exclude_unmatched`), because that is what a typo
  looks like, and a typo here fails in the worst available way: it ships the prompt you asked to
  keep out;
- an exclusion that **matched** also warns (`prompt_excluded`), so an omission is never silent;
- an empty value is rejected at parse time rather than pushed as `''`, which would match no prompt.

TDD throughout: five buildBundle behaviours and three CLI ones, each watched failing first. Two
things that caught out:

- The `requires a value` test initially passed against the unimplemented flag, because
  `unknown flag: --exclude-prompt` also matches /--exclude-prompt/. Tightened to the specific
  message so it can only pass for the right reason.
- Adding the extra prompts to the shared build fixture broke an unrelated test asserting
  `promptNames` is `['go']`. The fixture is now local to the exclusion suite rather than the
  existing expectation loosened -- collateral change is not coverage.

Verified at the CLI, not only in unit tests: exclusion warns and omits (exit 0), a typo warns
(exit 0), excluding the entry aborts with `entry_excluded` (exit 2), and a missing value fails fast
(exit 1). `make typecheck`, `make lint`, `make test-deploy` and `pnpm -r test` all pass -- harness
350, knative-server 209, k8s-sandbox 133, experiments 59, relay 16, work-queue 9.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
@pdettori

pdettori commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main (which now contains #217) — clean, no conflicts, and the branch is flag-only: the runbook/README/command work that depended on this flag moved out to #219 so this PR stays reviewable as one change.

Ready for your manual merge. I did not use the admin override myself: main requires 1 approval, GitHub blocks self-approval, and bypassing a protection you configured is your call rather than mine.

Order matters: merge this first, then #219 — I will rebase #219 afterwards so its diff drops this commit.

@pdettori
pdettori merged commit e1fc6df into rossoctl:main Sep 3, 2026
12 checks passed
@pdettori
pdettori deleted the feat/promote-exclude-prompt branch September 3, 2026 20:29
pdettori added a commit to pdettori/serverless-harness that referenced this pull request Sep 3, 2026
…-prompt exists

Completes the follow-up rossoctl#218 promised and rossoctl#217 could not take. rossoctl#217 had to install /promote into
real user scope, and justified it with a constraint that no longer holds: that a command living in
the project it promotes would ship itself into every bundle. --exclude-prompt removes that, so the
placement that actually buys local/remote parity becomes available.

The runbook now offers both, and says what each costs:

- **Option A, real user scope.** Simpler. The authoring session loads your whole ~/.claude, so the
  local agent has every skill you own while the promoted run gets the sandbox's one -- which makes
  "it behaved the same locally" weaker evidence than it looks.
- **Option B, in the sandbox**, with Claude Code launched as `HOME=$SANDBOX claude`, so the local
  agent sees exactly what the promoted run will. Costs a re-auth and your own skills for the
  duration. That is the standard dev/prod-parity trade, and parity is this demo's whole claim.

/promote self-excludes rather than making the reader remember: a Context probe reports whether
`.claude/commands/promote.md` exists in the project, and the body adds `--exclude-prompt promote`
only in that case. The condition matters as much as the flag -- passing it unconditionally would trip
the flag's own typo guard (`prompt_exclude_unmatched`), which exists precisely because an unmatched
exclusion ships the prompt you meant to omit.

Measured, and recorded in the runbook because it is the reassuring part: **both options produce the
same bundle.** Option B without the exclusion is 19456 bytes -- the command itself travelling -- and
with it 12288 bytes at sha256:43b8c4c0..., byte-identical to Option A. So every digest quoted in the
walkthrough holds for either placement.

Also reconciles what the rebase exposed: README carried rossoctl#217's claim that a project-local /promote
"would ship itself into every bundle" two paragraphs above rossoctl#218 documenting the flag that prevents
it, plus a doubled lead-in from the two edits landing separately.

The "Notes and limits" fidelity entry is rewritten rather than deleted: Option A's gap is real, it is
now a choice rather than a constraint, and a performer should say which option they ran if asked
whether local matched remote.

Verification: `make lint` (9 hooks), `make test-deploy` (129 checks), the demo at 14 passed /
0 failed against kind, and Option B exercised end to end from a scratch sandbox -- with and without
the self-exclusion -- to produce the two byte counts above.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant