Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
15 changes: 12 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <harness>`.** 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 <slug>`). 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.
Expand Down Expand Up @@ -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. |
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
#
Expand Down Expand Up @@ -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"); \
Expand Down
40 changes: 23 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand All @@ -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 `<!-- rtk-instructions -->` markers, and scc inserts
Expand Down Expand Up @@ -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
Expand Down
31 changes: 22 additions & 9 deletions internal/assets/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading
Loading