From ec0b9317a722e944ea87b49413c64946b1a1a681 Mon Sep 17 00:00:00 2001 From: Dmitrij Zykovic Date: Sun, 11 Oct 2026 17:00:59 +0300 Subject: [PATCH 1/8] Recorded the decision on local and targeted source artifacts --- ...021-local-and-targeted-source-artifacts.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 decisions/0021-local-and-targeted-source-artifacts.md diff --git a/decisions/0021-local-and-targeted-source-artifacts.md b/decisions/0021-local-and-targeted-source-artifacts.md new file mode 100644 index 0000000..8486386 --- /dev/null +++ b/decisions/0021-local-and-targeted-source-artifacts.md @@ -0,0 +1,145 @@ +# 0021 — Local and targeted source artifacts + +Date: 2026-10-11 +Status: accepted + +## Context + +Every rule, agent and skill in a configured source renders to every enabled +adapter. Nothing in a source can say "only on my machine" or "only for this +tool". + +A machine-local preference has no place. The documented home is a hand-made, +gitignored root `CLAUDE.md`, which reaches one tool. A gitignored file placed in +a source directory leaks instead: `AGENTS.md`, committed by default (decision +0020), inlines an always-on rule's body and links every rule, agent and skill. +A teammate without the file renders `AGENTS.md` without it, and `sync --check` +in CI reports `out-of-date` for the same reason. + +Tool-specific guidance has no place either: advice that matters to one tool +reaches all of them, and an always-on rule costs context in every one. + +Two delivery paths are shared, not per tool. Codex, Cursor, Copilot, +Antigravity, Pi and OpenCode read `AGENTS.md` natively; Claude Code does not, +and receives every rule in `.claude/rules/`. Codex, Antigravity, Pi and OpenCode +read `.agents/skills/`. A filter can withhold an artifact from a shared path, +never from one of its readers. + +Sources are enumerated two ways. `AGENTS.md` uses `find`, which returns +dotfiles; every other adapter uses a shell glob, which skips them, so a `.x.md` +rule reaches `AGENTS.md` and nothing else. `AGENTS.md` also lists and inlines +every same-named rule from every source, while the copying adapters let the +later source win. + +## Decision + +1. **Local is a name.** A rule or agent file named `*.local.md` and a skill + directory named `*.local/` are local. That is the pattern `.gitignore` + holds, so Git and the engine cannot disagree about which file is local: + `init` writes `/rules/*.local.md`, + `/agents/*.local.md` and `/skills/*.local/` to the + managed block, beside `.intelligence/`. +2. **A name is the file name without a leading `.`, `.md` and a trailing + `.local`.** `.environment.local.md` and `environment.local.md` are the local + rule `environment`; `.local.md` is the local rule `local`; `.draft.md` is the + rule `draft`. A leading dot means nothing, so every adapter renders dotfile + sources, as `AGENTS.md` already did. A skill directory `deploy.local/` + renders as `deploy/`, so it still matches its own `name: deploy`. +3. **Names stay unique.** Across sources the later source overrides the + earlier, as today, and now in `AGENTS.md` too. Sync refuses two artifacts + with one name in one source directory (`x.md` beside `.x.md`), and a local + and a non-local artifact with one name in any sources: a local file that + overrides or yields to a committed one would change what committed output + holds. +4. **Two frontmatter fields select adapters.** `targets:` lists the adapters an + artifact is for; without it, every enabled adapter. `exclude_targets:` + removes adapters from that set. Both take built-in or project adapter ids and + the group `committed`, in the list syntax `paths:` accepts; skills carry them + in `SKILL.md`. An unknown id refuses the sync. A known adapter that is not + enabled is no target and no warning: enablement is the manifest's decision. + `targets: []` renders the artifact nowhere. Both fields are removed from + every rendered copy. +5. **`committed` is every output Git tracks**: an output path that no `ignore` + record of its adapter's contract covers. It is resolved per path, not per + adapter — OpenCode's agents are ignored while its commands are tracked — + and `AGENTS.md` always belongs to it. `targets.copilot.commit_output: true` + moves Copilot's four directories into it, so a sync that resolves + `committed` evaluates contracts with the manifest. Decision 0020 kept that + read off every sync; a sync whose sources have no local artifact and name no + `committed` still has none. +6. **A local artifact excludes `committed` implicitly.** Its own `targets` and + `exclude_targets` narrow the set further. A local artifact whose `targets` + names a committed output refuses the sync: the source exists in no other + clone and not in CI, which render that output without it. +7. **Shared paths are delivered whole.** An artifact whose set contains + `agents` goes into `AGENTS.md`, and so reaches every tool that reads it, + listed or not. An always-on rule whose set leaves `agents` out reaches a tool + in its set through that tool's own channel: + - Claude Code: `.claude/rules/`, as today; + - Cursor: an `.mdc` file with `alwaysApply: true`; + - Copilot: an `.instructions.md` file with `applyTo: "**"`; + - Antigravity: `trigger: always_on`, the same Windsurf-lineage key family as + its `trigger: glob`, not vendor-documented; + - Pi: its generated rules extension, which adds the rule to the system + prompt; + - Codex: `AGENTS.override.md` beside `AGENTS.md`. Codex reads it instead of + `AGENTS.md` in that directory, so it holds the `AGENTS.md` content plus the + rules only Codex receives; Codex's contract ignores it, and it exists only + while such rules do; + - OpenCode: none — its `instructions:` live in a committed `opencode.json`. + + A skill whose set contains any reader of `.agents/skills/` lands there. Sync + warns when a shared path delivers an artifact to an enabled adapter outside + its set, and when an adapter in its set can receive it through no channel — + for the adapters an artifact names explicitly and for a local artifact's + implicit set. An artifact with neither field nor a `.local` name keeps + today's routing and warnings. +8. **One enumeration.** `AGENTS.md`, every built-in adapter and the adapter + template read sources through one function that applies the name rule, + overrides, locality and the target set; the per-adapter globs go. +9. The work lands in two changes: local artifacts with `committed` and the + single enumeration (items 1–3, 5, 6, 8), then the fields, the new + always-on channels and the shared-path warnings (items 4 and 7). + +## Consequences + +- A personal rule, agent or skill is a `*.local.md` file or `*.local/` + directory in the content directory. It reaches every tool whose output is + ignored and never changes a committed file. Tests hold that invariant: + `AGENTS.md` is byte-identical with and without local artifacts, and + `sync --check` passes in a clone that has none. +- With one enumeration, dotfile sources render in every adapter, and + `AGENTS.md` applies source overrides. A project with a dotfile source sees + new output in tool channels. +- Same-named artifacts in one directory, and local/non-local pairs, now refuse + a sync that rendered before. +- Cursor, Copilot, Antigravity and Pi gain an always-on channel and Codex an + override file; a project that never targets or localizes a rule never gets + one. OpenCode can receive neither a local nor a tool-targeted always-on rule. +- A project adapter that enumerates sources itself bypasses locality and + targets, so a committed output it writes can leak a local artifact. The + template and the adapter guide point it at the shared function. + +## Rejected + +- **A `local: true` field.** It makes locality and the ignore pattern two facts + that can disagree: a flagged file that is committed is shared anyway, and an + ignored file without the flag leaks into `AGENTS.md`. +- **Detecting local files with `git check-ignore`.** It costs a process per + sync, has no answer outside a Git work tree, and makes rendering depend on + `.gitignore` contents instead of a visible name. +- **Target lists in `intelligence.yaml`.** Who an artifact is for is a property + of the artifact; a manifest list is a second source that drifts from it. The + list parser also reads a `rules:` key two spaces deep under any parent as a + source list. +- **`exclude:` as the field name.** Beside `paths:` it reads as a path + exclusion. +- **Negation inside `targets` (`!agents`).** Unquoted, `!agents` is a YAML tag, + not a string. +- **Letting a local artifact name a committed output.** Every other clone and CI + render that output without it, so it flips on each teammate's sync and fails + `sync --check`. +- **Skipping dotfiles everywhere.** It gives a leading dot a meaning — "hidden" + — that `targets: []` states in the file, and it would drop + `.environment.local.md` silently. +- **Special-casing `.local.md`.** The name rule already yields `local`. From b5141df31c785506d6ab0a7c492b873325bd33c4 Mon Sep 17 00:00:00 2001 From: Dmitrij Zykovic Date: Sun, 11 Oct 2026 20:43:12 +0300 Subject: [PATCH 2/8] Added local rules, agents and skills and read every source through one catalog --- README.md | 5 +- cli/commands/init.sh | 3 +- cli/internal/check.sh | 8 + cli/lib/gitignore.sh | 21 +- cli/tests/e2e-compat.sh | 3 + cli/tests/e2e-local-artifacts.sh | 225 +++++++++ cli/tests/e2e-negative.sh | 3 + cli/tests/e2e-packages.sh | 3 + cli/tests/e2e-store-sha.sh | 3 +- cli/tests/unit-engine.sh | 112 +++++ cli/tests/unit-gitignore.sh | 23 +- cli/tests/verify.sh | 2 +- docs/cli.md | 22 +- engine/adapters/_template.sh | 37 +- engine/adapters/agents.sh | 80 +-- engine/adapters/antigravity.sh | 64 +-- engine/adapters/claude.sh | 95 ++-- engine/adapters/codex.sh | 23 +- engine/adapters/copilot.sh | 94 ++-- engine/adapters/cursor.sh | 111 ++--- engine/adapters/opencode.sh | 55 +-- engine/adapters/pi.sh | 63 +-- engine/lib/common.sh | 459 ++++++++++++++++-- engine/sync.sh | 67 +-- intelligence/rules/engine.md | 32 +- .../skills/dev-build-adapter/SKILL.md | 6 +- packages/sync/references/adapters.md | 54 +-- packages/sync/references/conventions.md | 33 +- .../sync/references/onboarding-migration.md | 8 +- packages/sync/rules/intelligence-authoring.md | 2 +- .../SKILL.md | 12 +- .../references/audit-checks.md | 2 +- .../references/rules.md | 5 + 33 files changed, 1215 insertions(+), 520 deletions(-) create mode 100644 cli/tests/e2e-local-artifacts.sh diff --git a/README.md b/README.md index e363a6e..6959528 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,9 @@ conventions](packages/sync/references/conventions.md#generated-output-and-versio `AGENTS.md` is the shared root instruction entry point. The learn skill proposes migrating still-valid guidance from backed-up legacy `.cursorrules` or instruction-bearing `CLAUDE.md` files into project-owned rules. It never -restores the original root monolith; a small new local file is created only for -separately approved machine-specific guidance. +restores the original root monolith; separately approved machine-specific +guidance becomes a local rule, `intelligence/rules/.local.md`, which Git +ignores and sync renders only into ignored tool output. Before the first sync can replace adapter-owned directories, `init` preserves existing AI instructions under the gitignored `intelligence/_backup/` (or the diff --git a/cli/commands/init.sh b/cli/commands/init.sh index 2f71cdd..66ab93e 100644 --- a/cli/commands/init.sh +++ b/cli/commands/init.sh @@ -356,7 +356,8 @@ manifest="$root/intelligence.yaml" } > "$manifest" # The package store is restorable state, never committed — the npm model. -ensure_base_gitignore "$root" +# Local rules, agents and skills stay on this machine (decision 0021). +ensure_base_gitignore "$root" "$content_dir" for target in $targets; do ensure_target_gitignore "$root" "$manifest" "$target" done diff --git a/cli/internal/check.sh b/cli/internal/check.sh index 6117e5f..10bf4c5 100644 --- a/cli/internal/check.sh +++ b/cli/internal/check.sh @@ -198,6 +198,14 @@ while IFS= read -r target; do ok "adapter $target contract v1" done < <(target_names "$manifest") +# A local rule, agent or skill is one these patterns name (decision 0021): the +# engine keeps it out of every committed output, and without the pattern Git +# would still offer to commit the file itself. +while IFS= read -r pattern; do + ignore_file_has_line "$IP_ROOT/.gitignore" "$pattern" \ + || warn "local artifact Git policy is missing '$pattern' — run 'intelligence init'" +done < <(local_artifact_ignore_patterns "$content_dir") + # Lock orphans. if [ "$lock_valid" -eq 1 ] && [ -f "$lock" ]; then while IFS= read -r name; do diff --git a/cli/lib/gitignore.sh b/cli/lib/gitignore.sh index cb0c895..1c9011f 100644 --- a/cli/lib/gitignore.sh +++ b/cli/lib/gitignore.sh @@ -312,10 +312,26 @@ ensure_gitignore_header() { fi } +# local_artifact_ignore_patterns — the patterns that make a rule, +# agent or skill local (decision 0021): a file or skill directory with one of +# these names never reaches committed output, and Git never commits it. The +# engine recognizes the same names, so the two cannot disagree; `init` writes +# them and `status --check` reports one missing. +local_artifact_ignore_patterns() { + local dir="${1#./}" + while [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done + printf '%s\n' "$dir/rules/*.local.md" "$dir/agents/*.local.md" "$dir/skills/*.local/" +} + +# ensure_base_gitignore — the package store and the local +# artifact patterns of the project's content directory. ensure_base_gitignore() { - local root="$1" + local root="$1" content_dir="$2" line ensure_gitignore_header "$root" gitignore_add_line "$root" '.intelligence/' + while IFS= read -r line; do + gitignore_add_line "$root" "$line" + done < <(local_artifact_ignore_patterns "$content_dir") } ensure_target_gitignore() { @@ -343,7 +359,7 @@ ensure_target_gitignore() { ensure_manifest_gitignore() { local root="$1" manifest="$2" target - ensure_base_gitignore "$root" + ensure_base_gitignore "$root" "$(manifest_intelligence_dir "$manifest")" while IFS= read -r target; do [ -n "$target" ] || continue if [ "$(is_target_enabled "$manifest" "$target")" = "1" ]; then @@ -420,6 +436,7 @@ managed_gitignore_patterns() { local root="$1" manifest="$2" content_dir target output records kind value content_dir="$(manifest_intelligence_dir "$manifest")" printf '%s\n' '.intelligence/' "$content_dir/_backup/" + local_artifact_ignore_patterns "$content_dir" while IFS= read -r target; do [ -n "$target" ] || continue [ "$(is_target_enabled "$manifest" "$target")" = "1" ] || continue diff --git a/cli/tests/e2e-compat.sh b/cli/tests/e2e-compat.sh index 3fe82f6..bfb53f6 100644 --- a/cli/tests/e2e-compat.sh +++ b/cli/tests/e2e-compat.sh @@ -111,6 +111,9 @@ MANIFEST printf '# Ctx\n\nCOMPAT_MARKER\n' > "$d/intelligence/rules/context.md" cat > "$d/.gitignore" <<'IGNORE' .intelligence/ +intelligence/rules/*.local.md +intelligence/agents/*.local.md +intelligence/skills/*.local/ CLAUDE.md .claude/* !.claude/settings.json diff --git a/cli/tests/e2e-local-artifacts.sh b/cli/tests/e2e-local-artifacts.sh new file mode 100644 index 0000000..4df9da8 --- /dev/null +++ b/cli/tests/e2e-local-artifacts.sh @@ -0,0 +1,225 @@ +#!/bin/bash +# Hermetic e2e for decision 0021: one source catalog names every artifact, +# applies overrides everywhere, refuses ambiguous names, and keeps a local +# rule, agent or skill out of every output Git tracks. +set -euo pipefail +unset CI +REPO="${1:-$(cd "$(dirname "$0")/../.." && pwd)}" +REPO="$(cd "$REPO" && pwd)" +OUT="$(mktemp -d)" +trap 'rm -rf "$OUT"' EXIT +CLI="$REPO/cli/intelligence" +fail=0 +chk() { if ! "$@" >/dev/null 2>&1; then echo "FAIL: $*"; fail=1; fi; } +chknot() { if "$@" >/dev/null 2>&1; then echo "FAIL(not): $*"; fail=1; fi; } +# is