Skip to content

SPEC-010: conformance audit — reverse sweep + folded recognition in global status - #84

Open
nmccready-tars wants to merge 2 commits into
mainfrom
feat/010-conformance-audit
Open

SPEC-010: conformance audit — reverse sweep + folded recognition in global status#84
nmccready-tars wants to merge 2 commits into
mainfrom
feat/010-conformance-audit

Conversation

@nmccready-tars

Copy link
Copy Markdown
Contributor

What

Two commits:

  1. SPEC-010 (specs/) — Conformance audit + unified fold/drill sync. Defines the ownership taxonomy for per-tool dirs (synced / folded / drifted / missing / conflict / orphaned / foreign), three precedence rules (.agents/ wins for its own artifacts; destination-only artifacts are hands-off; adoption is explicit-only), and a hard boundary: tool roots are application state (credentials, sessions, caches) and are never enumerated or mutated. Phase 2 (explicit adopt) and Phase 3 (Stow-style fold/drill materializer unifying local + global sync) are drafted as open discussion.

  2. Phase 1 implementationglobal status grows the reverse sweep and folded recognition:

    • Reverse sweep: enumerate each tool's managed artifact subdirs (Claude: skills/ commands/ rules/ agents/ plans/ specs/ adrs/; Windsurf: global_workflows/; Cursor: rules/) and classify unclaimed entries as [foreign] (untracked, hands-off) or [orphaned] (symlink into ~/.agents/ with no claiming artifact).
    • Folded: an expected destination that resolves to the canonical artifact through an ancestor symlink (e.g. a dir-level .claude/skills/<name> link) now reports [folded] instead of the false-conflict [not-a-symlink].
    • Per-state count summary line closes the report. Read-only throughout.

Why

global status was forward-only — it could see whether .agents/ artifacts were materialized, but had zero visibility into what else occupies the trees it fans into. On a machine with an organically-grown ~/.claude, that means no way to size the mess, spot real conflicts, or distinguish "managed differently" from "not managed at all."

Real-world run (before → after)

On a live ~/.claude with a dir-level rules symlink, one dir-level skill link, one genuine shadow copy, and two untracked skills, the old classifier reported 5 false conflicts. Now:

[folded]  claude/rule/claude-cli-preferred -> ~/.claude/rules/claude-cli-preferred.md  (resolves via ancestor symlink)
[folded]  claude/skill/decision-helper -> ~/.claude/skills/decision-helper/SKILL.md    (resolves via ancestor symlink)
[not-a-symlink] claude/skill/find-skills -> ~/.claude/skills/find-skills/SKILL.md      ← the one real conflict
[foreign] claude/hundred-million-offers -> ~/.claude/skills/hundred-million-offers  (untracked — left alone)
[foreign] claude/spec-driven-dev -> ~/.claude/skills/spec-driven-dev  (untracked — left alone)
[info] audit: 5 folded, 3 foreign, 11 missing, 1 not-a-symlink

App state (.credentials.json, sessions/, history.jsonl) never appears — the sweep is bounded to managed subdirs by construction.

Tests

audit_test.go covers all five SPEC-010 Phase 1 acceptance criteria: conflict still reported (AC-1), folded recognized + not double-reported (AC-2), foreign reported + untouched (AC-3), orphaned detected (AC-4), app state never swept (AC-5), plus the summary line. Existing status tests unchanged and green; go vet clean; full internal/... suite green.

Docs

docs/commands/global-status.md gains the [folded] row and an "Audit sweep" section documenting the new states and the app-state boundary.

🤖 Generated with Claude Code

nmccready and others added 2 commits July 28, 2026 19:04
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
Comment thread internal/agent/audit.go
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning: foldedResolves uses filepath.EvalSymlinks on both destPath and wantTarget. If destPath is a regular file (not a symlink), EvalSymlinks will resolve it to its own path. If wantTarget also resolves to the same path (e.g., via a symlink chain), the function returns true, incorrectly classifying a conflict as folded. This violates AC-1 (a real dir at an expected artifact path should report not-a-symlink). The function should first check if destPath is a symlink before resolving, or ensure that a non-symlink file at destPath cannot match wantTarget.

-inline

Detail: dest.SkipReason,
})
case StrategySymlink:
expected[dest.Path] = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning: In computeStatus, the expected map only records dest.Path for StrategySymlink entries. However, for folded entries (where classifySymlinkDestination returns StateFolded), the destination path is still claimed by the artifact but is not added to expected. This means the reverse sweep in sweepUnmanaged will not skip these paths, potentially double-reporting them as foreign or orphaned. The expected map should include all claimed destinations, including those that resolve via folding.

-inline

Comment thread internal/agent/audit.go
continue // absent dir: nothing to audit
}
for _, e := range entries {
full := filepath.Join(dir, e.Name())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion: In sweepUnmanaged, the check expected[full] || expected[filepath.Join(full, "SKILL.md")] only handles the case where a skill's SKILL.md is expected. However, the forward pass may also claim the skill directory itself (e.g., for folded skills). If a skill directory is claimed via a fold, it should be added to expected to prevent double-reporting. Consider adding the directory path to expected when a fold is detected.

-inline

Comment thread internal/agent/audit.go
// is foreign and hands-off.
func classifyUnclaimed(toolID, path, agentsRootAbs string) StatusEntry {
row := StatusEntry{
Tool: toolID,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion: In classifyUnclaimed, the function uses filepath.Base(path) for ArtifactName. For entries in subdirectories like skills/foo/SKILL.md, this would set ArtifactName to SKILL.md instead of foo. While the sweep is one level deep, if a foreign directory contains a file, the entry's name might be misleading. Consider using the relative path from the managed subdir for clarity.

-inline

@github-actions

Copy link
Copy Markdown

🤖 AI Code Review

The diff introduces a new audit sweep for SPEC-010 Phase 1. The implementation is generally sound, but there is a correctness bug in foldedResolves where a real file at destPath could incorrectly match wantTarget if both resolve to the same path via symlinks, and a potential issue with the expected map not accounting for folded entries, which could lead to double-reporting.

Findings: 0 critical · 2 warning · 2 suggestion (4 posted inline)

model: deepseek/deepseek-v4-flash · est. cost: $0.0009

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant