Skip to content

Commit b8fabac

Browse files
DelqhiSIN CI
andauthored
feat(profile): single-source-of-truth sin-profile.md + per-agent renderers (issue #175) (#202)
Co-authored-by: SIN CI <ci@opensin-code.local>
1 parent 14eed5b commit b8fabac

17 files changed

Lines changed: 1795 additions & 3 deletions

File tree

‎.github/workflows/ceo-audit.yml‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,66 @@ jobs:
224224
sarif_file: ${{ github.workspace }}/ceo-audit-output/report.sarif
225225
category: ceo-audit
226226
continue-on-error: true
227+
228+
# ─── Profile mirror drift gate (issue #175) ────────────────────────────
229+
# Builds the Go binary from the same commit under audit and runs
230+
# `sin-code profile verify`. If the on-disk per-agent mirrors under
231+
# .claude/, .codex/, .cursor/, .github/, etc. have drifted off the
232+
# single source `docs/agent-profiles/sin-profile.md`, the job fails
233+
# the same way the verify-gate fails in `internal/verify` (M3).
234+
#
235+
# CEO audit (above) runs concurrently because both jobs share no
236+
# state. This job does NOT intend to gate on per-machine Python
237+
# skills — it strictly mirrors the byte-stable contract of
238+
# internal/profile.Verify.
239+
profile-verify:
240+
name: Profile verify (issue #175)
241+
runs-on: ubuntu-latest
242+
timeout-minutes: 10
243+
steps:
244+
- name: Checkout
245+
uses: actions/checkout@v4
246+
with:
247+
fetch-depth: 0
248+
249+
- name: Setup Go
250+
uses: actions/setup-go@v5
251+
with:
252+
go-version: '1.25'
253+
cache: true
254+
255+
- name: Build sin-code
256+
run: go build -o /tmp/sin-code ./cmd/sin-code
257+
258+
# Render the mirrors if absent or stale. `render all` is
259+
# idempotent (byte-identical on unchanged source), so the
260+
# call is also a self-test for the byte-stable contract.
261+
- name: Render per-agent profile mirrors
262+
run: /tmp/sin-code profile render all
263+
264+
- name: Verify mirrors match source SHA
265+
run: |
266+
/tmp/sin-code profile verify
267+
STATUS=$?
268+
if [ $STATUS -ne 0 ]; then
269+
echo "::error::sin-code profile verify failed (exit=$STATUS); mirrors drifted off docs/agent-profiles/sin-profile.md"
270+
exit 1
271+
fi
272+
echo "::notice::Profile mirrors byte-stable; verify gate passed."
273+
274+
- name: Upload rendered mirrors as artifact
275+
if: always()
276+
uses: actions/upload-artifact@v4
277+
with:
278+
name: profile-mirrors-${{ github.run_id }}
279+
path: |
280+
.claude/skills/sin-code/SKILL.md
281+
.codex/rules/sin-code.md
282+
.config/opencode/skills/sin-code/SKILL.md
283+
.cursor/rules/sin-code.mdc
284+
.gemini/skills/sin-code/SKILL.md
285+
.windsurf/rules/sin-code.md
286+
.clinerules/sin-code.md
287+
.github/copilot-instructions.md
288+
retention-days: 14
289+
if-no-files-found: warn

‎AGENTS.md‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ SIN-Code/
240240
├── go.mod ← module github.com/OpenSIN-Code/SIN-Code
241241
├── .goreleaser.yaml
242242
├── .github/workflows/
243-
│ ├── ceo-audit.yml ← n8n delegation (mandate M1)
243+
│ ├── ceo-audit.yml ← n8n delegation (mandate M1) + profile-verify job (issue #175)
244244
│ ├── sin-code-release.yml ← goreleaser + brew tap
245245
│ └── ecosystem-sync.yml ← prevents registry/permission/ECOSYSTEM drift
246246
├── install.sh ← 27-line curl|bash shim → `sin-code install` (issue #170)
@@ -252,7 +252,9 @@ SIN-Code/
252252
│ ├── HOOKS.md
253253
│ ├── LEARNING.md
254254
│ ├── WEBUI.md ← WebUI-v2 backend contract
255-
│ └── mcp.json.example
255+
│ ├── mcp.json.example
256+
│ └── agent-profiles/
257+
│ └── sin-profile.md ← v3.18.0: single-source-of-truth per-agent profile (issue #175)
256258
│
257259
├── cmd/
258260
│ ├── sin-code/ ← MAIN BINARY (40 subcommands — v3.18.0)
@@ -280,6 +282,10 @@ SIN-Code/
280282
│ │ ├── compress_cmd.go ← v3.18.0: sin-code compress subcommand (plan/apply/rollback, issue #172)
281283
│ │ ├── permission_defaults.go ← C4: default rules + MCP prefix policy
282284
│ │ └── internal/ ← 17 packages (v3.8.0)
285+
│ │ ├── install_cmd.go ← v3.18.0: sin-code install (issue #170)
286+
│ │ ├── profile_cmd.go ← v3.18.0: single-source-of-truth profile renderer (issue #175)
287+
│ │ ├── permission_defaults.go ← C4: default rules + MCP prefix policy
288+
│ │ └── internal/ ← 18 packages (v3.18.0)
283289
│ │ ├── agentloop/ ← PLAN→ACT→VERIFY→DONE loop
284290
│ │ ├── session/ ← SQLite-backed resumable sessions
285291
│ │ ├── permission/ ← allow/ask/deny engine
@@ -299,6 +305,8 @@ SIN-Code/
299305
│ │ ├── summary/ ← v3.13.0: deterministic session summary builder
300306
│ │ ├── install/ ← v3.18.0: pure-stdlib release install + SHA256 verify + atomic place (issue #170)
301307
│ │ ├── mcpcompress/ ← v3.19.0: ponytail-tag compressor for `serve --compress-tools`
308+
│ │ ├── install/ ← v3.18.0: pure-stdlib release install + SHA256 verify (issue #170)
309+
│ │ ├── profile/ ← v3.18.0: single-source-of-truth per-agent renderer (issue #175)
302310
│ │ ├── llm/ ← provider layer
303311
│ │ ├── style/ ← v3.17.0: verbosity / compression mode system-prompt renderer (issue #167)
304312
│ │ ├── orchestrator/ ← DAG, critic, adversary, governor, ...
@@ -518,6 +526,7 @@ Core: discover, execute, map, grasp, scout, harvest, orchestrate,
518526
ibd, poc, sckg, adw, oracle, efm
519527
Agents: chat, sessions, mcp, goal, daemon, skill, superpowers,
520528
vane, stack, gh, install
529+
vane, stack, gh, install, profile
521530
Frontend: serve, tui, webui
522531
Lifecycle: memory, knowledge, todo, notifications, orchestrator_run,
523532
orchestrator_agents, orchestrator_plan, update
@@ -526,6 +535,42 @@ Utility: read, write, edit, lsp, plugin, index, security, sbom,
526535
``` (v3.18.0: 40 subcommands; `install` is the v3.18.0 single-binary installer from issue #170)
527536
config, self-update, hub, ledger, summary, compress
528537
``` (v3.18.0: 40 subcommands, up from 39 in v3.13.0)
538+
``` (v3.18.0: 40 subcommands, up from 39 in v3.16.0;
539+
`install` is the v3.18.0 single-binary installer from issue #170;
540+
`profile` is the v3.18.0 single-source-of-truth per-agent renderer
541+
from issue #175.)
542+
543+
### Per-agent profile distribution (issue #175)
544+
545+
`sin-code profile` renders the single in-repo source
546+
(`docs/agent-profiles/sin-profile.md`, ≤80 lines, KISS) into every
547+
per-host-agent mirror file. The render is **byte-stable** per
548+
`(target, source)` pair; the verify gate (`sin-code profile verify`)
549+
refuses to pass whenever any mirror drifts off the source SHA. Adding
550+
a target is non-breaking; renaming or removing one is a major bump.
551+
The single source of truth for the table is
552+
`cmd/sin-code/internal/profile/target.go` — keep the AGENTS.md row in
553+
sync if the table moves.
554+
555+
| Target | Format | Install path (relative to repo root) |
556+
| ------------- | ------- | ----------------------------------------------------------- |
557+
| claude-code | dir | `.claude/skills/sin-code/SKILL.md` |
558+
| opencode | dir | `.config/opencode/skills/sin-code/SKILL.md` |
559+
| gemini | dir | `.gemini/skills/sin-code/SKILL.md` |
560+
| codex | rule | `.codex/rules/sin-code.md` |
561+
| cursor | rule | `.cursor/rules/sin-code.mdc` |
562+
| windsurf | rule | `.windsurf/rules/sin-code.md` |
563+
| cline | rule | `.clinerules/sin-code.md` |
564+
| copilot | marker | `.github/copilot-instructions.md` |
565+
566+
The four marker-fence outputs (`rule` + `marker`) wrap the body in
567+
`<!-- SIN-CODE-SKILL-START: sin-code -->` … `<!-- SIN-CODE-SKILL-END: sin-code -->`
568+
inside the file. The fence is **byte-identical** to the one
569+
`internal/skilldist` uses (issue #169): the two systems share the
570+
same anchor (`SIN-CODE-SKILL`) so a downstream parser finds both
571+
per-skill bundles and per-profile mirrors with one regex. Profile
572+
renders are idempotent (rerun with unchanged source = byte-identical
573+
output), exactly as skilldist demands.
529574
530575
### Hook events (verified `internal/hooks/hooks.go`, v3.5.0)
531576

‎CHANGELOG.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,36 @@ All notable changes to the SIN-Code unified binary will be documented in this fi
320320
- **Snapshot dir**: `~/.local/share/sin-code/compress-snapshots/`
321321
(overridable via `SIN_CODE_SNAPSHOT_DIR`). Same form factor as
322322
lessons.db / ledger.db per AGENTS.md §7.
323+
### Added — Per-agent profile renderer (issue #175)
324+
- **Single source of truth** at `docs/agent-profiles/sin-profile.md`
325+
(≤80 lines, KISS, hard mandates + working style + subagent contracts +
326+
per-agent notes, edits roll out everywhere).
327+
- **`internal/profile`** package: targets map mirroring AGENTS.md §10
328+
(`claude-code`, `opencode`, `gemini`, `codex`, `cursor`, `windsurf`,
329+
`cline`, `copilot`); per-format writers (`dir`, `rule`, `marker`); a
330+
byte-stable `Render(tgt, body)`; a `Verify(base, body)` SHA-256
331+
drift gate; idempotent marker-fence envelopes byte-identical to
332+
`internal/skilldist` (issue #169 covenants preserved).
333+
- **`sin-code profile` subcommand** with four verbs:
334+
- `profile show` — print the source markdown
335+
- `profile list` — print the supported target table (text + `--json`)
336+
- `profile render <target|all>` — write one or all mirrors (idempotent;
337+
supports `--dry-run` for byte/audit preview without touching disk)
338+
- `profile verify` — CI gate: refuse on missing/drift;
339+
surfaces a 12-char-row table or a JSON envelope for `--json`
340+
- **Permission engine**: `profile__show` / `list` / `verify` are
341+
registered as `allow`; `profile__render` is `ask` because it
342+
touches per-agent dotdirs (mandate M4).
343+
- **CI sync**: `.github/workflows/ceo-audit.yml` grew a parallel
344+
`profile-verify` job that builds `sin-code`, runs
345+
`profile render all && profile verify`, uploads the rendered
346+
mirrors as artifacts, and fails the build if any drift surfaces.
347+
Mirrors AGENTS.md §6 + §10 — single source of truth in
348+
`internal/profile/target.go`.
349+
- **Test coverage**: 22 race-tested Go tests pinning the byte-stable
350+
contract (golden render, marker-fence idempotency, marker-Fence
351+
covenant, `Verify` pass / missing / drift, write-after-write SHA
352+
equality, replace-not-append for stale mirrors).
323353

324354
### Added — Loop Engineering (decoupled completion authority)
325355
### Added — MCP tool-manifest compression (issue #173, v3.19.0)

‎cmd/sin-code/internal/permission_defaults.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ func DefaultPermissionRules() []permission.Rule {
8989
{Tool: "compress__plan", Policy: "allow"}, // read-only projection
9090
{Tool: "compress__apply", Policy: "ask"}, // rewrites + LLM call
9191
{Tool: "compress__rollback", Policy: "allow"}, // restorative — never destructive
92+
// v3.18.0: profile renderer (issue #175). Read-only
93+
// (show/list/verify/dry-run) is allow; the writing `render`
94+
// surface is `ask` because it touches per-agent dotdirs.
95+
{Tool: "profile__show", Policy: "allow"},
96+
{Tool: "profile__list", Policy: "allow"},
97+
{Tool: "profile__verify", Policy: "allow"},
98+
{Tool: "profile__render", Policy: "ask"},
9299
// Backstop catch-all (mirrors sin_bash default at line 44 for unmatched prefixes).
93100
{Tool: "autodev__*", Policy: "ask"},
94101
{Tool: "*", Policy: "ask"},

‎cmd/sin-code/internal/permission_defaults_test.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,24 @@ func TestPermissionDefaultRules(t *testing.T) {
1414
t.Fatal("expected default rules")
1515
}
1616
hasRead := false
17+
hasProfileRender := false
1718
for _, r := range rules {
1819
if r.Tool == "sin_read" && r.Policy == "allow" {
1920
hasRead = true
2021
}
22+
// v3.18.0 (issue #175): profile renderer surfaced to agents.
23+
// The render verb touches per-agent dotdirs so must default
24+
// to "ask"; show/list/verify are pure reads.
25+
if r.Tool == "profile__render" && r.Policy == "ask" {
26+
hasProfileRender = true
27+
}
2128
}
2229
if !hasRead {
2330
t.Errorf("expected sin_read allow rule, got %+v", rules)
2431
}
32+
if !hasProfileRender {
33+
t.Errorf("expected profile__render ask rule (issue #175), got %+v", rules)
34+
}
2535
}
2636

2737
func TestPermissionRulesForAgent(t *testing.T) {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// SPDX-License-Identifier: MIT
2+
//
3+
// # Package profile
4+
//
5+
// Profile is the single-source-of-truth rewriter for SIN-Code's
6+
// per-agent project rules (issue #175). The source is the in-repo
7+
// markdown at docs/agent-profiles/sin-profile.md; output is one
8+
// artifact per supported host-agent family:
9+
//
10+
// Claude Code → <repo>/.claude/skills/sin-code/SKILL.md
11+
// opencode → <repo>/.config/opencode/skills/sin-code/SKILL.md
12+
// Gemini CLI → <repo>/.gemini/skills/sin-code/SKILL.md
13+
// Codex → <repo>/.codex/rules/sin-code.md
14+
// Cursor → <repo>/.cursor/rules/sin-code.mdc
15+
// Windsurf → <repo>/.windsurf/rules/sin-code.md
16+
// Cline → <repo>/.clinerules/sin-code.md
17+
// GitHub Copilot → <repo>/.github/copilot-instructions.md
18+
//
19+
// (Updates AGENTS.md §10 — same table.)
20+
//
21+
// # Why byte-stable
22+
//
23+
// Render(tgt, body) is a pure function over (tgt, body). The verify
24+
// gate (Verify + HashSource) computes the expected SHA-256 of every
25+
// rendered output and refuses to merge if any on-disk mirror drifts.
26+
// This is the same shape as the verify-gate in `internal/verify`
27+
// (M3) — the renderer is a deterministic preprocessor.
28+
//
29+
// # Marker-fence covenant
30+
//
31+
// RenderBlock / BeginMarker / EndMarker are byte-identical to
32+
// internal/skilldist's outputs for the same `<skill>` value. See
33+
// parser.go for the exact ASCII bytes.
34+
package profile
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# `internal/profile` — single-source-of-truth per-agent project profile (issue #175)
2+
3+
## What this package is
4+
5+
`internal/profile` renders a single in-repo markdown
6+
(`docs/agent-profiles/sin-profile.md`) into the per-agent mirror
7+
files that SIN-Code installs into every supported host agent family:
8+
9+
| Agent family | Output path (relative to repo root) | Format |
10+
| ------------ | ------------------------------------ | ------------ |
11+
| Claude Code | `.claude/skills/sin-code/SKILL.md` | `dir` |
12+
| opencode | `.config/opencode/skills/sin-code/SKILL.md` | `dir` |
13+
| Gemini CLI | `.gemini/skills/sin-code/SKILL.md` | `dir` |
14+
| Codex CLI | `.codex/rules/sin-code.md` | `rule` |
15+
| Cursor | `.cursor/rules/sin-code.mdc` | `rule` |
16+
| Windsurf | `.windsurf/rules/sin-code.md` | `rule` |
17+
| Cline | `.clinerules/sin-code.md` | `rule` |
18+
| GitHub Copilot | `.github/copilot-instructions.md` | `marker` |
19+
20+
The table is the **single source of truth** in this package. Adding a
21+
target is non-breaking; renaming or removing one is a major bump per
22+
AGENTS.md §10. The set is intentionally a mirror of the skilldist
23+
table so the two packages can be merged later without changing
24+
public output.
25+
26+
## Why byte-stable
27+
28+
`Render(tgt, body)` is pure: the bytes depend only on (target
29+
struct, source body). The CLI's `sin-code profile verify` reads
30+
every on-disk mirror, recomputes the expected render, and refuses
31+
to merge if any mirror is missing or drift. This is the same shape
32+
as the verify-gate in `internal/verify` (mandate M3) — the
33+
renderer is a deterministic preprocessor.
34+
35+
## Marker-fence covenant (issue #169)
36+
37+
Every `rule` / `marker` output is bracketed by a
38+
`<!-- SIN-CODE-SKILL-START/END -->` fence with the same exact
39+
ASCII bytes as `internal/skilldist`. A downstream parser that
40+
scans for one kind also finds the other; a `profile render` call
41+
is idempotent (rerun with unchanged source = byte-identical
42+
output). See `parser.go` for the contract.
43+
44+
## Public surface
45+
46+
```go
47+
type Target struct { Name, DisplayName, InstallPath, Format string }
48+
var Targets map[string]Target
49+
func TargetNames() []string
50+
func MustTarget(name string) Target
51+
52+
func RenderAll(body string) (map[string]string, []string, error)
53+
func Render(tgt Target, body string) (string, error)
54+
func Resolve(tgt Target, base string) (string, error)
55+
func StripFrontmatter(raw string) string
56+
57+
func HashSource(tgt Target, body string) (string, error)
58+
func Verify(base, body string) ([]Result, error)
59+
60+
func LoadSource(base string) (string, error)
61+
func WriteAll(base, body string) ([]string, error)
62+
func WriteSelected(base, body, name string) ([]string, error)
63+
func ListTable() []ListEntry
64+
```
65+
66+
## CLI surface
67+
68+
```
69+
sin-code profile show # print current source
70+
sin-code profile list # print target table
71+
sin-code profile render <target|all> # write one or all mirrors
72+
sin-code profile render --dry-run # preview without writing
73+
sin-code profile verify # CI gate
74+
sin-code profile verify --json # machine-readable drift
75+
```
76+
77+
## Tests
78+
79+
`profile_test.go` pins:
80+
81+
- Exact SHA-256 of `Render(tgt, fixture)` for one target per format.
82+
- Marker-fence idempotency (rerun = byte-identical output).
83+
- `ParseMarkers` open / close / half-opened-fence roundtrip.
84+
- `Verify` missing-file → DriftError.
85+
- `Verify` drift → DriftError.
86+
- `Verify` pass → nil error.
87+
88+
A new golden test fails the moment the renderer output drifts.

0 commit comments

Comments
 (0)