@ainova-systems/intelligence 0.20.0 → 0.21.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.
@@ -71,6 +71,46 @@ IS_AWK_LIB='
71
71
  return val
72
72
  }
73
73
  function base_name(p, q, m) { m = split(p, q, "/"); return q[m] }
74
+ # The name a source renders under (decision 0021) comes from the source
75
+ # catalog, which hands a batch its `<path> US <name>` lines through
76
+ # ENVIRON["IS_NAME_MAP"]. A path the map does not hold keeps its own name.
77
+ BEGIN {
78
+ is_nm_n = split(ENVIRON["IS_NAME_MAP"], is_nm_rows, "\n")
79
+ for (is_nm_i = 1; is_nm_i <= is_nm_n; is_nm_i++) {
80
+ is_nm_p = index(is_nm_rows[is_nm_i], sprintf("%c", 31))
81
+ if (is_nm_p > 0) ART_NAME[substr(is_nm_rows[is_nm_i], 1, is_nm_p - 1)] = substr(is_nm_rows[is_nm_i], is_nm_p + 1)
82
+ }
83
+ }
84
+ # art_file(path, ext) — the output file name: the catalog name plus <ext>,
85
+ # or the source file name itself.
86
+ function art_file(p, ext) { return (p in ART_NAME) ? ART_NAME[p] ext : base_name(p) }
87
+ # art_name(path) — the catalog name, or the file name without `.md`.
88
+ function art_name(p, b) {
89
+ if (p in ART_NAME) return ART_NAME[p]
90
+ b = base_name(p); sub(/\.md$/, "", b); return b
91
+ }
92
+ # tgt_drop(line) — whether a frontmatter line belongs to `targets:` or
93
+ # `exclude_targets:` (decision 0021), which select adapters and are
94
+ # removed from every rendered copy. Call tgt_reset() at the start of each
95
+ # file and tgt_drop() for each of its frontmatter lines in order: a block
96
+ # list, or a flow list spanning lines, goes with its key.
97
+ function tgt_reset() { tgt_cont = "" }
98
+ function tgt_drop(s, v) {
99
+ if (tgt_cont == "block") {
100
+ if (s ~ /^[ \t]/ || s == "") return 1
101
+ tgt_cont = ""
102
+ } else if (tgt_cont == "flow") {
103
+ if (index(s, "]")) tgt_cont = ""
104
+ return 1
105
+ }
106
+ if (s !~ /^(targets|exclude_targets):/) return 0
107
+ v = substr(s, index(s, ":") + 1)
108
+ sub(/[ \t]+#.*$/, "", v)
109
+ gsub(/^[ \t]+|[ \t]+$/, "", v)
110
+ if (v == "") tgt_cont = "block"
111
+ else if (substr(v, 1, 1) == "[" && !index(v, "]")) tgt_cont = "flow"
112
+ return 1
113
+ }
74
114
  '
75
115
 
76
116
  # is_fin_awk_vars — fill the global IS_FIN_V array with the -v bindings the
@@ -119,23 +159,58 @@ finalize_output_file() {
119
159
  finalize_copy_files() {
120
160
  local dst="$1"
121
161
  shift
162
+ finalize_copy_named "$dst" "" "$@"
163
+ }
164
+
165
+ # finalize_copy_named <dst_dir> <name-map> <src>... — finalize_copy_files with
166
+ # each source written as <dst_dir>/<name>.md when <name-map> holds it: the
167
+ # rendered names source_artifacts_var leaves in IS_SOURCE_NAME_MAP, so
168
+ # `.draft.md` and `me.local.md` land as `draft.md` and `me.md`.
169
+ finalize_copy_named() {
170
+ local dst="$1" map="$2" f
171
+ shift 2
122
172
  [ "$#" -gt 0 ] || return 0
123
- local f
124
173
  # awk never reads a record from an empty source, so pre-create those to
125
174
  # keep the old cp behavior of producing an empty output file.
126
175
  for f in "$@"; do
127
- [ -s "$f" ] || : > "$dst/${f##*/}"
176
+ [ -s "$f" ] && continue
177
+ if source_name_map_var "$map" "$f"; then
178
+ : > "$dst/$IS_MAPPED_NAME.md"
179
+ else
180
+ : > "$dst/${f##*/}"
181
+ fi
128
182
  done
129
183
  is_fin_awk_vars
130
- awk "${IS_FIN_V[@]}" -v dst="$dst" "$IS_AWK_LIB"'
184
+ IS_NAME_MAP="$map" awk "${IS_FIN_V[@]}" -v dst="$dst" "$IS_AWK_LIB"'
131
185
  FNR == 1 {
132
186
  if (out_file != "") close(out_file)
133
- out_file = dst "/" base_name(FILENAME)
187
+ out_file = dst "/" art_file(FILENAME, ".md")
188
+ tgt_reset()
134
189
  }
135
- { sub(/\r$/, ""); print fin_line($0) > out_file }
190
+ { sub(/\r$/, "") }
191
+ FNR == 1 { cp_fm = ($0 == "---") }
192
+ FNR > 1 && cp_fm == 1 {
193
+ if ($0 == "---") cp_fm = 2
194
+ else if (tgt_drop($0)) next
195
+ }
196
+ { print fin_line($0) > out_file }
136
197
  ' "$@"
137
198
  }
138
199
 
200
+ # source_name_map_var <name-map> <path> — set IS_MAPPED_NAME to the name the
201
+ # map gives <path>; return 1 when it gives none. A map is "\n" followed by one
202
+ # `<path> US <name>` line per source.
203
+ source_name_map_var() {
204
+ local rest
205
+ IS_MAPPED_NAME=""
206
+ case "$1" in
207
+ *$'\n'"$2"$'\x1f'*) ;;
208
+ *) return 1 ;;
209
+ esac
210
+ rest="${1#*$'\n'"$2"$'\x1f'}"
211
+ IS_MAPPED_NAME="${rest%%$'\n'*}"
212
+ }
213
+
139
214
  # frontmatter_index <keys-csv> <file>...
140
215
  # One awk pass over many files; prints one row per file, in argument order:
141
216
  # the path, then one value per requested key, all separated by \x1f (ASCII
@@ -394,6 +469,22 @@ emit_wrapped_bodies() {
394
469
  ' "${srcs[@]}"
395
470
  }
396
471
 
472
+ # emit_rule_bodies <file>... — every rule's body without its frontmatter, in
473
+ # one awk, a blank line after each: how AGENTS.md inlines always-on rules, and
474
+ # how Codex's AGENTS.override.md adds the ones AGENTS.md does not carry.
475
+ emit_rule_bodies() {
476
+ [ "$#" -gt 0 ] || return 0
477
+ awk '
478
+ FNR == 1 { if (NR != 1) print ""; in_fm = 0; past_fm = 0 }
479
+ { sub(/\r$/, "") }
480
+ /^---$/ {
481
+ if (!past_fm) { in_fm = !in_fm; if (!in_fm) { past_fm = 1 }; next }
482
+ }
483
+ past_fm || !in_fm { print }
484
+ END { print "" }
485
+ ' "$@"
486
+ }
487
+
397
488
  # Copy a markdown file with frontmatter, ensuring free-text string fields are
398
489
  # wrapped in double quotes. Used by adapters that feed strict-YAML consumers
399
490
  # (Codex CLI rejects unquoted colons / booleans). Idempotent — already-quoted
@@ -492,32 +583,59 @@ copy_skill_bundle_dirs() {
492
583
  copy_skill_bundle_dirs_for() {
493
584
  local effort_tool="$1" dest_root="$2"
494
585
  shift 2
586
+ copy_skill_bundle_dirs_named "$effort_tool" "$dest_root" "" "$@"
587
+ }
588
+
589
+ # copy_skill_bundle_dirs_named <tool> <dest_root> <name-map> <src_dir>... — the
590
+ # batch form with each skill copied to <dest_root>/<name> when <name-map> holds
591
+ # its directory: the rendered names source_artifacts_var leaves in
592
+ # IS_SOURCE_NAME_MAP, so `deploy.local/` lands as `deploy/` and still matches
593
+ # its own `name: deploy`. Only a skill whose name differs from its directory
594
+ # costs a copy of its own.
595
+ copy_skill_bundle_dirs_named() {
596
+ local effort_tool="$1" dest_root="$2" map="$3"
597
+ shift 3
495
598
  [ "$#" -gt 0 ] || return 0
496
- local src dest linked=0
497
- local -a srcs=()
599
+ local src from name dest i linked=0 renamed=0
600
+ local -a srcs=() names=()
498
601
  for src in "$@"; do
499
602
  src="${src%/}"
500
603
  srcs+=("$src")
604
+ name="${src##*/}"
605
+ if source_name_map_var "$map" "$src" && [ "$IS_MAPPED_NAME" != "$name" ]; then
606
+ name="$IS_MAPPED_NAME"
607
+ renamed=1
608
+ fi
609
+ names+=("$name")
501
610
  ! _ski_planned "$src" || linked=1
502
611
  done
503
612
  mkdir -p "$dest_root"
504
- if [ "$linked" = 0 ]; then
613
+ if [ "$linked" = 0 ] && [ "$renamed" = 0 ]; then
505
614
  cp -R "${srcs[@]}" "$dest_root/"
506
615
  else
507
- for src in "${srcs[@]}"; do
616
+ for ((i = 0; i < ${#srcs[@]}; i++)); do
617
+ src="${srcs[i]}"
618
+ from="$src"
508
619
  if _ski_planned "$src"; then
509
620
  _skill_bundle_materialize "$src" || return 1
510
- cp -R "$IS_SKI_STAGED" "$dest_root/" || return 1
511
- rm -rf "${IS_SKI_STAGED%/*}"
621
+ from="$IS_SKI_STAGED"
622
+ fi
623
+ dest="$dest_root/${names[i]}"
624
+ if [ "${names[i]}" = "${src##*/}" ]; then
625
+ cp -R "$from" "$dest_root/" || return 1
626
+ elif [ -e "$dest" ]; then
627
+ # A second skill of one name merges file by file, as the
628
+ # batch copy of same-named directories does.
629
+ cp -R "$from/." "$dest/" || return 1
512
630
  else
513
- cp -R "$src" "$dest_root/" || return 1
631
+ cp -R "$from" "$dest" || return 1
514
632
  fi
633
+ [ "$from" = "$src" ] || rm -rf "${IS_SKI_STAGED%/*}"
515
634
  done
516
635
  fi
517
636
  _skill_bundles_reset
518
- for src in "${srcs[@]}"; do
519
- dest="$dest_root/${src##*/}"
520
- _skill_bundle_note "$dest"
637
+ for name in "${names[@]}"; do
638
+ _skill_bundle_note "$dest_root/$name"
521
639
  done
522
640
  _skill_bundles_flush "$effort_tool"
523
641
  }
@@ -667,12 +785,14 @@ _skill_bundles_flush() {
667
785
  out_file = FILENAME; line_n = 0
668
786
  state = (FILENAME in QUOTE) ? "before" : ""
669
787
  effort_seen = 0
788
+ tgt_reset()
670
789
  }
671
790
  { sub(/\r$/, "") }
672
791
  state == "before" {
673
792
  if (FNR == 1 && $0 == "---") state = "in_fm"
674
793
  else state = "after"
675
794
  }
795
+ state == "in_fm" && FNR > 1 && $0 != "---" && tgt_drop($0) { next }
676
796
  state == "in_fm" && FNR > 1 && substr($0, 1, 7) == "effort:" {
677
797
  if (effort_seen++) next
678
798
  val = fm_value_strip(substr($0, 8))
@@ -742,26 +862,27 @@ sync_open_skill_dirs() {
742
862
  fi
743
863
  mkdir -p "$output_dir"
744
864
 
745
- local count=0 log="" d skill_file skill_name
865
+ local count=0 log="" i rel
746
866
  local -a skill_dirs=()
747
- read_source_artifact_files "$repo_root" "$config_file" "skills"
867
+ # Every tool that reads this directory reads all of it, so a skill lands
868
+ # here when it is for any of them (decision 0021). The engine names the
869
+ # enabled readers from their contracts.
870
+ normalize_path_var "$output_dir"
871
+ rel="${IS_NORM_PATH#"$repo_root"/}"
872
+ source_artifacts_var "$repo_root" "$config_file" skills \
873
+ "${IS_OPEN_SKILLS_READERS-antigravity codex opencode pi}" "$rel/{}"
748
874
  # Guard on the count, then expand quoted: Bash 3.2 (macOS) errors on an
749
875
  # empty array under `set -u`, and quoting keeps a path with a space or a
750
876
  # glob character intact.
751
- if [ "${#IS_SOURCE_FILES[@]}" -gt 0 ]; then
752
- for skill_file in "${IS_SOURCE_FILES[@]}"; do
753
- [ -n "$skill_file" ] || continue
754
- d="${skill_file%/SKILL.md}"
755
- skill_name="${d##*/}"
756
- skill_dirs+=("$d/")
757
- count=$((count + 1))
758
- log+=" skill: $skill_name"$'\n'
759
- done
760
- fi
877
+ for ((i = 0; i < ${#IS_SOURCE_FILES[@]}; i++)); do
878
+ skill_dirs+=("${IS_SOURCE_FILES[i]%/SKILL.md}/")
879
+ count=$((count + 1))
880
+ log+=" skill: ${IS_SOURCE_NAMES[i]}"$'\n'
881
+ done
761
882
  # copy_skill_bundle_dirs owns the frontmatter-quoting pass, so every
762
883
  # target gets it — not just this open-standard dir.
763
884
  if [ "$count" -gt 0 ]; then
764
- copy_skill_bundle_dirs_for open "$output_dir" "${skill_dirs[@]}"
885
+ copy_skill_bundle_dirs_named open "$output_dir" "$IS_SOURCE_NAME_MAP" "${skill_dirs[@]}"
765
886
  open_skill_invocation_policies "$output_dir" || return 1
766
887
  fi
767
888
 
@@ -935,7 +1056,13 @@ enforce_authored_invocation_policies() {
935
1056
  # Usage: lint_frontmatter_files "a.md" "b.md" ...
936
1057
  lint_frontmatter_files() {
937
1058
  [ "$#" -gt 0 ] || return 0
938
- awk -v levels="$IS_EFFORT_LEVELS" "$IS_AWK_LIB"'
1059
+ awk -v levels="$IS_EFFORT_LEVELS" "$IS_AWK_LIB$_IS_AWK_LINT" "$@"
1060
+ }
1061
+
1062
+ # The lint rules, shared with source_frontmatter_scan, which runs them in the
1063
+ # same pass that reads routing fields. Every rule may end with `next`, so a
1064
+ # program that joins them puts its own rules first.
1065
+ _IS_AWK_LINT='
939
1066
  BEGIN {
940
1067
  nl = split(levels, LV, " ")
941
1068
  allowed = ""
@@ -990,8 +1117,7 @@ lint_frontmatter_files() {
990
1117
  printf "WARNING: %s:%d %s is %d chars — over the %d-char limit; the skill/agent will be REJECTED at load time\n", FILENAME, FNR, key, length(val), limit > "/dev/stderr"
991
1118
  }
992
1119
  }
993
- ' "$@"
994
- }
1120
+ '
995
1121
 
996
1122
  # lint_frontmatter <file> — single-file form for project adapters.
997
1123
  lint_frontmatter() {
@@ -1747,10 +1873,11 @@ warn_unsynced() {
1747
1873
  # encloses it. Generated output never holds a link, and nothing is ever copied
1748
1874
  # from outside the source (decision 0006). Every consumer takes that decision
1749
1875
  # from here, once per run: the skill list read_source_artifact_files returns,
1750
- # the membership the glob-listing adapters check (skill_source_rendered), and
1751
- # the plan copy_skill_bundle_dirs applies to its copy. A skill rendered from a
1752
- # link is an ordinary skill from then on — quoting, token expansion, the
1753
- # effort rendering and the Codex invocation policy all run on the output copy.
1876
+ # which the source catalog reads, the membership skill_source_rendered
1877
+ # answers, and the plan copy_skill_bundle_dirs applies to its copy. A skill
1878
+ # rendered from a link is an ordinary skill from then on — quoting, token
1879
+ # expansion, the effort rendering and the Codex invocation policy all run on
1880
+ # the output copy.
1754
1881
  #
1755
1882
  # skill_source_inventory <repo_root> <config_file> fills, in the caller's
1756
1883
  # shell and fail-closed like read_source_artifact_files:
@@ -1797,8 +1924,11 @@ skill_source_inventory() {
1797
1924
  [ "${IS_SOURCE_FILES_MEMO:-0}" != 1 ] || IS_SKI_KEY="$memo_key"
1798
1925
  }
1799
1926
 
1800
- # skill_source_rendered <skill_dir> — the inventory renders this skill. For
1801
- # adapters that list a skills source with their own glob.
1927
+ # skill_source_rendered <skill_dir> — the inventory renders this skill. No
1928
+ # built-in adapter calls it: they read skills through source_artifacts_var
1929
+ # (decision 0021). It stays for a project adapter written against earlier
1930
+ # releases that still lists a skills source with its own glob — that adapter
1931
+ # keeps skipping skills sync leaves out, but not local or targeted ones.
1802
1932
  skill_source_rendered() {
1803
1933
  _ski_key_var "$1"
1804
1934
  case "${IS_SKI_KEYS:-}" in
@@ -2149,9 +2279,11 @@ read_source_artifact_files() {
2149
2279
  [ -d "$dir" ] || continue
2150
2280
  # Command substitution, so the pipeline's status is the assignment's:
2151
2281
  # `find` or `sort` failing aborts here instead of yielding a short list.
2282
+ # A link to a regular file is one too, read where it points, as the
2283
+ # adapters' globs always read it; a dangling one is no artifact.
2152
2284
  case "$section" in
2153
2285
  rules|agents)
2154
- listing="$(find "$dir" -maxdepth 1 -type f -name '*.md' -print | LC_ALL=C sort)" || {
2286
+ listing="$(find "$dir" -maxdepth 1 -name '*.md' \( -type f -o -type l \) -print | LC_ALL=C sort)" || {
2155
2287
  echo "ERROR: cannot enumerate $section under '$dir' — refusing to render a partial list." >&2
2156
2288
  exit 1
2157
2289
  }
@@ -2162,7 +2294,7 @@ read_source_artifact_files() {
2162
2294
  # A here-string redirect keeps this loop in the current shell, so the
2163
2295
  # array it fills survives and any failure inside it is this shell's.
2164
2296
  while IFS= read -r f; do
2165
- [ -n "$f" ] || continue
2297
+ [ -f "$f" ] || continue
2166
2298
  IS_SOURCE_FILES+=("$f")
2167
2299
  joined="$joined$f"$'\n'
2168
2300
  done <<< "$listing"
@@ -2173,6 +2305,556 @@ read_source_artifact_files() {
2173
2305
  fi
2174
2306
  }
2175
2307
 
2308
+ # --- The source catalog (decision 0021) --------------------------------------
2309
+ #
2310
+ # Every renderer — AGENTS.md, every built-in adapter, the adapter template —
2311
+ # reads sources through source_artifacts_var, so one place decides what an
2312
+ # artifact is called, which artifact of a name renders, and where it may go:
2313
+ #
2314
+ # name the entry without a leading `.`, `.md` and a trailing `.local`:
2315
+ # `.environment.local.md` is the rule `environment`, `.draft.md`
2316
+ # the rule `draft`, the skill directory `deploy.local/` the skill
2317
+ # `deploy`. A leading dot means nothing.
2318
+ # local a rule or agent named `*.local.md`, a skill directory named
2319
+ # `*.local` — exactly the patterns `init` writes to .gitignore, so
2320
+ # Git and the engine never disagree. A local artifact never renders
2321
+ # into an output Git tracks (`committed`).
2322
+ # override across sources the later artifact of a name renders. Two of one
2323
+ # name in one directory, or a local and a non-local one anywhere,
2324
+ # refuse the sync: either would change what committed output holds
2325
+ # depending on which file a clone has.
2326
+ # targets `targets:` lists the adapters an artifact is for, every enabled
2327
+ # one without it; `exclude_targets:` removes adapters from that
2328
+ # set. Both take adapter ids and the group `committed` — every
2329
+ # output path Git tracks — and a local artifact excludes
2330
+ # `committed` implicitly.
2331
+ #
2332
+ # source_catalog_build fills, in the caller's shell and fail-closed like the
2333
+ # listing it reads, one row per source entry in listing order:
2334
+ # IS_CAT_SEC IS_CAT_PATH IS_CAT_NAME IS_CAT_LOCAL IS_CAT_WIN IS_CAT_SCOPED
2335
+ # IS_CAT_HAS_T IS_CAT_T IS_CAT_HAS_X IS_CAT_X
2336
+ # (section; the file, or a skill's SKILL.md; the rendered name; 1 when local;
2337
+ # 1 when no later source overrides it; 1 for a rule with `paths:`; whether
2338
+ # `targets:` / `exclude_targets:` are set, and their ids, space-separated),
2339
+ # IS_CAT_N, and IS_CAT_NEED_COMMITTED, 1 when rendering has to know which
2340
+ # outputs Git tracks. With IS_SOURCE_FILES_MEMO=1 a second call replays the
2341
+ # first.
2342
+
2343
+ # source_artifact_name_var <section> <entry> — IS_ART_NAME and IS_ART_LOCAL for
2344
+ # one file (rules, agents) or skill directory (skills) name.
2345
+ source_artifact_name_var() {
2346
+ local n="$2"
2347
+ IS_ART_LOCAL=0
2348
+ case "$1:$n" in
2349
+ skills:*.local|rules:*.local.md|agents:*.local.md) IS_ART_LOCAL=1 ;;
2350
+ esac
2351
+ n="${n#.}"
2352
+ [ "$1" = skills ] || n="${n%.md}"
2353
+ case "$n" in *.local) n="${n%.local}" ;; esac
2354
+ IS_ART_NAME="$n"
2355
+ }
2356
+
2357
+ # shellcheck disable=SC2034 # IS_CAT_NEED_COMMITTED is read by engine/sync.sh
2358
+ source_catalog_build() {
2359
+ local repo_root="$1" config_file="$2" lint="${3:-}" key section f entry dir name
2360
+ local seen=$'\n' errors="" i n=0 prev rest rows path scoped kind has_t t has_x x
2361
+ local -a files=()
2362
+ key="$repo_root"$'\n'"$config_file"
2363
+ if [ "${IS_SOURCE_FILES_MEMO:-0}" = 1 ] && [ "${IS_CAT_KEY:-}" = "$key" ]; then
2364
+ return 0
2365
+ fi
2366
+ IS_CAT_KEY=""
2367
+ IS_CAT_SEC=(); IS_CAT_PATH=(); IS_CAT_NAME=(); IS_CAT_LOCAL=()
2368
+ IS_CAT_WIN=(); IS_CAT_SCOPED=()
2369
+ IS_CAT_HAS_T=(); IS_CAT_T=(); IS_CAT_HAS_X=(); IS_CAT_X=()
2370
+ IS_CAT_NEED_COMMITTED=0
2371
+ for section in rules agents skills; do
2372
+ read_source_artifact_files "$repo_root" "$config_file" "$section"
2373
+ for f in "${IS_SOURCE_FILES[@]+"${IS_SOURCE_FILES[@]}"}"; do
2374
+ if [ "$section" = skills ]; then
2375
+ dir="${f%/SKILL.md}"
2376
+ entry="${dir##*/}"
2377
+ dir="${dir%/*}"
2378
+ kind="skill"
2379
+ else
2380
+ entry="${f##*/}"
2381
+ dir="${f%/*}"
2382
+ kind="${section%s}"
2383
+ fi
2384
+ source_artifact_name_var "$section" "$entry"
2385
+ name="$IS_ART_NAME"
2386
+ IS_CAT_SEC[n]="$section"
2387
+ IS_CAT_PATH[n]="$f"
2388
+ IS_CAT_NAME[n]="$name"
2389
+ IS_CAT_LOCAL[n]="$IS_ART_LOCAL"
2390
+ IS_CAT_WIN[n]=1
2391
+ IS_CAT_SCOPED[n]=0
2392
+ IS_CAT_HAS_T[n]=0; IS_CAT_T[n]=""; IS_CAT_HAS_X[n]=0; IS_CAT_X[n]=""
2393
+ [ "$IS_ART_LOCAL" = 0 ] || IS_CAT_NEED_COMMITTED=1
2394
+ case "$seen" in
2395
+ *$'\n'"$section/$name"$'\t'*)
2396
+ rest="${seen##*$'\n'"$section/$name"$'\t'}"
2397
+ prev="${rest%%$'\n'*}"
2398
+ rest="${IS_CAT_PATH[prev]}"
2399
+ [ "$section" != skills ] || rest="${rest%/SKILL.md}"
2400
+ if [ "${IS_CAT_PATH[prev]}" = "$f" ]; then
2401
+ # One directory listed twice: the same artifact.
2402
+ IS_CAT_WIN[prev]=0
2403
+ elif [ "${rest%/*}" = "$dir" ]; then
2404
+ errors+="ERROR: ${IS_CAT_PATH[prev]#"$repo_root"/} and ${f#"$repo_root"/} are both the $kind '$name' in one directory — rename or remove one"$'\n'
2405
+ elif [ "${IS_CAT_LOCAL[prev]}" != "$IS_ART_LOCAL" ]; then
2406
+ errors+="ERROR: ${IS_CAT_PATH[prev]#"$repo_root"/} and ${f#"$repo_root"/} are both the $kind '$name', one local and one not — a local artifact cannot override or yield to a committed one; rename one"$'\n'
2407
+ else
2408
+ IS_CAT_WIN[prev]=0
2409
+ fi
2410
+ ;;
2411
+ esac
2412
+ seen="$seen$section/$name"$'\t'"$n"$'\n'
2413
+ files+=("$f")
2414
+ n=$((n + 1))
2415
+ done
2416
+ done
2417
+ IS_CAT_N="$n"
2418
+ if [ -n "$errors" ]; then
2419
+ printf '%s' "$errors" >&2
2420
+ exit 1
2421
+ fi
2422
+ if [ "$n" -gt 0 ]; then
2423
+ # One pass reads what routing needs from every source's frontmatter
2424
+ # and, for the engine, lints it. Captured, not streamed: a short answer
2425
+ # must stop the run, not route the files it lost as always-on.
2426
+ rows="$(source_frontmatter_scan "$lint" "${files[@]}")" || {
2427
+ echo "ERROR: cannot read the frontmatter of the sources — refusing to render a partial list." >&2
2428
+ exit 1
2429
+ }
2430
+ i=0
2431
+ while IFS=$'\x1f' read -r path scoped has_t t has_x x; do
2432
+ [ "$i" -lt "$n" ] && [ "$path" = "${IS_CAT_PATH[i]}" ] || {
2433
+ echo "ERROR: cannot read the frontmatter of the sources — refusing to render a partial list." >&2
2434
+ exit 1
2435
+ }
2436
+ [ "${IS_CAT_SEC[i]}" != rules ] || [ "$scoped" = 0 ] || IS_CAT_SCOPED[i]=1
2437
+ IS_CAT_HAS_T[i]="$has_t"; IS_CAT_T[i]="$t"
2438
+ IS_CAT_HAS_X[i]="$has_x"; IS_CAT_X[i]="$x"
2439
+ case " $t $x " in *" committed "*) IS_CAT_NEED_COMMITTED=1 ;; esac
2440
+ i=$((i + 1))
2441
+ done <<< "$rows"
2442
+ if [ "$i" -ne "$n" ]; then
2443
+ echo "ERROR: cannot read the frontmatter of the sources — refusing to render a partial list." >&2
2444
+ exit 1
2445
+ fi
2446
+ fi
2447
+ [ "${IS_SOURCE_FILES_MEMO:-0}" != 1 ] || IS_CAT_KEY="$key"
2448
+ }
2449
+
2450
+ # source_frontmatter_scan <lint> <file>... — one row per file in argument
2451
+ # order, \x1f-separated: the path; its `paths:` line count, read with
2452
+ # frontmatter_index's `paths#` semantics; then for `targets:` and for
2453
+ # `exclude_targets:` whether it is set and its ids, space-separated. Both take
2454
+ # the list syntax `paths:` takes — a block list or a flow list `[a, b]`, items
2455
+ # optionally quoted — and a bare value is a one-item list. The first
2456
+ # occurrence of a key wins, and a key with no value at all is not set
2457
+ # (`targets: []` is: it is the empty list). With <lint> = lint the same pass
2458
+ # runs lint_frontmatter_files over every file, its warnings on stderr.
2459
+ source_frontmatter_scan() {
2460
+ local do_lint=0
2461
+ [ "$1" != lint ] || do_lint=1
2462
+ shift
2463
+ awk -v levels="$IS_EFFORT_LEVELS" -v do_lint="$do_lint" "$IS_AWK_LIB"'
2464
+ BEGIN { SX_US = sprintf("%c", 31) }
2465
+ function sx_items(s, k, parts, m, j, v, q) {
2466
+ m = split(s, parts, ",")
2467
+ for (j = 1; j <= m; j++) {
2468
+ v = parts[j]
2469
+ gsub(/^[ \t]+|[ \t]+$/, "", v)
2470
+ q = substr(v, 1, 1)
2471
+ if (length(v) >= 2 && (q == "\"" || q == "\047") && substr(v, length(v), 1) == q)
2472
+ v = substr(v, 2, length(v) - 2)
2473
+ if (v == "") continue
2474
+ sx_v[k] = (sx_v[k] == "") ? v : sx_v[k] " " v
2475
+ sx_n[k]++
2476
+ }
2477
+ }
2478
+ # The end of a block list: one without an item is a key with no value.
2479
+ function sx_close() {
2480
+ if (sx_mode == "block" && sx_n[sx_list] == 0) sx_has[sx_list] = 0
2481
+ sx_list = ""; sx_mode = ""
2482
+ }
2483
+ function sx_line(l, c, k, v) {
2484
+ if (sx_mode == "flow") {
2485
+ c = index(l, "]")
2486
+ if (c) { sx_items(substr(l, 1, c - 1), sx_list); sx_list = ""; sx_mode = "" }
2487
+ else sx_items(l, sx_list)
2488
+ return
2489
+ }
2490
+ if (sx_mode == "block") {
2491
+ if (l ~ /^[ \t]+-/) {
2492
+ v = l
2493
+ sub(/^[ \t]+-[ \t]*/, "", v)
2494
+ sub(/[ \t]+#.*$/, "", v)
2495
+ sx_items(v, sx_list)
2496
+ return
2497
+ }
2498
+ if (l ~ /^[ \t]*$/ || l ~ /^[ \t]+#/) return
2499
+ sx_close()
2500
+ }
2501
+ c = index(l, ":")
2502
+ if (!c) return
2503
+ k = substr(l, 1, c - 1)
2504
+ if (k == "paths") { sx_paths++; return }
2505
+ if ((k != "targets" && k != "exclude_targets") || sx_seen[k]) return
2506
+ sx_seen[k] = 1; sx_has[k] = 1
2507
+ v = substr(l, c + 1)
2508
+ sub(/[ \t]+#.*$/, "", v)
2509
+ gsub(/^[ \t]+|[ \t]+$/, "", v)
2510
+ if (v == "") { sx_list = k; sx_mode = "block"; return }
2511
+ if (substr(v, 1, 1) == "[") {
2512
+ v = substr(v, 2)
2513
+ c = index(v, "]")
2514
+ if (c) sx_items(substr(v, 1, c - 1), k)
2515
+ else { sx_items(v, k); sx_list = k; sx_mode = "flow" }
2516
+ return
2517
+ }
2518
+ sx_items(v, k)
2519
+ }
2520
+ function sx_store() {
2521
+ if (sx_cur == "") return
2522
+ sx_close()
2523
+ SXR[sx_cur] = sx_cur SX_US sx_paths SX_US sx_has["targets"] SX_US sx_v["targets"] \
2524
+ SX_US sx_has["exclude_targets"] SX_US sx_v["exclude_targets"]
2525
+ }
2526
+ FNR == 1 {
2527
+ sx_store()
2528
+ sx_cur = FILENAME; sx_paths = 0; sx_fm = 0; sx_list = ""; sx_mode = ""
2529
+ sx_seen["targets"] = 0; sx_has["targets"] = 0; sx_v["targets"] = ""; sx_n["targets"] = 0
2530
+ sx_seen["exclude_targets"] = 0; sx_has["exclude_targets"] = 0
2531
+ sx_v["exclude_targets"] = ""; sx_n["exclude_targets"] = 0
2532
+ }
2533
+ {
2534
+ sx_l = $0
2535
+ sub(/\r$/, "", sx_l)
2536
+ if (FNR == 1) sx_fm = (sx_l == "---")
2537
+ else if (sx_fm == 1) {
2538
+ if (sx_l == "---") { sx_close(); sx_fm = 2 }
2539
+ else sx_line(sx_l)
2540
+ }
2541
+ }
2542
+ END {
2543
+ sx_store()
2544
+ for (sx_i = 1; sx_i < ARGC; sx_i++)
2545
+ print (ARGV[sx_i] in SXR) ? SXR[ARGV[sx_i]] : ARGV[sx_i] SX_US 0 SX_US 0 SX_US SX_US 0 SX_US
2546
+ }
2547
+ !do_lint { next }
2548
+ '"$_IS_AWK_LINT" "$@"
2549
+ }
2550
+
2551
+ # The source catalog's committed check needs every enabled adapter's Git
2552
+ # policy, which only the engine reads (sync.sh: IS_COMMIT_POLICY, one
2553
+ # `adapter TAB kind TAB pattern` line per ignore and include record, and
2554
+ # IS_COMMIT_POLICY_SET=1). It reads it only when IS_CAT_NEED_COMMITTED says a
2555
+ # render depends on it, so a project without local artifacts never pays for
2556
+ # the manifest read the Copilot contract makes (decision 0020).
2557
+ #
2558
+ # output_committed <adapter> <repo-relative path> — true when Git tracks that
2559
+ # output: no `ignore` record of the adapter's contract covers it, or an
2560
+ # `include` record takes it back. AGENTS.md always belongs here.
2561
+ output_committed() {
2562
+ local adapter="$1" path="$2" a kind pat ignored=0 included=0
2563
+ [ "$adapter" != agents ] || return 0
2564
+ if [ "${IS_COMMIT_POLICY_SET:-0}" != 1 ]; then
2565
+ echo "ERROR: rendering a local artifact needs the Git policy of every enabled adapter; run it through the engine: intelligence sync" >&2
2566
+ exit 1
2567
+ fi
2568
+ while IFS=$'\t' read -r a kind pat; do
2569
+ [ "$a" = "$adapter" ] || continue
2570
+ case "$kind" in
2571
+ ignore) [ "$ignored" = 1 ] || ! policy_pattern_covers "$pat" "$path" || ignored=1 ;;
2572
+ include) [ "$included" = 1 ] || ! policy_pattern_covers "$pat" "$path" || included=1 ;;
2573
+ esac
2574
+ done <<< "$IS_COMMIT_POLICY"
2575
+ [ "$ignored" = 1 ] && [ "$included" = 0 ] && return 1
2576
+ return 0
2577
+ }
2578
+
2579
+ # policy_pattern_covers <ignore-pattern> <path> — whether a contract's ignore
2580
+ # or include pattern covers a repo-relative path or one of its parents: a
2581
+ # trailing `/` names a directory, `*` and `?` match as Git's do inside one.
2582
+ policy_pattern_covers() {
2583
+ local pat="${1#./}" path="$2"
2584
+ pat="${pat#/}"
2585
+ case "$pat" in
2586
+ */)
2587
+ pat="${pat%/}"
2588
+ case "$path" in "$pat"|"$pat"/*) return 0 ;; esac
2589
+ return 1
2590
+ ;;
2591
+ esac
2592
+ # shellcheck disable=SC2254 # the pattern is a glob on purpose
2593
+ case "$path" in
2594
+ $pat|$pat/*) return 0 ;;
2595
+ esac
2596
+ return 1
2597
+ }
2598
+
2599
+ # _cat_member <index> <adapter> <repo-relative output path> — whether the
2600
+ # catalog row renders into that adapter's output at that path: its targets
2601
+ # name the adapter, or `committed` while Git tracks the path, or it has none;
2602
+ # its exclusions name neither; and a local row never reaches a tracked path.
2603
+ _cat_member() {
2604
+ local i="$1" a="$2" path="$3"
2605
+ if [ "${IS_CAT_HAS_T[i]}" = 1 ]; then
2606
+ case " ${IS_CAT_T[i]} " in
2607
+ *" $a "*) ;;
2608
+ *" committed "*) output_committed "$a" "$path" || return 1 ;;
2609
+ *) return 1 ;;
2610
+ esac
2611
+ fi
2612
+ if [ "${IS_CAT_HAS_X[i]}" = 1 ]; then
2613
+ case " ${IS_CAT_X[i]} " in *" $a "*) return 1 ;; esac
2614
+ case " ${IS_CAT_X[i]} " in
2615
+ *" committed "*) ! output_committed "$a" "$path" || return 1 ;;
2616
+ esac
2617
+ fi
2618
+ if [ "${IS_CAT_LOCAL[i]}" = 1 ]; then
2619
+ ! output_committed "$a" "$path" || return 1
2620
+ fi
2621
+ return 0
2622
+ }
2623
+
2624
+ # _cat_routed <index> — the row selects its adapters: a local artifact, or one
2625
+ # with `targets:` or `exclude_targets:`. Every other row renders everywhere,
2626
+ # with no question asked.
2627
+ _cat_routed() {
2628
+ [ "${IS_CAT_LOCAL[$1]}" = 1 ] || [ "${IS_CAT_HAS_T[$1]}" = 1 ] || [ "${IS_CAT_HAS_X[$1]}" = 1 ]
2629
+ }
2630
+
2631
+ # source_artifacts_var <repo_root> <config> <section> <adapters> <output>
2632
+ # — the artifacts of a section that render for one of <adapters> (space-
2633
+ # separated ids) into <output>, a repo-relative path in which `{}` stands for
2634
+ # the artifact's name (`.claude/rules/{}.md`, `.agents/skills/{}`). Fills, in
2635
+ # catalog order — sources in manifest order, entries in byte order:
2636
+ # IS_SOURCE_FILES the source file, or a skill's SKILL.md
2637
+ # IS_SOURCE_NAMES the rendered name
2638
+ # IS_SOURCE_SCOPED 1 for a rule with `paths:`
2639
+ # IS_SOURCE_IN_AGENTS 1 when AGENTS.md carries the artifact too
2640
+ # IS_SOURCE_NAME_MAP the name map finalize_copy_named and
2641
+ # copy_skill_bundle_dirs_named take (a skill keyed by
2642
+ # its directory)
2643
+ source_artifacts_var() {
2644
+ local repo_root="$1" config_file="$2" section="$3" adapters="$4" output="$5"
2645
+ local i a pre post path member key ph='{}'
2646
+ source_catalog_build "$repo_root" "$config_file"
2647
+ IS_SOURCE_FILES=(); IS_SOURCE_NAMES=(); IS_SOURCE_SCOPED=(); IS_SOURCE_IN_AGENTS=()
2648
+ IS_SOURCE_NAME_MAP=$'\n'
2649
+ case "$output" in
2650
+ *"$ph"*) pre="${output%%"$ph"*}"; post="${output#*"$ph"}" ;;
2651
+ *) pre="$output"; post="" ;;
2652
+ esac
2653
+ for ((i = 0; i < IS_CAT_N; i++)); do
2654
+ [ "${IS_CAT_SEC[i]}" = "$section" ] && [ "${IS_CAT_WIN[i]}" = 1 ] || continue
2655
+ if _cat_routed "$i"; then
2656
+ case "$output" in *"$ph"*) path="$pre${IS_CAT_NAME[i]}$post" ;; *) path="$output" ;; esac
2657
+ member=0
2658
+ for a in $adapters; do
2659
+ if _cat_member "$i" "$a" "$path"; then member=1; break; fi
2660
+ done
2661
+ [ "$member" = 1 ] || continue
2662
+ # AGENTS.md is always committed, so its path decides nothing.
2663
+ if _cat_member "$i" agents AGENTS.md; then
2664
+ IS_SOURCE_IN_AGENTS+=(1)
2665
+ else
2666
+ IS_SOURCE_IN_AGENTS+=(0)
2667
+ fi
2668
+ else
2669
+ IS_SOURCE_IN_AGENTS+=(1)
2670
+ fi
2671
+ IS_SOURCE_FILES+=("${IS_CAT_PATH[i]}")
2672
+ IS_SOURCE_NAMES+=("${IS_CAT_NAME[i]}")
2673
+ IS_SOURCE_SCOPED+=("${IS_CAT_SCOPED[i]}")
2674
+ key="${IS_CAT_PATH[i]}"
2675
+ [ "$section" != skills ] || key="${key%/SKILL.md}"
2676
+ IS_SOURCE_NAME_MAP="$IS_SOURCE_NAME_MAP$key"$'\x1f'"${IS_CAT_NAME[i]}"$'\n'
2677
+ done
2678
+ }
2679
+
2680
+ # The checks below run in the engine, after preflight read every enabled
2681
+ # contract: IS_ENABLED_ADAPTERS and IS_AGENTS_READERS (the enabled adapters,
2682
+ # and those that require `agents` and so read AGENTS.md), IS_OPEN_SKILLS_READERS
2683
+ # and IS_ADAPTER_CLAIMS (every enabled adapter's owned and managed paths).
2684
+
2685
+ # source_catalog_check_ids <repo_root> <known adapter ids> — refuse a
2686
+ # `targets:` or `exclude_targets:` id that is neither an adapter, built-in or
2687
+ # the project's, nor `committed`. Every finding is printed before the refusal.
2688
+ source_catalog_check_ids() {
2689
+ local repo_root="$1" known=" $2 committed " i id field ids bad=0
2690
+ local -a list=()
2691
+ for ((i = 0; i < IS_CAT_N; i++)); do
2692
+ [ "${IS_CAT_WIN[i]}" = 1 ] || continue
2693
+ for field in targets exclude_targets; do
2694
+ if [ "$field" = targets ]; then
2695
+ [ "${IS_CAT_HAS_T[i]}" = 1 ] || continue
2696
+ ids="${IS_CAT_T[i]}"
2697
+ else
2698
+ [ "${IS_CAT_HAS_X[i]}" = 1 ] || continue
2699
+ ids="${IS_CAT_X[i]}"
2700
+ fi
2701
+ read -r -a list <<< "$ids"
2702
+ for id in "${list[@]+"${list[@]}"}"; do
2703
+ case "$known" in *" $id "*) continue ;; esac
2704
+ echo "ERROR: ${IS_CAT_PATH[i]#"$repo_root"/}: $field names '$id', which is no adapter — use one of: ${2// /, }, or the group committed" >&2
2705
+ bad=1
2706
+ done
2707
+ done
2708
+ done
2709
+ return "$bad"
2710
+ }
2711
+
2712
+ # _adapter_paths_var <adapter> — IS_AP_PATHS, the adapter's owned and managed
2713
+ # paths as preflight registered them.
2714
+ _adapter_paths_var() {
2715
+ local a kind p
2716
+ IS_AP_PATHS=()
2717
+ while IFS=$'\t' read -r a kind p; do
2718
+ if [ "$a" = "$1" ] && [ -n "$p" ]; then IS_AP_PATHS+=("$p"); fi
2719
+ done <<< "${IS_ADAPTER_CLAIMS:-}"
2720
+ }
2721
+
2722
+ # _cat_adapter_member <index> <adapter> — the row renders into any output of
2723
+ # the adapter: its set of adapters, read per path.
2724
+ _cat_adapter_member() {
2725
+ local p
2726
+ _adapter_paths_var "$2"
2727
+ for p in "${IS_AP_PATHS[@]+"${IS_AP_PATHS[@]}"}"; do
2728
+ if _cat_member "$1" "$2" "$p"; then return 0; fi
2729
+ done
2730
+ return 1
2731
+ }
2732
+
2733
+ # source_catalog_check_local <repo_root> — refuse a local artifact whose
2734
+ # targets name a committed output: `committed` itself, `agents`, or an enabled
2735
+ # adapter all of whose paths Git tracks. The source exists in no other clone
2736
+ # and not in CI, which render that output without it.
2737
+ source_catalog_check_local() {
2738
+ local repo_root="$1" i id p tracked bad=0
2739
+ local -a list=()
2740
+ for ((i = 0; i < IS_CAT_N; i++)); do
2741
+ [ "${IS_CAT_WIN[i]}" = 1 ] && [ "${IS_CAT_LOCAL[i]}" = 1 ] && [ "${IS_CAT_HAS_T[i]}" = 1 ] || continue
2742
+ read -r -a list <<< "${IS_CAT_T[i]}"
2743
+ for id in "${list[@]+"${list[@]}"}"; do
2744
+ tracked=0
2745
+ case "$id" in
2746
+ committed|agents) tracked=1 ;;
2747
+ *)
2748
+ case " ${IS_ENABLED_ADAPTERS:-} " in *" $id "*) ;; *) continue ;; esac
2749
+ _adapter_paths_var "$id"
2750
+ [ "${#IS_AP_PATHS[@]}" -gt 0 ] || continue
2751
+ tracked=1
2752
+ for p in "${IS_AP_PATHS[@]}"; do
2753
+ if ! output_committed "$id" "$p"; then tracked=0; break; fi
2754
+ done
2755
+ ;;
2756
+ esac
2757
+ [ "$tracked" = 1 ] || continue
2758
+ echo "ERROR: ${IS_CAT_PATH[i]#"$repo_root"/} is local, but its targets name $id, whose output Git tracks — a local artifact exists in no other clone, so it renders only into ignored output" >&2
2759
+ bad=1
2760
+ done
2761
+ done
2762
+ return "$bad"
2763
+ }
2764
+
2765
+ # builtin_has_channel <adapter> <always|scoped|agent|skill> — whether a built-in
2766
+ # adapter has a channel of its own for that kind of artifact, AGENTS.md aside.
2767
+ # A project adapter's channels are its own business: it has one.
2768
+ builtin_has_channel() {
2769
+ case "$1:$2" in
2770
+ opencode:always|opencode:scoped|codex:scoped) return 1 ;;
2771
+ esac
2772
+ return 0
2773
+ }
2774
+
2775
+ # source_catalog_warn <repo_root> — the WARNING: lines for an artifact that
2776
+ # selects its adapters (decision 0021): a shared path — AGENTS.md, or
2777
+ # .agents/skills/ — that delivers it to an enabled adapter outside its set, and
2778
+ # an adapter in its set that no channel reaches. An artifact with neither field
2779
+ # nor a `.local` name keeps the routing every sync had, and no new warning.
2780
+ source_catalog_warn() {
2781
+ local repo_root="$1" i a rel kind in_agents outside unreach landed what
2782
+ for ((i = 0; i < IS_CAT_N; i++)); do
2783
+ [ "${IS_CAT_WIN[i]}" = 1 ] || continue
2784
+ _cat_routed "$i" || continue
2785
+ rel="${IS_CAT_PATH[i]#"$repo_root"/}"
2786
+ case "${IS_CAT_SEC[i]}" in
2787
+ rules) kind=always; [ "${IS_CAT_SCOPED[i]}" = 0 ] || kind=scoped ;;
2788
+ agents) kind=agent ;;
2789
+ *) kind=skill; rel="${rel%/SKILL.md}/" ;;
2790
+ esac
2791
+ in_agents=0
2792
+ case " ${IS_ENABLED_ADAPTERS:-} " in
2793
+ *" agents "*) if _cat_member "$i" agents AGENTS.md; then in_agents=1; fi ;;
2794
+ esac
2795
+ if [ "$in_agents" = 1 ]; then
2796
+ outside=""
2797
+ for a in ${IS_AGENTS_READERS:-}; do
2798
+ _cat_adapter_member "$i" "$a" || outside="$outside${outside:+, }$a"
2799
+ done
2800
+ if [ -n "$outside" ]; then
2801
+ echo "WARNING: $rel still reaches $outside through AGENTS.md, which they read, although its targets leave them out" >&2
2802
+ fi
2803
+ fi
2804
+ if [ "$kind" = skill ]; then
2805
+ landed=0
2806
+ outside=""
2807
+ for a in ${IS_OPEN_SKILLS_READERS:-}; do
2808
+ if _cat_member "$i" "$a" ".agents/skills/${IS_CAT_NAME[i]}"; then
2809
+ landed=1
2810
+ else
2811
+ outside="$outside${outside:+, }$a"
2812
+ fi
2813
+ done
2814
+ if [ "$landed" = 1 ] && [ -n "$outside" ]; then
2815
+ echo "WARNING: $rel still reaches $outside through .agents/skills/, which they read too, although its targets leave them out" >&2
2816
+ fi
2817
+ fi
2818
+ unreach=""
2819
+ for a in ${IS_ENABLED_ADAPTERS:-}; do
2820
+ [ "$a" != agents ] || continue
2821
+ _cat_adapter_member "$i" "$a" || continue
2822
+ if [ "$in_agents" = 1 ]; then
2823
+ case " ${IS_AGENTS_READERS:-} " in *" $a "*) continue ;; esac
2824
+ fi
2825
+ builtin_has_channel "$a" "$kind" || unreach="$unreach${unreach:+, }$a"
2826
+ done
2827
+ if [ -n "$unreach" ]; then
2828
+ what="an always-on rule"
2829
+ [ "$kind" != scoped ] || what="a scoped rule"
2830
+ echo "WARNING: $rel cannot reach $unreach: no channel there carries $what outside AGENTS.md" >&2
2831
+ fi
2832
+ done
2833
+ }
2834
+
2835
+ # output_rel_var <repo_root> <absolute output path> — IS_OUTPUT_REL, the
2836
+ # output's repo-relative path spelled as contracts and source_artifacts_var
2837
+ # spell it.
2838
+ # shellcheck disable=SC2034 # IS_OUTPUT_REL is the return channel read by adapters
2839
+ output_rel_var() {
2840
+ normalize_path_var "$2"
2841
+ IS_OUTPUT_REL="${IS_NORM_PATH#"$1"/}"
2842
+ }
2843
+
2844
+ # source_catalog_winners_var <repo_root> <config> <section> — every artifact of
2845
+ # a section that renders anywhere, whatever its outputs: IS_SOURCE_FILES and
2846
+ # IS_SOURCE_SCOPED, for the adapter-agnostic context report.
2847
+ source_catalog_winners_var() {
2848
+ local i
2849
+ source_catalog_build "$1" "$2"
2850
+ IS_SOURCE_FILES=(); IS_SOURCE_SCOPED=()
2851
+ for ((i = 0; i < IS_CAT_N; i++)); do
2852
+ [ "${IS_CAT_SEC[i]}" = "$3" ] && [ "${IS_CAT_WIN[i]}" = 1 ] || continue
2853
+ IS_SOURCE_FILES+=("${IS_CAT_PATH[i]}")
2854
+ IS_SOURCE_SCOPED+=("${IS_CAT_SCOPED[i]}")
2855
+ done
2856
+ }
2857
+
2176
2858
  # Apply the agents adapter's lexical output rule without consulting filesystem
2177
2859
  # state. An omitted output uses the generic adapter default directory.
2178
2860
  agents_output_path_var() {
@@ -2242,26 +2924,23 @@ context_files_bytes() {
2242
2924
  report_context_source_sizes() {
2243
2925
  local repo_root="$1"
2244
2926
  local config_file="$2"
2245
- local f path has_paths
2246
- local -a rule_files=() always_on_rules=() scoped_rules=() agent_files=() skill_files=()
2247
-
2248
- read_source_artifact_files "$repo_root" "$config_file" "rules"
2249
- [ "${#IS_SOURCE_FILES[@]}" -eq 0 ] || rule_files=("${IS_SOURCE_FILES[@]}")
2250
- if [ "${#rule_files[@]}" -gt 0 ]; then
2251
- while IFS=$'\x1f' read -r path has_paths; do
2252
- [ -n "$path" ] || continue
2253
- if [ "$has_paths" = "0" ]; then
2254
- always_on_rules+=("$path")
2255
- else
2256
- scoped_rules+=("$path")
2257
- fi
2258
- done < <(frontmatter_index "paths#" "${rule_files[@]}")
2259
- fi
2927
+ local i
2928
+ local -a always_on_rules=() scoped_rules=() agent_files=() skill_files=()
2929
+
2930
+ # What renders anywhere: an artifact a later source overrides is no context.
2931
+ source_catalog_winners_var "$repo_root" "$config_file" "rules"
2932
+ for ((i = 0; i < ${#IS_SOURCE_FILES[@]}; i++)); do
2933
+ if [ "${IS_SOURCE_SCOPED[i]}" = 0 ]; then
2934
+ always_on_rules+=("${IS_SOURCE_FILES[i]}")
2935
+ else
2936
+ scoped_rules+=("${IS_SOURCE_FILES[i]}")
2937
+ fi
2938
+ done
2260
2939
 
2261
- read_source_artifact_files "$repo_root" "$config_file" "agents"
2940
+ source_catalog_winners_var "$repo_root" "$config_file" "agents"
2262
2941
  [ "${#IS_SOURCE_FILES[@]}" -eq 0 ] || agent_files=("${IS_SOURCE_FILES[@]}")
2263
2942
 
2264
- read_source_artifact_files "$repo_root" "$config_file" "skills"
2943
+ source_catalog_winners_var "$repo_root" "$config_file" "skills"
2265
2944
  [ "${#IS_SOURCE_FILES[@]}" -eq 0 ] || skill_files=("${IS_SOURCE_FILES[@]}")
2266
2945
 
2267
2946
  local always_bytes=0 custom_bytes=0 agents_output agents_bytes=0 agents_status="disabled"