@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.
- package/cli/commands/init.sh +6 -1
- package/cli/commands/package.sh +8 -3
- package/cli/commands/registry.sh +1 -1
- package/cli/commands/source.sh +56 -17
- package/cli/commands/sync.sh +78 -6
- package/cli/intelligence +16 -3
- package/cli/internal/check.sh +64 -13
- package/cli/internal/convert-legacy.sh +1 -1
- package/cli/internal/package-add.sh +25 -4
- package/cli/internal/package-alias.sh +51 -0
- package/cli/internal/package-list.sh +16 -2
- package/cli/internal/package-remove.sh +3 -1
- package/cli/internal/package-search.sh +1 -1
- package/cli/internal/package-update.sh +14 -6
- package/cli/internal/restore.sh +3 -2
- package/cli/lib/adapter-lifecycle.sh +5 -2
- package/cli/lib/cli-common.sh +149 -34
- package/cli/lib/gitignore.sh +128 -34
- package/cli/lib/lockfile.sh +14 -4
- package/cli/lib/manifest.sh +315 -21
- package/cli/lib/qmap.awk +97 -32
- package/cli/lib/registry.sh +19 -14
- package/cli/lib/sync-cache.sh +596 -142
- package/cli/lib/sync-lock.sh +19 -2
- package/engine/ENGINE_SHA +1 -1
- package/engine/VERSION +1 -1
- package/engine/adapters/_template.sh +6 -2
- package/engine/adapters/agents.sh +8 -2
- package/engine/adapters/antigravity.sh +3 -1
- package/engine/adapters/claude.sh +29 -12
- package/engine/adapters/codex.sh +11 -13
- package/engine/adapters/copilot.sh +54 -4
- package/engine/adapters/cursor.sh +13 -2
- package/engine/adapters/opencode.sh +7 -1
- package/engine/adapters/pi.sh +3 -1
- package/engine/lib/adapter-contract.sh +51 -8
- package/engine/lib/common.sh +1072 -160
- package/engine/lib/contract.sh +5 -0
- package/engine/sync.sh +128 -28
- package/package.json +1 -1
- package/packages/sync/references/adapters.md +44 -9
- package/packages/sync/references/conventions.md +48 -8
- package/packages/sync/references/onboarding-migration.md +3 -3
- package/packages/sync/skills/intelligence-learn-from-repository/SKILL.md +3 -2
- package/packages/sync/skills/intelligence-review-context/SKILL.md +2 -1
- package/packages/sync/skills/intelligence-update-context/SKILL.md +4 -2
- package/packages/sync/skills/intelligence-update-context/references/agents.md +7 -3
- package/packages/sync/skills/intelligence-update-context/references/skills.md +3 -1
package/cli/lib/gitignore.sh
CHANGED
|
@@ -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
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
99
|
+
local key i mi n hdr keep final_newline
|
|
92
100
|
[ -f "$file" ] || return 0
|
|
93
101
|
|
|
94
|
-
|
|
95
|
-
|
|
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)
|
|
245
|
-
include)
|
|
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
|
package/cli/lib/lockfile.sh
CHANGED
|
@@ -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
|
-
|
|
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" ]
|
|
70
|
-
|
|
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"
|
package/cli/lib/manifest.sh
CHANGED
|
@@ -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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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 "$
|
|
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
|
|
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
|
-
|
|
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]
|
|
453
|
-
|
|
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]
|
|
525
|
-
|
|
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
|
+
}
|