From 96bb6a82bdb369035a1cb3a4a211189ae3045e72 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 26 Aug 2026 10:29:56 -0400 Subject: [PATCH 1/7] fix(skillkit): strip the legacy OpenCode AGENTS.md block on install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the OpenCode target to a native ~/.config/opencode/skills/ symlink leaves the marker block older af binaries appended to ~/.config/opencode/AGENTS.md behind forever: uninstallMarkerBlock is no longer reachable for this target, so nothing can remove it. Upgrading users end up with the native skill *and* the stale instructions — the AGENTS.md bloat #813 was actually about. Codex made the same migration in #910 and shipped removeLegacyMarkerBlock for exactly this reason. Install (once the symlink is in place) and Uninstall (per catalog skill) now strip that block. The rules are deliberately stricter than the Codex helper, because the two files are not alike: Codex's AGENTS.override.md was created by af for itself, while ~/.config/opencode/AGENTS.md is written by the user and read by OpenCode. So a file holding no block of ours is never opened for writing — bytes and mtime stay exactly as the user left them — and the file is deleted only when removing our block is what emptied it. Reusing uninstallMarkerBlock verbatim would instead rewrite any AGENTS.md it can read (measured: a user file with no agentfield block goes 23 -> 21 bytes) and delete a deliberately empty one on every install. Other tools' marker blocks and user prose on both sides of ours survive; a missing file is a no-op; read/write failures propagate, matching the target's existing Uninstall error contract. Co-Authored-By: Claude Fable 5 --- .../internal/skillkit/target_opencode.go | 81 +++++- .../skillkit/target_opencode_cleanup_test.go | 240 ++++++++++++++++++ 2 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 control-plane/internal/skillkit/target_opencode_cleanup_test.go diff --git a/control-plane/internal/skillkit/target_opencode.go b/control-plane/internal/skillkit/target_opencode.go index 13887f010..5a8d1d1b0 100644 --- a/control-plane/internal/skillkit/target_opencode.go +++ b/control-plane/internal/skillkit/target_opencode.go @@ -9,7 +9,15 @@ import ( "time" ) -// opencodeTarget installs skills where OpenCode discovers them natively. +// opencodeTarget installs skills where OpenCode discovers them natively: a +// directory at ~/.config/opencode/skills//, symlinked at the canonical +// versioned store so updates flow through without rewriting anything OpenCode +// owns. +// +// Older af binaries instead appended a marker block to +// ~/.config/opencode/AGENTS.md. Every install/uninstall now strips that block +// so upgrading users are left with the native skill instead of the native +// skill plus stale instructions. type opencodeTarget struct{} func init() { RegisterTarget(opencodeTarget{}) } @@ -30,6 +38,18 @@ func (opencodeTarget) TargetPath() (string, error) { return filepath.Join(h, ".config", "opencode", "skills"), nil } +// legacyRulesPath is the file older af binaries appended marker blocks to. +// Unlike Codex's AGENTS.override.md — a file af created for itself — this one +// is authored by the user and read by OpenCode, so it is only ever read, and +// only rewritten when it still holds a block of ours. +func (t opencodeTarget) legacyRulesPath() (string, error) { + root, err := t.TargetPath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(root), "AGENTS.md"), nil +} + func (t opencodeTarget) skillLink(skill Skill) (string, error) { root, err := t.TargetPath() if err != nil { @@ -62,6 +82,11 @@ func (t opencodeTarget) Install(skill Skill, canonicalCurrentDir string) (Instal if err := os.Symlink(canonicalCurrentDir, link); err != nil { return InstalledTarget{}, fmt.Errorf("symlink %s -> %s: %w", link, canonicalCurrentDir, err) } + // The native skill is in place; finish the migration off the old + // AGENTS.md block so the user is not left carrying both. + if err := t.removeLegacyMarkerBlock(skill); err != nil { + return InstalledTarget{}, err + } return InstalledTarget{TargetName: t.Name(), Method: t.Method(), Path: link, Version: skill.Version, InstalledAt: time.Now().UTC()}, nil } @@ -82,6 +107,11 @@ func (t opencodeTarget) Uninstall() error { return fmt.Errorf("remove %s: %w", link, err) } } + // Machines that never ran an install in between still carry the + // legacy block; uninstall has to clear it too. + if err := t.removeLegacyMarkerBlock(s); err != nil { + return err + } } return nil } @@ -120,3 +150,52 @@ func (t opencodeTarget) Status() (bool, string, error) { } return true, filepath.Base(resolved), nil } + +// removeLegacyMarkerBlock strips this skill's marker block from +// ~/.config/opencode/AGENTS.md, the rules file older af binaries wrote into. +// +// That file belongs to the user — OpenCode reads it, and af never created it +// on its own — so the rules are deliberately stricter than the Codex +// equivalent: a file holding no block of ours is not opened for writing at +// all (its bytes and mtime stay exactly as the user left them), and the file +// is deleted only when removing our block is what emptied it. Other tools' +// blocks and any user prose are preserved. Failures other than a missing file +// are reported to the caller. +func (t opencodeTarget) removeLegacyMarkerBlock(skill Skill) error { + path, err := t.legacyRulesPath() + if err != nil { + return err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read legacy OpenCode rules file %s: %w", path, err) + } + if _, ours := findMarkerBlock(string(data), skill); !ours { + return nil // nothing of ours in there; leave the user's file alone + } + + cleaned := strings.TrimRight(stripMarkerBlock(string(data), skill), "\n") + if strings.TrimSpace(cleaned) == "" { + // Our block was the only thing in it, so the file was ours alone. + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", path, err) + } + return nil + } + + perm := os.FileMode(0o644) + if info, err := os.Stat(path); err == nil { + perm = info.Mode().Perm() + } + tmp := path + ".af-tmp" + if err := os.WriteFile(tmp, []byte(cleaned+"\n"), perm); err != nil { + return fmt.Errorf("write %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("rename into %s: %w", path, err) + } + return nil +} diff --git a/control-plane/internal/skillkit/target_opencode_cleanup_test.go b/control-plane/internal/skillkit/target_opencode_cleanup_test.go new file mode 100644 index 000000000..9d1e5ec86 --- /dev/null +++ b/control-plane/internal/skillkit/target_opencode_cleanup_test.go @@ -0,0 +1,240 @@ +package skillkit + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// opencodeLegacyHome prepares an isolated home with ~/.config/opencode present +// and returns the home plus the legacy rules file path inside it. +func opencodeLegacyHome(t *testing.T) (string, string) { + t.Helper() + home := withTempHome(t) + dir := filepath.Join(home, ".config", "opencode") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir .config/opencode: %v", err) + } + return home, filepath.Join(dir, "AGENTS.md") +} + +// seedCurrentDir returns a canonical current/ directory an install can link at. +func seedCurrentDir(t *testing.T) string { + t.Helper() + current := filepath.Join(t.TempDir(), "current") + if err := os.MkdirAll(current, 0o755); err != nil { + t.Fatalf("mkdir current: %v", err) + } + return current +} + +// foreignBlock is another tool's marker block: same file, different owner. +const foreignBlock = "\nplandb rules\n" + +// Contract (a) + (e): installing the native skill strips this skill's legacy +// marker block from ~/.config/opencode/AGENTS.md while leaving the user's prose +// on both sides of it — and another tool's block — untouched. +func TestOpenCodeInstallStripsLegacyMarkerBlockAndKeepsForeignContent(t *testing.T) { + _, legacy := opencodeLegacyHome(t) + content := "# my own opencode notes\n\n" + + renderPointerBlock(Catalog[0], "/gone/canonical/current") + "\n\n" + + foreignBlock + "\n\nnotes that come after the block\n" + if err := os.WriteFile(legacy, []byte(content), 0o644); err != nil { + t.Fatalf("seed legacy rules file: %v", err) + } + + if _, err := (opencodeTarget{}).Install(Catalog[0], seedCurrentDir(t)); err != nil { + t.Fatalf("Install: %v", err) + } + + data, err := os.ReadFile(legacy) + if err != nil { + t.Fatalf("read legacy rules file: %v", err) + } + got := string(data) + if strings.Contains(got, markerStartPattern(Catalog[0])) { + t.Fatalf("legacy marker block survived the install:\n%s", got) + } + for _, keep := range []string{"# my own opencode notes", foreignBlock, "notes that come after the block"} { + if !strings.Contains(got, keep) { + t.Fatalf("migration destroyed content it does not own (%q missing):\n%s", keep, got) + } + } +} + +// Contract (c): a rules file that held nothing but our block was ours alone, so +// it is deleted rather than left behind as an empty file OpenCode keeps reading. +func TestOpenCodeInstallDeletesLegacyRulesFileItOwnedAlone(t *testing.T) { + for _, tc := range []struct { + name string + content string + }{ + {name: "block only", content: renderPointerBlock(Catalog[0], "/gone/current") + "\n"}, + {name: "block and whitespace", content: "\n \n" + renderPointerBlock(Catalog[0], "/gone/current") + "\n \n\t\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, legacy := opencodeLegacyHome(t) + if err := os.WriteFile(legacy, []byte(tc.content), 0o644); err != nil { + t.Fatalf("seed legacy rules file: %v", err) + } + if _, err := (opencodeTarget{}).Install(Catalog[0], seedCurrentDir(t)); err != nil { + t.Fatalf("Install: %v", err) + } + if _, err := os.Lstat(legacy); !os.IsNotExist(err) { + data, _ := os.ReadFile(legacy) + t.Fatalf("legacy rules file should be removed, lstat err=%v content=%q", err, data) + } + }) + } +} + +// Contract (d) + (e): ~/.config/opencode/AGENTS.md is the user's own file. When +// it carries no block of ours, neither install nor uninstall may touch it — +// same bytes, same modification time, whitespace-only content included. +func TestOpenCodeLeavesARulesFileWithoutOurBlockUntouched(t *testing.T) { + for _, tc := range []struct { + name string + content string + }{ + {name: "user prose", content: "# my rules\n\nbe concise\n"}, + {name: "foreign block only", content: foreignBlock + "\n"}, + {name: "whitespace only", content: "\n \n\t\n"}, + {name: "empty", content: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + _, legacy := opencodeLegacyHome(t) + if err := os.WriteFile(legacy, []byte(tc.content), 0o644); err != nil { + t.Fatalf("seed legacy rules file: %v", err) + } + // Backdate so a rewrite is visible even at coarse mtime resolution. + stamp := time.Date(2020, time.March, 4, 5, 6, 7, 0, time.UTC) + if err := os.Chtimes(legacy, stamp, stamp); err != nil { + t.Fatalf("chtimes: %v", err) + } + + target := opencodeTarget{} + if _, err := target.Install(Catalog[0], seedCurrentDir(t)); err != nil { + t.Fatalf("Install: %v", err) + } + assertRulesFileUnchanged(t, legacy, tc.content, stamp, "install") + + if err := target.Uninstall(); err != nil { + t.Fatalf("Uninstall: %v", err) + } + assertRulesFileUnchanged(t, legacy, tc.content, stamp, "uninstall") + }) + } +} + +func assertRulesFileUnchanged(t *testing.T, path, want string, stamp time.Time, stage string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s removed the user's rules file: %v", stage, err) + } + if string(data) != want { + t.Fatalf("%s rewrote the user's rules file:\ngot: %q\nwant: %q", stage, data, want) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat after %s: %v", stage, err) + } + if !info.ModTime().Equal(stamp) { + t.Fatalf("%s opened the user's rules file for writing: mtime %s, want %s", + stage, info.ModTime().UTC(), stamp) + } +} + +// Contract (b) + (e): uninstall removes every catalog skill's link and finishes +// the migration for machines that never ran an install in between, keeping the +// user's prose and other tools' blocks. +func TestOpenCodeUninstallRemovesLinksAndLegacyBlocks(t *testing.T) { + home, legacy := opencodeLegacyHome(t) + var content strings.Builder + content.WriteString("user prose\n\n") + for _, s := range Catalog { + content.WriteString(renderPointerBlock(s, "/gone/current")) + content.WriteString("\n\n") + } + content.WriteString(foreignBlock + "\n") + if err := os.WriteFile(legacy, []byte(content.String()), 0o644); err != nil { + t.Fatalf("seed legacy rules file: %v", err) + } + + target := opencodeTarget{} + root, err := target.TargetPath() + if err != nil { + t.Fatalf("TargetPath: %v", err) + } + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatalf("mkdir skills root: %v", err) + } + for _, s := range Catalog { + if err := os.Symlink(filepath.Join(home, "gone"), filepath.Join(root, s.Name)); err != nil { + t.Fatalf("seed link for %s: %v", s.Name, err) + } + } + + if err := target.Uninstall(); err != nil { + t.Fatalf("Uninstall: %v", err) + } + for _, s := range Catalog { + if _, err := os.Lstat(filepath.Join(root, s.Name)); !os.IsNotExist(err) { + t.Fatalf("link for %s remains: %v", s.Name, err) + } + } + data, err := os.ReadFile(legacy) + if err != nil { + t.Fatalf("read legacy rules file: %v", err) + } + if strings.Contains(string(data), "agentfield-skill:") { + t.Fatalf("legacy blocks remain after uninstall:\n%s", data) + } + if !strings.Contains(string(data), "user prose") || !strings.Contains(string(data), foreignBlock) { + t.Fatalf("uninstall destroyed content it does not own:\n%s", data) + } + // Uninstalling twice is a no-op, not an error. + if err := target.Uninstall(); err != nil { + t.Fatalf("second Uninstall: %v", err) + } +} + +// Contract (f): with no legacy rules file on disk, install and uninstall both +// succeed and neither conjures the file into existence. +func TestOpenCodeCleanupIsANoOpWithoutALegacyRulesFile(t *testing.T) { + _, legacy := opencodeLegacyHome(t) + target := opencodeTarget{} + + if _, err := target.Install(Catalog[0], seedCurrentDir(t)); err != nil { + t.Fatalf("Install: %v", err) + } + if _, err := os.Lstat(legacy); !os.IsNotExist(err) { + t.Fatalf("install created a legacy rules file: %v", err) + } + if err := target.Uninstall(); err != nil { + t.Fatalf("Uninstall: %v", err) + } + if _, err := os.Lstat(legacy); !os.IsNotExist(err) { + t.Fatalf("uninstall created a legacy rules file: %v", err) + } +} + +// Contract (g): a legacy rules file that cannot be read is reported, not +// silently skipped — matching the target's own Uninstall error contract. +func TestOpenCodeCleanupReportsAnUnreadableLegacyRulesFile(t *testing.T) { + _, legacy := opencodeLegacyHome(t) + // A directory where the rules file belongs: readable path, unreadable file. + if err := os.MkdirAll(legacy, 0o755); err != nil { + t.Fatalf("mkdir over legacy rules path: %v", err) + } + + target := opencodeTarget{} + if _, err := target.Install(Catalog[0], seedCurrentDir(t)); err == nil { + t.Fatal("Install should report a legacy rules file it cannot read") + } + if err := target.Uninstall(); err == nil { + t.Fatal("Uninstall should report a legacy rules file it cannot read") + } +} From 1bd61c174ce44080edb8ae8c962c1a31c0d844fa Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 26 Aug 2026 10:30:12 -0400 Subject: [PATCH 2/7] test(skillkit): isolate the OpenCode uninstall test and snapshot its new root TestOpenCodeTargetUninstallRemovesCatalogEntries was the one OpenCode test that did not call withTempHome, so it built and tore down catalog entries in the home shared by the whole package instead of its own. realHomeSnapshot also still only fingerprinted the old ~/.config/opencode/AGENTS.md. Now that OpenCode installs a directory of symlinks, add ~/.config/opencode/skills so the real-home pollution guard covers the path this target actually writes to. Co-Authored-By: Claude Fable 5 --- control-plane/internal/skillkit/target_opencode_test.go | 1 + control-plane/internal/skillkit/testmain_test.go | 1 + 2 files changed, 2 insertions(+) diff --git a/control-plane/internal/skillkit/target_opencode_test.go b/control-plane/internal/skillkit/target_opencode_test.go index ea98e5a96..2cc7e0a44 100644 --- a/control-plane/internal/skillkit/target_opencode_test.go +++ b/control-plane/internal/skillkit/target_opencode_test.go @@ -156,6 +156,7 @@ func TestOpenCodeTargetStatusPreservesVersionFromRemovedDirectLink(t *testing.T) } func TestOpenCodeTargetUninstallRemovesCatalogEntries(t *testing.T) { + withTempHome(t) target := opencodeTarget{} root, err := target.TargetPath() if err != nil { diff --git a/control-plane/internal/skillkit/testmain_test.go b/control-plane/internal/skillkit/testmain_test.go index 0fce5f08a..5244a1aca 100644 --- a/control-plane/internal/skillkit/testmain_test.go +++ b/control-plane/internal/skillkit/testmain_test.go @@ -147,6 +147,7 @@ func realHomeSnapshot(t *testing.T) string { filepath.Join(realHomeBeforeIsolation, ".codex", "skills"), filepath.Join(realHomeBeforeIsolation, ".codex", "AGENTS.override.md"), filepath.Join(realHomeBeforeIsolation, ".gemini", "GEMINI.md"), + filepath.Join(realHomeBeforeIsolation, ".config", "opencode", "skills"), filepath.Join(realHomeBeforeIsolation, ".config", "opencode", "AGENTS.md"), filepath.Join(realHomeBeforeIsolation, ".aider.conventions.md"), filepath.Join(realHomeBeforeIsolation, ".aider.conf.yml"), From 44b9cdb08d70c9ec9d5c079ce7c96b377ddfa5eb Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 26 Aug 2026 11:25:20 -0400 Subject: [PATCH 3/7] refactor(skillkit): route the OpenCode legacy cleanup through the package seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeLegacyMarkerBlock called os.Remove/os.WriteFile/os.Rename directly while every other write path in the package goes through the reconcile* seams (reconcile.go), which exist precisely so a test can force a failure. The consequence was that its "remove", "write" and "rename into" branches could not be exercised at all: six lines that never ran once, and error strings that could ship wrongly wrapped without anything noticing. Switch the four filesystem calls to reconcileReadFile/reconcileRemove/ reconcileWriteFile/reconcileRename and cover each failure through Uninstall, modelled on the reconciler's own rewrite-failure subtests. Also drop legacyRulesPath's error return. It could only fail when TargetPath() fails, and both call sites have already proven TargetPath() succeeds before reaching it — so the branch was unreachable and told a reader about a failure mode that does not exist. It now takes the resolved skills root, which lets Uninstall use the TargetPath() result it was already computing and discarding instead of re-resolving it per skill. No behaviour change: same files read, same files written, same errors returned. Co-Authored-By: Claude Fable 5 --- .../internal/skillkit/target_opencode.go | 44 +++++++------ .../skillkit/target_opencode_cleanup_test.go | 61 +++++++++++++++++++ 2 files changed, 82 insertions(+), 23 deletions(-) diff --git a/control-plane/internal/skillkit/target_opencode.go b/control-plane/internal/skillkit/target_opencode.go index 5a8d1d1b0..646084886 100644 --- a/control-plane/internal/skillkit/target_opencode.go +++ b/control-plane/internal/skillkit/target_opencode.go @@ -38,16 +38,16 @@ func (opencodeTarget) TargetPath() (string, error) { return filepath.Join(h, ".config", "opencode", "skills"), nil } -// legacyRulesPath is the file older af binaries appended marker blocks to. +// legacyRulesPath is the file older af binaries appended marker blocks to. It +// is derived from an already-resolved skills root, so unlike Codex's variant +// it cannot fail: every caller has proven TargetPath() succeeds before it gets +// here. +// // Unlike Codex's AGENTS.override.md — a file af created for itself — this one // is authored by the user and read by OpenCode, so it is only ever read, and // only rewritten when it still holds a block of ours. -func (t opencodeTarget) legacyRulesPath() (string, error) { - root, err := t.TargetPath() - if err != nil { - return "", err - } - return filepath.Join(filepath.Dir(root), "AGENTS.md"), nil +func (opencodeTarget) legacyRulesPath(root string) string { + return filepath.Join(filepath.Dir(root), "AGENTS.md") } func (t opencodeTarget) skillLink(skill Skill) (string, error) { @@ -84,7 +84,7 @@ func (t opencodeTarget) Install(skill Skill, canonicalCurrentDir string) (Instal } // The native skill is in place; finish the migration off the old // AGENTS.md block so the user is not left carrying both. - if err := t.removeLegacyMarkerBlock(skill); err != nil { + if err := t.removeLegacyMarkerBlock(skill, root); err != nil { return InstalledTarget{}, err } return InstalledTarget{TargetName: t.Name(), Method: t.Method(), Path: link, Version: skill.Version, InstalledAt: time.Now().UTC()}, nil @@ -94,14 +94,12 @@ func (t opencodeTarget) Uninstall() error { // Resolve the target root up front so failures (for example, an // unavailable home directory) are reported to the caller instead of // being silently ignored while iterating over the catalog. - if _, err := t.TargetPath(); err != nil { + root, err := t.TargetPath() + if err != nil { return err } for _, s := range Catalog { - link, err := t.skillLink(s) - if err != nil { - return err - } + link := filepath.Join(root, s.Name) if info, err := os.Lstat(link); err == nil && (info.Mode()&os.ModeSymlink != 0 || info.IsDir() || info.Mode().IsRegular()) { if err := os.RemoveAll(link); err != nil { return fmt.Errorf("remove %s: %w", link, err) @@ -109,7 +107,7 @@ func (t opencodeTarget) Uninstall() error { } // Machines that never ran an install in between still carry the // legacy block; uninstall has to clear it too. - if err := t.removeLegacyMarkerBlock(s); err != nil { + if err := t.removeLegacyMarkerBlock(s, root); err != nil { return err } } @@ -161,12 +159,12 @@ func (t opencodeTarget) Status() (bool, string, error) { // is deleted only when removing our block is what emptied it. Other tools' // blocks and any user prose are preserved. Failures other than a missing file // are reported to the caller. -func (t opencodeTarget) removeLegacyMarkerBlock(skill Skill) error { - path, err := t.legacyRulesPath() - if err != nil { - return err - } - data, err := os.ReadFile(path) +// +// Every filesystem call goes through the package's reconcile* seams so each +// failure branch below is reachable from a test. +func (t opencodeTarget) removeLegacyMarkerBlock(skill Skill, root string) error { + path := t.legacyRulesPath(root) + data, err := reconcileReadFile(path) if os.IsNotExist(err) { return nil } @@ -180,7 +178,7 @@ func (t opencodeTarget) removeLegacyMarkerBlock(skill Skill) error { cleaned := strings.TrimRight(stripMarkerBlock(string(data), skill), "\n") if strings.TrimSpace(cleaned) == "" { // Our block was the only thing in it, so the file was ours alone. - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + if err := reconcileRemove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove %s: %w", path, err) } return nil @@ -191,10 +189,10 @@ func (t opencodeTarget) removeLegacyMarkerBlock(skill Skill) error { perm = info.Mode().Perm() } tmp := path + ".af-tmp" - if err := os.WriteFile(tmp, []byte(cleaned+"\n"), perm); err != nil { + if err := reconcileWriteFile(tmp, []byte(cleaned+"\n"), perm); err != nil { return fmt.Errorf("write %s: %w", tmp, err) } - if err := os.Rename(tmp, path); err != nil { + if err := reconcileRename(tmp, path); err != nil { return fmt.Errorf("rename into %s: %w", path, err) } return nil diff --git a/control-plane/internal/skillkit/target_opencode_cleanup_test.go b/control-plane/internal/skillkit/target_opencode_cleanup_test.go index 9d1e5ec86..971a5e343 100644 --- a/control-plane/internal/skillkit/target_opencode_cleanup_test.go +++ b/control-plane/internal/skillkit/target_opencode_cleanup_test.go @@ -1,6 +1,7 @@ package skillkit import ( + "errors" "os" "path/filepath" "strings" @@ -238,3 +239,63 @@ func TestOpenCodeCleanupReportsAnUnreadableLegacyRulesFile(t *testing.T) { t.Fatal("Uninstall should report a legacy rules file it cannot read") } } + +// Contract (h): every write the cleanup performs is reported when it fails. +// These branches are unreachable through real filesystem permissions on some +// platforms, so they are driven through the package's reconcile* seams — the +// same way the reconciler's own rewrite failures are covered. +func TestOpenCodeUninstallReportsLegacyRewriteFailures(t *testing.T) { + ourBlock := renderPointerBlock(Catalog[0], "/gone/current") + for _, tc := range []struct { + name string + content string + inject func(t *testing.T) + }{ + { + name: "write", + content: "user prose\n\n" + ourBlock + "\n", + inject: func(t *testing.T) { + old := reconcileWriteFile + reconcileWriteFile = func(string, []byte, os.FileMode) error { + return errors.New("forced write failure") + } + t.Cleanup(func() { reconcileWriteFile = old }) + }, + }, + { + name: "rename", + content: "user prose\n\n" + ourBlock + "\n", + inject: func(t *testing.T) { + old := reconcileRename + reconcileRename = func(string, string) error { return errors.New("forced rename failure") } + t.Cleanup(func() { reconcileRename = old }) + }, + }, + { + name: "remove", + content: ourBlock + "\n", + inject: func(t *testing.T) { + old := reconcileRemove + reconcileRemove = func(string) error { return errors.New("forced remove failure") } + t.Cleanup(func() { reconcileRemove = old }) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, legacy := opencodeLegacyHome(t) + if err := os.WriteFile(legacy, []byte(tc.content), 0o644); err != nil { + t.Fatalf("seed legacy rules file: %v", err) + } + tc.inject(t) + + err := (opencodeTarget{}).Uninstall() + if err == nil { + t.Fatalf("Uninstall should report a failed %s of the legacy rules file", tc.name) + } + if !strings.Contains(err.Error(), "AGENTS.md") || + !strings.Contains(err.Error(), "forced "+tc.name+" failure") { + t.Fatalf("error should name the file and the cause: %v", err) + } + }) + } +} From 0e35ae482b8f0c9cba3c641fad1ab8564fa7ab08 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 26 Aug 2026 11:25:36 -0400 Subject: [PATCH 4/7] fix(skillkit): keep a live OpenCode install recorded when the legacy block cannot be cleaned Restores skillkit changes from a2762e14 (PR #947) dropped by the squash 9a14e21e. --- .../internal/skillkit/target_opencode.go | 16 ++++- .../skillkit/target_opencode_cleanup_test.go | 63 ++++++++++++++++--- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/control-plane/internal/skillkit/target_opencode.go b/control-plane/internal/skillkit/target_opencode.go index 646084886..5e4fac198 100644 --- a/control-plane/internal/skillkit/target_opencode.go +++ b/control-plane/internal/skillkit/target_opencode.go @@ -84,8 +84,17 @@ func (t opencodeTarget) Install(skill Skill, canonicalCurrentDir string) (Instal } // The native skill is in place; finish the migration off the old // AGENTS.md block so the user is not left carrying both. + // + // AGENTS.md belongs to the user, and by this point the integration is + // already live on disk. Failing the install over a file the skill does + // not need would push the caller down its failure path, which records + // nothing in state — leaving `af skill list` reporting OpenCode as not + // installed and every later install exiting non-zero, over a stale block + // that has nothing to do with whether OpenCode can load the skill. So the + // cleanup is advisory here and only Uninstall, where the block is the + // whole point of the call, treats it as fatal. if err := t.removeLegacyMarkerBlock(skill, root); err != nil { - return InstalledTarget{}, err + fmt.Fprintf(os.Stderr, "warning: could not clean the legacy OpenCode rules block: %v\n", err) } return InstalledTarget{TargetName: t.Name(), Method: t.Method(), Path: link, Version: skill.Version, InstalledAt: time.Now().UTC()}, nil } @@ -157,8 +166,9 @@ func (t opencodeTarget) Status() (bool, string, error) { // equivalent: a file holding no block of ours is not opened for writing at // all (its bytes and mtime stay exactly as the user left them), and the file // is deleted only when removing our block is what emptied it. Other tools' -// blocks and any user prose are preserved. Failures other than a missing file -// are reported to the caller. +// blocks and any user prose are preserved. A missing file is a no-op; every +// other failure is returned, and the two callers weigh it differently — +// Uninstall propagates it, Install warns (see there). // // Every filesystem call goes through the package's reconcile* seams so each // failure branch below is reachable from a test. diff --git a/control-plane/internal/skillkit/target_opencode_cleanup_test.go b/control-plane/internal/skillkit/target_opencode_cleanup_test.go index 971a5e343..f367ad281 100644 --- a/control-plane/internal/skillkit/target_opencode_cleanup_test.go +++ b/control-plane/internal/skillkit/target_opencode_cleanup_test.go @@ -222,25 +222,70 @@ func TestOpenCodeCleanupIsANoOpWithoutALegacyRulesFile(t *testing.T) { } } -// Contract (g): a legacy rules file that cannot be read is reported, not -// silently skipped — matching the target's own Uninstall error contract. -func TestOpenCodeCleanupReportsAnUnreadableLegacyRulesFile(t *testing.T) { +// Contract (g): uninstall reports a legacy rules file it cannot read, rather +// than silently leaving the block behind — there, stripping the block is the +// entire point of the call. +func TestOpenCodeUninstallReportsAnUnreadableLegacyRulesFile(t *testing.T) { _, legacy := opencodeLegacyHome(t) // A directory where the rules file belongs: readable path, unreadable file. if err := os.MkdirAll(legacy, 0o755); err != nil { t.Fatalf("mkdir over legacy rules path: %v", err) } - target := opencodeTarget{} - if _, err := target.Install(Catalog[0], seedCurrentDir(t)); err == nil { - t.Fatal("Install should report a legacy rules file it cannot read") - } - if err := target.Uninstall(); err == nil { + err := (opencodeTarget{}).Uninstall() + if err == nil { t.Fatal("Uninstall should report a legacy rules file it cannot read") } + if !strings.Contains(err.Error(), "AGENTS.md") { + t.Fatalf("error should name the file it could not read: %v", err) + } +} + +// Contract (h): the cleanup is a migration courtesy, not part of making the +// skill work. A legacy rules file af cannot clean up must not fail an install +// whose symlink is already on disk: the caller records nothing for a failed +// target, so failing here would report OpenCode as not installed while it is +// live, and every later `af skill install` would exit non-zero over a stale +// block OpenCode never reads. +func TestOpenCodeInstallSurvivesALegacyRulesFileItCannotClean(t *testing.T) { + home := withTempHome(t) + legacy := filepath.Join(home, ".config", "opencode", "AGENTS.md") + // A directory where the rules file belongs: readable path, unreadable file. + if err := os.MkdirAll(legacy, 0o755); err != nil { + t.Fatalf("mkdir over legacy rules path: %v", err) + } + + report, err := Install(InstallOptions{SkillName: Catalog[0].Name, Targets: []string{"opencode"}}) + if err != nil { + t.Fatalf("Install: %v", err) + } + if len(report.TargetsFailed) != 0 { + t.Fatalf("an uncleanable legacy rules file failed the install: %+v", report.TargetsFailed) + } + if len(report.TargetsInstalled) != 1 || report.TargetsInstalled[0].TargetName != "opencode" { + t.Fatalf("opencode was not reported as installed: %+v", report) + } + + link := filepath.Join(home, ".config", "opencode", "skills", Catalog[0].Name) + if _, err := os.Lstat(link); err != nil { + t.Fatalf("native skill link missing: %v", err) + } + // The link is on disk, so state has to agree — otherwise `af skill list` + // and the next install both disagree with reality. + state, err := LoadState() + if err != nil { + t.Fatalf("LoadState: %v", err) + } + recorded, ok := state.Skills[Catalog[0].Name].Targets["opencode"] + if !ok { + t.Fatal("a live OpenCode install was not recorded in state") + } + if recorded.Path != link { + t.Fatalf("recorded path = %q, want the link on disk %q", recorded.Path, link) + } } -// Contract (h): every write the cleanup performs is reported when it fails. +// Contract (i): every write the cleanup performs is reported when it fails. // These branches are unreachable through real filesystem permissions on some // platforms, so they are driven through the package's reconcile* seams — the // same way the reconciler's own rewrite failures are covered. From 388b5b442b63c05e2cdfd7d7cdac1d8da79912da Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 27 Aug 2026 18:27:54 -0400 Subject: [PATCH 5/7] fix(sdk): mark nested harness subprocesses --- sdk/go/harness/cli.go | 5 ++++ sdk/go/harness/coverage_helpers_test.go | 22 +++++++++++++++++ sdk/python/agentfield/harness/_cli.py | 5 ++++ sdk/python/tests/test_run_cli_env.py | 30 ++++++++++++++++++++++++ sdk/typescript/src/harness/cli.ts | 10 +++++++- sdk/typescript/tests/harness_cli.test.ts | 22 +++++++++++++++++ 6 files changed, 93 insertions(+), 1 deletion(-) diff --git a/sdk/go/harness/cli.go b/sdk/go/harness/cli.go index c2ce6936f..f782defbc 100644 --- a/sdk/go/harness/cli.go +++ b/sdk/go/harness/cli.go @@ -106,6 +106,11 @@ func runCLIWithStdin(ctx context.Context, cmd []string, env map[string]string, c } merged[key] = value } + parentDepth, err := strconv.Atoi(merged["AGENTFIELD_HARNESS_DEPTH"]) + if err != nil || parentDepth < 0 { + parentDepth = 0 + } + merged["AGENTFIELD_HARNESS_DEPTH"] = strconv.Itoa(parentDepth + 1) for k, v := range env { if v == "" { delete(merged, k) diff --git a/sdk/go/harness/coverage_helpers_test.go b/sdk/go/harness/coverage_helpers_test.go index fcc677955..709657a27 100644 --- a/sdk/go/harness/coverage_helpers_test.go +++ b/sdk/go/harness/coverage_helpers_test.go @@ -72,6 +72,28 @@ printf '%s\n' "KEEP_ME=$KEEP_ME" assert.Contains(t, result.Stdout, "KEEP_ME=set") }) + t.Run("sets and increments harness depth while caller env wins", func(t *testing.T) { + dir := t.TempDir() + script := writeTestScript(t, dir, "print-depth", "#!/bin/sh\nprintf '%s' \"$AGENTFIELD_HARNESS_DEPTH\"\n") + + for _, test := range []struct { + name, parent string + env map[string]string + want string + }{ + {name: "first level", want: "1"}, + {name: "nested", parent: "4", want: "5"}, + {name: "caller override", parent: "4", env: map[string]string{"AGENTFIELD_HARNESS_DEPTH": "99"}, want: "99"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("AGENTFIELD_HARNESS_DEPTH", test.parent) + result, err := RunCLI(context.Background(), []string{script}, test.env, "", 0) + require.NoError(t, err) + assert.Equal(t, test.want, result.Stdout) + }) + } + }) + t.Run("context cancellation returns a killed-process result with partial stdout", func(t *testing.T) { dir := t.TempDir() // Flush the line, give the reader a beat, then stall so the kill lands diff --git a/sdk/python/agentfield/harness/_cli.py b/sdk/python/agentfield/harness/_cli.py index 9b03197d3..35d6cb919 100644 --- a/sdk/python/agentfield/harness/_cli.py +++ b/sdk/python/agentfield/harness/_cli.py @@ -288,6 +288,11 @@ async def run_cli( the final JSONL result/usage events remain parseable. """ merged_env = {**os.environ} + try: + parent_depth = int(merged_env.get("AGENTFIELD_HARNESS_DEPTH", "0")) + except ValueError: + parent_depth = 0 + merged_env["AGENTFIELD_HARNESS_DEPTH"] = str(max(parent_depth, 0) + 1) if env: merged_env.update(env) apply_subprocess_env(merged_env) diff --git a/sdk/python/tests/test_run_cli_env.py b/sdk/python/tests/test_run_cli_env.py index 0dbffbd3a..d07bfbb20 100644 --- a/sdk/python/tests/test_run_cli_env.py +++ b/sdk/python/tests/test_run_cli_env.py @@ -87,6 +87,36 @@ async def test_run_cli_works_without_explicit_env(monkeypatch): assert stdout.strip() == "still_there" +@pytest.mark.asyncio +@pytest.mark.parametrize(("parent_depth", "expected"), [(None, "1"), ("4", "5")]) +async def test_run_cli_sets_harness_depth(monkeypatch, parent_depth, expected): + if parent_depth is None: + monkeypatch.delenv("AGENTFIELD_HARNESS_DEPTH", raising=False) + else: + monkeypatch.setenv("AGENTFIELD_HARNESS_DEPTH", parent_depth) + + stdout, _stderr, returncode = await run_cli( + ["bash", "-c", "echo $AGENTFIELD_HARNESS_DEPTH"], timeout=10.0 + ) + + assert returncode == 0 + assert stdout.strip() == expected + + +@pytest.mark.asyncio +async def test_run_cli_caller_harness_depth_wins(monkeypatch): + monkeypatch.setenv("AGENTFIELD_HARNESS_DEPTH", "4") + + stdout, _stderr, returncode = await run_cli( + ["bash", "-c", "echo $AGENTFIELD_HARNESS_DEPTH"], + env={"AGENTFIELD_HARNESS_DEPTH": "99"}, + timeout=10.0, + ) + + assert returncode == 0 + assert stdout.strip() == "99" + + def test_run_cli_merges_openrouter_attribution_defaults(monkeypatch): monkeypatch.delenv("AGENTFIELD_OPENROUTER_SITE_URL", raising=False) monkeypatch.delenv("AGENTFIELD_OPENROUTER_APP_NAME", raising=False) diff --git a/sdk/typescript/src/harness/cli.ts b/sdk/typescript/src/harness/cli.ts index 2dc797f82..495116516 100644 --- a/sdk/typescript/src/harness/cli.ts +++ b/sdk/typescript/src/harness/cli.ts @@ -39,7 +39,15 @@ export function runCli( ): Promise { return new Promise((resolve, reject) => { const [bin, ...args] = cmd; - const env = { ...process.env, ...options?.env }; + const parsedParentDepth = Number.parseInt(process.env.AGENTFIELD_HARNESS_DEPTH ?? '', 10); + const parentDepth = Number.isFinite(parsedParentDepth) && parsedParentDepth >= 0 + ? parsedParentDepth + : 0; + const env = { + ...process.env, + AGENTFIELD_HARNESS_DEPTH: String(parentDepth + 1), + ...options?.env + }; applyOpenRouterAttributionEnv(env); const hasInput = options?.inputText !== undefined; // 'ignore' on stdin gives the child an immediate EOF instead of an open diff --git a/sdk/typescript/tests/harness_cli.test.ts b/sdk/typescript/tests/harness_cli.test.ts index 7988338d3..eef045d53 100644 --- a/sdk/typescript/tests/harness_cli.test.ts +++ b/sdk/typescript/tests/harness_cli.test.ts @@ -68,6 +68,28 @@ describe('harness cli utilities', () => { }); }); + it.each([ + [undefined, undefined, '1'], + ['4', undefined, '5'], + ['4', '99', '99'] + ])('sets harness depth from parent %s and caller %s', async (parent, caller, expected) => { + if (parent === undefined) delete process.env.AGENTFIELD_HARNESS_DEPTH; + else process.env.AGENTFIELD_HARNESS_DEPTH = parent; + const proc = createProcess(); + spawnMock.mockReturnValueOnce(proc as unknown as ReturnType); + + const pending = runCli(['node'], caller === undefined + ? undefined + : { env: { AGENTFIELD_HARNESS_DEPTH: caller } }); + + expect(spawnMock).toHaveBeenCalledWith('node', [], expect.objectContaining({ + env: expect.objectContaining({ AGENTFIELD_HARNESS_DEPTH: expected }) + })); + proc.emit('close', 0); + await pending; + delete process.env.AGENTFIELD_HARNESS_DEPTH; + }); + it('reports a signal death as a negative exit code', async () => { const proc = createProcess(); spawnMock.mockReturnValueOnce(proc as unknown as ReturnType); From 5c12231e584b6c8f1d1983d8258a308cb2686ae9 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 27 Aug 2026 18:28:02 -0400 Subject: [PATCH 6/7] docs(harness): document depth guard and OpenCode limits Documents AGENTFIELD_HARNESS_DEPTH and removes unsupported OpenCode tool and permission claims. --- docs/ENVIRONMENT_VARIABLES.md | 7 +++++++ docs/harness-providers.md | 8 ++++++-- skills/agentfield-use/SKILL.md | 3 +++ skills/agentfield/SKILL.md | 3 +++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index a96c99e77..0a5f85482 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -222,3 +222,10 @@ Attribution is sent as `HTTP-Referer` and `X-Title`: - `AGENTFIELD_INFRON_ATTRIBUTION=false`: Disable Infron attribution headers. When the `AGENTFIELD_INFRON_*` vars are unset, these OpenRouter attribution values are used as fallbacks, so a deployment that already declares its identity keeps it after switching gateways: `AGENTFIELD_OPENROUTER_SITE_URL`, `OR_SITE_URL`, `AGENTFIELD_OPENROUTER_APP_NAME`, `OR_APP_NAME`. The opt-out travels with them: when `AGENTFIELD_OPENROUTER_ATTRIBUTION=false`, these values are not inherited and the Infron defaults apply instead. To control Infron attribution specifically, set the `AGENTFIELD_INFRON_*` vars explicitly or disable it with `AGENTFIELD_INFRON_ATTRIBUTION=false`. + +### Harness (SDKs) + +- `AGENTFIELD_HARNESS_DEPTH`: Marks subprocesses running inside an AgentField + harness session. The SDKs set it to `1` for a first-level child and increment + an inherited numeric value for nested sessions. An explicit per-call `env` + value wins over the derived depth. diff --git a/docs/harness-providers.md b/docs/harness-providers.md index 89af372ac..0204761c0 100644 --- a/docs/harness-providers.md +++ b/docs/harness-providers.md @@ -163,8 +163,8 @@ CLI-specific command construction to application code. | Project root | `--dir` | process working directory | `--cwd` plus process working directory | | One-shot machine output | JSON output | stdin + JSON event stream | stdin + JSON event stream | | System prompt | Native prompt option | Native prompt option | Native prompt option | -| Tool allowlist | Native tool flags | Normalized Pi tool names | Normalized OMP tool names | -| Plan / auto permissions | Native permission flags | Read-only tools / no approval flag | Read-only tools / `--auto-approve` | +| Tool allowlist | Ignored today | Normalized Pi tool names | Normalized OMP tool names | +| Plan / auto permissions | Ignored today | Read-only tools / no approval flag | Read-only tools / `--auto-approve` | | Session resume | Native session option | `--session` | `--resume` | | Structured output | Isolated schema file protocol | Same protocol | Same protocol | | Metrics | Sessions, turns, tokens, cost, duration | Same normalized fields | Same normalized fields | @@ -179,6 +179,10 @@ handled consistently: plan mode removes mutating tools, explicit model variants override `#variant`, and provider-reported metrics are normalized into the shared result type. +OpenCode currently receives only the selected model, project directory, and +prompt. Its adapters ignore `tools` and `permission_mode`; they do not translate +either option to native OpenCode flags today. + ## Model selection and reasoning-effort variants Every provider accepts a `model` option on `.harness()` calls. Leaving it unset diff --git a/skills/agentfield-use/SKILL.md b/skills/agentfield-use/SKILL.md index b28eec25c..ae01121b4 100644 --- a/skills/agentfield-use/SKILL.md +++ b/skills/agentfield-use/SKILL.md @@ -6,6 +6,9 @@ description: "Whenever you have a discrete task to perform — one the user dele # AgentField subharnesses — offload work instead of doing it inline +If `AGENTFIELD_HARNESS_DEPTH` is set, you are running inside an AgentField +harness session: do not dispatch to AgentField agents unless explicitly asked. + A machine with AgentField has one or more **control planes** — a local one (default `http://localhost:8080`) and possibly a **cloud deployment** configured in AgentField Desktop — plus **agent nodes** installed under `~/.agentfield`. diff --git a/skills/agentfield/SKILL.md b/skills/agentfield/SKILL.md index 897ff985f..dad3e7c9b 100644 --- a/skills/agentfield/SKILL.md +++ b/skills/agentfield/SKILL.md @@ -7,6 +7,9 @@ aliases: [agentfield-multi-reasoner-builder] # AgentField +If `AGENTFIELD_HARNESS_DEPTH` is set, you are running inside an AgentField +harness session: do not dispatch to AgentField agents unless explicitly asked. + You are a **systems architect**. Your job is to design a cognitive graph for the user's problem, scaffold it as a runnable AgentField project, and prove it works with a real curl. The intelligence is in the composition. Individual LLM calls reason at ~0.3 — a deliberately-shaped graph of ten of them can reach 0.8 on a real problem. Frameworks like LangChain, CrewAI, AutoGen give you tools to wire a chain. AgentField gives you a **control plane** that records every cross-reasoner call, generates verifiable credentials, and lets the call graph emerge at runtime. From 36800d2cd2296e9c5df23f2bb34d15d2ccfa90f8 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 27 Aug 2026 18:33:56 -0400 Subject: [PATCH 7/7] fix(skillkit): sync harness depth guidance --- .../internal/skillkit/skill_data/agentfield-use/SKILL.md | 3 +++ control-plane/internal/skillkit/skill_data/agentfield/SKILL.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md index b28eec25c..ae01121b4 100644 --- a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md +++ b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md @@ -6,6 +6,9 @@ description: "Whenever you have a discrete task to perform — one the user dele # AgentField subharnesses — offload work instead of doing it inline +If `AGENTFIELD_HARNESS_DEPTH` is set, you are running inside an AgentField +harness session: do not dispatch to AgentField agents unless explicitly asked. + A machine with AgentField has one or more **control planes** — a local one (default `http://localhost:8080`) and possibly a **cloud deployment** configured in AgentField Desktop — plus **agent nodes** installed under `~/.agentfield`. diff --git a/control-plane/internal/skillkit/skill_data/agentfield/SKILL.md b/control-plane/internal/skillkit/skill_data/agentfield/SKILL.md index 897ff985f..dad3e7c9b 100644 --- a/control-plane/internal/skillkit/skill_data/agentfield/SKILL.md +++ b/control-plane/internal/skillkit/skill_data/agentfield/SKILL.md @@ -7,6 +7,9 @@ aliases: [agentfield-multi-reasoner-builder] # AgentField +If `AGENTFIELD_HARNESS_DEPTH` is set, you are running inside an AgentField +harness session: do not dispatch to AgentField agents unless explicitly asked. + You are a **systems architect**. Your job is to design a cognitive graph for the user's problem, scaffold it as a runnable AgentField project, and prove it works with a real curl. The intelligence is in the composition. Individual LLM calls reason at ~0.3 — a deliberately-shaped graph of ten of them can reach 0.8 on a real problem. Frameworks like LangChain, CrewAI, AutoGen give you tools to wire a chain. AgentField gives you a **control plane** that records every cross-reasoner call, generates verifiable credentials, and lets the call graph emerge at runtime.