carlos / platform Public

settings-5 task 1: draft plan 5's update settings copy for review

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0b7355a1f956c1c60ddc216cfc7d89d4d461bd8c parent 8916558
3 files changed, +214 −6
  • internal/updatewords/export_test.go +122 −0
  • internal/updatewords/words_approved_test.go +15 −6
  • internal/updatewords/words_surfaces.go +77 −0
diff --git a/internal/updatewords/export_test.go b/internal/updatewords/export_test.go
new file mode 100644
index 000000000..5e39f58be
--- /dev/null
+++ b/internal/updatewords/export_test.go
@@ -0,0 +1,122 @@
+package updatewords
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// sectionFor maps a plan-5 draft ID to its review section. An ID that
+// matches no rule is unmapped and must be added here.
+func sectionFor(id string) string {
+ switch {
+ case strings.HasPrefix(id, "settings.cli.apps-set-"):
+ return "CLI: carlos apps set"
+ case strings.HasPrefix(id, "settings.cli.instances-usage-"),
+ strings.HasPrefix(id, "settings.cli.set-"),
+ strings.HasPrefix(id, "settings.cli.update-"),
+ id == "settings.cli.direct-mode",
+ id == "settings.cli.bad-major-flag",
+ id == "settings.cli.none":
+ return "CLI: carlos instances set and update"
+ case id == "settings.cli.not-enforced-reason":
+ return "CLI: carlos instances list"
+ case strings.HasPrefix(id, "settings.page."):
+ return "Console: instance page, Updates section"
+ case strings.HasPrefix(id, "settings.cli.docs-"),
+ id == "settings.cli.roll-note":
+ return "Docs and roll notes"
+ default:
+ return ""
+ }
+}
+
+// TestEverySurfaceDraftHasASection ensures every plan-5 draft is mapped to
+// a section, so a new draft cannot be silently left out of review.
+func TestEverySurfaceDraftHasASection(t *testing.T) {
+ for _, d := range All() {
+ if !strings.HasPrefix(d.ID, "settings.cli.") && !strings.HasPrefix(d.ID, "settings.page.") {
+ continue // not a plan-5 draft
+ }
+ sec := sectionFor(d.ID)
+ if sec == "" {
+ t.Errorf("%s: plan-5 draft is unmapped to a section", d.ID)
+ }
+ }
+}
+
+// TestExportDrafts writes every plan-5 draft to $UPDATEWORDS_EXPORT in JSON
+// format, organized by section for review. It does nothing unless the
+// variable is set.
+func TestExportDrafts(t *testing.T) {
+ out := os.Getenv("UPDATEWORDS_EXPORT")
+ if out == "" {
+ t.Skip("UPDATEWORDS_EXPORT not set")
+ }
+
+ type entry struct {
+ ID string `json:"id"`
+ Section string `json:"section"`
+ Label string `json:"label"`
+ Text string `json:"text"`
+ Context string `json:"context"`
+ }
+
+ // Collect all plan-5 drafts, organized by section then by ID.
+ type sectionGroup struct {
+ name string
+ entries []entry
+ }
+ sectionMap := map[string]*sectionGroup{}
+ sectionOrder := []string{
+ "CLI: carlos apps set",
+ "CLI: carlos instances set and update",
+ "CLI: carlos instances list",
+ "Console: instance page, Updates section",
+ "Docs and roll notes",
+ }
+
+ for _, sec := range sectionOrder {
+ sectionMap[sec] = &sectionGroup{name: sec}
+ }
+
+ for _, d := range All() {
+ if !strings.HasPrefix(d.ID, "settings.cli.") && !strings.HasPrefix(d.ID, "settings.page.") {
+ continue // not a plan-5 draft
+ }
+ sec := sectionFor(d.ID)
+ if sec == "" {
+ t.Fatalf("%s: plan-5 draft is unmapped to a section", d.ID)
+ }
+ parts := strings.Split(d.ID, ".")
+ label := parts[len(parts)-1]
+ sectionMap[sec].entries = append(sectionMap[sec].entries, entry{
+ ID: d.ID,
+ Section: sec,
+ Label: label,
+ Text: d.Text,
+ Context: d.Context,
+ })
+ }
+
+ // Build the final list in section order.
+ var entries []entry
+ for _, sec := range sectionOrder {
+ if group := sectionMap[sec]; group != nil && len(group.entries) > 0 {
+ entries = append(entries, group.entries...)
+ }
+ }
+
+ b, err := json.MarshalIndent(entries, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(out, b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/updatewords/words_approved_test.go b/internal/updatewords/words_approved_test.go
index 627d8725d..3721227d4 100644
--- a/internal/updatewords/words_approved_test.go
+++ b/internal/updatewords/words_approved_test.go
@@ -29,17 +29,20 @@ func loadApproved(t *testing.T, path string) approvedRecord {
return rec
}
-// The copy Paul approved lives in three records: plan 2's (2026-10-09, the
-// authorize strings), plan 3's (2026-10-11, the settings.console.* strings)
-// and plan 4's (the settings.box.* strings; the record is written only after
-// Paul approves them, so this test fails with the file missing until then).
+// The copy Paul approved lives in four records: plan 2's (2026-10-09, the
+// authorize strings), plan 3's (2026-10-11, the settings.console.* strings),
+// plan 4's (the settings.box.* strings), and plan 5's (the settings.cli.*
+// and settings.page.* strings; the record is written only after Paul approves
+// them, so this test fails with the file missing until then).
// Each record must name exactly its own IDs, each text must be what Text
// returns byte for byte, each entry must name the file that holds it, and
-// together the three records must name exactly the IDs of All(), so a new
+// together the four records must name exactly the IDs of All(), so a new
// draft with no approved record fails.
func TestApprovedCopyIsApplied(t *testing.T) {
const console = "settings.console."
const box = "settings.box."
+ const surfaces = "settings.cli."
+ const pagePrefix = "settings.page."
records := []struct {
path string
file string
@@ -47,13 +50,19 @@ func TestApprovedCopyIsApplied(t *testing.T) {
}{
{"../../docs/superpowers/plans/2026-10-07-instance-settings-2-copy.json",
"internal/updatewords/words_authorize.go",
- func(id string) bool { return !strings.HasPrefix(id, console) && !strings.HasPrefix(id, box) }},
+ func(id string) bool {
+ return !strings.HasPrefix(id, console) && !strings.HasPrefix(id, box) &&
+ !strings.HasPrefix(id, surfaces) && !strings.HasPrefix(id, pagePrefix)
+ }},
{"../../docs/superpowers/plans/2026-10-07-instance-settings-3-copy.json",
"internal/updatewords/words_console.go",
func(id string) bool { return strings.HasPrefix(id, console) }},
{"../../docs/superpowers/plans/2026-10-07-instance-settings-4-copy.json",
"internal/updatewords/words_box.go",
func(id string) bool { return strings.HasPrefix(id, box) }},
+ {"../../docs/superpowers/plans/2026-10-07-instance-settings-5-copy.json",
+ "internal/updatewords/words_surfaces.go",
+ func(id string) bool { return strings.HasPrefix(id, surfaces) || strings.HasPrefix(id, pagePrefix) }},
}
var union []string
docs := ""
diff --git a/internal/updatewords/words_surfaces.go b/internal/updatewords/words_surfaces.go
new file mode 100644
index 000000000..e40f6c786
--- /dev/null
+++ b/internal/updatewords/words_surfaces.go
@@ -0,0 +1,77 @@
+package updatewords
+
+// Plan 5's member-facing strings: carlos apps set's update flags, carlos
+// instances set and update, the list's REASON cell, the console instance
+// page's Updates section, and the docs and roll notes.
+//
+// TODO-COPY: these are drafts awaiting Paul's copy review (plan 5 Task 1).
+// None ships until docs/superpowers/plans/2026-10-07-instance-settings-5-copy.json
+// records his approved text and each Text below is replaced with it.
+func init() {
+ add(
+ // carlos apps set
+ Draft{ID: "settings.cli.apps-set-usage", Text: " carlos apps set <app> [--instance-control <settings>] [--default-updates auto|manual] [--default-channel <channel>] [--default-major <n>]\n\n --instance-control <settings> the settings an instance may change itself:\n any of updates, channel, major and pin,\n comma-separated. Empty keeps them all for\n the app.\n --default-updates auto|manual what instances do when they have not\n chosen. Empty restores auto.\n --default-channel <channel> the channel new instances follow.\n --default-major <n> hold instances on stable at this major\n version. Empty clears it.",
+ Context: "Printed under the existing carlos apps set usage (the swap flags), for the update policy flags. Instances cannot act for themselves until instance credentials ship, so instance-control only matters later."},
+ Draft{ID: "settings.cli.apps-set-flag-control", Text: "settings an instance may change itself: any of updates, channel, major, pin, comma-separated; empty keeps them all for the app",
+ Context: "Help text for carlos apps set --instance-control."},
+ Draft{ID: "settings.cli.apps-set-flag-updates", Text: "auto or manual: what instances do when they have not chosen; empty restores auto",
+ Context: "Help text for carlos apps set --default-updates."},
+ Draft{ID: "settings.cli.apps-set-flag-channel", Text: "the channel new instances follow",
+ Context: "Help text for carlos apps set --default-channel. It only reaches instances created afterwards."},
+ Draft{ID: "settings.cli.apps-set-flag-major", Text: "hold instances on stable at this major version; empty clears",
+ Context: "Help text for carlos apps set --default-major."},
+ Draft{ID: "settings.cli.apps-set-policy-done", Text: "%s: instances may change %s; default updates %s, default channel %s, default major %s",
+ Context: "Printed after carlos apps set changes the update policy. App, then the list (or none), then the three defaults (none where unset)."},
+ Draft{ID: "settings.cli.none", Text: "none",
+ Context: "Fills a list or a value that is empty in the lines above and below."},
+ // carlos instances set / update
+ Draft{ID: "settings.cli.instances-usage-lines", Text: " carlos instances set --app <a> <host> [--updates auto|manual]\n [--channel <c>] [--major <n>] [--pin <build>]\n carlos instances update --app <a> <host> [--fast-track]",
+ Context: "Two entries added to the list of verbs printed by carlos instances with no verb, after retry."},
+ Draft{ID: "settings.cli.instances-usage-para", Text: "set changes how one instance takes new builds: --updates auto or manual,\n--channel (the app's pipeline rules apply), --major to stay on one major\nversion of stable, and --pin to run exactly one build. An empty value clears\na setting, so the app's default applies again. update asks the instance's\nbox to install the newest build it considers ready, even with manual\nupdates; --fast-track skips the safety delay and needs an account owner.",
+ Context: "Paragraph added to the carlos instances usage text, after the paragraph about retry."},
+ Draft{ID: "settings.cli.set-usage", Text: "usage: carlos instances set <host> [--app <a>] [--updates auto|manual] [--channel <c>] [--major <n>] [--pin <build>] (an empty value clears)",
+ Context: "Usage line for carlos instances set."},
+ Draft{ID: "settings.cli.set-done", Text: "%s: updates %s, channel %s, major %s, pin %s",
+ Context: "Printed after a change. Host, then each setting as it now stands (none where unset)."},
+ Draft{ID: "settings.cli.set-unchanged", Text: "%s: nothing changed",
+ Context: "Printed when the flags given matched what the instance already had."},
+ Draft{ID: "settings.cli.set-not-enforced", Text: "not every box applies update settings yet, so %s may still follow its channel",
+ Context: "Muted line under the result while some box runs a release from before update settings, shown only when the instance has a non-default setting. %s is the host."},
+ Draft{ID: "settings.cli.update-usage", Text: "usage: carlos instances update <host> [--app <a>] [--fast-track]",
+ Context: "Usage line for carlos instances update."},
+ Draft{ID: "settings.cli.update-done", Text: "%s: update requested (request %d). Its box picks the build and installs it; it runs on the next start",
+ Context: "Printed after Update now from the CLI. Host and request number. The box, not the console, chooses the build."},
+ Draft{ID: "settings.cli.direct-mode", Text: "carlos instances %s works through a console, and this shell is in direct bucket mode (CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Log in instead: carlos auth login --console <url>",
+ Context: "Refusal when carlos instances set or update runs with the bucket variables set. %s is set or update. Worded like the approved carlos apps set refusal."},
+ Draft{ID: "settings.cli.not-enforced-reason", Text: "settings not enforced (box runs an older release)",
+ Context: "REASON cell in carlos instances list for an instance with settings on a box that ignores them."},
+ // the instance page
+ Draft{ID: "settings.page.heading", Text: "Updates", Context: "Heading of a new section on the instance page, under the facts list."},
+ Draft{ID: "settings.page.auto-label", Text: "Install new builds automatically", Context: "Checkbox. On is auto, off is manual: the instance stays on its build until someone installs or pins."},
+ Draft{ID: "settings.page.channel-label", Text: "Channel", Context: "The channel the instance follows, shown read-only in the Updates section."},
+ Draft{ID: "settings.page.channel-hint", Text: "Change it with carlos instances set --channel. The app's pipeline rules apply.", Context: "Small note under the channel. The console page has no channel control, because a move may need passkeys and approvals."},
+ Draft{ID: "settings.page.major-label", Text: "Stay on major version", Context: "Number box, empty for no hold. Only shown for instances on stable."},
+ Draft{ID: "settings.page.pin-label", Text: "Pin to build", Context: "Text box: a build name, empty for no pin. A pinned instance runs exactly that build."},
+ Draft{ID: "settings.page.save", Text: "Save", Context: "Button under the settings."},
+ Draft{ID: "settings.page.update-now", Text: "Update now", Context: "Button. Asks this instance's box to install the channel's newest build it considers ready."},
+ Draft{ID: "settings.page.fast-track", Text: "Skip the safety delay", Context: "Checkbox beside Update now, shown only to account owners."},
+ Draft{ID: "settings.page.status", Text: "Installed %s. Serving %s. Wants %s: %s", Context: "Status line. Installed, serving and desired builds, then the reason sentence from the reasons above."},
+ Draft{ID: "settings.page.kept", Text: "kept by the app", Context: "Small note beside a setting the app's policy keeps for itself (it is not in the app's instance-control list). The setting is shown read-only on the page; members change it with carlos instances set."},
+ Draft{ID: "settings.page.not-enforced", Text: "Settings not enforced: this instance's box runs a release that ignores them, so it follows its channel", Context: "Warning shown when the box does not apply the settings yet and the instance has a non-default setting."},
+ Draft{ID: "settings.page.refused-until-boxes", Text: "Manual updates, major holds and pins can be turned on once every box runs a release that applies them", Context: "Note shown under the form while the console refuses those settings. The controls stay usable: turning auto updates back on and clearing a major hold or a pin always work; only switching these on is refused."},
+ Draft{ID: "settings.page.saved", Text: "Update settings saved.", Context: "Green notice after Save."},
+ Draft{ID: "settings.page.requested", Text: "Update requested. The box installs the build and it runs on the next start.", Context: "Green notice after Update now."},
+ // flags and usage
+ Draft{ID: "settings.cli.bad-major-flag", Text: "--major and --default-major take a whole number, 0 or more, or empty to clear", Context: "Refusal for a major flag that is not a number."},
+ Draft{ID: "settings.cli.set-flag-updates", Text: "auto or manual; empty restores the app's default", Context: "Help text for carlos instances set --updates."},
+ Draft{ID: "settings.cli.set-flag-channel", Text: "the channel to follow (the app's pipeline rules apply)", Context: "Help text for carlos instances set --channel."},
+ Draft{ID: "settings.cli.set-flag-major", Text: "stay on this major version of stable; empty clears", Context: "Help text for carlos instances set --major."},
+ Draft{ID: "settings.cli.set-flag-pin", Text: "run exactly this build; empty clears", Context: "Help text for carlos instances set --pin."},
+ Draft{ID: "settings.cli.update-flag-fast-track", Text: "skip the safety delay (account owners only)", Context: "Help text for carlos instances update --fast-track."},
+ // docs
+ Draft{ID: "settings.cli.docs-instances-set", Text: "Sets how one instance takes new builds: auto or manual updates, the channel, a major version to stay on (stable only), or an exact build to pin. An empty value clears a setting, so the app's default applies again.", Context: "The CLI docs page's carlos instances set section."},
+ Draft{ID: "settings.cli.docs-instances-update", Text: "Asks the instance's box to install the newest build it considers ready, even with manual updates. The box chooses the build, and it runs on the next start. --fast-track skips the safety delay; only account owners can use it.", Context: "The CLI docs page's carlos instances update section."},
+ Draft{ID: "settings.cli.docs-apps-set", Text: "--instance-control names the settings an instance may change itself once instances can act for themselves; --default-updates, --default-channel and --default-major apply wherever an instance has not chosen.", Context: "Added to the CLI docs page's carlos apps set section."},
+ Draft{ID: "settings.cli.roll-note", Text: "**Instance update settings: the roll order.** Roll every box to the release that applies update settings before anyone turns on manual updates, a major hold or a pin: the console refuses those until every box reports it can protect the builds they name from pruning, and lists the boxes it is waiting on. Before rolling any box back below that release, turn retention off for the deployment (`carlos releases retention --off` for each app that has one), because an older housekeeper prunes builds a pin or a held instance still needs. After such a rollback the console shows *settings not enforced* for instances on the older boxes: they follow their channel until the box is rolled forward again.", Context: "The operator note added to README.md's release notes section."},
+ )
+}