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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,7 @@ internal/web/dist/*
/.mcp.json
/.githooks/
/specs/

# csdd operational state (.csdd/): local, regenerable — but commit manifests.
/.csdd/state.json
/.csdd/cache/
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/lipgloss v0.10.0
golang.org/x/text v0.3.8
)

require (
Expand All @@ -24,5 +25,4 @@ require (
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
56 changes: 56 additions & 0 deletions internal/cli/claudemd.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package cli

import (
"embed"
"os"
"strings"

"github.com/protonspy/csdd/internal/paths"
"github.com/protonspy/csdd/internal/templater"
"github.com/protonspy/csdd/internal/textutil"
"github.com/protonspy/csdd/internal/workspace"
)
Expand All @@ -15,6 +17,60 @@ const (
steeringMarkerEnd = "<!-- csdd:steering:end -->"
)

// Markers delimiting the csdd-managed knowledge-base section inside CLAUDE.md
// (the graph/wiki workflow moments + the tech-contract protocol).
const (
knowledgeMarkerStart = "<!-- csdd:knowledge:start -->"
knowledgeMarkerEnd = "<!-- csdd:knowledge:end -->"
)

// ensureKnowledgeSection writes the knowledge-base workflow moments (R16.3) and
// the tech-contract protocol (R17.4, R17.7) into the managed block of CLAUDE.md.
// It fills the empty markers a fresh template ships with, and — for a legacy
// CLAUDE.md that predates them — appends the whole section (markers + content).
// A no-op when CLAUDE.md is absent. Returns whether the file was changed.
func ensureKnowledgeSection(root string, templates embed.FS) (bool, error) {
entry := paths.Entry(root)
data, err := os.ReadFile(entry)
if err != nil {
return false, nil // no CLAUDE.md — nothing to wire
}
body, err := templater.Static(templates, "templates/root/knowledge-section.md.tmpl")
if err != nil {
return false, err
}
body = strings.TrimRight(body, "\n")
text := textutil.NormalizeNewlines(string(data))

start := strings.Index(text, knowledgeMarkerStart)
end := strings.Index(text, knowledgeMarkerEnd)
var rebuilt string
switch {
case start != -1 && end != -1 && end > start:
// Fill (or refresh) the content between existing markers.
before := text[:start+len(knowledgeMarkerStart)]
after := text[end:]
newBlock := before + "\n" + body + "\n" + after
if newBlock == text {
return false, nil
}
rebuilt = newBlock
default:
// Legacy CLAUDE.md without the markers: append the managed section.
section := "\n## Knowledge base — graph, wiki, tech contract\n\n" +
knowledgeMarkerStart + "\n" + body + "\n" + knowledgeMarkerEnd + "\n"
if strings.HasSuffix(text, "\n") {
rebuilt = text + section
} else {
rebuilt = text + "\n" + section
}
}
if err := workspace.AtomicWrite(entry, []byte(rebuilt), 0o644); err != nil {
return false, err
}
return true, nil
}

// ensureSteeringImports inserts `@.claude/steering/<name>` import lines into the
// managed block of CLAUDE.md, one per steering file name (e.g. "product.md").
// It is idempotent and a safe no-op when CLAUDE.md is missing or has no managed
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ func Run(args []string, templates embed.FS) int {
return runAgent(rest, templates)
case "mcp":
return runMCP(rest)
case "graph":
return runGraph(rest)
case "wiki":
return runWiki(rest, templates)
case "export":
return runExport(rest)
case "web", "--web":
Expand Down Expand Up @@ -139,6 +143,8 @@ RESOURCES
skill {create,list,show,add-reference,add-script,add-asset,validate,delete}
agent {create,list,show,delete}
mcp {add,install,presets,list,show,remove,enable,disable,validate}
graph {build,query,path,explain,analyze,export} The knowledge-base index (a structured brain over the workspace).
wiki {init,lint} The LLM-authored knowledge base under docs/ (scaffold + health lint).
export {kiro,codex} Convert the workspace to Kiro / Codex format.
web Launch a read-only web dashboard (live spec progress + file viewer).

Expand Down
69 changes: 69 additions & 0 deletions internal/cli/e2e_graph_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package cli

import (
"os"
"path/filepath"
"strings"
"testing"
)

// TestE2EGoldenPath drives the full knowledge-base flow through the CLI on a
// fixture workspace: init → build → analyze → query, and wiki lint — asserting
// the golden path holds end-to-end and the graph.json is byte-stable across
// rebuilds (task 15.1).
func TestE2EGoldenPath(t *testing.T) {
dir := freshWorkspace(t)
seedSpec(t, dir)

// build → graph.json exists.
if code, _, e := run(t, "graph", "build", "--root", dir); code != 0 {
t.Fatalf("graph build failed: %s", e)
}
graphPath := filepath.Join(dir, "docs", "graph", "graph.json")
first, err := os.ReadFile(graphPath)
if err != nil {
t.Fatalf("graph.json missing: %v", err)
}

// A second build with an unchanged corpus is byte-identical (§5.9, R7.2).
if code, _, e := run(t, "graph", "build", "--root", dir, "--full"); code != 0 {
t.Fatalf("second build failed: %s", e)
}
second, _ := os.ReadFile(graphPath)
if string(first) != string(second) {
t.Fatalf("graph.json not byte-stable across rebuilds")
}

// Incremental (default) also matches the full rebuild byte-for-byte.
if code, _, e := run(t, "graph", "build", "--root", dir); code != 0 {
t.Fatalf("incremental build failed: %s", e)
}
third, _ := os.ReadFile(graphPath)
if string(first) != string(third) {
t.Fatalf("incremental graph.json differs from full rebuild")
}

// analyze surfaces the seeded gap (criterion 1.2 untested); --strict gates.
if code, out, _ := run(t, "graph", "analyze", "--root", dir); code != 0 || !strings.Contains(out, "criterion") {
t.Fatalf("analyze: code=%d out=%s", code, out)
}
if code, _, _ := run(t, "graph", "analyze", "--strict", "--root", dir); code == 0 {
t.Fatalf("analyze --strict should gate non-zero on findings")
}

// query resolves the design component.
if code, out, _ := run(t, "graph", "query", "Svc", "--root", dir); code != 0 || !strings.Contains(out, "Svc") {
t.Fatalf("query: code=%d out=%s", code, out)
}

// wiki lint on the clean init scaffold passes; a dropped raw source fails it.
if code, _, _ := run(t, "wiki", "lint", "--root", dir); code != 0 {
t.Fatalf("clean wiki lint should pass")
}
if err := os.WriteFile(filepath.Join(dir, "docs", "raw", "src.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if code, _, _ := run(t, "wiki", "lint", "--root", dir); code == 0 {
t.Fatalf("unprocessed raw source should fail wiki lint")
}
}
Loading
Loading