diff --git a/CLAUDE.md b/CLAUDE.md index 7f6f9ef..e73e41e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -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` | `/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. | diff --git a/design/orchestration.md b/design/orchestration.md index 9208ab9..4f84158 100644 --- a/design/orchestration.md +++ b/design/orchestration.md @@ -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: diff --git a/internal/assets/assets.go b/internal/assets/assets.go index 8eb5c23..7ac63e2 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -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 @@ -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"} @@ -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), @@ -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), diff --git a/internal/assets/assets_test.go b/internal/assets/assets_test.go index 143f091..e6f789d 100644 --- a/internal/assets/assets_test.go +++ b/internal/assets/assets_test.go @@ -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) } } } @@ -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) { @@ -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) + } + } } } diff --git a/internal/assets/templates/docs/glossary.md b/internal/assets/templates/docs/glossary.md new file mode 100644 index 0000000..c324888 --- /dev/null +++ b/internal/assets/templates/docs/glossary.md @@ -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. + + diff --git a/internal/assets/templates/docs/stack.md b/internal/assets/templates/docs/stack.md new file mode 100644 index 0000000..eadee33 --- /dev/null +++ b/internal/assets/templates/docs/stack.md @@ -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. + + diff --git a/internal/assets/templates/docs/wiki/changelog.md b/internal/assets/templates/docs/wiki/changelog.md new file mode 100644 index 0000000..1fdbaa5 --- /dev/null +++ b/internal/assets/templates/docs/wiki/changelog.md @@ -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. + + diff --git a/internal/assets/templates/docs/wiki/index.md b/internal/assets/templates/docs/wiki/index.md new file mode 100644 index 0000000..893a383 --- /dev/null +++ b/internal/assets/templates/docs/wiki/index.md @@ -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 + + diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index 07effe8..b00ac4c 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -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) { @@ -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) diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 0e98a70..4772326 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -79,8 +79,9 @@ type Options struct { Force bool } -// Apply writes the template set into root. With opts.Force, existing files are -// overwritten; without it, they are left exactly as they are. +// Apply writes the template set into root, plus the docs/ seeds, which are written +// on the same never-overwrite terms and tracked in nothing. With opts.Force, +// existing files are overwritten; without it, they are left exactly as they are. // // The manifest is written last, and only when its serialization differs from what // is already on disk. Last, so a crash midway leaves no workspace marker and the @@ -110,7 +111,7 @@ func Apply(root string, opts Options) (*Result, error) { if err != nil { return nil, err } - action, err := write(root, f, pristine, prior, opts.Force) + action, err := write(root, f.Rel, pristine, prior, opts.Force) if err != nil { return nil, fmt.Errorf("%s: %w", f.Rel, err) } @@ -129,6 +130,24 @@ func Apply(root string, opts Options) (*Result, error) { next.Set(f.Rel, manifest.Hash(pristine), assets.Version) } + // The docs/ anchors, written when missing and recorded nowhere. They are the + // knowledge base's fixed documents, and the knowledge base is the user's: scc + // has nothing to deliver to glossary.md after the first write, and a manifest + // entry would only invite a later update to offer one. Untracked also settles + // what a second harness's init should do with a docs/ tree the first one + // already wrote — nothing, which is the same answer as for any existing file. + for _, s := range assets.Seeds() { + body, err := assets.Content(s.Name) + if err != nil { + return nil, err + } + action, err := write(root, s.Rel, body, prior, opts.Force) + if err != nil { + return nil, fmt.Errorf("%s: %w", s.Rel, err) + } + res.record(s.Rel, action) + } + // Entries for templates this version no longer ships are dropped: the manifest // is exactly the set of files scc manages, and a stale entry would send a later // upgrade looking for a template that does not exist. @@ -155,9 +174,10 @@ func Apply(root string, opts Options) (*Result, error) { return res, nil } -// write puts one file in place and reports what it did to whatever was there. -func write(root string, f assets.File, pristine string, prior *manifest.Manifest, force bool) (Action, error) { - dest := filepath.Join(root, filepath.FromSlash(f.Rel)) +// write puts one file in place and reports what it did to whatever was there. rel +// is slash-separated and relative to root, exactly as the manifest spells it. +func write(root, rel, pristine string, prior *manifest.Manifest, force bool) (Action, error) { + dest := filepath.Join(root, filepath.FromSlash(rel)) onDisk, err := os.ReadFile(dest) switch { case errors.Is(err, fs.ErrNotExist): @@ -178,7 +198,7 @@ func write(root string, f assets.File, pristine string, prior *manifest.Manifest // manifest recorded, or scc has no record of it at all. Both mean somebody // else's content is about to be destroyed, so both are reported as Clobbered. action := Clobbered - if e, ok := prior.Get(f.Rel); ok && manifest.Hash(string(onDisk)) == e.Hash { + if e, ok := prior.Get(rel); ok && manifest.Hash(string(onDisk)) == e.Hash { action = Replaced } return action, workspace.AtomicWrite(dest, []byte(pristine), 0o644) diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index 4037a9c..2b7e18c 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -74,8 +74,17 @@ func TestApplyWritesTheWholeSet(t *testing.T) { t.Errorf("%s: content does not match the template", f.Rel) } } - if res.Created != len(assets.Workspace(paths.Claude)) { - t.Errorf("created = %d, want %d", res.Created, len(assets.Workspace(paths.Claude))) + for _, s := range assets.Seeds() { + want, err := assets.Content(s.Name) + if err != nil { + t.Fatalf("Content: %v", err) + } + if got := read(t, root, s.Rel); got != want { + t.Errorf("%s: content does not match the seed", s.Rel) + } + } + if want := len(assets.Workspace(paths.Claude)) + len(assets.Seeds()); res.Created != want { + t.Errorf("created = %d, want %d", res.Created, want) } for _, dir := range assets.Dirs(paths.Claude) { info, err := os.Stat(filepath.Join(root, filepath.FromSlash(dir))) @@ -177,8 +186,8 @@ func TestApplyIsIdempotent(t *testing.T) { if res.Created != 0 || res.Replaced != 0 { t.Errorf("second run wrote files: created=%d replaced=%d", res.Created, res.Replaced) } - if res.Skipped != len(assets.Workspace(paths.Claude)) { - t.Errorf("skipped = %d, want %d", res.Skipped, len(assets.Workspace(paths.Claude))) + if want := len(assets.Workspace(paths.Claude)) + len(assets.Seeds()); res.Skipped != want { + t.Errorf("skipped = %d, want %d", res.Skipped, want) } if res.ManifestWritten { t.Error("second run rewrote the manifest")