From 8e45aab610f6ad783921561d92f00f5ec3012ebf Mon Sep 17 00:00:00 2001 From: nmccready Date: Tue, 28 Jul 2026 19:04:15 -0400 Subject: [PATCH 1/2] =?UTF-8?q?docs(specs):=20SPEC-010=20=E2=80=94=20confo?= =?UTF-8?q?rmance=20audit=20+=20unified=20fold/drill=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-tool dirs (~/.claude et al.) accrete artifacts from many writers and sync-agents has no view of what it doesn't manage. SPEC-010 defines the ownership taxonomy (synced/folded/drifted/missing/conflict/ orphaned/foreign), the three precedence rules (.agents wins for its own artifacts; destination-only is hands-off; adoption is explicit), and a hard boundary: tool roots are application state, never enumerated. Phase 1 (audit) is scoped for implementation; Phase 2 (explicit adopt) and Phase 3 (Stow-style fold/drill materializer unifying local+global sync, with the never-fold-the-tool-root hazard) are open for discussion. Co-Authored-By: Claude Opus 4.8 --- specs/README.md | 3 +- ...SPEC-010-conformance-audit-unified-sync.md | 147 ++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 specs/SPEC-010-conformance-audit-unified-sync.md diff --git a/specs/README.md b/specs/README.md index 9e79eeb..49645da 100644 --- a/specs/README.md +++ b/specs/README.md @@ -35,5 +35,6 @@ treat retired spec content as current intent. | SPEC-007 | linked sources | βœ… shipped v1.4.0 | `6c53595` | [linked-sources](../docs/linked-sources.md) | | SPEC-008 | full-context integrity lock (`agents.lock` + `agents.sum`) | πŸ“ draft | β€” (active) | β€” | | SPEC-009 | spec lifecycle tooling (`lint` spec checks + `spec retire`) | πŸ“ draft | β€” (active) | β€” | +| SPEC-010 | conformance audit + unified fold/drill sync | πŸ“ draft β€” Phase 1 (audit) in flight | β€” (active) | β€” | -Next spec ID: **SPEC-010**. +Next spec ID: **SPEC-011**. diff --git a/specs/SPEC-010-conformance-audit-unified-sync.md b/specs/SPEC-010-conformance-audit-unified-sync.md new file mode 100644 index 0000000..bcb68b9 --- /dev/null +++ b/specs/SPEC-010-conformance-audit-unified-sync.md @@ -0,0 +1,147 @@ +--- +id: SPEC-010 +title: "Conformance audit + unified fold/drill sync β€” bring order to per-tool dirs" +status: Draft β€” Phase 1 (audit) in flight; Phases 2–3 open for discussion +owner: nmccready +created: 2026-07-28 +updated: 2026-07-28 +related: SPEC-002, SPEC-003, SPEC-007 +--- + +# [SPEC-010] Feature: Conformance audit + unified fold/drill sync + +## Overview + +Per-tool directories (`~/.claude`, `~/.cursor`, `.claude/`, …) accrete +artifacts from many writers: `sync-agents`, the tools themselves +(plugin/skill installers), and humans. Today `sync-agents` can only see +the artifacts *it* expects to manage β€” it has no view of what else +occupies the trees it fans out into, and its collision behavior differs +between scopes (local `sync` whole-bucket skip-or-`RemoveAll`; global +`sync` per-artifact skip-or-backup). + +This spec brings **conformance and tracking** to the per-tool dirs, +sourced from the canonical `.agents/` tree + `sources.yaml`/lock, under +three precedence rules: + +1. **`.agents/` wins for its own artifacts.** If an artifact exists in + the canonical tree, the per-tool slot for it should be a (possibly + folded) symlink back into `.agents/`. A real file/dir shadowing it + is a **conflict** β€” surfaced, never silently destroyed. +2. **Destination-only artifacts are hands-off.** Anything in a + per-tool dir that `.agents/` doesn't claim is **foreign**: report + it, never touch it. +3. **Adoption is explicit.** Relocating a foreign artifact into + `.agents/` (for encapsulation + tracking) happens only on an + explicit command, never as a side effect of `sync`. + +## Ownership taxonomy + +Every entry in a managed subdir of a per-tool dir is exactly one of: + +| State | Meaning | Action | +|---|---|---| +| `synced` | symlink at the expected path, pointing at the canonical artifact | none | +| `folded` | expected path *resolves* to the canonical artifact via an ancestor symlink (e.g. `.claude/skills/foo -> ../../.agents/skills/foo`, so `foo/SKILL.md` resolves correctly) | none β€” conformant at a coarser granularity | +| `drifted` | symlink exists but points elsewhere | `sync` repairs | +| `missing` | nothing at the expected path | `sync` creates | +| `conflict` (`not-a-symlink`) | real file/dir shadows an artifact `.agents/` claims | warn + skip; resolve only via explicit `--force` (backup-rename, never delete) or adoption | +| `orphaned` | symlink pointing *into* the `.agents/` tree, but no current artifact claims it (artifact removed/renamed) | prune candidate (`clean` / `fix`) | +| `foreign` | anything else β€” not claimed by `.agents/`, not pointing into it | **hands off**; adoption candidate | + +**Hard boundary:** the audit and every future phase operate ONLY on the +known artifact subdirs of each tool (for Claude: `skills/`, `commands/`, +`rules/`, `agents/`, `plans/`, `specs/`, `adrs/`; for Windsurf: +`windsurf/global_workflows/`; for Cursor: `rules/`; Copilot/Codex: +their concat file). The tool root itself is **application state** +(credentials, sessions, history, caches) and is never enumerated, +reported on, or mutated. + +## Phase 1 β€” Conformance audit (in flight) + +Extend `global status` with the **reverse sweep**. Today the command is +forward-only: it classifies every destination the `.agents/` tree +expects. Phase 1 adds enumeration of what actually occupies the managed +subdirs, classifying unexpected entries as `foreign` or `orphaned`, and +adds `folded` recognition to the forward pass (a dir-level symlink that +correctly resolves an artifact is conformant, not a conflict). + +Requirements: + +- Read-only. `global status` performs no writes, exactly as today. +- Forward pass: a destination whose expected path resolves (via + `EvalSymlinks`) to the canonical artifact through an ancestor symlink + reports `[folded]` instead of `[not-a-symlink]`. +- Reverse sweep: one entry per unexpected child of each managed subdir + β€” `[foreign]` (hands-off) or `[orphaned]` (points into the `.agents/` + tree but unclaimed). Sweep is one level deep; foreign dirs are + reported as a single entry, not recursed into. +- Summary line with counts per state so a messy tree is sizeable at a + glance. +- Existing output lines for the forward pass keep their format + (regex-friendly `[state] tool/type/name -> path`). + +### Acceptance criteria + +- AC-1: a real dir at an expected artifact path still reports + `not-a-symlink` (conflict). +- AC-2: a dir-level symlink into `.agents/` that resolves the expected + artifact reports `folded`, and the sweep does not double-report it. +- AC-3: an entry in a managed subdir with no claiming artifact and no + link into `.agents/` reports `foreign` and is never mutated. +- AC-4: a symlink into the `.agents/` tree whose artifact no longer + exists reports `orphaned`. +- AC-5: entries outside managed subdirs (tool app state) never appear + in output. + +## Phase 2 β€” Adoption (open, discussion) + +An explicit command to relocate a `foreign` artifact into `.agents/` +(move + re-sync replaces it with a symlink), reusing `fix`'s +move/merge/`--no-clobber` machinery and the `.replaced-by-sync-agents` +backup convention. Open questions: + +- Surface: `adopt /…` as its own verb vs + `fix --from `. Leaning: own verb β€” adoption is a policy action + ("take ownership"), `fix` is repair/migration. +- Bulk mode (`--all-foreign`) with mandatory `--dry-run`-first UX for + large cleanups. +- Conflict resolution: per-artifact `--ours`/`--theirs` instead of the + run-wide `--force` blunderbuss. + +## Phase 3 β€” Unified fold/drill materializer (open, discussion) + +Local `sync` and `global sync` converge on one algorithm (GNU Stow's +tree-folding model): + +- Destination absent β†’ one symlink at the highest safe level + (**fold**), maximally `/`. +- Destination is a real dir β†’ descend and link per-child (**drill**), + recursing; foreign siblings coexist untouched. +- Destination is a real file conflicting with a claimed artifact β†’ + conflict per the taxonomy above. + +Notes and hazards to resolve before implementation: + +- **Never fold the tool root.** `~/.claude -> ~/.agents` would route + the tool's own state writes (credentials, sessions) into the + source-of-truth tree. Fold depth starts at the bucket level. +- **Fold capture semantics:** through a folded bucket link, a tool + that later installs into e.g. `.claude/skills/` writes *into* + `.agents/skills/` β€” silent auto-adoption. Drilled links keep future + foreign writes foreign. Decide default (likely: drill once any + foreign content exists; fold only pristine buckets) and document. +- Local `CmdSync` today ignores the semantic router entirely + (whole-bucket links, flat target list, no Codex). Unification means + deciding whether project scope adopts semantic routing (skills β†’ + `.claude/commands/` etc.) or the router grows a "mirror" mode for + local scope. This is the largest open decision. +- Migration: existing whole-bucket symlinks are valid folds under the + new model; drilling happens lazily only when coexistence is needed. + +## Non-goals + +- Auto-adoption of foreign artifacts (violates rule 3). +- Enumerating or mutating tool application state. +- Two-way sync (destination edits flowing back into `.agents/` other + than via explicit adoption). From 3947b64a3cffb40e875bf6e56d32ac276235873d Mon Sep 17 00:00:00 2001 From: nmccready Date: Tue, 28 Jul 2026 19:04:15 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(status):=20SPEC-010=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20reverse=20sweep=20+=20folded=20recognition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `global status` was forward-only: it classified every destination the .agents/ tree expects but had no view of what else occupies the managed subdirs, and a dir-level symlink that correctly resolves an artifact read as a conflict. Add the reverse sweep: enumerate each tool's managed artifact subdirs (never the tool root β€” that's application state) and classify unclaimed entries as [foreign] (hands-off, never touched) or [orphaned] (links into .agents/ with no claiming artifact). Teach the forward classifier [folded]: an expected path that resolves to the canonical artifact via an ancestor symlink is conformant, not a conflict. Close the report with a per-state count summary. Read-only throughout; covered by tests for all five SPEC-010 Phase 1 acceptance criteria. Co-Authored-By: Claude Opus 4.8 --- docs/commands/global-status.md | 20 +++- internal/agent/audit.go | 178 +++++++++++++++++++++++++++++++++ internal/agent/audit_test.go | 169 +++++++++++++++++++++++++++++++ internal/agent/globalstatus.go | 33 +++++- 4 files changed, 395 insertions(+), 5 deletions(-) create mode 100644 internal/agent/audit.go create mode 100644 internal/agent/audit_test.go diff --git a/docs/commands/global-status.md b/docs/commands/global-status.md index f9ec889..8571c7e 100644 --- a/docs/commands/global-status.md +++ b/docs/commands/global-status.md @@ -34,9 +34,10 @@ for shell pipelines that want to grep for `[drifted]` or `[stale]`. |---|---| | `[synced]` | A symlink exists and points at the correct canonical artifact under `~/.agents/`. No action needed. | | `[drifted]` | A symlink exists but its target is wrong (the canonical artifact moved, or a previous sync used a different path). Re-run `global sync` to repair. | -| `[not-a-symlink]` | A regular file or directory occupies the destination. `global sync` will skip it unless `--force` is set. | +| `[not-a-symlink]` | A regular file or directory occupies the destination β€” a conflict: the canonical tree claims this artifact but something real shadows it. `global sync` will skip it unless `--force` is set. | | `[missing]` | Nothing at the destination. `global sync` will create the symlink. | | `[skipped]` | The (artifact, tool) pair is intentionally not routable β€” e.g. a multi-file invocable skill targeting Windsurf workflows. The Detail field explains. | +| `[folded]` | The exact per-artifact link is absent, but the path *resolves* to the canonical artifact through an ancestor symlink (e.g. a dir-level `.claude/skills/` link). Conformant at a coarser granularity β€” no action needed. (SPEC-010) | ### Concat destinations @@ -47,6 +48,23 @@ for shell pipelines that want to grep for `[drifted]` or `[stale]`. | `[concat missing]` | No file at the destination. `global sync` will create it. | | `[concat foreign]` | A file exists at the destination but lacks the sync-agents banner β€” likely user-owned. `global sync` will overwrite (concat targets are sync-agents-owned by design); `global clean` will leave it alone with a warning. | +### Audit sweep (SPEC-010) + +After the per-destination rows, `global status` enumerates the managed +artifact subdirs of each tool (for Claude: `skills/`, `commands/`, +`rules/`, `agents/`, `plans/`, `specs/`, `adrs/`; Windsurf: +`global_workflows/`; Cursor: `rules/`) and reports entries the +canonical tree does *not* claim. The tool root itself is application +state (credentials, sessions, caches) and is never enumerated. + +| State | Meaning | +|---|---| +| `[foreign]` | An entry no `~/.agents/` artifact claims and that doesn't point into the canonical tree. Hands-off: reported, never touched. Adoption into `~/.agents/` is a future explicit command (SPEC-010 Phase 2). | +| `[orphaned]` | A symlink pointing into the `~/.agents/` tree that no current artifact claims β€” usually left behind by an artifact removal or rename. Prune candidate. | + +The report ends with a one-line summary counting every state, e.g. +`audit: 4 synced, 1 folded, 2 foreign, 1 orphaned`. + ### Special states | State | Meaning | diff --git a/internal/agent/audit.go b/internal/agent/audit.go new file mode 100644 index 0000000..63a8811 --- /dev/null +++ b/internal/agent/audit.go @@ -0,0 +1,178 @@ +package agent + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// audit.go implements the SPEC-010 Phase 1 reverse sweep: enumerate +// what actually occupies the managed subdirs of each per-tool +// directory and classify every entry the forward pass (computeStatus) +// did not already claim. +// +// The sweep is read-only and bounded by toolSweepDirs β€” the tool root +// itself is application state (credentials, sessions, caches) and is +// NEVER enumerated. See SPEC-010 Β§Hard boundary. + +const ( + // StateForeign: an entry in a managed subdir that no .agents/ + // artifact claims and that does not point into the .agents/ tree. + // Hands-off by rule 2 of SPEC-010 β€” reported, never mutated. + StateForeign = "foreign" + + // StateOrphaned: a symlink pointing into the .agents/ tree that no + // current artifact claims β€” typically left behind by an artifact + // removal or rename. Prune candidate. + StateOrphaned = "orphaned" + + // StateFolded: the expected destination resolves to the canonical + // artifact through an ancestor symlink (e.g. a dir-level + // .claude/skills/ -> .agents/skills/ link). Conformant + // at a coarser granularity than a fresh sync would create. + StateFolded = "folded" +) + +// toolSweepDirs returns the managed artifact subdirs the audit may +// enumerate for one tool at global scope. Anything not listed here is +// out of bounds β€” in particular the tool root, which holds the tool's +// own application state. +// +// Copilot and Codex have no per-artifact dirs (single concat file, +// already covered by concat states), so they sweep nothing. +func toolSweepDirs(tool Tool, parent string) []string { + switch tool.ID { + case "claude": + base := filepath.Join(parent, ".claude") + var dirs []string + for _, d := range []string{"skills", "commands", "rules", "agents", "plans", "specs", "adrs"} { + dirs = append(dirs, filepath.Join(base, d)) + } + return dirs + case "codeium": + return []string{filepath.Join(parent, ".codeium", "windsurf", "global_workflows")} + case "cursor": + return []string{filepath.Join(parent, ".cursor", "rules")} + default: + return nil + } +} + +// sweepUnmanaged enumerates the managed subdirs of each tool and +// returns one StatusEntry per entry the forward pass did not claim, +// classified foreign or orphaned. +// +// expected is the set of destination paths the forward pass computed +// (symlink-strategy destinations only). A directory entry is claimed +// either directly (its own path is expected) or as skill scaffolding +// (its /SKILL.md is expected β€” Claude skills link the SKILL.md +// inside a per-skill dir). +// +// agentsRoot is the canonical tree; a symlink resolving inside it but +// absent from expected is orphaned rather than foreign. +// +// The sweep is one level deep by design: a foreign directory is one +// row, not a recursive listing β€” the audit sizes the mess, it doesn't +// inventory other tools' internals. +func sweepUnmanaged(tools []Tool, parent, agentsRoot string, expected map[string]bool) []StatusEntry { + var rows []StatusEntry + agentsRootAbs, err := filepath.Abs(agentsRoot) + if err != nil { + agentsRootAbs = agentsRoot + } + + for _, tool := range tools { + for _, dir := range toolSweepDirs(tool, parent) { + entries, err := os.ReadDir(dir) + if err != nil { + continue // absent dir: nothing to audit + } + for _, e := range entries { + full := filepath.Join(dir, e.Name()) + if expected[full] || expected[filepath.Join(full, "SKILL.md")] { + continue // claimed β€” the forward pass reports it + } + rows = append(rows, classifyUnclaimed(tool.ID, full, agentsRootAbs)) + } + } + } + + sort.Slice(rows, func(i, j int) bool { + if rows[i].Tool != rows[j].Tool { + return rows[i].Tool < rows[j].Tool + } + return rows[i].DestinationPath < rows[j].DestinationPath + }) + return rows +} + +// classifyUnclaimed decides foreign vs orphaned for one unclaimed +// entry. Only a symlink whose target lies inside the .agents/ tree is +// orphaned; everything else β€” real files, real dirs, links elsewhere β€” +// is foreign and hands-off. +func classifyUnclaimed(toolID, path, agentsRootAbs string) StatusEntry { + row := StatusEntry{ + Tool: toolID, + ArtifactName: filepath.Base(path), + DestinationPath: path, + State: StateForeign, + Detail: "untracked β€” left alone", + } + + fi, err := os.Lstat(path) + if err != nil || fi.Mode()&os.ModeSymlink == 0 { + return row + } + target, err := os.Readlink(path) + if err != nil { + return row + } + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(path), target) + } + target = filepath.Clean(target) + if target == agentsRootAbs || strings.HasPrefix(target, agentsRootAbs+string(filepath.Separator)) { + row.State = StateOrphaned + row.Detail = fmt.Sprintf("points at %s but no artifact claims it", target) + } + return row +} + +// foldedResolves reports whether destPath, after resolving every +// symlink along it, lands on the same file as wantTarget β€” i.e. an +// ancestor symlink (a fold) already routes the destination to the +// canonical artifact even though destPath itself is not a link. A +// plain real file at destPath resolves to itself and can never equal +// a distinct wantTarget, so conflicts are not misclassified. +func foldedResolves(destPath, wantTarget string) bool { + rd, err := filepath.EvalSymlinks(destPath) + if err != nil { + return false + } + rw, err := filepath.EvalSymlinks(wantTarget) + if err != nil { + return false + } + return rd == rw +} + +// auditSummary renders the one-line count summary for the full row +// set (forward pass + sweep), keyed and ordered by state. +func auditSummary(rows []StatusEntry) string { + counts := map[string]int{} + for _, r := range rows { + counts[r.State]++ + } + states := make([]string, 0, len(counts)) + for s := range counts { + states = append(states, s) + } + sort.Strings(states) + parts := make([]string, 0, len(states)) + for _, s := range states { + parts = append(parts, fmt.Sprintf("%d %s", counts[s], s)) + } + return "audit: " + strings.Join(parts, ", ") +} diff --git a/internal/agent/audit_test.go b/internal/agent/audit_test.go new file mode 100644 index 0000000..d5e80eb --- /dev/null +++ b/internal/agent/audit_test.go @@ -0,0 +1,169 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// audit_test.go covers the SPEC-010 Phase 1 acceptance criteria: the +// reverse sweep (foreign / orphaned) and folded-ancestor recognition. + +// AC-3: an entry in a managed subdir that no artifact claims and that +// doesn't point into .agents/ reports foreign β€” and stays untouched. +func TestGlobalStatus_ForeignReported(t *testing.T) { + a, root, stdout := newStatusTestApp(t) + seedRule(t, a.ResolveGlobalRoot(), "managed", "body\n") + + foreign := filepath.Join(root, ".claude", "skills", "hundred-million-offers") + if err := os.MkdirAll(foreign, 0o755); err != nil { + t.Fatal(err) + } + marker := filepath.Join(foreign, "SKILL.md") + if err := os.WriteFile(marker, []byte("# not ours\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := a.CmdGlobalStatus(GlobalStatusOpts{}); err != nil { + t.Fatalf("status: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "[foreign] claude/hundred-million-offers") { + t.Errorf("expected foreign row for hundred-million-offers:\n%s", out) + } + // Untouched: read-only audit. + if _, err := os.Stat(marker); err != nil { + t.Errorf("audit mutated a foreign entry: %v", err) + } +} + +// AC-4: a symlink into the .agents/ tree with no claiming artifact +// reports orphaned, not foreign. +func TestGlobalStatus_OrphanedSymlink(t *testing.T) { + a, root, stdout := newStatusTestApp(t) + seedRule(t, a.ResolveGlobalRoot(), "managed", "body\n") + + commandsDir := filepath.Join(root, ".claude", "commands") + if err := os.MkdirAll(commandsDir, 0o755); err != nil { + t.Fatal(err) + } + // Points into .agents/ but the artifact is gone. + gone := filepath.Join(a.ResolveGlobalRoot(), "workflows", "deleted.md") + if err := os.Symlink(gone, filepath.Join(commandsDir, "deleted.md")); err != nil { + t.Fatal(err) + } + + if err := a.CmdGlobalStatus(GlobalStatusOpts{}); err != nil { + t.Fatalf("status: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "[orphaned] claude/deleted.md") { + t.Errorf("expected orphaned row for deleted.md:\n%s", out) + } +} + +// AC-2: a dir-level symlink into .agents/ that resolves the expected +// artifact reports folded β€” and the sweep does not double-report it. +func TestGlobalStatus_FoldedSkillRecognized(t *testing.T) { + a, root, stdout := newStatusTestApp(t) + seedSkill(t, a.ResolveGlobalRoot(), "decision-helper", + "---\nname: decision-helper\n---\nbody\n", nil) + + skillsDir := filepath.Join(root, ".claude", "skills") + if err := os.MkdirAll(skillsDir, 0o755); err != nil { + t.Fatal(err) + } + // The fold: dir-level link instead of the per-SKILL.md link a + // fresh sync would create. + src := filepath.Join(a.ResolveGlobalRoot(), "skills", "decision-helper") + if err := os.Symlink(src, filepath.Join(skillsDir, "decision-helper")); err != nil { + t.Fatal(err) + } + + if err := a.CmdGlobalStatus(GlobalStatusOpts{}); err != nil { + t.Fatalf("status: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "[folded] claude/skill/decision-helper") { + t.Errorf("expected folded row for decision-helper:\n%s", out) + } + if strings.Contains(out, "[foreign] claude/decision-helper") || + strings.Contains(out, "[orphaned] claude/decision-helper") { + t.Errorf("folded skill double-reported by the sweep:\n%s", out) + } + if strings.Contains(out, "[not-a-symlink] claude/skill/decision-helper") { + t.Errorf("folded skill misclassified as conflict:\n%s", out) + } +} + +// AC-1: a real dir shadowing a claimed artifact is still a conflict +// (not-a-symlink), never folded/foreign. +func TestGlobalStatus_ConflictStillReported(t *testing.T) { + a, root, stdout := newStatusTestApp(t) + seedSkill(t, a.ResolveGlobalRoot(), "find-skills", + "---\nname: find-skills\n---\nbody\n", nil) + + shadow := filepath.Join(root, ".claude", "skills", "find-skills") + if err := os.MkdirAll(shadow, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(shadow, "SKILL.md"), []byte("# local fork\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := a.CmdGlobalStatus(GlobalStatusOpts{}); err != nil { + t.Fatalf("status: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "[not-a-symlink] claude/skill/find-skills") { + t.Errorf("expected conflict row for find-skills:\n%s", out) + } +} + +// AC-5: tool app state (files outside the managed subdirs) never +// appears in output. +func TestGlobalStatus_AppStateNeverSwept(t *testing.T) { + a, root, stdout := newStatusTestApp(t) + seedRule(t, a.ResolveGlobalRoot(), "managed", "body\n") + + claude := filepath.Join(root, ".claude") + if err := os.MkdirAll(filepath.Join(claude, "sessions"), 0o755); err != nil { + t.Fatal(err) + } + for _, f := range []string{".credentials.json", "history.jsonl", "settings.json"} { + if err := os.WriteFile(filepath.Join(claude, f), []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + } + + if err := a.CmdGlobalStatus(GlobalStatusOpts{}); err != nil { + t.Fatalf("status: %v", err) + } + out := stdout.String() + for _, banned := range []string{".credentials.json", "history.jsonl", "sessions", "settings.json"} { + if strings.Contains(out, banned) { + t.Errorf("app-state entry %q leaked into audit output:\n%s", banned, out) + } + } +} + +// The summary line counts every state across forward pass + sweep. +func TestGlobalStatus_SummaryLine(t *testing.T) { + a, root, stdout := newStatusTestApp(t) + seedRule(t, a.ResolveGlobalRoot(), "managed", "body\n") + if err := os.MkdirAll(filepath.Join(root, ".claude", "skills", "stray"), 0o755); err != nil { + t.Fatal(err) + } + + if err := a.CmdGlobalStatus(GlobalStatusOpts{}); err != nil { + t.Fatalf("status: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "audit:") { + t.Errorf("expected audit summary line:\n%s", out) + } + if !strings.Contains(out, "1 foreign") { + t.Errorf("expected '1 foreign' in summary:\n%s", out) + } +} diff --git a/internal/agent/globalstatus.go b/internal/agent/globalstatus.go index 22a49aa..34899e7 100644 --- a/internal/agent/globalstatus.go +++ b/internal/agent/globalstatus.go @@ -143,7 +143,7 @@ func (a *App) CmdGlobalStatus(opts GlobalStatusOpts) error { return err } - entries, concatRows := computeStatus(artifacts, tools, parent) + entries, concatRows, expected := computeStatus(artifacts, tools, parent) a.Info(fmt.Sprintf("global status (%d artifacts, %d tool(s)):", len(artifacts), len(tools))) @@ -153,17 +153,32 @@ func (a *App) CmdGlobalStatus(opts GlobalStatusOpts) error { for _, e := range concatRows { a.printStatusEntry(e) } + + // SPEC-010 Phase 1: reverse sweep β€” classify what occupies the + // managed subdirs beyond what the .agents/ tree claims. Read-only; + // foreign entries are reported and never touched. + sweepRows := sweepUnmanaged(tools, parent, root, expected) + for _, e := range sweepRows { + a.printStatusEntry(e) + } + + all := append(append(append([]StatusEntry{}, entries...), concatRows...), sweepRows...) + a.Info(auditSummary(all)) return nil } // computeStatus is the pure logic of `global status`: given the // discovered artifacts and tool set, return the per-destination -// entries and the aggregated concat rows. +// entries, the aggregated concat rows, and the set of symlink +// destination paths the .agents/ tree claims (consumed by the +// SPEC-010 reverse sweep to separate managed entries from +// foreign/orphaned ones). // // Split out from CmdGlobalStatus so tests can exercise the state // classification without going through the App + filesystem-write // surface. -func computeStatus(artifacts []Artifact, tools []Tool, parent string) (perDestination []StatusEntry, concatRows []StatusEntry) { +func computeStatus(artifacts []Artifact, tools []Tool, parent string) (perDestination []StatusEntry, concatRows []StatusEntry, expected map[string]bool) { + expected = map[string]bool{} // Track concat targets and the entries that contribute to them // so we can both classify their state AND show the contributor // count. @@ -196,6 +211,7 @@ func computeStatus(artifacts []Artifact, tools []Tool, parent string) (perDestin Detail: dest.SkipReason, }) case StrategySymlink: + expected[dest.Path] = true state, detail := classifySymlinkDestination(dest.Path, symlinkTarget(art)) perDestination = append(perDestination, StatusEntry{ Tool: tool.ID, @@ -246,7 +262,7 @@ func computeStatus(artifacts []Artifact, tools []Tool, parent string) (perDestin }) } - return perDestination, concatRows + return perDestination, concatRows, expected } // classifySymlinkDestination inspects a symlink destination and @@ -260,6 +276,15 @@ func classifySymlinkDestination(destPath, wantTarget string) (DestinationState, return StateNotSymlink, err.Error() } if info.Mode()&os.ModeSymlink == 0 { + // Not a symlink itself β€” but Lstat follows intermediate + // components, so the path may be reached through a folded + // ancestor symlink (SPEC-010): .claude/skills/ -> + // .agents/skills/ makes /SKILL.md resolve to the + // canonical file with no per-file link. That's conformant, + // not a conflict. + if foldedResolves(destPath, wantTarget) { + return DestinationState(StateFolded), "resolves via ancestor symlink" + } return StateNotSymlink, "" } current, err := os.Readlink(destPath)