From fba917f5bd7e92c0bdb9bfa7ff0690285950d15b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 11 Oct 2026 13:02:54 +0000 Subject: [PATCH] Capture PromptKit selectors before retaining callbacks --- docs/roadmap/remediation.md | 11 ++++- integrations/ckprompt/command.go | 1 + integrations/ckprompt/composition_test.go | 49 +++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index a3d0a9e..6b36bcb 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -3,7 +3,7 @@ Reviewed on 2026-10-11, against committed implementation `cc574915ee17e2d738f27874d2c8857aa1f51a47`. The implementation sprint starts after planning commit `0d23676144ab1ce5047924e98f1ab6a2a9d6aeb6`. -All issues below are open. P1 identifies a functional contract violation; P2 +Completion status and evidence are recorded per issue below. P1 identifies a functional contract violation; P2 identifies architecture or documentation corrections; P3 identifies smaller maintenance corrections. These priorities do not add publication gates. @@ -38,7 +38,14 @@ provider caches, or permanent validation gates to resolve these issues. ## R1: Capture PromptKit selector handles before retaining the suite -**Status:** Open. **Priority:** P1. +**Status:** Completed (2026-10-11). **Priority:** P1. + +**Completed work:** the enabled family copies its selector-handle collection +once, preserving original identities for both generated commands and retained +callbacks. Empty-suite behavior is unchanged. The public consumer regression +replaces a source element after capture and exercises prompt/profile inspection +in two apps reusing the declaration. It failed before the fix and passes after +it; the complete ckprompt suite passes offline with Go 1.26.8. **Evidence:** [ckprompt/command.go](../../integrations/ckprompt/command.go), `Command`, `newDefinitionCommand`, and `prepareDefinitionInput`. diff --git a/integrations/ckprompt/command.go b/integrations/ckprompt/command.go index 8d7b5a2..13181d5 100644 --- a/integrations/ckprompt/command.go +++ b/integrations/ckprompt/command.go @@ -37,6 +37,7 @@ func Command[I, S any](suite Suite[I, S]) commandkit.Command { return command } + suite.Selectors = append([]commandkit.Flag[I](nil), suite.Selectors...) showCommands := make([]commandkit.Command, 0, 2) if suite.InspectPrompt != nil { showCommands = append(showCommands, newDefinitionCommand(suite, "prompt", true)) diff --git a/integrations/ckprompt/composition_test.go b/integrations/ckprompt/composition_test.go index 94a4f6f..5e04e28 100644 --- a/integrations/ckprompt/composition_test.go +++ b/integrations/ckprompt/composition_test.go @@ -17,6 +17,55 @@ import ( type compositionInput struct{ Campaign commandkit.Supplied[string] } type compositionState struct{ token int } +func TestPublicDefinitionCommandsCaptureSelectorHandles(t *testing.T) { + newFlag := func() commandkit.Flag[compositionInput] { + return commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Required: true}, (*string)(nil), + func(value string) (string, error) { return value, nil }, + func(input *compositionInput, value commandkit.Supplied[string]) { input.Campaign = value }) + } + selectors := []commandkit.Flag[compositionInput]{newFlag()} + calls := 0 + checkInput := func(input compositionInput) { + t.Helper() + calls++ + if !input.Campaign.Set || input.Campaign.Value != "spring" { + t.Errorf("captured selector value = %#v", input.Campaign) + } + } + declaration := ckprompt.Command(ckprompt.Suite[compositionInput, struct{}]{ + Selectors: selectors, + InspectPrompt: func(_ context.Context, request ckprompt.PromptRequest, input compositionInput, _ struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) { + checkInput(input) + return commandkit.Acquired[*promptkit.PromptInspection]{Value: &promptkit.PromptInspection{PromptID: request.ID}}, nil + }, + InspectProfile: func(_ context.Context, request ckprompt.ProfileRequest, input compositionInput, _ struct{}) (commandkit.Acquired[*promptkit.ProfileInspection], error) { + checkInput(input) + return commandkit.Acquired[*promptkit.ProfileInspection]{Value: &promptkit.ProfileInspection{ProfileID: request.ID}}, nil + }, + }) + first, err := commandkit.New(commandkit.Identity{Name: "first"}, declaration) + if err != nil { + t.Fatal(err) + } + // A separately constructed lookalike must not replace the captured binding. + selectors[0] = newFlag() + second, err := commandkit.New(commandkit.Identity{Name: "second"}, declaration) + if err != nil { + t.Fatal(err) + } + for _, app := range []*commandkit.App{first, second} { + for _, resource := range []string{"prompt", "profile"} { + outcome := app.Execute(context.Background(), []string{"promptkit", "show", resource, "briefing", "--campaign", "spring"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("%s after selector edit: %#v", resource, outcome) + } + } + } + if calls != 4 { + t.Fatalf("inspection calls = %d, want 4", calls) + } +} + func TestPublicConfigurationAndDefinitionFamiliesRemainIndependent(t *testing.T) { inputs, err := promptkit.FilesystemInputs("composition fixture", fstest.MapFS{ "config/prompts.yml": &fstest.MapFile{Data: []byte(`prompts: