ways
Concepts

Slots & bindings

How one way serves teams with different tools, and why the choice is declared instead of guessed.

Your development way says: when the work is ready, open a pull request and wait for review.

Your team is on GitHub. Your client's team is on GitLab. Same process, same phases, same review discipline — but gh pr create and glab mr create are different commands.

A way that hardcodes gh only serves half of you. So a way never names a tool. It says it needs a vcs-host, and each repo says which one it has.

One way, two toolchains

The way author writes the skill once, against the capability:

# in the way — no vendor anywhere
spec:
  skills:
    - path: skills/incu-way-development
      uses: [vcs-host, issue-tracker]

Each consuming repo fills those slots with what it actually uses. Your repo:

# ways.yaml
ways:
  incu/dev:
    bindings:
      vcs-host: github
      issue-tracker: jira

Your client's repo, same way, same pin:

# ways.yaml
ways:
  incu/dev:
    bindings:
      vcs-host: gitlab
      issue-tracker: linear

One way, installed from the same source at the same commit, driving two different toolchains. That is the whole point of a slot. Everything below is detail in service of it.

"But my git remote already says GitHub"

This is the first thing most people ask, and it's a fair question. Your environment does describe itself: the remote URL, the lockfiles, the CI config. Why write the binding down at all?

Two reasons.

The author needs the placeholder. Detection could tell your machine that you use GitHub. It can't help the person writing the way, who has never seen your repo and has to phrase every instruction without naming a vendor. The slot is what lets open a PR on the vcs-host exist as a sentence at all. Take the slot away and the author is back to writing one way per toolchain.

A committed choice beats a per-machine guess. Inference happens on one laptop and leaves no trace. bindings: { vcs-host: github } lives in ways.yaml, gets committed, and is the same fact for every teammate and for CI. That's what makes doctor able to say the environment is wrong rather than quietly adapting to whatever it finds.

In the common case this costs you one line, set once, when you first add the way. If the way ships a sensible default you don't even write that — you confirm it.

Binding, in practice

npx ways.sh bind                          # wizard over every unbound slot
npx ways.sh bind incu/dev vcs-host github # scripted, idempotent
npx ways.sh bind incu/dev                 # list slots and where they came from
npx ways.sh unbind incu/dev vcs-host      # back to the way's default, or unbound

Your selection lands in the repo's ways.yaml, which is committable:

ways:
  incu/dev:
    source: github:incu-tech/incuway
    bindings:
      vcs-host: github

Project bindings beat the way's defaults. A default is a pre-fill, so your binding always wins and unbind falls back to it. (Team and user-global tiers are reserved in the contract for later.)

Where slots come from

A way's slot set isn't a hand-maintained list. It's derived from what each skill declares it uses, then optionally enriched:

spec:
  skills:
    - path: skills/incu-way-development
      uses: [vcs-host]              # this skill needs a vcs-host, so that IS a slot
  needs:                            # optional: give the derived slot a human face
    - id: vcs-host
      displayName: Version-control host
      description: Hosts the repo and PRs, and is where this way opens and gates them.
      contract: ways.dev/vcs-host@1
      required: true
      options: [github, gitlab, bitbucket]
  defaults:
    bindings:
      vcs-host: github

A slot id that appears only in skills[].uses becomes a slot with sane defaults (contract: ways.dev/{id}@1, required: true, cardinality: single), so a simple way needs no needs section at all. A needs[] entry enriches that derived slot: a human name and description, suggested options for the wizard menu, a softened required, a default binding.

Because slots carry provenance, every surface that shows one — the bind wizard, doctor, ways list --components — leads with the human name and says why the slot exists (needed by: <skill>, or declared by the way), keeping id and contract as dimmed technical detail.

Required means required

required is the way author's contract, and the consumer can't demote it:

  • You can bind a required slot to any impl. Required constrains that it's filled, not with what.
  • The wizard lets you skip a required slot only after an explicit confirmation, and doctor keeps failing it (exit 1) until it's bound. There is no consumer-side flag to silence it.

Reference: contracts and catalogs

Everything up to here is what you need to use slots. This section is for way authors and for anyone registering in-house implementations.

A SlotContract defines what any vcs-host guarantees. A Binding profile says what satisfying that contract with github actually requires, which is how doctor knows to check for the gh CLI and a GH_TOKEN. A Binding's identity is the pair {contract, impl} rather than its filename, since one impl like snyk can satisfy several contracts.

A slot's contract reference (ways.dev/vcs-host@1) resolves through its namespace, which points at a catalog: a pinned, acquirable collection of SlotContract and Binding artifacts, shaped like Terraform's required_providers.

# in a way. The ways.dev namespace has a built-in default source, so a way that only
# uses ways.dev/* contracts can omit this block entirely.
spec:
  contracts:
    ways.dev: { source: github:incu-tech/ways-contracts, version: ^1.0.0 }
  needs:
    - { id: sast, contract: ways.dev/sast@1 }
# in a project's ways.yaml: register an in-house impl as a first-class option
# without forking any way.
contracts:
  acme.internal: { source: ../our-catalog }

Catalog layout is convention (contracts/, bindings/); resolution is by content, with contracts keyed by metadata.name@<major> and bindings by {contract, impl}.

Binding profiles resolve across three tiers

Pinned, approved, validated

  • Pinned and reproducible. Resolved catalogs land in ways.lock under a contracts section (source, ref/sha, digest), and ways install reproduces them offline.
  • Executable surface, so approved. A Binding contributes requires.cli[].check commands that doctor runs, which makes a catalog trusted content: ways add lists catalogs in its approval plan and re-asks when a digest changes. Read-only commands (doctor, bind, ways contracts) are cache-only and never fetch, so a catalog only enters through an explicit approval. WAYS_NO_CATALOGS=1 disables resolution entirely.
  • Real validation. doctor reports the operations a bound impl's coversOperations omits (advisory by default, a failure under --strict), and an unresolvable contract as unknown with the reason. conformance fails a contract reference that doesn't exist in its catalog and names the near-miss.
  • Inspect what resolved with ways contracts: the namespaces, the contracts each provides, and the impls available per contract with coverage and provenance.

A bound slot whose Binding profile can't be found reports unknown in doctor: recorded and honest, but unverifiable. See Doctor & trust.

On this page