From 18df20ba57dd510c8dff7c821e153f028299dca5 Mon Sep 17 00:00:00 2001 From: prode Date: Tue, 4 Aug 2026 00:49:27 -0300 Subject: [PATCH 1/4] feat(cli): scc launch starts a harness behind Headroom's compression proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scc launch [harness]` resolves the workspace, picks the harness it was scaffolded for, and starts that agent from the workspace root — through `headroom wrap ` when Headroom is available. Headroom is the default rather than a flag, 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, so it stays opt-in. Headroom wraps one process for one session and changes nothing on disk, so defaulting costs nothing when it is absent: a missing binary, a declined install, an unattended run, or a harness Headroom does not wrap all degrade to starting the agent bare with a warning naming the reason. `--no-headroom` forces that path. This is the one command that does not obey the 0/1/2 exit-code contract: it returns whatever the agent returned. A launcher that flattened the status of what it launched would be unusable in a script. scc's own failures still report 1, and they all happen before anything starts. internal/headroom keeps the agent-slug table and the install path (uv, then pip — never npm, which ships the SDK and no CLI), so a third party's vocabulary ages in one package instead of in the dispatcher. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J54qbk8RpC2tZH8LBz6T2b --- internal/cli/cli.go | 7 +- internal/cli/harness.go | 37 ++- internal/cli/harness_test.go | 4 +- internal/cli/launch.go | 341 +++++++++++++++++++++++ internal/cli/launch_test.go | 417 +++++++++++++++++++++++++++++ internal/headroom/headroom.go | 164 ++++++++++++ internal/headroom/headroom_test.go | 121 +++++++++ internal/paths/paths.go | 15 +- 8 files changed, 1089 insertions(+), 17 deletions(-) create mode 100644 internal/cli/launch.go create mode 100644 internal/cli/launch_test.go create mode 100644 internal/headroom/headroom.go create mode 100644 internal/headroom/headroom_test.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 5fbaee1..2800066 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -62,6 +62,8 @@ func Run(args []string) int { return runUpdate(args[1:]) case "rtk": return runRTK(args[1:]) + case "launch": + return runLaunch(args[1:]) case "spec": return runSpec(args[1:]) case "plan": @@ -107,6 +109,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 spec Create and inspect specs — new | list | show | delete | validate plan Create and inspect plans — new | list | delete | validate skill Agent Skills conformance — validate @@ -123,5 +126,7 @@ Exit codes: 0 ok 1 usage or runtime error 2 validation findings -`, render.Bold(prog()), prog(), prog()) + +"%s launch" is the one exception: it returns whatever the agent it started returned. +`, render.Bold(prog()), prog(), prog(), prog()) } diff --git a/internal/cli/harness.go b/internal/cli/harness.go index 8afb777..9c1eb62 100644 --- a/internal/cli/harness.go +++ b/internal/cli/harness.go @@ -35,7 +35,7 @@ func chooseHarness(picks map[string]*bool, jsonOut bool) (paths.Harness, error) if jsonOut || !interactive() { return paths.Claude, nil } - return promptHarness(promptIn), nil + return promptHarness(promptIn, "Which harness is this workspace for?", paths.Harnesses()), nil default: // Exclusive rather than additive because each run writes one manifest and // one entry file: two harnesses in one invocation would have to pick which @@ -51,20 +51,21 @@ func chooseHarness(picks map[string]*bool, jsonOut bool) (paths.Harness, error) } } -// promptHarness shows the supported harnesses and reads a choice. Claude Code is -// first and is the default, because it is the harness the methodology was -// designed against and the only one whose subagent, skill, and slash-command -// surfaces all exist at project scope. +// promptHarness shows a set of harnesses and reads a choice, with the first one +// as the default. init offers all of them, in the order paths declares — Claude +// Code first, because it is the harness the methodology was designed against and +// the only one whose subagent, skill, and slash-command surfaces all exist at +// project scope. launch offers only the ones this workspace was scaffolded for, +// which is why the set is a parameter rather than read from paths here: asking +// somebody to pick a harness that is not set up would be offering a broken answer. // // A numbered list read line by line, not a full-screen selector: scc is a // headless CLI that happens to be talkative when a human is present, and a raw // terminal mode would be a second interaction model to maintain — and to get // wrong on Windows — for one question asked once per repo. -func promptHarness(in io.Reader) paths.Harness { - all := paths.Harnesses() - +func promptHarness(in io.Reader, question string, all []paths.Harness) paths.Harness { fmt.Println() - render.Ask("Which harness is this workspace for?\n") + render.Ask(question + "\n") fmt.Println() width := 0 for _, h := range all { @@ -98,14 +99,28 @@ func promptHarness(in io.Reader) paths.Harness { if n, err := strconv.Atoi(answer); err == nil && n >= 1 && n <= len(all) { return all[n-1] } - // A name works too — somebody who typed "codex" meant it. - if h, err := paths.ParseHarness(strings.ToLower(answer)); err == nil { + // A name works too — somebody who typed "codex" meant it. Matched against + // the offered set rather than against every harness scc knows, so a name + // that is not on the list is rejected the same way a number off the end is. + if h, ok := byName(all, answer); ok { return h } render.Warn(fmt.Sprintf("not one of the choices: %q", answer)) } } +// byName resolves a typed answer against the offered harnesses, by ID or by the +// label the tool calls itself. +func byName(all []paths.Harness, answer string) (paths.Harness, bool) { + answer = strings.ToLower(strings.TrimSpace(answer)) + for _, h := range all { + if answer == h.ID || answer == strings.ToLower(h.Label) { + return h, true + } + } + return paths.Harness{}, false +} + // Where an interactive prompt reads from, and whether anybody is there to answer // it. Two variables rather than a direct os.Stdin read, so the tests can drive // both halves: the prompts are a real part of the surface and would otherwise be diff --git a/internal/cli/harness_test.go b/internal/cli/harness_test.go index 6a4747d..320a403 100644 --- a/internal/cli/harness_test.go +++ b/internal/cli/harness_test.go @@ -135,7 +135,7 @@ func TestPromptHarnessReadsAChoice(t *testing.T) { for in, want := range cases { var got paths.Harness _, _, _ = capture(t, func() int { - got = promptHarness(strings.NewReader(in)) + got = promptHarness(strings.NewReader(in), "Which harness?", paths.Harnesses()) return 0 }) if got.ID != want { @@ -148,7 +148,7 @@ func TestPromptHarnessReadsAChoice(t *testing.T) { // is visible rather than something the user has to know to ask about. func TestPromptHarnessListsEveryHarness(t *testing.T) { stdout, _, _ := capture(t, func() int { - promptHarness(strings.NewReader("\n")) + promptHarness(strings.NewReader("\n"), "Which harness?", paths.Harnesses()) return 0 }) for _, h := range paths.Harnesses() { diff --git a/internal/cli/launch.go b/internal/cli/launch.go new file mode 100644 index 0000000..3b48e5d --- /dev/null +++ b/internal/cli/launch.go @@ -0,0 +1,341 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/protonspy/spec-claude-code/internal/headroom" + "github.com/protonspy/spec-claude-code/internal/paths" + "github.com/protonspy/spec-claude-code/internal/render" + "github.com/protonspy/spec-claude-code/internal/workspace" +) + +// runLaunch starts a harness in this workspace, through Headroom's compression +// proxy when it can. +// +// It exists because "which agent, from which directory, with what in front of it" +// is a question scc already knows the answer to. The workspace walk finds the root +// whatever subdirectory the shell is in, the manifest says which harnesses were +// scaffolded, and the harness profile names the binary — so `scc launch` is one +// 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, changes nothing on disk, and degrades to +// starting the agent bare — so defaulting to it costs nothing when it is absent +// and saves context when it is there. +// +// 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 +// flattened the exit status of what it launched would be unusable in the scripts +// people actually write. scc's own failures, before anything is started, still +// report 1. +func runLaunch(args []string) int { + // Split on `--` before the flag package sees it, so `scc launch claude -- + // --resume` can tell scc's flags from the agent's. Doing this by hand rather + // than leaning on flag's own terminator is what keeps the harness name a + // positional while everything after `--` stays untouched. + own, passthrough := splitPassthrough(args) + + 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") + noInstall := fs.Bool("no-install", false, "never install Headroom; use it only if it is already on PATH") + yes := fs.Bool("yes", false, "answer the install prompt with yes, for an unattended run") + dryRun := fs.Bool("dry-run", false, "print the command this would run, and run nothing") + jsonOut := addJSON(fs) + rest, err := parseFlags(fs, own) + if err != nil { + return ExitError + } + + target, ok := resolveRoot(*root) + if !ok { + return ExitError + } + if !requireWorkspace(target) { + return ExitError + } + + harness, err := launchHarness(target, rest, *jsonOut) + if err != nil { + render.Err(err.Error()) + return ExitError + } + + // --json and --dry-run both report the plan and start nothing. For --json that + // is not a shortcut but the only coherent answer: the agent inherits this + // terminal and writes to the same stdout, so a launched session and a clean + // JSON document on stdout cannot both exist. + plan := *jsonOut || *dryRun + opts := headroomOptions{ + disabled: *noHeadroom, + noInstall: *noInstall || plan, + yes: *yes, + quiet: *jsonOut, + } + + cmd := launchCommand{Harness: harness.ID, Dir: target, Bin: harness.Bin, Args: passthrough} + if hr := resolveHeadroom(harness, opts); hr != nil { + cmd.Headroom = hr + if hr.Wrapping { + cmd.Bin = headroom.Bin + cmd.Args = headroom.WrapArgs(hr.Agent, passthrough) + } + } + if cmd.Args == nil { + // A JSON consumer gets [] rather than null: the field is a command line, + // and an empty one is still a list. + cmd.Args = []string{} + } + + if *jsonOut { + return emitJSON(cmd) + } + if *dryRun { + render.Info(cmd.String()) + return ExitOK + } + + if _, err := exec.LookPath(cmd.Bin); err != nil { + render.Err(fmt.Sprintf("%s is not on PATH", cmd.Bin)) + render.Detail(fmt.Sprintf(" install %s, or run `%s launch --dry-run` to see the command", harness.Label, prog())) + return ExitError + } + render.Info(cmd.String()) + code, err := launchExec(cmd) + if err != nil { + render.Err(fmt.Sprintf("could not start %s: %v", cmd.Bin, err)) + return ExitError + } + return code +} + +// launchCommand is both the frozen JSON shape and what the human line is printed +// from, so the two cannot describe different commands. +type launchCommand struct { + Harness string `json:"harness"` + Dir string `json:"dir"` + Bin string `json:"bin"` + Args []string `json:"args"` + Headroom *headroomReport `json:"headroom,omitempty"` +} + +// String is the command as a person would type it. Not shell-quoted, because it +// is a status line rather than something to paste: scc execs the argv directly +// and no shell is ever involved. +func (c launchCommand) String() string { + return strings.TrimSpace(c.Bin + " " + strings.Join(c.Args, " ")) +} + +// headroomReport says what happened on the Headroom side of a launch — reported +// on every run rather than only the ones that wrapped, because "started without +// compression" is exactly the outcome somebody would otherwise not notice. +type headroomReport struct { + // Wrapping is whether this launch actually goes through `headroom wrap`. + Wrapping bool `json:"wrapping"` + // Agent is the slug Headroom knows this harness by. + Agent string `json:"agent,omitempty"` + Path string `json:"path,omitempty"` + Version string `json:"version,omitempty"` + // Install is what happened to the binary: present | installed | skipped | + // failed. The same vocabulary `scc rtk` uses, for the same reason — it + // describes the binary, which is a different question from what the command + // ended up doing. + Install string `json:"install"` + // Reason names why a launch is not wrapping, for the run where that is a + // surprise. Empty when it is. + Reason string `json:"reason,omitempty"` +} + +type headroomOptions struct { + disabled bool + noInstall bool + yes bool + quiet bool +} + +// resolveHeadroom decides whether this launch goes through Headroom, installing +// it first if the user says so. It returns nil only when the user asked for no +// Headroom at all — every other outcome is reportable. +// +// Nothing here returns an exit code, and that is the design: Headroom is an +// enhancement, so every way of not getting it degrades to starting the agent bare +// rather than to failing. A launch that refused to run because a compression +// proxy was missing would be scc putting its own preference above the thing the +// user actually asked for. +func resolveHeadroom(h paths.Harness, opts headroomOptions) *headroomReport { + if opts.disabled { + return nil + } + + agent, wraps := headroom.Agent(h) + if !wraps { + return &headroomReport{Install: installSkipped, Reason: "Headroom does not wrap " + h.Label} + } + report := &headroomReport{Agent: agent} + + if p, ok := headroom.Path(); ok { + report.Wrapping, report.Path, report.Version, report.Install = true, p, headroom.Version(p), installPresent + return report + } + + report.Install = installSkipped + installer, available := headroom.Available() + switch { + case opts.noInstall: + report.Reason = headroom.Bin + " is not on PATH" + case !available: + report.Reason = fmt.Sprintf("neither uv nor pip is on PATH, so %s cannot be installed", headroom.Bin) + case opts.yes: + // Asked for by flag; no question to put. + case opts.quiet || !interactive(): + // Unattended. Installing a Python distribution without being asked, in a + // CI job or under an agent, is not a decision scc gets to make silently. + report.Reason = fmt.Sprintf("%s is not on PATH, and nobody is here to answer the install prompt", headroom.Bin) + default: + render.Warn(fmt.Sprintf("%s is not on PATH — %s compresses the agent's context before it reaches the model", headroom.Bin, headroom.Bin)) + render.Detail(" " + headroom.Repo) + if !confirm(promptIn, fmt.Sprintf("Install it now with `%s`?", installer.Cmd)) { + report.Reason = "install declined" + } + } + if report.Reason != "" { + warnUnwrapped(report, opts) + return report + } + + render.Info(fmt.Sprintf("installing %s: %s — this takes a few minutes", headroom.Bin, installer.Cmd)) + // The installer's own output goes to stderr in both streams when the caller is + // emitting JSON, because stdout carries the document and nothing else. + out := os.Stdout + if opts.quiet { + out = os.Stderr + } + if err := headroom.Install(installer, out, os.Stderr); err != nil { + report.Install, report.Reason = installFailed, err.Error() + warnUnwrapped(report, opts) + return report + } + p, ok := headroom.Path() + if !ok { + report.Install = installFailed + report.Reason = fmt.Sprintf("%s reported success but %s is still not on PATH", installer.Prog, headroom.Bin) + warnUnwrapped(report, opts) + return report + } + report.Wrapping, report.Path, report.Version, report.Install = true, p, headroom.Version(p), installInstalled + render.OK(strings.TrimSpace(headroom.Bin + " installed: " + p + " " + report.Version)) + return report +} + +// warnUnwrapped says, once, why the agent is starting without compression. It is +// a warning rather than a status line because the run is about to do less than +// the user asked for, and silence there is how somebody spends a month wondering +// why Headroom never seemed to help. +func warnUnwrapped(report *headroomReport, opts headroomOptions) { + if opts.quiet { + return + } + render.Warn(fmt.Sprintf("starting without %s: %s", headroom.Bin, report.Reason)) + if report.Install != installFailed { + render.Detail(" install it with: " + headroom.InstallHint()) + } +} + +// launchHarness picks which harness to start. +// +// An explicit name wins, and must be one this workspace was actually scaffolded +// for — starting Codex in a Claude-only repo would hand the user an agent with +// none of the methodology loaded, which is the exact failure scc exists to +// prevent, and it would do it while looking like it worked. Otherwise: the only +// harness here, or a picker when a person is at the terminal, or an error naming +// the choices when nobody is. +func launchHarness(root string, positionals []string, jsonOut bool) (paths.Harness, error) { + here := workspace.Harnesses(root) + + if len(positionals) > 1 { + return paths.Harness{}, fmt.Errorf("expected at most one harness name, got %d: %s", + len(positionals), strings.Join(positionals, " ")) + } + if len(positionals) == 1 { + h, err := paths.ParseHarness(strings.ToLower(positionals[0])) + if err != nil { + return paths.Harness{}, err + } + for _, in := range here { + if in.ID == h.ID { + return h, nil + } + } + return paths.Harness{}, fmt.Errorf("%s is not scaffolded here (found %s); run `%s init --%s` first", + h.ID, harnessIDs(here), prog(), h.ID) + } + + switch len(here) { + case 1: + return here[0], nil + case 0: + // requireWorkspace has already run, so this is unreachable through the CLI. + return paths.Harness{}, fmt.Errorf("no harness is scaffolded here; run `%s init` first", prog()) + default: + if jsonOut || !interactive() { + return paths.Harness{}, fmt.Errorf("this workspace has %s; name the one to start, e.g. `%s launch %s`", + harnessIDs(here), prog(), here[0].ID) + } + return promptHarness(promptIn, "Which harness do you want to start?", here), nil + } +} + +// harnessIDs lists a set for an error message, in the order paths declares them. +func harnessIDs(all []paths.Harness) string { + ids := make([]string, 0, len(all)) + for _, h := range all { + ids = append(ids, h.ID) + } + return strings.Join(ids, ", ") +} + +// splitPassthrough divides scc's own arguments from the agent's at the first bare +// `--`. Everything after it is passed through untouched, so `scc launch claude -- +// --dangerously-skip-permissions` reaches Claude Code rather than being rejected +// here as an unknown flag. +func splitPassthrough(args []string) (own, rest []string) { + for i, a := range args { + if a == "--" { + return args[:i], args[i+1:] + } + } + return args, nil +} + +// launchExec starts the resolved command with this terminal attached and returns +// its exit code. +// +// A child process rather than an exec(2) replacement, because Windows is a +// first-class target and has no execve: one code path that behaves identically +// everywhere beats a faster one on two platforms out of three. It is a package +// var so the tests can drive the whole command without starting a real agent. +var launchExec = func(cmd launchCommand) (int, error) { + c := exec.Command(cmd.Bin, cmd.Args...) + c.Dir = cmd.Dir + // The agent owns this terminal for the length of its session: it is + // interactive, and anything scc interposed here would break its rendering. + c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := c.Run(); err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) { + return exit.ExitCode(), nil + } + return ExitError, err + } + return ExitOK, nil +} diff --git a/internal/cli/launch_test.go b/internal/cli/launch_test.go new file mode 100644 index 0000000..d536623 --- /dev/null +++ b/internal/cli/launch_test.go @@ -0,0 +1,417 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/protonspy/spec-claude-code/internal/paths" +) + +// No test here may reach a real install: `uv tool install` would spend minutes of +// CI resolving a Python distribution, and the result would depend on whether the +// machine happened to have Headroom already. Every test therefore replaces PATH +// wholesale with a directory it controls — which also makes "Headroom is absent" +// a fact of the test rather than a fact about the developer's laptop. +func isolatedPath(t *testing.T, bins ...string) { + t.Helper() + dir := t.TempDir() + for _, bin := range bins { + script, name := "#!/bin/sh\necho '"+bin+" 0.0.0-stub'\n", bin + if runtime.GOOS == "windows" { + script, name = "@echo "+bin+" 0.0.0-stub\r\n", bin+".bat" + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil { + t.Fatalf("stub %s: %v", bin, err) + } + } + t.Setenv("PATH", dir) +} + +// withLaunchExec replaces the process launcher with a recorder, so the whole +// command surface is drivable without ever starting an agent. +func withLaunchExec(t *testing.T, code int) *launchCommand { + t.Helper() + var got launchCommand + orig := launchExec + launchExec = func(cmd launchCommand) (int, error) { + got = cmd + return code, nil + } + t.Cleanup(func() { launchExec = orig }) + return &got +} + +func launchJSON(t *testing.T, args ...string) launchCommand { + t.Helper() + stdout, stderr, code := run(t, args...) + if code != ExitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, ExitOK, stderr) + } + var cmd launchCommand + if err := json.Unmarshal([]byte(stdout), &cmd); err != nil { + t.Fatalf("stdout is not valid JSON (%v): %q", err, stdout) + } + 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. +func TestLaunchWrapsWithHeadroomWhenItIsThere(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "headroom", "claude") + + cmd := launchJSON(t, "launch", "--root", root, "--json") + if cmd.Bin != "headroom" { + t.Errorf("bin = %q, want headroom", cmd.Bin) + } + if strings.Join(cmd.Args, " ") != "wrap claude" { + t.Errorf("args = %v, want [wrap claude]", cmd.Args) + } + if cmd.Harness != paths.Claude.ID { + t.Errorf("harness = %q, want claude", cmd.Harness) + } + if cmd.Headroom == nil || !cmd.Headroom.Wrapping { + t.Fatalf("headroom = %+v, want it wrapping", cmd.Headroom) + } + if cmd.Headroom.Install != installPresent { + t.Errorf("install = %q, want %q", cmd.Headroom.Install, installPresent) + } +} + +// Headroom is an enhancement, so every way of not getting it degrades to starting +// the agent bare. A launch that refused to run because a compression proxy was +// missing would put scc's preference above what the user asked for. +func TestLaunchStartsBareWhenHeadroomIsMissing(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "claude") + + cmd := launchJSON(t, "launch", "--root", root, "--json") + if cmd.Bin != paths.Claude.Bin { + t.Errorf("bin = %q, want %q", cmd.Bin, paths.Claude.Bin) + } + if len(cmd.Args) != 0 { + t.Errorf("args = %v, want empty", cmd.Args) + } + if cmd.Headroom == nil || cmd.Headroom.Wrapping { + t.Fatalf("headroom = %+v, want it reported as not wrapping", cmd.Headroom) + } + // Why it is not wrapping has to survive into the report: "started without + // compression" is exactly the outcome somebody would otherwise not notice. + if cmd.Headroom.Reason == "" { + t.Error("the report does not say why the launch is unwrapped") + } +} + +// --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) { + root := initWorkspace(t) + isolatedPath(t, "headroom", "claude") + + cmd := launchJSON(t, "launch", "--root", root, "--no-headroom", "--json") + if cmd.Bin != paths.Claude.Bin { + t.Errorf("bin = %q, want %q", cmd.Bin, paths.Claude.Bin) + } + if cmd.Headroom != nil { + t.Errorf("headroom = %+v, want it absent from the report", cmd.Headroom) + } +} + +// Everything after `--` belongs to the agent. scc must not parse it, reject it, +// or reorder it — it goes behind the wrap slug exactly as typed. +func TestLaunchPassesArgumentsThroughToTheAgent(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "headroom", "claude") + + cmd := launchJSON(t, "launch", "claude", "--json", "--root", root, "--", "--resume", "--model", "opus") + if got, want := strings.Join(cmd.Args, " "), "wrap claude --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") + if got, want := strings.Join(cmd.Args, " "), "--resume"; got != want { + t.Errorf("bare args = %q, want %q", got, want) + } +} + +// The agent's exit code is the command's exit code. A launcher that flattened the +// status of what it launched into scc's own 0/1/2 would be unusable in a script. +func TestLaunchPassesTheAgentsExitCodeThrough(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "claude") + got := withLaunchExec(t, 42) + + if _, stderr, code := run(t, "launch", "--root", root, "--no-headroom"); code != 42 { + t.Errorf("exit = %d, want the agent's 42 (stderr: %s)", code, stderr) + } + if got.Bin != paths.Claude.Bin { + t.Errorf("launched %q, want %q", got.Bin, paths.Claude.Bin) + } + // The workspace root, not the shell's directory: `scc launch` from a + // subdirectory has to start the agent where the methodology lives. + if !sameDir(t, got.Dir, root) { + t.Errorf("started in %q, want the workspace root %q", got.Dir, root) + } +} + +// Run from a subdirectory with no --root, the agent still starts at the workspace +// root: the same upward walk every other command uses. Without it `scc launch` +// from specs/ would come up scoped to half the repo, which is the kind of thing +// nobody notices until the agent cannot find the rules. +func TestLaunchStartsAtTheWorkspaceRoot(t *testing.T) { + root := initWorkspace(t) + sub := filepath.Join(root, "specs", "deep") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + isolatedPath(t, "claude") + got := withLaunchExec(t, 0) + t.Chdir(sub) + + if _, stderr, code := run(t, "launch", "--no-headroom"); code != ExitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr) + } + if !sameDir(t, got.Dir, root) { + t.Errorf("started in %q, want the workspace root %q", got.Dir, root) + } +} + +// --dry-run and --json report the plan and start nothing. For --json that is the +// only coherent answer: the agent inherits this terminal and writes to the same +// stdout, so a live session and a clean JSON document cannot both exist. +func TestLaunchDryRunAndJSONStartNothing(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "headroom", "claude") + started := false + orig := launchExec + 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") + 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") + if started { + t.Error("--dry-run or --json started the agent") + } +} + +// Installing a Python distribution is a decision, and a plan-only run has not +// been given it. Even with an installer on PATH and somebody at the terminal +// answering yes, --json and --dry-run must report and stop. +func TestLaunchNeverInstallsOnAPlanOnlyRun(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "uv", "claude") + withPrompt(t, "y\ny\n") + + for _, flag := range []string{"--json", "--dry-run"} { + stdout, _, code := run(t, "launch", "--root", root, flag) + if code != ExitOK { + t.Fatalf("%s: exit = %d", flag, code) + } + if strings.Contains(stdout, "Install it now") { + t.Errorf("%s asked to install: %q", flag, stdout) + } + } + cmd := launchJSON(t, "launch", "--root", root, "--json") + if cmd.Headroom == nil || cmd.Headroom.Install != installSkipped { + t.Errorf("headroom = %+v, want the install reported as skipped", cmd.Headroom) + } +} + +// With nobody at the terminal — a CI job, or an agent driving scc — the install +// prompt cannot be asked, so it is not asked. The launch says why and goes ahead +// unwrapped rather than blocking on a read no one will answer. +func TestLaunchDoesNotAskUnattended(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "uv", "claude") + withoutTerminal(t) + got := withLaunchExec(t, 0) + + stdout, stderr, code := run(t, "launch", "--root", root) + if code != ExitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr) + } + if strings.Contains(stdout, "Install it now") { + t.Errorf("scc prompted with nobody there: %q", stdout) + } + if !strings.Contains(stderr, "without headroom") { + t.Errorf("stderr does not say the launch is unwrapped: %q", stderr) + } + if got.Bin != paths.Claude.Bin { + t.Errorf("launched %q, want the bare agent", got.Bin) + } +} + +// Asked and declined: the answer is respected for this run and the agent still +// starts. "No" means no compression, not no agent. +func TestLaunchFallsBackWhenTheInstallIsDeclined(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "uv", "claude") + withPrompt(t, "n\n") + got := withLaunchExec(t, 0) + + stdout, stderr, code := run(t, "launch", "--root", root) + if code != ExitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr) + } + if !strings.Contains(stdout, "Install it now") { + t.Errorf("scc did not ask before installing: %q", stdout) + } + if got.Bin != paths.Claude.Bin { + t.Errorf("launched %q, want the bare agent after declining", got.Bin) + } + if !strings.Contains(stderr, "declined") { + t.Errorf("stderr does not record the declined install: %q", stderr) + } +} + +// --no-install is the standing "never build anything": no prompt, no install, use +// Headroom only if it is already there. +func TestLaunchNoInstallNeverAsks(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "uv", "claude") + withPrompt(t, "y\n") + got := withLaunchExec(t, 0) + + stdout, _, code := run(t, "launch", "--root", root, "--no-install") + if code != ExitOK { + t.Fatalf("exit = %d", code) + } + if strings.Contains(stdout, "Install it now") { + t.Errorf("--no-install still asked: %q", stdout) + } + if got.Bin != paths.Claude.Bin { + t.Errorf("launched %q, want the bare agent", got.Bin) + } +} + +// Starting Codex in a Claude-only repo would hand the user an agent with none of +// the methodology loaded, while looking like it worked. +func TestLaunchRefusesAHarnessThatIsNotScaffolded(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t, "codex") + + _, stderr, code := run(t, "launch", "codex", "--root", root) + if code != ExitError { + t.Errorf("exit = %d, want %d", code, ExitError) + } + if !strings.Contains(stderr, "codex") || !strings.Contains(stderr, "init") { + t.Errorf("stderr does not say how to fix it: %q", stderr) + } +} + +func TestLaunchRejectsAnUnknownHarness(t *testing.T) { + root := initWorkspace(t) + _, stderr, code := run(t, "launch", "nope", "--root", root) + if code != ExitError { + t.Errorf("exit = %d, want %d", code, ExitError) + } + if !strings.Contains(stderr, "nope") { + t.Errorf("stderr does not name the unknown harness: %q", stderr) + } +} + +// A repo worked on from two tools has no single answer, so scc asks — and when +// nobody is there to ask, it names the choices instead of guessing. +func TestLaunchResolvesAmbiguityByAskingOrNamingTheChoices(t *testing.T) { + root := t.TempDir() + for _, h := range []string{"--codex", "--opencode"} { + if _, stderr, code := run(t, "init", h, "--root", root); code != ExitOK { + t.Fatalf("init %s: exit = %d (%s)", h, code, stderr) + } + } + isolatedPath(t, "codex", "opencode") + + withoutTerminal(t) + _, stderr, code := run(t, "launch", "--root", root, "--no-headroom") + if code != ExitError { + t.Errorf("exit = %d, want %d", code, ExitError) + } + if !strings.Contains(stderr, "codex") || !strings.Contains(stderr, "opencode") { + t.Errorf("stderr does not name both harnesses: %q", stderr) + } + + withPrompt(t, "2\n") + got := withLaunchExec(t, 0) + stdout, stderr, code := run(t, "launch", "--root", root, "--no-headroom") + if code != ExitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr) + } + if !strings.Contains(stdout, "Which harness") { + t.Errorf("launch did not ask:\n%s", stdout) + } + if got.Bin != paths.OpenCode.Bin { + t.Errorf("launched %q, want %q", got.Bin, paths.OpenCode.Bin) + } +} + +// The picker offers only what is scaffolded here. Offering a harness that is not +// set up would be offering a broken answer. +func TestLaunchPickerOffersOnlyTheScaffoldedHarnesses(t *testing.T) { + root := t.TempDir() + for _, h := range []string{"--codex", "--opencode"} { + if _, stderr, code := run(t, "init", h, "--root", root); code != ExitOK { + t.Fatalf("init %s: exit = %d (%s)", h, code, stderr) + } + } + isolatedPath(t, "codex", "opencode") + withPrompt(t, "\n") + withLaunchExec(t, 0) + + stdout, _, _ := run(t, "launch", "--root", root, "--no-headroom") + if strings.Contains(stdout, paths.Claude.Label) { + t.Errorf("the picker offered a harness that is not scaffolded here:\n%s", stdout) + } +} + +// Outside a workspace there is nothing to start the agent in, and the walk would +// otherwise fall back to whatever directory the user happened to be in. +func TestLaunchRequiresAWorkspace(t *testing.T) { + _, stderr, code := run(t, "launch", "--root", t.TempDir()) + if code != ExitError { + t.Errorf("exit = %d, want %d", code, ExitError) + } + if !strings.Contains(stderr, "not an scc workspace") { + t.Errorf("stderr = %q, want it to say the directory is not a workspace", stderr) + } +} + +// A harness scc knows about but that is not installed gets a message about the +// binary, not a stack trace from exec. +func TestLaunchReportsAMissingAgentBinary(t *testing.T) { + root := initWorkspace(t) + isolatedPath(t) + + _, stderr, code := run(t, "launch", "--root", root, "--no-headroom") + if code != ExitError { + t.Errorf("exit = %d, want %d", code, ExitError) + } + if !strings.Contains(stderr, paths.Claude.Bin) || !strings.Contains(stderr, "not on PATH") { + t.Errorf("stderr = %q, want it to name the missing binary", stderr) + } +} + +// sameDir compares resolved paths rather than strings: t.TempDir can sit under a +// symlink on macOS, and Windows reports 8.3 short names. +func sameDir(t *testing.T, a, b string) bool { + t.Helper() + fa, err := os.Stat(a) + if err != nil { + t.Fatalf("Stat %s: %v", a, err) + } + fb, err := os.Stat(b) + if err != nil { + t.Fatalf("Stat %s: %v", b, err) + } + return os.SameFile(fa, fb) +} diff --git a/internal/headroom/headroom.go b/internal/headroom/headroom.go new file mode 100644 index 0000000..cc572b1 --- /dev/null +++ b/internal/headroom/headroom.go @@ -0,0 +1,164 @@ +// Package headroom wires Headroom — the context-compression layer that sits +// between a coding agent and its model — into the way scc starts a harness. +// +// Three things, kept apart because they fail differently: naming the agent slug +// `headroom wrap` takes for a given harness, which is pure data; finding the +// binary, which is a PATH lookup; and installing it, which needs a Python +// toolchain and a network and can take minutes. +// +// scc only ever composes the command line. The proxy Headroom starts, the config +// it injects, and the agent's own lifetime are Headroom's, and scc deliberately +// knows nothing about them — `headroom wrap claude` is one process to launch, not +// a protocol to implement. +package headroom + +import ( + "fmt" + "io" + "os/exec" + "strings" + + "github.com/protonspy/spec-claude-code/internal/paths" +) + +// Repo is where Headroom is developed, for the error that has to send somebody +// somewhere. +const Repo = "https://github.com/headroomlabs-ai/headroom" + +// Bin is the executable's name, as it appears on PATH. +const Bin = "headroom" + +// Dist is the distribution that carries the CLI. +// +// The [all] extra is load-bearing: it is what pulls in the proxy and the +// code-aware compressors alongside the library. Installing the bare package +// would leave `headroom` either absent or unable to wrap anything. +const Dist = "headroom-ai[all]" + +// agents maps a paths.Harness ID to the slug `headroom wrap` takes. +// +// A map here rather than a field on paths.Harness, and the reason is ownership: +// which agents Headroom wraps is Headroom's vocabulary, published in Headroom's +// README and changing on Headroom's schedule. Putting those slugs in the on-disk +// profile would make every harness scc adds look like it already had an answer +// here. The three agree with scc's own IDs today; this indirection is what keeps +// that a coincidence rather than a coupling. +var agents = map[string]string{ + paths.Claude.ID: "claude", + paths.Codex.ID: "codex", + paths.OpenCode.ID: "opencode", +} + +// Agent reports the slug for h, and whether Headroom wraps that harness at all. +// A false here is a fact about Headroom's support, not an error: the caller's +// answer is to start the agent unwrapped. +func Agent(h paths.Harness) (string, bool) { + slug, ok := agents[h.ID] + return slug, ok +} + +// WrapArgs is the whole argument vector: `wrap `, then whatever the caller +// is passing straight through to the agent itself. +func WrapArgs(agent string, rest []string) []string { + args := make([]string, 0, len(rest)+2) + args = append(args, "wrap", agent) + return append(args, rest...) +} + +// Path reports where the headroom binary is, and whether it is on PATH at all. +func Path() (string, bool) { + p, err := exec.LookPath(Bin) + if err != nil { + return "", false + } + return p, true +} + +// Version reports what `headroom --version` says, or "" when the binary cannot +// answer. Advisory only: it is printed, never branched on, so a build that words +// its version differently costs nothing. +func Version(bin string) string { + out, err := exec.Command(bin, "--version").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// Installer is one way to get the CLI onto PATH. +type Installer struct { + // Prog is the program that does the installing, looked up on PATH. + Prog string + // Args is the argument vector passed to Prog, unquoted — exec takes no shell, + // so the extras bracket needs no escaping here. + Args []string + // Cmd is the same command written the way a shell takes it, for printing. It + // is not the argv form: `headroom-ai[all]` is a glob in zsh, so a line a human + // copy-pastes has to carry the quotes that a line scc execs must not. + Cmd string +} + +// Installers are the ways to install the CLI, in preference order. +// +// uv first because it is what Headroom's own docs lead with and because it puts +// the tool in an isolated environment, which is the right default for a CLI that +// happens to be written in Python. pip second, for a machine that has no uv. +// +// npm is deliberately absent even though Headroom publishes there: +// `npm install headroom-ai` ships the TypeScript SDK and no CLI, so installing it +// would report success and leave `headroom` still missing. +func Installers() []Installer { + return []Installer{ + { + Prog: "uv", + Args: []string{"tool", "install", "--python", "3.13", Dist}, + Cmd: `uv tool install --python 3.13 "` + Dist + `"`, + }, + { + Prog: "pip", + Args: []string{"install", Dist}, + Cmd: `pip install "` + Dist + `"`, + }, + } +} + +// Available returns the first installer whose program is actually on this machine. +func Available() (Installer, bool) { + for _, i := range Installers() { + if _, err := exec.LookPath(i.Prog); err == nil { + return i, true + } + } + return Installer{}, false +} + +// InstallHint is what to tell somebody who has to do it themselves: every way, +// in preference order, written the way a shell takes it. +func InstallHint() string { + all := Installers() + lines := make([]string, 0, len(all)) + for _, i := range all { + lines = append(lines, i.Cmd) + } + return strings.Join(lines, "\n or: ") +} + +// Install runs i, streaming its output — resolving and building a Python +// distribution takes a while, and a silent command that long reads as a hang. +// +// A missing program is reported as itself rather than as a failed install: the +// user has to get uv or pip first, which is a different problem from an install +// that broke, and "install failed" would send them looking in the wrong place. +func Install(i Installer, stdout, stderr io.Writer) error { + prog, err := exec.LookPath(i.Prog) + if err != nil { + return fmt.Errorf("%s is not on PATH; install it, then run: %s", i.Prog, i.Cmd) + } + cmd := exec.Command(prog, i.Args...) + cmd.Stdout = stdout + cmd.Stderr = stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s: %w", i.Cmd, err) + } + return nil +} diff --git a/internal/headroom/headroom_test.go b/internal/headroom/headroom_test.go new file mode 100644 index 0000000..0ca003f --- /dev/null +++ b/internal/headroom/headroom_test.go @@ -0,0 +1,121 @@ +package headroom + +import ( + "io" + "strings" + "testing" + + "github.com/protonspy/spec-claude-code/internal/paths" +) + +// Every harness scc scaffolds for is one Headroom wraps, so `scc launch` never +// silently drops compression for a harness that could have had it. A new harness +// arriving here without a slug is a decision to make deliberately — add it, or +// accept that launching it starts the agent bare — not one to discover in the +// field. +func TestEveryHarnessHasAnAgentSlug(t *testing.T) { + for _, h := range paths.Harnesses() { + slug, ok := Agent(h) + if !ok { + t.Errorf("%s has no headroom agent slug", h.ID) + continue + } + if slug == "" { + t.Errorf("%s maps to an empty slug", h.ID) + } + } +} + +func TestAgentRejectsAnUnknownHarness(t *testing.T) { + if _, ok := Agent(paths.Harness{ID: "nope"}); ok { + t.Error("an unknown harness reported a slug") + } +} + +// WrapArgs is the whole command line, and it must not alias the caller's slice: +// the pass-through arguments come straight off the command line, and appending +// into their backing array would corrupt what the caller still holds. +func TestWrapArgsPrefixesWithoutAliasing(t *testing.T) { + rest := []string{"--resume", "--model", "opus"} + got := WrapArgs("claude", rest) + + want := []string{"wrap", "claude", "--resume", "--model", "opus"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("WrapArgs = %v, want %v", got, want) + } + got[2] = "clobbered" + if rest[0] != "--resume" { + t.Errorf("WrapArgs wrote through to the caller's slice: %v", rest) + } + + if got := WrapArgs("codex", nil); strings.Join(got, " ") != "wrap codex" { + t.Errorf("WrapArgs with no pass-through = %v", got) + } +} + +// uv is what Headroom's docs lead with and what puts the CLI in an isolated +// environment, so it has to be tried first on a machine that has both. +func TestInstallersPreferUV(t *testing.T) { + all := Installers() + if len(all) < 2 { + t.Fatalf("installers = %v, want at least uv and pip", all) + } + if all[0].Prog != "uv" { + t.Errorf("first installer is %q, want uv", all[0].Prog) + } + for _, i := range all { + if !strings.Contains(i.Cmd, Dist) { + t.Errorf("%s does not install %s: %q", i.Prog, Dist, i.Cmd) + } + // The printable form is quoted for a shell; the argv form must not be, or + // the extras bracket becomes part of the distribution name. + if !strings.Contains(i.Cmd, `"`+Dist+`"`) { + t.Errorf("%s's printable command does not quote the extras: %q", i.Prog, i.Cmd) + } + found := false + for _, a := range i.Args { + if a == Dist { + found = true + } + } + if !found { + t.Errorf("%s's argv does not carry %s unquoted: %v", i.Prog, Dist, i.Args) + } + } +} + +// npm publishes headroom-ai too, but that package is the TypeScript SDK with no +// CLI: installing it would report success and leave `headroom` still missing. +func TestNPMIsNotAnInstaller(t *testing.T) { + for _, i := range Installers() { + if i.Prog == "npm" { + t.Error("npm is listed as an installer, but it ships no CLI") + } + } + if strings.Contains(InstallHint(), "npm") { + t.Errorf("the install hint offers npm: %q", InstallHint()) + } +} + +// The hint is what somebody reads when scc cannot install for them, so it has to +// name every way rather than only the one scc would have picked. +func TestInstallHintNamesEveryInstaller(t *testing.T) { + hint := InstallHint() + for _, i := range Installers() { + if !strings.Contains(hint, i.Cmd) { + t.Errorf("the hint omits %s: %q", i.Prog, hint) + } + } +} + +// A missing uv is reported as a missing uv. Calling it a failed install would +// send the user looking at Headroom instead of at their own toolchain. +func TestInstallReportsAMissingProgram(t *testing.T) { + err := Install(Installer{Prog: "definitely-not-a-real-program", Cmd: "nope"}, io.Discard, io.Discard) + if err == nil { + t.Fatal("Install with a missing program returned no error") + } + if !strings.Contains(err.Error(), "not on PATH") { + t.Errorf("error = %q, want it to say the program is not on PATH", err) + } +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 86e8ef4..34d2ebe 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -50,6 +50,15 @@ type Harness struct { // flags and JSON; "Claude Code" is what the user actually installed. Label string + // Bin is the harness's own executable, as it appears on PATH — what + // `scc launch` starts. + // + // The one field here that is not about the on-disk layout, and it belongs + // here anyway: scaffolding for a harness and then starting that harness in + // the workspace it just configured is one profile's question asked twice. A + // launcher switching on ID instead would be this profile missing a field. + Bin string + // Dir is the harness's configuration directory at the project root. Dir string @@ -109,17 +118,17 @@ func (h Harness) AgentExt() string { // surfaces all exist at project scope. var ( Claude = Harness{ - ID: "claude", Label: "Claude Code", Dir: ClaudeDir, EntryFile: "CLAUDE.md", + ID: "claude", Label: "Claude Code", Bin: "claude", Dir: ClaudeDir, EntryFile: "CLAUDE.md", AgentsSeg: "agents", AgentFormat: FormatMarkdown, SkillsSeg: "skills", CommandsSeg: "commands", RulesSeg: "rules", } Codex = Harness{ - ID: "codex", Label: "Codex", Dir: CodexDir, EntryFile: "AGENTS.md", + ID: "codex", Label: "Codex", Bin: "codex", Dir: CodexDir, EntryFile: "AGENTS.md", AgentsSeg: "agents", AgentFormat: FormatTOML, SkillsSeg: "skills", CommandsSeg: "", RulesSeg: "rules", } OpenCode = Harness{ - ID: "opencode", Label: "opencode", Dir: OpenCodeDir, EntryFile: "AGENTS.md", + ID: "opencode", Label: "opencode", Bin: "opencode", Dir: OpenCodeDir, EntryFile: "AGENTS.md", AgentsSeg: "agent", AgentFormat: FormatMarkdown, SkillsSeg: "skills", CommandsSeg: "command", RulesSeg: "rules", } From 02c0c11f56c452d8a79cf18db18b8fca2c4b5052 Mon Sep 17 00:00:00 2001 From: prode Date: Tue, 4 Aug 2026 00:49:39 -0300 Subject: [PATCH 2/4] feat(npm): publish the launcher as scc-cli alongside the scoped name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm i -g scc-cli` is now the documented install, and the command it puts on PATH is still `scc`. npm resolves the package name and installs the bin name, and those never had to match — the bare `scc` on npm has belonged to an unrelated project since 2013. @protonspy/scc stays published from the same source so earlier installs keep receiving versions. Both launchers ship one shim, so the shim now reads its own package.json for SCC_PROG instead of naming a package: hardcoding either spelling would tell half the users to re-run under a package they never installed. Launchers are emitted under npm/dist/launchers/ rather than beside the platform packages, and that is load-bearing. Publishing walks dist/scc-*/ first and dist/launchers/*/ second; a second launcher at the top level would be swept into the platform glob and reach the registry ahead of the binaries its optionalDependencies name, which is a broken install for anyone in that window. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J54qbk8RpC2tZH8LBz6T2b --- .github/workflows/release.yml | 14 ++++++--- Makefile | 10 +++--- README.md | 40 ++++++++++++++---------- internal/cli/cli_test.go | 8 ++--- npm/README.md | 27 ++++++++++++++-- npm/scc/README.md | 21 ++++++++++--- npm/scc/bin/scc.js | 25 +++++++++++---- npm/scc/package.json | 5 ++- npm/scripts/build-packages.mjs | 57 +++++++++++++++++++++++++--------- 9 files changed, 148 insertions(+), 59 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbbfe6d..a6bfd75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,8 +7,8 @@ name: release # (or: make release VERSION=v0.1.0) # # It runs the full pipeline for the given version: CI gate → cross-platform -# binaries → publish every npm package (the launcher + 6 platform binaries, all -# at the same version) → tag the commit + GitHub Release. Nothing is released on +# binaries → publish every npm package (2 launcher names + 6 platform binaries, +# all at the same version) → tag the commit + GitHub Release. Nothing is released on # push; releasing is always a deliberate manual action. on: workflow_dispatch: @@ -164,7 +164,7 @@ jobs: VERSION: ${{ inputs.version }} run: node npm/scripts/build-packages.mjs "$VERSION" artifacts - - name: Publish (platform packages first, then the launcher) + - name: Publish (platform packages first, then the launchers) env: # Automation token (bypasses 2FA). setup-node wrote an .npmrc that # reads this. --provenance still attaches a signed attestation via the @@ -187,11 +187,15 @@ jobs: echo ">> publishing $name@$ver" npm publish "$dir" --access public --provenance } - # The platform packages must land before the launcher that depends on them. + # The platform packages must land before any launcher that optionally + # depends on them: a launcher on the registry ahead of its binaries is a + # broken install for whoever hits that window. for d in npm/dist/scc-*/; do publish "${d%/}" done - publish npm/dist/scc + for d in npm/dist/launchers/*/; do + publish "${d%/}" + done # Tag the released commit and create the GitHub Release — only after npm # publishing succeeds, so a failed publish never leaves a dangling tag/release. diff --git a/Makefile b/Makefile index b0802fd..62d5f1e 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ # # Manual npm publish (bootstrap / fallback, when CI can't do it): # make dist VERSION=v0.1.0 # cross-compile all 6 targets into dist/ -# make npm-build VERSION=v0.1.0 # assemble npm/dist/ (7 packages) from those +# make npm-build VERSION=v0.1.0 # assemble npm/dist/ (8 packages) from those # make npm-dry-run # validate every package without publishing # make npm-publish [OTP=123456] # publish (skips already-published) # @@ -86,17 +86,17 @@ npm-build: require-version ## Assemble npm/dist/ from the artifacts (VERSION=vX. .PHONY: npm-dry-run npm-dry-run: ## Dry-run publish every assembled package @set -euo pipefail; \ - if [ ! -f npm/dist/$(BIN)/package.json ]; then echo "npm/dist not assembled — run: make npm-build VERSION=vX.Y.Z first" >&2; exit 1; fi; \ - for d in npm/dist/$(BIN)-*/ npm/dist/$(BIN)/; do \ + if [ ! -f npm/dist/launchers/$(BIN)/package.json ]; then echo "npm/dist not assembled — run: make npm-build VERSION=vX.Y.Z first" >&2; exit 1; fi; \ + for d in npm/dist/$(BIN)-*/ npm/dist/launchers/*/; do \ [ -f "$$d/package.json" ] || continue; \ echo "== $$d"; npm publish "$$d" --access public --dry-run; done .PHONY: npm-publish npm-publish: ## Publish the assembled packages, skips already-published; OTP=123456 if 2FA @set -euo pipefail; \ - if [ ! -f npm/dist/$(BIN)/package.json ]; then echo "npm/dist not assembled — run: make dist VERSION=vX.Y.Z && make npm-build VERSION=vX.Y.Z" >&2; exit 1; fi; \ + if [ ! -f npm/dist/launchers/$(BIN)/package.json ]; then echo "npm/dist not assembled — run: make dist VERSION=vX.Y.Z && make npm-build VERSION=vX.Y.Z" >&2; exit 1; fi; \ otp=; if [ -n "$(OTP)" ]; then otp="--otp=$(OTP)"; fi; \ - for d in npm/dist/$(BIN)-*/ npm/dist/$(BIN)/; do \ + for d in npm/dist/$(BIN)-*/ npm/dist/launchers/*/; do \ [ -f "$$d/package.json" ] || continue; \ name=$$(cd "$$d" && node -p "require('./package.json').name"); \ ver=$$(cd "$$d" && node -p "require('./package.json').version"); \ diff --git a/README.md b/README.md index 3dfb418..04a109c 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,16 @@ AI agents. No install — run it straight from npm inside the repo you want to govern: ```bash -npx @protonspy/scc init # asks which harness, then scaffolds the rules, agents, and layout -npx @protonspy/scc init --codex # or name it: --claude (default), --codex, --opencode -npx @protonspy/scc spec new user-auth # specs/user-auth/: requirements.md, design.md, tasks.md -npx @protonspy/scc plan new checkout-revamp # plans/checkout-revamp.md -npx @protonspy/scc validate # every check; exit 2 means it found something -npx @protonspy/scc update # show what a newer scc would change, then confirm +npx scc-cli init # asks which harness, then scaffolds the rules, agents, and layout +npx scc-cli init --codex # or name it: --claude (default), --codex, --opencode +npx scc-cli spec new user-auth # specs/user-auth/: requirements.md, design.md, tasks.md +npx scc-cli plan new checkout-revamp # plans/checkout-revamp.md +npx scc-cli validate # every check; exit 2 means it found something +npx scc-cli update # show what a newer scc would change, then confirm ``` -Installed globally (`npm i -g @protonspy/scc`) the same commands are just `scc init`, -`scc spec new user-auth`, and so on. +Installed globally (`npm i -g scc-cli`) the same commands are just `scc init`, +`scc spec new user-auth`, and so on — the package is `scc-cli`, the command is `scc`. | Command | What it does | |---|---| @@ -43,9 +43,9 @@ installs it with cargo when it is not on PATH, and puts its usage block into `CLAUDE.md`/`AGENTS.md` so the agent knows to prefix commands with it: ```bash -npx @protonspy/scc init --rtk # scaffold, then wire RTK in -npx @protonspy/scc rtk # wire it into a workspace that already exists -npx @protonspy/scc rtk --check # CI: exit 2 when the block is missing +npx scc-cli init --rtk # scaffold, then wire RTK in +npx scc-cli rtk # wire it into a workspace that already exists +npx scc-cli rtk --check # CI: exit 2 when the block is missing ``` The block sits between RTK's own `` markers, and scc inserts @@ -89,16 +89,22 @@ accountability, and a checker that was confidently incomplete would be worse tha ## Install -Published on npm as [`@protonspy/scc`](https://www.npmjs.com/package/@protonspy/scc) — -the launcher pulls the right prebuilt binary for your platform as an optional dependency, -so there is no toolchain to set up. +Published on npm as [`scc-cli`](https://www.npmjs.com/package/scc-cli) — the launcher +pulls the right prebuilt binary for your platform as an optional dependency, so there +is no toolchain to set up. ```bash -npx @protonspy/scc help # no install; pins nothing, always the latest -npx @protonspy/scc@0.0.1 help # pin a version (CI) -npm i -g @protonspy/scc # then: scc help +npx scc-cli help # no install; pins nothing, always the latest +npx scc-cli@0.0.1 help # pin a version (CI) +npm i -g scc-cli # then: scc help ``` +The package is `scc-cli`; the command it installs is `scc`. Without `-g` it lands in +`node_modules/.bin`, which npm scripts see and your shell does not — reach it there as +`npx scc`. The same package is also published as `@protonspy/scc` for installs that +predate the shorter name; use one or the other, not both, since they claim the same +command. + Or from source (Go 1.25+): ```bash diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 5aa09ef..28b000f 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -132,14 +132,14 @@ func TestUnknownFlagIsAUsageError(t *testing.T) { } // SCC_PROG lets the npm launcher make help text echo the spelling the user typed -// (`npx @protonspy/scc`) instead of a bare binary name they may not have. +// (`npx scc-cli`) instead of a bare binary name they may not have. func TestProgHonorsEnvOverride(t *testing.T) { - t.Setenv("SCC_PROG", "npx @protonspy/scc") - if got := prog(); got != "npx @protonspy/scc" { + t.Setenv("SCC_PROG", "npx scc-cli") + if got := prog(); got != "npx scc-cli" { t.Errorf("prog() = %q, want the override", got) } _, stderr, _ := run(t, "help") - if !strings.Contains(stderr, "npx @protonspy/scc") { + if !strings.Contains(stderr, "npx scc-cli") { t.Errorf("usage did not use SCC_PROG: %q", stderr) } } diff --git a/npm/README.md b/npm/README.md index f5201c9..0fcf3d5 100644 --- a/npm/README.md +++ b/npm/README.md @@ -6,9 +6,10 @@ How `scc` reaches npm. Nothing here is built from source at install time. | Path | What it is | |---|---| -| `scc/` | The published launcher package (`@protonspy/scc`): a Node shim, no binary. | +| `scc/` | The launcher source: a Node shim, no binary. Its `package.json` is a template — `name`, `version`, and `optionalDependencies` are all generated. | | `scripts/build-packages.mjs` | Assembles `npm/dist/` from the release artifacts. | -| `dist/` | Generated, git-ignored. One directory per package to publish. | +| `dist/scc-/` | Generated. One per target, carrying the native binary. | +| `dist/launchers//` | Generated. The same shim, once per published launcher name. | ## How the install works @@ -20,6 +21,28 @@ matching the host, and `bin/scc.js` resolves that package's binary and execs it. No postinstall script and no network access at install time — the binary is already there or the install failed. +## Two launcher names + +The launcher is published twice, from one source, listed in `LAUNCHERS` in +`build-packages.mjs`: + +| Name | Role | +|---|---| +| `scc-cli` | The documented install. Unscoped, so `npm i -g scc-cli` is the whole line. | +| `@protonspy/scc` | Kept published so earlier installs keep receiving versions. | + +Both put the same `scc` command on PATH: npm resolves the *package* name and +puts the *bin* name on PATH, and those never had to match. The bare `scc` on npm +has belonged to an unrelated project since 2013, which is why neither name is it. + +Installing both globally is the one thing to avoid — they compete for the same +command name, and npm resolves that by letting the last one win. + +The split is also why launchers live under `dist/launchers/` instead of beside +the platform packages: publishing walks `dist/scc-*/` first and `dist/launchers/*/` +second, and a launcher that reached the registry ahead of the binaries it depends +on would be a broken install for anyone who hit that window. + ## Releasing The release workflow does this automatically. Manually, from a clean tree: diff --git a/npm/scc/README.md b/npm/scc/README.md index b5acd51..13b316d 100644 --- a/npm/scc/README.md +++ b/npm/scc/README.md @@ -1,13 +1,24 @@ -# @protonspy/scc +# scc -Spec-driven development for Claude Code — a single Go binary that turns the SDD -workflow into a mechanically validated contract for humans and AI agents. +Spec-driven development for coding agents — a single Go binary that turns the SDD +workflow into a mechanically validated contract for humans and AI agents. Works +with Claude Code, Codex, and opencode. ```bash -npx @protonspy/scc help # no install -npm i -g @protonspy/scc # then: scc help +npm i -g scc-cli # then, anywhere: scc help +npx scc-cli help # no install ``` +The command is `scc` either way. Installing without `-g` puts it in +`node_modules/.bin`, which is on PATH for npm scripts but not for your shell — +there, reach it as `npx scc`. + +> Published under two names: **`scc-cli`**, which is the one to use, and +> `@protonspy/scc`, kept so earlier installs keep receiving versions. Same +> package, same `scc` command — install one, not both, since they compete for +> the same command name. The bare `scc` on npm belongs to an unrelated project +> from 2013. + This package is a thin launcher. The native binary ships in a per-platform optional dependency (`@protonspy/scc-linux-x64`, `…-darwin-arm64`, …); npm installs only the one matching your machine. There is no postinstall step and no diff --git a/npm/scc/bin/scc.js b/npm/scc/bin/scc.js index 673e13c..f4a1076 100644 --- a/npm/scc/bin/scc.js +++ b/npm/scc/bin/scc.js @@ -39,11 +39,16 @@ try { process.exit(1); } -// When invoked via `npx @protonspy/scc` (which is `npm exec` under the hood), -// echo that exact spelling in the binary's help/usage output. A global install -// runs this same launcher as the bare `scc` command — there npm is not in the -// picture (npm_command is unset), so the binary keeps its default name. An -// explicit SCC_PROG always wins. +// When invoked via `npx scc-cli` (which is `npm exec` under the hood), echo that +// exact spelling in the binary's help/usage output. A global install runs this +// same launcher as the bare `scc` command — there npm is not in the picture +// (npm_command is unset), so the binary keeps its default name. An explicit +// SCC_PROG always wins. +// +// The name is read from this package's own package.json rather than written in, +// because one shim is published under two names (`scc-cli` and `@protonspy/scc`) +// and a hardcoded spelling would be wrong for whichever one the user did not +// type — telling them to re-run a command under a package they never installed. const env = { ...process.env }; if (!env.SCC_PROG) { const argv1 = process.argv[1] || ""; @@ -51,7 +56,15 @@ if (!env.SCC_PROG) { process.env.npm_command === "exec" || argv1.includes("/_npx/") || argv1.includes("\\_npx\\"); - if (viaNpx) env.SCC_PROG = "npx @protonspy/scc"; + if (viaNpx) { + let self = "scc-cli"; + try { + self = require("../package.json").name || self; + } catch { + /* published without its manifest — fall back to the documented name */ + } + env.SCC_PROG = `npx ${self}`; + } } const child = spawn(binPath, process.argv.slice(2), { stdio: "inherit", env }); diff --git a/npm/scc/package.json b/npm/scc/package.json index 56fb7a9..26cf6ff 100644 --- a/npm/scc/package.json +++ b/npm/scc/package.json @@ -1,10 +1,13 @@ { "name": "@protonspy/scc", "version": "0.0.0", - "description": "Spec-driven development for Claude Code — a single Go binary that turns the SDD workflow into a mechanically validated contract for humans and AI agents.", + "description": "Spec-driven development for coding agents — a single Go binary that turns the SDD workflow into a mechanically validated contract. Works with Claude Code, Codex, and opencode.", "keywords": [ "claude", "claude-code", + "codex", + "opencode", + "coding-agent", "spec-driven-development", "sdd", "cli", diff --git a/npm/scripts/build-packages.mjs b/npm/scripts/build-packages.mjs index 57872e5..24cd577 100644 --- a/npm/scripts/build-packages.mjs +++ b/npm/scripts/build-packages.mjs @@ -10,8 +10,11 @@ // (default: "artifacts"). // // Output: npm/dist/ -// scc/ launcher package (shim + optionalDependencies) // scc--/ one per target, carrying the native binary +// launchers// the shim, published under each launcher name +// +// Publish order is the layout: every scc-*/ package must reach the registry +// before any launchers/ package that optionally depends on it. // // The Go binaries are reused as-is from the release artifacts (they already // carry the version baked in via -ldflags), so the npm binary is byte-identical @@ -125,17 +128,43 @@ for (const t of TARGETS) { console.log(`built ${pkgName}@${version}`); } -// --- launcher package ------------------------------------------------------ -const rootSrc = join(npmDir, BIN); -const rootOut = join(outDir, BIN); -mkdirSync(join(rootOut, "bin"), { recursive: true }); -cpSync(join(rootSrc, "bin", `${BIN}.js`), join(rootOut, "bin", `${BIN}.js`)); -cpSync(join(rootSrc, "README.md"), join(rootOut, "README.md")); +// --- launcher packages ----------------------------------------------------- +// Two names, one package. `scc-cli` is the documented install line; the scoped +// `@protonspy/scc` stays published so anybody who already installed it keeps +// receiving versions. Both carry the same shim, the same optionalDependencies, +// and the same `bin` — so both put the same `scc` command on PATH. The package +// name is what npm resolves and the bin name is what you type, and those were +// never required to match: the bare `scc` on npm has been taken since 2013. +// +// They are emitted under launchers/ rather than beside the platform packages, +// and that is load-bearing rather than tidy: the publish order is expressed as +// "everything in dist/scc-*/ first, then everything in dist/launchers/", and a +// second launcher sitting at the top level would be swept into the platform +// glob and published ahead of the binaries it optionally depends on. Anyone +// installing during that window gets a launcher that cannot resolve a binary. +const LAUNCHERS = [ + { dir: "scc-cli", name: "scc-cli" }, + { dir: BIN, name: `${SCOPE}/${BIN}` }, +]; + +const launcherSrc = join(npmDir, BIN); +// The checked-in package.json is a template: its name and optionalDependencies +// are both placeholders, generated here so that neither a new platform nor a new +// launcher name can ever be half-wired. +const launcherPkg = JSON.parse(readFileSync(join(launcherSrc, "package.json"), "utf8")); +for (const launcher of LAUNCHERS) { + const out = join(outDir, "launchers", launcher.dir); + mkdirSync(join(out, "bin"), { recursive: true }); + cpSync(join(launcherSrc, "bin", `${BIN}.js`), join(out, "bin", `${BIN}.js`)); + cpSync(join(launcherSrc, "README.md"), join(out, "README.md")); -// The checked-in optionalDependencies are a placeholder; the real list is -// generated from TARGETS above so a new platform can never be half-wired. -const rootPkg = JSON.parse(readFileSync(join(rootSrc, "package.json"), "utf8")); -rootPkg.version = version; -rootPkg.optionalDependencies = optionalDependencies; -writeFileSync(join(rootOut, "package.json"), JSON.stringify(rootPkg, null, 2) + "\n"); -console.log(`built ${rootPkg.name}@${version}`); + writeFileSync( + join(out, "package.json"), + JSON.stringify( + { ...launcherPkg, name: launcher.name, version, optionalDependencies }, + null, + 2 + ) + "\n" + ); + console.log(`built ${launcher.name}@${version}`); +} From ea04417b248b98bcd762ee12d3fd79ed63be8f7f Mon Sep 17 00:00:00 2001 From: prode Date: Tue, 4 Aug 2026 00:49:55 -0300 Subject: [PATCH 3/4] feat(assets): the entry file stops telling Claude Code to read rules it already has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code loads .claude/rules/*.md at launch with the same priority as CLAUDE.md. An entry file telling that agent to "read the rule when the concern is live" therefore describes a mechanism that already ran: it asks for a re-read that puts the same ~26KB in context twice, and it leaves the one document the agent is meant to trust wrong about its own environment. paths.Harness gains PreloadsRules, and entry.md branches on it — the trigger lists stay a single copy shared by both arms, because two copies would diverge the first time somebody edited a trigger and Codex and Claude would then follow silently different methodologies. Codex and opencode keep the instruction, since there rules/ is scc's own directory and nothing loads it. The same pass gives each rule its own trigger line instead of running four of them together in a sentence. project.md most of all: it was the third item of a prose list, and a build command that did not come from it is a guess. The test asserts the two lead-ins are mutually exclusive rather than merely present — a template shipping both would satisfy a contains check while contradicting itself in the file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J54qbk8RpC2tZH8LBz6T2b --- internal/assets/assets.go | 31 +++++++++++++++------- internal/assets/assets_test.go | 42 ++++++++++++++++++++++++++++++ internal/assets/templates/entry.md | 37 +++++++++++++++++++------- internal/paths/paths.go | 24 ++++++++++++++--- 4 files changed, 111 insertions(+), 23 deletions(-) diff --git a/internal/assets/assets.go b/internal/assets/assets.go index 9d96690..fdf59e1 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -67,7 +67,11 @@ import ( // 8: the entry file names *when* to read each rule instead of tabulating all nine // as equals — five read on their own trigger, four looked up by name — and the two // review agents are tightened in the same pass. -const Version = "8" +// 9: the npx fallback names the unscoped `scc-cli` package; the entry file gives +// each rule its own trigger line instead of running four of them together in a +// sentence — project.md above all, since a build command nobody read is guessed — +// and it stops telling a harness that preloads the rules to go and read them. +const Version = "9" // The embedded tree. "all:" so nothing is silently dropped for having a name the // default embed pattern skips. @@ -317,7 +321,10 @@ func Content(name string) (string, error) { // things, and nothing else. Every field is derived from the profile, so a // template cannot come to depend on the machine, the project, or the clock. type layout struct { - Harness string + Harness string + // Label is the tool's own name, for the one sentence a template addresses to + // the agent about the tool it is running inside. + Label string Dir string Entry string Rules string @@ -326,17 +333,23 @@ type layout struct { Commands string HasCommands bool Manifest string + // RulesPreloaded says the harness already put Rules in the agent's context, + // so a template can stop telling it to go and read them. See + // paths.Harness.PreloadsRules. + RulesPreloaded bool } func layoutOf(h paths.Harness) layout { l := layout{ - Harness: h.ID, - Dir: h.Dir, - Entry: h.EntryFile, - Rules: path.Join(h.Dir, h.RulesSeg), - Skills: path.Join(h.Dir, h.SkillsSeg), - Agents: path.Join(h.Dir, h.AgentsSeg), - Manifest: path.Join(h.Dir, paths.ManifestSeg), + Harness: h.ID, + Label: h.Label, + Dir: h.Dir, + Entry: h.EntryFile, + Rules: path.Join(h.Dir, h.RulesSeg), + Skills: path.Join(h.Dir, h.SkillsSeg), + Agents: path.Join(h.Dir, h.AgentsSeg), + Manifest: path.Join(h.Dir, paths.ManifestSeg), + RulesPreloaded: h.PreloadsRules, } if h.CommandsSeg != "" { l.Commands = path.Join(h.Dir, h.CommandsSeg) diff --git a/internal/assets/assets_test.go b/internal/assets/assets_test.go index 52f1225..6988901 100644 --- a/internal/assets/assets_test.go +++ b/internal/assets/assets_test.go @@ -289,6 +289,48 @@ func TestEntryFileNamesEveryRule(t *testing.T) { } } +// The entry file must not tell an agent to go and read rules its harness already +// put in front of it, and must tell one whose harness did not. +// +// Both halves matter, and they fail in opposite directions. Told to read what it +// already has, the agent re-reads all nine rules and pays for the same ~26KB twice +// — and, worse, catches the one document it is meant to trust being wrong about +// its own environment. Not told to read what it does not have, it works from a +// methodology it never opened. So this asserts the two lead-ins are mutually +// exclusive rather than merely present: a template that shipped both sentences +// would satisfy a "contains" check while contradicting itself in the file. +func TestEntryFileMatchesWhetherTheHarnessPreloadsRules(t *testing.T) { + const ( + preloaded = "nothing to open" + lazy = "Open the file whose moment has arrived" + ) + for _, h := range paths.Harnesses() { + raw, err := Render(h, entryFile(t, h)) + if err != nil { + t.Fatalf("%s: %v", h.ID, err) + } + gotPreloaded := strings.Contains(raw, preloaded) + gotLazy := strings.Contains(raw, lazy) + + if gotPreloaded == gotLazy { + t.Errorf("%s: %s carries %v of the two rule lead-ins; it must carry exactly one", + h.ID, h.EntryFile, map[bool]string{true: "both", false: "neither"}[gotPreloaded]) + continue + } + if gotPreloaded != h.PreloadsRules { + t.Errorf("%s: PreloadsRules=%v but %s tells the agent %s", + h.ID, h.PreloadsRules, h.EntryFile, + map[bool]string{true: "the rules are already loaded", false: "to open them itself"}[gotPreloaded]) + } + // The preloaded wording names the tool that did the loading, which is the + // only place a workspace template addresses the harness by its own name. + if h.PreloadsRules && !strings.Contains(raw, h.Label) { + t.Errorf("%s: %s says the rules are preloaded without naming %s as what loaded them", + h.ID, h.EntryFile, h.Label) + } + } +} + func entryFile(t *testing.T, h paths.Harness) File { t.Helper() for _, f := range Workspace(h) { diff --git a/internal/assets/templates/entry.md b/internal/assets/templates/entry.md index 6f4f937..a316c0d 100644 --- a/internal/assets/templates/entry.md +++ b/internal/assets/templates/entry.md @@ -1,20 +1,37 @@ # {{.Entry}} Spec-driven development, scaffolded and checked by `scc`. Keep this file short — -the methodology lives in `{{.Rules}}/`, read when the concern is live. Never inline it here. +the methodology lives in `{{.Rules}}/`. Never inline it here. ## Rules — `{{.Rules}}/.md` -Read at these moments, without being asked: +{{if .RulesPreloaded -}} +{{.Label}} loads `{{.Rules}}/` into your context at session start, so these are already +in front of you and there is nothing to open. What the triggers below tell you is *when* +each rule governs — the failure they prevent is not a rule you never read, it is a rule +you had all along and applied at the wrong moment, or not at all. +{{- else -}} +Nothing loads these for you. Open the file whose moment has arrived, and open it again +in a new session: a rule you read yesterday is not a rule you have read. +{{- end}} + +Triggered by where you are in the work: + +- `autonomy.md` — at kickoff, before writing anything +- `routing.md` — work arrives and needs a vehicle: a spec, or a plan +- `methodology.md` — starting a task: which cycle, what to run first +- `verification.md` — code is written and you think it is done +- `delivery.md` — last task done: branch, review, PR -- autonomy — at kickoff, before writing anything -- routing — work arrives and needs a vehicle: a spec, or a plan -- methodology — starting a task: which cycle, what to run first -- verification — code is written and you think it is done -- delivery — last task done: branch, review, PR +Triggered by what you are about to touch: -Read by name when you're in that territory: tasks, specs, project (build/test/lint -commands), knowledge-base (something learned, or a decision made). +- `project.md` — **before you run any build, test, lint, or format command.** This + project's commands exist nowhere else: `scc` ships the file as a stub for the team + to fill in, and runs none of them itself. A command that did not come from there is + a guess, and a guessed test command that exits 0 looks exactly like a passing suite. +- `specs.md` — writing requirements, design, or tasks for a spec +- `tasks.md` — working through a spec's task list +- `knowledge-base.md` — something was learned, or a decision was made ## Layout @@ -32,7 +49,7 @@ docs/ knowledge base — wiki, adr, codewiki, glossary, stack ## Checking your work -`scc validate` — or `npx @protonspy/scc validate` if not installed (`@` pins for CI). +`scc validate` — or `npx scc-cli validate` if not installed (`@` pins for CI). `scc update` brings a newer scc's rules and agents in: it shows the plan, then asks. Exit `0` ok · `1` could not run · `2` ran and found something. A finding is an answer, not a crash. diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 34d2ebe..6477813 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -88,11 +88,26 @@ type Harness struct { // the skills alone there rather than writing into the user's home directory. CommandsSeg string - // RulesSeg is where the methodology goes under Dir. No harness loads it on - // its own — the entry file's table is what sends the agent to a rule when - // the concern is live — so this is scc's choice in all three, kept parallel - // so one layout is learned once. + // RulesSeg is where the methodology goes under Dir. The path is scc's choice + // in all three, kept parallel so one layout is learned once — but what the + // harness then does with it is not, which is what PreloadsRules records. RulesSeg string + + // PreloadsRules is whether the harness reads every file under RulesSeg into + // context by itself at session start. + // + // It changes what the entry file may truthfully say, which is why it is a + // field rather than a footnote. Claude Code loads `.claude/rules/*.md` at + // launch with the same priority as CLAUDE.md, so an entry file telling that + // agent to "read the rule when the concern is live" describes a mechanism + // that already ran — it asks for a re-read that puts the same bytes in + // context twice, and it makes the one document the agent is meant to trust + // wrong about its own environment. For Codex and opencode, RulesSeg is scc's + // invention and nothing loads it, so there the instruction is the only thing + // that gets a rule read at all. + // + // Verifiable per harness: `/context` in Claude Code lists what loaded. + PreloadsRules bool } // Format is the dialect a harness's subagent definitions are written in. @@ -121,6 +136,7 @@ var ( ID: "claude", Label: "Claude Code", Bin: "claude", Dir: ClaudeDir, EntryFile: "CLAUDE.md", AgentsSeg: "agents", AgentFormat: FormatMarkdown, SkillsSeg: "skills", CommandsSeg: "commands", RulesSeg: "rules", + PreloadsRules: true, } Codex = Harness{ ID: "codex", Label: "Codex", Bin: "codex", Dir: CodexDir, EntryFile: "AGENTS.md", From 4f8c09c2a95cf5f70367c2d716ca4a689d8720f3 Mon Sep 17 00:00:00 2001 From: prode Date: Tue, 4 Aug 2026 00:49:55 -0300 Subject: [PATCH 4/4] docs: record launch, scc-cli, and what each harness does with rules/ Corrects a claim that was load-bearing and false: paths.RulesSeg said "no harness loads it on its own", which holds for Codex and opencode but not for the harness the project is named after. Confirm per harness with /context. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J54qbk8RpC2tZH8LBz6T2b --- CLAUDE.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9fdf870..94def68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,11 +14,14 @@ Note: this repo is not itself an scc workspace (no harness directory, `specs/`, **Status: v0.4.0-shaped.** Everything through `design/plan.md` phase 10 is built and green: scaffolding (`init`), artifact creation (`spec`, `plan`), and all eight validators behind `scc validate`. `init` also scaffolds the seven skills named in `design/orchestration.md` §6 — the six knowledge-base authors (one per `docs/` artifact a validator checks, plus `prd`) and the `plan-run` workflow skill — each with a `scc-`-prefixed slash command derived from the same list (`assets.Skills()`, which is `KnowledgeSkills` + `WorkflowSkills`), wherever the harness has a command surface. -Three things landed after phase 10 and all are documented in `design/orchestration.md` §6 and §12: +These landed after phase 10, and all are documented in `design/orchestration.md` §6 and §12: - **Three harnesses, one template set.** `scc init --claude|--codex|--opencode` (Claude Code is the default, and a terminal with no flag gets a picker). One prose source: paths come from a `paths.Harness` profile and the header each loader parses is synthesized at render time — YAML frontmatter for Claude Code and opencode, a TOML agent role file for Codex. + + The profile also carries `PreloadsRules`, because where the rules go is scc's choice but what the harness then does with them is not. Claude Code loads `.claude/rules/*.md` at launch with the same priority as `CLAUDE.md`; Codex and opencode load nothing from `rules/`, which is scc's own directory there. The entry file branches on it: told to "read the rule when the concern is live", an agent that already has all nine in context re-reads them, putting the same ~26KB in twice — and learns that the one document it is meant to trust is wrong about its own environment. Confirm per harness with `/context`. - **`scc update` (phase 11), as replace-or-keep rather than the planned three-way merge.** It hashes every managed file against this build and against the manifest, prints the plan grouped by outcome, asks, and then replaces what is safe to replace. An edited file is kept and named; `--force` is the separate decision. `internal/merge` is still unbuilt. - **`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. Insert-only — a block already between the markers is left alone whatever version it claims, because RTK owns that text and `rtk init` refreshes it; `--force` is the separate decision. 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 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 `). 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 one session and changes nothing on disk. 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. - **The four seeded `docs/` anchors** (`assets.Seeds`). `init` writes `glossary.md`, `stack.md`, `wiki/index.md`, and `wiki/changelog.md` — the knowledge base's only fixed-name documents, each holding the format its validator checks. A seed is written once and tracked nowhere: not in the manifest, not by `scc update`. `scc` is a redesign of `csdd` (`github.com/protonspy/csdd`), narrowed to spec-driven development and deliberately leaner. When reaching for something from there, port the *decision*, not the file. Already decided against: a TUI, an embedded web dashboard, an MCP server, a devcontainer. @@ -69,7 +72,7 @@ cmd/scc/main.go os.Exit(cli.Run(os.Args[1:])) | Package | Role | |---|---| -| `internal/paths` | Every directory/file name in the on-disk layout, in one place, plus the `Harness` profile (`Claude`, `Codex`, `OpenCode`) that says where each tool keeps things. Never hardcode `".claude"` or `"specs"` elsewhere — the harness-relative paths are methods on `Harness`. | +| `internal/paths` | Every directory/file name in the on-disk layout, in one place, plus the `Harness` profile (`Claude`, `Codex`, `OpenCode`) that says where each tool keeps things — and, in `PreloadsRules`, what it does with them. Never hardcode `".claude"` or `"specs"` elsewhere — the harness-relative paths are methods on `Harness`. | | `internal/workspace` | Resolves the root by walking up for *any* harness's `scc-manifest.json` marker; `Harnesses(root)` says which trees exist. Owns `KebabCheck`, `SafeName`, `AtomicWrite`. Knows nothing about specs or wikis. | | `internal/render` | CLI terminal output (`✓ ✗ ! •`, `NO_COLOR`/TTY aware), split across stdout/stderr. | | `internal/textutil` | Line-ending and BOM normalization, in exactly one place. | @@ -80,15 +83,20 @@ cmd/scc/main.go os.Exit(cli.Run(os.Args[1:])) | `internal/mdscan` | The only Markdown parser: fence- and HTML-comment-aware headings, checkboxes, links, wikilinks, slugs, plus a small frontmatter reader. `Body` is the comment/fence-stripped text every validator applies its grammar to. | | `internal/ears` | EARS requirement parsing, all five patterns plus complex. | | `internal/validate` | The eight validators, one file each, sharing `mdscan` and `finding`. The exception is `stack_manifests.go`: the seven dependency-file readers age on their own schedule, so they sit beside the rule rather than inside it. | -| `internal/rtk` | RTK's marker pair and the idempotent splice of its block into the entry file, plus finding or `cargo install`ing the binary. The only package that shells out to another program — keep that boundary here rather than in a command handler. | +| `internal/rtk` | RTK's marker pair and the idempotent splice of its block into the entry file, plus finding or `cargo install`ing the binary. | +| `internal/headroom` | Headroom's agent-slug table, the `wrap` argument vector, and finding or installing the binary (uv, then pip — never npm, which ships the SDK and no CLI). The slugs live here rather than on `paths.Harness` because they are Headroom's vocabulary, not scc's layout. | | `internal/cli` | The dispatcher and every command handler. | +`internal/rtk` and `internal/headroom` are the only packages that shell out to another program. Keep that boundary there rather than in a command handler: a third party's binary name, install command, and argument vocabulary all age on that third party's schedule, and one package per integration is what keeps a version bump from touching the dispatcher. + `go.mod` is stdlib-only. Keep it that way unless a dependency earns its place — the binary is distributed to six platforms and every dep is a supply-chain surface. ## Conventions **Exit codes are the contract.** `0` ok · `1` usage/runtime error · `2` validation findings. Every lint/validate command returns `2` on findings so CI and agents can branch on it. A finding is a legitimate answer to a lint question, not a failure of the tool — don't collapse `2` into `1`. +`scc launch` is the single exception, and it has to be: it returns whatever the agent it started returned. A launcher that flattened the exit status of what it launched into its own vocabulary would be unusable in the scripts people actually write. scc's own failures — no workspace, unknown harness, binary not on PATH — still happen before anything starts and still report `1`. + **A validator that fires on scc's own output is the worst bug in the product.** The templates carry their instructions in HTML comments and fenced examples, which is exactly what `mdscan` excludes — and `TestFreshArtifactsPassTheirOwnValidators` in `internal/cli` is the gate. Treat it as required reading before changing a template or a validator: one wrong finding teaches the user to disbelieve all eight. **Machine-readable output.** Bind the `--json` flag via `addJSON` and emit through `emitJSON` (`internal/cli/jsonout.go`) so the flag name, help text, and stdout/stderr split stay identical across commands — stdout is a clean JSON stream, diagnostics go to stderr. @@ -125,6 +133,7 @@ Tests live beside the code and lean on a few package-local helpers rather than a - **A version is immutable.** Re-dispatching an already-released version from a *different* commit is refused, because publishing is idempotent and the run would otherwise go green having shipped nothing. - **Publishing is idempotent.** Already-published packages are skipped, so a run that died after `npm-publish` can be resumed by re-dispatching the same commit. - **Adding a platform touches three places** that must agree: `TARGETS` in `npm/scripts/build-packages.mjs`, `PLATFORMS` in the `Makefile`, and the `build` matrix in `release.yml`. +- **The launcher is published under two names**, listed in `LAUNCHERS` in the same script: `scc-cli` is the documented install, and `@protonspy/scc` stays published so earlier installs keep receiving versions. Both ship the same shim and put the same `scc` command on PATH — npm resolves the package name and installs the `bin` name, and those never had to match. Launchers are emitted under `npm/dist/launchers/` rather than beside the platform packages so that publish order stays structural: `dist/scc-*/` first, `dist/launchers/*/` second. A launcher that reached the registry ahead of the binaries in its `optionalDependencies` is a broken install for anyone in that window. - Actions are pinned by commit SHA. Keep them pinned. ## Commits