Skip to content

Declare which harnesses sync targets #1111

Description

@williamthorsen

Problem

Project-domain sync resolves its target harnesses by probing for {projectRoot}/.claude and {projectRoot}/.rovodev, so a harness is targeted only when the repository already contains its directory. Nothing establishes that precondition: those directories hold generated output, and a repository has no reason to commit one. Claude is targeted incidentally wherever a repository commits .claude/ harness config; Rovo generally is not targeted at all. When the probe misses, every project-domain artifact for that harness — skills, subagents, and ambient rulebooks alike — goes undelivered, and the run reports zero targeted harnesses rather than naming the one it skipped.

Home-domain detection is sound, since ~/.claude and ~/.rovodev are created by each harness's own installer. But nothing there is declarable either, so a stale harness directory cannot be overridden.

Context

resolveHarnessIds(options.harness, baseDir) is the single resolution point, consumed by sync, install, uninstall, status, and configure-hooks. Passing --harness <id> bypasses detection and is the only workaround today.

Every declaration type already carries the {use, drop} shape (typeDeclarationSchema()); accumulateType merges across the scope chain and root: true resets it, so a harnesses block needs no new grammar.

resolveScopeChain currently walks exactly two tiers under a single base directory, so the project and home domains never cross.

init's scaffold comment still reads "inlined into PROJECT.md", stale since #1088 moved ambient content to the machine-local hosts. It sits in a file this change already edits.

Confirmed downstream: williamthorsen/operations declares an ambient rulebook and receives nothing, because it contains neither harness directory. sync resolves the rulebook and reports success having written no file.

Proposed solution

Add harnesses: {use, drop} to codeassembly.yaml, resolved on a machine-wide chain:

# ~/.agents/codeassembly.yaml — machine default
harnesses:
  use: [claude]

# <repo>/.agents/codeassembly.yaml — project requirement
harnesses:
  use: [rovo]

# <repo>/.agents/codeassembly.local.yaml — personal override
harnesses:
  drop: [rovo]

Resolution order: the --harness flag, then the declaration chain (~/.agents/codeassembly.yaml, its .local sibling, then the project pair), then a fallback of the harnesses installed on the machine — detected under the home directory, not in the repository.

Three tiers separate three distinct claims: the machine states which harnesses are installed, the project states which it requires, and codeassembly.local.yaml lets an individual developer override either. A committed project tier therefore never asserts what an individual runs.

A key resolves on the chain its meaning implies. harnesses is machine-scoped and therefore crosses tiers. Artifact keys are domain-scoped and must not: a home declaration carrying collections: use: [all] would otherwise deploy the entire catalog into every repository's harness dirs. The two-chain rule belongs in the schema documentation, not left implicit in one key's behavior.

root: true discards lower-precedence contributions from its own domain. For every artifact key, whose chain lies wholly within one domain, this describes current behavior exactly. It acquires force only for harnesses, where it stops a committed project file from discarding the machine's declaration. drop still reaches across the boundary, so codeassembly.local.yaml can override a home-declared harness.

An unknown harness id fails the run, naming the file and the offending entry. A declaration resolving to an empty set is honored — sync targets nothing and says so — and is distinct from no declaration at all, which falls back.

Every run reports its targeting; on the fallback path it also names the key that pins it:

Targeting claude, rovo (detected in ~).
Declare `harnesses.use` in .agents/codeassembly.yaml to pin this.

It does not prompt: the command runs unattended in postinstall and CI, and init has no interactive machinery either.

The declaration governs both sync domains. install, uninstall, status, and configure-hooks are home-domain and keep detection.

Two scopes, one word

#963 renamed the artifact-level frontmatter key to supported-harnesses:, so the two are distinguishable on sight. They compose, and the composition needs stating once:

Lives in Governs Says
harnesses: codeassembly.yaml a sync run target these harnesses
supported-harnesses: artifact frontmatter one artifact this artifact only deploys to these

Targeting selects the harness set; artifact narrowing filters within it. A project targeting [claude, rovo] with a skill declaring supported-harnesses: [rovo] deploys that skill to Rovo alone.

Also in scope

Fix init's stale scaffold comment, which names a destination that stopped receiving ambient content in #1088.

Rejected

  • Hosting the declaration in preferences.yaml. Its project → global cascade fits a machine fact, but the file is shared across tooling — commit and PR title formats, worktree conventions, artifact paths — so a CodeAssembly-only key there inverts the ownership boundary. Keys CodeAssembly owns live in the file CodeAssembly owns.
  • Hosting it only in codeassembly.local.yaml. Per-checkout and git-ignored, so a machine-wide policy would need recreating in every worktree — four, for williamthorsen/operations alone.

Acceptance criteria

Must have

  • codeassembly.yaml accepts harnesses: {use, drop}, merged along its chain. An unknown harness id fails the run, naming the file and the offending entry.
  • The harnesses block resolves across the home and project tiers; artifact keys continue to resolve on the project tier alone.
  • root: true discards only its own domain's contributions, so a project tier cannot discard a home-declared harness. drop still crosses the boundary.
  • sync targeting resolves in the order: --harness flag, declaration, harnesses installed on the machine.
  • A repository declaring no harnesses receives project-domain artifacts for every harness installed on the machine, whatever directories the repository itself contains.
  • A declaration resolving to an empty set targets no harnesses, distinct from an absent declaration, which falls back.
  • sync --global honors the declaration on the same terms.
  • Every sync run, dry-run included, names its targeted harnesses and how targeting resolved; the fallback path also names the key that pins them.
  • sync and init remain non-interactive.
  • init's scaffolds name the current ambient destination rather than PROJECT.md, and surface the harnesses key.
  • Documentation covers the key, the two-chain rule, the domain-scoped root rule, the resolution order, and how targeting composes with artifact-level supported-harnesses:, naming which commands honor the declaration.
  • New and modified behavior in this change is covered by tests.

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureAdded or improved external functionalityscope:agents

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions