diff --git a/CLAUDE.md b/CLAUDE.md index f1ed5e6..d06da40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,16 +23,18 @@ These landed after phase 10, and all are documented in `design/orchestration.md` - **`scc rtk`, and `scc init --rtk`.** Wires in [RTK](https://github.com/rtk-ai/rtk), the CLI proxy that filters command output: `cargo install` when the binary is missing, plus a splice of RTK's marker-delimited usage block into the entry file. **scc's block wins by default**, replacing whatever sits between the markers; `--keep` is the separate decision. The reason is size, not authorship: `rtk init` (measured on 0.42.4) writes 139 lines / 5140 bytes, scc ships 18 lines / ~900, both stamped `v2`, both saying the same thing — and the entry file is preloaded into every request of the session, so the difference is paid continuously rather than once. Between two blocks of the same version the condensed one is simply better, and leaving the larger one because it got there first is a standing cost. Where that costs something is a block claiming a *newer* version, which is a real downgrade: the run names the version it displaced and points at `--keep`. Opt-in in both places, since the block tells the agent to prefix every command with a binary the machine may not have. `--check` reports without writing and exits 2 when the block is missing. **scc uses RTK's own markers rather than namespacing its own**, and that is load-bearing: `rtk init` writes `` … `` into the project entry file (verified against rtk 0.42.4), so addressing the block by that pair is what makes `rtk init` and `scc rtk` converge on one copy. A namespaced `scc:rtk-instructions` would make each tool blind to the other's block and leave the file carrying both — which is the bug, not the fix. Headroom is the counter-example: it *does* namespace (`headroom:rtk-instructions`), which is why a workspace wired by both can end up with two blocks. scc detects that one and names it in the report (`rtkFile.Foreign`), with `headroom unwrap ` as the fix — reported, never touched, because that block belongs to Headroom. -- **`scc launch `.** Starts the harness this workspace was scaffolded for, from the workspace root, behind [Headroom](https://github.com/headroomlabs-ai/headroom)'s compression proxy (`headroom wrap `), with the workspace's symbol graph brought up to date first. Headroom is the *default* here, which is the deliberate opposite of how RTK is wired: RTK edits a file the user owns and changes how every later command is typed, while Headroom wraps one process for the length of one session. So it degrades instead of failing — missing binary, declined install, unattended run, or a harness Headroom does not wrap all end in the agent starting bare with a warning saying why. `--no-headroom` forces that path, and a missing binary prompts for `uv tool install` (`--yes`/`--no-install` are the unattended answers). This is the one command that does not obey the 0/1/2 exit-code contract; see the convention below. +- **`scc launch `.** Starts the harness this workspace was scaffolded for, from the workspace root, with the workspace's symbol graph brought up to date and its entry file wired for RTK first. What a bare `scc launch` does, and does not, is settled by one question: **does this leave anything behind after the session ends?** The graph and the RTK block do, and both are what the agent needs. [Headroom](https://github.com/headroomlabs-ai/headroom)'s `wrap` does too — MCP registrations in the agent's own config — but what it buys is one session of compression, so it is **opt-in behind `--headroom`**. This is the one command that does not obey the 0/1/2 exit-code contract; see the convention below. + + Everything it does degrades instead of failing: a missing binary, a declined install, an unattended run, or a harness Headroom does not wrap all end in the agent starting anyway with a warning saying why. A missing Headroom prompts for `uv tool install` (`--yes`/`--no-install` are the unattended answers). + + **RTK preflight is on by default, bounded by the block.** `scc launch` makes sure the entry file the agent is about to load actually mentions RTK — the same setup `scc rtk` does standalone — and the *trigger is the block, not the binary*: it runs when an entry file carries none and does nothing at all when they all do. That bound is what lets it be the default. It fires once per workspace rather than once per session, so a wired workspace pays no cargo prompt and no edit at the top of every session. The write is the point: an agent that never read the block never types the prefix, so installing the binary and leaving the file alone would buy nothing. It splices with `keep`, so a block that is already there is left exactly as it is even when scc ships a different one — replacing somebody's block is a real trade-off, and `scc rtk` is where it is made deliberately. `--no-rtk` opts out, and a plan-only run (`--json`/`--dry-run`) reports the splice without performing it. Two things about `wrap` are load-bearing and were originally documented wrong here: - **It does write to disk.** `headroom wrap` registers MCP servers into the agent's own config (`~/.claude.json` and the Codex/opencode equivalents), and those registrations outlive the session that made them — which is why Headroom ships `unwrap` at all. So `scc launch` defaults to **`--headroom-mcp none`**: the only thing a bare `scc launch` wants from Headroom is the compression proxy, so no MCP server is registered on the agent's behalf, not even Headroom's own retrieve tool. The cost of that is real — the proxy's compression markers go unactionable without retrieve — and `--headroom-mcp retrieve` hands that back for anyone who wants the markers expanded; `all` keeps Headroom's defaults, `none` (the default) drops the retrieve tool too. + **It does write to disk.** `headroom wrap` registers MCP servers into the agent's own config (`~/.claude.json` and the Codex/opencode equivalents), and those registrations outlive the session that made them — which is why Headroom ships `unwrap` at all. That is the reason `--headroom` is opt-in at all, and why even then `scc launch` defaults to **`--headroom-mcp none`**: the only thing it wants from Headroom is the compression proxy, so no MCP server is registered on the agent's behalf, not even Headroom's own retrieve tool. The cost of that is real — the proxy's compression markers go unactionable without retrieve — and `--headroom-mcp retrieve` hands that back for anyone who wants the markers expanded; `all` keeps Headroom's defaults, `none` (the default) drops the retrieve tool too. Any `--headroom-*` flag implies `--headroom`, because accepting a flag and then ignoring it is how somebody spends a session believing they configured something. **It also wants the entry file, and scc says no.** `headroom wrap`'s context-tool setup appends RTK guidance to `$PWD/CLAUDE.md` or `$PWD/AGENTS.md` — the same file `scc rtk` splices — behind its own marker pair, ``. Neither marker is a substring of the other, so both tools' idempotency checks pass and both append: an entry file carrying the same RTK instructions twice, in every request of the session. So `scc launch` passes `--no-context-tool` by default. Headroom already gates that injection behind `HEADROOM_RTK`, which makes this belt-and-braces — but only until an environment exports that variable for its own reasons, and `wrap claude` resolves `setup_context_tool = (context_tool or _rtk_opt_in()) and not no_rtk`, so the flag wins over both the env var and `--context-tool`. `--headroom-context-tool` hands it back. - **RTK preflight is opt-in too.** `scc launch` can also make sure the agent it starts has RTK's binary and usage block ready — the same setup `scc rtk` does standalone — but only when asked: `--rtk`. A bare `scc launch` leaves the entry file and PATH untouched on that front, for the same reason MCP and the context tool default off: the only thing Headroom is there for by default is the compression proxy. - **The opt-out flags are discovered, not hardcoded.** `internal/headroom` reads `headroom wrap --help` and picks the spelling that build advertises. This is not defensiveness for its own sake: Headroom renamed this exact control once already (`--no-serena` → `--code-memory none`, and `--no-tokensave` in between), and the harness profiles disagree today — `wrap opencode` still takes `--no-serena` while `wrap claude` and `wrap codex` take `--code-memory`. A flag name compiled into scc turns that kind of release into a launch that dies on `no such option`, which is strictly worse than one unwanted MCP server. A build advertising no opt-out is reported, not overridden. **`--` reaches `wrap`, not only the agent.** `headroom wrap` parses every flag it recognizes out of the tail and forwards only the rest, so a pass-through argument that collides with one of Headroom's — `--verbose`, which both Claude Code and `wrap` define — is silently eaten. `WrapArgs` therefore takes scc's options and the pass-through as separate parameters and puts scc's first, so a colliding argument the user typed lands last and wins. To force something past `wrap` to the agent, use a second terminator: `scc launch claude -- -- -p`. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 61ef5e0..9de1be3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -115,7 +115,7 @@ Commands: init Scaffold a workspace: rules, agents, skills, commands, layout, manifest update Bring the managed files onto this build's templates, after showing the plan rtk Install RTK if missing and put its usage block in the entry file - launch Start a harness in this workspace, through Headroom's compression proxy + launch Start a harness in this workspace, with its symbol graph and RTK block current graph The workspace's symbol graph — build | sync | status | query | explore spec Create and inspect specs — new | list | show | delete | validate plan Create and inspect plans — new | list | delete | validate diff --git a/internal/cli/launch.go b/internal/cli/launch.go index 2deba54..b60406b 100644 --- a/internal/cli/launch.go +++ b/internal/cli/launch.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" "github.com/protonspy/spec-claude-code/internal/assets" @@ -28,25 +29,26 @@ import ( // word where the alternative is remembering to cd first and to spell the wrapper // right. // -// Headroom is the default rather than a flag, which is a deliberate reversal of -// how RTK is wired. The difference is who bears the cost of being wrong: RTK's -// block edits a file the user owns and tells the agent to prefix every command -// with a binary the machine may not have, so it stays opt-in. Headroom wraps one -// process for the length of one session and degrades to starting the agent bare — -// so defaulting to it costs nothing when it is absent and saves context when it -// is there. +// What a bare `scc launch` does, and does not, is settled by one question: does +// this leave anything behind after the session ends? // -// What it does leave behind is MCP registrations in the agent's own config, which -// outlive the session that made them. That is why the default is -// --headroom-mcp=none rather than Headroom's own defaults: the only thing this -// launch wants from Headroom is the compression proxy itself, so no MCP server — -// not even Headroom's own retrieve tool — gets registered into the agent's config -// on its behalf. The cost is that the proxy's compression markers go -// unactionable; `--headroom-mcp retrieve` hands that back for anyone who wants -// the markers expanded again. RTK setup and Headroom's own context-tool are -// opt-in for the same reason: `--rtk` and `--headroom-context-tool` ask for them -// explicitly, rather than a bare `scc launch` doing more than start the agent -// behind the proxy. +// RTK is on by default because what it leaves behind is what the agent needs. +// Its usage block in the entry file is the whole point — an agent that has not +// read it does not prefix anything, so the binary alone buys nothing. And the +// step is bounded by that block: it runs when an entry file has none and does +// nothing when they all do, so it fires once per workspace rather than once per +// session, and never rewrites a block somebody already has. `--no-rtk` opts out. +// +// Headroom is off by default because what *it* leaves behind is not: `headroom +// wrap` registers MCP servers into the agent's own config, and those +// registrations outlive the session that made them — which is why Headroom ships +// `unwrap` at all. Compression for one session is not worth a config edit the +// user did not ask for and will not see. `--headroom` asks for it, and then +// --headroom-mcp=none is still the default so the wrap is the proxy and nothing +// else; `--headroom-mcp retrieve` hands back the tool that makes the proxy's +// compression markers actionable, and `--headroom-context-tool` lets Headroom +// append its own RTK guidance to the entry file — off because `scc rtk` owns +// that block here, behind markers Headroom's do not match. // // The agent's own exit code is passed straight through, which is the one place // scc's 0/1/2 contract does not apply — and it has to be. A launcher that @@ -63,13 +65,13 @@ func runLaunch(args []string) int { fs := flag.NewFlagSet("launch", flag.ContinueOnError) fs.SetOutput(os.Stderr) root := addRoot(fs) - noHeadroom := fs.Bool("no-headroom", false, "start the agent directly, without Headroom's compression proxy") + headroomFlag := fs.Bool("headroom", false, "start the agent behind Headroom's compression proxy (it registers MCP servers that outlive the session)") mcp := fs.String("headroom-mcp", headroom.MCPNone.String(), "which MCP servers Headroom may register: all | retrieve (its own only) | none") contextTool := fs.Bool("headroom-context-tool", false, "let Headroom set up its own CLI context tool (RTK or lean-ctx) and append its guidance to the entry file") noGraph := fs.Bool("no-graph", false, "start the agent without building or refreshing the symbol graph") - rtkFlag := fs.Bool("rtk", false, "also set up RTK's binary and usage block before starting the agent") + noRTK := fs.Bool("no-rtk", false, "start the agent without setting up RTK's binary or its usage block in the entry file") noInstall := fs.Bool("no-install", false, "never install anything; use Headroom, CodeGraph and RTK only if they are already on PATH") yes := fs.Bool("yes", false, "answer the install prompts with yes, for an unattended run") dryRun := fs.Bool("dry-run", false, "print the command this would run, and run nothing") @@ -83,6 +85,15 @@ func runLaunch(args []string) int { render.Err(err.Error()) return ExitError } + // Any --headroom-* flag is itself a request for Headroom. The alternative is + // accepting a flag and then ignoring it, which is how somebody spends a session + // believing they configured something. + wantHeadroom := *headroomFlag + fs.Visit(func(f *flag.Flag) { + if strings.HasPrefix(f.Name, "headroom-") { + wantHeadroom = true + } + }) target, ok := resolveRoot(*root) if !ok { @@ -104,7 +115,7 @@ func runLaunch(args []string) int { // JSON document on stdout cannot both exist. plan := *jsonOut || *dryRun opts := headroomOptions{ - disabled: *noHeadroom, + disabled: !wantHeadroom, noInstall: *noInstall || plan, yes: *yes, quiet: *jsonOut, @@ -134,7 +145,7 @@ func runLaunch(args []string) int { // once their binary is known to be present: guidance naming a command the machine // cannot run is worse than no guidance, because it costs the file its credibility. cmd.RTK = resolveRTK(target, rtkLaunchOptions{ - disabled: !*rtkFlag, + disabled: *noRTK, noInstall: *noInstall, yes: *yes, plan: plan, @@ -369,16 +380,20 @@ type rtkLaunchOptions struct { quiet bool } -// resolveRTK makes sure the agent about to start can actually use the prefix its entry -// file tells it to use. +// resolveRTK makes sure the entry file the agent is about to load actually tells it +// about RTK, and that the binary that guidance names is there. +// +// The trigger is the block, not the binary: this runs when an entry file carries no +// RTK block, and does nothing at all when every one of them already does. That +// bound is what lets it be the default. A workspace wired on a previous launch pays +// nothing at the top of the next session — no read of substance, no cargo prompt, +// and above all no edit — while a fresh workspace gets the one thing that makes RTK +// work at all, since an agent that never read the block never types the prefix. // -// This is the one integration whose setup writes to a file the user owns, and that is -// why it is the one whose prompt has to name both halves. `scc rtk` exists as a -// separate opt-in command precisely because splicing into somebody's CLAUDE.md is not -// a thing to do on the side; offering it here is the same decision put where it is -// actually actionable — at the moment the session that would benefit is starting. -// It stays opt-in here too: --rtk is what asks for it, and a bare `scc launch` -// leaves the entry file untouched. +// It also splices with keep, so a block that is already there is left exactly as it +// is even when scc ships a different one. Replacing somebody's block is a real +// decision with a real trade-off, and `scc rtk` is where it is made deliberately; +// starting an agent is not the moment to make it as a side effect. // // It degrades the way the other two do: RTK is an enhancement, so a missing cargo, a // declined install, or a failed build all end in the agent starting anyway. @@ -388,6 +403,24 @@ func resolveRTK(root string, opts rtkLaunchOptions) *rtkLaunchReport { } report := &rtkLaunchReport{Install: installSkipped, Block: "skipped"} + if rtkBlockPresent(root) { + report.Block = string(mdblock.Present) + p, ok := rtk.Path() + if ok { + report.Install, report.Path, report.Version = installPresent, p, rtk.Version(p) + return report + } + // The file tells the agent to prefix every command with a binary this machine + // does not have. Said once, and not acted on: the block is wired, so this is a + // PATH problem on the user's side rather than something for scc to fix mid-launch. + report.Reason = rtk.Bin + " is not on PATH, and the entry file already tells the agent to use it" + if !opts.quiet && !opts.plan { + render.Warn(report.Reason) + render.Detail(" install it with: " + rtk.InstallCmd()) + } + return report + } + if _, ok := rtk.Path(); !ok { switch { case opts.noInstall || opts.plan: @@ -418,7 +451,12 @@ func resolveRTK(root string, opts rtkLaunchOptions) *rtkLaunchReport { // applyRTK owns both halves and is what `scc rtk` runs, so a launch cannot drift // from the command. noInstall is false here only because the prompt above already // settled it — this call is what actually builds the binary and writes the block. - sub, _ := applyRTK(root, rtkOptions{quiet: opts.quiet}) + // + // check is how a plan-only run reports the splice without performing it: --json + // has to leave stdout clean for the document, and a --dry-run that edited the + // entry file would be the one flag nobody expects to change anything doing + // exactly that. + sub, _ := applyRTK(root, rtkOptions{check: opts.plan, keep: true, quiet: opts.quiet}) report.Install, report.Path, report.Version = sub.Install, sub.Path, sub.Version if sub.Error != "" { report.Reason = sub.Error @@ -429,6 +467,27 @@ func resolveRTK(root string, opts rtkLaunchOptions) *rtkLaunchReport { return report } +// rtkBlockPresent reports whether every entry file in this workspace already carries +// an RTK block — the question `scc launch` decides its whole RTK step on. +// +// Every, not any: a workspace scaffolded for two harnesses has two entry files, and +// one of them being wired says nothing about the other. A workspace with no entry +// file at all answers no, so the run goes down the normal path and reports the file +// as missing rather than quietly calling an absent file done. +func rtkBlockPresent(root string) bool { + entries := entryFiles(root) + if len(entries) == 0 { + return false + } + for _, entry := range entries { + raw, err := os.ReadFile(filepath.Join(root, entry)) + if err != nil || rtk.Markers.Block(string(raw)) == "" { + return false + } + } + return true +} + // graphReport says what happened to the symbol graph on the way to starting the // agent. type graphReport struct { diff --git a/internal/cli/launch_test.go b/internal/cli/launch_test.go index c3eef0b..4de7dc8 100644 --- a/internal/cli/launch_test.go +++ b/internal/cli/launch_test.go @@ -10,6 +10,7 @@ import ( "github.com/protonspy/spec-claude-code/internal/codegraph" "github.com/protonspy/spec-claude-code/internal/paths" + "github.com/protonspy/spec-claude-code/internal/rtk" ) // No test here may reach a real install: `uv tool install` would spend minutes of @@ -123,13 +124,14 @@ func launchJSON(t *testing.T, args ...string) launchCommand { return cmd } -// Headroom on PATH is the whole point of the command: the agent starts behind the -// compression proxy without anybody having to remember the wrapper's spelling. +// --headroom is what asks for the compression proxy, and with Headroom on PATH it +// is the whole point of the command: the agent starts behind the wrapper without +// anybody having to remember its spelling. func TestLaunchWrapsWithHeadroomWhenItIsThere(t *testing.T) { root := initWorkspace(t) isolatedPath(t, "headroom", "claude") - cmd := launchJSON(t, "launch", "--root", root, "--json") + cmd := launchJSON(t, "launch", "--root", root, "--headroom", "--json") if cmd.Bin != "headroom" { t.Errorf("bin = %q, want headroom", cmd.Bin) } @@ -154,7 +156,7 @@ func TestLaunchStartsBareWhenHeadroomIsMissing(t *testing.T) { root := initWorkspace(t) isolatedPath(t, "claude") - cmd := launchJSON(t, "launch", "--root", root, "--json") + cmd := launchJSON(t, "launch", "--root", root, "--headroom", "--json") if cmd.Bin != paths.Claude.Bin { t.Errorf("bin = %q, want %q", cmd.Bin, paths.Claude.Bin) } @@ -171,13 +173,17 @@ func TestLaunchStartsBareWhenHeadroomIsMissing(t *testing.T) { } } -// --no-headroom is the explicit "just start the agent", and it must not even ask -// the question — no PATH lookup, no report, no prompt. -func TestLaunchNoHeadroomSkipsItEntirely(t *testing.T) { +// A bare launch does not touch Headroom at all — not even to look for it. `headroom +// wrap` registers MCP servers into the agent's own config and those registrations +// outlive the session, which is a price for one session's compression that nobody +// asked to pay. The report has to leave the field out entirely rather than say +// "skipped": a launch that never considered Headroom and one that considered it and +// could not use it are different answers. +func TestLaunchLeavesHeadroomAloneByDefault(t *testing.T) { root := initWorkspace(t) isolatedPath(t, "headroom", "claude") - cmd := launchJSON(t, "launch", "--root", root, "--no-headroom", "--json") + cmd := launchJSON(t, "launch", "--root", root, "--json") if cmd.Bin != paths.Claude.Bin { t.Errorf("bin = %q, want %q", cmd.Bin, paths.Claude.Bin) } @@ -187,15 +193,15 @@ func TestLaunchNoHeadroomSkipsItEntirely(t *testing.T) { } // Headroom's wrap registers MCP servers into the user's agent config, and those -// registrations outlive the session that made them. A bare `scc launch` wants -// nothing from Headroom but the compression proxy, so by default no MCP server -// gets registered at all — not the code-memory server (CodeGraph's job in this -// workspace) and not even Headroom's own retrieve tool. +// registrations outlive the session that made them. `--headroom` asks for the +// compression proxy and nothing else, so even then no MCP server gets registered — +// not the code-memory server (CodeGraph's job in this workspace) and not even +// Headroom's own retrieve tool. func TestLaunchTurnsHeadroomsMCPOffByDefault(t *testing.T) { root := initWorkspace(t) isolatedPath(t, "headroom", "claude") - cmd := launchJSON(t, "launch", "--root", root, "--json") + cmd := launchJSON(t, "launch", "--root", root, "--headroom", "--json") if cmd.Headroom == nil { t.Fatal("no headroom report") } @@ -216,7 +222,7 @@ func TestLaunchDeclinesHeadroomsContextToolByDefault(t *testing.T) { root := initWorkspace(t) isolatedPath(t, "headroom", "claude") - cmd := launchJSON(t, "launch", "--root", root, "--json") + cmd := launchJSON(t, "launch", "--root", root, "--headroom", "--json") if cmd.Headroom.ContextTool { t.Error("context_tool = true, want Headroom's own setup declined") } @@ -263,13 +269,13 @@ func TestLaunchPassesArgumentsThroughToTheAgent(t *testing.T) { root := initWorkspace(t) isolatedPath(t, "headroom", "claude") - cmd := launchJSON(t, "launch", "claude", "--json", "--root", root, "--", "--resume", "--model", "opus") + cmd := launchJSON(t, "launch", "claude", "--json", "--headroom", "--root", root, "--", "--resume", "--model", "opus") if got, want := strings.Join(cmd.Args, " "), "wrap claude --code-memory none --no-mcp --no-context-tool --resume --model opus"; got != want { t.Errorf("args = %q, want %q", got, want) } // And with no Headroom in front, the same arguments reach the binary directly. - cmd = launchJSON(t, "launch", "claude", "--json", "--no-headroom", "--root", root, "--", "--resume") + cmd = launchJSON(t, "launch", "claude", "--json", "--root", root, "--", "--resume") if got, want := strings.Join(cmd.Args, " "), "--resume"; got != want { t.Errorf("bare args = %q, want %q", got, want) } @@ -285,7 +291,7 @@ func TestLaunchBuildsTheGraphAndThenRefreshesIt(t *testing.T) { log := recordingStub(t, dir, "codegraph") withLaunchExec(t, 0) - if _, stderr, code := run(t, "launch", "--root", root, "--no-headroom"); code != ExitOK { + if _, stderr, code := run(t, "launch", "--root", root); code != ExitOK { t.Fatalf("exit = %d (stderr: %s)", code, stderr) } if got := recorded(t, log); !strings.Contains(got, "init") { @@ -299,7 +305,7 @@ func TestLaunchBuildsTheGraphAndThenRefreshesIt(t *testing.T) { if err := os.Remove(log); err != nil { t.Fatalf("Remove: %v", err) } - if _, stderr, code := run(t, "launch", "--root", root, "--no-headroom"); code != ExitOK { + if _, stderr, code := run(t, "launch", "--root", root); code != ExitOK { t.Fatalf("exit = %d (stderr: %s)", code, stderr) } got := recorded(t, log) @@ -321,7 +327,7 @@ func TestLaunchWritesTheCodeGraphBlockIntoTheEntryFile(t *testing.T) { recordingStub(t, dir, "codegraph") withLaunchExec(t, 0) - if _, stderr, code := run(t, "launch", "--root", root, "--no-headroom"); code != ExitOK { + if _, stderr, code := run(t, "launch", "--root", root); code != ExitOK { t.Fatalf("exit = %d (stderr: %s)", code, stderr) } entry := filepath.Join(root, paths.Claude.EntryFile) @@ -334,7 +340,7 @@ func TestLaunchWritesTheCodeGraphBlockIntoTheEntryFile(t *testing.T) { } // And it is a splice, not an append: a second launch leaves one copy. - if _, stderr, code := run(t, "launch", "--root", root, "--no-headroom"); code != ExitOK { + if _, stderr, code := run(t, "launch", "--root", root); code != ExitOK { t.Fatalf("second launch: exit = %d (stderr: %s)", code, stderr) } second, err := os.ReadFile(entry) @@ -358,7 +364,7 @@ func TestLaunchWritesNoCodeGraphBlockWithoutTheBinary(t *testing.T) { withoutTerminal(t) withLaunchExec(t, 0) - if _, stderr, code := run(t, "launch", "--root", root, "--no-headroom"); code != ExitOK { + if _, stderr, code := run(t, "launch", "--root", root); code != ExitOK { t.Fatalf("exit = %d (stderr: %s)", code, stderr) } entry, err := os.ReadFile(filepath.Join(root, paths.Claude.EntryFile)) @@ -378,7 +384,7 @@ func TestLaunchPlanOnlyWritesNoCodeGraphBlock(t *testing.T) { recordingStub(t, dir, "codegraph") for _, flag := range []string{"--json", "--dry-run"} { - if _, stderr, code := run(t, "launch", "--root", root, "--no-headroom", flag); code != ExitOK { + if _, stderr, code := run(t, "launch", "--root", root, flag); code != ExitOK { t.Fatalf("%s: exit = %d (stderr: %s)", flag, code, stderr) } entry, err := os.ReadFile(filepath.Join(root, paths.Claude.EntryFile)) @@ -401,7 +407,7 @@ func TestLaunchStartsWithoutAGraphWhenCodeGraphIsMissing(t *testing.T) { withoutTerminal(t) got := withLaunchExec(t, 0) - _, stderr, code := run(t, "launch", "--root", root, "--no-headroom") + _, stderr, code := run(t, "launch", "--root", root) if code != ExitOK { t.Fatalf("exit = %d (stderr: %s)", code, stderr) } @@ -420,7 +426,7 @@ func TestLaunchNoGraphSkipsItEntirely(t *testing.T) { dir := isolatedPath(t, "claude") log := recordingStub(t, dir, "codegraph") - cmd := launchJSON(t, "launch", "--root", root, "--no-headroom", "--no-graph", "--json") + cmd := launchJSON(t, "launch", "--root", root, "--no-graph", "--json") if cmd.Graph != nil { t.Errorf("graph = %+v, want it absent from the report", cmd.Graph) } @@ -438,14 +444,14 @@ func TestLaunchNeverIndexesOnAPlanOnlyRun(t *testing.T) { log := recordingStub(t, dir, "codegraph") for _, flag := range []string{"--json", "--dry-run"} { - if _, stderr, code := run(t, "launch", "--root", root, "--no-headroom", flag); code != ExitOK { + if _, stderr, code := run(t, "launch", "--root", root, flag); code != ExitOK { t.Fatalf("%s: exit = %d (stderr: %s)", flag, code, stderr) } if got := recorded(t, log); strings.Contains(got, "init") || strings.Contains(got, "sync") { t.Errorf("%s indexed the workspace: %q", flag, got) } } - cmd := launchJSON(t, "launch", "--root", root, "--no-headroom", "--json") + cmd := launchJSON(t, "launch", "--root", root, "--json") if cmd.Graph == nil || cmd.Graph.Action != graphSkipped { t.Errorf("graph = %+v, want the index reported as skipped", cmd.Graph) } @@ -458,7 +464,7 @@ func TestLaunchPassesTheAgentsExitCodeThrough(t *testing.T) { isolatedPath(t, "claude") got := withLaunchExec(t, 42) - if _, stderr, code := run(t, "launch", "--root", root, "--no-headroom"); code != 42 { + if _, stderr, code := run(t, "launch", "--root", root); code != 42 { t.Errorf("exit = %d, want the agent's 42 (stderr: %s)", code, stderr) } if got.Bin != paths.Claude.Bin { @@ -485,7 +491,7 @@ func TestLaunchStartsAtTheWorkspaceRoot(t *testing.T) { got := withLaunchExec(t, 0) t.Chdir(sub) - if _, stderr, code := run(t, "launch", "--no-headroom"); code != ExitOK { + if _, stderr, code := run(t, "launch"); code != ExitOK { t.Fatalf("exit = %d (stderr: %s)", code, stderr) } if !sameDir(t, got.Dir, root) { @@ -504,14 +510,14 @@ func TestLaunchDryRunAndJSONStartNothing(t *testing.T) { launchExec = func(launchCommand) (int, error) { started = true; return 0, nil } t.Cleanup(func() { launchExec = orig }) - stdout, stderr, code := run(t, "launch", "--root", root, "--dry-run") + stdout, stderr, code := run(t, "launch", "--root", root, "--headroom", "--dry-run") if code != ExitOK { t.Fatalf("exit = %d (stderr: %s)", code, stderr) } if !strings.Contains(stdout, "headroom wrap claude") { t.Errorf("--dry-run does not name the command: %q", stdout) } - _ = launchJSON(t, "launch", "--root", root, "--json") + _ = launchJSON(t, "launch", "--root", root, "--headroom", "--json") if started { t.Error("--dry-run or --json started the agent") } @@ -526,7 +532,7 @@ func TestLaunchNeverInstallsOnAPlanOnlyRun(t *testing.T) { withPrompt(t, "y\ny\n") for _, flag := range []string{"--json", "--dry-run"} { - stdout, _, code := run(t, "launch", "--root", root, flag) + stdout, _, code := run(t, "launch", "--root", root, "--headroom", flag) if code != ExitOK { t.Fatalf("%s: exit = %d", flag, code) } @@ -534,7 +540,7 @@ func TestLaunchNeverInstallsOnAPlanOnlyRun(t *testing.T) { t.Errorf("%s asked to install: %q", flag, stdout) } } - cmd := launchJSON(t, "launch", "--root", root, "--json") + cmd := launchJSON(t, "launch", "--root", root, "--headroom", "--json") if cmd.Headroom == nil || cmd.Headroom.Install != installSkipped { t.Errorf("headroom = %+v, want the install reported as skipped", cmd.Headroom) } @@ -549,7 +555,7 @@ func TestLaunchDoesNotAskUnattended(t *testing.T) { withoutTerminal(t) got := withLaunchExec(t, 0) - stdout, stderr, code := run(t, "launch", "--root", root) + stdout, stderr, code := run(t, "launch", "--root", root, "--headroom") if code != ExitOK { t.Fatalf("exit = %d (stderr: %s)", code, stderr) } @@ -572,7 +578,7 @@ func TestLaunchFallsBackWhenTheInstallIsDeclined(t *testing.T) { withPrompt(t, "n\n") got := withLaunchExec(t, 0) - stdout, stderr, code := run(t, "launch", "--root", root) + stdout, stderr, code := run(t, "launch", "--root", root, "--headroom") if code != ExitOK { t.Fatalf("exit = %d (stderr: %s)", code, stderr) } @@ -595,7 +601,7 @@ func TestLaunchNoInstallNeverAsks(t *testing.T) { withPrompt(t, "y\n") got := withLaunchExec(t, 0) - stdout, _, code := run(t, "launch", "--root", root, "--no-install") + stdout, _, code := run(t, "launch", "--root", root, "--headroom", "--no-install") if code != ExitOK { t.Fatalf("exit = %d", code) } @@ -645,7 +651,7 @@ func TestLaunchResolvesAmbiguityByAskingOrNamingTheChoices(t *testing.T) { isolatedPath(t, "codex", "opencode") withoutTerminal(t) - _, stderr, code := run(t, "launch", "--root", root, "--no-headroom") + _, stderr, code := run(t, "launch", "--root", root) if code != ExitError { t.Errorf("exit = %d, want %d", code, ExitError) } @@ -655,7 +661,7 @@ func TestLaunchResolvesAmbiguityByAskingOrNamingTheChoices(t *testing.T) { withPrompt(t, "2\n") got := withLaunchExec(t, 0) - stdout, stderr, code := run(t, "launch", "--root", root, "--no-headroom") + stdout, stderr, code := run(t, "launch", "--root", root) if code != ExitOK { t.Fatalf("exit = %d (stderr: %s)", code, stderr) } @@ -680,7 +686,7 @@ func TestLaunchPickerOffersOnlyTheScaffoldedHarnesses(t *testing.T) { withPrompt(t, "\n") withLaunchExec(t, 0) - stdout, _, _ := run(t, "launch", "--root", root, "--no-headroom") + stdout, _, _ := run(t, "launch", "--root", root) if strings.Contains(stdout, paths.Claude.Label) { t.Errorf("the picker offered a harness that is not scaffolded here:\n%s", stdout) } @@ -704,7 +710,7 @@ func TestLaunchReportsAMissingAgentBinary(t *testing.T) { root := initWorkspace(t) isolatedPath(t) - _, stderr, code := run(t, "launch", "--root", root, "--no-headroom") + _, stderr, code := run(t, "launch", "--root", root) if code != ExitError { t.Errorf("exit = %d, want %d", code, ExitError) } @@ -728,57 +734,129 @@ func sameDir(t *testing.T, a, b string) bool { return os.SameFile(fa, fb) } -// RTK preflight is opt-in: a bare `scc launch` wants nothing from Headroom's -// wrap but the compression proxy, and asking scc to also wire up RTK is a -// separate decision, made with --rtk. The two runs have to differ in whether the -// field is present at all, not just in what it says: a launch that never -// considered RTK and a launch that considered it and skipped it are different -// answers, and only one of them means somebody decided. -func TestLaunchRTKIsOptIn(t *testing.T) { +// A workspace whose entry file says nothing about RTK gets the block on the next +// launch, because the block is the whole integration: an agent that never read it +// never types the prefix, so a binary on PATH alone buys nothing. +func TestLaunchWritesTheRTKBlockWhenTheEntryFileHasNone(t *testing.T) { root := initWorkspace(t) - isolatedPath(t, "claude") + isolatedPath(t, "rtk", "claude") + withLaunchExec(t, 0) - var without launchCommand - stdout, _, code := run(t, "launch", "--root", root, "--json") - if code != ExitOK { - t.Fatalf("exit = %d", code) + if _, stderr, code := run(t, "launch", "--root", root); code != ExitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr) + } + entry, err := os.ReadFile(filepath.Join(root, paths.Claude.EntryFile)) + if err != nil { + t.Fatalf("ReadFile: %v", err) } - if err := json.Unmarshal([]byte(stdout), &without); err != nil { - t.Fatalf("unmarshal: %v\n%s", err, stdout) + if !strings.Contains(string(entry), rtk.Markers.Open) { + t.Errorf("the entry file carries no RTK block:\n%s", entry) } - if without.RTK != nil { - t.Errorf("a bare launch reported %+v, want rtk left out", without.RTK) +} + +// The block is also the trigger. A workspace already carrying one is wired, so the +// launch does nothing at all: no rewrite of a block somebody may have curated — +// that decision belongs to `scc rtk`, not to starting an agent — and no cargo +// prompt at the top of every session. +func TestLaunchLeavesAnRTKBlockThatIsAlreadyThere(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "rtk", "claude") + withLaunchExec(t, 0) + + entry := filepath.Join(root, paths.Claude.EntryFile) + raw, err := os.ReadFile(entry) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + before := string(raw) + "\n" + rtk.Markers.Open + " v9 -->\nmine, not scc's\n" + rtk.Markers.Close + "\n" + if err := os.WriteFile(entry, []byte(before), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) } - var with launchCommand - stdout, _, code = run(t, "launch", "--root", root, "--json", "--rtk") - if code != ExitOK { - t.Fatalf("exit = %d", code) + cmd := launchJSON(t, "launch", "--root", root, "--json") + if cmd.RTK == nil || cmd.RTK.Block != "present" { + t.Errorf("rtk = %+v, want the block reported as present", cmd.RTK) } - if err := json.Unmarshal([]byte(stdout), &with); err != nil { - t.Fatalf("unmarshal: %v\n%s", err, stdout) + after, err := os.ReadFile(entry) + if err != nil { + t.Fatalf("ReadFile: %v", err) } - if with.RTK == nil { - t.Fatal("--rtk reported no rtk field at all") + if string(after) != before { + t.Errorf("the launch rewrote a block it did not own:\n%s", after) + } +} + +// --no-rtk is the explicit "just start the agent": no lookup, no block, no report. +// The field has to be absent rather than say "skipped", because a launch that never +// considered RTK and one that considered it and could not use it are different +// answers, and only one of them means somebody decided. +func TestLaunchNoRTKSkipsItEntirely(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "rtk", "claude") + withLaunchExec(t, 0) + + cmd := launchJSON(t, "launch", "--root", root, "--no-rtk", "--json") + if cmd.RTK != nil { + t.Errorf("rtk = %+v, want it absent from the report", cmd.RTK) + } + entry, err := os.ReadFile(filepath.Join(root, paths.Claude.EntryFile)) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if strings.Contains(string(entry), rtk.Markers.Open) { + t.Error("--no-rtk still edited the entry file") + } +} + +// A plan-only run reports the splice without performing it. --json has to leave stdout +// clean for the document, and a --dry-run that edited a file the user owns would be +// the one flag nobody expects to change anything doing exactly that. +func TestLaunchPlanOnlyWritesNoRTKBlock(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "rtk", "claude") + + for _, flag := range []string{"--json", "--dry-run"} { + if _, stderr, code := run(t, "launch", "--root", root, flag); code != ExitOK { + t.Fatalf("%s: exit = %d (stderr: %s)", flag, code, stderr) + } + entry, err := os.ReadFile(filepath.Join(root, paths.Claude.EntryFile)) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if strings.Contains(string(entry), rtk.Markers.Open) { + t.Errorf("%s edited the entry file", flag) + } + } +} + +// RTK degrades the way the other two integrations do: no cargo means no binary, and +// the agent starts anyway with a line saying so. The report distinguishes that from +// a launch that never looked. +func TestLaunchStartsWithoutRTKWhenItCannotBeBuilt(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "claude") + + cmd := launchJSON(t, "launch", "--root", root, "--json") + if cmd.RTK == nil { + t.Fatal("a bare launch reported no rtk field at all") } - if with.RTK.Install != installSkipped { - t.Errorf("install = %q, want %q with cargo absent", with.RTK.Install, installSkipped) + if cmd.RTK.Install != installSkipped { + t.Errorf("install = %q, want %q with cargo absent", cmd.RTK.Install, installSkipped) } - if with.RTK.Reason == "" { + if cmd.RTK.Reason == "" { t.Error("rtk was skipped without saying why") } } // --no-install covers RTK too. The flag says "never build anything", and a cargo -// build that takes minutes is the most expensive thing it governs — but only -// once --rtk has asked for RTK to be considered at all. +// build that takes minutes is the most expensive thing it governs. func TestLaunchNoInstallCoversRTK(t *testing.T) { root := initWorkspace(t) isolatedPath(t, "cargo", "claude") withPrompt(t, "\n") withLaunchExec(t, 0) - stdout, stderr, code := run(t, "launch", "--root", root, "--rtk", "--no-install") + stdout, stderr, code := run(t, "launch", "--root", root, "--no-install") if code != ExitOK { t.Fatalf("exit = %d", code) }