From 5bb148a1b69b594840e828c3c6231d00cd622f78 Mon Sep 17 00:00:00 2001 From: Dmitrij Zykovic Date: Wed, 7 Oct 2026 14:58:29 +0300 Subject: [PATCH 1/6] Named package sources by package instead of by store path --- CHANGELOG.md | 18 +- cli/commands/init.sh | 4 +- cli/commands/source.sh | 35 ++- cli/internal/align-project.sh | 12 +- cli/internal/check.sh | 30 ++- cli/internal/convert-legacy.sh | 4 + cli/internal/package-add.sh | 5 +- cli/internal/package-remove.sh | 3 +- cli/internal/package-update.sh | 14 +- cli/lib/cli-common.sh | 5 +- cli/lib/manifest.sh | 132 +++++++++- cli/lib/registry.sh | 34 ++- cli/tests/e2e-lifecycle.sh | 54 +++- cli/tests/e2e-packages.sh | 74 +++++- cli/tests/e2e-sources.sh | 66 +++++ cli/tests/unit-engine.sh | 102 +++++++ cli/tests/unit-manifest.sh | 124 +++++++++ .../0015-name-package-sources-by-package.md | 97 +++++++ docs/cli.md | 51 +++- engine/VERSION | 2 +- engine/adapters/_template.sh | 3 +- engine/lib/common.sh | 249 +++++++++++++++--- engine/sync.sh | 9 + examples/cli-project/intelligence.yaml | 27 +- .../intelligence.lock | 6 +- .../intelligence.yaml | 17 +- .../go-api-with-antigravity/intelligence.lock | 6 +- .../go-api-with-antigravity/intelligence.yaml | 17 +- .../go-api-with-opencode/intelligence.lock | 6 +- .../go-api-with-opencode/intelligence.yaml | 17 +- .../intelligence.lock | 6 +- .../intelligence.yaml | 17 +- examples/go-api/intelligence.lock | 6 +- examples/go-api/intelligence.yaml | 17 +- .../intelligence.lock | 6 +- .../intelligence.yaml | 17 +- examples/with-remote-skills/intelligence.yaml | 29 +- packages/sync/references/adapters.md | 4 +- packages/sync/references/conventions.md | 12 +- .../intelligence-review-context/SKILL.md | 6 +- .../intelligence-update-context/SKILL.md | 3 +- 41 files changed, 1146 insertions(+), 200 deletions(-) create mode 100644 decisions/0015-name-package-sources-by-package.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db5884..a8315f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,27 @@ 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.18.1] +## [0.19.0] + +### Added + +- `sources:` names an installed package's directory as `package:/` — `package:sync/rules` instead of `.intelligence/packages/@ainova-systems/sync/rules` — so a project's source list carries no vendor scope. `` is the full `@scope/name`, or the part after its `/` while no other declared package shares it. The engine renders a token exactly as the store path it stands for; `sync` warns about, and `status --check` reports, a token that names no declared package, one two declared packages share, or a malformed one (decision 0015). ### Changed - On Windows, `bash cli/tests/verify.sh` runs its scope in WSL when a distribution can, so the suites finish in minutes instead of close to an hour under Git Bash; `INTELLIGENCE_VERIFY_NATIVE=1` keeps Git Bash (decision 0014). +- Package add, remove, update and restore write package sources as `package:` tokens in one canonical spelling: short, or full for every package sharing a short name. Adding a package whose short name collides rewrites the existing tokens of that name in full, and removing it returns the survivor to the short form, each in place. +- Lifecycle alignment rewrites every `.intelligence/packages//` entry in place to its `package:` token; the generated output is byte-identical. Legacy conversion writes tokens too. +- `source add` and `source remove` refuse a `package:` token as package content, naming `package add` and `package remove`, and accept one as a `--before` / `--after` anchor. + +### Fixed + +- `update` keeps a package's `sources:` entries where they stand. It removed them and wired them first again, so updating one of two packages moved it ahead of the other and reversed which package's same-named files won. + +### Breaking + +- [ ] Every teammate and CI job that syncs the project runs CLI `0.19.0` or later before the aligned manifest is committed: a CLI `0.18` or earlier reads a `package:` source as a path, so it renders a migrated project without package content and still reports `IS_STATUS=ok`. +- [ ] After alignment, `sources:` names each declared package as `package:/`, no `.intelligence/packages/` path of a declared package remains, and `intelligence status --check` succeeds. ## [0.18.0] diff --git a/cli/commands/init.sh b/cli/commands/init.sh index 227e22b..e19c46a 100644 --- a/cli/commands/init.sh +++ b/cli/commands/init.sh @@ -298,7 +298,9 @@ manifest="$root/intelligence.yaml" echo "# tier: frontier|heavy|standard|light," echo "# access: full|readonly)" echo "# $content_dir/skills//SKILL.md a procedure invoked as /" - echo "# '.intelligence/' is CLI-managed (packages + engine content) — never edit it." + echo "# 'package:/' entries are an installed package's directories, wired" + echo "# by 'intelligence package add'. '.intelligence/' is CLI-managed (packages +" + echo "# engine content) — never edit it." echo "sources:" echo " rules:" echo " - \"$content_dir/rules\"" diff --git a/cli/commands/source.sh b/cli/commands/source.sh index 14f9e0b..f3d3fc6 100644 --- a/cli/commands/source.sh +++ b/cli/commands/source.sh @@ -9,9 +9,10 @@ # content that should behave like a package (a pack developed in the repository # that ships it) belongs after the store entries and before the project's. # -# Installed package content is NOT managed here: `.intelligence/` entries are -# written by `package add` and removed by `package remove`, and a hand-placed -# entry under the store does not survive the next lifecycle alignment. +# Installed package content is NOT managed here: `package:/` entries +# — and the `.intelligence/` store paths they replaced — are written by +# `package add` and removed by `package remove`. A token is still a valid +# `--before` / `--after` anchor: that is how a directory lands next to a package. set -euo pipefail source "$CLI_DIR/lib/cli-common.sh" @@ -39,13 +40,16 @@ assert_source_section() { # assert_valid_source_dir — refuse what the engine cannot report. # The shape checks are `source_entry_problem`, shared with `status --check` so # one definition covers both the entry about to be written and the one already -# in the manifest; the store rule is this command's alone, because a -# `.intelligence/` entry is legitimate when `package add` wrote it. +# in the manifest; the package rule is this command's alone, because a +# `package:` or `.intelligence/` entry is legitimate when `package add` wrote it. assert_valid_source_dir() { local root="$1" dir="$2" problem [ -n "$dir" ] || die "$USAGE" case "$dir" in *\\*) die "invalid source '$dir' — manifest paths use '/': ${dir//\\//}" ;; + package:*) + die "'$dir' names installed package content — install it with 'intelligence package add', which wires its sources automatically" + ;; .intelligence|.intelligence/*) die "'$dir' is inside the CLI-managed package store — install content with 'intelligence package add', and it is wired into sources automatically" ;; @@ -58,18 +62,25 @@ assert_valid_source_dir() { # Position is the whole point of the block, so every mutation ends by showing # the order it produced rather than only naming what it wrote. print_section() { - local manifest="$1" root="$2" section="$3" entry n=0 note + local manifest="$1" root="$2" section="$3" entry state dir n=0 note echo "sources.$section (a later entry overrides an earlier one):" - while IFS= read -r entry; do + [ -f "$manifest" ] || { echo " (none)"; return 0; } + # Listed as written; judged by the directory the engine renders it as. + while IFS=$'\037' read -r entry state dir _; do [ -n "$entry" ] || continue n=$((n + 1)) note="" - case "$entry" in - .intelligence/*) note=" package" ;; + case "$state" in + ok|undeclared) note=" package" ;; + unknown|ambiguous|invalid) note=" UNRESOLVED — intelligence status --check" ;; + *) case "$dir" in .intelligence/*) note=" package" ;; esac ;; + esac + case "$state" in + unknown|ambiguous|invalid) ;; + *) [ -d "$root/$dir" ] || note="$note MISSING" ;; esac - [ -d "$root/$entry" ] || note="$note MISSING" printf ' %d. %s%s\n' "$n" "$entry" "$note" - done < <(sources_list_entries "$manifest" "$section") + done < <(read_source_entries "$manifest" "$section") [ "$n" -eq 0 ] && echo " (none)" return 0 } @@ -143,7 +154,7 @@ case "$action" in [ $# -le 2 ] || die "$USAGE" dir="$(normalize_source_dir "$dir")" case "$dir" in - .intelligence|.intelligence/*) + package:*|.intelligence|.intelligence/*) die "'$dir' is installed package content — remove the package instead: intelligence package remove <@scope/name>" ;; esac diff --git a/cli/internal/align-project.sh b/cli/internal/align-project.sh index cc00fb9..106b94a 100644 --- a/cli/internal/align-project.sh +++ b/cli/internal/align-project.sh @@ -92,7 +92,7 @@ for section in rules agents skills; do migrated=1 ;; esac - done < <(read_yaml_list "$manifest" "$section") + done < <(read_yaml_list_raw "$manifest" "$section") done if [ "$migrated" -eq 1 ]; then echo " migrating: staged engine content -> $SYNC_PKG_NAME package" @@ -101,6 +101,16 @@ if [ "$migrated" -eq 1 ]; then sync_pkg_install "$IP_ROOT" fi +# migration 3: a package's sources name the package, not its store path. +# `.intelligence/packages//` becomes the canonical +# `package:/` in place — position is the override order, so no entry +# moves — and renders the same directory. A store path of a package the +# manifest does not declare is left as written. +package_sources_respell "$manifest" = +if [ "$IS_SOURCES_RESPELLED" -eq 1 ]; then + echo " migrating: package store paths in sources -> package:/" +fi + # --- steady state --------------------------------------------------------- # Align the engine-content package with the bundled engine. Only a manifest # that HAS the entry is touched — a --bare project opted out, and upgrade diff --git a/cli/internal/check.sh b/cli/internal/check.sh index 4976c9f..9e7cb76 100644 --- a/cli/internal/check.sh +++ b/cli/internal/check.sh @@ -188,22 +188,40 @@ if [ "$lock_valid" -eq 1 ] && [ -f "$lock" ]; then fi # Sources must exist (a missing dir is silently skipped by the engine, which -# is exactly why doctor names it). +# is exactly why doctor names it). Each entry is judged by what the engine +# renders it as: a package token through its expansion, from the same parser. for section in rules agents skills; do - while IFS= read -r src; do + while IFS=$'\037' read -r src state dir candidates; do [ -n "$src" ] || continue + # A package token that resolves to no declared package names no + # directory, so it is skipped like a missing source. + case "$state" in + unknown|undeclared) + warn "sources.$section '$src' matches no declared package — 'intelligence package add' installs one, or correct the entry" + continue + ;; + ambiguous) + short="${src#package:}" + warn "sources.$section '$src' is ambiguous: $candidates are all named '${short%%/*}' — name one in full: package:<@scope/name>/${short#*/}" + continue + ;; + invalid) + warn "sources.$section '$src' is not a package source — expected package:/" + continue + ;; + esac case "$src" in git+*|@*) continue ;; esac # A hand-edited entry the engine cannot render is worse than a missing # one: it is skipped without a word and the sync still reports ok. - problem="$(source_entry_problem "$IP_ROOT" "$src")" + problem="$(source_entry_problem "$IP_ROOT" "$dir")" if [ -n "$problem" ]; then warn "sources.$section '$src' $problem" continue fi - if [ ! -d "$IP_ROOT/$src" ]; then - case "$src" in + if [ ! -d "$IP_ROOT/$dir" ]; then + case "$dir" in # Store content is restorable state: absent means un-installed. .intelligence/*) if [ "$lock_valid" -eq 1 ]; then @@ -219,7 +237,7 @@ for section in rules agents skills; do *) ok "sources.$section '$src' — not created yet (optional)" ;; esac fi - done < <(read_yaml_list "$manifest" "$section") + done < <(read_source_entries "$manifest" "$section") done if [ "$problems" -eq 0 ]; then diff --git a/cli/internal/convert-legacy.sh b/cli/internal/convert-legacy.sh index aad5b72..38e9b85 100644 --- a/cli/internal/convert-legacy.sh +++ b/cli/internal/convert-legacy.sh @@ -320,6 +320,10 @@ qmap_set "$manifest_stage" "packages" "$SYNC_PKG_NAME" "version" "$eng" printf '%s\037%s\037%s\037%s\037%s\037%s\n' \ "$SYNC_PKG_NAME" "$eng" "$SYNC_PKG_URL" "$SYNC_PKG_PATH" "v$eng" "$sync_pkg_sha" >> "$lock_rows" +# With every package declared, the store paths written above take the spelling +# the CLI writes everywhere else: `package:/`, in place. +package_sources_respell "$manifest_stage" = + # ---- Verify --------------------------------------------------------------- echo "== verifying staged state ==" fail=0 diff --git a/cli/internal/package-add.sh b/cli/internal/package-add.sh index 644d5d7..9f90a2f 100644 --- a/cli/internal/package-add.sh +++ b/cli/internal/package-add.sh @@ -129,7 +129,10 @@ rel=".intelligence/packages/$name" store_record_remove "$IP_ROOT" "$name" sha="$(fetch_package "$url" "${ref:-$resolved_tag}" "$path" "$IP_ROOT/$rel")" -wire_package_sources "$manifest" "$name" "$rel" "$IP_ROOT" +# Before the packages: entry below: a short token another package already +# holds still resolves against the set without this one, and is respelled in +# full when the names collide. +wire_package_sources "$manifest" "$name" "$rel" "$IP_ROOT" declaring # The manifest records requested intent only. The resolved source URL/path, # tag/ref and SHA live in the required lock. Re-adding a package is the diff --git a/cli/internal/package-remove.sh b/cli/internal/package-remove.sh index 93fad36..6e2a850 100644 --- a/cli/internal/package-remove.sh +++ b/cli/internal/package-remove.sh @@ -39,7 +39,8 @@ while IFS= read -r k; do done < <(qmap_keys "$manifest" "packages") [ "$known" -eq 1 ] || die "package '$name' is not in the manifest" -unwire_package_sources "$manifest" "$rel" +# Before the packages: entry goes: the entries still resolve against it. +unwire_package_sources "$manifest" "$name" qmap_delete_key "$manifest" "packages" "$name" lock_remove "$lock" "$name" rm -rf "${IP_ROOT:?}/$rel" diff --git a/cli/internal/package-update.sh b/cli/internal/package-update.sh index e69a371..ab88c3b 100644 --- a/cli/internal/package-update.sh +++ b/cli/internal/package-update.sh @@ -167,13 +167,19 @@ while IFS= read -r name; do fi rel=".intelligence/packages/$name" # Fetch into staging FIRST: a failed clone must leave the current install - # fully wired and intact. Only after success is the old shape unwired - # (the new version may have dropped a section dir), the store swapped, - # and the new shape wired. + # fully wired and intact. Only after success are the sections the new + # version dropped unwired, the store swapped, and the sections it gained + # wired. A section it keeps stays where it stands in the list: position is + # the override order between packages, and an update changes content, not + # which package wins. staging="$IP_ROOT/.intelligence/.staging-$$" rm -rf "$staging" sha="$(fetch_package "$url" "$tag" "$path" "$staging")" - unwire_package_sources "$manifest" "$rel" + dropped=() + for section in rules agents skills; do + [ -d "$staging/$section" ] || dropped+=("$section") + done + package_sources_respell "$manifest" = "$name" ${dropped[@]+"${dropped[@]}"} store_record_remove "$IP_ROOT" "$name" rm -rf "${IP_ROOT:?}/$rel" mkdir -p "$(dirname "$IP_ROOT/$rel")" diff --git a/cli/lib/cli-common.sh b/cli/lib/cli-common.sh index 25a5e43..eeacad2 100644 --- a/cli/lib/cli-common.sh +++ b/cli/lib/cli-common.sh @@ -5,7 +5,8 @@ # All YAML reading goes through the engine's lib/common.sh — the CLI never # grows a parallel parser for shapes the engine can already read. The only # CLI-owned parsing lives in lib/manifest.sh (quoted-key `packages:` / -# `registries:` blocks, which the engine deliberately never reads). +# `registries:` blocks, which the engine never reads beyond the package names +# its list parser resolves `package:` sources against). # Engine libraries (readers, is_status, IS_RC_*, engine_version). CLI_DIR / # IS_ENGINE_DIR come exported from the dispatcher. @@ -545,6 +546,8 @@ project_store_missing() { assert_valid_pkg_name "$name" [ -d "$root/.intelligence/packages/$name" ] || return 0 done < <(qmap_keys "$manifest" "packages") + # The parser expands a `package:` token, so it is checked here as the store + # directory it names. for section in rules agents skills; do while IFS= read -r src; do case "$src" in diff --git a/cli/lib/manifest.sh b/cli/lib/manifest.sh index 6ee2e90..a550394 100644 --- a/cli/lib/manifest.sh +++ b/cli/lib/manifest.sh @@ -2,9 +2,10 @@ # The CLI-owned YAML shapes: quoted-key maps (`packages:`, `registries:` in # the manifest, `packages:` in the lock and in registry indexes). # -# The engine deliberately never reads these blocks, and its readers cannot -# hold `@scope/name` keys — so this is the CLI's single, deliberate parser, -# scoped to exactly one shape: +# The engine reads none of these blocks beyond the names in `packages:`, which +# resolve `package:/` sources (engine/lib/common.sh), and its list +# readers cannot hold `@scope/name` keys — so this is the CLI's single, +# deliberate parser, scoped to exactly one shape: # # block: # "@scope/name": # 2-space indent, key always quoted @@ -407,12 +408,14 @@ registries_remove() { # meaning: a later entry overrides a same-named artifact from an earlier one. # Reading goes through the engine's own list parser, so the CLI sees exactly # what the engine will render instead of a second reading of the same file. +# The editors read it raw — a `package:` token as written, which is what they +# compare and rewrite — and read_source_entries says what each one renders. -# sources_list_entries
— entries of sources.
, one per -# line, in manifest order. +# sources_list_entries
— entries of sources.
as +# written, one per line, in manifest order. sources_list_entries() { [ -f "$1" ] || return 0 - read_yaml_list "$1" "$2" + read_yaml_list_raw "$1" "$2" } # sources_has_entry
— true (0) when that section lists @@ -530,3 +533,120 @@ sources_remove_entry() { { print } ' } + +# --- package sources: one canonical spelling -------------------------------- +# A package's directories enter `sources:` as `package:/` tokens +# (engine/lib/common.sh defines them). The CLI writes the canonical spelling: +# the short name after the package's `/` while no other declared package shares +# it, the full `@scope/name` for every package that does. Which spelling is +# canonical depends on the whole declared set, so adding a package whose short +# name collides respells the existing tokens of that name in full, and removing +# it returns the survivor to the short form. Every path that wires or unwires +# package content goes through package_sources_respell, which is the one place +# that decision is made. + +# The declared set once an edit lands: the names `packages:` holds now, plus +# for `+` or without it for `-`. Fills AFTER[1..NA] in manifest order. +_SOURCES_AFTER_AWK=' + function after_set(change, name, i, n) { + split("", AFTER) + NA = 0 + n = PKT_N + 0 + for (i = 1; i <= n; i++) { + if (change == "-" && PKT_NAMES[i] == name) continue + AFTER[++NA] = PKT_NAMES[i] + } + if (change == "+" && name != "" && !(name in pkt_seen)) AFTER[++NA] = name + } +' + +# package_sources_respell [name] [drop-section...] +# Rewrite every rules/agents/skills entry that names an installed package into +# the canonical token for the declared set the edit leaves behind: +# + is about to be declared — package add, before it writes the +# packages: entry +# - is about to be undeclared — package remove, before it deletes +# the packages: entry +# = the declared set stays as it is +# An entry resolves against the names declared now, so a short token keeps +# meaning the package it meant before the edit, and is rewritten in place: +# order is the override rule, so nothing moves. The store path of a declared +# package — or of — becomes a token as well, which is how a manifest +# written before tokens migrates. An entry naming 's own
+# directory under that section is dropped when
is a drop section. +# Whatever resolves to no package is left exactly as written. Sets +# IS_SOURCES_RESPELLED to 1 when the file changed; an unchanged file is not +# rewritten at all. +package_sources_respell() { + local file="$1" change="$2" name="${3:-}" tmp rc=0 + shift 2 + [ "$#" -eq 0 ] || shift + # Read by callers that report a migration; per-file shellcheck cannot see it. + # shellcheck disable=SC2034 + IS_SOURCES_RESPELLED=0 + [ -f "$file" ] || return 0 + case "$change" in +|-|=) ;; *) die "internal: unknown sources change '$change'" ;; esac + tmp="$file.cli.tmp" + awk -v change="$change" -v name="$name" -v drop=" $* " \ + "$IS_PKG_TOKEN_AWK$_SOURCES_AFTER_AWK"' + function resolve(v, st) { + st = pkt_parse(v) + if (st == "ok" || st == "undeclared") return 1 + return pkt_store(v) && ((PKT_NAME in pkt_seen) || PKT_NAME == name) + } + FNR == 1 { pass++ } + pass == 1 { sub(/\r$/, ""); pkt_collect($0); next } + FNR == 1 { after_set(change, name) } + { sub(/\r$/, "") } + /^sources:[ \t]*$/ { ins = 1; print; next } + ins && /^[^ #]/ { ins = 0; sec = "" } + ins && /^ [A-Za-z_]/ { + sec = $0 + sub(/^ /, "", sec) + sub(/:.*$/, "", sec) + if (sec != "rules" && sec != "agents" && sec != "skills") sec = "" + print + next + } + ins && sec != "" && /^[ \t]*-/ { + v = $0 + sub(/^[ \t]*-[ \t]*/, "", v) + gsub(/["\047]/, "", v) + sub(/[ \t]+#.*$/, "", v) + sub(/[ \t]+$/, "", v) + if (v == "" || !resolve(v)) { print; next } + if (PKT_NAME == name && PKT_DIR == sec && index(drop, " " sec " ")) { changed = 1; next } + spelled = pkt_spell(PKT_NAME, PKT_DIR, AFTER, NA) + p = index($0, v) + if (spelled == v || p == 0) { print; next } + print substr($0, 1, p - 1) spelled substr($0, p + length(v)) + changed = 1 + next + } + { print } + END { exit (changed ? 0 : 10) } + ' "$file" "$file" > "$tmp" || rc=$? + case "$rc" in + 0) + [ -s "$tmp" ] || { rm -f "$tmp"; die "internal: sources respell produced an empty file for $file"; } + mv "$tmp" "$file" + # shellcheck disable=SC2034 + IS_SOURCES_RESPELLED=1 + ;; + 10) rm -f "$tmp" ;; + *) rm -f "$tmp"; die "internal: sources respell refused (awk exit $rc) for $file" ;; + esac +} + +# package_source_prefix [change] — `package:/`, the +# canonical prefix of 's directories once (as for +# package_sources_respell) lands. +package_source_prefix() { + awk -v name="$2" -v change="${3:-=}" "$IS_PKG_TOKEN_AWK$_SOURCES_AFTER_AWK"' + { sub(/\r$/, ""); pkt_collect($0) } + END { + after_set(change, name) + print pkt_spell(name, "", AFTER, NA) + } + ' "$1" +} diff --git a/cli/lib/registry.sh b/cli/lib/registry.sh index 3ffa6bc..5389a34 100644 --- a/cli/lib/registry.sh +++ b/cli/lib/registry.sh @@ -200,25 +200,31 @@ fetch_package() { printf '%s' "$sha" } -# wire_package_sources <@scope/name> +# wire_package_sources <@scope/name> [declaring] # The provides convention: whichever of rules/agents/skills the installed -# package has becomes a sources entry — inserted FIRST in its section, so -# project-owned entries come later and win on same-named artifacts. +# package has becomes a `package:/
` entry — inserted FIRST in +# its section, so project-owned entries come later and win on same-named +# artifacts. An entry the section already holds stays where it is. +# +# `declaring` says the caller is about to write into `packages:` (package +# add): the spelling is chosen for the set that includes it, so a short name it +# collides with is respelled in full now. Without it the declared set is taken +# as it stands, and a package missing from it is spelled in full — a short token +# resolves against declared packages only. wire_package_sources() { - local manifest="$1" name="$2" rel="$3" root="$4" - local section + local manifest="$1" name="$2" rel="$3" root="$4" change="=" prefix="" section + [ "${5:-}" != declaring ] || change="+" + package_sources_respell "$manifest" "$change" "$name" for section in rules agents skills; do - if [ -d "$root/$rel/$section" ]; then - sources_add_entry_first "$manifest" "$section" "$rel/$section" - fi + [ -d "$root/$rel/$section" ] || continue + [ -n "$prefix" ] || prefix="$(package_source_prefix "$manifest" "$name" "$change")" + sources_add_entry_first "$manifest" "$section" "$prefix$section" done } -# unwire_package_sources +# unwire_package_sources <@scope/name> — drop every entry naming the +# package's rules/agents/skills, whatever its spelling, and respell the rest for +# the set without it. Call before the `packages:` entry is deleted. unwire_package_sources() { - local manifest="$1" rel="$2" - local section - for section in rules agents skills; do - sources_remove_entry "$manifest" "$section" "$rel/$section" - done + package_sources_respell "$1" - "$2" rules agents skills } diff --git a/cli/tests/e2e-lifecycle.sh b/cli/tests/e2e-lifecycle.sh index 86695a1..f0c7db9 100644 --- a/cli/tests/e2e-lifecycle.sh +++ b/cli/tests/e2e-lifecycle.sh @@ -505,7 +505,8 @@ echo "== init --preview (legacy conversion) ==" (cd "$LEG" && IS_SUPPRESS_CLI_NOTE=1 bash "$CLI" init --preview > "$OUT/dry.txt") chknot test -f "$LEG/intelligence.yaml" chk test -f "$LEG/intelligence/config.yaml" -chk grep -q 'packages/@' "$OUT/dry.txt" +# The staged manifest names each package, never its store path. +chk grep -q 'package:shared-intel/rules' "$OUT/dry.txt" echo "== init --apply (legacy conversion) ==" (cd "$LEG" && IS_SUPPRESS_CLI_NOTE=1 bash "$CLI" init --apply > "$OUT/migrate.txt") @@ -527,6 +528,11 @@ chk grep -q "url: \"file://$PACK\"" "$LEG/intelligence.lock" chknot grep -q '^packs:' "$LEG/intelligence.yaml" chknot grep -q '^sync_version:' "$LEG/intelligence.yaml" chk grep -q "^schema_version: \"$ENGINE_VER\"" "$LEG/intelligence.yaml" +# Converted sources take the spelling the CLI writes everywhere: the package's +# name, in the position the legacy entry held. +chk grep -qx ' - "package:sync/rules"' "$LEG/intelligence.yaml" +chk grep -qx ' - "package:shared-intel/rules"' "$LEG/intelligence.yaml" +chknot grep -q '\.intelligence/packages/' "$LEG/intelligence.yaml" chk grep -Fqx '.intelligence/**' "$LEG/.dockerignore" chk grep -Fqx 'intelligence.yaml' "$LEG/.dockerignore" git -C "$LEG" status --porcelain | grep -q . || { echo "FAIL: migrate produced no diff"; fail=1; } @@ -822,5 +828,51 @@ run_in "$CFG" init --apply --force chk test "$RC" -eq 0 chknot grep -q 'intelligence_dir' "$CFG/intelligence.yaml" +echo "== a 0.18 manifest migrates to package tokens and renders the same bytes ==" +M18="$OUT/migrate-018" +mkdir -p "$M18" +git -C "$M18" init --quiet +run_in "$M18" init --targets claude +chk test "$RC" -eq 0 +# m18_entries
— sources.
as written, comma-joined. +m18_entries() { + awk -v sec="$1" ' + { sub(/\r$/, "") } + /^sources:[ \t]*$/ { ins = 1; next } + ins && /^[^ #]/ { ins = 0; f = 0 } + ins && $0 ~ "^ " sec ":[ \t]*$" { f = 1; next } + ins && /^ [A-Za-z_]/ { f = 0 } + f && /^[ \t]*-/ { v = $0; sub(/^[ \t]*-[ \t]*/, "", v); gsub(/["\047]/, "", v); print v } + ' "$M18/intelligence.yaml" | paste -sd, - +} +# Hashes of everything sync rendered, recorded outside the project. +m18_hashes() { + (cd "$M18" && find .claude -type f | LC_ALL=C sort | while IFS= read -r f; do + printf '%s %s\n' "$(git hash-object --no-filters "$f")" "$f" + done) +} +m18_hashes > "$OUT/m18-before.hashes" +chk test -s "$OUT/m18-before.hashes" +cp "$M18/intelligence.yaml" "$OUT/m18-current.yaml" +# The shape a 0.18 CLI wrote: the store path, under its own stamp. +sed -e 's|"package:sync/|".intelligence/packages/@ainova-systems/sync/|' \ + -e 's|^schema_version: ".*"$|schema_version: "0.18.0"|' "$OUT/m18-current.yaml" > "$M18/intelligence.yaml" +chk grep -qx ' - ".intelligence/packages/@ainova-systems/sync/rules"' "$M18/intelligence.yaml" +run_in "$M18" sync +chk test "$RC" -eq 0 +printf '%s\n' "$OUTPUT" | grep -qF 'migrating: package store paths in sources -> package:/' \ + || { echo "FAIL: alignment printed no migration line"; printf '%s\n' "$OUTPUT" | head -12; fail=1; } +for s in rules agents skills; do + [ "$(m18_entries "$s")" = "package:sync/$s,intelligence/$s" ] \ + || { echo "FAIL: sources.$s after migration: $(m18_entries "$s")"; fail=1; } +done +chk grep -q "^schema_version: \"$ENGINE_VER\"" "$M18/intelligence.yaml" +# Nothing but the spelling moved: the manifest is the one init wrote. +chk cmp -s "$OUT/m18-current.yaml" "$M18/intelligence.yaml" +m18_hashes > "$OUT/m18-after.hashes" +chk cmp -s "$OUT/m18-before.hashes" "$OUT/m18-after.hashes" +run_in "$M18" status --check +chk test "$RC" -eq 0 + [ "$fail" -eq 0 ] && echo "MIGRATE-E2E: ALL OK" exit "$fail" diff --git a/cli/tests/e2e-packages.sh b/cli/tests/e2e-packages.sh index b8e4be0..bde71a1 100644 --- a/cli/tests/e2e-packages.sh +++ b/cli/tests/e2e-packages.sh @@ -11,6 +11,19 @@ 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