Skip to content

feat: partial override / extends for specd.local.yaml and named config variants #16

@lsmonki

Description

@lsmonki

Problem

`specd.local.yaml` today is a full config replacement — `specd.yaml` is not read at all when the local file is present. This makes any local customisation expensive: you must duplicate the entire config just to change one field.

This is a general problem, independent of any specific feature (e.g. worktree support). Teams that want to override a single workspace path, tweak a storage directory, or adjust a hook for local development have no option other than maintaining a full copy.

Proposed cascade model

Activation by presence

There is no explicit activation mechanism — if a file matching the pattern exists on disk, it is applied. No flags, no env vars, no state files.

Default cascade (no explicit --config)

specd discovers and merges all present files in this fixed order:

specd.yaml
  → specd.*.yaml            (all present named variants, in alphabetical order)
    → specd.local.yaml      (local override)
      → specd.local.*.yaml  (all present local named variants, in alphabetical order)

Each layer merges on top of the previous. Having specd.local.prueba.yaml on disk is enough for it to be applied — no opt-in required.

Standalone local config

If specd.local.yaml does not opt in to the cascade (no extends or equivalent), it is treated as a complete, self-contained base. The cascade then continues only from that point:

specd.local.yaml
  → specd.local.*.yaml    (all present local named variants, in alphabetical order)

This preserves the current behaviour for projects using specd.local.yaml as a full replacement.

Explicit file selection (--config)

When a specific file is passed, only that file — and anything in its own extends chain — is used. Discovery and the presence-based cascade are skipped entirely.

# only specd.local.prueba.yaml and whatever it extends
specd --config specd.local.prueba.yaml <command>

Multiple explicit files

Allow passing multiple files whose layers are merged in declaration order, for scripting and CI:

specd --config specd.yaml --config specd.local.yaml <command>

Merge semantics

  • Scalar fields — later layers override earlier ones.
  • Objects (workspaces, storage sections) — deep merge. A later layer overrides individual keys within an object without replacing the whole object.
  • New keys — if a later layer introduces a key not present in any previous layer (e.g. a new workspace, a new plugin), it is additive.
  • Arrays (workflow, context, plugins) — additive by default; items declared in a later layer are appended to the base.

Removing array items: remove with YAML AST selectors

To remove items inherited from a previous layer, use the remove key. Each entry uses the same YAML AST selector model already defined in specs/core/delta-format/spec.md and used in deltaValidationstype, where, matches, contentMatches, etc. — applied against the YAML AST of the config arrays. No new selector vocabulary is introduced.

# specd.local.yaml
remove:
  context:
    - type: sequence-item
      where:
        file: specd-bootstrap.md

  hooks:
    - type: sequence-item
      where:
        step: archiving
        id: notify-team

  plugins:
    - type: sequence-item
      where:
        name: '@specd/plugin-copilot'

remove is applied before the current layer's additions: first delete matching items from the accumulated base, then append the new items declared in this layer.

Optional id field on array items

All items that can appear in arrays support an optional id field: context entries (both file and instruction), workflow hook entries (both run and instruction), and plugins.

The id is only necessary when the item may need to be referenced or removed by a downstream layer — it documents extensibility intent. Items without id can still be targeted via other where fields (e.g. file, name, run).

# specd.yaml
context:
  - id: bootstrap          # removable by id in downstream layers
    file: specd-bootstrap.md
  - instruction: 'Always prefer editing existing files over creating new ones.'
    # no id — identifiable by content via contentMatches if needed

workflow:
  - step: archiving
    hooks:
      post:
        - id: notify-team  # removable by id
          run: 'pnpm run notify-team'

id values must be unique within their array.

Gitignore convention

Files committed to the repo:

  • specd.yaml
  • specd.*.yaml

Files always gitignored (added by specd init):

  • specd.local.yaml
  • specd.local.*.yaml

Notes

  • The current constraint — specd.local.yaml without cascade opt-in is a complete, standalone config — is preserved. No breaking change for existing setups.
  • The remove selector engine reuses the existing YAML AST selector infrastructure — no new parsing or matching logic needed.
  • This is independent of worktree support (feat: worktree (git) support on storage adapters (fs) #15), though it simplifies the ergonomics of worktree-specific local overrides.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions