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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ 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 six knowledge-base skills named in `design/orchestration.md` §6 — one per `docs/` artifact a validator checks, plus `prd` — each with a `scc-`-prefixed slash command derived from the same list (`assets.KnowledgeSkills`), wherever the harness has a command surface.

Two things landed after phase 10 and both are documented in `design/orchestration.md` §6 and §12:
Three things 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.
- **`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.
- **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 @@ -73,7 +74,7 @@ cmd/scc/main.go os.Exit(cli.Run(os.Args[1:]))
| `internal/textutil` | Line-ending and BOM normalization, in exactly one place. |
| `internal/finding` | One finding type and one frozen JSON shape (`{findings, count}`) for every validator, plus the grouped human report. |
| `internal/manifest` | `<harness>/scc-manifest.json`: `{path, hash, version}` per managed file plus the harness, deterministic serialization, `Status → pristine\|edited\|missing`. Unknown fields are preserved. Every call takes the `paths.Harness` whose manifest it means. |
| `internal/assets` | The embedded template set — rules, review agents, skills, slash commands, artifact templates. **Workspace templates are data-free except for the harness profile** (a `(version, harness)` pair still renders byte-identically everywhere, and the manifest records both, so the future three-way merge can still reconstruct the old side); **artifact templates take data** (`spec new` renders them and the user owns the result). `Render(h, file)` is the only way to get a workspace file's bytes: it expands paths and synthesizes the per-harness header for agents and commands. `Version` is the template-set version and must be bumped whenever a workspace template changes. |
| `internal/assets` | The embedded template set — rules, review agents, skills, slash commands, artifact templates. **Workspace templates are data-free except for the harness profile** (a `(version, harness)` pair still renders byte-identically everywhere, and the manifest records both, so the future three-way merge can still reconstruct the old side); **artifact templates take data** (`spec new` renders them and the user owns the result); **seeds are the `docs/` anchors** — data-free like a workspace file, untracked like an artifact. `Render(h, file)` is the only way to get a workspace file's bytes: it expands paths and synthesizes the per-harness header for agents and commands. `Version` is the template-set version and must be bumped whenever a workspace template changes. |
| `internal/scaffold` | Applies the template set to a root (`Apply`) and brings an existing one current (`PlanUpdate`/`ApplyUpdate`). Idempotent, never overwrites without being told to, manifest written last. |
| `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. |
Expand Down
22 changes: 22 additions & 0 deletions design/orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,28 @@ The skills carry the *procedure and the judgment*; the format they produce stays
`.claude/rules/knowledge-base.md`, stated once. A skill that restated the format would
be the same duplication the methodology rules are being kept out of skills to avoid.

### The four seeded anchors — `docs/` is not an empty tree

`init` writes `docs/glossary.md`, `docs/stack.md`, `docs/wiki/index.md`, and
`docs/wiki/changelog.md`, each one a heading and the format its validator checks.
These are the knowledge base's only documents with a *fixed name*; a wiki page, an
ADR, and a codewiki page are named after what they are about, so those directories
are correctly scaffolded empty.

The reason is the same one that makes the skills ship: a workspace that arrives with
eight validators and an empty `docs/` demands conformance to documents nobody was
handed. Seeding also improves the first finding a real project sees — a repo with
dependencies used to get one `stack.missing`, and now gets one line per undocumented
dependency, pointing at a file that already explains what an entry looks like.

**A seed is written once and tracked nowhere** — not in the manifest, not by
`scc update`. It is a third category alongside managed files and artifact templates,
and it is closer to the artifacts: the moment the file exists it holds project
knowledge, which scc has no improved version of to deliver. Untracked also settles
the two-harness case without a rule of its own, because `docs/` is one tree per repo
rather than one per harness — the second `init` finds the files already there and
leaves them alone, exactly as it does for any existing file.

## 7 · Agents — review only

Two subagents ship with the workspace, and both of them read rather than write:
Expand Down
51 changes: 48 additions & 3 deletions internal/assets/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
// immediately, nothing tracks them in the manifest, and no upgrade ever touches
// them.
//
// - **Seeds** — the `docs/` anchors init lays down. Untracked like an artifact and
// data-free like a workspace file: they are the knowledge base's fixed
// documents, so scc writes each one once, holding the format its validator
// checks, and never writes it again. See Seed.
//
// One prose source, three harnesses. The methodology does not change with the
// tool reading it, so the rules, the skills, and the review agents are written
// once and re-addressed per harness: paths come from the profile, and the
Expand Down Expand Up @@ -175,6 +180,45 @@ func Workspace(h paths.Harness) []File {
return set
}

// Seed is one of the `docs/` anchor files `scc init` lays down: the knowledge base's
// four fixed documents, each holding the format its validator checks and nothing
// else.
//
// A seed is deliberately NOT a managed file, and that is a third category rather
// than an oversight. init writes one only when it is missing, the manifest never
// records it, and no upgrade ever touches it — the same terms artifact templates
// ship on, for the same reason: the moment the file exists it holds project
// knowledge, which scc has nothing to deliver improvements to.
//
// They are also harness-neutral, because `docs/` is. A repo scaffolded for two
// harnesses has one knowledge base, written by whichever init ran first, and a seed
// that varied by harness would make the second init's copy the odd one out.
type Seed struct {
// Name is the path inside the embedded tree, e.g. "docs/glossary.md".
Name string
// Rel is where it goes: slash-separated and relative to the workspace root.
Rel string
}

// Seeds returns the anchors in destination order.
//
// Four, and the rule that picks them is the same one that picks the skills: a seed
// for each fixed `docs/` document a validator checks. The per-concept pages, the
// ADRs, and the codewiki pages have no fixed name, so they have no anchor — an
// empty directory is the honest scaffold for those.
//
// Seeding is what makes the knowledge base's own rules discoverable at the moment
// somebody first opens the file, rather than only after a validator has already
// fired on the missing document.
func Seeds() []Seed {
return []Seed{
{Name: "docs/glossary.md", Rel: path.Join(paths.DocsSeg, paths.GlossarySeg)},
{Name: "docs/stack.md", Rel: path.Join(paths.DocsSeg, paths.StackSeg)},
{Name: "docs/wiki/changelog.md", Rel: path.Join(paths.DocsSeg, paths.WikiSeg, paths.WikiLog)},
{Name: "docs/wiki/index.md", Rel: path.Join(paths.DocsSeg, paths.WikiSeg, paths.WikiIndex)},
}
}

// ReviewAgents names the two subagents scc ships. Both read and neither writes:
// review is where a cold context is worth paying for, and authorship is not.
var ReviewAgents = []string{"code-review", "security-review"}
Expand All @@ -192,9 +236,9 @@ var KnowledgeSkills = []string{"adr", "codewiki", "glossary", "prd", "stack", "w
// commandPrefix namespaces the scaffolded slash commands.
const commandPrefix = "scc-"

// Dirs returns the directories `scc init` creates even when it has no file to put
// in them. An agent that can see specs/, plans/, and docs/wiki/ knows where its
// output goes; one that has to infer the layout from a rule file guesses.
// Dirs returns every directory `scc init` creates, including the ones it has no
// file to put in. An agent that can see specs/, plans/, and docs/adr/ knows where
// its output goes; one that has to infer the layout from a rule file guesses.
func Dirs(h paths.Harness) []string {
dirs := []string{
path.Join(h.Dir, h.RulesSeg),
Expand All @@ -207,6 +251,7 @@ func Dirs(h paths.Harness) []string {
return append(dirs,
paths.SpecsSeg,
paths.PlansSeg,
paths.DocsSeg,
path.Join(paths.DocsSeg, paths.WikiSeg),
path.Join(paths.DocsSeg, paths.RawSeg),
path.Join(paths.DocsSeg, paths.ADRSeg),
Expand Down
76 changes: 75 additions & 1 deletion internal/assets/assets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,15 @@ func TestWorkspaceSetAndTreeAgree(t *testing.T) {
for _, name := range []string{"requirements.md", "design.md", "tasks.md", "plan.md"} {
referenced["artifacts/"+name] = true
}
for _, s := range Seeds() {
if _, err := Content(s.Name); err != nil {
t.Errorf("Seeds() references %q which is not embedded", s.Name)
}
referenced[s.Name] = true
}
for name := range inTree {
if !referenced[name] {
t.Errorf("embedded template %q is in no harness's Workspace() and is not an artifact template", name)
t.Errorf("embedded template %q is in no harness's Workspace(), Seeds(), and is not an artifact template", name)
}
}
}
Expand Down Expand Up @@ -472,6 +478,68 @@ func TestCommandsCarryTheirDescription(t *testing.T) {
}
}

// The seeds are the knowledge base's four fixed documents, at the paths every
// validator already looks for them. A seed written anywhere else would be a file
// nothing reads, next to the finding saying the real one is missing.
func TestSeedsLandWhereTheValidatorsLook(t *testing.T) {
want := []string{
"docs/" + paths.GlossarySeg,
"docs/" + paths.StackSeg,
"docs/" + paths.WikiSeg + "/" + paths.WikiLog,
"docs/" + paths.WikiSeg + "/" + paths.WikiIndex,
}
var got []string
for _, s := range Seeds() {
got = append(got, s.Rel)
}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("Seeds() destinations = %v, want %v", got, want)
}
for i := 1; i < len(got); i++ {
if got[i-1] >= got[i] {
t.Errorf("Seeds() is not sorted by destination: %q before %q", got[i-1], got[i])
}
}
}

// A seed is untracked by construction, so nothing else may claim it: a destination
// that is also a managed file would be recorded in one harness's manifest and
// rewritten by that harness's update, which is exactly what seeding is not.
func TestSeedsAreNotManagedFiles(t *testing.T) {
for _, h := range paths.Harnesses() {
managed := map[string]bool{}
for _, f := range Workspace(h) {
managed[f.Rel] = true
}
for _, s := range Seeds() {
if managed[s.Rel] {
t.Errorf("%s: %s is both a seed and a managed file", h.ID, s.Rel)
}
}
}
}

// docs/ is one tree per repo, not one per harness, and a seed is written verbatim
// rather than rendered. Both facts have the same consequence: a seed may not name a
// harness or carry a template action, or the knowledge base would read as belonging
// to whichever tool happened to run init first.
func TestSeedsAreHarnessNeutralAndDataFree(t *testing.T) {
for _, s := range Seeds() {
raw, err := Content(s.Name)
if err != nil {
t.Fatalf("%s: %v", s.Name, err)
}
if strings.Contains(raw, "{{") {
t.Errorf("%s: carries a template action, and seeds are written verbatim", s.Name)
}
for _, h := range paths.Harnesses() {
if strings.Contains(raw, h.Dir+"/") {
t.Errorf("%s: names %s/, and docs/ belongs to no harness", s.Name, h.Dir)
}
}
}
}

// Directories init creates on its own must be inside the workspace and
// slash-separated, for the same reason destinations must be.
func TestDirsAreRelativeAndSlashed(t *testing.T) {
Expand Down Expand Up @@ -507,6 +575,12 @@ func TestDirsCoverEveryDestination(t *testing.T) {
t.Errorf("%s: %s lands in %q, which Dirs() does not create", h.ID, f.Rel, parent)
}
}
for _, s := range Seeds() {
parent := strings.TrimSuffix(s.Rel[:strings.LastIndex(s.Rel, "/")+1], "/")
if !dirs[parent] {
t.Errorf("%s: seed %s lands in %q, which Dirs() does not create", h.ID, s.Rel, parent)
}
}
}
}

Expand Down
24 changes: 24 additions & 0 deletions internal/assets/templates/docs/glossary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Glossary

One canonical term per concept, and the synonyms nobody should use for it. Domain
vocabulary drifts by default — three names for one thing appear within a week of two
people working in parallel — so this file picks the name and the rest become
findings.

One entry per line:

```markdown
- **order total** — the amount charged, in minor units. Avoid: grand total, sum
```

The term is bold, the definition follows an em dash, and `Avoid:` lists the
synonyms it replaces, comma-separated and optional. Every synonym listed here is
reported wherever it appears as a whole word under `docs/`, so list one only when
you mean it.

Reach for the `glossary` skill when a term is coined, contested, or renamed. Define
what the thing *is*, not what it is called, and do not define what is obvious to
anyone in the domain.

<!-- Entries go below this line. Delete the guidance above once they read for
themselves. -->
26 changes: 26 additions & 0 deletions internal/assets/templates/docs/stack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Stack

Every adopted technology, with one line on why it earned its place. **Technology
that is not listed here is an open decision, never something adopted silently** —
and because dependency manifests are structured data, that rule is checkable: a
direct dependency in `go.mod` or `package.json` that is absent from this file is
reported.

So adding a dependency is a two-step act: add it, and say here why.

```markdown
## Runtime

- **the-http-router** — routing with the standard library's handler contract, no
framework underneath it.

## Development

- **the-test-runner** — what CI runs; the suite is the gate for a feature.
```

Group however the project actually thinks about it. What matters is that the name
appears and the reason is one line a reader can disagree with.

<!-- Entries go below this line. Delete the guidance above once they read for
themselves. -->
11 changes: 11 additions & 0 deletions internal/assets/templates/docs/wiki/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog

What changed in the wiki, and when. Newest first, one line per change, naming the
pages it touched — this is the log that says whether a page was revisited after the
thing it describes moved.

A page named here that no longer exists is reported, so a rename is recorded as a
change rather than left pointing at the old slug.

<!-- Newest first, as `- 2026-01-14 — added [[page-slug]], folded [[other-page]]
into it`. -->
14 changes: 14 additions & 0 deletions internal/assets/templates/docs/wiki/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Wiki

The entry point. Every page under `wiki/` has to be reachable from here — directly,
or through a page that is — because a page nothing links to is a page nobody will
find again.

Pages link to each other as `[[page-slug]]`, where the slug is the filename without
its extension. A link that resolves to no page is reported, and so is a page this
index cannot reach.

## Pages

<!-- One line per page, as `- [[page-slug]] — what it covers`. Group them under
their own headings once there are enough that a flat list stops helping. -->
64 changes: 62 additions & 2 deletions internal/cli/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,65 @@ func TestInitCreatesAWorkspace(t *testing.T) {
t.Errorf("%s was not created: %v", dir, err)
}
}
// The knowledge base's four fixed documents exist from the first run, each one
// holding the format its validator checks. A workspace that ships eight
// validators and an empty docs/ asks the user to conform to documents nobody
// gave them.
for _, path := range []string{paths.Glossary(root), paths.Stack(root),
filepath.Join(paths.Wiki(root), paths.WikiIndex), filepath.Join(paths.Wiki(root), paths.WikiLog)} {
if _, err := os.Stat(path); err != nil {
t.Errorf("%s missing after init: %v", path, err)
}
}
}

// The seeds are the user's knowledge base, not scc's files: init lays them down and
// then has no further claim on them. Recording one would put a document scc cannot
// improve into the set an update reasons about — and the two harnesses that share a
// docs/ tree would each record the other's copy.
func TestInitSeedsTheKnowledgeBaseWithoutManagingIt(t *testing.T) {
root := initWorkspace(t)
b, err := os.ReadFile(paths.Claude.Manifest(root))
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
for _, s := range assets.Seeds() {
if strings.Contains(string(b), `"`+s.Rel+`"`) {
t.Errorf("the manifest tracks %s, which is the user's knowledge base", s.Rel)
}
}

// And an edited one survives, on the same terms as every other existing file.
mine := "# Glossary\n\n- **workspace** — a directory holding a manifest.\n"
if err := os.WriteFile(paths.Glossary(root), []byte(mine), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, stderr, code := run(t, "init", "--claude", "--root", root); code != ExitOK {
t.Fatalf("second init: %d (%s)", code, stderr)
}
got, err := os.ReadFile(paths.Glossary(root))
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != mine {
t.Errorf("init overwrote an edited glossary:\n%s", got)
}
}

// A fresh knowledge base is one `scc update` has nothing to say about: the seeds are
// outside the managed set, so an update that reported them would be offering to
// rewrite documents the user authored.
func TestUpdateIgnoresTheSeededKnowledgeBase(t *testing.T) {
root := initWorkspace(t)
stdout, stderr, code := run(t, "update", "--root", root, "--json")
if code != ExitOK {
t.Fatalf("update: %d (%s)", code, stderr)
}
for _, s := range assets.Seeds() {
if strings.Contains(stdout, s.Rel) {
t.Errorf("update's plan names %s, which it does not manage:\n%s", s.Rel, stdout)
}
}
}

func TestInitJSONReportsEveryAction(t *testing.T) {
Expand All @@ -60,8 +119,9 @@ func TestInitJSONReportsEveryAction(t *testing.T) {
if err := json.Unmarshal([]byte(stdout), &got); err != nil {
t.Fatalf("stdout is not valid JSON (%v): %q", err, stdout)
}
if got.Created != len(assets.Workspace(paths.Claude)) || len(got.Changes) != len(assets.Workspace(paths.Claude)) {
t.Errorf("created = %d, changes = %d, want %d of each", got.Created, len(got.Changes), len(assets.Workspace(paths.Claude)))
want := len(assets.Workspace(paths.Claude)) + len(assets.Seeds())
if got.Created != want || len(got.Changes) != want {
t.Errorf("created = %d, changes = %d, want %d of each", got.Created, len(got.Changes), want)
}
if stderr != "" {
t.Errorf("stderr = %q, want empty so stdout can be piped", stderr)
Expand Down
Loading
Loading