diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3bc75d..46123f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,9 @@ Legacy Intelligence Sync history remains in its [archive](https://github.com/ain
- Agents and skills accept a tool-neutral `effort:` — `low`, `medium`, `high`, `xhigh`, `max` or `ultra`. Claude Code agents and skills receive it as `effort:` (`ultra` becomes `max`), Codex agents as `model_reasoning_effort`, and the shared `.agents/skills/` tree keeps it as written; Copilot, Cursor, OpenCode, Antigravity and Pi receive no effort key (decision 0015).
- An `effort:` off that scale prints a `WARNING:` line naming the source file and the value, also under `sync --compact` and `init`, renders as absent and leaves sync successful; an empty `effort:` is absent without a warning.
- `targets.copilot.commit_output: true` keeps generated Copilot output tracked for Copilot on github.com (decision 0020).
+- `sources:` names a package's directory by the package as well as by its store path: `@scope/name/
` for a package `packages:` declares, and `:` through the alias that package declares. Both render exactly as the store path. The CLI still writes store paths and rewrites no entry: wiring leaves a directory a section already names in any spelling where it stands, and `package remove` removes the package's entries in every spelling. An `@scope/name/…` entry for an undeclared package stays an ordinary path, which `status --check` now judges like any project directory. A CLI `0.18` or earlier reads a reference as a path that does not exist and skips it (decision 0019).
+- `intelligence package alias <@scope/name> ` and `package add --alias ` set the alias a package declares, `package alias <@scope/name> --remove` drops it, and `package list` shows it. Both refuse a malformed alias or one another package declares, and refuse to remove or replace an alias `sources:` still uses, naming the entries.
+- `sync` skips a reference that names nothing — an alias no package declares or two packages declare, or a `` that is empty or leaves its package — with a `WARNING:` line naming it, and `status --check` reports it, a malformed or shared alias, and a section that lists one package directory under two spellings. `source add` and `source remove` refuse a reference, naming `intelligence package`, and take any spelling of a package's directory as a `--before` / `--after` anchor.
### Changed
@@ -21,6 +24,8 @@ Legacy Intelligence Sync history remains in its [archive](https://github.com/ain
### Fixed
- `status --check` no longer reports `.gitignore` patterns as missing when the file has CRLF line endings on Linux and macOS; it decides presence the way the `.gitignore` writer does, so `intelligence init` clears the finding (#45).
+- `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.
+- An entry in `sources:`, `ignore:` or `submodules:` followed by a `# comment` reads without the comment, and without whitespace outside its quotes. The comment was read as part of the path, so sync skipped that source without a word and `status --check` refused the `#`; a `#` inside quotes is still part of the value, and the `source` and `package` commands read entries the same way.
### Breaking
diff --git a/cli/commands/package.sh b/cli/commands/package.sh
index 740e90e..0bc1101 100644
--- a/cli/commands/package.sh
+++ b/cli/commands/package.sh
@@ -1,10 +1,10 @@
#!/bin/bash
-# intelligence package ...
+# intelligence package ...
set -euo pipefail
source "$CLI_DIR/lib/cli-common.sh"
action="${1:-}"
-[ -n "$action" ] || die "usage: intelligence package [args]"
+[ -n "$action" ] || die "usage: intelligence package [args]"
shift
case "$action" in
@@ -18,6 +18,11 @@ case "$action" in
ensure_project_current "$IP_ROOT"
exec bash "$CLI_DIR/internal/package-remove.sh" "$@"
;;
+ alias)
+ require_cli_project
+ ensure_project_current "$IP_ROOT"
+ exec bash "$CLI_DIR/internal/package-alias.sh" "$@"
+ ;;
list)
exec bash "$CLI_DIR/internal/package-list.sh" "$@"
;;
@@ -25,6 +30,6 @@ case "$action" in
exec bash "$CLI_DIR/internal/package-search.sh" "$@"
;;
*)
- die "usage: intelligence package [args]"
+ die "usage: intelligence package [args]"
;;
esac
diff --git a/cli/commands/source.sh b/cli/commands/source.sh
index 14f9e0b..bce4eb6 100644
--- a/cli/commands/source.sh
+++ b/cli/commands/source.sh
@@ -10,8 +10,11 @@
# 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.
+# written by `package add` and removed by `package remove`, and so are the
+# entries that name a package's directory by its full name or alias
+# (`@scope/name/`, `:`, decision 0019). Any of those spellings
+# 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"
@@ -36,11 +39,32 @@ assert_source_section() {
# repaired — a source entry decides which artifact wins, so a path the user did
# not type is the wrong kind of help.
+# assert_not_package_reference — refuse an entry
+# the engine reads as a package reference: a directory of a package `packages:`
+# declares, by full name or alias, or an alias no package declares. It is
+# package territory however it is spelled.
+assert_not_package_reference() {
+ local state rendered rest pkg
+ IFS=$'\037' read -r state rendered <<< "$(sources_classify_entry "$1" "$2")"
+ rest="${rendered#.intelligence/packages/}"
+ pkg="${rest%%/*}/"
+ rest="${rest#*/}"
+ pkg="$pkg${rest%%/*}"
+ case "$state/$3" in
+ path/*) return 0 ;;
+ ok/add) die "'$2' names a directory of the installed package $pkg — 'intelligence package add' wires a package's directories into sources" ;;
+ ok/remove) die "'$2' is installed package content — remove the package instead: intelligence package remove $pkg" ;;
+ */add) die "'$2' is a package reference, not a project directory — 'intelligence package add' wires a package's directories into sources" ;;
+ *) die "'$2' is a package reference, not a project directory — 'intelligence package remove <@scope/name>' removes a package's entries" ;;
+ esac
+}
+
# 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 rules are this command's alone, because a
+# package entry is legitimate when `package add` wrote it or a person spelled
+# it by the package's name.
assert_valid_source_dir() {
local root="$1" dir="$2" problem
[ -n "$dir" ] || die "$USAGE"
@@ -50,26 +74,34 @@ assert_valid_source_dir() {
die "'$dir' is inside the CLI-managed package store — install content with 'intelligence package add', and it is wired into sources automatically"
;;
esac
+ assert_not_package_reference "$root/intelligence.yaml" "$dir" add
problem="$(source_entry_problem "$root" "$dir")"
[ -z "$problem" ] || die "invalid source '$dir': it $problem"
}
# Print one section with its override direction and the state of each entry.
# 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.
+# the order it produced rather than only naming what it wrote. Entries are
+# listed as written and judged by the directory the engine renders them as.
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; }
+ 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
+ path|ok)
+ case "$dir" in
+ .intelligence/*) note=" package" ;;
+ esac
+ [ -d "$root/$dir" ] || note="$note MISSING"
+ ;;
+ *) note=" UNRESOLVED — intelligence status --check" ;;
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
}
@@ -110,10 +142,16 @@ case "$action" in
ensure_project_current "$IP_ROOT"
manifest="$IP_ROOT/intelligence.yaml"
- if [ -n "$anchor" ] && ! sources_has_entry "$manifest" "$section" "$anchor"; then
- echo "ERROR: sources.$section does not list '$anchor'." >&2
- print_section "$manifest" "$IP_ROOT" "$section" >&2
- exit 1
+ # The anchor may be spelled differently from the entry it means — a
+ # package's store path, full name or alias — so it is matched by the
+ # directory it names and placed against the entry as written.
+ if [ -n "$anchor" ]; then
+ if ! listed="$(sources_find_entry "$manifest" "$section" "$anchor")"; then
+ echo "ERROR: sources.$section does not list '$anchor'." >&2
+ print_section "$manifest" "$IP_ROOT" "$section" >&2
+ exit 1
+ fi
+ anchor="$listed"
fi
if sources_has_entry "$manifest" "$section" "$dir"; then
@@ -149,8 +187,9 @@ case "$action" in
esac
require_cli_project
- ensure_project_current "$IP_ROOT"
manifest="$IP_ROOT/intelligence.yaml"
+ assert_not_package_reference "$manifest" "$dir" remove
+ ensure_project_current "$IP_ROOT"
if ! sources_has_entry "$manifest" "$section" "$dir"; then
echo "not listed: sources.$section does not hold $dir"
diff --git a/cli/intelligence b/cli/intelligence
index 0e502e4..4af4025 100644
--- a/cli/intelligence
+++ b/cli/intelligence
@@ -61,7 +61,7 @@ Usage: intelligence [args]
--latest Move one named package past its range to the newest version
upgrade [--next] [--preview|--apply] Replace the installed CLI with the newest npm version
--next Follow the prerelease line instead of the stable one
- package add | remove | list | search
+ package add | remove | alias | list | search
source add | remove | list — the sources: block (own directories)
adapter list | create | enable | disable | remove
status [--check] Project state; --check runs deep consistency checks
diff --git a/cli/internal/check.sh b/cli/internal/check.sh
index 46413ee..6117e5f 100644
--- a/cli/internal/check.sh
+++ b/cli/internal/check.sh
@@ -119,6 +119,18 @@ while IFS= read -r name; do
fi
done < <(qmap_keys "$manifest" "packages")
+# Aliases name packages in sources: (decision 0019). One the engine would not
+# resolve, or one two packages state, leaves every : entry naming
+# nothing. An invalid alias is never echoed: it is manifest input that may hold
+# anything.
+while IFS=$'\037' read -r name alias verdict; do
+ [ -n "$name" ] || continue
+ case "$verdict" in
+ invalid) warn "$name declares an alias that is not one — expected $IS_PKG_ALIAS_RULE: intelligence package alias $name " ;;
+ ambiguous) warn "$name declares alias '$alias', which another package declares too — an alias names one package" ;;
+ esac
+done < <(read_package_aliases "$manifest")
+
# Adapter contracts are the shared source of truth for dependencies, rollback,
# onboarding backup and Git policy. Validate every declared target, including
# disabled ones, so a later enable cannot reveal a stale custom adapter.
@@ -199,22 +211,50 @@ 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 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.
for section in rules agents skills; do
- while IFS= read -r src; do
+ seen=$'\n'
+ while IFS=$'\037' read -r src state dir alias holders; do
[ -n "$src" ] || continue
+ case "$state" in
+ unknown|ambiguous|invalid)
+ source_reference_problem_var "$state" "$alias" "$holders"
+ case "$state" in
+ unknown) warn "sources.$section '$src' $IS_SOURCE_PROBLEM — declare it with 'intelligence package alias <@scope/name> $alias', or correct the entry" ;;
+ *) warn "sources.$section '$src' $IS_SOURCE_PROBLEM" ;;
+ esac
+ continue
+ ;;
+ esac
+ # One package directory under two spellings is listed twice, and the
+ # later one moves the package past every entry between them.
+ case "$dir" in
+ .intelligence/packages/*)
+ case "$seen" in
+ *$'\n'"$dir"$'\037'*)
+ first="${seen#*$'\n'"$dir"$'\037'}"
+ first="${first%%$'\n'*}"
+ [ "$first" = "$src" ] \
+ || warn "sources.$section names $dir twice, as '$first' and as '$src' — keep one: where it stands decides which artifacts win"
+ ;;
+ *) seen="$seen$dir"$'\037'"$src"$'\n' ;;
+ esac
+ ;;
+ esac
case "$src" in
- git+*|@*) continue ;;
+ 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
@@ -230,7 +270,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/package-add.sh b/cli/internal/package-add.sh
index 644d5d7..fc675af 100644
--- a/cli/internal/package-add.sh
+++ b/cli/internal/package-add.sh
@@ -9,15 +9,18 @@ set -euo pipefail
source "$CLI_DIR/lib/cli-common.sh"
spec="${1:-}"
-[ -n "$spec" ] || die "usage: intelligence package add <@scope/name[@range] | github:org/repo[#path] | git+[@ref][#path]>"
+[ -n "$spec" ] || die "usage: intelligence package add <@scope/name[@range] | github:org/repo[#path] | git+[@ref][#path]> [--name @scope/name] [--alias ] [--no-sync]"
shift || true
no_sync=0
name_override=""
+alias_given=0
+alias_req=""
while [ $# -gt 0 ]; do
case "$1" in
--no-sync) no_sync=1 ;;
--name) shift; name_override="${1:-}" ;;
+ --alias) shift; alias_given=1; alias_req="${1:-}" ;;
*) die "unknown option '$1'" ;;
esac
shift || true
@@ -77,6 +80,20 @@ esac
[ -n "$name_override" ] && name="$name_override"
assert_valid_pkg_name "$name"
+# The alias is settled before anything is fetched or written: a refusal leaves
+# the manifest, the lock and the store as they were. Re-adding a package keeps
+# the alias it declares unless --alias names another one, and never takes away
+# an alias `sources:` still spells entries with.
+alias="$(package_alias_of "$manifest" "$name")"
+if [ "$alias_given" -eq 1 ]; then
+ assert_valid_alias "$alias_req"
+ assert_alias_free "$manifest" "$name" "$alias_req"
+ if [ -n "$alias" ] && [ "$alias" != "$alias_req" ]; then
+ assert_alias_unused "$manifest" "$name" "$alias" replace
+ fi
+ alias="$alias_req"
+fi
+
if [ "$mode" = "registry" ]; then
resolve_package_source "$manifest" "$name"
url="$RES_URL"; path="$RES_PATH"
@@ -129,8 +146,6 @@ 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"
-
# 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
# explicit operation for changing its trusted source.
@@ -142,6 +157,12 @@ elif [ -n "$requested" ]; then
else
die "internal: package '$name' has neither a requested version nor ref"
fi
+[ -z "$alias" ] || qmap_set "$manifest" "packages" "$name" "alias" "$alias"
+
+# Wired once the package and its alias are declared, so a section that already
+# names one of its directories as @scope/name/ or : is left as
+# it stands instead of gaining the store path beside it.
+wire_package_sources "$manifest" "$name" "$rel" "$IP_ROOT"
lock_upsert "$lock" "$name" "$requested" "$url" "$path" "${ref:-$resolved_tag}" "$sha"
store_record_set "$IP_ROOT" "$name" "$url" "$path" "${ref:-$resolved_tag}" "$sha"
@@ -150,7 +171,7 @@ sections=""
for s in rules agents skills; do
[ -d "$IP_ROOT/$rel/$s" ] && sections="$sections $s"
done
-echo "+ $name@${resolved_tag:-${ref:-HEAD}} (${sections# })"
+echo "+ $name@${resolved_tag:-${ref:-HEAD}} (${sections# })${alias:+ alias $alias}"
if [ "$no_sync" -eq 0 ]; then
exec bash "$CLI_DIR/commands/sync.sh"
diff --git a/cli/internal/package-alias.sh b/cli/internal/package-alias.sh
new file mode 100644
index 0000000..8b093ec
--- /dev/null
+++ b/cli/internal/package-alias.sh
@@ -0,0 +1,51 @@
+#!/bin/bash
+# Internal package alias operation: set, replace or remove the alias a package
+# declares in `packages:`. `sources:` spells that package's directories as
+# : (decision 0019); the alias is the CLI's to write, like the rest
+# of the block, and one alias names exactly one package.
+set -euo pipefail
+source "$CLI_DIR/lib/cli-common.sh"
+
+USAGE="usage: intelligence package alias <@scope/name>
+ intelligence package alias <@scope/name> --remove"
+
+[ $# -eq 2 ] || die "$USAGE"
+name="$1"
+want="$2"
+
+require_cli_project
+project_lock_hold "$IP_ROOT"
+manifest="$IP_ROOT/intelligence.yaml"
+assert_valid_pkg_name "$name"
+
+known=0
+while IFS= read -r k; do
+ [ "$k" = "$name" ] && known=1
+done < <(qmap_keys "$manifest" "packages")
+[ "$known" -eq 1 ] || die "package '$name' is not in the manifest — 'intelligence package list' shows the declared ones"
+
+current="$(package_alias_of "$manifest" "$name")"
+
+if [ "$want" = "--remove" ]; then
+ # An entry can only spell a well-formed alias, so only a well-formed one can
+ # still be in use; a malformed one is dropped like any other.
+ [ -z "$current" ] || assert_alias_unused "$manifest" "$name" "$current" remove
+ if [ -z "$(qmap_field "$manifest" "packages" "$name" "alias")" ]; then
+ echo "$name declares no alias"
+ exit 0
+ fi
+ qmap_delete_field "$manifest" "packages" "$name" "alias"
+ echo "$name: alias removed"
+ exit 0
+fi
+
+case "$want" in -*) die "$USAGE" ;; esac
+assert_valid_alias "$want"
+if [ "$current" = "$want" ]; then
+ echo "$name: alias $want (unchanged)"
+ exit 0
+fi
+assert_alias_free "$manifest" "$name" "$want"
+[ -z "$current" ] || assert_alias_unused "$manifest" "$name" "$current" replace
+qmap_set "$manifest" "packages" "$name" "alias" "$want"
+echo "$name: alias $want — sources: may name its directories $want:"
diff --git a/cli/internal/package-list.sh b/cli/internal/package-list.sh
index 2c1029a..4e8354a 100644
--- a/cli/internal/package-list.sh
+++ b/cli/internal/package-list.sh
@@ -9,6 +9,10 @@ lock="$IP_ROOT/intelligence.lock"
lock_valid=1
if ! check_project_lock "$IP_ROOT"; then lock_valid=0; fi
+# Aliases come from the parser the engine resolves them with, in one pass; an
+# alias it would not resolve is shown as such, never echoed.
+aliases="$(read_package_aliases "$manifest")"
+
count=0
while IFS= read -r name; do
[ -n "$name" ] || continue
@@ -29,11 +33,21 @@ while IFS= read -r name; do
else
state="store missing — run 'intelligence sync'"
fi
- printf '%s %s locked:%s %s\n' \
+ alias_note=""
+ while IFS=$'\037' read -r a_name a_alias a_verdict; do
+ [ "$a_name" = "$name" ] || continue
+ case "$a_verdict" in
+ ok) alias_note=" alias:$a_alias" ;;
+ ambiguous) alias_note=" alias:$a_alias (another package declares it too)" ;;
+ *) alias_note=" alias:invalid — intelligence status --check" ;;
+ esac
+ done <<< "$aliases"
+ printf '%s %s locked:%s %s%s\n' \
"$name" \
"${ref:+ref:$ref}${ref:-${range:-*}}" \
"$(pin_label "$ref" "$resolved" "$sha")" \
- "$state"
+ "$state" \
+ "$alias_note"
done < <(qmap_keys "$manifest" "packages")
if [ "$count" -eq 0 ]; then
diff --git a/cli/internal/package-remove.sh b/cli/internal/package-remove.sh
index 93fad36..70b8fa7 100644
--- a/cli/internal/package-remove.sh
+++ b/cli/internal/package-remove.sh
@@ -39,7 +39,9 @@ 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"
+# Every entry naming the package, in every spelling, goes while `packages:`
+# still declares it and its alias: that is what resolves the references.
+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..b6b80a8 100644
--- a/cli/internal/package-update.sh
+++ b/cli/internal/package-update.sh
@@ -167,13 +167,21 @@ 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, in every spelling, 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
+ if [ "${#dropped[@]}" -gt 0 ]; then
+ unwire_package_sources "$manifest" "$name" "${dropped[@]}"
+ fi
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..d77dd1d 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
+# and aliases its list parser resolves `sources:` references 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 hands a package reference over as the store directory it
+ # names, so every spelling is checked here as that directory.
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..da0fdda 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 and aliases in
+# `packages:`, which resolve package references in `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 reference 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
@@ -439,7 +442,7 @@ sources_has_entry() {
sources_add_entry() {
local file="$1" section="$2" entry="$3" pos="${4:-last}" anchor="${5:-}"
sources_has_entry "$file" "$section" "$entry" && return 0
- _qmap_stage "$file" -v section="$section" -v entry="$entry" -v pos="$pos" -v anchor="$anchor" '
+ _qmap_stage "$file" -v section="$section" -v entry="$entry" -v pos="$pos" -v anchor="$anchor" "$IS_YAML_ITEM_AWK"'
function line() { return " - \"" entry "\"" }
function place() { print line(); done = 1 }
# Blank lines inside sources: are held back so an insert lands next to
@@ -447,13 +450,11 @@ sources_add_entry() {
# one section from the next.
function flush_tail( i) { for (i = 1; i <= ntail; i++) print tail[i]; blanks = ntail; ntail = 0 }
function open_section() { flush_tail(); print " " section ":"; place(); if (blanks) print "" }
+ # The entry as the engine reads it (yaml_item, engine/lib/common.sh).
function value(s, v) {
v = s
- sub(/^[ \t]*-[ \t]*/, "", v)
- gsub(/["\x27]/, "", v)
- sub(/[ \t]+#.*$/, "", v)
- sub(/[ \t]+$/, "", v)
- return v
+ sub(/^[ \t]*-/, "", v)
+ return yaml_item(v)
}
BEGIN { anchored = (pos == "before" || pos == "after") }
{ sub(/\r$/, "") }
@@ -509,24 +510,171 @@ sources_add_entry_first() {
}
# sources_remove_entry — remove `- "entry"` from
-# sources..
+# sources., matching each item as the engine reads it (yaml_item).
sources_remove_entry() {
local file="$1" section="$2" entry="$3"
[ -f "$file" ] || return 0
- _qmap_stage "$file" -v section="$section" -v entry="$entry" '
+ _qmap_stage "$file" -v section="$section" -v entry="$entry" "$IS_YAML_ITEM_AWK"'
{ sub(/\r$/, "") }
/^sources:[ \t]*$/ { ins = 1; print; next }
ins && /^[^ #]/ { ins = 0; insec = 0 }
ins && $0 ~ "^ " section ":[ \t]*$" { insec = 1; print; next }
ins && /^ [A-Za-z_]/ { insec = 0 }
- insec {
+ insec && /^[ \t]*-/ {
line = $0
- sub(/^[ \t]*-[ \t]*/, "", line)
- gsub(/["\x27]/, "", line)
- sub(/[ \t]+#.*$/, "", line)
- sub(/[ \t]+$/, "", line)
- if (line == entry) next
+ sub(/^[ \t]*-/, "", line)
+ if (yaml_item(line) == entry) next
}
{ print }
'
}
+
+# --- package references and aliases ------------------------------------------
+# `sources:` may name a package's directory by its store path, by the package's
+# full name or by the alias its `packages:` entry declares (decision 0019). The
+# engine's list parser (IS_PKG_REF_AWK in engine/lib/common.sh) is the only
+# definition of those spellings; everything below reads through it, so the CLI
+# never judges an entry differently from the way the engine renders it. The CLI
+# itself only ever writes store paths, and writes an alias only through
+# `package add --alias` and `package alias`.
+
+# assert_valid_alias — refuse an alias the engine would never resolve.
+assert_valid_alias() {
+ pkg_alias_valid "$1" \
+ || die "invalid alias '$1' — expected $IS_PKG_ALIAS_RULE"
+}
+
+# package_alias_of <@scope/name> — the alias that package declares, when it
+# is well formed; nothing otherwise (an invalid one resolves nothing).
+package_alias_of() {
+ local name alias verdict
+ while IFS=$'\037' read -r name alias verdict; do
+ [ "$name" = "$2" ] || continue
+ [ "$verdict" = invalid ] || printf '%s' "$alias"
+ return 0
+ done < <(read_package_aliases "$1")
+}
+
+# assert_alias_free <@scope/name> — refuse an alias another package
+# already declares: one alias names exactly one package.
+assert_alias_free() {
+ local file="$1" self="$2" want="$3" name alias verdict
+ while IFS=$'\037' read -r name alias verdict; do
+ [ "$name" != "$self" ] && [ "$alias" = "$want" ] || continue
+ die "alias '$want' already names $name — one alias names one package; choose another"
+ done < <(read_package_aliases "$file")
+}
+
+# sources_alias_users — every sources entry spelled :,
+# one `sources. ''` line each.
+sources_alias_users() {
+ local file="$1" alias="$2" section entry
+ for section in rules agents skills; do
+ while IFS= read -r entry; do
+ case "$entry" in
+ "$alias":*) printf "sources.%s '%s'\n" "$section" "$entry" ;;
+ esac
+ done < <(sources_list_entries "$file" "$section")
+ done
+}
+
+# assert_alias_unused <@scope/name> — refuse to
+# take away an alias `sources:` still spells entries with: they would stop
+# resolving, and the package's content would leave the outputs in silence.
+assert_alias_unused() {
+ local file="$1" name="$2" alias="$3" verb="$4" users
+ users="$(sources_alias_users "$file" "$alias")"
+ [ -n "$users" ] || return 0
+ echo "ERROR: cannot $verb alias '$alias' of $name — sources: still uses it:" >&2
+ printf '%s\n' "$users" | sed 's/^/ /' >&2
+ echo " Spell those entries $name/ instead, then $verb the alias." >&2
+ exit 1
+}
+
+# sources_classify_entry — `\037` for an entry
+# that is not in the manifest (yet): what the engine would render it as against
+# the packages the manifest declares. States are pkr_parse's.
+sources_classify_entry() {
+ local file="$1"
+ [ -f "$file" ] || file=/dev/null
+ IS_PKR_CANDIDATE="$2" awk "$IS_PKG_REF_AWK"'
+ { sub(/\r$/, ""); pkr_collect($0) }
+ END { dir = pkr_expand(ENVIRON["IS_PKR_CANDIDATE"]); print PKR_STATE "\037" dir }
+ ' "$file"
+}
+
+# sources_find_entry — print the entry of
+# sources., as written, that names the same directory as in any
+# spelling; fail when the section names it nowhere. An entry that renders
+# nothing matches only itself.
+sources_find_entry() {
+ local file="$1" section="$2" entry="$3" want _state raw _st dir _rest
+ [ -f "$file" ] || return 1
+ IFS=$'\037' read -r _state want <<< "$(sources_classify_entry "$file" "$entry")"
+ while IFS=$'\037' read -r raw _st dir _rest; do
+ if [ "$raw" = "$entry" ] || { [ -n "$want" ] && [ "$dir" = "$want" ]; }; then
+ printf '%s' "$raw"
+ return 0
+ fi
+ done < <(read_source_entries "$file" "$section")
+ return 1
+}
+
+# package_source_listed — true when sources.
+# already names , in whichever spelling.
+package_source_listed() {
+ local _raw _st dir _rest
+ [ -f "$1" ] || return 1
+ while IFS=$'\037' read -r _raw _st dir _rest; do
+ [ "$dir" = "$3" ] && return 0
+ done < <(read_source_entries "$1" "$2")
+ return 1
+}
+
+# sources_remove_package <@scope/name> [section...] — drop the package's
+# entries from sources:, in every spelling. With sections, only the entry of
+# sources. that names the package's own directory goes;
+# without, every rules/agents/skills entry naming a directory inside the
+# package. The references resolve through `packages:`, so call it while the
+# package and its alias are still declared. Every other line survives byte for
+# byte, and an unchanged file is not rewritten.
+sources_remove_package() {
+ local file="$1" name="$2" tmp rc=0
+ shift 2
+ [ -f "$file" ] || return 0
+ tmp="$file.cli.tmp"
+ awk -v name="$name" -v only=" $* " "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK"'
+ BEGIN { store = ".intelligence/packages/" name }
+ FNR == 1 { pass++ }
+ pass == 1 { sub(/\r$/, ""); pkr_collect($0); next }
+ { 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]*-/, "", v)
+ dir = pkr_expand(yaml_item(v))
+ if (only == " ") drop = dir == store || index(dir, store "/") == 1
+ else drop = index(only, " " sec " ") && dir == store "/" sec
+ if (drop) { 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 edit produced an empty file for $file"; }
+ mv "$tmp" "$file"
+ ;;
+ 10) rm -f "$tmp" ;;
+ *) rm -f "$tmp"; die "internal: sources edit refused (awk exit $rc) for $file" ;;
+ esac
+}
diff --git a/cli/lib/registry.sh b/cli/lib/registry.sh
index 3ffa6bc..e068b9f 100644
--- a/cli/lib/registry.sh
+++ b/cli/lib/registry.sh
@@ -200,25 +200,30 @@ fetch_package() {
printf '%s' "$sha"
}
-# wire_package_sources <@scope/name>
+# wire_package_sources <@scope/name>
# 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 sources entry — its store path, inserted FIRST in its
+# section, so project-owned entries come later and win on same-named artifacts.
+# A section that already names that directory, in any spelling, keeps the entry
+# where it stands: the person who wrote `@scope/name/` or `:`
+# chose both the spelling and the position. Call it once `packages:` declares
+# the package and its alias, which is what resolves those spellings.
wire_package_sources() {
- local manifest="$1" name="$2" rel="$3" root="$4"
+ local manifest="$1" rel="$3" root="$4"
local section
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
+ package_source_listed "$manifest" "$section" "$rel/$section" && continue
+ sources_add_entry_first "$manifest" "$section" "$rel/$section"
done
}
-# unwire_package_sources
+# unwire_package_sources <@scope/name> [section...]
+# Drop the package's sources entries in every spelling (sources_remove_package):
+# every one without sections, as `package remove` does; only the named
+# sections' own directories with them, as `update` does for the directories a
+# new version no longer has. Call it while `packages:` still declares the
+# package and its alias.
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
+ sources_remove_package "$@"
}
diff --git a/cli/tests/e2e-packages.sh b/cli/tests/e2e-packages.sh
index b8e4be0..935d424 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