Implementation Plan: ADR-0045 Forge-Portable Harness Schema — Phase 2 (Adopt)
Context
Phase 1 (shipped) added role, slug, base, and forge: to the harness YAML schema as optional fields with full merge logic, base composition, forge resolution, and pipeline integration. All existing harnesses continue to work unchanged. The scaffold harness templates already contain role and slug fields.
Phase 2 completes the "Adopt" milestone from the ADR migration path: existing infrastructure transitions to use the new schema fields. Specifically:
fullsend installwritesrole/sluginto harness files -- today the admin install flow writes agent identity only toconfig.yaml'sagents:block. Phase 2 makes install also writerole/sluginto the harness files themselves, establishing the harness as the source of truth (while keeping theagents:block for backward compatibility until Phase 4).Thin
base:wrappers replace full scaffold copies -- todayfullsend installdelivers harness files as static embedded copies. Phase 2 changes install to generate thin wrapper harnesses that reference upstream scaffold harnesses via URL (with#sha256=...integrity hash), using the samebasecomposition from Phase 1. Orgs customize by overriding fields in the wrapper instead of editing the upstream copy.GitHub-specific fields move into
forge.github:blocks -- today all scaffold harness templates havepre_script,post_script, andrunner_envat the top level, making them implicitly GitHub-only. Phase 2 moves these intoforge.github:blocks, making the templates structurally portable even though only GitHub is supported today.harness.DiscoverAgents()enables harness-based agent discovery -- today agent inventory is read fromconfig.yaml'sagents:block viaOrgConfig.AgentSlugs(). Phase 2 adds a function that scansharness/*.yamlfiles forrole/slug, providing a parallel discovery path. Consumers are NOT migrated yet (that is Phase 3).
ADR: docs/ADRs/0045-forge-portable-harness-schema.md Phase 1 plan: docs/plans/adr-0045-forge-portable-harness-phase1.md
Relationship to Phase 1
Phase 2 builds on Phase 1's deliverables:
| Phase 1 artifact | Phase 2 usage |
|---|---|
Harness.Role, Harness.Slug fields | Install writes these into generated harness wrappers |
Harness.Base field + LoadWithBase() | Generated wrappers use base: to reference upstream scaffold harnesses |
ForgeConfig struct + ResolveForge() | Scaffold templates move GitHub fields into forge.github: blocks |
LoadRaw() | DiscoverAgents() uses it to scan harness files without full validation |
Dependency type from LoadWithBase() | fullsend lock records base URL deps for generated wrappers |
Relationship to ADR-0038 (Universal Harness Access)
Phase 2 generates base: URLs pointing to upstream scaffold harnesses. These URLs follow the same rules as all ADR-0038 URL resources:
- HTTPS-only with mandatory
#sha256=...integrity hash - Must be covered by
allowed_remote_resourcesinconfig.yaml - Fetched via the SSRF-hardened
internal/fetchlayer - Cached in
.fullsend-cache/and recorded inlock.yamlviafullsend lock
The base URL format uses raw.githubusercontent.com with the release tag SHA:
https://raw.githubusercontent.com/fullsend-ai/fullsend/<commit-sha>/internal/scaffold/fullsend-repo/harness/triage.yaml#sha256=<hash>The <commit-sha> and <hash> are computed at build time or during install from the current CLI release. fullsend lock pins them in lock.yaml.
Open questions resolved by this plan
Slug derivation convention (ADR open question): Phase 2 does NOT auto-derive slugs from roles. The install flow already knows the actual slug from GitHub's manifest response (or from loadKnownSlugs()). Harness wrappers receive the real slug. This avoids introducing an implicit <org>-<role> naming contract.
config.yaml agents: block coexistence: Phase 2 writes agent identity to BOTH locations -- harness files AND config.yaml. The agents: block remains the canonical source for all existing consumers (loadKnownSlugs, runUninstall, SecretsLayer). Phase 3 migrates consumers to harness discovery; Phase 4 removes the agents: block.
fullsend lock iteration: Today fullsend lock takes a single agent name. Phase 2 adds fullsend lock --all to iterate all harness files in the directory, which is needed because install generates wrappers with base: URLs that all need locking.
PR Dependency Graph
PR 1 (DiscoverAgents) ──────────────────────────────────────────────┐
│
PR 2 (forge.github in scaffold templates) ──────────────────────────┤
│
PR 3 (base URL generation + scaffold hashing) ──> PR 4 (install) ──┤
│
PR 5 (fullsend lock --all) ────────────────────────────────────────>│
│
└──> PR 6 (integration tests + verification)PRs 1, 2, 3, 5 can start in parallel. PR 4 depends on PR 3 (needs the base URL builder). PR 6 depends on all others for end-to-end integration verification.
PR 1: harness.DiscoverAgents() -- harness-based agent inventory
Scope: Add a function that scans a harness directory and returns agent identity (role, slug) from each YAML file. No consumers are migrated -- this is pure library code for Phase 3.
Create internal/harness/discover.go:
AgentInfostruct:gotype AgentInfo struct { Role string // from harness role: field Slug string // from harness slug: field Filename string // e.g. "triage.yaml" Path string // absolute path to the harness file }DiscoverAgents(dir string) ([]AgentInfo, error):- Globs
filepath.Join(dir, "*.yaml")andfilepath.Join(dir, "*.yml") - For each file, calls
LoadRaw(path)(unmarshal only, no validation) - Extracts
h.Roleandh.Slug; skips files where both are empty (not harness identity files, or legacy harnesses without role/slug) - Returns sorted by
Rolefor deterministic output - Errors on individual files are collected and returned as a multi-error (one bad YAML file should not prevent discovery of others). This partial-result semantic is intentional: discovery is a read-only inventory operation where returning what we can find is more useful than failing entirely.
lock --all(PR 5) uses similar partial-progress semantics: successfully resolved harnesses are saved before returning the error, so they don't need to be re-resolved on retry. - Does NOT resolve
base:chains -- reads only the top-levelrole/slugfrom each file. This is correct because generated wrappers setrole/slugat the top level (not inherited from base).
- Globs
Create internal/harness/discover_test.go:
- Directory with multiple harness files -> returns sorted AgentInfo list
- Harness without role/slug -> skipped
- Harness with only role (no slug) -> included (role is the primary key)
- Malformed YAML -> included in multi-error, other files still returned
- Empty directory -> empty list, no error
- Non-existent directory -> nil, nil (not an error -- matches LoadProviderDefs convention)
.ymlextension -> discovered alongside.yaml
Known complexity for Phase 3 consumers: DiscoverAgents can return multiple entries with the same role value (e.g., code.yaml and fix.yaml both have role: coder). Phase 3 consumers that build role-to-slug maps must handle this — likely by using the filename as a disambiguator or by grouping entries by role.
After merge: DiscoverAgents exists as a tested library function. No callers. Phase 3 will wire it into loadKnownSlugs() and other consumers.
PR 2: Move GitHub-specific fields into forge.github: in scaffold templates
Scope: Restructure all scaffold harness templates to use forge.github: blocks for platform-specific fields. The templates remain functionally identical when loaded with --forge github (which is the only supported platform today). This is a pure template change -- no Go code changes.
Design note: The forge.github: blocks contain pre_script, post_script, and runner_env entries that are GitHub-specific. Platform-neutral runner_env keys (e.g., FULLSEND_OUTPUT_SCHEMA, FULLSEND_OUTPUT_FILE, TARGET_BRANCH) remain at the top level as shared defaults, merged with the forge-specific keys at runtime via the Phase 1 ResolveForge logic. The skills field stays at the top level because skills are agent instructions, not forge API integrations (even skills that reference gh CLI are invoked inside the sandbox where the CLI is pre-installed regardless of forge).
Per-template changes
internal/scaffold/fullsend-repo/harness/triage.yaml:
- Move to
forge.github::pre_script,post_script - Move to
forge.github.runner_env::GITHUB_ISSUE_URL,GH_TOKEN - Keep at top level
runner_env::FULLSEND_OUTPUT_SCHEMA - Keep at top level:
agent,doc,model,image,policy,role,slug,skills,host_files,timeout_minutes,validation_loop
internal/scaffold/fullsend-repo/harness/code.yaml:
- Move to
forge.github::pre_script,post_script - Move to
forge.github.runner_env::REPO_FULL_NAME,ISSUE_NUMBER,REPO_DIR(value${GITHUB_WORKSPACE}/target-repo) PUSH_TOKEN,PUSH_TOKEN_SOURCE: auto-minted bymintAgentToken()when--mint-urlis provided- Keep at top levelrunner_env::TARGET_BRANCH- Keep at top level:
agent,doc,model,image,policy,role,slug,skills,plugins,host_files,timeout_minutes
internal/scaffold/fullsend-repo/harness/review.yaml:
- Move to
forge.github::pre_script,post_script - Move to
forge.github.runner_env::REPO_FULL_NAME,PR_NUMBER,GITHUB_PR_URL REVIEW_TOKEN: auto-minted bymintAgentToken()when--mint-urlis provided- Keep at top levelrunner_env::FULLSEND_OUTPUT_SCHEMA- Keep at top level:
agent,doc,model,image,policy,role,slug,skills,host_files,timeout_minutes,validation_loop
internal/scaffold/fullsend-repo/harness/fix.yaml:
- Move to
forge.github::pre_script,post_script - Move to
forge.github.runner_env::REPO_FULL_NAME,PR_NUMBER,REPO_DIR(value${GITHUB_WORKSPACE}/target-repo) PUSH_TOKEN,PUSH_TOKEN_SOURCE: auto-minted bymintAgentToken()when--mint-urlis provided- Keep at top levelrunner_env::TARGET_BRANCH,TRIGGER_SOURCE,HUMAN_INSTRUCTION,FIX_ITERATION,REVIEW_BODY_FILE,PRE_AGENT_HEAD,FULLSEND_OUTPUT_SCHEMA,FULLSEND_OUTPUT_FILE- Keep at top level:
agent,doc,model,image,policy,role,slug,skills,host_files,timeout_minutes,validation_loop
internal/scaffold/fullsend-repo/harness/retro.yaml:
- Move to
forge.github::pre_script,post_script - Move to
forge.github.runner_env::ORIGINATING_URL,REPO_FULL_NAME,GH_TOKEN - Keep at top level
runner_env::FULLSEND_OUTPUT_SCHEMA - Keep at top level:
agent,doc,model,image,policy,role,slug,skills,host_files,timeout_minutes,validation_loop
internal/scaffold/fullsend-repo/harness/prioritize.yaml:
- Move to
forge.github::pre_script,post_script - Move to
forge.github.runner_env::GITHUB_ISSUE_URL,GH_TOKEN,ORG,PROJECT_NUMBER - Keep at top level
runner_env::FULLSEND_OUTPUT_SCHEMA - Keep at top level:
agent,doc,model,image,policy,role,slug,skills,host_files,timeout_minutes
Test updates
internal/scaffold/scaffold_test.go -- TestHarnessesLoadAndValidate:
- This test currently calls
harness.Load()which does NOT resolve forge blocks. Theforge.github:blocks will passvalidateForge()(called fromValidate()) but will not be merged into top-level fields. - Update this test to also call
harness.LoadWithOpts(path, harness.LoadOpts{ForgePlatform: "github"})on each template, verifying that forge resolution produces the expected merged state (e.g.,h.PreScriptis set,h.RunnerEnvcontains the GitHub keys after merge). - Add assertions: after
LoadWithOptswithgithub, verifyh.Forgeis nil (consumed),h.PreScript != "",h.PostScript != "", and the mergedRunnerEnvcontains both top-level and forge-specific keys.
After merge: Scaffold harness templates are structurally portable. fullsend run --forge github triage produces identical behavior to the pre-PR state. harness.Load() (without forge) still works but leaves pre_script and post_script empty at the top level (they live inside forge.github). The runner always uses LoadWithBase which calls ResolveForge, so this is transparent at runtime.
PR 3: Base URL generation and scaffold content hashing
Scope: Add infrastructure to generate base: URLs pointing to upstream scaffold harnesses, including content hashing for integrity verification. This is the foundation that PR 4 (install) uses to generate thin wrapper harnesses.
Create internal/scaffold/baseurl.go:
HarnessBaseURL(harnessName, commitSHA string) (string, error):- Returns
https://raw.githubusercontent.com/fullsend-ai/fullsend/<commitSHA>/internal/scaffold/fullsend-repo/harness/<harnessName>.yaml - Validates
harnessNamematches^[a-z][a-z0-9_-]*$(same pattern as role validation) - Validates
commitSHAis a 40-character hex string - No hash fragment -- the caller appends
#sha256=...after computing the content hash
- Returns
HarnessContentHash(harnessName string) (string, error):- Reads the embedded harness file from
fullsend-repo/harness/<harnessName>.yamlvia the embeddedembed.FS - Returns the SHA-256 hex digest of the raw file content
- This hash is the integrity hash that goes into the
#sha256=...URL fragment - The hash is computed from the compile-time embedded content, which matches what
raw.githubusercontent.comserves for the release tag's commit SHA
- Reads the embedded harness file from
HarnessBaseURLWithHash(harnessName, commitSHA string) (string, error):- Convenience wrapper: calls
HarnessBaseURL+HarnessContentHashand returns the full URL with#sha256=...fragment - Returns an error if the harness name does not exist in the embedded scaffold
- Convenience wrapper: calls
HarnessNames() ([]string, error):- Returns the list of harness names available in the embedded scaffold (e.g.,
["code", "fix", "prioritize", "retro", "review", "triage"]) - Derived from
fullsend-repo/harness/*.yamlin the embedded FS - Sorted alphabetically
- Returns the list of harness names available in the embedded scaffold (e.g.,
Design decisions:
Commit SHA source: The
commitSHAparameter comes from the CLI's build-time version metadata. For tagged releases, GoReleaser sets theversionvariable via-ldflagswhich includes the git tag. The install flow can derive the commit SHA from the tag viagit ls-remoteor from a build-time constant. If the version is"dev"(local builds), the install flow should warn that base URLs cannot be generated (no stable commit SHA) and fall back to scaffolding full copies as today.Content hash correctness: The hash is computed from the embedded file content at the time the CLI binary was built. This matches what
raw.githubusercontent.comserves for the exact commit the release was built from. If someone modifies the file after tagging but before building, the hash would be wrong -- GoReleaser's reproducible build pipeline prevents this.The
allowed_remote_resourcesprefix: Generated base URLs use thehttps://raw.githubusercontent.com/fullsend-ai/fullsend/prefix. The install flow must add this prefix toconfig.yaml'sallowed_remote_resourcesif not already present (handled in PR 4).
Create internal/scaffold/baseurl_test.go:
HarnessBaseURLreturns expected URL formatHarnessBaseURLrejects invalid harness names and commit SHAsHarnessContentHashreturns a 64-character hex string for each known harnessHarnessContentHasherrors on unknown harness nameHarnessBaseURLWithHashproduces a valid URL with#sha256=...fragmentHarnessNamesreturns the expected set of names, sorted- Hash stability: hash of a known harness matches
sha256sumof the embedded file content
After merge: The scaffold package can generate integrity-verified base URLs for any embedded harness template. No install flow changes yet.
PR 4: Update fullsend install to generate thin wrapper harnesses
Scope: Change the admin install flow to generate thin harness wrappers with base: URLs instead of relying solely on the static embedded scaffold. Also write role/slug into the generated wrappers. The config.yaml agents: block continues to be written (dual-write for backward compatibility).
Changes to internal/layers/workflows.go
The WorkflowsLayer currently uses scaffold.WalkFullsendRepo() which skips layeredDirs (including harness/). This is unchanged -- harness files are still NOT delivered by the workflows layer. Instead, a new layer handles harness wrapper generation.
New layer: internal/layers/harnesswrappers.go
HarnessWrappersLayer struct:
- Fields:
org string,client forge.Client,printer *ui.Printer,agents []AgentCredentials,commitSHA string,existingHarnesses map[string]bool NewHarnessWrappersLayer(org string, client forge.Client, printer *ui.Printer, agents []AgentCredentials, commitSHA string) *HarnessWrappersLayer
Install() error:
- For each agent in
agents:- Derive the harness name from
agent.Role. Special case: role"coder"maps to harness name"code"(matching the existing scaffold convention wherecode.yamlhasrole: coder). Role"fullsend"is the org-level app and has no harness -- skip it. - Call
scaffold.HarnessBaseURLWithHash(harnessName, commitSHA)to get the base URL - Generate wrapper YAML:yaml
# This file is managed by fullsend. Do not edit it directly. # To customize, add overrides below the base: line. base: <base-url-with-hash> role: <agent.Role> slug: <agent.Slug> - For the
fixrole: the wrapper setsrole: coderandslug: <coder-slug>because fix reuses the coder app (perDefaultAgentRoles()comment: "The fix stage reuses the coder app"). The fix harness name is"fix"but the role and slug come from the coder agent entry.
- Derive the harness name from
- Check if the
.fullsendconfig repo already hasharness/directory with existing files (viaclient.GetFileorclient.ListDir). If an existing harness file is present and NOT managed (no managed header), skip it -- the org has customized that harness and should not have it overwritten. - Commit all wrapper files via
client.CommitFiles()in a single atomic commit.
Analyze() (*AnalysisResult, error):
- Reports which wrapper files would be created/updated
- Flags existing non-managed harness files that would be skipped
Fallback for dev builds: When commitSHA is empty or version is "dev", log a warning and skip wrapper generation. The existing scaffold delivery (via reusable workflow workspace) continues to work as-is. Wrapper generation is an enhancement for versioned releases only.
Changes to internal/cli/admin.go
buildLayerStack() (line 1860):
- Add
HarnessWrappersLayerto the layer stack, afterWorkflowsLayerand beforeSecretsLayer - Pass
commitSHAderived from the CLI version:gocommitSHA := resolveCommitSHA(version) - Add
resolveCommitSHA(version string) string:- If version matches a semver tag pattern (e.g.,
v1.2.3), extract the commit SHA from build metadata (a new build-time variablevar commitSHA = ""set by GoReleaser's-ldflags) - If version is
"dev", return""(triggers fallback in HarnessWrappersLayer)
- If version matches a semver tag pattern (e.g.,
runInstall() (line 1480):
- No changes to the
config.yamlwriting path -- theagents:block continues to be written viaconfig.NewOrgConfig(). This is the dual-write: bothconfig.yamland harness wrappers contain role/slug.
Changes to internal/cli/root.go
- Add
var commitSHA = "dev"alongside existingvar version = "dev", set via GoReleaser-ldflags - Add
CommitSHA() stringaccessor
Changes to internal/config/config.go
OrgConfig -- AllowedRemoteResources:
NewOrgConfig()should includehttps://raw.githubusercontent.com/fullsend-ai/fullsend/inAllowedRemoteResourcesby default, so generated base URLs pass the allowlist check without manual configuration. Only add this prefix if the org is using the default fullsend-ai scaffold (which it always is today).
Harness name to role mapping
The scaffold uses harness filenames that don't always match roles:
| Harness file | role: value | Notes |
|---|---|---|
triage.yaml | triage | Direct match |
code.yaml | coder | Name differs from role |
review.yaml | review | Direct match |
fix.yaml | coder | Reuses coder app/slug |
retro.yaml | retro | Direct match |
prioritize.yaml | prioritize | Direct match |
The HarnessWrappersLayer maintains this mapping. A helper function harnessNameForRole(role string) string handles the coder -> code case. A separate harnessesForRole(role string) []string returns ["code", "fix"] for role "coder" since both harnesses use the coder app.
Test plan
Create internal/layers/harnesswrappers_test.go:
- Generates wrapper for each role -> valid YAML with
base:,role:,slug: - Coder role generates wrappers for both
code.yamlandfix.yaml fullsendrole (org app) -> skipped, no harness generated- Dev build (
commitSHA="") -> no wrappers generated, warning logged - Existing non-managed harness -> skipped with message
- Existing managed harness -> overwritten
- Generated wrapper loads successfully via
harness.LoadRaw()and has expected Role/Slug - Generated wrapper YAML is valid and parseable
Modify internal/cli/admin_test.go:
- Verify
HarnessWrappersLayeris in the layer stack - Verify
commitSHAis passed through from CLI version metadata
After merge: fullsend install generates thin wrapper harnesses in the .fullsend config repo alongside the existing config.yaml agents block. Both locations contain role/slug. The wrappers reference upstream scaffold harnesses by URL with integrity hashes. Dev builds skip wrapper generation.
Depends on: PR 3 (base URL generation)
PR 5: fullsend lock --all -- lock all harnesses in a directory
Scope: Today fullsend lock takes exactly one agent name. Generated wrappers all have base: URLs that need locking. Add --all flag to iterate all harness files and lock them in a single pass.
Modify internal/cli/lock.go:
- Change
cobra.ExactArgs(1)tocobra.RangeArgs(0, 1)-- accept zero or one positional argument - Add
--allbool flag - Validation:
--alland a positional argument are mutually exclusive -> error- Neither
--allnor a positional argument -> error with usage hint
- When
--allis set:- Glob
filepath.Join(absFullsendDir, "harness", "*.yaml")and*.ymlto get all harness files. Usesfilepath.Globdirectly rather thanDiscoverAgentsto avoid coupling lock to the discover API -- lock only needs filenames, not role/slug. - For each harness file found, run the existing lock logic (load, resolve, hash, record in lockfile). If any individual file fails, previously resolved harnesses are saved to the lock file (partial-progress semantics) so they don't need to be re-resolved on retry. The error message includes the failing harness name.
- Write the combined lock file once at the end with all harness entries.
- Report summary:
Locked N harnesses: triage, code, review, fix, retro, prioritize
- Glob
Modify internal/lock/lock.go:
- No schema changes needed. The lock file already supports multiple harness entries via
SetHarness(name, lock). - Ensure
Save()writes all harness entries atomically.
Create internal/cli/lock_all_test.go:
--allwith positional arg -> error--allwith no harness files -> warning, empty lock file--allwith multiple harnesses -> all locked, lock file contains entries for each--allwith one harness having URL base, others local-only -> only URL-bearing harnesses get lock entries (or all get entries with empty deps -- follow existing convention)--allwith one harness failing to parse -> error with harness name in message, partial progress saved for already-resolved harnesses
After merge: fullsend lock --all locks every harness in the directory. Combined with PR 4's wrapper generation, running fullsend lock --all after install pins all base URLs in lock.yaml.
PR 6: Integration tests and end-to-end verification
Scope: End-to-end tests that verify the full Phase 2 flow: install generates wrappers, wrappers load and resolve correctly through the pipeline, lock pins base URLs, and the forge-restructured templates produce correct merged output.
Create internal/harness/phase2_integration_test.go:
Wrapper -> LoadWithBase -> correct merge:
- Create a temp dir with a thin wrapper YAML (
base:pointing to a local scaffold harness,role: triage,slug: test-triage) - Call
LoadWithBase()withForgePlatform: "github" - Verify the merged harness has:
Role == "triage",Slug == "test-triage"(from wrapper, overriding base)Agent,Model,Image,Policyinherited from basePreScript,PostScriptpopulated (fromforge.github:after merge)RunnerEnvcontains both top-level keys (e.g.,FULLSEND_OUTPUT_SCHEMA) and GitHub keys (e.g.,GH_TOKEN) after forge resolutionSkillscontains both base skills and forge skills (concatenated)Forgeis nil (consumed by ResolveForge)Baseis empty (consumed by LoadWithBase)
- Create a temp dir with a thin wrapper YAML (
All scaffold templates through forge.github resolution:
- For each harness in
scaffold.HarnessNames():- Load via
LoadWithOptswithForgePlatform: "github" - Verify
PreScript != ""andPostScript != ""(they come fromforge.github) - Verify
RunnerEnvis non-empty and contains expected keys - Verify
Forgeis nil (consumed) - This catches any template where fields were incorrectly split between top-level and forge
- Load via
- For each harness in
Backward compat: templates without forge flag:
- Load each scaffold template via
Load()(no forge platform) - Verify it loads without error
- Verify
PreScriptandPostScriptare empty (they live inforge.github:, not top-level) - Verify
Forgemap is present and has"github"key - This confirms existing
Load()callers are not broken
- Load each scaffold template via
DiscoverAgents on scaffold directory:
- Extract scaffold to temp dir, run
DiscoverAgents(harnessDir) - Verify all 6 harnesses are discovered with correct role/slug pairs
- Verify sorting by role
- Extract scaffold to temp dir, run
Base URL integrity:
- For each harness, compute
HarnessContentHash(name) - Load the embedded file directly from
embed.FS, computesha256.Sum256, compare - Verify
HarnessBaseURLWithHashproduces a URL whose hash fragment matches
- For each harness, compute
Update internal/scaffold/scaffold_test.go:
TestHarnessesLoadAndValidate: Add a parallel test path that loads each template withLoadWithOpts(path, LoadOpts{ForgePlatform: "github"})and verifies the merged state. Keep the existingLoad()path for backward compat verification.
After merge: Full Phase 2 verification. All integration tests pass.
Verification
After all PRs merge, verify Phase 2 end-to-end:
make go-test-- all new and existing tests passmake go-vet-- no issuesmake lint-- passes- Scaffold templates: Each template in
internal/scaffold/fullsend-repo/harness/hasforge.github:block withpre_script,post_script, and GitHub-specificrunner_envkeys. Platform-neutralrunner_envkeys remain at top level. - Forge resolution:
LoadWithOpts(path, {ForgePlatform: "github"})on each scaffold template produces the same effective config as the pre-Phase-2 templates (samePreScript,PostScript,RunnerEnvkey set). Verify with a comparison test. - DiscoverAgents:
DiscoverAgents(scaffoldHarnessDir)returns 6 agents with correct role/slug pairs:coder/fullsend-ai-coder(code.yaml),coder/fullsend-ai-coder(fix.yaml),prioritize/fullsend-ai-prioritize,retro/fullsend-ai-retro,review/fullsend-ai-review,triage/fullsend-ai-triage. - Base URLs:
HarnessBaseURLWithHash("triage", "<sha>")returns a well-formed URL with#sha256=...that matches the embedded file's hash. - Wrapper generation: Simulated install produces wrapper YAML files that parse correctly via
LoadRaw()and contain expectedbase:,role:,slug:fields. - Wrapper loading: Wrapper YAML loaded via
LoadWithBase()withForgePlatform: "github"produces a fully-populated harness with all fields from the base scaffold resolved. - Lock --all:
fullsend lock --allin a directory with wrapper harnesses records all base URL dependencies inlock.yaml. - Dual write: After install, both
config.yaml'sagents:block and the harness wrapper files contain the same role/slug values for each agent. - Backward compat: Existing harnesses without
forge:blocks orbase:references load identically to pre-Phase-2 behavior viaLoad()andLoadWithOpts().
