@ainova-systems/intelligence 0.17.0 → 0.17.2

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.
@@ -0,0 +1,148 @@
1
+ #!/bin/bash
2
+ # One writer per project at a time. The engine clears and rewrites every
3
+ # adapter's output, the store restore and package operations replace package
4
+ # directories, and target and alignment edits rewrite the manifest. Two of these
5
+ # overlapping interleave their writes and both report success over a corrupted
6
+ # tree, so every script that writes shared project state holds this lock while it
7
+ # does: `project_lock_hold` at its start.
8
+ #
9
+ # `mkdir` is the lock: it creates or fails atomically on every filesystem the
10
+ # CLI supports. The owner file names the holder — pid, host, start time, process
11
+ # group and a token — so a refusal says who it is, a child of the holder can
12
+ # join instead of refusing, and a holder that died can be told from one whose
13
+ # writers still run. Taking over a dead holder's lock happens under a second
14
+ # lock, so two runs that judged the same lock stale cannot both take it.
15
+
16
+ SYNC_LOCK_HELD=""
17
+ SYNC_LOCK_TOKEN=""
18
+
19
+ sync_lock_now() {
20
+ if [ -n "${EPOCHSECONDS:-}" ]; then
21
+ IS_NOW="$EPOCHSECONDS"
22
+ else
23
+ IS_NOW="$(date +%s)"
24
+ fi
25
+ }
26
+
27
+ # sync_lock_pgid — set IS_PGID to this process's group, "" when unknown. The
28
+ # engine and its jobs inherit the group, so it outlives a killed wrapper for as
29
+ # long as any writer it started still runs.
30
+ sync_lock_pgid() {
31
+ local stat
32
+ local -a fields
33
+ IS_PGID=""
34
+ if [ -r "/proc/$$/stat" ] && IFS= read -r stat < "/proc/$$/stat"; then
35
+ # pid (comm) state ppid pgrp …: comm may hold spaces, so cut after it.
36
+ read -r -a fields <<< "${stat##*) }"
37
+ IS_PGID="${fields[2]:-}"
38
+ else
39
+ IS_PGID="$(ps -o pgid= -p "$$" 2>/dev/null || true)"
40
+ IS_PGID="${IS_PGID//[[:space:]]/}"
41
+ fi
42
+ case "$IS_PGID" in ''|*[!0-9]*|0|1) IS_PGID="" ;; esac
43
+ }
44
+
45
+ sync_lock_write_owner() {
46
+ sync_lock_now
47
+ sync_lock_pgid
48
+ SYNC_LOCK_TOKEN="$$.$IS_NOW.$RANDOM$RANDOM"
49
+ printf '%s %s %s %s %s\n' "$$" "${HOSTNAME:-unknown}" "$IS_NOW" "${IS_PGID:--}" "$SYNC_LOCK_TOKEN" > "$1/owner"
50
+ }
51
+
52
+ # sync_lock_read_owner <lock> — set IS_LOCK_PID / _HOST / _SINCE / _PGID /
53
+ # _TOKEN; returns 1 when the folder has no readable owner yet.
54
+ sync_lock_read_owner() {
55
+ IS_LOCK_PID="" IS_LOCK_HOST="" IS_LOCK_SINCE="" IS_LOCK_PGID="" IS_LOCK_TOKEN=""
56
+ [ -f "$1/owner" ] || return 1
57
+ read -r IS_LOCK_PID IS_LOCK_HOST IS_LOCK_SINCE IS_LOCK_PGID IS_LOCK_TOKEN < "$1/owner" \
58
+ || [ -n "$IS_LOCK_PID" ] || return 1
59
+ case "$IS_LOCK_PID" in ''|*[!0-9]*) return 1 ;; esac
60
+ case "$IS_LOCK_PGID" in ''|*[!0-9]*|0|1) IS_LOCK_PGID="" ;; esac
61
+ }
62
+
63
+ # sync_lock_holder_gone — true only when no process of the holder remains: its
64
+ # whole process group when the owner recorded one, else its pid. Only "No such
65
+ # process" proves that; a permission refusal is another user's live process,
66
+ # and any other answer keeps the lock.
67
+ sync_lock_holder_gone() {
68
+ local target="$IS_LOCK_PID" answer
69
+ [ -z "$IS_LOCK_PGID" ] || target="-$IS_LOCK_PGID"
70
+ answer="$(LC_ALL=C kill -0 -- "$target" 2>&1)" && return 1
71
+ case "$answer" in *"No such process"*) return 0 ;; esac
72
+ return 1
73
+ }
74
+
75
+ # sync_lock_is_stale <lock> — its holder can no longer write: gone from this
76
+ # machine, or a folder that never got an owner and is past a minute old. A
77
+ # holder on another machine (a shared filesystem) is never judged from here.
78
+ sync_lock_is_stale() {
79
+ if sync_lock_read_owner "$1"; then
80
+ [ "$IS_LOCK_HOST" = "${HOSTNAME:-unknown}" ] || return 1
81
+ sync_lock_holder_gone
82
+ return
83
+ fi
84
+ [ -d "$1" ] && [ -n "$(find "$1" -maxdepth 0 -mmin +1 2>/dev/null)" ]
85
+ }
86
+
87
+ # sync_lock_acquire <root> — take <root>/.intelligence/sync.lock or refuse.
88
+ sync_lock_acquire() {
89
+ local root="$1" lock="$1/.intelligence/sync.lock" took=0 age=""
90
+ mkdir -p "$root/.intelligence" || die "cannot create $root/.intelligence"
91
+ if mkdir "$lock" 2>/dev/null; then
92
+ took=1
93
+ elif mkdir "$lock.takeover" 2>/dev/null; then
94
+ # Judge again under the takeover lock: another run may have taken
95
+ # it over since this one looked.
96
+ if sync_lock_is_stale "$lock"; then
97
+ rm -rf "$lock"
98
+ mkdir "$lock" 2>/dev/null && took=1
99
+ fi
100
+ rmdir "$lock.takeover"
101
+ elif [ -n "$(find "$lock.takeover" -maxdepth 0 -mmin +1 2>/dev/null)" ]; then
102
+ # A takeover runs for milliseconds. Removing an abandoned one is itself
103
+ # a check-then-delete race, so it is left to a person.
104
+ die "a takeover of $lock was abandoned; if no intelligence command is running in this project, remove $lock.takeover"
105
+ fi
106
+ if [ "$took" = 1 ]; then
107
+ sync_lock_write_owner "$lock" || { rm -rf "$lock"; die "cannot write $lock/owner"; }
108
+ SYNC_LOCK_HELD="$lock"
109
+ return 0
110
+ fi
111
+ if sync_lock_read_owner "$lock"; then
112
+ sync_lock_now
113
+ case "$IS_LOCK_SINCE" in ''|*[!0-9]*) ;; *) age=", for $((IS_NOW - IS_LOCK_SINCE))s" ;; esac
114
+ die "another intelligence sync is running in this project (pid $IS_LOCK_PID on $IS_LOCK_HOST$age). Wait for it to finish; if no such process exists, remove $lock"
115
+ fi
116
+ die "another intelligence sync is starting in this project. Wait for it to finish; if none is running, remove $lock"
117
+ }
118
+
119
+ # project_lock_hold <root> — hold the project lock for the rest of this script.
120
+ # A script started by the holder joins it instead (INTELLIGENCE_SYNC_LOCK names
121
+ # the lock and its token): the holder releases it, unless `exec` made this
122
+ # process the holder, which then releases it on exit.
123
+ project_lock_hold() {
124
+ local root="$1" lock="$1/.intelligence/sync.lock" held="${INTELLIGENCE_SYNC_LOCK:-}"
125
+ if [ -n "$held" ] && [ "${held%%|*}" = "$lock" ] && sync_lock_read_owner "$lock" \
126
+ && [ -n "$IS_LOCK_TOKEN" ] && [ "$IS_LOCK_TOKEN" = "${held#*|}" ]; then
127
+ if [ "$IS_LOCK_PID" = "$$" ]; then
128
+ SYNC_LOCK_HELD="$lock"
129
+ SYNC_LOCK_TOKEN="$IS_LOCK_TOKEN"
130
+ trap 'sync_lock_release' EXIT
131
+ trap 'exit 130' INT TERM
132
+ fi
133
+ return 0
134
+ fi
135
+ sync_lock_acquire "$root"
136
+ export INTELLIGENCE_SYNC_LOCK="$lock|$SYNC_LOCK_TOKEN"
137
+ trap 'sync_lock_release' EXIT
138
+ trap 'exit 130' INT TERM
139
+ }
140
+
141
+ # sync_lock_release — drop the lock this process holds, and only that one.
142
+ sync_lock_release() {
143
+ [ -n "$SYNC_LOCK_HELD" ] || return 0
144
+ if sync_lock_read_owner "$SYNC_LOCK_HELD" && [ "$IS_LOCK_TOKEN" = "$SYNC_LOCK_TOKEN" ]; then
145
+ rm -rf "$SYNC_LOCK_HELD"
146
+ fi
147
+ SYNC_LOCK_HELD=""
148
+ }
package/engine/ENGINE_SHA CHANGED
@@ -1 +1 @@
1
- f23e407da080869b07481730d81b6ebce73a1d2e
1
+ 97484ac7ccc2e093642b833edfa8cae7bfe47c9e
package/engine/VERSION CHANGED
@@ -1 +1 @@
1
- 0.17.0
1
+ 0.17.2
@@ -173,9 +173,7 @@ sync_to_claude() {
173
173
  sync_claude_skills "$repo_root" "$config_file" "$output_dir"
174
174
  sync_claude_agents "$repo_root" "$config_file" "$output_dir"
175
175
 
176
- local rules_count skills_count agents_count
177
- rules_count=$(find "$output_dir/rules" -name "*.md" 2>/dev/null | wc -l)
178
- skills_count=$(find "$output_dir/skills" -name "SKILL.md" 2>/dev/null | wc -l)
179
- agents_count=$(find "$output_dir/agents" -name "*.md" 2>/dev/null | wc -l)
180
- echo " -> Rules: $rules_count, Skills: $skills_count, Agents: $agents_count"
176
+ count_matching_files "$output_dir/rules" "*.md" \
177
+ "$output_dir/skills" "SKILL.md" "$output_dir/agents" "*.md"
178
+ echo " -> Rules: ${IS_FILE_COUNTS[0]}, Skills: ${IS_FILE_COUNTS[1]}, Agents: ${IS_FILE_COUNTS[2]}"
181
179
  }
@@ -204,9 +204,7 @@ sync_to_cursor() {
204
204
  sync_cursor_skills "$repo_root" "$config_file" "$output_dir"
205
205
  sync_cursor_agents "$repo_root" "$config_file" "$output_dir"
206
206
 
207
- local rules_count skills_count agents_count
208
- rules_count=$(find "$output_dir/rules" -name "*.mdc" 2>/dev/null | wc -l)
209
- skills_count=$(find "$output_dir/skills" -name "SKILL.md" 2>/dev/null | wc -l)
210
- agents_count=$(find "$output_dir/agents" -name "*.md" 2>/dev/null | wc -l)
211
- echo " -> Rules: $rules_count (.mdc), Skills: $skills_count, Agents: $agents_count"
207
+ count_matching_files "$output_dir/rules" "*.mdc" \
208
+ "$output_dir/skills" "SKILL.md" "$output_dir/agents" "*.md"
209
+ echo " -> Rules: ${IS_FILE_COUNTS[0]} (.mdc), Skills: ${IS_FILE_COUNTS[1]}, Agents: ${IS_FILE_COUNTS[2]}"
212
210
  }
@@ -15,10 +15,6 @@ adapter_contract_preserve() { printf 'preserve\t%s\n' "$1"; }
15
15
  adapter_contract_ignore() { printf 'ignore\t%s\n' "$1"; }
16
16
  adapter_contract_include() { printf 'include\t%s\n' "$1"; }
17
17
 
18
- adapter_contract_function() {
19
- printf 'adapter_contract_%s' "$1"
20
- }
21
-
22
18
  # --- cross-adapter ownership -------------------------------------------------
23
19
  #
24
20
  # `owned` is exclusive: one adapter writes and prunes that path. `managed` is
@@ -118,7 +114,7 @@ adapter_contract_records() (
118
114
  local name="$1" file="$2" output="$3" fn line kind value saw_version=0
119
115
  # shellcheck source=/dev/null
120
116
  source "$file"
121
- fn="$(adapter_contract_function "$name")"
117
+ printf -v fn 'adapter_contract_%s' "$name"
122
118
  declare -F "$fn" >/dev/null 2>&1 || {
123
119
  echo "ERROR: adapter '$name' has no $fn contract" >&2
124
120
  return 1
@@ -820,34 +820,38 @@ has_paths() {
820
820
  # When you bump these, re-run sync in projects; any project whose config.yaml
821
821
  # `models:` section diverges from these will print a drift warning so users
822
822
  # know their override is now stale.
823
- get_model_default() {
824
- local ide="$1"
825
- local tier="$2"
826
- case "$ide:$tier" in
827
- claude:heavy) echo "opus" ;;
828
- claude:standard) echo "sonnet" ;;
829
- claude:light) echo "haiku" ;;
830
- cursor:heavy) echo "inherit" ;;
831
- cursor:standard) echo "inherit" ;;
832
- cursor:light) echo "fast" ;;
833
- copilot:heavy) echo "gpt-5.6-sol" ;;
834
- copilot:standard) echo "gpt-5.6-terra" ;;
835
- copilot:light) echo "gpt-5.6-luna" ;;
836
- codex:heavy) echo "gpt-5.6-sol" ;;
837
- codex:standard) echo "gpt-5.6-terra" ;;
838
- codex:light) echo "gpt-5.6-luna" ;;
823
+ # get_model_default_var sets IS_MODEL_DEFAULT; get_model_default prints it.
824
+ get_model_default_var() {
825
+ case "$1:$2" in
826
+ claude:heavy) IS_MODEL_DEFAULT="opus" ;;
827
+ claude:standard) IS_MODEL_DEFAULT="sonnet" ;;
828
+ claude:light) IS_MODEL_DEFAULT="haiku" ;;
829
+ cursor:heavy) IS_MODEL_DEFAULT="inherit" ;;
830
+ cursor:standard) IS_MODEL_DEFAULT="inherit" ;;
831
+ cursor:light) IS_MODEL_DEFAULT="fast" ;;
832
+ copilot:heavy) IS_MODEL_DEFAULT="gpt-5.6-sol" ;;
833
+ copilot:standard) IS_MODEL_DEFAULT="gpt-5.6-terra" ;;
834
+ copilot:light) IS_MODEL_DEFAULT="gpt-5.6-luna" ;;
835
+ codex:heavy) IS_MODEL_DEFAULT="gpt-5.6-sol" ;;
836
+ codex:standard) IS_MODEL_DEFAULT="gpt-5.6-terra" ;;
837
+ codex:light) IS_MODEL_DEFAULT="gpt-5.6-luna" ;;
839
838
  # Antigravity accepts only inherit|flash|pro, so standard and light
840
839
  # share `flash` — there is no third tier to map onto.
841
- antigravity:heavy) echo "pro" ;;
842
- antigravity:standard) echo "flash" ;;
843
- antigravity:light) echo "flash" ;;
844
- opencode:heavy) echo "anthropic/claude-opus-4-8" ;;
845
- opencode:standard) echo "anthropic/claude-sonnet-5" ;;
846
- opencode:light) echo "anthropic/claude-haiku-4-5-20251001" ;;
847
- *) echo "" ;;
840
+ antigravity:heavy) IS_MODEL_DEFAULT="pro" ;;
841
+ antigravity:standard) IS_MODEL_DEFAULT="flash" ;;
842
+ antigravity:light) IS_MODEL_DEFAULT="flash" ;;
843
+ opencode:heavy) IS_MODEL_DEFAULT="anthropic/claude-opus-4-8" ;;
844
+ opencode:standard) IS_MODEL_DEFAULT="anthropic/claude-sonnet-5" ;;
845
+ opencode:light) IS_MODEL_DEFAULT="anthropic/claude-haiku-4-5-20251001" ;;
846
+ *) IS_MODEL_DEFAULT="" ;;
848
847
  esac
849
848
  }
850
849
 
850
+ get_model_default() {
851
+ get_model_default_var "$1" "$2"
852
+ echo "$IS_MODEL_DEFAULT"
853
+ }
854
+
851
855
  # Read a nested key from config.yaml: section -> sub -> key.
852
856
  # Resolves `models.<ide>.<tier>` overrides and `packs.<name>.<field>`.
853
857
  #
@@ -859,12 +863,16 @@ get_model_default() {
859
863
  # The sub-key is matched LITERALLY (`index(...) == 1`), never interpolated into
860
864
  # a regex: a pack name may contain `.`, which as a pattern is any character, so
861
865
  # `packs.a.b` would happily read a pack named `axb`.
862
- get_nested_yaml_value() {
863
- local file="$1"
864
- local section="$2"
865
- local sub="$3"
866
- local key="$4"
867
- awk -v section="$section" -v subname="$sub" -v key="$key" '
866
+ #
867
+ # _nested_yaml_values <plain|tagged> <file> <section> <sub> <key>... — one pass
868
+ # for any number of keys, first occurrence of each. `plain` prints the value
869
+ # alone (one key); `tagged` prints `key<US>value`, US being \037.
870
+ _nested_yaml_values() {
871
+ local tagged=0 file="$2" section="$3" sub="$4"
872
+ [ "$1" = tagged ] && tagged=1
873
+ shift 4
874
+ awk -v section="$section" -v subname="$sub" -v keys="$*" -v tagged="$tagged" '
875
+ BEGIN { n = split(keys, want, " "); left = n }
868
876
  { sub(/\r$/, "") }
869
877
  $0 ~ "^" section ":[[:space:]]*$" { in_section=1; in_sub=0; next }
870
878
  in_section && /^[a-zA-Z]/ && $0 !~ "^" section ":" { in_section=0; in_sub=0 }
@@ -872,24 +880,34 @@ get_nested_yaml_value() {
872
880
  if (substr($0, length(subname) + 4) ~ /^[[:space:]]*$/) { in_sub=1; next }
873
881
  }
874
882
  in_section && in_sub && /^ [A-Za-z0-9_]/ { in_sub=0 }
875
- in_section && in_sub && index($0, " " key ":") == 1 {
876
- val = $0
877
- sub(/^[[:space:]]*[^:]*:[[:space:]]*/, "", val)
878
- if (val ~ /^"/ || val ~ /^\047/) {
879
- q = substr(val, 1, 1)
880
- val = substr(val, 2)
881
- i = index(val, q)
882
- if (i > 0) val = substr(val, 1, i - 1)
883
- } else {
884
- sub(/[[:space:]]+#.*$/, "", val)
885
- sub(/[[:space:]]+$/, "", val)
883
+ in_section && in_sub {
884
+ for (k = 1; k <= n; k++) {
885
+ if ((k in done) || index($0, " " want[k] ":") != 1) continue
886
+ val = $0
887
+ sub(/^[[:space:]]*[^:]*:[[:space:]]*/, "", val)
888
+ if (val ~ /^"/ || val ~ /^\047/) {
889
+ q = substr(val, 1, 1)
890
+ val = substr(val, 2)
891
+ i = index(val, q)
892
+ if (i > 0) val = substr(val, 1, i - 1)
893
+ } else {
894
+ sub(/[[:space:]]+#.*$/, "", val)
895
+ sub(/[[:space:]]+$/, "", val)
896
+ }
897
+ if (tagged) printf "%s\037%s\n", want[k], val
898
+ else print val
899
+ done[k] = 1
900
+ if (--left == 0) exit
901
+ break
886
902
  }
887
- print val
888
- exit
889
903
  }
890
904
  ' "$file"
891
905
  }
892
906
 
907
+ get_nested_yaml_value() {
908
+ _nested_yaml_values plain "$1" "$2" "$3" "$4"
909
+ }
910
+
893
911
  # Resolve a model: config.yaml `models:` override wins, otherwise default.
894
912
  # Usage: get_model "$CONFIG_FILE" "claude" "$tier"
895
913
  get_model() {
@@ -911,14 +929,27 @@ get_model() {
911
929
  # once per adapter run (IS_MODEL_HEAVY / IS_MODEL_STANDARD / IS_MODEL_LIGHT)
912
930
  # so per-file loops map tier -> model without forking. resolve_model_var
913
931
  # consumes them; a non-standard tier value still goes through get_model so a
914
- # `models:` override for it keeps working.
932
+ # `models:` override for it keeps working. One manifest pass reads all three.
915
933
  load_model_tiers() {
916
- local config_file="$1" ide="$2"
934
+ local config_file="$1" ide="$2" overrides="" tier val heavy="" standard="" light=""
917
935
  IS_MODEL_CFG="$config_file"
918
936
  IS_MODEL_IDE="$ide"
919
- IS_MODEL_HEAVY="$(get_model "$config_file" "$ide" "heavy")"
920
- IS_MODEL_STANDARD="$(get_model "$config_file" "$ide" "standard")"
921
- IS_MODEL_LIGHT="$(get_model "$config_file" "$ide" "light")"
937
+ if [ -n "$config_file" ] && [ -f "$config_file" ]; then
938
+ overrides="$(_nested_yaml_values tagged "$config_file" "models" "$ide" heavy standard light)"
939
+ fi
940
+ while IFS=$'\037' read -r tier val; do
941
+ case "$tier" in
942
+ heavy) heavy="$val" ;;
943
+ standard) standard="$val" ;;
944
+ light) light="$val" ;;
945
+ esac
946
+ done <<< "$overrides"
947
+ get_model_default_var "$ide" heavy
948
+ IS_MODEL_HEAVY="${heavy:-$IS_MODEL_DEFAULT}"
949
+ get_model_default_var "$ide" standard
950
+ IS_MODEL_STANDARD="${standard:-$IS_MODEL_DEFAULT}"
951
+ get_model_default_var "$ide" light
952
+ IS_MODEL_LIGHT="${light:-$IS_MODEL_DEFAULT}"
922
953
  }
923
954
 
924
955
  # resolve_model_var <tier> — set IS_MODEL from the tiers load_model_tiers
@@ -968,7 +999,8 @@ report_model_drift() {
968
999
  while IFS=$'\t' read -r ide tier from_config; do
969
1000
  [ -z "$from_config" ] && continue
970
1001
  local default
971
- default=$(get_model_default "$ide" "$tier")
1002
+ get_model_default_var "$ide" "$tier"
1003
+ default="$IS_MODEL_DEFAULT"
972
1004
  [ "$from_config" = "$default" ] && continue
973
1005
  if [ $printed_header -eq 0 ]; then
974
1006
  echo ""
@@ -1361,13 +1393,32 @@ warn_unsynced() {
1361
1393
  # its writer's exit status entirely. Assembling in the caller's shell leaves no
1362
1394
  # writer whose failure can be lost, and the remaining pipeline is checked. An
1363
1395
  # enumeration that cannot answer must stop the run, never shorten the answer.
1396
+ #
1397
+ # With IS_SOURCE_FILES_MEMO=1 a section is enumerated once and later calls
1398
+ # replay that answer. Only the engine sets it: a sync never writes into a
1399
+ # source (validate_output_path refuses), so each caller would otherwise re-run
1400
+ # the same find and sort for every source directory.
1364
1401
  read_source_artifact_files() {
1365
1402
  local repo_root="$1"
1366
1403
  local config_file="$2"
1367
1404
  local section="$3"
1368
- local src f dir listing
1405
+ local src f dir listing memo_key="" key_var="" list_var="" joined=""
1369
1406
 
1370
1407
  IS_SOURCE_FILES=()
1408
+ case "${IS_SOURCE_FILES_MEMO:-0}:$section" in
1409
+ 1:rules|1:agents|1:skills)
1410
+ key_var="IS_SF_${section}_KEY"
1411
+ list_var="IS_SF_${section}_LIST"
1412
+ memo_key="$repo_root"$'\n'"$config_file"
1413
+ if [ "${!key_var:-}" = "$memo_key" ]; then
1414
+ listing="${!list_var:-}"
1415
+ while IFS= read -r f; do
1416
+ [ -n "$f" ] && IS_SOURCE_FILES+=("$f")
1417
+ done <<< "$listing"
1418
+ return 0
1419
+ fi
1420
+ ;;
1421
+ esac
1371
1422
  load_yaml_list "$config_file" "$section"
1372
1423
  while IFS= read -r src; do
1373
1424
  [ -n "$src" ] || continue
@@ -1400,18 +1451,28 @@ read_source_artifact_files() {
1400
1451
  f="$f/SKILL.md"
1401
1452
  fi
1402
1453
  IS_SOURCE_FILES+=("$f")
1454
+ joined="$joined$f"$'\n'
1403
1455
  done <<< "$listing"
1404
1456
  done <<< "$IS_YAML_LIST"
1457
+ if [ -n "$memo_key" ]; then
1458
+ printf -v "$list_var" '%s' "$joined"
1459
+ printf -v "$key_var" '%s' "$memo_key"
1460
+ fi
1405
1461
  }
1406
1462
 
1407
1463
  # Apply the agents adapter's lexical output rule without consulting filesystem
1408
1464
  # state. An omitted output uses the generic adapter default directory.
1409
- agents_output_path() {
1465
+ agents_output_path_var() {
1410
1466
  local output="${1:-.agents}"
1411
1467
  if [[ "$output" == */ ]] || [[ "$output" != *.md ]]; then
1412
1468
  output="${output%/}/AGENTS.md"
1413
1469
  fi
1414
- printf '%s\n' "$output"
1470
+ IS_AGENTS_OUTPUT_PATH="$output"
1471
+ }
1472
+
1473
+ agents_output_path() {
1474
+ agents_output_path_var "$@"
1475
+ printf '%s\n' "$IS_AGENTS_OUTPUT_PATH"
1415
1476
  }
1416
1477
 
1417
1478
  # Report source prompt pressure independently of adapter formats. "Always-on"
@@ -1420,6 +1481,43 @@ agents_output_path() {
1420
1481
  # them only when a skill explicitly reads them. When the shared agents target
1421
1482
  # exists, also report its rendered size; policy for any tool-specific limit
1422
1483
  # stays in that tool's adapter.
1484
+ # count_matching_files <dir> <name-glob> [<dir> <name-glob>]... — fill
1485
+ # IS_FILE_COUNTS with one count per pair, what `find <dir> -name <glob> | wc -l`
1486
+ # reports for each, from a single find. The directories must be siblings, not
1487
+ # nested in one another; a missing one fails the call, as find would.
1488
+ count_matching_files() {
1489
+ local -a dirs=() globs=() expr=()
1490
+ local listing path base i n
1491
+ while [ "$#" -ge 2 ]; do
1492
+ dirs+=("$1")
1493
+ globs+=("$2")
1494
+ shift 2
1495
+ done
1496
+ n=${#dirs[@]}
1497
+ IS_FILE_COUNTS=()
1498
+ for ((i = 0; i < n; i++)); do
1499
+ IS_FILE_COUNTS[i]=0
1500
+ [ "$i" -eq 0 ] || expr+=(-o)
1501
+ expr+=(-name "${globs[i]}")
1502
+ done
1503
+ [ "$n" -gt 0 ] || return 0
1504
+ listing="$(find "${dirs[@]}" \( "${expr[@]}" \) -print 2>/dev/null)" || return 1
1505
+ [ -n "$listing" ] || return 0
1506
+ while IFS= read -r path; do
1507
+ base="${path##*/}"
1508
+ for ((i = 0; i < n; i++)); do
1509
+ case "$path" in
1510
+ "${dirs[i]}"|"${dirs[i]}"/*) ;;
1511
+ *) continue ;;
1512
+ esac
1513
+ # shellcheck disable=SC2254 # the glob is the pattern being matched
1514
+ case "$base" in
1515
+ ${globs[i]}) IS_FILE_COUNTS[i]=$((IS_FILE_COUNTS[i] + 1)) ;;
1516
+ esac
1517
+ done
1518
+ done <<< "$listing"
1519
+ }
1520
+
1423
1521
  context_files_bytes() {
1424
1522
  if [ "$#" -eq 0 ]; then
1425
1523
  echo 0
@@ -1453,22 +1551,43 @@ report_context_source_sizes() {
1453
1551
  read_source_artifact_files "$repo_root" "$config_file" "skills"
1454
1552
  [ "${#IS_SOURCE_FILES[@]}" -eq 0 ] || skill_files=("${IS_SOURCE_FILES[@]}")
1455
1553
 
1456
- local always_bytes custom_bytes agents_output agents_bytes=0 agents_status="disabled"
1457
- always_bytes="$(context_files_bytes "${always_on_rules[@]+"${always_on_rules[@]}"}")"
1458
- custom_bytes="$(context_files_bytes \
1459
- "${scoped_rules[@]+"${scoped_rules[@]}"}" \
1460
- "${agent_files[@]+"${agent_files[@]}"}" \
1461
- "${skill_files[@]+"${skill_files[@]}"}")"
1554
+ local always_bytes=0 custom_bytes=0 agents_output agents_bytes=0 agents_status="disabled"
1555
+ local always_n custom_n listing size rest idx=0
1556
+ local -a measured=()
1557
+ always_n=${#always_on_rules[@]}
1558
+ custom_n=$(( ${#scoped_rules[@]} + ${#agent_files[@]} + ${#skill_files[@]} ))
1559
+ measured=(
1560
+ "${always_on_rules[@]+"${always_on_rules[@]}"}"
1561
+ "${scoped_rules[@]+"${scoped_rules[@]}"}"
1562
+ "${agent_files[@]+"${agent_files[@]}"}"
1563
+ "${skill_files[@]+"${skill_files[@]}"}")
1462
1564
  target_enabled_var "$config_file" "agents"
1463
1565
  if [ "$IS_TGT_ENABLED" = "1" ]; then
1464
1566
  agents_status="not-generated"
1465
1567
  target_output_var "$config_file" "agents"
1466
- agents_output="$(agents_output_path "${IS_TGT_OUTPUT:-.agents}")"
1568
+ agents_output_path_var "${IS_TGT_OUTPUT:-.agents}"
1569
+ agents_output="$IS_AGENTS_OUTPUT_PATH"
1467
1570
  if [ -f "$repo_root/$agents_output" ]; then
1468
- agents_bytes="$(context_files_bytes "$repo_root/$agents_output")"
1571
+ measured+=("$repo_root/$agents_output")
1469
1572
  agents_status="generated"
1470
1573
  fi
1471
1574
  fi
1575
+ # One wc for every group: its per-file lines come back in argument order,
1576
+ # and each group's sum is what a separate `wc` total would have reported.
1577
+ if [ "${#measured[@]}" -gt 0 ]; then
1578
+ listing="$(LC_ALL=C wc -c "${measured[@]}")"
1579
+ while read -r size rest; do
1580
+ [ "$idx" -lt "${#measured[@]}" ] || break
1581
+ if [ "$idx" -lt "$always_n" ]; then
1582
+ always_bytes=$((always_bytes + size))
1583
+ elif [ "$idx" -lt $((always_n + custom_n)) ]; then
1584
+ custom_bytes=$((custom_bytes + size))
1585
+ else
1586
+ agents_bytes=$((agents_bytes + size))
1587
+ fi
1588
+ idx=$((idx + 1))
1589
+ done <<< "$listing"
1590
+ fi
1472
1591
 
1473
1592
  printf 'CONTEXT: always-on=%s bytes (%s rules); custom=%s bytes (%s scoped rules, %s agents, %s skills); agents-md=%s bytes; agents-md-status=%s\n' \
1474
1593
  "$always_bytes" "${#always_on_rules[@]}" "$custom_bytes" \
@@ -1554,6 +1673,56 @@ load_yaml_list() {
1554
1673
  esac
1555
1674
  }
1556
1675
 
1676
+ # load_yaml_lists <file> <section>... — warm the load_yaml_list cache for several
1677
+ # sections in one manifest pass. Each section runs read_yaml_list's own state
1678
+ # machine, so every cached value is what load_yaml_list would have stored.
1679
+ load_yaml_lists() {
1680
+ local file="$1" out line s v j
1681
+ shift
1682
+ for s in "$@"; do
1683
+ case "$s" in
1684
+ rules|agents|skills|ignore|submodules) ;;
1685
+ *) return 1 ;;
1686
+ esac
1687
+ done
1688
+ out="$(awk -v sections="$*" '
1689
+ BEGIN { n = split(sections, want, " ") }
1690
+ { sub(/\r$/, "") }
1691
+ {
1692
+ for (k = 1; k <= n; k++) {
1693
+ s = want[k]
1694
+ if ($0 ~ /^[a-z]/) { cur[k] = ""; dep[k] = 0 }
1695
+ if ($0 ~ /^ [a-z]/) { cur[k] = ""; dep[k] = 0 }
1696
+ if ($0 ~ "^" s ":") { cur[k] = s; dep[k] = 0; continue }
1697
+ if ($0 ~ "^ " s ":") { cur[k] = s; dep[k] = 2; continue }
1698
+ if (cur[k] == s && dep[k] == 0 && /^ - /) {
1699
+ val = $0
1700
+ sub(/^ - /, "", val)
1701
+ gsub(/["\047]/, "", val)
1702
+ print s "\037" val
1703
+ }
1704
+ if (cur[k] == s && dep[k] == 2 && /^ - /) {
1705
+ val = $0
1706
+ sub(/^ - /, "", val)
1707
+ gsub(/["\047]/, "", val)
1708
+ print s "\037" val
1709
+ }
1710
+ }
1711
+ }
1712
+ ' "$file")"
1713
+ for s in "$@"; do
1714
+ j=""
1715
+ while IFS= read -r line; do
1716
+ [ "${line%%$'\037'*}" = "$s" ] || continue
1717
+ v="${line#*$'\037'}"
1718
+ j="$j$v"$'\n'
1719
+ done <<< "$out"
1720
+ while [[ "$j" == *$'\n' ]]; do j="${j%$'\n'}"; done
1721
+ printf -v "IS_YL_${s}_FILE" '%s' "$file"
1722
+ printf -v "IS_YL_${s}_VAL" '%s' "$j"
1723
+ done
1724
+ }
1725
+
1557
1726
  # load_targets_cache <file> — parse the whole targets: section once into the
1558
1727
  # global IS_TGT_TSV (one `name<TAB>enabled<TAB>output` row per target),
1559
1728
  # matching is_target_enabled and get_target_output semantics: inline and block