Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions integrations/isolation/acq-kits/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ a `TROUBLESHOOTING.md`, and `docs/decisions/` records.
The [`kits.yaml`](kits.yaml) registry is the human-readable parity summary
(kit → supported backends + parity prose).

## Templates

Copy-and-rename starting points under `examples/`. They are **not kits `acq`
applies**: `hybrid/v1` has no parameters, so a template carries one live,
harmless value per extension point for you to replace, plus a `scripts/verify`
that asserts how the kit composes with the global layer. CI validates them like
kits (schema, `files[]` sources, env names, README) but they are exempt from —
and must stay out of — the `kits.yaml` registry. See
[`../docs/decisions/0005-kit-templates.md`](../docs/decisions/0005-kit-templates.md).

## What is a hybrid/v1 kit?

A kit is a directory with a `spec.yaml` (`schemaVersion: "hybrid/v1"`). A
Expand Down
81 changes: 76 additions & 5 deletions integrations/isolation/acq-kits/validate-kits.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@
rather than only failing the schema's additionalProperties rule)
- a README.md exists (parity note lives there)

`acq-kits/examples/` is a CONTAINER of copy-and-rename kit templates, not a
kit (see integrations/isolation/docs/decisions/0005-kit-templates.md). Each
`examples/<name>/` gets the same per-kit checks above, but templates are not
applied by acq, so they are exempt from — and must stay out of — the kits.yaml
registry cross-check.

Across the whole tree, no two kits or templates may declare the same `name:`,
and a tree with no kits or templates at all fails rather than passing with
nothing validated.

It also emits WARN-level advisories (non-fatal by default) for likely re-home
regressions the schema can't catch:

Expand Down Expand Up @@ -41,6 +51,22 @@

KNOWN_BACKENDS = {"sbx", "msb", "ppp"}

# Subdirectory of acq-kits/ that holds copy-and-rename TEMPLATES rather than
# kits. Its children are validated like kits but never registered in kits.yaml
# (isolation ADR 0005).
TEMPLATES_DIR = "examples"


# Directories tools leave behind next to this file or a template. Named
# explicitly: any other directory, dot-named included, is still fetchable by
# acq through a `dir=` ref, so it must be validated like a kit.
TOOL_DROPPINGS = frozenset({"__pycache__", ".pytest_cache", ".ruff_cache", ".mypy_cache"})


def _is_tool_dropping(name: str) -> bool:
return name in TOOL_DROPPINGS


# Env var NAME must be a POSIX-portable identifier. Env values reach the guest
# environment and possibly a shell; the schema enforces this via patternProperties,
# but we ALSO check it here so a bad name is reported with a clear message at the
Expand Down Expand Up @@ -316,14 +342,36 @@ def main(argv: list[str] | None = None) -> int:
schema = json.loads(schema_path.read_text())
jsonschema.Draft202012Validator.check_schema(schema)

kit_dirs = sorted(d for d in kits_dir.iterdir() if d.is_dir()) if kits_dir.exists() else []
if not kit_dirs:
print(f"No kits found under {kits_dir}")
return 0
# Skip the templates container (handled below) and tool droppings such as
# __pycache__ (written next to this file when the test suite imports it).
kit_dirs = (
sorted(
d for d in kits_dir.iterdir() if d.is_dir() and d.name != TEMPLATES_DIR and not _is_tool_dropping(d.name)
)
if kits_dir.exists()
else []
)
# Templates (examples/<name>/) are discovered here so a tree holding only
# templates is still validated below rather than short-circuited as empty.
templates_dir = kits_dir / TEMPLATES_DIR
template_dirs = (
sorted(d for d in templates_dir.iterdir() if d.is_dir() and not _is_tool_dropping(d.name))
if templates_dir.is_dir()
else []
)
if not kit_dirs and not template_dirs:
# Nothing validated is not a pass: a moved or emptied tree must fail
# the gate, just as a missing kits.yaml does.
print(f"ERROR: no kits or templates found under {kits_dir}; nothing was validated", file=sys.stderr)
return 1

all_errors: list[str] = []
all_warnings: list[str] = []
kit_names: list[str] = []
# name -> every directory (kit or template) that declares it. Templates are
# copy-and-renamed, so a renamed directory with an unchanged `name:` is the
# likeliest slip; the registry cross-check alone cannot see it.
name_owners: dict[str, list[str]] = {}
for kit_dir in kit_dirs:
errs, warns = validate_kit(kit_dir, schema)
all_warnings.extend(warns)
Expand All @@ -339,9 +387,31 @@ def main(argv: list[str] | None = None) -> int:
spec = yaml.safe_load((kit_dir / "spec.yaml").read_text())
if isinstance(spec, dict) and spec.get("name"):
kit_names.append(spec["name"])
name_owners.setdefault(spec["name"], []).append(kit_dir.name)
except (yaml.YAMLError, OSError):
pass

# Templates: the same per-kit checks, reported under an `examples/` prefix,
# but never part of the registry cross-check below — they are not kits acq
# applies.
for tpl_dir in template_dirs:
errs, warns = validate_kit(tpl_dir, schema)
all_warnings.extend(f"{TEMPLATES_DIR}/{w}" for w in warns)
if errs:
all_errors.extend(f"{TEMPLATES_DIR}/{e}" for e in errs)
else:
print(f" OK {TEMPLATES_DIR}/{tpl_dir.name}")
try:
spec = yaml.safe_load((tpl_dir / "spec.yaml").read_text())
if isinstance(spec, dict) and spec.get("name"):
name_owners.setdefault(spec["name"], []).append(f"{TEMPLATES_DIR}/{tpl_dir.name}")
except (yaml.YAMLError, OSError):
pass

for name, owners in sorted(name_owners.items()):
if len(owners) > 1:
all_errors.append(f"duplicate kit name {name!r} declared by: {', '.join(owners)}")

# Registry cross-check: kits.yaml must list exactly the kits present.
registry_path = kits_dir / "kits.yaml"
if registry_path.exists():
Expand Down Expand Up @@ -378,7 +448,8 @@ def main(argv: list[str] | None = None) -> int:
return 1

suffix = f" ({len(all_warnings)} warning(s))" if all_warnings else ""
print(f"\nAll {len(kit_dirs)} acq-kits valid.{suffix}")
tpl_suffix = f" + {len(template_dirs)} template(s)" if template_dirs else ""
print(f"\nAll {len(kit_dirs)} acq-kits{tpl_suffix} valid.{suffix}")
return 0


Expand Down
144 changes: 144 additions & 0 deletions integrations/isolation/docs/decisions/0005-kit-templates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
---
title: "Ship scope-layer kits as copy-and-rename templates under acq-kits/examples/"
status: "proposed"
date: "2026-09-25"
decision_makers: ["Basilio Bogado"]
category: "repository-structure"
impact_level: "low"
---

# ADR 0005 (isolation) — Ship scope-layer kits as copy-and-rename templates under `acq-kits/examples/`

> Area-scoped ADR for `integrations/isolation/`. Extends ADR 0001 (the neutral
> `hybrid/v1` acq-kits area) with a home for kit *templates*, and records why the
> team and personal scope layers are shipped as templates rather than as kits.

## Context and Problem Statement

Every team adopting `acq` builds the same two things: a **team scope layer**
between the global kits `acq` applies for everyone and each repo's own agent
config, and a **personal layer** each teammate stacks on top. `ACQ_EXTRA_KITS`
already supports both, but until now nothing in this repo said so, stated how
the fields of stacked kits compose, said what belongs in each layer, or gave a
validated starting point. One team has run such a kit in production since June
2026 and re-learned a list of failure modes (whole-file overlays clobbering
shared rc files, binary payloads, create-time installs through the proxy,
secret rotation changing the placeholder, `environment` last-wins shadowing)
that the next team would hit again.

The pattern is documented in `../scope-layers.md`. This record covers the two
structural decisions that document depends on.

## Decision Drivers

- The `hybrid/v1` vocabulary has **no parameters**. A kit is applied verbatim;
it cannot carry a placeholder team value.
- A starting point is only useful if CI keeps it valid as the schema evolves.
- Templates must not be mistaken for deployable kits, by humans or by the
registry cross-check.
- Zero or minimal validator change; no new tooling.

## Considered Options

### A parameterized generic kit vs. templates

1. **Copy-and-rename templates** with one live, harmless value per extension
point. (chosen)
2. A **generic team kit** in the registry that teams apply as-is and extend
with a second kit. Rejected: with no parameters, its extension points would
be empty, so applying it does nothing, and a team still has to author the
real kit. The value is the pattern plus a skeleton, not a deployable.
3. **Doc only**, linking the reference implementation. Rejected: the reference
implementation lives in a team repo this repo must not link (internal), and
nothing would keep a skeleton schema-valid over time.

### Placement

- **(A)** `acq-kits/<name>/` with a `kits.yaml` entry whose parity note says
TEMPLATE, not applied by acq. No validator change; `prime-agent` already sits
in the registry as a skeleton, so there is precedent.
- **(B)** `acq-kits/examples/<name>/`, schema- and `files[]`-validated by
`validate-kits.py` but exempt from the registry cross-check. (chosen)

## Decision

Adopt **templates (option 1)** and **placement (B)**.

- **Templates, not kits.** Each template is a complete, valid `hybrid/v1` mixin
where every extension point (`caps.network.allow`, `files[]`, `commands[]`,
`environment`) carries **one live, harmless value** the schema actually
checks, with a comment saying what to replace. Commented-out YAML is never
schema-checked, so a template made of comments would rot silently. Names are
real kebab-case names (`team-kit`); the README says to rename both the
directory and `name:`.
- **`scripts/verify` asserts the composition, not just liveness.** It creates
a throwaway sandbox through `acq` (which applies the pinned built-in bundle,
including `usai-provider`), stacks a generated lower fixture kit and then the
template via `ACQ_EXTRA_KITS`, and asserts the per-field rules the pattern
doc states: `environment` last-wins, `files[]` last-wins by path,
`commands[]` append in order, `caps.network.allow` union. It uses only `acq`
verbs, so the same script runs on every backend.
- **Placement (B).** `acq-kits/examples/` is a *container of templates*, not a
kit. `validate-kits.py` skips it in the kit walk, validates each
`examples/<name>/` with the same per-kit checks (schema, `files[].source`
resolution, env names, README presence), reports them under an `examples/`
prefix, and leaves them out of the `kits.yaml` cross-check. A template that
*is* listed in `kits.yaml` fails validation ("registry lists unknown kit"),
so the separation is enforced, not advisory. `acq-kits/README.md` gets a
separate **Templates** section rather than rows in the Available kits table.
- **Backend-neutral, acq-first prose.** Every command in the pattern doc and
the templates is an `acq` command, and the templates use no
`backend_shortcuts` or `backend_extras`. The backends still behave
differently (command user, `agentContext`, kit re-apply, unlisted files, SSH
egress, secret rotation), so the pattern doc keeps one canonical list of
those differences and the templates link to it.
- **The doc states current `acq` behavior; defects live upstream.** The
pattern doc carries no `acq` version numbers. Where a rule exists only to
work around an `acq` parser or adapter defect (trailing comments in
commands, `#` in environment values, flow-style argv, empty file drops), the
doc states the rule once, in a short "check against your acq" section, and
the defect itself is tracked in the quickstart repo. Defects fixed on `acq`'s
main branch are dropped from the doc rather than dated.
- **What stays out.** The reference team's egress list, its Nix/devenv
coupling, agent-specific config beyond a single OpenCode example of the
config-tier mechanism (`OPENCODE_CONFIG` pointing at a team file), any
workaround specific to its repos, and any internal URL. The reference
implementation is named, not linked.

## Consequences

**Positive**

- Other teams bootstrap a team layer from a validated skeleton instead of
re-learning the failure modes.
- The composition table is checked against `acq`'s code where `acq` composes
the kits (msb) and asserted live by `scripts/verify` on both backends, so
the doc and the behavior cannot drift apart unnoticed. Rows sbx composes
natively and `scripts/verify` does not exercise are marked unverified.
- The validator change is small and additive: existing kits and the registry
cross-check are unaffected; the `examples/` container is opt-in by presence.

**Negative / residual**

- A template's live values are real config. A team that copies without
replacing them gets a harmless but pointless egress entry, env var, file,
and startup command. The README and in-spec comments say what to replace.
- `scripts/verify` needs a sandbox-capable host and network; like every kit's
verify in this area, it cannot run in this repo's CI. The offline gate is
`validate-kits.py`.
- The composition table and the parser rules were checked against `acq`
v4.0.1 (quickstart commit `1679530`, 2026-10-06): by reading the adapters,
by running the kit parser's functions on a fixture spec, and by running the
team-kit `scripts/verify` on msb 0.7.7. acq releases before v4.0.0 still
have two older msb behaviors (kit commands seeing only their own kit's
environment, and published ports colliding across sandboxes). A change in
`acq`'s adapters needs the table and `scripts/verify` updated together.

## Links

- Pattern doc: `../scope-layers.md`.
- Template: `../../acq-kits/examples/team-kit/`.
- Validator: [`../../acq-kits/validate-kits.py`](../../acq-kits/validate-kits.py)
(`TEMPLATES_DIR`).
- ADR 0001 (isolation): the neutral `hybrid/v1` acq-kits area this extends.
- Proposal and placement discussion: GSA-TTS/agentic-coding-patterns#421.
Loading
Loading