Implements ADR 0058.
Goal: Both org and per-repo config can express agent entries (URLs or local paths) and remote resource allowlists. No behavioral changes yet.
File: internal/config/config.go
Add an AgentEntry type that supports both string shorthand and
object form via a custom YAML unmarshaler:
type AgentEntry struct {
Name string `yaml:"name,omitempty"`
Source string `yaml:"source"`
}AgentEntry implements yaml.Unmarshaler: if the YAML node is a
scalar string, it populates Source and leaves Name empty (derived
from the source filename at usage time). If the node is a mapping, it
decodes name and source fields normally.
Add Agents to OrgConfig (top-level, alongside
allowed_remote_resources):
type OrgConfig struct {
// ... existing fields ...
Agents []AgentEntry `yaml:"agents,omitempty"`
AllowedRemoteResources []string `yaml:"allowed_remote_resources,omitempty"`
// ...
}Add Agents and AllowedRemoteResources to PerRepoConfig:
type PerRepoConfig struct {
Version string `yaml:"version"`
KillSwitch bool `yaml:"kill_switch,omitempty"`
Roles []string `yaml:"roles,omitempty"`
Agents []AgentEntry `yaml:"agents,omitempty"`
AllowedRemoteResources []string `yaml:"allowed_remote_resources,omitempty"`
CreateIssues *CreateIssuesConfig `yaml:"create_issues,omitempty"`
}Add a AgentEntry.DerivedName() helper that returns Name if set,
otherwise derives it from the Source filename.
File: internal/config/config.go — Validate() for both
OrgConfig and PerRepoConfig
- Each entry is classified as a URL (starts with
https://) or a local path. - URL entries must include a
#sha256=fragment (64-char hex) and must be prefixed by an entry inallowed_remote_resources. - Local path entries must not contain path traversal (
..). - Agent names (derived from filename) must be unique across all entries.
File: internal/config/config.go
Add AllowedRemoteResources to PerRepoConfig (shown above). The
call sites that construct ComposeOpts.OrgAllowlist (in
internal/harness/compose.go) must merge both org and per-repo
allowlists before passing them to matchingAllowedPrefix.
Files: internal/cli/admin.go, internal/cli/github.go,
internal/config/config.go
A shared helper computes default agent URLs:
func DefaultAgentEntries(commitSHA string) ([]AgentEntry, error)This calls scaffold.HarnessBaseURLWithHash() for each default
harness name. The --agents flag filters which roles (and therefore
which harness URLs) are included.
Org-mode install (runInstall): NewOrgConfig() populates
Agents with default URLs.
Per-repo install (runPerRepoInstall / runGitHubSetupPerRepo):
NewPerRepoConfig() populates Agents with default URLs.
Both also seed default AllowedRemoteResources:
[]string{
"https://raw.githubusercontent.com/fullsend-ai/fullsend/",
"https://raw.githubusercontent.com/fullsend-ai/agents/",
}File: internal/config/config_test.go
- Parse/marshal round-trip with
agentsandallowed_remote_resourcesfor bothOrgConfigandPerRepoConfig. - Validation: duplicate agent names, missing hash, non-HTTPS, URL not in allowlist, path traversal rejected.
- Local path entries accepted without hash.
- String shorthand and object form both parse correctly.
- Explicit
nameoverrides filename-derived name. NewOrgConfigandNewPerRepoConfiginclude default agent URLs.
Goal: Users can manage agents in config from the CLI.
File: internal/cli/agent.go (new)
fullsend agent add <url-or-path> [--name <name>]
fullsend agent list [--fullsend-dir <path>]
fullsend agent update <name> [<sha>] [--fullsend-dir <path>]
fullsend agent remove <name> [--fullsend-dir <path>]
Register in internal/cli/root.go:
cmd.AddCommand(newAgentCmd())- Classify input as URL or local path.
- URL path:
a. Pin commit SHA — if the URL lacks a pinned commit (e.g. a
github.combrowse URL or araw.githubusercontent.comURL withmain/HEADinstead of a 40-char SHA), resolve the default branch HEAD viaforge.Clientand rewrite the URL to the pinnedraw.githubusercontent.comform. b. Fetch harness YAML content. c. Compute integrity hash — if the URL lacks a#sha256=fragment, compute SHA-256 of the fetched content and append it. d. If a#sha256=fragment was already present, verify it matches. e. Add URL prefix toallowed_remote_resourcesif not present. - Local path:
a. Validate path exists and has no traversal (
..). b. Read harness YAML from disk. - Determine agent name: use
--nameif provided, otherwise derive from filename. - Parse harness to validate structure and extract role/slug.
- Check for duplicate name in existing config.
- Append entry to
agentsin config (string shorthand if no--name, object form if--nameprovided). - Write updated config.
Build the merged agent set (scaffold base + config overlay) and print a table: name, role, source (scaffold, URL, or path). Agents overridden by config entries are shown with their config source, not the scaffold.
- Look up agent by name in config. Error if not found or if entry is a local path (nothing to pin).
- Parse the existing URL to extract the repo owner/name and harness path.
- If a SHA argument is provided: use it directly.
If no SHA argument: resolve the default branch HEAD via
forge.Client. - Rewrite the URL with the new commit SHA.
- Fetch the harness at the new URL, compute SHA-256, update the
#sha256=fragment. - Write updated config.
Match by name (derived from filename), remove from agents list,
write config. Optionally clean up allowed_remote_resources if no
remaining URL agents use that prefix.
File: internal/cli/agent_test.go (new)
- Add/list/update/remove round-trip.
- Add duplicate rejected.
- Add with bad URL rejected.
- Add with unpinned URL resolves SHA and computes hash.
- Add with pinned URL verifies existing hash.
- Add with local path works.
- Add with path traversal rejected.
- Update re-pins SHA and recomputes hash.
- Update with explicit SHA uses it directly.
- Update on local path entry returns error.
- Remove nonexistent name returns error.
Goal: fullsend run <name> resolves agents from config at
runtime, loading harnesses directly from URLs or local paths. No
wrapper files are generated.
File: internal/config/config.go (or new internal/config/agents.go)
func MergedAgents(scaffoldNames []string, commitSHA string, configAgents []AgentEntry) ([]MergedAgent, error)- Build base set from
scaffoldNamesusingscaffold.HarnessBaseURLWithHash(). - Overlay
configAgents— config entries with the same agent name replace scaffold entries; new names are appended. The agent name is the explicitnamefield if set, otherwise derived from theSourcefilename (e.g.triage.yaml→triage). - Return merged list sorted by name.
MergedAgent contains the resolved name, source (URL, path, or
scaffold), and whether it came from config or scaffold.
Files: internal/cli/run.go, internal/harness/ (loader)
When fullsend run <name> is invoked:
- Build the merged agent set via
MergedAgents()(scaffold base + config overlay from the target repo'sconfig.yaml). - Look up the requested agent by name.
- URL source: pass the URL directly to
LoadWithBase(). The harness is fetched at runtime — no wrapper file is written to disk. Role and slug come from the harness content itself. - Local path source: resolve the path relative to
.fullsend/and load the harness file directly. - Scaffold source (no config override): load the scaffold harness as today.
The --harness flag, when given a name instead of a file path, uses
this same resolution path.
File: internal/layers/harnesswrappers.go
HarnessWrappersLayer continues to generate wrappers for
scaffold-based agents in org mode (these still need role: and
slug: from the GitHub App credentials). Config-driven agents bypass
wrapper generation entirely — fullsend run resolves them at runtime
(3b). As agents migrate from scaffold to config, the wrapper layer
shrinks.
File: internal/cli/run_test.go (or new test file)
- Config agent resolved by name loads from URL at runtime.
- Config agent resolved by name loads from local path.
- Name collision: config entry wins over scaffold.
- Unknown agent name returns an error.
- Scaffold fallback works when agent is not in config.
Goal: Delete compiled-in agent map and triage scaffold files.
Prerequisite: Dispatch routing must resolve agents from the merged
agent set (Phase 3) before scaffold files are deleted. Verify that
dispatch does not check for scaffold harness existence independently
— if it does, update it to use MergedAgents() so triage dispatch
continues to work after scaffold deletion.
File: internal/scaffold/baseurl.go
Once agents are resolved from config (Phase 3), HarnessBaseURL(),
HarnessContentHash(), and HarnessBaseURLWithHash() are only used
for install-time default seeding (Phase 1d). Remove any branches or
helpers that are no longer needed. HarnessNames() continues to
return scaffold-embedded names for default URL computation.
Directory: internal/scaffold/fullsend-repo/
Delete:
agents/triage.mdenv/triage.envpolicies/triage.yamlschemas/triage-result.schema.jsonscripts/pre-triage.shscripts/post-triage.shscripts/post-triage-test.shskills/issue-labels/SKILL.md
File: internal/scaffold/scaffold.go
Remove deleted files from executableFiles map.
File: Makefile
Remove post-triage-test.sh from script-test target.
File: internal/scaffold/fullsend-repo/scripts/validate-output-schema-test.sh
Change default schema from triage-result.schema.json to
prioritize-result.schema.json. Update test data to match.
File: docs/agents/triage.md
Update source link to fullsend-ai/agents repo.
Multiple test files need updates for removed files and deleted agent map:
internal/scaffold/scaffold_test.go— remove triage from expected file lists, delete triage-specific tests, removeIsExternalAgentskip branches.internal/scaffold/baseurl_test.go— remove external agent test cases.internal/harness/scaffold_integration_test.go— remove triage from test tables, removeIsExternalAgentskips, updateTestDiscoverAgentscount.internal/layers/harnesswrappers_test.go— update base URL assertions.internal/scaffold/vendormanifest_test.go— changeagents/triage.mdreferences toagents/code.md.internal/layers/workflows_test.go— same.
Goal: Remove the scaffold fallback so agents in config is the
sole source of agent discovery.
Precondition: All first-party agents (code, fix, review, retro, prioritize, triage) have been extracted from the scaffold into standalone repos and are registered via config in all active installations.
Once the last first-party agent is extracted, file a GitHub issue to track the transition. The issue should verify:
- All first-party agents are available in standalone repos.
- All active installations have
agentspopulated in config (check viafullsend repos status). - The deprecation notice from the additive merge is no longer being triggered in CI/logs.
File: internal/config/agents.go (or wherever MergedAgents
lives)
Change MergedAgents() to return only config entries — scaffold
names are no longer included as a base set. If agents is empty,
return an empty list (or error) instead of falling back.
Directory: internal/scaffold/fullsend-repo/harness/
Delete all remaining harness YAML files. The scaffold embed no longer hosts agent harnesses. Related agent files (agents/, env/, policies/, schemas/, scripts/, skills/) for each extracted agent should already have been deleted in their respective extraction phases.
Files: internal/scaffold/baseurl.go,
internal/layers/harnesswrappers.go
HarnessNames() is no longer needed for agent discovery. Either
remove it or repurpose it for install-time defaults only.
HarnessWrappersLayer is no longer needed — all agents are resolved
at runtime from config. Remove the layer and its harnessesForRole()
helper.
Files: internal/cli/admin.go, internal/cli/github.go,
internal/config/config.go
NewOrgConfig() and NewPerRepoConfig() populate agents with
default URLs pointing to the standalone repos (not the scaffold).
This is the only place default agent URLs are defined.
- Verify empty
agentsreturns empty/error (no fallback). - Verify
fullsend agent listwith empty config shows nothing. - Remove all scaffold-harness-related test infrastructure.
| PR | Phase | Dependencies |
|---|---|---|
| 1 | 1a-1e: config schema | None |
| 2 | 2a-2f: CLI commands | PR 1 |
| 3 | 3a-3d: runtime agent resolution | PR 1 |
| 4 | 4a-4g: remove agent map + triage files | PRs 1-3 |
| 5 | 5a-5f: authoritative config | All agents extracted |
PRs 2 and 3 can be developed in parallel after PR 1 merges. PR 4 is the cleanup that depends on everything else. Phase 5 is a follow-up tracked by a GitHub issue, filed once all first-party agents have been extracted from the scaffold.
Related: fullsend agent migrate-customizations (implemented in
ADR-0064 / PR #2954) migrates existing customized/ overrides into
config-driven agents. It uses DiffHarness to compute minimal base:
composition harnesses and registers agents via the same config schema
defined in Phase 1.