Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,11 @@ AGENTS.md is also symlinked to CLAUDE.md so that Claude reads the index natively
| `pull [--dry-run\|--offline\|--force\|--only NAME\|--global]` | Fetch every `sources.yaml` entry, verify integrity, install into the matching buckets |
| `update [NAME]` | Re-resolve refs and re-pull entries whose upstream moved; SHA-pinned entries are skipped |
| `source add <entry>` | Append an entry to `sources.yaml` and pull it |
| `source add --link[=<path>] [<entry>]` | Declare a **linked (editable)** source — symlink a live local checkout instead of a fetched snapshot (SPEC-007) |
| `source remove <name> [--keep]` | Remove the manifest entry and delete the artifact (`--keep` converts it to manual) |
| `source list [--json]` | Show each entry's local state: `ok` / `outdated` / `modified` / `missing` |
| `source list [--json]` | Show each entry's local state: `ok` / `outdated` / `modified` / `missing` / `linked` |
| `source bundle` | Rebuild `sources.yaml` from installed artifacts' origin metadata |
| `source detach <name>` | Un-manage an artifact: flip its origin to manual and drop the manifest entry |
| `source detach <name>` | Un-manage an artifact: flip its origin to manual and drop the manifest entry (for a **linked** source, freeze the live copy into a vendored snapshot) |
| `quarantine` | List remotely-fetched artifacts awaiting review, with their scan findings |
| `approve <name>\|--all [--force]` | Promote a quarantined artifact into `.agents/` (`--force` accepts critical findings, recorded in the lock) |
| `reject <name>\|--all` | Delete a quarantined artifact without installing it |
Expand Down Expand Up @@ -240,6 +241,32 @@ sync-agents reject sketchy-rule # delete without installing

Critical findings block `approve`; overriding with `--force` is recorded in `sources.lock` as `approved_with_findings` so the decision is auditable. `--trust` on `pull`/`update` skips the gate for one invocation (findings still print), and `quarantine = off` in `.agents/config` disables it for teams that review via pinned SHAs in PRs instead.

## Linked (editable) sources

When you're **actively developing** a skill that lives in another repo (or the upstream repo itself), a SHA-pinned snapshot forces a slow `edit → commit → push → update` loop. A **linked** source is the `npm link` / `go mod replace` for `.agents/` — it symlinks the artifact at a live local checkout, so edits flow both ways and `git pull` in the checkout reaps updates with no re-fetch (SPEC-007).

```bash
# Link a checkout you already have (path relative to cwd or absolute):
sync-agents source add --link=../foo-skill skill:me/foo-skill

# Derive the entry from the checkout's github remote + layout:
sync-agents source add --link=../foo-skill

# Managed clone: sync-agents clones the repo under .agents/.sources/ and links out of it
# (the initial clone is scanned once through the quarantine scanner; --trust to skip):
sync-agents source add --link skill:me/foo-skill
```

The link is recorded declaratively as a `link:` override in `sources.yaml` and echoed in `sources.lock`, so the intent is committed and reviewable:

```yaml
overrides:
- match: skill:me/foo-skill
link: file:../foo-skill # relative to .agents/; mutually exclusive with pin_sha
```

All persisted paths are **relative** (`file:` scheme) and the on-disk symlink is created relative too — an absolute path would break the instant another machine cloned the repo, so absolute `file:` paths are rejected at parse time. `pull` verifies (and self-heals) the symlink and never re-fetches a snapshot over it; a missing checkout surfaces as a warning, not a silent revert. `update` advances a **managed clone** with `git pull --ff-only` (a checkout you own is yours to drive). `source list` shows linked entries as `[linked] … link → file:../foo-skill`, and `source detach` **freezes** the live copy into an ordinary vendored snapshot (materialize the files, drop the `link` override, keep the upstream identity). Because a linked source is a local working tree you own, it is trusted by default — only the first fetch of a managed clone is scanned.

## ADRs (Architecture Decision Records)

ADRs live in `.agents/adrs/` with **status encoded by subdirectory**: `proposed/`, `accepted/`, `denied/`. `add adr <name>` scaffolds into `proposed/`; `sync-agents adr accept|deny|propose <name>` moves a record between statuses (nested grouping subdirs are preserved) and regenerates the index.
Expand Down
6 changes: 4 additions & 2 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1442,7 +1442,9 @@ func (a *App) generateAgentsMD() {
skillsDir := filepath.Join(agentsDir, "skills")
if entries, err := os.ReadDir(skillsDir); err == nil {
for _, entry := range entries {
if !entry.IsDir() {
// Follow symlinks so a linked skill (SPEC-007) — a symlink
// to a checkout dir — is indexed like a vendored one.
if !entryIsDir(filepath.Join(skillsDir, entry.Name()), entry) {
continue
}
name := entry.Name()
Expand All @@ -1454,7 +1456,7 @@ func (a *App) generateAgentsMD() {
}
// Legacy flat skills
for _, entry := range entries {
if entry.IsDir() {
if entryIsDir(filepath.Join(skillsDir, entry.Name()), entry) {
continue
}
name := entry.Name()
Expand Down
22 changes: 20 additions & 2 deletions internal/agent/globalsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -520,16 +520,34 @@ func discoverBucketDir(dir string, b Bucket, prefix string) ([]Artifact, error)
func artifactFromEntry(dir string, e os.DirEntry, b Bucket, prefix string) (Artifact, bool) {
abs := filepath.Join(dir, e.Name())
if b.DirPerArtifact {
if !e.IsDir() {
// A linked skill (SPEC-007) is a symlink to a checkout dir, so
// e.IsDir() is false — stat-through the entry so linked skills
// enumerate exactly like vendored ones.
if !entryIsDir(abs, e) {
return Artifact{}, false
}
if _, err := os.Stat(filepath.Join(abs, "SKILL.md")); err != nil {
return Artifact{}, false
}
return Artifact{Type: b.Artifact, Name: prefix + e.Name(), SourcePath: abs}, true
}
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
if entryIsDir(abs, e) || !strings.HasSuffix(e.Name(), ".md") {
return Artifact{}, false
}
return Artifact{Type: b.Artifact, Name: prefix + strings.TrimSuffix(e.Name(), ".md"), SourcePath: abs}, true
}

// entryIsDir reports whether a directory entry is (or points at, via a
// symlink) a directory. os.DirEntry.IsDir() reports the symlink itself,
// not its target, so a linked skill dir would otherwise read as a
// non-directory and vanish from discovery (SPEC-007 §index).
func entryIsDir(abs string, e os.DirEntry) bool {
if e.IsDir() {
return true
}
if e.Type()&os.ModeSymlink == 0 {
return false
}
fi, err := os.Stat(abs) // follows the symlink
return err == nil && fi.IsDir()
}
66 changes: 66 additions & 0 deletions internal/agent/link_index_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package agent

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

// link_index_test.go covers the SPEC-007 §index guarantee: a linked
// skill — a symlink to a live checkout dir — must enumerate in AGENTS.md
// and DiscoverArtifacts exactly like a vendored one. os.DirEntry.IsDir()
// reports the symlink, not its target, so the discovery paths have to
// stat through the link.

func TestLinkedSkillIsIndexed(t *testing.T) {
root := t.TempDir()
agentsDir := filepath.Join(root, ".agents")
if err := os.MkdirAll(filepath.Join(agentsDir, "skills"), 0o755); err != nil {
t.Fatal(err)
}

// Real checkout living outside the .agents tree.
checkout := filepath.Join(root, "checkout", "linked-skill")
if err := os.MkdirAll(checkout, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(checkout, "SKILL.md"),
[]byte("---\nname: linked-skill\ndescription: A linked skill. Use when testing SPEC-007.\n---\n"), 0o644); err != nil {
t.Fatal(err)
}

// Symlink the skill into the bucket (relative, as SPEC-007 wires it).
dest := filepath.Join(agentsDir, "skills", "linked-skill")
rel, err := filepath.Rel(filepath.Dir(dest), checkout)
if err != nil {
t.Fatal(err)
}
if err := os.Symlink(rel, dest); err != nil {
t.Fatal(err)
}

// DiscoverArtifacts follows the symlink.
arts, err := DiscoverArtifacts(agentsDir)
if err != nil {
t.Fatalf("DiscoverArtifacts: %v", err)
}
found := false
for _, a := range arts {
if a.Name == "linked-skill" && a.Type == ArtifactSkill {
found = true
}
}
if !found {
t.Fatalf("linked skill not discovered: %+v", arts)
}

// AGENTS.md enumerates it.
a := &App{ProjectRoot: root, Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}}
a.generateAgentsMD()
idx := readFile(t, filepath.Join(root, "AGENTS.md"))
if !strings.Contains(idx, "linked-skill") {
t.Fatalf("linked skill missing from AGENTS.md:\n%s", idx)
}
}
110 changes: 100 additions & 10 deletions internal/agent/source/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)

// bundle.go implements the manifest-maintenance half of SPEC-003:
Expand Down Expand Up @@ -119,28 +121,40 @@ func (p *Puller) removeOrDetachArtifact(dest string, dirArtifact bool, o Origin,
return nil
}

// Detach flips an artifact to source:"manual" and removes its
// manifest + lock entries. The artifact stays on disk — this is the
// documented escape hatch for "I want to keep my local edits".
func (p *Puller) Detach(name string) error {
// Detach severs a pulled artifact from live management while keeping it
// on disk. For a normal source it flips origin to source:"manual" and
// drops the manifest + lock entries. For a linked source (SPEC-007) it
// instead FREEZES the dev copy into a vendored snapshot — see
// detachLink. The returned frozen bool is true in the latter case, so
// callers can report the right outcome.
func (p *Puller) Detach(name string) (frozen bool, err error) {
m, exists, err := LoadManifest(p.AgentsDir)
if err != nil {
return err
return false, err
}
if !exists {
return fmt.Errorf("no %s found", ManifestPath(p.AgentsDir))
return false, fmt.Errorf("no %s found", ManifestPath(p.AgentsDir))
}
raw, e, err := findEntry(m, name)
if err != nil {
return err
return false, err
}

// SPEC-007: detaching a linked source is "freeze my dev copy" —
// materialize the symlink target into a real vendored artifact,
// drop the link override, and record a normal snapshot lock entry.
// The upstream identity in `sources` stays, so pull governs it as a
// snapshot from here on.
if linkRel := linkFor(e, m); linkRel != "" {
return true, p.detachLink(raw, e, linkRel, m)
}

if e.Type == EntryTree {
for _, a := range p.scanInstalled() {
if a.origin.Owner == e.Owner && a.origin.Repo == e.Repo {
a.origin.Source = SourceManual
if err := WriteOriginFor(a.dest, a.dirArtifact, a.origin); err != nil {
return err
return false, err
}
}
}
Expand All @@ -150,23 +164,99 @@ func (p *Puller) Detach(name string) error {
if o, oerr := ReadOriginFor(dest, dirArtifact); oerr == nil {
o.Source = SourceManual
if err := WriteOriginFor(dest, dirArtifact, o); err != nil {
return err
return false, err
}
}
}

m.Sources = removeString(m.Sources, raw)
if err := SaveManifest(p.AgentsDir, m); err != nil {
return false, err
}
lock, err := LoadLock(p.AgentsDir)
if err != nil {
return false, err
}
lock.Remove(raw)
return false, SaveLock(p.AgentsDir, lock)
}

// detachLink freezes a linked source (SPEC-007): it replaces the
// symlink with a real copy of the current target contents, drops the
// link override (keeping the upstream `sources` entry), writes normal
// origin metadata, and records a snapshot lock entry hashed over the
// materialized files. After this the entry is an ordinary vendored
// artifact that pull governs by SHA.
func (p *Puller) detachLink(raw string, e Entry, linkRel string, m Manifest) error {
name, _ := applyOverrides(e, m)
dest, dirArtifact := p.artifactDest(e, name)

checkoutRoot := filepath.Join(p.AgentsDir, filepath.FromSlash(linkRel))
target := linkArtifactTarget(checkoutRoot, e)
if _, err := os.Stat(target); err != nil {
return fmt.Errorf("cannot detach %s: link target %s is missing (%w) — restore the checkout or `source remove` it", name, target, err)
}

// Provenance to freeze into the snapshot: the informational HEAD the
// lock recorded at link/update time (empty if never resolved).
sha := ""
if lock, err := LoadLock(p.AgentsDir); err == nil {
if le := lock.Find(raw); le != nil {
sha = le.ResolvedSHA
}
}

// Materialize: real copy replaces the symlink. installArtifact copies
// the target into a sibling temp first, then swaps — the checkout is
// never mutated.
if err := installArtifact(target, dest, dirArtifact); err != nil {
return err
}
contentHash, err := HashTree(dest)
if err != nil {
return err
}
fetchedAt := p.now().Format(time.RFC3339)
if err := WriteOriginFor(dest, dirArtifact, Origin{
Owner: e.Owner, Repo: e.Repo, Path: e.Path, Ref: e.Ref,
SHA: sha, ContentHash: contentHash, FetchedAt: fetchedAt, Source: SourceManifest,
}); err != nil {
return err
}

// Drop the link override; keep the sources identity entry.
m.Overrides = dropLinkOverride(m.Overrides, e)
if err := SaveManifest(p.AgentsDir, m); err != nil {
return err
}
lock, err := LoadLock(p.AgentsDir)
if err != nil {
return err
}
lock.Remove(raw)
lock.Set(LockEntry{
Entry: raw, ResolvedSHA: sha, ContentHash: contentHash, FetchedAt: fetchedAt,
})
return SaveLock(p.AgentsDir, lock)
}

// dropLinkOverride clears the link field from an entry's override,
// removing the override entirely when nothing else remains on it.
func dropLinkOverride(overrides []Override, e Entry) []Override {
out := overrides[:0]
for _, o := range overrides {
if o.Matches(e.Raw) && o.Link != "" {
o.Link = ""
// An override that was link-only is now empty — drop it so
// the manifest stays clean.
if o.Rename == "" && o.PinSHA == "" && len(o.ExcludePaths) == 0 {
continue
}
}
out = append(out, o)
}
return out
}

// BundleReport summarizes what Bundle changed.
type BundleReport struct {
Added []string // entries appended to sources.yaml
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/source/bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ func TestDetach_FlipsToManualAndDropsEntry(t *testing.T) {
}
dest := filepath.Join(p.AgentsDir, "rules", "security.md")

if err := p.Detach("security"); err != nil {
if _, err := p.Detach("security"); err != nil {
t.Fatalf("detach: %v", err)
}
if _, err := os.Stat(dest); err != nil {
Expand Down
Loading
Loading