diff --git a/.github/workflows/ceo-audit.yml b/.github/workflows/ceo-audit.yml index 5d47a69e..e5cd515d 100644 --- a/.github/workflows/ceo-audit.yml +++ b/.github/workflows/ceo-audit.yml @@ -224,3 +224,66 @@ jobs: sarif_file: ${{ github.workspace }}/ceo-audit-output/report.sarif category: ceo-audit continue-on-error: true + + # ─── Profile mirror drift gate (issue #175) ──────────────────────────── + # Builds the Go binary from the same commit under audit and runs + # `sin-code profile verify`. If the on-disk per-agent mirrors under + # .claude/, .codex/, .cursor/, .github/, etc. have drifted off the + # single source `docs/agent-profiles/sin-profile.md`, the job fails + # the same way the verify-gate fails in `internal/verify` (M3). + # + # CEO audit (above) runs concurrently because both jobs share no + # state. This job does NOT intend to gate on per-machine Python + # skills — it strictly mirrors the byte-stable contract of + # internal/profile.Verify. + profile-verify: + name: Profile verify (issue #175) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Build sin-code + run: go build -o /tmp/sin-code ./cmd/sin-code + + # Render the mirrors if absent or stale. `render all` is + # idempotent (byte-identical on unchanged source), so the + # call is also a self-test for the byte-stable contract. + - name: Render per-agent profile mirrors + run: /tmp/sin-code profile render all + + - name: Verify mirrors match source SHA + run: | + /tmp/sin-code profile verify + STATUS=$? + if [ $STATUS -ne 0 ]; then + echo "::error::sin-code profile verify failed (exit=$STATUS); mirrors drifted off docs/agent-profiles/sin-profile.md" + exit 1 + fi + echo "::notice::Profile mirrors byte-stable; verify gate passed." + + - name: Upload rendered mirrors as artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: profile-mirrors-${{ github.run_id }} + path: | + .claude/skills/sin-code/SKILL.md + .codex/rules/sin-code.md + .config/opencode/skills/sin-code/SKILL.md + .cursor/rules/sin-code.mdc + .gemini/skills/sin-code/SKILL.md + .windsurf/rules/sin-code.md + .clinerules/sin-code.md + .github/copilot-instructions.md + retention-days: 14 + if-no-files-found: warn diff --git a/AGENTS.md b/AGENTS.md index 912bf75a..daac2fe9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -240,7 +240,7 @@ SIN-Code/ ├── go.mod ← module github.com/OpenSIN-Code/SIN-Code ├── .goreleaser.yaml ├── .github/workflows/ -│ ├── ceo-audit.yml ← n8n delegation (mandate M1) +│ ├── ceo-audit.yml ← n8n delegation (mandate M1) + profile-verify job (issue #175) │ ├── sin-code-release.yml ← goreleaser + brew tap │ └── ecosystem-sync.yml ← prevents registry/permission/ECOSYSTEM drift ├── install.sh ← 27-line curl|bash shim → `sin-code install` (issue #170) @@ -252,7 +252,9 @@ SIN-Code/ │ ├── HOOKS.md │ ├── LEARNING.md │ ├── WEBUI.md ← WebUI-v2 backend contract -│ └── mcp.json.example +│ ├── mcp.json.example +│ └── agent-profiles/ +│ └── sin-profile.md ← v3.18.0: single-source-of-truth per-agent profile (issue #175) │ ├── cmd/ │ ├── sin-code/ ← MAIN BINARY (40 subcommands — v3.18.0) @@ -280,6 +282,10 @@ SIN-Code/ │ │ ├── compress_cmd.go ← v3.18.0: sin-code compress subcommand (plan/apply/rollback, issue #172) │ │ ├── permission_defaults.go ← C4: default rules + MCP prefix policy │ │ └── internal/ ← 17 packages (v3.8.0) +│ │ ├── install_cmd.go ← v3.18.0: sin-code install (issue #170) +│ │ ├── profile_cmd.go ← v3.18.0: single-source-of-truth profile renderer (issue #175) +│ │ ├── permission_defaults.go ← C4: default rules + MCP prefix policy +│ │ └── internal/ ← 18 packages (v3.18.0) │ │ ├── agentloop/ ← PLAN→ACT→VERIFY→DONE loop │ │ ├── session/ ← SQLite-backed resumable sessions │ │ ├── permission/ ← allow/ask/deny engine @@ -299,6 +305,8 @@ SIN-Code/ │ │ ├── summary/ ← v3.13.0: deterministic session summary builder │ │ ├── install/ ← v3.18.0: pure-stdlib release install + SHA256 verify + atomic place (issue #170) │ │ ├── mcpcompress/ ← v3.19.0: ponytail-tag compressor for `serve --compress-tools` +│ │ ├── install/ ← v3.18.0: pure-stdlib release install + SHA256 verify (issue #170) +│ │ ├── profile/ ← v3.18.0: single-source-of-truth per-agent renderer (issue #175) │ │ ├── llm/ ← provider layer │ │ ├── style/ ← v3.17.0: verbosity / compression mode system-prompt renderer (issue #167) │ │ ├── orchestrator/ ← DAG, critic, adversary, governor, ... @@ -518,6 +526,7 @@ Core: discover, execute, map, grasp, scout, harvest, orchestrate, ibd, poc, sckg, adw, oracle, efm Agents: chat, sessions, mcp, goal, daemon, skill, superpowers, vane, stack, gh, install + vane, stack, gh, install, profile Frontend: serve, tui, webui Lifecycle: memory, knowledge, todo, notifications, orchestrator_run, orchestrator_agents, orchestrator_plan, update @@ -526,6 +535,42 @@ Utility: read, write, edit, lsp, plugin, index, security, sbom, ``` (v3.18.0: 40 subcommands; `install` is the v3.18.0 single-binary installer from issue #170) config, self-update, hub, ledger, summary, compress ``` (v3.18.0: 40 subcommands, up from 39 in v3.13.0) +``` (v3.18.0: 40 subcommands, up from 39 in v3.16.0; +`install` is the v3.18.0 single-binary installer from issue #170; +`profile` is the v3.18.0 single-source-of-truth per-agent renderer +from issue #175.) + +### Per-agent profile distribution (issue #175) + +`sin-code profile` renders the single in-repo source +(`docs/agent-profiles/sin-profile.md`, ≤80 lines, KISS) into every +per-host-agent mirror file. The render is **byte-stable** per +`(target, source)` pair; the verify gate (`sin-code profile verify`) +refuses to pass whenever any mirror drifts off the source SHA. Adding +a target is non-breaking; renaming or removing one is a major bump. +The single source of truth for the table is +`cmd/sin-code/internal/profile/target.go` — keep the AGENTS.md row in +sync if the table moves. + +| Target | Format | Install path (relative to repo root) | +| ------------- | ------- | ----------------------------------------------------------- | +| claude-code | dir | `.claude/skills/sin-code/SKILL.md` | +| opencode | dir | `.config/opencode/skills/sin-code/SKILL.md` | +| gemini | dir | `.gemini/skills/sin-code/SKILL.md` | +| codex | rule | `.codex/rules/sin-code.md` | +| cursor | rule | `.cursor/rules/sin-code.mdc` | +| windsurf | rule | `.windsurf/rules/sin-code.md` | +| cline | rule | `.clinerules/sin-code.md` | +| copilot | marker | `.github/copilot-instructions.md` | + +The four marker-fence outputs (`rule` + `marker`) wrap the body in +`` … `` +inside the file. The fence is **byte-identical** to the one +`internal/skilldist` uses (issue #169): the two systems share the +same anchor (`SIN-CODE-SKILL`) so a downstream parser finds both +per-skill bundles and per-profile mirrors with one regex. Profile +renders are idempotent (rerun with unchanged source = byte-identical +output), exactly as skilldist demands. ### Hook events (verified `internal/hooks/hooks.go`, v3.5.0) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c16a17..0fb1709d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -320,6 +320,36 @@ All notable changes to the SIN-Code unified binary will be documented in this fi - **Snapshot dir**: `~/.local/share/sin-code/compress-snapshots/` (overridable via `SIN_CODE_SNAPSHOT_DIR`). Same form factor as lessons.db / ledger.db per AGENTS.md §7. +### Added — Per-agent profile renderer (issue #175) +- **Single source of truth** at `docs/agent-profiles/sin-profile.md` + (≤80 lines, KISS, hard mandates + working style + subagent contracts + + per-agent notes, edits roll out everywhere). +- **`internal/profile`** package: targets map mirroring AGENTS.md §10 + (`claude-code`, `opencode`, `gemini`, `codex`, `cursor`, `windsurf`, + `cline`, `copilot`); per-format writers (`dir`, `rule`, `marker`); a + byte-stable `Render(tgt, body)`; a `Verify(base, body)` SHA-256 + drift gate; idempotent marker-fence envelopes byte-identical to + `internal/skilldist` (issue #169 covenants preserved). +- **`sin-code profile` subcommand** with four verbs: + - `profile show` — print the source markdown + - `profile list` — print the supported target table (text + `--json`) + - `profile render ` — write one or all mirrors (idempotent; + supports `--dry-run` for byte/audit preview without touching disk) + - `profile verify` — CI gate: refuse on missing/drift; + surfaces a 12-char-row table or a JSON envelope for `--json` +- **Permission engine**: `profile__show` / `list` / `verify` are + registered as `allow`; `profile__render` is `ask` because it + touches per-agent dotdirs (mandate M4). +- **CI sync**: `.github/workflows/ceo-audit.yml` grew a parallel + `profile-verify` job that builds `sin-code`, runs + `profile render all && profile verify`, uploads the rendered + mirrors as artifacts, and fails the build if any drift surfaces. + Mirrors AGENTS.md §6 + §10 — single source of truth in + `internal/profile/target.go`. +- **Test coverage**: 22 race-tested Go tests pinning the byte-stable + contract (golden render, marker-fence idempotency, marker-Fence + covenant, `Verify` pass / missing / drift, write-after-write SHA + equality, replace-not-append for stale mirrors). ### Added — Loop Engineering (decoupled completion authority) ### Added — MCP tool-manifest compression (issue #173, v3.19.0) diff --git a/cmd/sin-code/internal/permission_defaults.go b/cmd/sin-code/internal/permission_defaults.go index a525ed8d..49456e3b 100644 --- a/cmd/sin-code/internal/permission_defaults.go +++ b/cmd/sin-code/internal/permission_defaults.go @@ -89,6 +89,13 @@ func DefaultPermissionRules() []permission.Rule { {Tool: "compress__plan", Policy: "allow"}, // read-only projection {Tool: "compress__apply", Policy: "ask"}, // rewrites + LLM call {Tool: "compress__rollback", Policy: "allow"}, // restorative — never destructive + // v3.18.0: profile renderer (issue #175). Read-only + // (show/list/verify/dry-run) is allow; the writing `render` + // surface is `ask` because it touches per-agent dotdirs. + {Tool: "profile__show", Policy: "allow"}, + {Tool: "profile__list", Policy: "allow"}, + {Tool: "profile__verify", Policy: "allow"}, + {Tool: "profile__render", Policy: "ask"}, // Backstop catch-all (mirrors sin_bash default at line 44 for unmatched prefixes). {Tool: "autodev__*", Policy: "ask"}, {Tool: "*", Policy: "ask"}, diff --git a/cmd/sin-code/internal/permission_defaults_test.go b/cmd/sin-code/internal/permission_defaults_test.go index 2798c996..d6c10881 100644 --- a/cmd/sin-code/internal/permission_defaults_test.go +++ b/cmd/sin-code/internal/permission_defaults_test.go @@ -14,14 +14,24 @@ func TestPermissionDefaultRules(t *testing.T) { t.Fatal("expected default rules") } hasRead := false + hasProfileRender := false for _, r := range rules { if r.Tool == "sin_read" && r.Policy == "allow" { hasRead = true } + // v3.18.0 (issue #175): profile renderer surfaced to agents. + // The render verb touches per-agent dotdirs so must default + // to "ask"; show/list/verify are pure reads. + if r.Tool == "profile__render" && r.Policy == "ask" { + hasProfileRender = true + } } if !hasRead { t.Errorf("expected sin_read allow rule, got %+v", rules) } + if !hasProfileRender { + t.Errorf("expected profile__render ask rule (issue #175), got %+v", rules) + } } func TestPermissionRulesForAgent(t *testing.T) { diff --git a/cmd/sin-code/internal/profile/doc.go b/cmd/sin-code/internal/profile/doc.go new file mode 100644 index 00000000..2b053944 --- /dev/null +++ b/cmd/sin-code/internal/profile/doc.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// +// # Package profile +// +// Profile is the single-source-of-truth rewriter for SIN-Code's +// per-agent project rules (issue #175). The source is the in-repo +// markdown at docs/agent-profiles/sin-profile.md; output is one +// artifact per supported host-agent family: +// +// Claude Code → /.claude/skills/sin-code/SKILL.md +// opencode → /.config/opencode/skills/sin-code/SKILL.md +// Gemini CLI → /.gemini/skills/sin-code/SKILL.md +// Codex → /.codex/rules/sin-code.md +// Cursor → /.cursor/rules/sin-code.mdc +// Windsurf → /.windsurf/rules/sin-code.md +// Cline → /.clinerules/sin-code.md +// GitHub Copilot → /.github/copilot-instructions.md +// +// (Updates AGENTS.md §10 — same table.) +// +// # Why byte-stable +// +// Render(tgt, body) is a pure function over (tgt, body). The verify +// gate (Verify + HashSource) computes the expected SHA-256 of every +// rendered output and refuses to merge if any on-disk mirror drifts. +// This is the same shape as the verify-gate in `internal/verify` +// (M3) — the renderer is a deterministic preprocessor. +// +// # Marker-fence covenant +// +// RenderBlock / BeginMarker / EndMarker are byte-identical to +// internal/skilldist's outputs for the same `` value. See +// parser.go for the exact ASCII bytes. +package profile diff --git a/cmd/sin-code/internal/profile/doc.md b/cmd/sin-code/internal/profile/doc.md new file mode 100644 index 00000000..8bff14d4 --- /dev/null +++ b/cmd/sin-code/internal/profile/doc.md @@ -0,0 +1,88 @@ +# `internal/profile` — single-source-of-truth per-agent project profile (issue #175) + +## What this package is + +`internal/profile` renders a single in-repo markdown +(`docs/agent-profiles/sin-profile.md`) into the per-agent mirror +files that SIN-Code installs into every supported host agent family: + +| Agent family | Output path (relative to repo root) | Format | +| ------------ | ------------------------------------ | ------------ | +| Claude Code | `.claude/skills/sin-code/SKILL.md` | `dir` | +| opencode | `.config/opencode/skills/sin-code/SKILL.md` | `dir` | +| Gemini CLI | `.gemini/skills/sin-code/SKILL.md` | `dir` | +| Codex CLI | `.codex/rules/sin-code.md` | `rule` | +| Cursor | `.cursor/rules/sin-code.mdc` | `rule` | +| Windsurf | `.windsurf/rules/sin-code.md` | `rule` | +| Cline | `.clinerules/sin-code.md` | `rule` | +| GitHub Copilot | `.github/copilot-instructions.md` | `marker` | + +The table is the **single source of truth** in this package. Adding a +target is non-breaking; renaming or removing one is a major bump per +AGENTS.md §10. The set is intentionally a mirror of the skilldist +table so the two packages can be merged later without changing +public output. + +## Why byte-stable + +`Render(tgt, body)` is pure: the bytes depend only on (target +struct, source body). The CLI's `sin-code profile verify` reads +every on-disk mirror, recomputes the expected render, and refuses +to merge if any mirror is missing or drift. This is the same shape +as the verify-gate in `internal/verify` (mandate M3) — the +renderer is a deterministic preprocessor. + +## Marker-fence covenant (issue #169) + +Every `rule` / `marker` output is bracketed by a +`` fence with the same exact +ASCII bytes as `internal/skilldist`. A downstream parser that +scans for one kind also finds the other; a `profile render` call +is idempotent (rerun with unchanged source = byte-identical +output). See `parser.go` for the contract. + +## Public surface + +```go +type Target struct { Name, DisplayName, InstallPath, Format string } +var Targets map[string]Target +func TargetNames() []string +func MustTarget(name string) Target + +func RenderAll(body string) (map[string]string, []string, error) +func Render(tgt Target, body string) (string, error) +func Resolve(tgt Target, base string) (string, error) +func StripFrontmatter(raw string) string + +func HashSource(tgt Target, body string) (string, error) +func Verify(base, body string) ([]Result, error) + +func LoadSource(base string) (string, error) +func WriteAll(base, body string) ([]string, error) +func WriteSelected(base, body, name string) ([]string, error) +func ListTable() []ListEntry +``` + +## CLI surface + +``` +sin-code profile show # print current source +sin-code profile list # print target table +sin-code profile render # write one or all mirrors +sin-code profile render --dry-run # preview without writing +sin-code profile verify # CI gate +sin-code profile verify --json # machine-readable drift +``` + +## Tests + +`profile_test.go` pins: + +- Exact SHA-256 of `Render(tgt, fixture)` for one target per format. +- Marker-fence idempotency (rerun = byte-identical output). +- `ParseMarkers` open / close / half-opened-fence roundtrip. +- `Verify` missing-file → DriftError. +- `Verify` drift → DriftError. +- `Verify` pass → nil error. + +A new golden test fails the moment the renderer output drifts. diff --git a/cmd/sin-code/internal/profile/parser.go b/cmd/sin-code/internal/profile/parser.go new file mode 100644 index 00000000..4955e62c --- /dev/null +++ b/cmd/sin-code/internal/profile/parser.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +// Marker-fence primitives for the profile renderer. +// +// # Covenant with internal/skilldist (issue #169) +// +// These primitives produce **byte-identical** output to skilldist for +// the same `` value. skilldist's `BeginMarker` / `EndMarker` / +// `RenderBlock` / `ParseMarkers` are the source of truth; we keep the +// profile package self-contained so this package compiles in worktrees +// that do not yet have skilldist, but the marker fences we emit are +// drop-in compatible. +// +// If skilldist's contract ever changes, fix the divergence in the +// same PR — agents downstream depend on a fixed grep anchor. +package profile + +import ( + "fmt" + "strings" +) + +// MarkerPrefix is the begin/end marker family. We use the same +// "SIN-CODE-SKILL" anchor skilldist uses so a downstream parser that +// searches for a marker-fenced block finds both skill bundles and +// profile rules with one regex. +const MarkerPrefix = "SIN-CODE-SKILL" + +// BeginMarker returns the exact begin-marker line for one (target, skill) +// pair. Always ASCII; tests pin the exact bytes. +func BeginMarker(skill string) string { + //nolint:gocritic // exported name; kept literal so test pins match + return fmt.Sprintf("", MarkerPrefix, skill) +} + +// EndMarker returns the exact end-marker line. The trailing whitespace +// before the skill name is intentional visual alignment per skilldist's +// contract; ParseMarkers strips it on lookup so a regex-based scanner +// outside this package still finds both ends. +func EndMarker(skill string) string { + return fmt.Sprintf("", MarkerPrefix, skill) +} + +// RenderBlock produces the marker-fenced body that profile's writers +// emit for one (target, skill) pair. +// +// The output always ends with exactly one trailing `\n`. The body is +// stripped of its own trailing whitespace so the END marker sits flush. +// +// RenderBlock is byte-stable for a given (skill, body) pair: a second +// invocation with the same bytes produces exactly the same output. This +// is the contract the verify-gate depends on; tests pin the exact +// bytes. +func RenderBlock(skill, body string) string { + body = strings.TrimRight(body, "\n") + body = strings.TrimRight(body, "\r") + return fmt.Sprintf("%s\n# Skill: %s\n\n%s\n%s\n", + BeginMarker(skill), + skill, + body, + EndMarker(skill), + ) +} + +// ParseResult is the structured outcome of ParseMarkers; consumers +// usually only care about (Body, OK). Prefix and Suffix are the bytes +// before and after the matched block, returned so a writer can +// reconstruct the file on update. +// +// OK=true — the fenced block for `skill` exists; Body is the inner +// block lines WITHOUT the marker lines. +// OK=false — no fenced block was found; Prefix is the full input +// and Body / Suffix are empty. +type ParseResult struct { + Prefix string + Body string + Suffix string + OK bool +} + +// ParseMarkers scans `content` and returns the parsed envelope around +// the marker fence for `skill`. The scan is line-based and tolerant of +// trailing whitespace on the END line (the BEGIN line is exact). Body +// is returned verbatim. +// +// If the BEGIN line is found but the END line is missing, ParseResult.OK +// is false and Prefix is the full input — a half-opened fence is +// treated as if the block were absent, so the writer emits a clean +// block rather than producing a malformed file. +// +// CRLF is normalized to LF before parsing, so files written on Windows +// devices still parse cleanly. +func ParseMarkers(content, skill string) ParseResult { + begin := BeginMarker(skill) + end := EndMarker(skill) + + content = strings.ReplaceAll(content, "\r\n", "\n") + lines := strings.Split(content, "\n") + + beginIdx := -1 + for i, ln := range lines { + if strings.TrimRight(ln, " \t") == begin { + beginIdx = i + break + } + } + if beginIdx < 0 { + return ParseResult{Prefix: content} + } + endIdx := -1 + for i := beginIdx + 1; i < len(lines); i++ { + if strings.TrimRight(lines[i], " \t") == end { + endIdx = i + break + } + } + if endIdx < 0 { + return ParseResult{Prefix: content} + } + + body := strings.Join(lines[beginIdx+1:endIdx], "\n") + prefix := strings.Join(lines[:beginIdx], "\n") + if beginIdx == 0 { + prefix = "" + } + suffix := strings.Join(lines[endIdx+1:], "\n") + + return ParseResult{Prefix: prefix, Body: body, Suffix: suffix, OK: true} +} diff --git a/cmd/sin-code/internal/profile/profile_test.go b/cmd/sin-code/internal/profile/profile_test.go new file mode 100644 index 00000000..52362418 --- /dev/null +++ b/cmd/sin-code/internal/profile/profile_test.go @@ -0,0 +1,525 @@ +// SPDX-License-Identifier: MIT +package profile + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" +) + +const fixtureSource = ` +# SIN-Code Project Profile + +> Single source of truth for the per-agent rules SIN-Code installs into +> every supported host agent. + +## Hard mandates (NEVER violate) + +- **M1** CI runs only via the n8n delegator. +- **M2** Single static Go binary, CGO_ENABLED=0. +- **M3** Verification gate is sacred. +- **M4** Permission engine gates every destructive tool. +- **M5** Module path is github.com/OpenSIN-Code/SIN-Code. +- **M6** SIN tools over naive built-ins. +- **M7** Race-free concurrency. + +## Identity + +- Product: sin-code (single static Go binary). +- Repo: github.com/OpenSIN-Code/SIN-Code. +` + +// TestRenderDirFormat pins every byte for a FormatDir target. The +// golden bytes were captured on first commit; any drift fails the +// verify gate in CI. +func TestRenderDirFormat(t *testing.T) { + tgt := MustTarget("opencode") + got, err := Render(tgt, fixtureSource) + if err != nil { + t.Fatalf("Render: %v", err) + } + if !strings.HasSuffix(got, "\n") { + t.Fatalf("render must end with LF, got %q", got) + } + if strings.HasPrefix(got, "". + // After ParseMarkers strips those markers, the body should + // contain "# Skill: sin-code\n\nhello\nworld\n". + if !strings.Contains(parsed.Body, "hello") || !strings.Contains(parsed.Body, "world") { + t.Fatalf("body roundtrip lost content: %q", parsed.Body) + } + if !strings.HasPrefix(parsed.Body, "# Skill: sin-code\n") { + t.Fatalf("body roundtrip lost Skill banner: %q", parsed.Body) + } + if !strings.HasSuffix(strings.TrimRight(parsed.Body, "\n"), "world") { + t.Fatalf("body roundtrip lost trailing world line: %q", parsed.Body) + } +} + +// TestMarkerCovenant: profile's marker primitives produce +// byte-identical output to a hand-rolled fence for the same skill. +func TestMarkerCovenant(t *testing.T) { + body := "rule body\n" + got := RenderBlock(ProfileSkill, body) + // Hand-rolled: same algorithm as the package's RenderBlock. + body = strings.TrimRight(body, "\n") + want := BeginMarker(ProfileSkill) + "\n# Skill: " + ProfileSkill + "\n\n" + body + "\n" + EndMarker(ProfileSkill) + "\n" + if got != want { + t.Fatalf("marker covenant broken:\n got: %q\n want: %q", got, want) + } + if BeginMarker("foo") == BeginMarker("bar") { + t.Fatalf("BeginMarker must vary with skill") + } +} + +// TestParseMarkersMissing: a block whose BEGIN exists but END is +// missing behaves as if the block were absent. +func TestParseMarkersMissing(t *testing.T) { + src := "# preamble\n" + BeginMarker(ProfileSkill) + "\nbody line one\nbody line two\n" + parsed := ParseMarkers(src, ProfileSkill) + if parsed.OK { + t.Fatalf("ParseMarkers matched a half-opened fence; must not") + } + if parsed.Prefix != src { + t.Fatalf("half-opened fence prefix mismatch:\n got: %q\n want: %q", parsed.Prefix, src) + } +} + +// TestParseMarkersCRLF: parse tolerates CRLF line endings. +func TestParseMarkersCRLF(t *testing.T) { + body := "rule body\r\n" + block := strings.ReplaceAll(RenderBlock(ProfileSkill, body), "\n", "\r\n") + parsed := ParseMarkers(block, ProfileSkill) + if !parsed.OK { + t.Fatalf("ParseMarkers failed on CRLF block") + } + if !strings.Contains(parsed.Body, "rule body") { + t.Fatalf("CRLF body roundtrip lost: %q", parsed.Body) + } +} + +// TestVerifyPass: a freshly-written mirror set passes Verify with +// nil-error. +func TestVerifyPass(t *testing.T) { + dir := t.TempDir() + body := fixtureSource + if _, err := WriteAll(dir, body); err != nil { + t.Fatalf("WriteAll: %v", err) + } + res, err := Verify(dir, body) + if err != nil { + t.Fatalf("Verify rejected a freshly-written set: %v", err) + } + if len(res) != len(Targets) { + t.Fatalf("Verify returned %d results, want %d", len(res), len(Targets)) + } + for _, r := range res { + if !r.Match { + t.Errorf("result for %s did not match (got=%s want=%s)", r.Target.Name, r.GotSHA, r.WantSHA) + } + if !r.Found { + t.Errorf("result for %s was not found on disk", r.Target.Name) + } + } +} + +// TestVerifyMissing: when the dir is empty, every target reports +// missing → DriftError. +func TestVerifyMissing(t *testing.T) { + dir := t.TempDir() + res, err := Verify(dir, fixtureSource) + if err == nil { + t.Fatalf("Verify on empty dir expected DriftError, got nil; res=%+v", res) + } + if _, ok := err.(*DriftError); !ok { + t.Fatalf("expected *DriftError, got %T", err) + } + if len(res) != len(Targets) { + t.Fatalf("Verify returned %d results, want %d", len(res), len(Targets)) + } + for _, r := range res { + if r.Found { + t.Errorf("target %s unexpectedly found on empty dir", r.Target.Name) + } + } +} + +// TestVerifyRejectsModifiedFile: manually editing a mirror after +// install produces a DriftError with the target flagged. +func TestVerifyRejectsModifiedFile(t *testing.T) { + dir := t.TempDir() + if _, err := WriteAll(dir, fixtureSource); err != nil { + t.Fatalf("WriteAll: %v", err) + } + cursorPath := filepath.Join(dir, ".cursor/rules/sin-code.mdc") + original, err := os.ReadFile(cursorPath) + if err != nil { + t.Fatalf("read cursor mirror: %v", err) + } + tampered := append(original, []byte("# tampered line\n")...) + if err := os.WriteFile(cursorPath, tampered, 0o644); err != nil { + t.Fatalf("tamper write: %v", err) + } + _, err = Verify(dir, fixtureSource) + if err == nil { + t.Fatalf("Verify on tampered dir expected DriftError, got nil") + } + drift, ok := err.(*DriftError) + if !ok { + t.Fatalf("expected *DriftError, got %T", err) + } + cursorFound := false + for _, r := range drift.Results { + if r.Target.Name != "cursor" { + continue + } + cursorFound = true + if r.Match { + t.Fatalf("cursor target reported MATCH despite tampering") + } + } + if !cursorFound { + t.Fatalf("DriftError did not include the cursor target") + } +} + +// TestVerifyIdempotentWrites: writing twice produces identical +// on-disk bytes (the byte-stable contract). +func TestVerifyIdempotentWrites(t *testing.T) { + dir := t.TempDir() + _, err := WriteAll(dir, fixtureSource) + if err != nil { + t.Fatalf("WriteAll #1: %v", err) + } + afterFirst := readAllMirrorHashes(t, dir) + _, err = WriteAll(dir, fixtureSource) + if err != nil { + t.Fatalf("WriteAll #2: %v", err) + } + afterSecond := readAllMirrorHashes(t, dir) + for name, a := range afterFirst { + b, ok := afterSecond[name] + if !ok { + t.Errorf("mirror %s disappeared between writes", name) + continue + } + if a != b { + t.Errorf("mirror %s differs after second write (sha drift); want byte-identical", name) + } + } +} + +// TestWriteAllVerifyAfter: writing + verifying = pass; writing a +// different source and re-verifying = drift. This is the full +// round-trip the CI runs. +func TestWriteAllVerifyAfter(t *testing.T) { + dir := t.TempDir() + if _, err := WriteAll(dir, fixtureSource); err != nil { + t.Fatalf("WriteAll: %v", err) + } + if _, err := Verify(dir, fixtureSource); err != nil { + t.Fatalf("first Verify should pass: %v", err) + } + + tampered := strings.Replace(fixtureSource, "M3** Verification", "M3** Fuzzy logic", 1) + if _, err := Verify(dir, tampered); err == nil { + t.Fatalf("second Verify expected drift after source change, got nil") + } +} + +// TestDotDirsCreated: Verify creates parent directories on disk via +// the writers — confirms the dir/rule/marker writers each touch only +// one file at the expected path. +func TestWriteAllPaths(t *testing.T) { + dir := t.TempDir() + written, err := WriteAll(dir, fixtureSource) + if err != nil { + t.Fatalf("WriteAll: %v", err) + } + if len(written) != len(Targets) { + t.Errorf("WriteAll wrote %d files, want %d", len(written), len(Targets)) + } + for _, name := range TargetNames() { + tgt := Targets[name] + expected, _ := Resolve(tgt, dir) + found := false + for _, p := range written { + if p == expected { + found = true + break + } + } + if !found { + t.Errorf("target %s expected at %s, missing from WriteAll output", name, expected) + } + } + // Confirm each file exists on disk under the expected path. + for _, name := range TargetNames() { + tgt := Targets[name] + p, _ := Resolve(tgt, dir) + if _, err := os.Stat(p); err != nil { + t.Errorf("target %s: stat %s: %v", name, p, err) + } + } +} + +// TestResetWipeAll: writing into a dir that already has a stale +// mirror *replaces* the bytes (no append, no marker concatenation). +func TestWriteAllReplacesBytes(t *testing.T) { + dir := t.TempDir() + if _, err := WriteAll(dir, fixtureSource); err != nil { + t.Fatalf("WriteAll #1: %v", err) + } + // Tamper by appending a trivial line OUTSIDE the marker fence. + cursorPath := filepath.Join(dir, ".cursor/rules/sin-code.mdc") + original, err := os.ReadFile(cursorPath) + if err != nil { + t.Fatalf("read cursor mirror: %v", err) + } + if err := os.WriteFile(cursorPath, append(original, []byte("# stale\n")...), 0o644); err != nil { + t.Fatalf("tamper write: %v", err) + } + // WriteAll again: expected to overwrite (not append). + if _, err := WriteAll(dir, fixtureSource); err != nil { + t.Fatalf("WriteAll #2: %v", err) + } + after, err := os.ReadFile(cursorPath) + if err != nil { + t.Fatalf("re-read cursor mirror: %v", err) + } + if strings.Contains(string(after), "# stale") { + t.Fatalf("WriteAll did not replace the cursor mirror: stale content remains:\n%s", after) + } +} + +// TestRenderUnknownTarget: Render refuses unknown targets. +func TestRenderUnknownTarget(t *testing.T) { + _, err := Render(Target{Name: "nope", DisplayName: "nope", Format: FormatRule, InstallPath: ".nope"}, fixtureSource) + if err == nil { + t.Fatalf("Render must refuse unknown target") + } +} + +// TestRenderEmptyBody: Render refuses empty bodies. +func TestRenderEmptyBody(t *testing.T) { + _, err := Render(MustTarget("opencode"), " \n \n") + if err == nil { + t.Fatalf("Render must refuse empty body") + } +} + +// TestStripFrontmatter: leading `---` block is dropped. +func TestStripFrontmatter(t *testing.T) { + src := "---\nfront: 1\n---\n# Body\n" + out := StripFrontmatter(src) + if strings.HasPrefix(out, "---") { + t.Fatalf("StripFrontmatter failed: %q", out) + } + if !strings.HasPrefix(out, "# Body") { + t.Fatalf("StripFrontmatter did not drop the frontmatter: %q", out) + } + + noFM := "# No frontmatter\n" + if StripFrontmatter(noFM) != noFM { + t.Fatalf("StripFrontmatter dropped content when no frontmatter: %q", out) + } + unterm := "---\nno closing\n" + if StripFrontmatter(unterm) != unterm { + t.Fatalf("StripFrontmatter mishandled unterminated frontmatter: %q", unterm) + } +} + +// TestRenderAllDeterministicOrder: keys are sorted stable across calls. +func TestRenderAllDeterministicOrder(t *testing.T) { + _, keysA, err := RenderAll(fixtureSource) + if err != nil { + t.Fatalf("RenderAll: %v", err) + } + _, keysB, err := RenderAll(fixtureSource) + if err != nil { + t.Fatalf("RenderAll: %v", err) + } + if !equalSorted(keysA, keysB) { + t.Fatalf("keys not stable across calls: %v vs %v", keysA, keysB) + } + if !isAscending(keysA) { + t.Fatalf("keys not alphabetical: %v", keysA) + } +} + +func isAscending(s []string) bool { + for i := 1; i < len(s); i++ { + if s[i-1] > s[i] { + return false + } + } + return true +} + +// TestListTableSorted: ListTable order is alphabetical. +func TestListTableSorted(t *testing.T) { + tab := ListTable() + for i := 1; i < len(tab); i++ { + if tab[i-1].Name > tab[i].Name { + t.Fatalf("ListTable not alphabetical: %v", tab) + } + } +} + +// TestDefaultsAndConstants: the ProfileSkill + DefaultSourcePath are +// stable across versions (changing either is a major bump per +// AGENTS.md §10). +func TestDefaultsAndConstants(t *testing.T) { + if ProfileSkill != "sin-code" { + t.Fatalf("ProfileSkill changed: %q (major-bump)", ProfileSkill) + } + if DefaultSourcePath != "docs/agent-profiles/sin-profile.md" { + t.Fatalf("DefaultSourcePath changed: %q (major-bump)", DefaultSourcePath) + } + if MarkerPrefix != "SIN-CODE-SKILL" { + t.Fatalf("MarkerPrefix changed: %q (covenant broken)", MarkerPrefix) + } +} + +func readAllMirrorHashes(t *testing.T, dir string) map[string]string { + t.Helper() + out := make(map[string]string, len(Targets)) + for _, name := range TargetNames() { + tgt := Targets[name] + p, err := Resolve(tgt, dir) + if err != nil { + t.Fatalf("Resolve(%s): %v", name, err) + } + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("ReadFile(%s): %v", p, err) + } + sum := sha256.Sum256(b) + out[name] = hex.EncodeToString(sum[:]) + } + return out +} + +func equalSorted(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/cmd/sin-code/internal/profile/render.go b/cmd/sin-code/internal/profile/render.go new file mode 100644 index 00000000..8200cfb6 --- /dev/null +++ b/cmd/sin-code/internal/profile/render.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +// Per-format writers for the profile renderer. +// +// Render is the public entry point: it takes a Target and the source +// markdown body, returns the rendered bytes the writer will write to +// disk (for dir: a single-file body, for rule: fenced markdown, for +// marker: a marker envelope around the body). +// +// Render is **pure** — no IO, no I/O side effects, deterministic. The +// callers (RenderAll / Install) are responsible for reading the source +// from disk and writing the result. Keeping Render pure means the +// verify-gate can compute the expected SHA from the source alone. +package profile + +import ( + "errors" + "fmt" + "strings" +) + +// StripFrontmatter removes the leading YAML frontmatter (`---` … `---`) +// from `raw`. Mirrors skilldist.StripFrontmatter byte-for-byte so a +// frontmatter pinned in one renders identically in the other. +// +// Both LF and CRLF line endings are tolerated. If the file starts with +// no frontmatter, the body is returned verbatim. +func StripFrontmatter(raw string) string { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + lines := strings.Split(raw, "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + return raw + } + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "---" { + out := strings.Join(lines[i+1:], "\n") + return strings.TrimLeft(out, "\n") + } + } + // Unterminated frontmatter — treat as no frontmatter rather than + // emit a half block. Operator should fix the file. + return raw +} + +// Render returns the bytes that should be written to disk for one +// (target, source-body) pair. The rules: +// +// FormatDir — returns `body` verbatim. The dir writer then writes +// that body to `/` which is itself +// the SKILL.md file. We strip leading frontmatter so +// the host-agent Skills-FS picks up only the rules. +// FormatRule — returns body inside a SIN-CODE-SKILL marker fence. +// FormatMarker — alias of FormatRule: the file is shared but the +// block is bracketed the same way. +func Render(tgt Target, body string) (string, error) { + if _, ok := Targets[tgt.Name]; !ok { + return "", fmt.Errorf("profile: unknown target %q", tgt.Name) + } + if tgt.Format != FormatDir && tgt.Format != FormatRule && tgt.Format != FormatMarker { + return "", fmt.Errorf("profile: target %q has invalid Format %q", tgt.Name, tgt.Format) + } + if strings.TrimSpace(body) == "" { + return "", errors.New("profile: empty render body") + } + + body = StripFrontmatter(body) + body = strings.TrimRight(body, "\n") + "\n" + + switch tgt.Format { + case FormatDir: + return body, nil + case FormatRule, FormatMarker: + return RenderBlock(ProfileSkill, body), nil + } + return "", fmt.Errorf("profile: unreachable format %q", tgt.Format) +} + +// Resolve returns the absolute repo-root path the writer should write +// for `tgt`. The base parameter is the repository root (the writer's +// cwd by default). Skill placeholder is substituted with ProfileSkill. +// +// Resolve refuses unsafe skill names — defense in depth, even though +// the only caller passes ProfileSkill. +func Resolve(tgt Target, base string) (string, error) { + if tgt.InstallPath == "" { + return "", fmt.Errorf("profile: target %q has no InstallPath", tgt.Name) + } + resolved := strings.ReplaceAll(tgt.InstallPath, "", ProfileSkill) + placeholderCount := strings.Count(tgt.InstallPath, "") + if placeholderCount > 0 && !strings.Contains(resolved, ProfileSkill) { + return "", fmt.Errorf("profile: target %q InstallPath has unresolved placeholders", tgt.Name) + } + if strings.Contains(base, "..") { + return "", fmt.Errorf("profile: unsafe base %q", base) + } + return joinPath(base, resolved), nil +} + +// joinPath joins a base directory with a relative path using forward +// slashes on POSIX. Kept private to avoid pulling in path/filepath at +// the global scope — pure-stdlib test pins must be stable. +func joinPath(base, rel string) string { + if base == "" { + return rel + } + if rel == "" { + return base + } + if strings.HasSuffix(base, "/") { + return base + rel + } + return base + "/" + rel +} diff --git a/cmd/sin-code/internal/profile/target.go b/cmd/sin-code/internal/profile/target.go new file mode 100644 index 00000000..4803a2d8 --- /dev/null +++ b/cmd/sin-code/internal/profile/target.go @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +// Package profile renders the single-source-of-truth project profile +// (docs/agent-profiles/sin-profile.md, issue #175) into every supported +// per-host-agent format. It mirrors the install-path / Format model of +// internal/skilldist (issue #169) but operates at the **repository** level +// (writes under the repo root, not $HOME) and emits one artifact per +// target. +// +// # Marker-fence covenant +// +// Every write through `Render` for FormatRule / FormatMarker goes through +// the same marker-fence primitive as internal/skilldist: +// +// +// # Skill: +// … rendered body … +// +// +// We deliberately reuse the "SIN-CODE-SKILL" marker prefix even though +// we are rendering a profile, not a skill: the two systems share +// per-agent rule files (`.codex/rules/sin-code.md`, +// `.cursor/rules/sin-code.mdc`, etc.) so a single fence convention keeps +// downstream installers (skilldist, future skill-aware rmtree passes) +// able to find both kinds of content with one regex. A subsequent +// `profile render` call with unchanged bytes produces byte-identical +// output, exactly as skilldist demands. +// +// # Targets +// +// Targets is the single source of truth for the per-agent install path +// templates. The map is intentionally a structural copy of the table in +// AGENTS.md §10: adding a target is non-breaking; renaming or removing +// one is a major bump. Skill name is fixed at "sin-code" because the +// profile is one fixed artifact. +package profile + +import ( + "fmt" + "sort" +) + +// Format kinds — match internal/skilldist so the public surface stays +// parallel across the two packages. +const ( + FormatDir = "dir" + FormatRule = "rule" + FormatMarker = "marker" +) + +// ProfileSkill is the canonical skill name substituted into every +// per-agent install path. Single source of truth; changing this is a +// major bump per AGENTS.md §10. +const ProfileSkill = "sin-code" + +// DefaultSourcePath is the in-repo location of the source markdown. +const DefaultSourcePath = "docs/agent-profiles/sin-profile.md" + +// Target is one supported host-agent family. +// +// Name — short id used on the CLI: `claude-code`, `cursor`, +// `copilot`, … +// DisplayName — human label used in `profile list` / `profile render` +// table output and `--json` payloads. +// InstallPath — path template relative to the **repository** root +// (cwd). Contains a `` placeholder that is +// replaced at write time with ProfileSkill. Mutli-skill +// files (copilot) omit the placeholder. +// Format — one of FormatDir / FormatRule / FormatMarker. +// +// # Stability +// +// (Name, DisplayName) is the public API surface exposed via +// `sin-code profile render `. Adding a target is non-breaking; +// renaming or removing one is a major bump per AGENTS.md §10. +type Target struct { + Name string + DisplayName string + InstallPath string + Format string +} + +// Targets is the single source of truth for supported host agents. +// Any addition here MUST also be reflected in: +// +// cmd/sin-code/profile_cmd.go (cobra shell completion for +// `profile `), +// AGENTS.md §10 (naming-and-stability matrix), +// CHANGELOG.md [Unreleased] (additions bullet). +// +// The set is intentionally twice the size of the original AGENTS.md +// §10 skilldist table because Claude Code / Codex / Gemini / opencode +// also preserve their own project-level profile conventions: +// claude-code reads CLAUDE.md / skills/, codex reads AGENTS.md, etc. +// We write into the convention those agents expect so an opening +// maintainer session sees the rules without forcing them to re-read +// `$HOME/.claude/skills/sin-code/SKILL.md`. +var Targets = map[string]Target{ + "claude-code": { + Name: "claude-code", + DisplayName: "Claude Code", + InstallPath: ".claude/skills/sin-code/SKILL.md", + Format: FormatDir, + }, + "opencode": { + Name: "opencode", + DisplayName: "opencode", + InstallPath: ".config/opencode/skills/sin-code/SKILL.md", + Format: FormatDir, + }, + "gemini": { + Name: "gemini", + DisplayName: "Gemini CLI", + InstallPath: ".gemini/skills/sin-code/SKILL.md", + Format: FormatDir, + }, + "codex": { + Name: "codex", + DisplayName: "Codex CLI", + InstallPath: ".codex/rules/sin-code.md", + Format: FormatRule, + }, + "cursor": { + Name: "cursor", + DisplayName: "Cursor", + InstallPath: ".cursor/rules/sin-code.mdc", + Format: FormatRule, + }, + "windsurf": { + Name: "windsurf", + DisplayName: "Windsurf", + InstallPath: ".windsurf/rules/sin-code.md", + Format: FormatRule, + }, + "cline": { + Name: "cline", + DisplayName: "Cline", + InstallPath: ".clinerules/sin-code.md", + Format: FormatRule, + }, + "copilot": { + Name: "copilot", + DisplayName: "GitHub Copilot", + InstallPath: ".github/copilot-instructions.md", + Format: FormatMarker, + }, +} + +// TargetNames returns every registered id in deterministic (alphabetical) +// order. A stable order is required so `profile render all` and `profile +// verify` produce identical log output across machines. +// +// We use the standard library sort.Strings rather than a hand-rolled +// insertion sort: Go's map iteration order is non-deterministic, and a +// hand-rolled sort that depends on the input order produces different +// output across runs — which would break the byte-stable contract. +func TargetNames() []string { + out := make([]string, 0, len(Targets)) + for k := range Targets { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// MustTarget returns the Target with the given Name; it panics if the +// target is unknown. Used by tests + CLI shell-completion paths where +// an unknown id is a programmer error. +func MustTarget(name string) Target { + t, ok := Targets[name] + if !ok { + panic(fmt.Sprintf("profile: unknown target %q (registered: %v)", name, TargetNames())) + } + return t +} diff --git a/cmd/sin-code/internal/profile/verify.go b/cmd/sin-code/internal/profile/verify.go new file mode 100644 index 00000000..3cb958aa --- /dev/null +++ b/cmd/sin-code/internal/profile/verify.go @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: MIT +// Verify gate for the profile renderer. The renderer is byte-stable +// per (target, source bytes) pair; this package exposes the hash +// function so CI (`sin-code profile verify`) can refuse to merge +// whenever a per-agent mirror drifts off the source. +package profile + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// HashSource returns the canonical SHA-256 of the rendered output for +// `tgt` against `body`. The hash is bit-identical across machines for +// the same (tgt, body) pair; CI depends on this property. +func HashSource(tgt Target, body string) (string, error) { + rendered, err := Render(tgt, body) + if err != nil { + return "", err + } + sum := sha256.Sum256([]byte(rendered)) + return hex.EncodeToString(sum[:]), nil +} + +// Result is the outcome of one Verify invocation. +type Result struct { + Target Target + Path string + Found bool // file exists on disk + Match bool // file exists AND contents match the expected render + WantSHA string // expected SHA-256 of rendered output + GotSHA string // actual SHA-256 of disk content (empty if missing) +} + +// Verify compares the on-disk files for every target with the +// expected Render(tgt, body) bytes. Sources for `body` is the same +// bytes the writer would emit for `tgt`. +// +// All targets are checked even after the first mismatch (the loop is +// fast and binary reporters love full tables). Returns nil if every +// match row is true; returns a *DriftError otherwise. +// +// `base` is the repo root (writer's cwd). Tests may pass t.TempDir(). +// `body` is the source markdown (already loaded by the caller). +func Verify(base, body string) ([]Result, error) { + out := make([]Result, 0, len(Targets)) + anyDrift := false + for _, name := range TargetNames() { + tgt := Targets[name] + resolved, err := Resolve(tgt, base) + if err != nil { + return out, err + } + res := Result{Target: tgt, Path: resolved} + + rendered, err := Render(tgt, body) + if err != nil { + return out, err + } + want := sha256.Sum256([]byte(rendered)) + res.WantSHA = hex.EncodeToString(want[:]) + + got, err := os.ReadFile(resolved) + if errors.Is(err, os.ErrNotExist) { + out = append(out, res) + anyDrift = true + continue + } + if err != nil { + return out, fmt.Errorf("profile: read %q: %w", resolved, err) + } + res.Found = true + g := sha256.Sum256(got) + res.GotSHA = hex.EncodeToString(g[:]) + res.Match = res.WantSHA == res.GotSHA + if !res.Match { + anyDrift = true + } + out = append(out, res) + } + if anyDrift { + return out, &DriftError{Results: out} + } + return out, nil +} + +// DriftError is returned by Verify when at least one target's on-disk +// file does not match its expected Render(tgt, body) bytes. The caller +// (CLI) prints the table; tests assert on the populated Results slice. +type DriftError struct { + Results []Result +} + +func (e *DriftError) Error() string { + var b strings.Builder + b.WriteString("profile: per-agent mirrors are out of sync with the source:\n") + for _, r := range e.Results { + if r.Match { + continue + } + switch { + case !r.Found: + fmt.Fprintf(&b, " - %s (%s): MISSING %s\n", r.Target.Name, r.Target.Format, r.Path) + default: + fmt.Fprintf(&b, " - %s (%s): DRIFT want_sha=%s got_sha=%s at %s\n", + r.Target.Name, r.Target.Format, shortSHA(r.WantSHA), shortSHA(r.GotSHA), r.Path) + } + } + return strings.TrimRight(b.String(), "\n") +} + +// RenderAll computes the rendered bytes for every target (no IO). +// Returns a map keyed by Target.Name. Useful for `--dry-run` inspection +// and for tests that pin per-target golden output. +// +// RenderAll returns the sorted (alphabetical by target name) keys +// alongside the map so callers can iterate deterministically. +func RenderAll(body string) (map[string]string, []string, error) { + out := make(map[string]string, len(Targets)) + keys := TargetNames() + for _, name := range keys { + rendered, err := Render(Targets[name], body) + if err != nil { + return out, keys, err + } + out[name] = rendered + } + return out, keys, nil +} + +// List returns the sorted, human-readable target table the CLI prints +// for `profile list --json` and `profile --help`. +type ListEntry struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Format string `json:"format"` + InstallPath string `json:"install_path"` +} + +// ListTable returns ListEntry rows in alphabetical order. +func ListTable() []ListEntry { + names := TargetNames() + out := make([]ListEntry, 0, len(names)) + for _, n := range names { + t := Targets[n] + out = append(out, ListEntry{ + Name: t.Name, + DisplayName: t.DisplayName, + Format: t.Format, + InstallPath: strings.ReplaceAll(t.InstallPath, "", ProfileSkill), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// shortSHA returns the first 12 hex chars of a full sha256. Used in +// human-readable error output; full hex is still surfaced via +// Result.WantSHA / Result.GotSHA. +func shortSHA(s string) string { + if len(s) <= 12 { + return s + } + return s[:12] +} + +// LoadSource reads the canonical source markdown. The path is resolved +// relative to `base` if not absolute. os.ErrNotExist is returned with +// no wrapping so callers can recognize it. +func LoadSource(base string) (string, error) { + p := DefaultSourcePath + if !filepath.IsAbs(p) { + p = filepath.Join(base, p) + } + data, err := os.ReadFile(p) + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("profile: source missing at %s (run from repo root)", p) + } + if err != nil { + return "", fmt.Errorf("profile: read source %q: %w", p, err) + } + return string(data), nil +} + +// WriteAll renders `body` for every registered target and writes each +// result to disk under `base`. Returns the list of paths that were +// written. Errors stop on the first failure (the binary is headless +// during CI — partial mirrors are worse than no mirrors). +func WriteAll(base, body string) ([]string, error) { + keys := TargetNames() + written := make([]string, 0, len(keys)) + for _, name := range keys { + tgt := Targets[name] + resolved, err := Resolve(tgt, base) + if err != nil { + return written, err + } + rendered, err := Render(tgt, body) + if err != nil { + return written, err + } + if err := atomicWrite(resolved, []byte(rendered)); err != nil { + return written, fmt.Errorf("profile: write %s: %w", resolved, err) + } + written = append(written, resolved) + } + return written, nil +} + +// WriteSelected renders `body` for the named target (or "all") and +// writes the result to disk under `base`. +func WriteSelected(base, body, name string) ([]string, error) { + if name == "all" { + return WriteAll(base, body) + } + tgt, ok := Targets[name] + if !ok { + return nil, fmt.Errorf("profile: unknown target %q (registered: %v)", name, TargetNames()) + } + resolved, err := Resolve(tgt, base) + if err != nil { + return nil, err + } + rendered, err := Render(tgt, body) + if err != nil { + return nil, err + } + if err := atomicWrite(resolved, []byte(rendered)); err != nil { + return nil, fmt.Errorf("profile: write %s: %w", resolved, err) + } + return []string{resolved}, nil +} + +// atomicWrite writes `data` to `path` via temp-file rename so a partial +// rewrite can never be observed by the agent mid-write. Mirrors +// skilldist.atomicWrite (byte semantics). The parent directory is +// created with the same 0755 mode skilldist uses; permission drift +// here would surface as a "directory not found" error on CI. +func atomicWrite(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".sin-profile-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} diff --git a/cmd/sin-code/main.go b/cmd/sin-code/main.go index 32ec6cd9..845d4804 100644 --- a/cmd/sin-code/main.go +++ b/cmd/sin-code/main.go @@ -79,12 +79,14 @@ func init() { rootCmd.AddCommand(tuiCmd) rootCmd.AddCommand(webuiCmd) rootCmd.AddCommand(NewChatCmd(), NewSessionsCmd(), NewMCPCmd(), - NewGoalCmd(), NewDaemonCmd(), NewSkillCmd(), NewSwarmCmd(), NewSuperpowersCmd(), NewDoxCmd(), + NewGoalCmd(), NewDaemonCmd(), NewSkillCmd(), NewSwarmCmd(), NewSuperpowersCmd(), NewDoxCmd(), NewVaneCmd(), NewStackCmd(), NewGhCmd(), NewHubCmd(), NewLedgerCmd(), NewSummaryCmd(), NewAutodevCmd(), // v3.4.0 + v3.5.0 + v3.6.0 + v3.7.0 + v3.8.0 + v3.9.0 + v3.12.0 + v3.13.0 + autodev-bridge (Python MIT v0.4.0, stdio MCP via autodev-mcp) NewCompressCmd(), // v3.18.0 — deterministic + LLM compaction (issue #172) NewSkillsCmd(), // bundled project-local agent skills NewEvalCmd(), NewTraceCmd(), // v3.18.0: Eval & Observability System (issue #75) + NewProfileCmd(), // v3.18.0: single-source-of-truth per-agent profile renderer (issue #175) + NewEvalCmd(), NewTraceCmd(), // v3.18.0: Eval + Observability System (issue #75) NewRtkCmd(), // rtk (Rust Token Killer) bridge (issue #123) NewCodeGraphCmd(), // CodeGraph multi-language analysis bridge (issue #126) NewSpecCmd(), // Spec-Layer: *.spec.md contracts (issue #122) diff --git a/cmd/sin-code/profile_cmd.go b/cmd/sin-code/profile_cmd.go new file mode 100644 index 00000000..496ee685 --- /dev/null +++ b/cmd/sin-code/profile_cmd.go @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MIT +// Purpose: `sin-code profile` — render the single-source-of-truth +// project profile (docs/agent-profiles/sin-profile.md, issue #175) +// into the per-agent mirror files (Claude Code, Codex, opencode, +// Gemini CLI, Cursor, Windsurf, Cline, GitHub Copilot) and verify +// the mirrors stay byte-stable against the source. +// +// Subcommands: +// +// profile show # print the source markdown +// profile list # print the per-agent target table +// profile render # write one or all mirrors +// profile render --dry-run # preview bytes without writing +// profile verify # gate CI on mirror SHA drift +// +// All writers go through `internal/profile`, which keeps the +// renderer pure and the verify-gate reproducible (M3). +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/profile" +) + +// NewProfileCmd builds the `profile` cobra subcommand, complete with +// render / show / list / verify sub-actions. +func NewProfileCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "profile", + Short: "Render & verify the single-source-of-truth agent profile (issue #175)", + Long: `sin-code profile renders docs/agent-profiles/sin-profile.md — the +single-source-of-truth project profile — into the per-agent mirror files +SIN-Code installs into every supported host agent: Claude Code, +opencode, Gemini CLI, Codex, Cursor, Windsurf, Cline, and GitHub +Copilot. Edit the source markdown, run "sin-code profile render all", +and the bytes stable across every host agent. + +CI integrations should call "sin-code profile verify" — it refuses to +pass whenever any per-agent mirror drifts off the source.`, + } + + cmd.AddCommand(newProfileShowCmd()) + cmd.AddCommand(newProfileListCmd()) + cmd.AddCommand(newProfileRenderCmd()) + cmd.AddCommand(newProfileVerifyCmd()) + + return cmd +} + +// newProfileShowCmd prints the source markdown to stdout. +func newProfileShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show", + Short: "Print the source profile markdown", + RunE: func(_ *cobra.Command, _ []string) error { + base := resolveRepoRoot() + body, err := profile.LoadSource(base) + if err != nil { + return err + } + fmt.Print(body) + return nil + }, + } +} + +// newProfileListCmd prints the per-agent target table. +func newProfileListCmd() *cobra.Command { + var jsonOut bool + cmd := &cobra.Command{ + Use: "list", + Short: "List every supported host-agent target", + RunE: func(_ *cobra.Command, _ []string) error { + tab := profile.ListTable() + if jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(tab) + } + fmt.Printf("%-13s %-13s %-9s %s\n", "NAME", "FORMAT", "PATH-TPL", "INSTALL") + for _, e := range tab { + fmt.Printf("%-13s %-13s %-9s %s\n", + e.Name, e.Format, "", e.InstallPath) + } + return nil + }, + } + cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON") + return cmd +} + +// newProfileRenderCmd writes one or all mirrors to disk. +// +// `render ` (Claude Code, codex…) — write a single mirror +// `render all` — write every mirror +// `render --dry-run ` — preview to stdout, no IO +func newProfileRenderCmd() *cobra.Command { + var dryRun bool + cmd := &cobra.Command{ + Use: "render ", + Short: "Write one or all per-agent mirrors", + Args: cobra.MinimumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + base := resolveRepoRoot() + + // Dry-run path: render but do not write. + if dryRun { + body, err := profile.LoadSource(base) + if err != nil { + return err + } + if args[0] == "all" { + return renderDryRunAll(body, base) + } + return renderDryRunOne(args[0], body, base) + } + + body, err := profile.LoadSource(base) + if err != nil { + return err + } + + written, err := profile.WriteSelected(base, body, args[0]) + if err != nil { + return err + } + for _, p := range written { + fmt.Printf("WROTE %s\n", p) + } + return nil + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, + "preview the rendered bytes to stdout; do not write to disk") + return cmd +} + +// newProfileVerifyCmd is the CI gate. It reads every per-agent mirror +// on disk and refuses to succeed if any of them is missing or drifted +// from the source render. Exits 0 on full match, non-zero with a +// Markdown-table error on drift. +// +// --json emits a JSON array suitable for CI parsing. +func newProfileVerifyCmd() *cobra.Command { + var jsonOut bool + cmd := &cobra.Command{ + Use: "verify", + Short: "Verify per-agent mirrors match the source SHA (CI gate)", + RunE: func(_ *cobra.Command, _ []string) error { + base := resolveRepoRoot() + body, err := profile.LoadSource(base) + if err != nil { + return err + } + res, err := profile.Verify(base, body) + if jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(res) + } else { + writeVerifyTable(res) + } + if err != nil { + return err + } + fmt.Fprintln(os.Stderr, "profile: verify OK (all mirrors match source SHA)") + return nil + }, + } + cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON") + return cmd +} + +// renderDryRunAll prints every per-target byte sequence to stdout, +// each prefixed by the target name and a 12-char SHA digest. Useful +// for diffing without modifying the working tree. +func renderDryRunAll(body, base string) error { + rendered, keys, err := profile.RenderAll(body) + if err != nil { + return err + } + for _, name := range keys { + h, err := profile.HashSource(profile.Targets[name], body) + if err != nil { + return err + } + fmt.Fprintf(os.Stdout, "──────── %s (sha256:%s) ────────\n", name, short(h, 12)) + resolved, _ := profile.Resolve(profile.Targets[name], base) + fmt.Fprintf(os.Stdout, "→ %s\n\n", resolved) + fmt.Println(rendered[name]) + fmt.Println() + } + return nil +} + +// renderDryRunOne prints a single per-target render. +func renderDryRunOne(name, body, _ string) error { + tgt, ok := profile.Targets[name] + if !ok { + return fmt.Errorf("profile: unknown target %q (registered: %v)", + name, profile.TargetNames()) + } + h, err := profile.HashSource(tgt, body) + if err != nil { + return err + } + rendered, err := profile.Render(tgt, body) + if err != nil { + return err + } + fmt.Fprintf(os.Stdout, "──────── %s (sha256:%s) ────────\n", name, short(h, 12)) + fmt.Println(rendered) + return nil +} + +// writeVerifyTable emits a Markdown-style table the human reads at +// the terminal. JSON mode is opt-in. +func writeVerifyTable(res []profile.Result) { + fmt.Printf("%-13s %-9s %-8s %s\n", "TARGET", "FOUND", "MATCH", "PATH") + for _, r := range res { + fmt.Printf("%-13s %-9v %-8v %s\n", + r.Target.Name, r.Found, r.Match, r.Path) + } +} + +// short returns the first n chars of a hex string. Used in +// human-readable output; full hex is still in --json output. +func short(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + +// resolveRepoRoot returns the directory writers should treat as the +// repository root. Defaults to "." (writer's cwd). The CLI never +// chdirs; the source path is relative. +func resolveRepoRoot() string { + return "." +} diff --git a/cmd/sin-code/testdata/golden/help.golden b/cmd/sin-code/testdata/golden/help.golden index 4843915f..b37cfde3 100644 --- a/cmd/sin-code/testdata/golden/help.golden +++ b/cmd/sin-code/testdata/golden/help.golden @@ -63,6 +63,7 @@ Available Commands: orchestrator-run Run a prompt through the multi-agent orchestrator (Pre-LLM router → planner → parallel agents) plugin Manage user-installed plugins (subcommands, agents, tools, hooks) poc Proof-of-Correctness — verify code satisfies its specification + profile Render & verify the single-source-of-truth agent profile (issue #175) prp Product Requirement Prompt workflow (plan→implement→verify→pr) read Read files with hashline anchors, outline, and size guards rtk Bridge to rtk (Rust Token Killer) to cut LLM token usage 60-90% diff --git a/cmd/sin-code/testdata/scripts/golden_help.txt b/cmd/sin-code/testdata/scripts/golden_help.txt index ed391690..92cd2cd7 100644 --- a/cmd/sin-code/testdata/scripts/golden_help.txt +++ b/cmd/sin-code/testdata/scripts/golden_help.txt @@ -67,6 +67,7 @@ Available Commands: orchestrator-run Run a prompt through the multi-agent orchestrator (Pre-LLM router → planner → parallel agents) plugin Manage user-installed plugins (subcommands, agents, tools, hooks) poc Proof-of-Correctness — verify code satisfies its specification + profile Render & verify the single-source-of-truth agent profile (issue #175) prp Product Requirement Prompt workflow (plan→implement→verify→pr) read Read files with hashline anchors, outline, and size guards rtk Bridge to rtk (Rust Token Killer) to cut LLM token usage 60-90% diff --git a/docs/agent-profiles/sin-profile.md b/docs/agent-profiles/sin-profile.md new file mode 100644 index 00000000..e36f316b --- /dev/null +++ b/docs/agent-profiles/sin-profile.md @@ -0,0 +1,59 @@ + +# SIN-Code Project Profile + +> Single source of truth for the per-agent rules SIN-Code installs into +> every supported host agent (Claude Code, Codex, opencode, Gemini, Cursor, +> Windsurf, Cline, GitHub Copilot). Edit **only** this file — the +> `sin-code profile render` step (issue #175) syncs every per-agent +> mirror off these bytes. + +## Identity + +- Product: `sin-code` (single static Go binary, `CGO_ENABLED=0`). +- Companion: `sin` Python CLI (`sin serve`, `sin chat`) — same rules. +- Repo: `github.com/OpenSIN-Code/SIN-Code`. 44+ MCP tools. 34 bundled skills. + +## Hard mandates (NEVER violate) + +- **M1** CI runs **only** via the n8n delegator. No GitHub Actions + runners for build/test/lint. +- **M2** Single static binary. No runtime deps beyond the binary. +- **M3** **Verification gate is sacred.** Never report "done" unless the + PoC/Oracle check passed. +- **M4** Permission engine (allow/ask/deny) gates every destructive tool. + In headless mode `ask` → `deny` unless `--yolo`. +- **M5** Module path is `github.com/OpenSIN-Code/SIN-Code`. Never + `SIN-Code-Bundle` (history only). +- **M6** Prefer SIN semantic tools over naive built-ins (`sin_edit` over + string replace, SCKG over blind reads). +- **M7** Race-free concurrency. New goroutine code must pass + `go test -race -count=1` before merge. + +## Working style + +- Read the file before editing it. Match the existing style. +- Smallest change that satisfies the issue. No drive-by refactors. +- Test what you break; new loop code targets ≥ 80% coverage. +- Conventional Commits (`feat:`, `fix:`, `docs:`, `feat!:` for breaking). + +## Subagent contracts + +- `sin_poc(code, spec)` — proof of correctness; mandatory for M3. +- `sin_oracle(claim, evidence)` — independent re-verification. +- `sin_adw(strict=true)` — flag god modules, circular deps, high coupling. + +## Per-agent notes + +- **Claude Code / Codex / opencode** — read `AGENTS.md` first, then this. +- **Gemini** — single-context; ambient rules = hard mandates only. +- **Cursor / Windsurf / Cline** — single `.mdc`/`.md` rule, full body + inlined verbatim. Always-on. +- **GitHub Copilot** — shared `.github/copilot-instructions.md`; this + body is appended inside a marker-fenced block so rerenders stay + idempotent. + +## Auto-clarity + +If the next action is destructive, security-relevant, or order-sensitive, +drop terse mode and write normal prose around the action. The Verify +Gate (M3) is sacred — never compress the report of a verification step.