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
497 changes: 303 additions & 194 deletions .agents/skills/migrating-fleet-context/SKILL.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,11 @@ jobs:
TestNoUsageLineNamesAFlagThatDoesNotExist
TestReadmeCommandBlockIsCurrent
TestLivingDocumentsNameOnlyRealCommands
TestHarnessSkillCoversAgentCommands"
TestHarnessSkillCoversAgentCommands
TestMigrationSkillCoversItsSpecifiedProtocol
TestMigrationSkillMatchesEmbeddedAsset
TestMigrationSkillPastesTheCanonicalRouter
TestMigrationSkillNamesNoRepoLocalDocuments"
filter="$(echo $tests | tr ' ' '|')"
go test -count=1 -v -run "^(${filter})$" ./... | tee /tmp/docs.log
# `go test -run` exits 0 when the filter matches NOTHING -- it prints
Expand Down
6 changes: 3 additions & 3 deletions agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ A developer harness manager, repository context framework, and transcript record
## Features

- **Multi-Harness Wiring**: Automatically configures and keeps in sync hook configurations for Claude Code (`.claude/settings.json`), Codex (`.codex/hooks.json`), and Antigravity (`.agents/hooks.json`).
- **Two-Tier Context & Drift Detection**: Enforces clean separation between canonical machine routing (`AGENTS.md`, `CLAUDE.md`) and repository domain guidelines (`.agents/AGENTS.md`). `agents drift` inspects context layout, canonical diffs, domain context, skills, and misplaced documentation across repositories.
- **Two-Tier Context & Drift Detection**: Enforces clean separation between canonical machine routing (`AGENTS.md`, `CLAUDE.md`) and repository domain guidelines (`.agents/AGENTS.md`). `agents drift` inspects context layout, canonical diffs, domain context, bundled skills, and misplaced documentation across repositories. Repository-specific skills under `.agents/skills/` are listed as `local_skills` and never classified as drift.
- **Fleet Maintenance & Skill Refresh**: `agents update` rewires machine hooks across registered repositories, refreshes the authoritative `migrating-fleet-context` skill, and emits advisory notices if any repository exhibits context drift.
- **Durable Transcript Caching**: Captures and preserves subagent conversation transcripts before harnesses delete them, storing them in `.agents/transcripts/` with retention and size bounding.
- **Repository Guardrails & Pre-Commit Secret Scanning**: Integrates `gitleaks` into `agents guard --staged` to catch secret leaks before commit.
Expand Down Expand Up @@ -112,8 +112,8 @@ When installed via Homebrew or downloaded from releases, `agents` operates as a
- `scaffold:router`: Validates that root `AGENTS.md` matches the canonical router template without unpartitioned domain drift.
- `scaffold:symlink`: Verifies that `CLAUDE.md` is a valid relative symlink to `AGENTS.md`.
- `scaffold:domain`: Confirms presence of `.agents/AGENTS.md` for repository-specific domain rules.
- `scaffold:skill-recording`: Checks status and customization state of `.agents/skills/recording-what-you-learn/`.
- `scaffold:skill-migrating`: Checks status and customization state of `.agents/skills/migrating-fleet-context/`.
- `scaffold:skill-recording`: Checks status and customization state of `.agents/skills/recording-what-you-learn/`. This skill is repository-customizable, so local edits are reported without warning.
- `scaffold:skill-migrating`: Checks that `.agents/skills/migrating-fleet-context/` matches the installed binary. This skill is `agents`-owned, so any divergence is staleness and warns; run `agents update --apply` to refresh it.
- Git hook dispatching executes repository-level hooks and built-in guards.

### 2. Dotfiles Operator Mode
Expand Down
6 changes: 6 additions & 0 deletions agents/cmd_drift.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ func printDriftReport(w io.Writer, rep drift.DriftReport) {
for _, name := range skillNames {
fmt.Fprintf(w, " %-26s %s\n", name+":", rep.Skills[name])
}
if len(rep.LocalSkills) > 0 {
fmt.Fprintln(w, " Local skills (not managed by agents):")
for _, name := range rep.LocalSkills {
fmt.Fprintf(w, " %s\n", name)
}
}
fmt.Fprintln(w, " Docs stores:")
var storeNames []string
for name := range rep.DocsStores {
Expand Down
145 changes: 145 additions & 0 deletions agents/docs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"regexp"
"strings"
"testing"

"github.com/nilbot/dotfiles/agents/internal/scaffold"
)

const (
Expand Down Expand Up @@ -166,3 +168,146 @@ func TestHarnessSkillCoversAgentCommands(t *testing.T) {
t.Errorf("agent-facing commands absent from the skill:\n %s", strings.Join(missing, "\n "))
}
}

// The migrating-fleet-context skill is the one deliverable of the two-tier work
// that is prose rather than code, and it shipped contradicting the tooling it
// drives: no fleet mode, one procedure for four router states, an unconditional
// `rm -f CLAUDE.md`, and a commit with no approval gate.
//
// TestLivingDocumentsNameOnlyRealCommands already scans this file, but only in
// one direction -- it catches a command that does not exist, never a required
// command that is absent. That asymmetry is exactly why the omissions survived
// review. This is the other direction, the way TestHarnessSkillCoversAgentCommands
// is the other direction for claude/skills/agents-tool.
//
// See docs/journal/2026-09-01-why-the-migration-skill-shipped-hollow.md and
// Amendment 1 of docs/design/2026-08-29-two-tier-context-and-llm-migration-architecture.md.
func TestMigrationSkillCoversItsSpecifiedProtocol(t *testing.T) {
root := task18RepoRoot(t)
rel := filepath.Join(".agents", "skills", "migrating-fleet-context", "SKILL.md")
data, err := os.ReadFile(filepath.Join(root, rel))
if err != nil {
t.Fatalf("the migration skill is missing: %v", err)
}
text := string(data)

// Required by the amended design section 7. Each entry names the section
// that requires it, so a future edit that drops one can find out why.
required := []struct{ substr, why string }{
{"agents ls", "7.1.2 target discovery over the registered fleet"},
{"agents drift --json", "7.1.2 single-repository inspection"},
{"agents drift --all --json", "7.6 fleet inspection, which returns an array"},
{"agents update --apply", "7.1.1 self-currency check before trusting itself"},
{"agents doctor", "7.1.7 verification gate"},
{"clean_current", "7.3 router state table"},
{"clean_legacy", "7.3 router state table"},
{"drifted", "7.3 router state table"},
{"missing", "7.3 router state table"},
{"upstream", "7.3 named merge sources"},
{"base", "7.3 named merge sources"},
{"local", "7.3 named merge sources"},
{"stop and ask", "7.5 unclassifiable blocks are not a judgement call"},
{"traceability", "7.5 evidence for zero rule dropping"},
{"gh pr create", "7.1.9 the migration ends in a pull request"},
}
lower := strings.ToLower(text)
for _, r := range required {
if !strings.Contains(lower, strings.ToLower(r.substr)) {
t.Errorf("%s does not mention %q, required by %s", rel, r.substr, r.why)
}
}

// Forbidden: the unconditional symlink replacement. On the pre-2026-08-19
// topology (AGENTS.md -> CLAUDE.md, content in CLAUDE.md) this deletes the
// only real file and leaves AGENTS.md -> CLAUDE.md -> AGENTS.md, a symlink
// loop with every line of repository context gone. playground/desktop_pet
// was in exactly that state on 2026-09-01.
if strings.Contains(text, "rm -f CLAUDE.md") {
t.Errorf("%s still carries the unconditional `rm -f CLAUDE.md`; design 7.4 requires "+
"stat-ing both root paths and preserving content before the symlink", rel)
}

// Forbidden: a command that relocates out of the immutable archive. Prose
// forbidding the move is fine and expected; a `git mv` with an archive
// source is not.
for _, line := range strings.Split(text, "\n") {
if strings.Contains(line, "git mv") && strings.Contains(line, "docs/archive/") {
t.Errorf("%s relocates out of docs/archive/, which is immutable: %q", rel, strings.TrimSpace(line))
}
}
}

// The skill exists twice: the repository's own copy and the embedded asset the
// binary scaffolds into every other repository. Nothing bound them together,
// so they could diverge silently and the fleet would be migrated by whichever
// copy the reader happened to open.
func TestMigrationSkillMatchesEmbeddedAsset(t *testing.T) {
root := task18RepoRoot(t)
repoCopy, err := os.ReadFile(filepath.Join(root, ".agents", "skills", "migrating-fleet-context", "SKILL.md"))
if err != nil {
t.Fatalf("repository copy: %v", err)
}
asset, err := os.ReadFile(filepath.Join(root, "agents", "internal", "scaffold",
"assets", "skills", "migrating-fleet-context", "SKILL.md"))
if err != nil {
t.Fatalf("embedded asset: %v", err)
}
if !bytes.Equal(repoCopy, asset) {
t.Errorf(".agents/skills/migrating-fleet-context/SKILL.md and its embedded asset differ; "+
"they are scaffolded into every other repository from the asset, so they must be identical "+
"(repo copy %d bytes, asset %d bytes)", len(repoCopy), len(asset))
}
}

// The skill pastes the canonical router so a migrating agent can restore it
// without a second tool. That is a second copy of DefaultAgentsMD, and the two
// ship in the same binary -- so nothing except this test stops a change to one
// from silently leaving the other behind, telling every migrated repository to
// adopt a router the tool then reports as drifted.
func TestMigrationSkillPastesTheCanonicalRouter(t *testing.T) {
root := task18RepoRoot(t)
data, err := os.ReadFile(filepath.Join(root, ".agents", "skills",
"migrating-fleet-context", "SKILL.md"))
if err != nil {
t.Fatalf("the migration skill is missing: %v", err)
}

const fence = "```markdown\n# Agent context\n"
i := strings.Index(string(data), fence)
if i < 0 {
t.Fatal("the skill no longer pastes a canonical router block; if that is deliberate, " +
"delete this test, and if it is not, restore the block")
}
body := string(data)[i+len("```markdown\n"):]
j := strings.Index(body, "\n```")
if j < 0 {
t.Fatal("unterminated router code fence in the skill")
}
pasted := body[:j+1]

if pasted != scaffold.DefaultAgentsMD {
t.Errorf("the router pasted into the skill does not match scaffold.DefaultAgentsMD\n"+
"pasted %d bytes, canonical %d bytes", len(pasted), len(scaffold.DefaultAgentsMD))
}
}

// The skill is scaffolded into other people's repositories, which do not have
// this repository's documents. A "where this comes from" list of dated design
// and Q&A paths reads as a working reference and resolves to nothing there --
// worse than no pointer, because an agent will try to follow it.
//
// Bare dates describing an era ("the pre-2026-08-19 topology") are fine; a dated
// *filename* is a path into this repository and is not.
func TestMigrationSkillNamesNoRepoLocalDocuments(t *testing.T) {
root := task18RepoRoot(t)
rel := filepath.Join(".agents", "skills", "migrating-fleet-context", "SKILL.md")
data, err := os.ReadFile(filepath.Join(root, rel))
if err != nil {
t.Fatalf("the migration skill is missing: %v", err)
}
datedDoc := regexp.MustCompile(`[0-9]{4}-[0-9]{2}-[0-9]{2}-[A-Za-z0-9-]+\.md`)
for _, m := range datedDoc.FindAllString(string(data), -1) {
t.Errorf("%s names %q, a document that exists only in this repository; "+
"the skill ships into repositories that have no copy of it", rel, m)
}
}
9 changes: 7 additions & 2 deletions agents/internal/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -1118,10 +1118,15 @@ func checkScaffold(repoRoot string) []Check {
Detail: ".agents/skills/migrating-fleet-context/ matches legacy template",
})
case string(drift.ComponentCustomized):
// Unlike recording-what-you-learn, this skill is authoritative and
// agents-owned (design 5.1): a local divergence is staleness, not a
// customization to respect. Reporting it ok let a skill carrying
// obsolete migration instructions pass its own health check.
checks = append(checks, Check{
Name: "scaffold:skill-migrating",
Status: OK,
Detail: ".agents/skills/migrating-fleet-context/ carries repository customizations",
Status: Warn,
Detail: ".agents/skills/migrating-fleet-context/ does not match the installed binary",
Remedy: "run 'agents update --apply' to refresh infrastructure skills",
})
default:
checks = append(checks, Check{
Expand Down
11 changes: 9 additions & 2 deletions agents/internal/doctor/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1390,9 +1390,16 @@ func TestCheckScaffoldGranularChecks(t *testing.T) {
if err := os.WriteFile(skillPath, []byte("# customized migration skill\n"), 0o644); err != nil {
t.Fatal(err)
}
// migrating-fleet-context is 100% agents-owned (design 5.1), so a copy
// that does not match the embedded asset is stale, not customized --
// and a stale migration skill reported `ok` is a skill that passes its
// own health check while carrying obsolete instructions.
cCustom := checkByName(t, checkScaffold(rootCustom), "scaffold:skill-migrating")
if cCustom.Status != OK || cCustom.Detail != ".agents/skills/migrating-fleet-context/ carries repository customizations" {
t.Errorf("customized migrating = %+v", cCustom)
if cCustom.Status != Warn || cCustom.Detail != ".agents/skills/migrating-fleet-context/ does not match the installed binary" {
t.Errorf("stale migrating = %+v", cCustom)
}
if cCustom.Remedy != "run 'agents update --apply' to refresh infrastructure skills" {
t.Errorf("stale migrating remedy = %q", cCustom.Remedy)
}

// Missing
Expand Down
22 changes: 19 additions & 3 deletions agents/internal/drift/drift.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ type DriftReport struct {
RouterState RouterState `json:"router_state"`
SymlinkState string `json:"symlink_state"` // "ok" | "broken" | "not_symlink" | "missing"
DomainState string `json:"domain_state"` // "ok" | "missing"
Skills map[string]string `json:"skills"` // skill_name -> ComponentState
Skills map[string]string `json:"skills"` // embedded skill_name -> ComponentState
LocalSkills []string `json:"local_skills"` // repo-specific skills: listed, never judged
DocsStores map[string]bool `json:"docs_stores"` // design, plans, journal, qna
MisplacedDocs []string `json:"misplaced_docs"` // e.g. plans living in docs/journal/
Diff string `json:"diff,omitempty"` // Unified diff against canonical router
Expand All @@ -28,6 +29,7 @@ func InspectRepo(root string) (DriftReport, error) {
report := DriftReport{
RepoPath: root,
Skills: make(map[string]string),
LocalSkills: []string{},
DocsStores: map[string]bool{"design": false, "plans": false, "journal": false, "qna": false},
MisplacedDocs: []string{},
}
Expand Down Expand Up @@ -109,22 +111,28 @@ func InspectRepo(root string) (DriftReport, error) {
}
}

// Repository-specific skills are listed, never classified. `.agents/skills/`
// is where design section 2 says they belong, so `agents` owning the whole
// directory made a repository dirty for using the feature as intended --
// and no migration could clear it, because there is nothing to fix. The
// tool judges only what it embeds.
skillsDir := filepath.Join(root, ".agents", "skills")
if entries, err := os.ReadDir(skillsDir); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if _, already := report.Skills[name]; already {
if _, tracked := report.Skills[name]; tracked {
continue
}
skillFile := filepath.Join(skillsDir, name, "SKILL.md")
if _, err := os.Stat(skillFile); err == nil {
report.Skills[name] = string(ComponentCustomized)
report.LocalSkills = append(report.LocalSkills, name)
}
}
}
sort.Strings(report.LocalSkills)

// 5. Docs stores inspection
for store := range report.DocsStores {
Expand All @@ -148,6 +156,14 @@ func InspectRepo(root string) (DriftReport, error) {
relSlash := filepath.ToSlash(rel)
name := d.Name()

// docs/archive/ is immutable by repository rule, so nothing in it
// can be "misplaced": a report here is an instruction to move a
// file that must not move. Excluding it keeps this classifier and
// the migrating-fleet-context skill agreeing on one definition.
if strings.HasPrefix(relSlash, "docs/archive/") {
return nil
}

if strings.HasSuffix(name, "-plan.md") && !strings.HasPrefix(relSlash, "docs/plans/") {
report.MisplacedDocs = append(report.MisplacedDocs, relSlash)
}
Expand Down
Loading