diff --git a/.gitignore b/.gitignore index 7a1e8e7..9ee87b1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ GEMINI.md .agents/agents/ !.claude/ .antigravity.md +intelligence/rules/*.local.md +intelligence/agents/*.local.md +intelligence/skills/*.local/ +AGENTS.override.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ec9decc..1b15ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ All notable changes to Intelligence are recorded here. Legacy Intelligence Sync history remains in its [archive](https://github.com/ainova-systems/intelligence-sync/blob/main/CHANGELOG.md). +## [0.21.0] + +### Added + +- Local rules, agents and skills: a `*.local.md` rule or agent and a `*.local/` skill directory in the content directory render only into outputs Git ignores, never into `AGENTS.md` or another tracked output, so every clone and CI render the same committed files; `.environment.local.md` renders as `environment` (decision 0021). +- `intelligence init` writes `/rules/*.local.md`, `/agents/*.local.md` and `/skills/*.local/` into the managed `.gitignore` block, and `status --check` reports one missing. +- `status --check` names a rule, agent or skill with a local name that Git tracks in any project-owned source: `.gitignore` never applies to a tracked file, so Git would share what sync renders only into ignored output. +- `targets:` and `exclude_targets:` in a rule's, agent's or `SKILL.md`'s frontmatter select the adapters it renders for: adapter ids and the group `committed` (every output Git tracks), in the list syntax `paths:` takes. `targets: []` renders nowhere, an unknown id or a local artifact naming a committed output refuses the sync, and both fields are removed from every generated copy (decision 0021). +- An always-on rule `AGENTS.md` does not carry — a local one, or one whose targets leave `agents` out — reaches each tool through its own channel: Cursor `.mdc` with `alwaysApply: true`, Copilot `.instructions.md` with `applyTo: "**"`, Antigravity `trigger: always_on`, Pi's rules extension, and Codex `AGENTS.override.md`, which holds all of `AGENTS.md` plus those rules, is ignored, and exists only while such a rule does (decision 0021). +- Sync prints a `WARNING:` line when `AGENTS.md` or `.agents/skills/` delivers a local or targeted artifact to an enabled tool outside its targets, and when a tool in its targets has no channel for it — OpenCode for an always-on rule outside `AGENTS.md`. + +### Changed + +- `AGENTS.md`, every built-in adapter and the adapter template read sources through one catalog (`source_artifacts_var`): an artifact is named without a leading `.`, `.md` and a trailing `.local`, so a dotfile source renders in every adapter under that name, not only in `AGENTS.md` (decision 0021). +- A skill a later source overrides is replaced whole in every skill tree; its earlier version's resources no longer merge into the copy. +- 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, refuse the sync before any output is touched, naming both files. +- Pi's scoped-rule extension lists rules in byte order, as `AGENTS.md` does, instead of the locale's glob order. +- The Codex contract manages and ignores `AGENTS.override.md`; with Codex enabled, run `intelligence init` once to add the pattern. + +### Fixed + +- `AGENTS.md` listed and inlined a same-named rule, agent or skill once per source while every other adapter rendered only the later one; it now applies the same override (decision 0021). +- A rule or agent that is a symlink to a file is listed in `AGENTS.md`, as every other adapter already rendered it. + +### Breaking + +- [ ] No source directory holds two artifacts whose names differ only by a leading `.` or a trailing `.local`, and no artifact name is both local and committed across the sources: `intelligence sync` succeeds. +- [ ] No file Git tracks in a project source is a rule or agent named `*.local.md` or a skill directory named `*.local/`: such a file now renders only into ignored output and leaves `AGENTS.md`. Rename it to keep it shared, or `git rm --cached` it to keep it on one machine. +- [ ] The CLI-managed block of `.gitignore` lists `/rules/*.local.md`, `/agents/*.local.md` and `/skills/*.local/`, and `AGENTS.override.md` when Codex is enabled — run `intelligence init` to apply it. +- [ ] `intelligence status --check` exits 0. + ## [0.20.0] ### Added 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..d8efa44 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 @@ -214,6 +222,7 @@ fi # is exactly why doctor names it). Each entry is judged by the directory the # engine renders it as, read through the same parser: a package reference by # its store path, one that resolves to nothing by why. +local_specs=() for section in rules agents skills; do seen=$'\n' while IFS=$'\037' read -r src state dir alias holders; do @@ -269,10 +278,40 @@ for section in rules agents skills; do # authoring later needs no config edit. *) ok "sources.$section '$src' — not created yet (optional)" ;; esac + continue fi + # A project-owned source is where a tracked file can carry a local + # name; store content is restorable state Git never tracks. + case "$dir" in + .intelligence/*) ;; + *) + if [ "$section" = skills ]; then + local_specs+=(":(glob)${dir%/}/*.local/SKILL.md") + else + local_specs+=(":(glob)${dir%/}/*.local.md") + fi + ;; + esac done < <(read_source_entries "$manifest" "$section") done +# A name makes an artifact local (decision 0021), and .gitignore never applies +# to a file Git already tracks: one committed before its name meant anything, +# or added by hand, renders only into ignored output while every clone holds +# it. Git and the engine would disagree about it, so it is named here. +if [ "${#local_specs[@]}" -gt 0 ] \ + && git -C "$IP_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + if tracked_local="$(git -C "$IP_ROOT" -c core.quotePath=false ls-files -- "${local_specs[@]}")"; then + while IFS= read -r path; do + [ -n "$path" ] || continue + case "$path" in */SKILL.md) path="${path%/SKILL.md}/" ;; esac + warn "$path is tracked by Git, but its name makes it local: sync renders it only into ignored output — rename it to share it, or 'git rm --cached' it to keep it on this machine" + done <<< "$tracked_local" + else + warn "tracked local artifacts could not be listed — 'git ls-files' failed" + fi +fi + if [ "$problems" -eq 0 ]; then echo "all good." else 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..420d8ff --- /dev/null +++ b/cli/tests/e2e-local-artifacts.sh @@ -0,0 +1,381 @@ +#!/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