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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ Rules that earn their place:
handle opened none of what its paragraph cites.
-->

## Unreleased

_A repository that uses one agent is asked about one agent._

**Fixed — a repository worked on with a single agent is no longer blocked
for the eleven hosts it does not use.**
([#280](https://github.com/azrtydxb/procoder/pull/280),
[#279](https://github.com/azrtydxb/procoder/issues/279)) A root
`AGENTS.md` with no host copies beside it made the gate demand all twelve,
Comment thread
piwi3910 marked this conversation as resolved.
blocking every commit, with no way to say "this repository uses one agent"
short of deleting `AGENTS.md` — which switches off the drift check for the
copies it does keep. A missing copy now counts only once the repository
has adopted the layer, which is the rule the reporting half of this check
already applied and explained in its own comment. Drifted and unreadable
copies still block whatever was adopted: a stale rule file is another
agent being told something this repository stopped believing, and a file
that does not exist tells no agent anything.

## 3.5.0 — 2026-09-01

_Every host now holds the same turn end, and the record-keeping outlives the reflow._
Expand Down
13 changes: 12 additions & 1 deletion internal/gate/scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"procoder/internal/portability"
"runtime"
"strings"
"testing"
Expand Down Expand Up @@ -460,8 +461,18 @@ func houseRuleFixture(t *testing.T, adopting bool) (root string, paths []string)
}
// Formatting: gofmt has an opinion about this and it is not met.
paths = append(paths, writeFile(t, root, "bad.go", "package main\nfunc main( ){}\n"))
// The agent layer: an AGENTS.md that is somebody else's.
// The agent layer: an AGENTS.md, and one host copy that has drifted
// from it.
//
// The copy is what makes this repository one that ADOPTED the layer.
// Without it the master alone says nothing — a repository carrying an
// AGENTS.md for its own reasons is asked nothing, which is the rule
// #279 restored — and this fixture would no longer exercise the agent
// domain at all. A drifted copy is also the stronger case to pin: it
// blocks whether or not the layer was adopted.
writeFile(t, root, "AGENTS.md", "# Agents\n\nThis project has its own bot.\n")
writeFile(t, root, portability.Copies[0].Path,
portability.Copies[0].Frontmatter+"# Agents\n\nSomething this repository stopped believing.\n")
// Debt: a marker with no revisit condition.
paths = append(paths, writeFile(t, root, "worker.go",
"package worker\n\n// debt: one global lock\nvar mu int\n"))
Expand Down
42 changes: 42 additions & 0 deletions internal/portability/drift.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,26 @@ func AgentsDrift(root string) []gitx.Finding {
Message: fmt.Sprintf("%s is unreadable (%v) — no rule file could be checked against it (agents)", Master, err)}}
}
want := normalize(stripFrontmatter(string(master)))

// A missing copy only means something once this repository has opted
// into the layer — which is exactly the rule Check() already applies,
// and says why in its own comment: an AGENTS.md alone is a file many
// repositories carry for unrelated reasons, and twelve nag lines per
// gate run would be noise.
//
// The two functions disagreed, and the blocking one was the one wired
// into the commit hook: a repository worked on with a single agent had
// every commit blocked by eleven demands for rule files no agent here
// will ever read, with no way to say so short of deleting AGENTS.md
// and losing the drift check with it (#279).
//
// Drifted and unreadable still block whatever the repository has
// adopted. A stale rule file is another agent being told something
// this repository stopped believing; a file that does not exist tells
// no agent anything. Missing and drifted are different failures, and
// until now this function treated them as one.
adopted := adoptedLayer(root)

var out []gitx.Finding
for _, c := range Copies {
v, rerr := check(root, c, want)
Expand All @@ -70,6 +90,9 @@ func AgentsDrift(root string) []gitx.Finding {
out = append(out, gitx.Finding{Blocking: true, File: c.Path,
Message: fmt.Sprintf("%s rule file is unreadable (%v) — NOT checked against %s (agents)", c.Host, rerr, Master)})
case missing:
if !adopted {
continue
}
out = append(out, gitx.Finding{Blocking: true, File: c.Path,
Message: fmt.Sprintf("%s has no rule file — run `procoder agents` for the content to write (agents)", c.Host)})
case drifted:
Expand All @@ -79,3 +102,22 @@ func AgentsDrift(root string) []gitx.Finding {
}
return out
}

// adoptedLayer reports whether any host copy is present.
//
// Present, not readable. A copy that exists and cannot be read — a
// permission, a broken mount — is still a copy this repository chose to
// have, and it already blocks on its own account; letting a stat error
// mean "not adopted" would suppress every missing-copy finding on the
// strength of one unreadable file.
//
// Check() applies the same rule through the same function, so the two
// cannot drift apart again.
func adoptedLayer(root string) bool {
for _, c := range Copies {
if _, err := os.Stat(filepath.Join(root, c.Path)); err == nil || !os.IsNotExist(err) {
return true
}
}
return false
}
140 changes: 130 additions & 10 deletions internal/portability/drift_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package portability
import (
"os"
"path/filepath"
"procoder/internal/gitx"
"runtime"
"strings"
"testing"
Expand Down Expand Up @@ -33,20 +34,17 @@ func TestDriftedRuleFilesBlock(t *testing.T) {
if err := os.WriteFile(filepath.Join(root, Master), []byte(master), 0o644); err != nil {
t.Fatal(err)
}
// Every host missing: each is reported, and each blocks.
got := AgentsDrift(root)
if len(got) != len(Copies) {
t.Fatalf("every host without a rule file must be reported: %d of %d", len(got), len(Copies))
}
for _, f := range got {
if !f.Blocking {
t.Errorf("a host reading nothing must block: %q", f.Message)
}
// An AGENTS.md with no copies beside it is a repository that never
// opted into the layer, and it is asked nothing. See
// TestMissingCopiesOnlyMatterOnceTheLayerIsAdopted.
if got := AgentsDrift(root); len(got) != 0 {
t.Fatalf("a repository that never adopted the layer was asked for %d rule file(s): %+v", len(got), got)
}

// One host given content that differs is DRIFTED, and the message says
// so rather than calling it missing — the reader has a file to fix,
// not one to create.
// not one to create. Writing it also adopts the layer, which is what
// makes the other eleven worth mentioning at all.
c := Copies[0]
writeRules(t, root, c.Path, c.Frontmatter+"# Rules\n\nSomething else entirely.\n")
var found bool
Expand Down Expand Up @@ -130,3 +128,125 @@ func TestMatchingFilesAndNoAgentLayerAreSilent(t *testing.T) {
t.Errorf("rule files that match must be silent: %+v", got)
}
}

// A missing rule file only means something once this repository has
// opted into the agent layer.
//
// Check() has always applied that rule and says why in its own comment;
// AgentsDrift did not, and AgentsDrift is the one wired into the commit
// hook. So a repository worked on with a single agent had every commit
// blocked by eleven demands for rule files no agent there will ever read,
// and the only escape was deleting AGENTS.md — which switches off the
// drift check for the copies it does keep (#279).
//
// Drifted and unreadable still block whatever the repository adopted. A
// stale rule file is another agent being told something this repository
// stopped believing; a file that does not exist tells no agent anything.
//
// proved by: reporting missing copies unconditionally again — the first
// case below goes back to twelve blocking findings on a repository that
// carries an AGENTS.md for its own reasons.
func TestMissingCopiesOnlyMatterOnceTheLayerIsAdopted(t *testing.T) {
root := t.TempDir()
master := "# Rules\n\nAlways do the thing.\n"
if err := os.WriteFile(filepath.Join(root, Master), []byte(master), 0o644); err != nil {
t.Fatal(err)
}

if got := AgentsDrift(root); len(got) != 0 {
t.Fatalf("an AGENTS.md alone asked for %d rule file(s): %+v", len(got), got)
}

// Adopt the layer by writing one copy correctly. Every OTHER host is
// now a gap the repository chose to have, and is reported.
adopt := Copies[0]
writeRules(t, root, adopt.Path, adopt.Frontmatter+master)

got := AgentsDrift(root)
if len(got) != len(Copies)-1 {
t.Fatalf("with the layer adopted, the other %d hosts must be reported, got %d: %+v",
len(Copies)-1, len(got), got)
}
for _, f := range got {
if f.File == adopt.Path {
t.Errorf("the copy that is present and correct was reported: %q", f.Message)
}
if !f.Blocking {
t.Errorf("a host reading nothing in an adopted repository must block: %q", f.Message)
}
}
}

// The two functions that judge the agent layer agree about what a missing
// copy means. They disagreed once, and the blocking one was the one the
// commit hook ran.
//
// proved by: dropping the adopted guard from either — this test finds one
// reporting a gap the other does not.
func TestDriftAndCheckAgreeOnMissingCopies(t *testing.T) {
root := t.TempDir()
master := "# Rules\n\nAlways do the thing.\n"
if err := os.WriteFile(filepath.Join(root, Master), []byte(master), 0o644); err != nil {
t.Fatal(err)
}

missingIn := func(findings []gitx.Finding) int {
n := 0
for _, f := range findings {
if strings.Contains(f.Message, "missing") || strings.Contains(f.Message, "no rule file") {
n++
}
}
return n
}

if d, c := missingIn(AgentsDrift(root)), missingIn(Check(root)); d != c {
t.Errorf("unadopted: AgentsDrift reports %d missing copies, Check reports %d", d, c)
}

adopt := Copies[0]
writeRules(t, root, adopt.Path, adopt.Frontmatter+master)
if d, c := missingIn(AgentsDrift(root)), missingIn(Check(root)); d != c {
t.Errorf("adopted: AgentsDrift reports %d missing copies, Check reports %d", d, c)
}
}

// A copy that exists and cannot be read still means the layer was
// adopted.
//
// It is a file this repository chose to have, and it blocks on its own
// account. Letting a stat error mean "not adopted" would suppress every
// missing-copy finding on the strength of one unreadable file — the check
// going quietest exactly where something is wrong.
//
// proved by: treating only `err == nil` as adopted again — the eleven
// missing copies below go unreported.
func TestAnUnreadableCopyStillCountsAsAdopted(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("chmod 000 does not make a file unreadable on Windows")
}
if os.Geteuid() == 0 {
t.Skip("root reads anything, so this cannot be exercised")
}
root := t.TempDir()
master := "# Rules\n\nAlways do the thing.\n"
if err := os.WriteFile(filepath.Join(root, Master), []byte(master), 0o644); err != nil {
t.Fatal(err)
}
c := Copies[0]
writeRules(t, root, c.Path, c.Frontmatter+master)
path := filepath.Join(root, c.Path)
if err := os.Chmod(path, 0o000); err != nil {
t.Skip("cannot make a file unreadable here: ", err)
}
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })

if !adoptedLayer(root) {
t.Fatal("an unreadable copy was read as a repository that never adopted the layer")
}
got := AgentsDrift(root)
if len(got) != len(Copies) {
t.Fatalf("want the unreadable copy plus the %d missing ones, got %d: %+v",
len(Copies)-1, len(got), got)
}
}
8 changes: 1 addition & 7 deletions internal/portability/portability.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,7 @@ func Check(root string) []gitx.Finding {
// the layer (at least one copy present) — an AGENTS.md alone is a file
// many repos carry for unrelated reasons, and ten nag lines per gate
// run would be noise; drifted or unreadable copies always report
adopted := false
for _, c := range Copies {
if _, err := os.Stat(filepath.Join(root, c.Path)); err == nil {
adopted = true
break
}
}
adopted := adoptedLayer(root)
var out []gitx.Finding
for _, c := range Copies {
raw, err := os.ReadFile(filepath.Join(root, c.Path))
Expand Down