diff --git a/integrations/isolation/acq-kits/README.md b/integrations/isolation/acq-kits/README.md index b37b417a..36ecaf3a 100644 --- a/integrations/isolation/acq-kits/README.md +++ b/integrations/isolation/acq-kits/README.md @@ -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 diff --git a/integrations/isolation/acq-kits/validate-kits.py b/integrations/isolation/acq-kits/validate-kits.py index 22e6d43f..fab5fdfb 100644 --- a/integrations/isolation/acq-kits/validate-kits.py +++ b/integrations/isolation/acq-kits/validate-kits.py @@ -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//` 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: @@ -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 @@ -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//) 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) @@ -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(): @@ -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 diff --git a/integrations/isolation/docs/decisions/0005-kit-templates.md b/integrations/isolation/docs/decisions/0005-kit-templates.md new file mode 100644 index 00000000..54c33801 --- /dev/null +++ b/integrations/isolation/docs/decisions/0005-kit-templates.md @@ -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//` 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//`, 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//` 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. diff --git a/scripts/tests/test_validate_kits_examples.py b/scripts/tests/test_validate_kits_examples.py new file mode 100644 index 00000000..355b23c1 --- /dev/null +++ b/scripts/tests/test_validate_kits_examples.py @@ -0,0 +1,176 @@ +"""Tests for the `acq-kits/examples/` templates container. + +`integrations/isolation/acq-kits/examples//` holds copy-and-rename kit +TEMPLATES (see isolation ADR 0005). A template is schema-validated exactly like +a kit (spec, files[].source resolution, env names, README) but it is NOT a kit +acq applies, so it is exempt from the `kits.yaml` registry cross-check — and it +must stay out of the registry. These tests lock in that contract end to end +through `main()`, which is what CI and pre-commit run. +""" + +from __future__ import annotations + +import importlib.util +import shutil +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SCHEMA_PATH = ROOT / "schemas" / "kit-hybrid-v1.schema.json" +VALIDATOR_PATH = ROOT / "integrations" / "isolation" / "acq-kits" / "validate-kits.py" + +MINIMAL_SPEC = "schemaVersion: hybrid/v1\nkind: mixin\nname: {name}\ndisplayName: T\ndescription: d\n" + + +def _load_main(): + spec = importlib.util.spec_from_file_location("validate_kits", VALIDATOR_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.main + + +def _write_kit(kit_dir: Path, spec_yaml: str) -> Path: + kit_dir.mkdir(parents=True, exist_ok=True) + (kit_dir / "README.md").write_text("# t\n") + (kit_dir / "spec.yaml").write_text(spec_yaml) + return kit_dir + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + """A minimal repo root: the real schema, one registered kit, one template.""" + (tmp_path / "schemas").mkdir() + shutil.copy(SCHEMA_PATH, tmp_path / "schemas" / "kit-hybrid-v1.schema.json") + kits = tmp_path / "integrations" / "isolation" / "acq-kits" + _write_kit(kits / "kit-a", MINIMAL_SPEC.format(name="kit-a")) + _write_kit(kits / "examples" / "tpl-a", MINIMAL_SPEC.format(name="tpl-a")) + (kits / "kits.yaml").write_text("schemaVersion: acq-kits-registry/v1\nkits:\n kit-a:\n backends: [sbx]\n") + return tmp_path + + +def _kits_dir(repo: Path) -> Path: + return repo / "integrations" / "isolation" / "acq-kits" + + +class TestExamplesContainer: + def test_examples_dir_is_a_container_not_a_kit(self, repo, capsys): + # Before the exemption, `examples/` itself was walked as a kit and + # failed on its missing spec.yaml. + assert _load_main()(["--root", str(repo)]) == 0 + err = capsys.readouterr().err + assert "examples: missing spec.yaml" not in err + + def test_template_is_exempt_from_registry_cross_check(self, repo, capsys): + # tpl-a is not in kits.yaml and that is correct — no "missing registry + # entry" error. + assert _load_main()(["--root", str(repo)]) == 0 + assert "missing registry entry for kit 'tpl-a'" not in capsys.readouterr().err + + def test_template_is_reported_as_validated(self, repo, capsys): + assert _load_main()(["--root", str(repo)]) == 0 + out = capsys.readouterr().out + assert "OK examples/tpl-a" in out + + def test_template_listed_in_registry_is_an_error(self, repo, capsys): + # Templates are not applied by acq; listing one in the registry is a + # contradiction the validator must reject. + (_kits_dir(repo) / "kits.yaml").write_text( + "schemaVersion: acq-kits-registry/v1\nkits:\n kit-a:\n backends: [sbx]\n tpl-a:\n backends: [sbx]\n" + ) + assert _load_main()(["--root", str(repo)]) == 1 + assert "registry lists unknown kit 'tpl-a'" in capsys.readouterr().err + + def test_template_schema_error_fails_the_run(self, repo, capsys): + _write_kit(_kits_dir(repo) / "examples" / "tpl-bad", "schemaVersion: hybrid/v1\nkind: mixin\nname: tpl-bad\n") + assert _load_main()(["--root", str(repo)]) == 1 + assert "examples/tpl-bad: schema:" in capsys.readouterr().err + + def test_template_missing_source_fails_the_run(self, repo, capsys): + _write_kit( + _kits_dir(repo) / "examples" / "tpl-src", + MINIMAL_SPEC.format(name="tpl-src") + + 'files:\n - path: /home/agent/x\n mode: "0644"\n source: files/home/x\n', + ) + assert _load_main()(["--root", str(repo)]) == 1 + assert "examples/tpl-src: files[].source not found: files/home/x" in capsys.readouterr().err + + def test_template_without_readme_fails_the_run(self, repo, capsys): + d = _kits_dir(repo) / "examples" / "tpl-noreadme" + d.mkdir(parents=True) + (d / "spec.yaml").write_text(MINIMAL_SPEC.format(name="tpl-noreadme")) + assert _load_main()(["--root", str(repo)]) == 1 + assert "examples/tpl-noreadme: missing README.md" in capsys.readouterr().err + + def test_stray_file_under_examples_is_ignored(self, repo): + # A README.md describing the container is not a template. + (_kits_dir(repo) / "examples" / "README.md").write_text("# templates\n") + assert _load_main()(["--root", str(repo)]) == 0 + + @pytest.mark.parametrize("name", ["__pycache__", ".pytest_cache", ".ruff_cache", ".mypy_cache"]) + def test_tool_droppings_under_examples_are_ignored(self, repo, capsys, name): + (_kits_dir(repo) / "examples" / name).mkdir() + assert _load_main()(["--root", str(repo)]) == 0 + assert name not in capsys.readouterr().err + + @pytest.mark.parametrize("parent", ["", "examples"]) + def test_dot_named_dirs_are_still_validated(self, repo, capsys, parent): + # acq can fetch any directory through a `dir=` ref, so a dot-named kit + # or template must not be skipped as if it were a tool dropping. + (_kits_dir(repo) / parent / ".hidden").mkdir() + assert _load_main()(["--root", str(repo)]) == 1 + prefix = f"{parent}/" if parent else "" + assert f"{prefix}.hidden: missing spec.yaml" in capsys.readouterr().err + + def test_underscore_prefixed_dirs_are_still_validated(self, repo, capsys): + # Only tool droppings are skipped; a `_wip` kit or template is not. + (_kits_dir(repo) / "_wip-kit").mkdir() + (_kits_dir(repo) / "examples" / "_wip-tpl").mkdir() + assert _load_main()(["--root", str(repo)]) == 1 + err = capsys.readouterr().err + assert "_wip-kit: missing spec.yaml" in err + assert "examples/_wip-tpl: missing spec.yaml" in err + + def test_templates_are_validated_when_no_kit_dirs_exist(self, repo, capsys): + # The "no kits found" short-circuit must not skip a templates-only tree. + shutil.rmtree(_kits_dir(repo) / "kit-a") + (_kits_dir(repo) / "kits.yaml").write_text("schemaVersion: acq-kits-registry/v1\nkits: {}\n") + _write_kit(_kits_dir(repo) / "examples" / "tpl-bad", "schemaVersion: hybrid/v1\nkind: mixin\nname: tpl-bad\n") + assert _load_main()(["--root", str(repo)]) == 1 + captured = capsys.readouterr() + assert "OK examples/tpl-a" in captured.out + assert "examples/tpl-bad: schema:" in captured.err + + def test_absent_examples_dir_is_fine(self, repo): + shutil.rmtree(_kits_dir(repo) / "examples") + assert _load_main()(["--root", str(repo)]) == 0 + + +class TestTreeLevelChecks: + def test_duplicate_name_across_kits_fails(self, repo, capsys): + # A copied kit directory that kept the original `name:`. + _write_kit(_kits_dir(repo) / "kit-a-copy", MINIMAL_SPEC.format(name="kit-a")) + assert _load_main()(["--root", str(repo)]) == 1 + assert "duplicate kit name 'kit-a' declared by: kit-a, kit-a-copy" in capsys.readouterr().err + + def test_duplicate_name_across_templates_fails(self, repo, capsys): + _write_kit(_kits_dir(repo) / "examples" / "tpl-b", MINIMAL_SPEC.format(name="tpl-a")) + assert _load_main()(["--root", str(repo)]) == 1 + assert "duplicate kit name 'tpl-a' declared by: examples/tpl-a, examples/tpl-b" in capsys.readouterr().err + + def test_template_reusing_a_kit_name_fails(self, repo, capsys): + _write_kit(_kits_dir(repo) / "examples" / "tpl-b", MINIMAL_SPEC.format(name="kit-a")) + assert _load_main()(["--root", str(repo)]) == 1 + assert "duplicate kit name 'kit-a' declared by: kit-a, examples/tpl-b" in capsys.readouterr().err + + def test_empty_tree_is_not_a_pass(self, repo, capsys): + for d in _kits_dir(repo).iterdir(): + if d.is_dir(): + shutil.rmtree(d) + assert _load_main()(["--root", str(repo)]) == 1 + assert "nothing was validated" in capsys.readouterr().err + + def test_missing_kits_dir_is_not_a_pass(self, repo, capsys): + shutil.rmtree(_kits_dir(repo)) + assert _load_main()(["--root", str(repo)]) == 1 + assert "nothing was validated" in capsys.readouterr().err