Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<dir>` for a package `packages:` declares, and `<alias>:<dir>` 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> <alias>` and `package add --alias <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 `<dir>` 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

Expand All @@ -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

Expand Down
11 changes: 8 additions & 3 deletions cli/commands/package.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/bin/bash
# intelligence package <add|remove|list|search> ...
# intelligence package <add|remove|alias|list|search> ...
set -euo pipefail
source "$CLI_DIR/lib/cli-common.sh"

action="${1:-}"
[ -n "$action" ] || die "usage: intelligence package <add|remove|list|search> [args]"
[ -n "$action" ] || die "usage: intelligence package <add|remove|alias|list|search> [args]"
shift

case "$action" in
Expand All @@ -18,13 +18,18 @@ 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" "$@"
;;
search)
exec bash "$CLI_DIR/internal/package-search.sh" "$@"
;;
*)
die "usage: intelligence package <add|remove|list|search> [args]"
die "usage: intelligence package <add|remove|alias|list|search> [args]"
;;
esac
71 changes: 55 additions & 16 deletions cli/commands/source.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<dir>`, `<alias>:<dir>`, 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"

Expand All @@ -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 <manifest> <dir> <add|remove> — 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 <root> <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"
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion cli/intelligence
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Usage: intelligence <command> [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 <command> add | remove | list | search
package <command> add | remove | alias | list | search
source <command> add | remove | list — the sources: block (own directories)
adapter <command> list | create | enable | disable | remove
status [--check] Project state; --check runs deep consistency checks
Expand Down
54 changes: 47 additions & 7 deletions cli/internal/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alias>:<dir> 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 <alias>" ;;
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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
29 changes: 25 additions & 4 deletions cli/internal/package-add.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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+<url>[@ref][#path]>"
[ -n "$spec" ] || die "usage: intelligence package add <@scope/name[@range] | github:org/repo[#path] | git+<url>[@ref][#path]> [--name @scope/name] [--alias <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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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/<dir> or <alias>:<dir> 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"
Expand All @@ -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"
Expand Down
51 changes: 51 additions & 0 deletions cli/internal/package-alias.sh
Original file line number Diff line number Diff line change
@@ -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
# <alias>:<dir> (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> <alias>
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:<dir>"
Loading
Loading