Skip to content

Implementation Plan: ADR-0045 Forge-Portable Harness Schema — Phase 3 (Deprecate)

Context

Phase 2 (shipped) completed the "Adopt" milestone: fullsend install generates thin wrapper harness files with base:, role:, and slug: in the .fullsend config repo. Scaffold templates use forge.github: blocks for platform-specific fields. harness.DiscoverAgents() scans local harness directories for agent identity. fullsend lock --all locks all harnesses in a single pass. Both the config.yaml agents: block and harness wrapper files now contain role/slug (dual-write).

Phase 3 completes the "Deprecate" milestone from the ADR migration path. Specifically:

  1. Lint() diagnostic method warns on missing role — today Validate() returns hard errors only. Phase 3 adds a separate Lint() method that returns non-fatal diagnostics (warnings), starting with "role is not set; it will be required in a future version." This keeps Validate() callers (which treat all errors as hard stops) unaffected.

  2. Consumers migrate to harness-first discovery — today loadKnownSlugs(), runUninstall, and runGitHubUninstall read agent identity exclusively from config.yaml's agents: block. Phase 3 adds remote harness discovery via forge.Client.ListDirectoryContents + GetFileContentAtRef (used for harness wrapper discovery in config repos, distinct from skill directory fetching which uses gitfetch.FetchTree), and migrates these consumers to check harness files first, falling back to the agents: block.

  3. OrgConfig.Agents becomes optional — the Agents field gains omitempty so config.yaml can omit the agents: block. When present during load, a deprecation notice is logged. The dual-write during install continues (Phase 4 stops it).

ADR: docs/ADRs/0045-forge-portable-harness-schema.md Phase 1 plan: docs/plans/adr-0045-forge-portable-harness-phase1.md Phase 2 plan: docs/plans/adr-0045-forge-portable-harness-phase2.md

Relationship to Phase 2

Phase 3 builds on Phase 2's deliverables:

Phase 2 artifactPhase 3 usage
Harness.Role, Harness.Slug fieldsLint() warns when role is absent
DiscoverAgents() + LoadRaw()Foundation for remote harness discovery (same parse logic, different I/O)
Wrapper harness files in config repoRemote discovery reads these instead of config.yaml agents: block
forge.github: blocks in scaffold templatesLint can validate forge section completeness in future phases
HarnessWrappersLayer dual-writeEnsures both sources exist during Phase 3 transition; Phase 4 removes the agents: write

Key design insight: remote vs local discovery

All current consumers of OrgConfig.Agents operate on remote config repo data (fetched via forge.Client) during install/uninstall CLI commands. harness.DiscoverAgents() operates on local harness files on disk. These are fundamentally different data sources:

  • Local discovery (DiscoverAgents): used at agent runtime — the runner reads harness files from the cloned .fullsend/ directory. No migration needed here; the runner already loads harness files directly.
  • Remote discovery (new): used during install/uninstall CLI commands — the CLI reads the .fullsend config repo via the forge API. Phase 2 writes wrapper harness files there, so remote discovery can now read them instead of the agents: block.

All three remote consumers (loadKnownSlugs, runUninstall, runGitHubUninstall) already have fallback paths that derive slugs from DefaultAgentRoles() + naming convention, making the migration lower-risk.

What Phase 3 does NOT do

  • Does NOT require role in Validate() (Phase 4)
  • Does NOT remove AgentSlugs() or the Agents field from OrgConfig (Phase 4)
  • Does NOT stop the dual-write in install (Phase 4)
  • Does NOT remove the fallback to agents: block (Phase 4)

PR Dependency Graph

PR 1 (Lint diagnostic infra) ──> PR 3 (wire Lint into CLI)
                                                           \
PR 2 (remote harness discovery) ──> PR 4 (migrate loadKnownSlugs) ──> PR 6 (OrgConfig.Agents omitempty)
                                 \                                  /
                                  └──> PR 5 (migrate uninstall) ──┘

PRs 1 and 2 can start in parallel (no dependencies on each other or on Phase 2 PR 6). PR 3 depends on PR 1. PRs 4 and 5 depend on PR 2. PR 6 depends on PRs 4 and 5 (all consumers migrated before making the field optional).


PR 1: Lint() diagnostic infrastructure and role warning

Scope: New diagnostic type, Lint() method on Harness, and a "missing role" warning. No callers — pure library code.

Create internal/harness/lint.go:

  • DiagnosticSeverity type:
    go
    type DiagnosticSeverity int
    
    const (
        SeverityWarning DiagnosticSeverity = iota
        SeverityError
    )
  • Diagnostic struct:
    go
    type Diagnostic struct {
        Severity DiagnosticSeverity
        Field    string // e.g. "role", "forge.github.pre_script"
        Message  string
    }
  • (d Diagnostic) String() string — formats as "warning: role: <message>" or "error: role: <message>"
  • (h *Harness) Lint() []Diagnostic:
    • If h.Role == "": append warning {SeverityWarning, "role", "role is not set; it will be required in a future version"}
    • Returns nil when no diagnostics are found (not an empty slice — callers can do if diags := h.Lint(); len(diags) > 0)
    • Called AFTER Validate() / LoadWithBase() — operates on the post-merge, post-forge-resolution harness. Lint() assumes the harness is already valid; callers should not call Lint() if Validate() failed.
    • Unlike Validate(), Lint() never returns an error — it returns a slice of diagnostics that callers can print or ignore.

Design note: Lint() is intentionally separate from Validate() rather than adding a "warnings" return channel to Validate(). This avoids changing Validate()'s signature (error([]Diagnostic, error)) which would require updating every caller. The two methods serve different purposes: Validate() gates execution (hard stop), Lint() provides advisory feedback.

Future lint rules (not in this PR, but the infrastructure supports them):

  • slug is missing
  • forge: section has only one platform (informational)
  • base: uses a pinned commit SHA that differs from the running CLI version

Create internal/harness/lint_test.go:

  • Harness with role → no diagnostics
  • Harness without role → one warning diagnostic with field "role"
  • Harness with role and slug → no diagnostics
  • Diagnostic.String() formats correctly for warning and error severities
  • Lint() returns nil (not empty slice) when no issues found

After merge: Lint() and Diagnostic exist as tested library code. No callers yet. Validate() is unchanged.


PR 2: Remote harness agent discovery

Scope: Add a function that discovers agent identity (role, slug) from harness files in a remote config repo via the forge API. Analogous to DiscoverAgents() but reads via forge.Client instead of the local filesystem.

Create internal/harness/discover_remote.go:

  • DiscoverRemoteAgents(ctx context.Context, client forge.Client, owner, repo, ref string) ([]AgentInfo, error):
    • Calls client.ListDirectoryContents(ctx, owner, repo, "harness", ref, false) to list files in the harness/ directory
    • Filters for .yaml and .yml extensions (same as DiscoverAgents)
    • For each YAML file: calls client.GetFileContentAtRef(ctx, owner, repo, entry.Path, ref) to read the file content
    • Unmarshals each file into a Harness struct using the same minimal parse as LoadRaw — but from bytes rather than a file path. Extract a helper: ParseRaw(data []byte) (*Harness, error) that does yaml.Unmarshal without file I/O, validation, or forge resolution. LoadRaw can be refactored to call ParseRaw internally.
    • Extracts h.Role and h.Slug; skips files where both are empty
    • Returns sorted by Role then Filename (same ordering as DiscoverAgents)
    • If ListDirectoryContents returns forge.ErrNotFound (no harness/ directory), returns (nil, nil) — same convention as DiscoverAgents for non-existent directories
    • Per-file errors (parse failures, GetFileContentAtRef failures) are collected into a multi-error; valid files are still returned. Same partial-result semantics as DiscoverAgents.

Refactor internal/harness/harness.go:

  • Extract ParseRaw(data []byte) (*Harness, error) from LoadRaw:
    go
    func ParseRaw(data []byte) (*Harness, error) {
        var h Harness
        if err := yaml.Unmarshal(data, &h); err != nil {
            return nil, err
        }
        return &h, nil
    }
    
    func LoadRaw(path string) (*Harness, error) {
        data, err := os.ReadFile(path)
        if err != nil {
            return nil, err
        }
        return ParseRaw(data)
    }
  • ParseRaw is exported for use by DiscoverRemoteAgents and any other caller that has raw YAML bytes (e.g., test helpers). LoadRaw remains the convenience wrapper for file-based loading.

Create internal/harness/discover_remote_test.go:

  • Mock forge client (implement forge.Client interface with in-memory file map)
  • Directory with multiple harness files → returns sorted AgentInfo list
  • No harness/ directory (ErrNotFound) → (nil, nil)
  • File without role/slug → skipped
  • Malformed YAML → multi-error, other files still returned
  • GetFileContentAtRef failure for one file → multi-error, other files returned
  • Empty harness/ directory → empty list, no error
  • Results match what DiscoverAgents would return for the same content on disk

After merge: DiscoverRemoteAgents and ParseRaw exist as tested library functions. No production callers. The forge API surface required (ListDirectoryContents, GetFileContentAtRef) already exists.


PR 3: Wire Lint() into fullsend run and lock

Scope: Call Lint() after harness loading in fullsend run and fullsend lock, printing warnings to stderr. Non-fatal — commands still succeed.

Modify internal/cli/run.go:

  • After LoadWithBase() returns successfully, call h.Lint()
  • For each diagnostic, print via printer.Warning(diag.String())
  • No early exit — lint diagnostics are informational only
  • Example output:
    ⚠ warning: role: role is not set; it will be required in a future version

Modify internal/cli/lock.go:

  • Same pattern: call h.Lint() after LoadWithBase() in runLock()
  • For --all mode: lint each harness after loading, print diagnostics with the harness filename as context: printer.Warning(fmt.Sprintf("%s: %s", harnessName, diag.String()))

Check internal/ui/printer.go:

  • Verify Warning(msg string) method exists (or Warn). If not, add it — print to stderr with a prefix, colored yellow if terminal supports it. Follow existing printer.Error() / printer.Info() patterns.

Create/modify test files:

  • internal/cli/run_test.go: test that a harness without role produces a warning line in output but command succeeds
  • internal/cli/lock_test.go (or lock_all_test.go): same for lock path

After merge: fullsend run and fullsend lock emit warnings for harnesses missing role. No behavioral change — commands succeed regardless.

Depends on: PR 1


PR 4: Migrate loadKnownSlugs to harness-first discovery

Scope: Change loadKnownSlugs() in internal/cli/admin.go to prefer harness wrapper files over the config.yaml agents: block. Emits a deprecation notice when falling back to the agents: block.

Modify internal/cli/admin.go:

  • Rename loadKnownSlugsloadKnownSlugsLegacy (unexported, kept as fallback)
  • New loadKnownSlugs(ctx context.Context, client forge.Client, owner, configRepo, ref string, printer *ui.Printer) map[string]string:
    1. Call harness.DiscoverRemoteAgents(ctx, client, owner, configRepo, ref)
    2. If result is non-empty: build map[role]slug from []AgentInfo, return it
    3. If result is empty (no harness files or no role/slug in them): call loadKnownSlugsLegacy (reads config.yaml agents: block)
    4. If legacy returns non-empty: emit deprecation notice via printer.Warning("agent identity read from config.yaml agents: block; migrate to harness files with role/slug fields")
    5. If legacy also empty: return nil (existing behavior — falls through to DefaultAgentRoles() convention in appsetup)
  • Update the call site at line ~1349 (runOrgInstall) to pass ctx and printer to the new signature

Handling duplicate roles: DiscoverRemoteAgents can return multiple entries with the same role (e.g., code.yaml and fix.yaml both have role: coder). When building the map[role]slug, the first entry wins (sorted order: code.yaml before fix.yaml). This matches the existing behavior where AgentSlugs() returns one slug per role. Log at debug level when a duplicate role is encountered.

Modify internal/cli/admin_test.go:

  • Test: config repo has harness wrappers with role/slug → loadKnownSlugs returns slugs from harness files, no deprecation warning
  • Test: config repo has no harness/ dir but has config.yaml with agents: → falls back, emits deprecation warning
  • Test: config repo has harness wrappers WITHOUT role/slug (legacy format) → falls back to agents: block
  • Test: neither harness files nor agents: block → returns nil

After merge: loadKnownSlugs prefers harness wrapper files in the config repo. Existing installs with only config.yaml agents: block continue to work but see a deprecation notice.

Depends on: PR 2


PR 5: Migrate uninstall flows to harness-first discovery

Scope: Change runUninstall and runGitHubUninstall to discover agent slugs from harness wrapper files before falling back to the agents: block.

Modify internal/cli/admin.gorunUninstall (line ~1600):

  • Before reading parsedCfg.Agents, call harness.DiscoverRemoteAgents(ctx, client, owner, configRepo, ref)
  • If harness discovery returns results: build slug list from AgentInfo.Slug values
  • If harness discovery returns empty: fall back to parsedCfg.Agents (existing behavior) with deprecation notice
  • If both empty: fall back to DefaultAgentRoles() convention (existing behavior)
  • The three-tier fallback chain is:
    harness files → config.yaml agents: block → DefaultAgentRoles() convention

Modify internal/cli/github.gorunGitHubUninstall (line ~822):

  • Same three-tier fallback chain as runUninstall
  • Extract a shared helper to avoid duplicating the fallback logic:
    go
    func discoverAgentSlugs(ctx context.Context, client forge.Client, owner, configRepo, ref string, cfg *config.OrgConfig, printer *ui.Printer) []string
    This helper encapsulates the three-tier discovery and deprecation warning. Both runUninstall and runGitHubUninstall call it.

Create internal/cli/discover_slugs.go:

  • discoverAgentSlugs helper function (unexported)
  • Returns []string (slug list, deduplicated)
  • Logs which discovery tier was used at debug level
  • Emits deprecation warning when falling back to agents: block

Tests:

  • internal/cli/admin_test.go: uninstall with harness wrappers → uses harness slugs
  • internal/cli/admin_test.go: uninstall with only agents: block → falls back, deprecation warning
  • internal/cli/github_test.go: same scenarios for runGitHubUninstall
  • Both: empty harness and empty agents → falls back to DefaultAgentRoles() convention

After merge: Uninstall flows prefer harness wrapper files for agent discovery. Existing installations without harness wrappers continue to work via fallback.

Depends on: PR 2


PR 6: Make OrgConfig.Agents optional with deprecation notice

Scope: Allow config.yaml to omit the agents: block entirely. When present, log a deprecation notice during config load. The install flow continues to dual-write (Phase 4 stops it).

Modify internal/config/config.go:

  • Change Agents yaml tag from yaml:"agents" to yaml:"agents,omitempty"
  • AgentSlugs() already handles nil Agents (returns empty map) — verify with a test
  • Add HasAgentsBlock() bool — returns len(c.Agents) > 0. Used by CLI commands to decide whether to emit a deprecation notice.

Modify internal/config/config_test.go:

  • Test: config YAML without agents: block → OrgConfig.Agents is nil, AgentSlugs() returns empty map
  • Test: config YAML with empty agents: []AgentSlugs() returns empty map
  • Test: config YAML with populated agents: → existing behavior unchanged
  • Test: HasAgentsBlock() returns correct values for each case
  • Test: serializing OrgConfig with nil Agents omits the agents: key from YAML output

Modify internal/cli/admin.go:

  • After loading config in runOrgInstall: if cfg.HasAgentsBlock(), emit deprecation notice:
    ⚠ config.yaml contains an agents: block. Agent identity is now managed in harness files.
      The agents: block will be removed in a future version.
      Run 'fullsend install' to migrate.
  • The install flow still writes the agents: block (dual-write continues). Phase 4 will remove it.

Modify internal/cli/admin.gorunPerRepoInstall:

  • Check for cfg.HasAgentsBlock() and emit the same deprecation notice if present.

After merge: config.yaml can omit agents: without errors. When present, a deprecation notice encourages migration. Install continues dual-writing for backward compatibility.

Depends on: PRs 4, 5 (consumers migrated before making the field optional)


Verification

After all PRs merge, verify Phase 3 end-to-end:

  1. make go-test — all new and existing tests pass
  2. make go-vet — no issues
  3. make lint — passes
  4. Lint diagnostics: fullsend run on a harness without role emits a warning but succeeds
  5. Lint diagnostics: fullsend lock and fullsend lock --all emit warnings for harnesses missing role
  6. No warning for valid harnesses: fullsend run on a harness with role produces no lint output
  7. Remote discovery: loadKnownSlugs reads role/slug from remote harness wrapper files in the config repo
  8. Remote discovery fallback: when no harness files exist, loadKnownSlugs falls back to config.yaml agents: block with deprecation notice
  9. Uninstall discovery: runUninstall discovers agent slugs from remote harness files
  10. Uninstall fallback: when no harness files exist, uninstall falls back to agents: block then DefaultAgentRoles()
  11. OrgConfig optional agents: config.yaml without agents: block loads without error; AgentSlugs() returns empty map
  12. OrgConfig omitempty: serializing OrgConfig with nil Agents omits the key from YAML output
  13. Deprecation notice: loading config.yaml with an agents: block emits deprecation warning
  14. Backward compat: existing config.yaml with agents: block continues to work identically (dual-write still active, all consumers still check agents: as fallback)
  15. Dual-write intact: fullsend install still writes both harness wrapper files and config.yaml agents: block

Future: Phase 4 (Remove)

Phase 4 is not planned in detail here, but its scope is:

  • Require role in Validate() (move from Lint() warning to hard error)
  • Stop writing agents: block during install (remove the dual-write from HarnessWrappersLayer and config generation)
  • Remove OrgConfig.Agents field and AgentSlugs() method
  • Remove loadKnownSlugsLegacy and the fallback tier in discoverAgentSlugs
  • Remove HasAgentsBlock() and all deprecation notice code
  • Consider config schema version bump to "v2" (per ADR open question)
  • Audit all consumers (2-3 PRs estimated)