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: 13 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ These landed after phase 10, and all are documented in `design/orchestration.md`
What scc adds over typing `codegraph` directly is the two things it already knows: the workspace root, so `scc graph build` from `specs/` indexes the repo rather than a subtree, and whether the binary is there at all. The graph itself is *not* an scc artifact — not in the manifest, never touched by `scc update`, and `.codegraph/` stays CodeGraph's directory on CodeGraph's schedule. Unlike the launch path, a missing binary in `scc graph` is a hard error: the whole command is the binary.

npm is the only installer scc will run. CodeGraph's headline install pipes a remote script into a shell (`curl … | sh`, `irm … | iex`), which is a fine thing for a person to type and not a thing scc executes on their behalf — `InstallHint` names it and leaves the decision where it belongs.
- **`scc map` and `scc patch`.** The artifacts are structured documents that happen to be Markdown, and the cost of treating them as prose is paid on every request rather than once: measured on a real workspace, one plan is 56KB and its 31 specs bring the corpus to ~90k tokens, so an agent answering "what is the next open task?" by reading the file carries the whole plan for the rest of the session. `map` turns a file into addressable pieces — `index | outline | tasks | show | blocks | find | trace` — and `patch` changes one of them — `check | uncheck | task | add | rm | append | prepend | replace | fm`.

**The addresses are the design.** A task is `1.2`, a requirement `R1.2`, a section `#notes`, a leaf `specs/<feature>/`, a paragraph `notes:7`; `L120-160` is the escape hatch and the only form that is a line number. That is what lets `patch` write into a file nobody read: a line number stops being true the moment anything above it moves, so an editor addressing by line has to read first — which is the cost the package exists to remove. The guard that reading-first was providing is replaced by three that are stronger for a structured file: an address that does not resolve is an error and never an insert at a guess, the file is re-validated afterwards and **rolled back if the edit introduced a finding** (exit 2), and the displaced and written lines are printed back — elided past a few lines, because a confirmation that echoed 400 lines would put the file in context by the back door.

Two things measurement decided rather than taste. **`blocks` exists because section addressing bottoms out**: that plan's `## Notes` is 411 lines — half the file — with no headings inside it, but every paragraph opens with a bolded thesis, so the leads alone are an index a twentieth of the size. And **a requirement id is scoped to its spec**: `R2.5` is defined in nine of those 31 specs, so `map trace R2.5` unscoped answers with the list of specs and stops rather than concatenating nine traces.

**`internal/artifact` owns the grammars, and `internal/validate` consumes them.** The task grammar used to live in the validator; a reader that disagreed with the validator about what a task is would be worse than no reader. The parser now states facts about a line (`Methodologies`, `Loose`, `HasCitation`) and turning a fact into a finding stays in `validate` — which is also what lets `map` read a malformed artifact instead of refusing exactly the file a user most needs to inspect.

**No search engine.** The obvious reach for `find` is an inverted index; at 352KB and 94 artifacts a linear pass ranks the whole workspace in 55ms, and Tantivy or its kin would cost a CGO surface or a second binary against a stdlib-only `go.mod` and a six-platform cross-compile. What precision needed here was not a better index but a better *unit*: BM25 over addressable regions rather than lines, so a hit comes back as something `show` accepts. The seam is `artifact.Search` — it takes artifacts and returns hits, and nothing outside that file knows how it found them.
- **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 @@ -79,7 +88,7 @@ cmd/scc/main.go os.Exit(cli.Run(os.Args[1:]))
|
scaffold · validate write / check
| \
assets · manifest ears · mdscan templates, hashes, grammars
assets · manifest ears · mdscan · artifact templates, hashes, grammars
|
paths · workspace · render · textutil · finding
|
Expand All @@ -101,6 +110,7 @@ Three packages sit off to the side of that tree — `rtk`, `headroom`, `codegrap
| `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/artifact` | The navigable model of one artifact, layered on `mdscan`: sections (two ends — the subtree, and the body before the first child), tasks with their continuation, requirements, decomposition leaves, paragraph blocks. Owns **every grammar** (task, requirement, spec reference), `Find` for address resolution, `Editor` for line splices resolved against the original and applied bottom-up, and `Search`. Knows nothing about findings or exit codes. |
| `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. |
Expand All @@ -126,6 +136,8 @@ Three packages sit off to the side of that tree — `rtk`, `headroom`, `codegrap

**Writes are atomic.** Use `workspace.AtomicWrite` for anything a concurrent reader might see.

**A command that edits an artifact verifies it afterwards.** `scc patch` snapshots the file, writes, re-runs the validator that owns it, and restores the snapshot if the edit introduced a finding the file did not already have. Two details are load-bearing: the comparison is on `rule + message` and deliberately **not on line number**, because an insertion moves every finding below it and comparing on line would blame this edit for the whole tail of a pre-existing problem; and an artifact scc has no validator for is written and *reported as unverified* rather than silently claimed clean.

**The marker is the file `<harness>/scc-manifest.json`, never the harness directory.** Two reasons, and both are load-bearing: every harness has a global twin in the user's home (`~/.claude`, `~/.codex`, `~/.config/opencode`) that exists on any machine running that tool, so an upward walk accepting the *directory* would resolve the root to `$HOME` for any command run outside a workspace — every command would then read and write the user's global configuration. And those directories exist in every repo that merely *uses* the tool, where scc was never initialized. `workspace.Find` therefore stats a regular file, for each harness in turn.

**scc has exactly one file per harness and no config file.** The manifest is it — content hashes, doubling as the marker. scc runs no tests and no linters, so it has nothing to configure; a project's test and lint commands are a rule under `<harness>/rules/`, which is Markdown the orchestrator already reads. Resist adding `scc.json`: a JSON schema to version, read by nothing inside the binary, is dead weight.
Expand Down
153 changes: 153 additions & 0 deletions internal/artifact/address.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package artifact

import (
"fmt"
"regexp"
"strconv"
"strings"

"github.com/protonspy/spec-claude-code/internal/paths"
)

// TargetKind says what an address resolved to, so a caller can render it in the
// vocabulary the user already types.
type TargetKind string

const (
TargetTask TargetKind = "task"
TargetRequirement TargetKind = "requirement"
TargetSection TargetKind = "section"
TargetLeaf TargetKind = "leaf"
TargetBlock TargetKind = "block"
TargetRange TargetKind = "range"
)

// Target is one resolved address: what it is, what it is called, and the lines it
// occupies.
type Target struct {
Kind TargetKind `json:"kind"`
Ref string `json:"ref"`
Label string `json:"label"`
Line int `json:"line"`
End int `json:"end"`
}

// Lines is how many lines the target covers.
func (t Target) Lines() int { return t.End - t.Line + 1 }

var (
taskRefRe = regexp.MustCompile(`^\d+(?:\.\d+)*$`)
reqRefRe = regexp.MustCompile(`^(?i:R)\d+(?:\.\d+)+$`)
rangeRefRe = regexp.MustCompile(`^(?i:L)?(\d+)(?:-(\d+))?$`)
blockRefRe = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*):(\d+)$`)
)

// Find resolves an address against the artifact.
//
// The forms, tried in this order:
//
// 1.2 a task, by its number
// R1.2 a requirement, by its id
// specs/foo/ a decomposition leaf, by the spec it names
// #notes | Notes a section, by anchor slug or by the title as written
// notes:7 the 7th paragraph of that section
// L120-160 an explicit line range, the escape hatch
//
// None of them is a line number except the last, which is why an address survives an
// edit above it. Order matters where the forms could collide: a bare number is a
// task before it is a line, because a caller who means a line writes the L.
func (a *Artifact) Find(ref string) (Target, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return Target{}, fmt.Errorf("no address given")
}

if taskRefRe.MatchString(ref) {
if t, ok := a.Task(ref); ok {
return Target{TargetTask, ref, t.Summary(72), t.Line, t.End}, nil
}
return Target{}, a.unknown("task", ref)
}
if reqRefRe.MatchString(ref) {
if r, ok := a.Requirement(ref); ok {
return Target{TargetRequirement, r.ID, clip(r.Text, 72), r.Line, r.End}, nil
}
return Target{}, a.unknown("requirement", ref)
}
if strings.HasPrefix(ref, paths.SpecsSeg+"/") {
if l, ok := a.Leaf(ref); ok {
return Target{TargetLeaf, l.Ref, clip(l.Text, 72), l.Line, l.End}, nil
}
return Target{}, a.unknown("leaf", ref)
}
if m := blockRefRe.FindStringSubmatch(ref); m != nil {
n, _ := strconv.Atoi(m[2])
for _, b := range a.Blocks() {
if b.Section == m[1] && b.Index == n {
return Target{TargetBlock, ref, clip(b.Lead, 72), b.Line, b.End}, nil
}
}
return Target{}, a.unknown("block", ref)
}
if m := rangeRefRe.FindStringSubmatch(ref); m != nil && strings.ContainsAny(ref, "Ll-") {
from, _ := strconv.Atoi(m[1])
to := from
if m[2] != "" {
to, _ = strconv.Atoi(m[2])
}
if from < 1 || from > len(a.Lines) {
return Target{}, fmt.Errorf("line %d is outside %s (%d lines)", from, a.Path, len(a.Lines))
}
if to > len(a.Lines) {
to = len(a.Lines)
}
return Target{TargetRange, ref, "", from, to}, nil
}
if s, ok := a.Section(ref); ok {
return Target{TargetSection, s.Slug, s.Title, s.Line, s.End}, nil
}
// A paragraph's own slug, which is what the notes index prints and therefore what
// a caller is most likely to paste back.
for _, b := range a.Blocks() {
if b.Slug == ref {
return Target{TargetBlock, fmt.Sprintf("%s:%d", b.Section, b.Index), clip(b.Lead, 72), b.Line, b.End}, nil
}
}
return Target{}, a.unknown("address", ref)
}

// unknown says what was not found and what the artifact does have, because an
// address that misses is nearly always a near-miss and the caller is an agent that
// cannot see the file.
func (a *Artifact) unknown(kind, ref string) error {
var have []string
switch kind {
case "task":
for _, t := range a.Tasks {
have = append(have, t.Number)
}
case "requirement":
for _, r := range a.Requirements {
have = append(have, r.ID)
}
case "leaf":
for _, l := range a.Leaves {
have = append(have, l.Ref)
}
default:
for _, s := range a.Sections {
have = append(have, "#"+s.Slug)
}
}
if len(have) == 0 {
return fmt.Errorf("no %s %q in %s, which has none", kind, ref, a.Path)
}
return fmt.Errorf("no %s %q in %s — it has %s", kind, ref, a.Path, listOf(have, 12))
}

func listOf(items []string, max int) string {
if len(items) <= max {
return strings.Join(items, ", ")
}
return strings.Join(items[:max], ", ") + fmt.Sprintf(", … (%d more)", len(items)-max)
}
Loading
Loading