@ainova-systems/intelligence 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/cli/commands/init.sh +6 -1
  2. package/cli/commands/package.sh +8 -3
  3. package/cli/commands/registry.sh +1 -1
  4. package/cli/commands/source.sh +56 -17
  5. package/cli/commands/sync.sh +78 -6
  6. package/cli/intelligence +16 -3
  7. package/cli/internal/check.sh +64 -13
  8. package/cli/internal/convert-legacy.sh +1 -1
  9. package/cli/internal/package-add.sh +25 -4
  10. package/cli/internal/package-alias.sh +51 -0
  11. package/cli/internal/package-list.sh +16 -2
  12. package/cli/internal/package-remove.sh +3 -1
  13. package/cli/internal/package-search.sh +1 -1
  14. package/cli/internal/package-update.sh +14 -6
  15. package/cli/internal/restore.sh +3 -2
  16. package/cli/lib/adapter-lifecycle.sh +5 -2
  17. package/cli/lib/cli-common.sh +149 -34
  18. package/cli/lib/gitignore.sh +128 -34
  19. package/cli/lib/lockfile.sh +14 -4
  20. package/cli/lib/manifest.sh +315 -21
  21. package/cli/lib/qmap.awk +97 -32
  22. package/cli/lib/registry.sh +19 -14
  23. package/cli/lib/sync-cache.sh +596 -142
  24. package/cli/lib/sync-lock.sh +19 -2
  25. package/engine/ENGINE_SHA +1 -1
  26. package/engine/VERSION +1 -1
  27. package/engine/adapters/_template.sh +6 -2
  28. package/engine/adapters/agents.sh +8 -2
  29. package/engine/adapters/antigravity.sh +3 -1
  30. package/engine/adapters/claude.sh +29 -12
  31. package/engine/adapters/codex.sh +11 -13
  32. package/engine/adapters/copilot.sh +54 -4
  33. package/engine/adapters/cursor.sh +13 -2
  34. package/engine/adapters/opencode.sh +7 -1
  35. package/engine/adapters/pi.sh +3 -1
  36. package/engine/lib/adapter-contract.sh +51 -8
  37. package/engine/lib/common.sh +1072 -160
  38. package/engine/lib/contract.sh +5 -0
  39. package/engine/sync.sh +128 -28
  40. package/package.json +1 -1
  41. package/packages/sync/references/adapters.md +44 -9
  42. package/packages/sync/references/conventions.md +48 -8
  43. package/packages/sync/references/onboarding-migration.md +3 -3
  44. package/packages/sync/skills/intelligence-learn-from-repository/SKILL.md +3 -2
  45. package/packages/sync/skills/intelligence-review-context/SKILL.md +2 -1
  46. package/packages/sync/skills/intelligence-update-context/SKILL.md +4 -2
  47. package/packages/sync/skills/intelligence-update-context/references/agents.md +7 -3
  48. package/packages/sync/skills/intelligence-update-context/references/skills.md +3 -1
@@ -13,12 +13,13 @@ IS_CR=$'\r'
13
13
  IS_LF=$'\n'
14
14
  IS_CRLF=$'\r\n'
15
15
 
16
- # An ignore file checked out on Windows can hold CRLF, and a CR belongs to the
17
- # pattern Git matches — so these two helpers decide presence ignoring it, and
18
- # append in whatever ending the file already uses. Comparing with the CR
19
- # attached made every existing line look absent on Linux and macOS (where it is
20
- # part of the line, unlike under MSYS), so each alignment appended an LF copy of
21
- # a line that was already there and the file grew forever.
16
+ # An ignore file checked out on Windows can hold CRLF. Git drops one trailing
17
+ # CR from each line before matching, so `foo` and `foo<CR>` are the same rule:
18
+ # these helpers decide presence ignoring that CR, and append in whatever ending
19
+ # the file already uses. Comparing with the CR attached made every existing
20
+ # line look absent on Linux and macOS (where it is part of the line, unlike
21
+ # under MSYS), so each alignment appended an LF copy of a line that was already
22
+ # there and the file grew forever.
22
23
 
23
24
  # ignore_file_eol_var <file> — set IS_IGNORE_EOL from the file's first line.
24
25
  ignore_file_eol_var() {
@@ -32,12 +33,19 @@ ignore_file_eol_var() {
32
33
  }
33
34
 
34
35
  # ignore_file_has_line <file> <line> — true when the file lists exactly this
35
- # line, with or without a trailing CR.
36
+ # line, with or without a trailing CR. The one presence test for an ignore
37
+ # file: the writer and `status --check` both ask it, so a line one appends is a
38
+ # line the other reports present. Decided in bash, because an exact-line grep
39
+ # answers per platform — MSYS grep never sees the CR, Linux and macOS grep keep
40
+ # it as part of the line.
36
41
  ignore_file_has_line() {
37
- local file="$1" line="$2"
42
+ local file="$1" line="$2" entry=""
38
43
  [ -f "$file" ] || return 1
39
- grep -Fqx -- "$line" "$file" 2>/dev/null && return 0
40
- grep -Fqx -- "$line$IS_CR" "$file" 2>/dev/null
44
+ while IFS= read -r entry || [ -n "$entry" ]; do
45
+ [ "${entry%"$IS_CR"}" = "$line" ] && return 0
46
+ entry=""
47
+ done < "$file"
48
+ return 1
41
49
  }
42
50
 
43
51
  # ignore_file_append_line <file> <line> — append in the file's own ending.
@@ -88,29 +96,17 @@ gitignore_collapse_duplicates() {
88
96
  local root="$1" probe="$2"; shift 2
89
97
  local file="$root/.gitignore" tmp backup before=0 after=0
90
98
  local -a lines=() managed=("$@") last_idx=()
91
- local line key i mi n hdr=-1 keep final_newline=1
99
+ local key i mi n hdr keep final_newline
92
100
  [ -f "$file" ] || return 0
93
101
 
94
- # Read and write the file's own bytes, without awk: on Windows it reads in
95
- # text mode and would hand back every line stripped of its CR, rewriting a
96
- # CRLF file as LF. A line's CR is significant to Git, and either way the
97
- # lines this function does not remove must survive byte-for-byte.
98
- line=""
99
- while IFS= read -r line; do
100
- lines[${#lines[@]}]="$line"
101
- line=""
102
- done < "$file"
103
- # `read` returns 1 at EOF but still fills `line` when the file's last line
104
- # carries no newline; preserve that ending instead of adding one.
105
- if [ -n "$line" ]; then
106
- lines[${#lines[@]}]="$line"
107
- final_newline=0
108
- fi
109
- n=${#lines[@]}
110
- for ((i = 0; i < n; i++)); do
111
- if [ "${lines[i]%"$IS_CR"}" = "$IS_GITIGNORE_HEADER" ]; then hdr=$i; break; fi
112
- done
102
+ gitignore_read_var "$file"
103
+ hdr=$IS_GI_HEADER
113
104
  [ "$hdr" -ge 0 ] || return 0
105
+ # A header was found, so the array is not empty — Bash 3.2 under `set -u`
106
+ # cannot expand an empty one.
107
+ lines=("${IS_GI_LINES[@]}")
108
+ final_newline=$IS_GI_FINAL_NEWLINE
109
+ n=${#lines[@]}
114
110
 
115
111
  # Where each managed line last occurs below the header. Indexed arrays and
116
112
  # arithmetic `for` only — Bash 3.2 has no associative arrays.
@@ -161,6 +157,97 @@ gitignore_collapse_duplicates() {
161
157
  rm -f "$backup"
162
158
  }
163
159
 
160
+ # gitignore_read_var <file> — load the file's lines as stored, CRs included, into
161
+ # IS_GI_LINES. IS_GI_HEADER is the index of this policy's header (-1 when the
162
+ # file has none) and IS_GI_FINAL_NEWLINE is 0 when the last line ends without
163
+ # one. Every writer that rewrites .gitignore line by line reads it here.
164
+ #
165
+ # `read`, never awk: on Windows awk reads in text mode and would hand back every
166
+ # line stripped of its CR, rewriting a CRLF file as LF. The lines a writer does
167
+ # not remove must survive byte-for-byte, whatever their ending.
168
+ gitignore_read_var() {
169
+ local file="$1" line i
170
+ IS_GI_LINES=()
171
+ IS_GI_HEADER=-1
172
+ IS_GI_FINAL_NEWLINE=1
173
+ line=""
174
+ while IFS= read -r line; do
175
+ IS_GI_LINES[${#IS_GI_LINES[@]}]="$line"
176
+ line=""
177
+ done < "$file"
178
+ # `read` returns 1 at EOF but still fills `line` when the file's last line
179
+ # carries no newline; preserve that ending instead of adding one.
180
+ if [ -n "$line" ]; then
181
+ IS_GI_LINES[${#IS_GI_LINES[@]}]="$line"
182
+ IS_GI_FINAL_NEWLINE=0
183
+ fi
184
+ for ((i = 0; i < ${#IS_GI_LINES[@]}; i++)); do
185
+ if [ "${IS_GI_LINES[i]%"$IS_CR"}" = "$IS_GITIGNORE_HEADER" ]; then
186
+ IS_GI_HEADER=$i
187
+ break
188
+ fi
189
+ done
190
+ }
191
+
192
+ # gitignore_managed_has_line <root> <line> — true when the region this policy
193
+ # owns (its header to end of file) lists the line, with or without a CR.
194
+ gitignore_managed_has_line() {
195
+ local file="$1/.gitignore" want="$2" i
196
+ [ -f "$file" ] || return 1
197
+ gitignore_read_var "$file"
198
+ [ "$IS_GI_HEADER" -ge 0 ] || return 1
199
+ for ((i = IS_GI_HEADER + 1; i < ${#IS_GI_LINES[@]}; i++)); do
200
+ if [ "${IS_GI_LINES[i]%"$IS_CR"}" = "$want" ]; then
201
+ return 0
202
+ fi
203
+ done
204
+ return 1
205
+ }
206
+
207
+ # gitignore_remove_managed_lines <root> <line…> — take every copy of these lines
208
+ # out of the region this policy owns. A contract names them with `unignore` when
209
+ # the project's configuration keeps tracked what the default policy ignores
210
+ # (targets.copilot.commit_output), so the ignore an earlier alignment wrote is
211
+ # withdrawn by the writer that wrote it, not by a hand edit. Lines above the
212
+ # header predate Intelligence and stay — a hand-written copy of the same
213
+ # pattern included — and every kept line keeps its bytes and its own ending:
214
+ # only the file's last line can lack a newline, and none is added to it.
215
+ gitignore_remove_managed_lines() {
216
+ local root="$1"; shift
217
+ local file="$root/.gitignore" tmp key i mi n dropped=0
218
+ local -a remove=("$@") keep=()
219
+ [ "$#" -gt 0 ] && [ -f "$file" ] || return 0
220
+ gitignore_read_var "$file"
221
+ [ "$IS_GI_HEADER" -ge 0 ] || return 0
222
+ n=${#IS_GI_LINES[@]}
223
+ for ((i = 0; i < n; i++)); do
224
+ keep[i]=1
225
+ [ "$i" -gt "$IS_GI_HEADER" ] || continue
226
+ key="${IS_GI_LINES[i]%"$IS_CR"}"
227
+ for ((mi = 0; mi < ${#remove[@]}; mi++)); do
228
+ if [ "$key" = "${remove[mi]}" ]; then
229
+ keep[i]=0
230
+ dropped=1
231
+ break
232
+ fi
233
+ done
234
+ done
235
+ [ "$dropped" -eq 1 ] || return 0
236
+
237
+ tmp="$file.cli.tmp"
238
+ {
239
+ for ((i = 0; i < n; i++)); do
240
+ [ "${keep[i]}" -eq 1 ] || continue
241
+ if [ "$i" -eq $((n - 1)) ] && [ "$IS_GI_FINAL_NEWLINE" -eq 0 ]; then
242
+ printf '%s' "${IS_GI_LINES[i]}"
243
+ else
244
+ printf '%s\n' "${IS_GI_LINES[i]}"
245
+ fi
246
+ done
247
+ } > "$tmp"
248
+ mv -- "$tmp" "$file"
249
+ }
250
+
164
251
  # Git cannot re-include a child of an excluded directory, so an include needs an
165
252
  # explicit negation for every parent. A negation only wins when it comes after
166
253
  # the rule excluding the parent, and the only repair an append can make is to
@@ -233,18 +320,25 @@ ensure_base_gitignore() {
233
320
 
234
321
  ensure_target_gitignore() {
235
322
  local root="$1" manifest="$2" target="$3" content_dir output kind value records
323
+ local -a unignored=()
236
324
  ensure_gitignore_header "$root"
237
325
  content_dir="$(manifest_intelligence_dir "$manifest")"
238
326
  output="$(get_target_output "$manifest" "$target")"
239
327
  [ -n "$output" ] || output="$(default_target_output "$target")"
240
- records="$(adapter_records_for "$root" "$content_dir" "$target" "$output")" \
328
+ records="$(adapter_records_for "$root" "$content_dir" "$target" "$output" "$manifest")" \
241
329
  || die "adapter '$target' has an invalid ownership contract"
242
330
  while IFS=$'\t' read -r kind value; do
243
331
  case "$kind" in
244
- ignore) gitignore_add_line "$root" "$value" ;;
245
- include) gitignore_add_effective_include "$root" "$value" ;;
332
+ ignore) gitignore_add_line "$root" "$value" ;;
333
+ include) gitignore_add_effective_include "$root" "$value" ;;
334
+ unignore) unignored+=("$value") ;;
246
335
  esac
247
336
  done <<< "$records"
337
+ # One rewrite for all of them; Bash 3.2 cannot expand an empty array under
338
+ # `set -u`, hence the count guard.
339
+ if [ "${#unignored[@]}" -gt 0 ]; then
340
+ gitignore_remove_managed_lines "$root" "${unignored[@]}"
341
+ fi
248
342
  }
249
343
 
250
344
  ensure_manifest_gitignore() {
@@ -331,7 +425,7 @@ managed_gitignore_patterns() {
331
425
  [ "$(is_target_enabled "$manifest" "$target")" = "1" ] || continue
332
426
  output="$(get_target_output "$manifest" "$target")"
333
427
  [ -n "$output" ] || output="$(default_target_output "$target")"
334
- records="$(adapter_records_for "$root" "$content_dir" "$target" "$output")" \
428
+ records="$(adapter_records_for "$root" "$content_dir" "$target" "$output" "$manifest")" \
335
429
  || return 1
336
430
  while IFS=$'\t' read -r kind value; do
337
431
  case "$kind" in
@@ -13,6 +13,12 @@ LOCK_SEP=$'\x1f'
13
13
  # lock_validate <lock> [--metadata|--restore] [manifest] — read-only preflight. A subshell
14
14
  # contains the fail-fast input guards so status can report a failed verdict too.
15
15
  lock_validate() (
16
+ lock_validate_checks "$@"
17
+ )
18
+
19
+ # lock_validate_checks — lock_validate without its subshell, for a caller that
20
+ # already runs it in one (a capturing `$(...)`): the guards exit through `die`.
21
+ lock_validate_checks() {
16
22
  local lock="$1" mode="${2---metadata}" manifest="${3:-}" count=0
17
23
  local kind name requested url path resolved sha line rows current
18
24
  local -x CLI_INPUT_CONTEXT=""
@@ -23,7 +29,8 @@ lock_validate() (
23
29
  esac
24
30
  # The same tokenizer supplies ordinary lock rows and strict metadata. Buffer
25
31
  # before consumption: a failed parser must never yield a partial package set.
26
- rows="$(_qmap_read lock "$lock" packages '' '' "$LOCKFILE_VERSION")" || return $?
32
+ _qmap_read_var lock "$lock" packages '' '' "$LOCKFILE_VERSION" || return $?
33
+ rows="$IS_QMAP_OUT"
27
34
  bundled_engine_version_var
28
35
  current="$IS_BUNDLED_ENGINE_VERSION"
29
36
  while IFS="$LOCK_SEP" read -r kind name requested url path resolved sha line; do
@@ -66,10 +73,12 @@ lock_validate() (
66
73
  [[ "$sha" =~ ^[0-9a-f]{40}$ || "$sha" =~ ^[0-9a-f]{64}$ ]] \
67
74
  || die "locked restore: $name has a missing or invalid commit SHA — restore a valid intelligence.lock"
68
75
  done <<< "$rows"
69
- if [ "$count" -eq 0 ] && [ -n "$manifest" ] && [ -n "$(qmap_keys "$manifest" packages)" ]; then
70
- die "$lock: packages is empty but the manifest declares packages — restore their recorded identities"
76
+ if [ "$count" -eq 0 ] && [ -n "$manifest" ]; then
77
+ _qmap_read_var keys "$manifest" packages
78
+ [ -z "$IS_QMAP_OUT" ] \
79
+ || die "$lock: packages is empty but the manifest declares packages — restore their recorded identities"
71
80
  fi
72
- )
81
+ }
73
82
 
74
83
  # top_scalar <file> <key> — one top-level scalar through the shared tokenizer.
75
84
  top_scalar() {
@@ -86,6 +95,7 @@ lock_to_tsv() {
86
95
  lock_write_from_tsv() {
87
96
  local lock="$1" tsv="$2"
88
97
  local tmp="$lock.cli.tmp"
98
+ qmap_memo_reset
89
99
  {
90
100
  echo "# Generated by the intelligence CLI. Do not edit."
91
101
  echo "lockfile_version: $LOCKFILE_VERSION"
@@ -2,9 +2,10 @@
2
2
  # The CLI-owned YAML shapes: quoted-key maps (`packages:`, `registries:` in
3
3
  # the manifest, `packages:` in the lock and in registry indexes).
4
4
  #
5
- # The engine deliberately never reads these blocks, and its readers cannot
6
- # hold `@scope/name` keys — so this is the CLI's single, deliberate parser,
7
- # scoped to exactly one shape:
5
+ # The engine reads none of these blocks beyond the names and aliases in
6
+ # `packages:`, which resolve package references in `sources:`
7
+ # (engine/lib/common.sh), and its list readers cannot hold `@scope/name` keys —
8
+ # so this is the CLI's single, deliberate parser, scoped to exactly one shape:
8
9
  #
9
10
  # block:
10
11
  # "@scope/name": # 2-space indent, key always quoted
@@ -155,10 +156,20 @@ target_set_enabled() {
155
156
  '
156
157
  }
157
158
 
159
+ _QMAP_AWK="${BASH_SOURCE[0]%/*}/qmap.awk"
160
+
158
161
  # _qmap_read <mode> <file> <block> [key] [field] [expected-lock-version]
159
162
  # All scalar reads and strict lock records share one tokenizer. Environment
160
163
  # transport preserves literal backslashes that awk -v would interpret again.
161
164
  _qmap_read() {
165
+ if _qmap_memo_find "$@"; then
166
+ _qmap_memo_replay
167
+ return
168
+ fi
169
+ _qmap_read_file "$@"
170
+ }
171
+
172
+ _qmap_read_file() {
162
173
  local mode="$1" file="$2"
163
174
  if [ ! -f "$file" ] || [ ! -r "$file" ]; then
164
175
  case "$mode" in
@@ -168,7 +179,140 @@ _qmap_read() {
168
179
  fi
169
180
  QMAP_MODE="$mode" QMAP_FILE="$file" QMAP_BLOCK="${3:-}" \
170
181
  QMAP_KEY="${4:-}" QMAP_FIELD="${5:-}" QMAP_EXPECTED_VERSION="${6:-}" \
171
- LC_ALL=C awk -f "${BASH_SOURCE[0]%/*}/qmap.awk" < "$file"
182
+ LC_ALL=C awk -f "$_QMAP_AWK" < "$file"
183
+ }
184
+
185
+ # _qmap_read_var <mode> <file> <block> [key] [field] [expected-lock-version]
186
+ # — _qmap_read into IS_QMAP_OUT, as `$(_qmap_read ...)` would capture it, and
187
+ # with its status. A preloaded answer costs no process: on Git Bash every
188
+ # `$(...)` forks, which is what made a lifecycle preflight take half a second.
189
+ # shellcheck disable=SC2034 # IS_QMAP_OUT is the return channel
190
+ _qmap_read_var() {
191
+ if _qmap_memo_find "$@"; then
192
+ IS_QMAP_OUT="${QMAP_MEMO_OUTS[$IS_QMAP_MEMO_AT]}"
193
+ while [[ "$IS_QMAP_OUT" == *$'\n' ]]; do IS_QMAP_OUT="${IS_QMAP_OUT%$'\n'}"; done
194
+ if [ "${QMAP_MEMO_RCS[$IS_QMAP_MEMO_AT]}" -ne 0 ]; then
195
+ printf '%s' "${QMAP_MEMO_OUTS[$IS_QMAP_MEMO_AT]}" >&2
196
+ IS_QMAP_OUT=""
197
+ fi
198
+ return "${QMAP_MEMO_RCS[$IS_QMAP_MEMO_AT]}"
199
+ fi
200
+ IS_QMAP_OUT="$(_qmap_read_file "$@")"
201
+ }
202
+
203
+ # --- Reads answered once per process ------------------------------------
204
+ # qmap_preload runs one awk over several requests and remembers each answer —
205
+ # output or strict error, and status — under the exact request it answers.
206
+ # Only a command that holds the project lock may preload, and anything that
207
+ # writes a qmap document forgets every answer (qmap_memo_reset): a remembered
208
+ # read must never outlive the bytes it was read from.
209
+ QMAP_MEMO_KEYS=()
210
+ QMAP_MEMO_RCS=()
211
+ QMAP_MEMO_OUTS=()
212
+
213
+ qmap_memo_reset() {
214
+ QMAP_MEMO_KEYS=()
215
+ QMAP_MEMO_RCS=()
216
+ QMAP_MEMO_OUTS=()
217
+ if declare -F manifest_view_reset >/dev/null; then manifest_view_reset; fi
218
+ }
219
+
220
+ # _qmap_memo_find <request...> — set IS_QMAP_MEMO_AT to the remembered answer.
221
+ _qmap_memo_find() {
222
+ local request="${1:-}"$'\037'"${2:-}"$'\037'"${3:-}"$'\037'"${4:-}"$'\037'"${5:-}"$'\037'"${6:-}" i=0
223
+ while [ "$i" -lt "${#QMAP_MEMO_KEYS[@]}" ]; do
224
+ if [ "${QMAP_MEMO_KEYS[$i]}" = "$request" ]; then
225
+ IS_QMAP_MEMO_AT="$i"
226
+ return 0
227
+ fi
228
+ i=$((i + 1))
229
+ done
230
+ return 1
231
+ }
232
+
233
+ # Print a remembered answer exactly as the awk reader printed it.
234
+ _qmap_memo_replay() {
235
+ if [ "${QMAP_MEMO_RCS[$IS_QMAP_MEMO_AT]}" -ne 0 ]; then
236
+ printf '%s' "${QMAP_MEMO_OUTS[$IS_QMAP_MEMO_AT]}" >&2
237
+ return "${QMAP_MEMO_RCS[$IS_QMAP_MEMO_AT]}"
238
+ fi
239
+ printf '%s' "${QMAP_MEMO_OUTS[$IS_QMAP_MEMO_AT]}"
240
+ }
241
+
242
+ # qmap_preload <request>... — each request is one string of _qmap_read's six
243
+ # arguments, unit-separated. Requests for absent or empty documents are left
244
+ # to the ordinary reader, which reports them exactly as before. A preload that
245
+ # cannot run leaves nothing remembered; reads then take the ordinary path.
246
+ # A caller that wants the awk pass to overlap other work uses the three steps
247
+ # itself: qmap_preload_plan, qmap_preload_run (prints), qmap_preload_store.
248
+ qmap_preload() {
249
+ local out
250
+ qmap_preload_plan "$@" || return 0
251
+ out="$(qmap_preload_run)" || return 0
252
+ qmap_preload_store "$out"
253
+ }
254
+
255
+ # qmap_preload_plan <request>... — QMAP_PLAN_REQUESTS, _FILES and _JOBS for the
256
+ # requests one awk pass can answer; returns 1 when there are none.
257
+ qmap_preload_plan() {
258
+ local request saved_ifs="$IFS"
259
+ local -a fields=()
260
+ QMAP_PLAN_REQUESTS=() QMAP_PLAN_FILES=() QMAP_PLAN_JOBS=""
261
+ for request in "$@"; do
262
+ IFS=$'\037'
263
+ set -f
264
+ # shellcheck disable=SC2206 # splitting on the unit separator is the point
265
+ fields=($request)
266
+ set +f
267
+ IFS="$saved_ifs"
268
+ case "${fields[1]:-}" in /*) ;; *) continue ;; esac
269
+ [ -f "${fields[1]}" ] && [ -r "${fields[1]}" ] && [ -s "${fields[1]}" ] || continue
270
+ QMAP_PLAN_FILES+=("${fields[1]}")
271
+ QMAP_PLAN_REQUESTS+=("$request")
272
+ QMAP_PLAN_JOBS="$QMAP_PLAN_JOBS${fields[0]:-}"$'\037'"${fields[2]:-}"$'\037'"${fields[3]:-}"$'\037'"${fields[4]:-}"$'\037'"${fields[5]:-}"$'\n'
273
+ done
274
+ [ "${#QMAP_PLAN_FILES[@]}" -gt 0 ]
275
+ }
276
+
277
+ # qmap_preload_run — the planned pass; a trailing `end` proves it completed.
278
+ qmap_preload_run() {
279
+ QMAP_JOBS="${QMAP_PLAN_JOBS%$'\n'}" LC_ALL=C awk -f "$_QMAP_AWK" "${QMAP_PLAN_FILES[@]}" && echo end
280
+ }
281
+
282
+ # qmap_preload_store <output> — remember every planned answer, or nothing when
283
+ # the output is not exactly one complete answer per request.
284
+ qmap_preload_store() {
285
+ local header line status count raw i=0 saved_ifs="$IFS"
286
+ local -a lines=() rcs=() outs=()
287
+ IFS=$'\n'
288
+ set -f
289
+ # shellcheck disable=SC2206 # no line is empty: rows carry a ">" prefix
290
+ lines=($1)
291
+ set +f
292
+ IFS="$saved_ifs"
293
+ while [ "$i" -lt "${#lines[@]}" ]; do
294
+ header="${lines[i]}"
295
+ i=$((i + 1))
296
+ if [ "$header" = end ]; then
297
+ [ "$i" -eq "${#lines[@]}" ] && [ "${#rcs[@]}" -eq "${#QMAP_PLAN_REQUESTS[@]}" ] || return 0
298
+ QMAP_MEMO_KEYS+=("${QMAP_PLAN_REQUESTS[@]}")
299
+ QMAP_MEMO_RCS+=("${rcs[@]}")
300
+ QMAP_MEMO_OUTS+=("${outs[@]}")
301
+ return 0
302
+ fi
303
+ case "$header" in $'\036'[01]' '[0-9]*) ;; *) return 0 ;; esac
304
+ status="${header:1:1}" count="${header#* }"
305
+ raw=""
306
+ while [ "$count" -gt 0 ]; do
307
+ line="${lines[i]-}"
308
+ case "$line" in '>'*) ;; *) return 0 ;; esac
309
+ raw="$raw${line#>}"$'\n'
310
+ i=$((i + 1))
311
+ count=$((count - 1))
312
+ done
313
+ rcs+=("$status")
314
+ outs+=("$raw")
315
+ done
172
316
  }
173
317
 
174
318
  # qmap_validate_document <file> <block> — structural validation only.
@@ -190,6 +334,7 @@ qmap_value() { _qmap_read value "$1" "$2" "$3"; }
190
334
  _qmap_stage() {
191
335
  local file="$1"; shift
192
336
  local tmp="$file.cli.tmp" rc=0
337
+ qmap_memo_reset
193
338
  awk "$@" "$file" > "$tmp" || rc=$?
194
339
  [ "$rc" -eq 0 ] || { rm -f "$tmp"; die "internal: manifest edit refused (awk exit $rc) for $file"; }
195
340
  [ -s "$tmp" ] || { rm -f "$tmp"; die "internal: manifest edit produced an empty file for $file"; }
@@ -407,12 +552,14 @@ registries_remove() {
407
552
  # meaning: a later entry overrides a same-named artifact from an earlier one.
408
553
  # Reading goes through the engine's own list parser, so the CLI sees exactly
409
554
  # what the engine will render instead of a second reading of the same file.
555
+ # The editors read it raw — a package reference as written, which is what they
556
+ # compare and rewrite — and read_source_entries says what each one renders.
410
557
 
411
- # sources_list_entries <file> <section> — entries of sources.<section>, one per
412
- # line, in manifest order.
558
+ # sources_list_entries <file> <section> — entries of sources.<section> as
559
+ # written, one per line, in manifest order.
413
560
  sources_list_entries() {
414
561
  [ -f "$1" ] || return 0
415
- read_yaml_list "$1" "$2"
562
+ read_yaml_list_raw "$1" "$2"
416
563
  }
417
564
 
418
565
  # sources_has_entry <file> <section> <entry> — true (0) when that section lists
@@ -439,7 +586,7 @@ sources_has_entry() {
439
586
  sources_add_entry() {
440
587
  local file="$1" section="$2" entry="$3" pos="${4:-last}" anchor="${5:-}"
441
588
  sources_has_entry "$file" "$section" "$entry" && return 0
442
- _qmap_stage "$file" -v section="$section" -v entry="$entry" -v pos="$pos" -v anchor="$anchor" '
589
+ _qmap_stage "$file" -v section="$section" -v entry="$entry" -v pos="$pos" -v anchor="$anchor" "$IS_YAML_ITEM_AWK"'
443
590
  function line() { return " - \"" entry "\"" }
444
591
  function place() { print line(); done = 1 }
445
592
  # Blank lines inside sources: are held back so an insert lands next to
@@ -447,13 +594,11 @@ sources_add_entry() {
447
594
  # one section from the next.
448
595
  function flush_tail( i) { for (i = 1; i <= ntail; i++) print tail[i]; blanks = ntail; ntail = 0 }
449
596
  function open_section() { flush_tail(); print " " section ":"; place(); if (blanks) print "" }
597
+ # The entry as the engine reads it (yaml_item, engine/lib/common.sh).
450
598
  function value(s, v) {
451
599
  v = s
452
- sub(/^[ \t]*-[ \t]*/, "", v)
453
- gsub(/["\x27]/, "", v)
454
- sub(/[ \t]+#.*$/, "", v)
455
- sub(/[ \t]+$/, "", v)
456
- return v
600
+ sub(/^[ \t]*-/, "", v)
601
+ return yaml_item(v)
457
602
  }
458
603
  BEGIN { anchored = (pos == "before" || pos == "after") }
459
604
  { sub(/\r$/, "") }
@@ -509,24 +654,173 @@ sources_add_entry_first() {
509
654
  }
510
655
 
511
656
  # sources_remove_entry <file> <section> <entry> — remove `- "entry"` from
512
- # sources.<section>.
657
+ # sources.<section>, matching each item as the engine reads it (yaml_item).
513
658
  sources_remove_entry() {
514
659
  local file="$1" section="$2" entry="$3"
515
660
  [ -f "$file" ] || return 0
516
- _qmap_stage "$file" -v section="$section" -v entry="$entry" '
661
+ _qmap_stage "$file" -v section="$section" -v entry="$entry" "$IS_YAML_ITEM_AWK"'
517
662
  { sub(/\r$/, "") }
518
663
  /^sources:[ \t]*$/ { ins = 1; print; next }
519
664
  ins && /^[^ #]/ { ins = 0; insec = 0 }
520
665
  ins && $0 ~ "^ " section ":[ \t]*$" { insec = 1; print; next }
521
666
  ins && /^ [A-Za-z_]/ { insec = 0 }
522
- insec {
667
+ insec && /^[ \t]*-/ {
523
668
  line = $0
524
- sub(/^[ \t]*-[ \t]*/, "", line)
525
- gsub(/["\x27]/, "", line)
526
- sub(/[ \t]+#.*$/, "", line)
527
- sub(/[ \t]+$/, "", line)
528
- if (line == entry) next
669
+ sub(/^[ \t]*-/, "", line)
670
+ if (yaml_item(line) == entry) next
529
671
  }
530
672
  { print }
531
673
  '
532
674
  }
675
+
676
+ # --- package references and aliases ------------------------------------------
677
+ # `sources:` may name a package's directory by its store path, by the package's
678
+ # full name or by the alias its `packages:` entry declares (decision 0019). The
679
+ # engine's list parser (IS_PKG_REF_AWK in engine/lib/common.sh) is the only
680
+ # definition of those spellings; everything below reads through it, so the CLI
681
+ # never judges an entry differently from the way the engine renders it. The CLI
682
+ # itself only ever writes store paths, and writes an alias only through
683
+ # `package add --alias` and `package alias`.
684
+
685
+ # assert_valid_alias <alias> — refuse an alias the engine would never resolve.
686
+ assert_valid_alias() {
687
+ pkg_alias_valid "$1" \
688
+ || die "invalid alias '$1' — expected $IS_PKG_ALIAS_RULE"
689
+ }
690
+
691
+ # package_alias_of <file> <@scope/name> — the alias that package declares, when it
692
+ # is well formed; nothing otherwise (an invalid one resolves nothing).
693
+ package_alias_of() {
694
+ local name alias verdict
695
+ while IFS=$'\037' read -r name alias verdict; do
696
+ [ "$name" = "$2" ] || continue
697
+ [ "$verdict" = invalid ] || printf '%s' "$alias"
698
+ return 0
699
+ done < <(read_package_aliases "$1")
700
+ }
701
+
702
+ # assert_alias_free <file> <@scope/name> <alias> — refuse an alias another package
703
+ # already declares: one alias names exactly one package.
704
+ assert_alias_free() {
705
+ local file="$1" self="$2" want="$3" name alias verdict
706
+ while IFS=$'\037' read -r name alias verdict; do
707
+ [ "$name" != "$self" ] && [ "$alias" = "$want" ] || continue
708
+ die "alias '$want' already names $name — one alias names one package; choose another"
709
+ done < <(read_package_aliases "$file")
710
+ }
711
+
712
+ # sources_alias_users <file> <alias> — every sources entry spelled <alias>:<dir>,
713
+ # one `sources.<section> '<entry>'` line each.
714
+ sources_alias_users() {
715
+ local file="$1" alias="$2" section entry
716
+ for section in rules agents skills; do
717
+ while IFS= read -r entry; do
718
+ case "$entry" in
719
+ "$alias":*) printf "sources.%s '%s'\n" "$section" "$entry" ;;
720
+ esac
721
+ done < <(sources_list_entries "$file" "$section")
722
+ done
723
+ }
724
+
725
+ # assert_alias_unused <file> <@scope/name> <alias> <remove|replace> — refuse to
726
+ # take away an alias `sources:` still spells entries with: they would stop
727
+ # resolving, and the package's content would leave the outputs in silence.
728
+ assert_alias_unused() {
729
+ local file="$1" name="$2" alias="$3" verb="$4" users
730
+ users="$(sources_alias_users "$file" "$alias")"
731
+ [ -n "$users" ] || return 0
732
+ echo "ERROR: cannot $verb alias '$alias' of $name — sources: still uses it:" >&2
733
+ printf '%s\n' "$users" | sed 's/^/ /' >&2
734
+ echo " Spell those entries $name/<dir> instead, then $verb the alias." >&2
735
+ exit 1
736
+ }
737
+
738
+ # sources_classify_entry <file> <entry> — `<state>\037<directory>` for an entry
739
+ # that is not in the manifest (yet): what the engine would render it as against
740
+ # the packages the manifest declares. States are pkr_parse's.
741
+ sources_classify_entry() {
742
+ local file="$1"
743
+ [ -f "$file" ] || file=/dev/null
744
+ IS_PKR_CANDIDATE="$2" awk "$IS_PKG_REF_AWK"'
745
+ { sub(/\r$/, ""); pkr_collect($0) }
746
+ END { dir = pkr_expand(ENVIRON["IS_PKR_CANDIDATE"]); print PKR_STATE "\037" dir }
747
+ ' "$file"
748
+ }
749
+
750
+ # sources_find_entry <file> <section> <entry> — print the entry of
751
+ # sources.<section>, as written, that names the same directory as <entry> in any
752
+ # spelling; fail when the section names it nowhere. An entry that renders
753
+ # nothing matches only itself.
754
+ sources_find_entry() {
755
+ local file="$1" section="$2" entry="$3" want _state raw _st dir _rest
756
+ [ -f "$file" ] || return 1
757
+ IFS=$'\037' read -r _state want <<< "$(sources_classify_entry "$file" "$entry")"
758
+ while IFS=$'\037' read -r raw _st dir _rest; do
759
+ if [ "$raw" = "$entry" ] || { [ -n "$want" ] && [ "$dir" = "$want" ]; }; then
760
+ printf '%s' "$raw"
761
+ return 0
762
+ fi
763
+ done < <(read_source_entries "$file" "$section")
764
+ return 1
765
+ }
766
+
767
+ # package_source_listed <file> <section> <store-dir> — true when sources.<section>
768
+ # already names <store-dir>, in whichever spelling.
769
+ package_source_listed() {
770
+ local _raw _st dir _rest
771
+ [ -f "$1" ] || return 1
772
+ while IFS=$'\037' read -r _raw _st dir _rest; do
773
+ [ "$dir" = "$3" ] && return 0
774
+ done < <(read_source_entries "$1" "$2")
775
+ return 1
776
+ }
777
+
778
+ # sources_remove_package <file> <@scope/name> [section...] — drop the package's
779
+ # entries from sources:, in every spelling. With sections, only the entry of
780
+ # sources.<section> that names the package's own <section> directory goes;
781
+ # without, every rules/agents/skills entry naming a directory inside the
782
+ # package. The references resolve through `packages:`, so call it while the
783
+ # package and its alias are still declared. Every other line survives byte for
784
+ # byte, and an unchanged file is not rewritten.
785
+ sources_remove_package() {
786
+ local file="$1" name="$2" tmp rc=0
787
+ shift 2
788
+ [ -f "$file" ] || return 0
789
+ tmp="$file.cli.tmp"
790
+ # A write to a qmap document forgets every remembered read, as _qmap_stage does.
791
+ qmap_memo_reset
792
+ awk -v name="$name" -v only=" $* " "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK"'
793
+ BEGIN { store = ".intelligence/packages/" name }
794
+ FNR == 1 { pass++ }
795
+ pass == 1 { sub(/\r$/, ""); pkr_collect($0); next }
796
+ { sub(/\r$/, "") }
797
+ /^sources:[ \t]*$/ { ins = 1; print; next }
798
+ ins && /^[^ #]/ { ins = 0; sec = "" }
799
+ ins && /^ [A-Za-z_]/ {
800
+ sec = $0
801
+ sub(/^ /, "", sec)
802
+ sub(/:.*$/, "", sec)
803
+ if (sec != "rules" && sec != "agents" && sec != "skills") sec = ""
804
+ print
805
+ next
806
+ }
807
+ ins && sec != "" && /^[ \t]*-/ {
808
+ v = $0
809
+ sub(/^[ \t]*-/, "", v)
810
+ dir = pkr_expand(yaml_item(v))
811
+ if (only == " ") drop = dir == store || index(dir, store "/") == 1
812
+ else drop = index(only, " " sec " ") && dir == store "/" sec
813
+ if (drop) { changed = 1; next }
814
+ }
815
+ { print }
816
+ END { exit (changed ? 0 : 10) }
817
+ ' "$file" "$file" > "$tmp" || rc=$?
818
+ case "$rc" in
819
+ 0)
820
+ [ -s "$tmp" ] || { rm -f "$tmp"; die "internal: sources edit produced an empty file for $file"; }
821
+ mv "$tmp" "$file"
822
+ ;;
823
+ 10) rm -f "$tmp" ;;
824
+ *) rm -f "$tmp"; die "internal: sources edit refused (awk exit $rc) for $file" ;;
825
+ esac
826
+ }