@ainova-systems/intelligence 0.19.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.
Files changed (41) hide show
  1. package/cli/commands/init.sh +2 -1
  2. package/cli/commands/registry.sh +1 -1
  3. package/cli/commands/source.sh +1 -1
  4. package/cli/commands/sync.sh +78 -6
  5. package/cli/intelligence +15 -2
  6. package/cli/internal/check.sh +39 -0
  7. package/cli/internal/convert-legacy.sh +1 -1
  8. package/cli/internal/package-search.sh +1 -1
  9. package/cli/internal/package-update.sh +2 -2
  10. package/cli/internal/restore.sh +3 -2
  11. package/cli/lib/cli-common.sh +145 -33
  12. package/cli/lib/gitignore.sh +19 -2
  13. package/cli/lib/lockfile.sh +14 -4
  14. package/cli/lib/manifest.sh +147 -1
  15. package/cli/lib/qmap.awk +97 -32
  16. package/cli/lib/registry.sh +1 -1
  17. package/cli/lib/sync-cache.sh +596 -142
  18. package/cli/lib/sync-lock.sh +19 -2
  19. package/engine/ENGINE_SHA +1 -1
  20. package/engine/VERSION +1 -1
  21. package/engine/adapters/_template.sh +32 -8
  22. package/engine/adapters/agents.sh +47 -52
  23. package/engine/adapters/antigravity.sh +71 -53
  24. package/engine/adapters/claude.sh +42 -57
  25. package/engine/adapters/codex.sh +77 -16
  26. package/engine/adapters/copilot.sh +54 -65
  27. package/engine/adapters/cursor.sh +88 -78
  28. package/engine/adapters/opencode.sh +23 -32
  29. package/engine/adapters/pi.sh +140 -42
  30. package/engine/lib/adapter-contract.sh +36 -4
  31. package/engine/lib/common.sh +1388 -165
  32. package/engine/lib/contract.sh +5 -0
  33. package/engine/sync.sh +145 -38
  34. package/package.json +1 -1
  35. package/packages/sync/references/adapters.md +48 -38
  36. package/packages/sync/references/conventions.md +61 -12
  37. package/packages/sync/references/onboarding-migration.md +4 -4
  38. package/packages/sync/rules/intelligence-authoring.md +2 -2
  39. package/packages/sync/skills/intelligence-learn-from-repository/SKILL.md +7 -5
  40. package/packages/sync/skills/intelligence-review-context/references/audit-checks.md +1 -1
  41. package/packages/sync/skills/intelligence-update-context/references/rules.md +5 -0
@@ -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
@@ -452,8 +543,11 @@ copy_md_with_quoted_frontmatter() {
452
543
  # ship support files beside SKILL.md, and SKILL.md bodies point at them by
453
544
  # relative path, so dropping them breaks the skill at runtime). Markdown is
454
545
  # normalized to LF; every other file is copied byte-for-byte so potentially
455
- # binary assets survive. `cp -R` copies symlinks as symlinks (POSIX), so a
456
- # link inside a source skill never leaks host file content into the output.
546
+ # binary assets survive. A symlink never reaches the output: the plan
547
+ # skill_source_inventory made turns a link inside its source into the regular
548
+ # file or directory it points at and leaves any other out, and the flush
549
+ # removes a link no plan covered (a copy from outside every skills source), so
550
+ # no host file outside the source is ever copied in (decision 0017).
457
551
  #
458
552
  # SKILL.md frontmatter is quoted for EVERY consumer, not only the strict-YAML
459
553
  # ones. `argument-hint: [pr-number]` is a YAML *flow sequence*, so an unquoted
@@ -470,7 +564,7 @@ copy_md_with_quoted_frontmatter() {
470
564
  # Usage: copy_skill_bundle "src/skill/dir" "dest/skill/dir"
471
565
  copy_skill_bundle() {
472
566
  _skill_bundles_reset
473
- _skill_bundle_stage "$1" "$2"
567
+ _skill_bundle_stage "$1" "$2" || return 1
474
568
  _skill_bundles_flush open
475
569
  }
476
570
 
@@ -478,7 +572,8 @@ copy_skill_bundle() {
478
572
  # copies every source skill directory into <dest_root>/<skill-name>, then one
479
573
  # awk pass quotes and finalizes every bundled markdown file across all
480
574
  # bundles. A later source with the same skill name overwrites file-by-file in
481
- # order, exactly like the sequential per-bundle copies did.
575
+ # order, exactly like the sequential per-bundle copies did. A skill with links
576
+ # to resolve is copied from its materialized stage, in the same order.
482
577
  copy_skill_bundle_dirs() {
483
578
  copy_skill_bundle_dirs_for open "$@"
484
579
  }
@@ -488,18 +583,59 @@ copy_skill_bundle_dirs() {
488
583
  copy_skill_bundle_dirs_for() {
489
584
  local effort_tool="$1" dest_root="$2"
490
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
491
598
  [ "$#" -gt 0 ] || return 0
492
- local src dest
493
- local -a srcs=()
599
+ local src from name dest i linked=0 renamed=0
600
+ local -a srcs=() names=()
494
601
  for src in "$@"; do
495
- srcs+=("${src%/}")
602
+ src="${src%/}"
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")
610
+ ! _ski_planned "$src" || linked=1
496
611
  done
497
612
  mkdir -p "$dest_root"
498
- cp -R "${srcs[@]}" "$dest_root/"
613
+ if [ "$linked" = 0 ] && [ "$renamed" = 0 ]; then
614
+ cp -R "${srcs[@]}" "$dest_root/"
615
+ else
616
+ for ((i = 0; i < ${#srcs[@]}; i++)); do
617
+ src="${srcs[i]}"
618
+ from="$src"
619
+ if _ski_planned "$src"; then
620
+ _skill_bundle_materialize "$src" || return 1
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
630
+ else
631
+ cp -R "$from" "$dest" || return 1
632
+ fi
633
+ [ "$from" = "$src" ] || rm -rf "${IS_SKI_STAGED%/*}"
634
+ done
635
+ fi
499
636
  _skill_bundles_reset
500
- for src in "${srcs[@]}"; do
501
- dest="$dest_root/${src##*/}"
502
- _skill_bundle_note "$dest"
637
+ for name in "${names[@]}"; do
638
+ _skill_bundle_note "$dest_root/$name"
503
639
  done
504
640
  _skill_bundles_flush "$effort_tool"
505
641
  }
@@ -513,24 +649,63 @@ _skill_bundles_reset() {
513
649
  _skill_bundle_stage() {
514
650
  local src="${1%/}" dest="$2"
515
651
  mkdir -p "$dest"
516
- cp -R "$src/." "$dest/"
652
+ if _ski_planned "$src"; then
653
+ _skill_bundle_materialize "$src" || return 1
654
+ cp -R "$IS_SKI_STAGED/." "$dest/" || return 1
655
+ rm -rf "${IS_SKI_STAGED%/*}"
656
+ else
657
+ cp -R "$src/." "$dest/"
658
+ fi
517
659
  _skill_bundle_note "$dest"
518
660
  }
519
661
 
662
+ # _skill_bundle_materialize <skill_dir> — copy a skill whose plan holds links
663
+ # into a fresh stage and apply the plan there, parents first: each planned link
664
+ # is replaced by a copy of the file or directory it resolves to inside its
665
+ # source, or removed. Sets IS_SKI_STAGED to <stage>/<name>; the caller copies
666
+ # it on and removes the stage. Spawns per link, never per file — a source
667
+ # without links never comes here.
668
+ _skill_bundle_materialize() {
669
+ local src="$1" key stage out rec rel act phys first=1
670
+ _ski_key_var "$src"
671
+ key="$_SKI_KEY"
672
+ stage="$(mktemp -d -t intelligence-skill-XXXXXX)" || return 1
673
+ out="$stage/${key##*/}"
674
+ while IFS= read -r rec; do
675
+ case "$rec" in
676
+ "$key$IS_SKI_US"*) ;;
677
+ *) continue ;;
678
+ esac
679
+ rec="${rec#"$key$IS_SKI_US"}"
680
+ rel="${rec%%"$IS_SKI_US"*}"
681
+ rec="${rec#*"$IS_SKI_US"}"
682
+ act="${rec%%"$IS_SKI_US"*}"
683
+ phys="${rec#*"$IS_SKI_US"}"
684
+ if [ "$first" = 1 ]; then
685
+ first=0
686
+ if [ -z "$rel" ]; then
687
+ # The skill directory itself is the link.
688
+ mkdir "$out" && cp -R "$phys/." "$out/" || return 1
689
+ continue
690
+ fi
691
+ cp -R "$src" "$stage/" || return 1
692
+ fi
693
+ # The copy holds the link itself here; `rm -f` never follows it and
694
+ # refuses a directory, so a plan that no longer matches stops the run.
695
+ rm -f "$out/$rel" || return 1
696
+ case "$act" in
697
+ f) cp "$phys" "$out/$rel" || return 1 ;;
698
+ d) mkdir "$out/$rel" && cp -R "$phys/." "$out/$rel/" || return 1 ;;
699
+ esac
700
+ done <<< "$IS_SKI_PLAN"
701
+ IS_SKI_STAGED="$out"
702
+ }
703
+
520
704
  # Record a staged bundle for the flush pass: mark its SKILL.md for
521
705
  # frontmatter quoting and deduplicate the destination.
522
706
  _skill_bundle_note() {
523
707
  local dest="$1"
524
- # A symlinked SKILL.md is left exactly as `cp -R` produced it — a
525
- # symlink. `[ -f ]` follows links, so quoting it would read the link's
526
- # TARGET and write that content into a real file, turning
527
- # `skills/x/SKILL.md -> /etc/…` into a copy of a host file inside the
528
- # output. That is the leak the symlink-preserving copy exists to
529
- # prevent, so skip the rewrite and say so (the same reason
530
- # `find -type f` in the flush never matches a symlink).
531
- if [ -L "$dest/SKILL.md" ]; then
532
- echo " WARN: ${dest##*/}/SKILL.md is a symlink — emitted as-is (frontmatter not quoted, tokens not expanded)" >&2
533
- elif [ -f "$dest/SKILL.md" ]; then
708
+ if [ -f "$dest/SKILL.md" ]; then
534
709
  _SB_QUOTE_LIST="$_SB_QUOTE_LIST$dest/SKILL.md"$'\n'
535
710
  fi
536
711
  # The same skill name from a later source overwrites the earlier copy;
@@ -547,11 +722,25 @@ _skill_bundle_note() {
547
722
  # _skill_bundles_flush <effort-tool>
548
723
  _skill_bundles_flush() {
549
724
  [ "${#_SB_DESTS[@]}" -gt 0 ] || return 0
550
- local -a mds=()
551
- local f quote_list="$_SB_QUOTE_LIST"
725
+ local -a mds=() links=()
726
+ local f listing quote_list="$_SB_QUOTE_LIST"
727
+ # Captured, not streamed: a listing cut short could leave a link behind.
728
+ listing="$(find "${_SB_DESTS[@]}" \( -type l -o -type f -name '*.md' \) -print)" || {
729
+ echo "ERROR: could not list the skill copies under ${_SB_DESTS[0]%/*}" >&2
730
+ return 1
731
+ }
552
732
  while IFS= read -r f; do
553
- [ -n "$f" ] && mds+=("$f")
554
- done < <(find "${_SB_DESTS[@]}" -type f -name '*.md')
733
+ [ -n "$f" ] || continue
734
+ if [ -L "$f" ]; then links+=("$f"); else mds+=("$f"); fi
735
+ done <<< "$listing"
736
+ # A link only a copy from outside every skills source can bring: no plan
737
+ # resolved it, so it is not followed but removed.
738
+ if [ "${#links[@]}" -gt 0 ]; then
739
+ rm -f "${links[@]}" || return 1
740
+ for f in "${links[@]}"; do
741
+ echo "WARNING: ${f#"${REPO_ROOT:-}"/} was copied as a symlink from outside every skills source — removed; generated output never holds one" >&2
742
+ done
743
+ fi
555
744
  _skill_bundles_reset
556
745
  [ "${#mds[@]}" -gt 0 ] || return 0
557
746
  effort_map_var "$1"
@@ -596,12 +785,14 @@ _skill_bundles_flush() {
596
785
  out_file = FILENAME; line_n = 0
597
786
  state = (FILENAME in QUOTE) ? "before" : ""
598
787
  effort_seen = 0
788
+ tgt_reset()
599
789
  }
600
790
  { sub(/\r$/, "") }
601
791
  state == "before" {
602
792
  if (FNR == 1 && $0 == "---") state = "in_fm"
603
793
  else state = "after"
604
794
  }
795
+ state == "in_fm" && FNR > 1 && $0 != "---" && tgt_drop($0) { next }
605
796
  state == "in_fm" && FNR > 1 && substr($0, 1, 7) == "effort:" {
606
797
  if (effort_seen++) next
607
798
  val = fm_value_strip(substr($0, 8))
@@ -671,26 +862,27 @@ sync_open_skill_dirs() {
671
862
  fi
672
863
  mkdir -p "$output_dir"
673
864
 
674
- local count=0 log="" d skill_file skill_name
865
+ local count=0 log="" i rel
675
866
  local -a skill_dirs=()
676
- 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/{}"
677
874
  # Guard on the count, then expand quoted: Bash 3.2 (macOS) errors on an
678
875
  # empty array under `set -u`, and quoting keeps a path with a space or a
679
876
  # glob character intact.
680
- if [ "${#IS_SOURCE_FILES[@]}" -gt 0 ]; then
681
- for skill_file in "${IS_SOURCE_FILES[@]}"; do
682
- [ -n "$skill_file" ] || continue
683
- d="${skill_file%/SKILL.md}"
684
- skill_name="${d##*/}"
685
- skill_dirs+=("$d/")
686
- count=$((count + 1))
687
- log+=" skill: $skill_name"$'\n'
688
- done
689
- 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
690
882
  # copy_skill_bundle_dirs owns the frontmatter-quoting pass, so every
691
883
  # target gets it — not just this open-standard dir.
692
884
  if [ "$count" -gt 0 ]; then
693
- 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[@]}"
694
886
  open_skill_invocation_policies "$output_dir" || return 1
695
887
  fi
696
888
 
@@ -711,19 +903,15 @@ sync_open_skill_dirs() {
711
903
  # when the author's file sets none, and a file whose policy says otherwise, or
712
904
  # that sync cannot read as saying it, refuses the render — an owner-only skill
713
905
  # Codex can still select is the exact failure the field exists to prevent, so
714
- # it never passes as a success.
906
+ # it never passes as a success. The tree holds no symlink — copy_skill_bundle_dirs
907
+ # renders a link inside its source as a regular file and leaves any other out —
908
+ # so a skill whose SKILL.md or agents/openai.yaml is a link in its source is
909
+ # read and enforced like any other.
715
910
  open_skill_invocation_policies() {
716
911
  local output_dir="$1" path dir flag rows refusals
717
912
  local -a skill_mds=() policy_dirs=() policies=() authored=()
718
913
  for path in "$output_dir"/*/SKILL.md; do
719
- dir="${path%/SKILL.md}"
720
- # A link is emitted as-is (see _skill_bundle_note): reading through it
721
- # or writing beside it would reach outside the output tree.
722
- if [ -L "$dir" ] || [ -L "$path" ]; then
723
- echo " WARN: ${dir##*/}/SKILL.md is reached through a symlink — Codex gets no invocation policy derived for it" >&2
724
- elif [ -f "$path" ]; then
725
- skill_mds+=("$path")
726
- fi
914
+ [ ! -f "$path" ] || skill_mds+=("$path")
727
915
  done
728
916
  [ "${#skill_mds[@]}" -gt 0 ] || return 0
729
917
  # Captured, not streamed: a reader cut short must fail the render rather
@@ -736,10 +924,6 @@ open_skill_invocation_policies() {
736
924
  [ "$flag" = "true" ] || continue
737
925
  dir="${path%/SKILL.md}"
738
926
  path="$dir/agents"
739
- if [ -L "$path" ] || [ -L "$path/openai.yaml" ]; then
740
- echo " WARN: ${dir##*/}/agents/openai.yaml is a symlink — left as-is, its invocation policy is not enforced" >&2
741
- continue
742
- fi
743
927
  if [ -s "$path/openai.yaml" ]; then
744
928
  authored+=("$path/openai.yaml")
745
929
  continue
@@ -860,6 +1044,8 @@ enforce_authored_invocation_policies() {
860
1044
  # Lint YAML frontmatter for common pitfalls (unquoted colons, leading tabs).
861
1045
  # Print warnings to stderr; do not fail. Strict consumers (Codex CLI) reject
862
1046
  # these files with cryptic messages — catching them in sync gives better DX.
1047
+ # Each finding is a `WARNING:` line: the author has to edit the source, so
1048
+ # `sync --compact` (the first sync `init` runs) must keep it.
863
1049
  # Batched: one awk process lints every file passed.
864
1050
  # The engine lints every source once, so this is also where an `effort:` off
865
1051
  # IS_EFFORT_LEVELS is reported — read with frontmatter_index's semantics,
@@ -870,7 +1056,13 @@ enforce_authored_invocation_policies() {
870
1056
  # Usage: lint_frontmatter_files "a.md" "b.md" ...
871
1057
  lint_frontmatter_files() {
872
1058
  [ "$#" -gt 0 ] || return 0
873
- 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='
874
1066
  BEGIN {
875
1067
  nl = split(levels, LV, " ")
876
1068
  allowed = ""
@@ -892,7 +1084,7 @@ lint_frontmatter_files() {
892
1084
  }
893
1085
  }
894
1086
  in_fm && /^\t/ {
895
- printf " WARN: %s:%d leading tab in frontmatter (use spaces)\n", FILENAME, FNR > "/dev/stderr"
1087
+ printf "WARNING: %s:%d leading tab in frontmatter (use spaces)\n", FILENAME, FNR > "/dev/stderr"
896
1088
  }
897
1089
  in_fm && /^[a-zA-Z0-9_-]+:[[:space:]]+[^"\047|>[{]/ {
898
1090
  value_start = index($0, ":") + 1
@@ -900,10 +1092,10 @@ lint_frontmatter_files() {
900
1092
  sub(/^[[:space:]]+/, "", value)
901
1093
  if (value ~ /:[[:space:]]/ || value ~ /:$/) {
902
1094
  col = index(value, ":") + value_start
903
- printf " WARN: %s:%d unquoted colon in value at column %d — wrap value in quotes\n", FILENAME, FNR, col > "/dev/stderr"
1095
+ printf "WARNING: %s:%d unquoted colon in value at column %d — wrap value in quotes\n", FILENAME, FNR, col > "/dev/stderr"
904
1096
  }
905
1097
  if (value ~ /"/) {
906
- printf " WARN: %s:%d literal double quote in unquoted value — wrap value in single quotes or escape as \\\" so strict-YAML targets accept it\n", FILENAME, FNR > "/dev/stderr"
1098
+ printf "WARNING: %s:%d literal double quote in unquoted value — wrap value in single quotes or escape as \\\" so strict-YAML targets accept it\n", FILENAME, FNR > "/dev/stderr"
907
1099
  }
908
1100
  }
909
1101
  # Field-length limits. Both Claude Code and the Agent Skills standard
@@ -922,11 +1114,10 @@ lint_frontmatter_files() {
922
1114
  }
923
1115
  limit = (key == "name") ? 64 : 1024
924
1116
  if (length(val) > limit) {
925
- printf " WARN: %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"
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"
926
1118
  }
927
1119
  }
928
- ' "$@"
929
- }
1120
+ '
930
1121
 
931
1122
  # lint_frontmatter <file> — single-file form for project adapters.
932
1123
  lint_frontmatter() {
@@ -1561,7 +1752,8 @@ warn_unsynced() {
1561
1752
  load_yaml_list "$config_file" "$section"
1562
1753
  while IFS= read -r src; do
1563
1754
  [ -z "$src" ] && continue
1564
- all_sources+=("$src")
1755
+ normalize_path_var "$repo_root/$src"
1756
+ all_sources+=("${IS_NORM_PATH#"$repo_root/"}")
1565
1757
  done <<< "$IS_YAML_LIST"
1566
1758
  done
1567
1759
 
@@ -1655,11 +1847,7 @@ warn_unsynced() {
1655
1847
  done
1656
1848
 
1657
1849
  if [ "$matched" = false ]; then
1658
- if [ $warnings -eq 0 ]; then
1659
- echo ""
1660
- echo "=== WARNING: Unsynced directories ==="
1661
- fi
1662
- echo " NOT SYNCED: $rel_dir"
1850
+ echo "WARNING: NOT SYNCED: $rel_dir" >&2
1663
1851
  warnings=$((warnings + 1))
1664
1852
  fi
1665
1853
  done < <(find "$repo_root" \( -name ".git" -o -name "node_modules" -o -name "vendor" -o -name "dist" -o -name ".claude" -o -name ".cursor" -o -name ".github" -o -name ".codex" -o -name ".agents" -o -name ".intelligence" \) -prune -o -type d \( -name "rules" -o -name "agents" -o -name "skills" -o -name "Rules" -o -name "Agents" -o -name "Skills" \) -print 2>/dev/null)
@@ -1670,10 +1858,367 @@ warn_unsynced() {
1670
1858
  # and hand-placing an entry in an ordered list decides which artifact
1671
1859
  # wins. The engine may also run without the CLI around it.
1672
1860
  if [ "${IS_CLI:-0}" = "1" ]; then
1673
- echo " Wire one in: intelligence source add <rules|agents|skills> <path>"
1861
+ echo "WARNING: Wire one in: intelligence source add <rules|agents|skills> <path>" >&2
1674
1862
  else
1675
- echo " Add these paths to sources: in ${config_file##*/}"
1863
+ echo "WARNING: Add these paths to sources: in ${config_file##*/}" >&2
1864
+ fi
1865
+ fi
1866
+ }
1867
+
1868
+ # --- Skill sources and the symlinks inside them (decision 0017) ---
1869
+ #
1870
+ # A symlink in a skills source renders as the regular file or directory it
1871
+ # resolves to while that stays inside the same source, and is left out with a
1872
+ # WARNING: line when it resolves outside it, nowhere, or to a directory that
1873
+ # encloses it. Generated output never holds a link, and nothing is ever copied
1874
+ # from outside the source (decision 0006). Every consumer takes that decision
1875
+ # from here, once per run: the skill list read_source_artifact_files returns,
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.
1881
+ #
1882
+ # skill_source_inventory <repo_root> <config_file> fills, in the caller's
1883
+ # shell and fail-closed like read_source_artifact_files:
1884
+ # IS_SKI_LIST <source>/<skill>/SKILL.md of every rendered skill, one a line,
1885
+ # sources in manifest order, names in byte order;
1886
+ # IS_SKI_KEYS "\n<source>/<skill>\n…", the same skills as a membership set;
1887
+ # IS_SKI_PLAN "\n" + one record a line for every link in a rendered skill,
1888
+ # parents first: <source>/<skill> US <path in the skill copy>
1889
+ # US f|d|x US <physical target>. f and d materialize the file or
1890
+ # directory at that path, x leaves the link out; an empty path
1891
+ # is the skill directory itself.
1892
+ # The resolution spawns processes only for a source that holds a link, and
1893
+ # then per source and per link-chain hop, never per file.
1894
+ IS_SKI_US=$'\x1f'
1895
+
1896
+ skill_source_inventory() {
1897
+ local repo_root="$1" config_file="$2" memo_key src dir listing
1898
+ memo_key="$repo_root"$'\n'"$config_file"
1899
+ if [ "${IS_SOURCE_FILES_MEMO:-0}" = 1 ] && [ "${IS_SKI_KEY:-}" = "$memo_key" ]; then
1900
+ return 0
1901
+ fi
1902
+ IS_SKI_KEY=""
1903
+ IS_SKI_LIST=""
1904
+ IS_SKI_KEYS=$'\n'
1905
+ IS_SKI_PLAN=$'\n'
1906
+ load_yaml_list "$config_file" "skills"
1907
+ while IFS= read -r src; do
1908
+ [ -n "$src" ] || continue
1909
+ dir="$repo_root/$src"
1910
+ while [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done
1911
+ [ -d "$dir" ] || continue
1912
+ # Every directory and link at any depth: the top level holds the
1913
+ # skills, and a link anywhere below one is part of its bundle. -H
1914
+ # lists a source that is itself a link through it, as a glob does.
1915
+ # Command substitution, so find or sort failing stops the run here
1916
+ # instead of yielding a short list.
1917
+ listing="$(find -H "$dir" -mindepth 1 \( -type d -o -type l \) -print | LC_ALL=C sort)" || {
1918
+ echo "ERROR: cannot enumerate skills under '$dir' — refusing to render a partial list." >&2
1919
+ exit 1
1920
+ }
1921
+ [ -n "$listing" ] || continue
1922
+ _ski_source "$repo_root" "$dir" "$listing"
1923
+ done <<< "$IS_YAML_LIST"
1924
+ [ "${IS_SOURCE_FILES_MEMO:-0}" != 1 ] || IS_SKI_KEY="$memo_key"
1925
+ }
1926
+
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.
1932
+ skill_source_rendered() {
1933
+ _ski_key_var "$1"
1934
+ case "${IS_SKI_KEYS:-}" in
1935
+ *$'\n'"$_SKI_KEY"$'\n'*) return 0 ;;
1936
+ esac
1937
+ return 1
1938
+ }
1939
+
1940
+ # _ski_key_var <skill_dir> — the key a skill is listed and planned under, its
1941
+ # source and name joined by one slash however the caller spelled a trailing
1942
+ # slash on either.
1943
+ _ski_key_var() {
1944
+ local path="$1" parent
1945
+ while [ "${path%/}" != "$path" ]; do path="${path%/}"; done
1946
+ parent="${path%/*}"
1947
+ while [ -n "$parent" ] && [ "${parent%/}" != "$parent" ]; do parent="${parent%/}"; done
1948
+ _SKI_KEY="$parent/${path##*/}"
1949
+ }
1950
+
1951
+ # _ski_planned <skill_dir> — the skill has links the copy has to resolve.
1952
+ _ski_planned() {
1953
+ _ski_key_var "$1"
1954
+ case "${IS_SKI_PLAN:-}" in
1955
+ *$'\n'"$_SKI_KEY$IS_SKI_US"*) return 0 ;;
1956
+ esac
1957
+ return 1
1958
+ }
1959
+
1960
+ # _ski_source <repo_root> <source_dir> <listing> — inventory one skills source.
1961
+ _ski_source() {
1962
+ local repo_root="$1" f rel
1963
+ local -a tops=() links=()
1964
+ _SKI_DIR="$2"
1965
+ _SKI_ROOT_REL="${2#"$repo_root"/}"
1966
+ _SKI_REPO="$repo_root"
1967
+ while IFS= read -r f; do
1968
+ [ -n "$f" ] || continue
1969
+ rel="${f#"$_SKI_DIR"/}"
1970
+ case "$rel" in
1971
+ */*) [ ! -L "$f" ] || links+=("$f") ;;
1972
+ *)
1973
+ tops+=("$f")
1974
+ [ ! -L "$f" ] || links+=("$f")
1975
+ ;;
1976
+ esac
1977
+ done <<< "$3"
1978
+ _SKI_LREL=(); _SKI_LSTAT=(); _SKI_LKIND=(); _SKI_LTREL=(); _SKI_LPHYS=()
1979
+ [ "${#links[@]}" -eq 0 ] || _ski_classify_links "${links[@]}"
1980
+ for f in "${tops[@]+"${tops[@]}"}"; do
1981
+ _ski_skill "${f##*/}"
1982
+ done
1983
+ }
1984
+
1985
+ # _ski_classify_links <link>... — resolve every link of the current source and
1986
+ # record where it leads: ok (inside the source), outside, dangling, or other
1987
+ # (neither a file nor a directory). A link that cannot be resolved stops the
1988
+ # run, like any enumeration that cannot answer.
1989
+ _ski_classify_links() {
1990
+ local out root_phys rec kind phys i=0 link
1991
+ local -a recs=()
1992
+ out="$(_ski_physical "$_SKI_DIR" "$@")" || {
1993
+ echo "ERROR: cannot resolve the symlinks under '$_SKI_DIR' — refusing to render a partial list." >&2
1994
+ exit 1
1995
+ }
1996
+ while IFS= read -r rec; do recs+=("$rec"); done <<< "$out"
1997
+ if [ "${#recs[@]}" -ne $(($# + 1)) ]; then
1998
+ echo "ERROR: cannot resolve the symlinks under '$_SKI_DIR' — refusing to render a partial list." >&2
1999
+ exit 1
2000
+ fi
2001
+ root_phys="${recs[0]%/}"
2002
+ for link in "$@"; do
2003
+ rec="${recs[i + 1]}"
2004
+ kind="${rec%%"$IS_SKI_US"*}"
2005
+ phys="${rec#*"$IS_SKI_US"}"
2006
+ _SKI_LREL[i]="${link#"$_SKI_DIR"/}"
2007
+ _SKI_LKIND[i]="$kind"
2008
+ _SKI_LPHYS[i]="$phys"
2009
+ _SKI_LTREL[i]=""
2010
+ case "$kind" in
2011
+ d|f)
2012
+ case "$phys" in
2013
+ "$root_phys"/*) _SKI_LSTAT[i]=ok; _SKI_LTREL[i]="${phys#"$root_phys"/}" ;;
2014
+ "${root_phys:-/}") _SKI_LSTAT[i]=ok ;;
2015
+ *) _SKI_LSTAT[i]=outside ;;
2016
+ esac
2017
+ ;;
2018
+ o) _SKI_LSTAT[i]=other ;;
2019
+ *) _SKI_LSTAT[i]=dangling ;;
2020
+ esac
2021
+ i=$((i + 1))
2022
+ done
2023
+ }
2024
+
2025
+ # _ski_physical <source_dir> <link>... — print the source's physical path,
2026
+ # then one "<kind> US <physical target>" line per link: d (directory), f
2027
+ # (regular file), o (anything else) or x (dangling, or a link loop). Runs in a
2028
+ # subshell so it can change directory: `cd -P` resolves a directory, and a
2029
+ # file's chain is read with one readlink per hop for all links at once (no
2030
+ # readlink -f on macOS).
2031
+ _ski_physical() (
2032
+ local dir="$1" link target targets i k n hops=0 parent
2033
+ local -a kind=() cur=() phys=() pend=() next=() paths=()
2034
+ shift
2035
+ n=$#
2036
+ cd -P -- "$dir" 2>/dev/null || exit 1
2037
+ printf '%s\n' "$PWD"
2038
+ i=0
2039
+ for link in "$@"; do
2040
+ kind[i]=x; phys[i]=""; cur[i]="$link"
2041
+ if [ -d "$link" ]; then
2042
+ cd -P -- "$link" 2>/dev/null || exit 1
2043
+ kind[i]=d; phys[i]="$PWD"
2044
+ elif [ -f "$link" ]; then
2045
+ kind[i]=f; pend+=("$i")
2046
+ elif [ -e "$link" ]; then
2047
+ kind[i]=o
2048
+ fi
2049
+ i=$((i + 1))
2050
+ done
2051
+ while [ "${#pend[@]}" -gt 0 ]; do
2052
+ hops=$((hops + 1))
2053
+ [ "$hops" -le 40 ] || exit 1
2054
+ paths=()
2055
+ for i in "${pend[@]}"; do paths+=("${cur[i]}"); done
2056
+ targets="$(readlink "${paths[@]}")" || exit 1
2057
+ next=(); k=0
2058
+ while IFS= read -r target; do
2059
+ # More lines than links: a target held a newline.
2060
+ [ "$k" -lt "${#pend[@]}" ] || exit 1
2061
+ i="${pend[k]}"
2062
+ case "$target" in
2063
+ /*) cur[i]="$target" ;;
2064
+ *) cur[i]="${cur[i]%/*}/$target" ;;
2065
+ esac
2066
+ [ ! -L "${cur[i]}" ] || next+=("$i")
2067
+ k=$((k + 1))
2068
+ done <<< "$targets"
2069
+ [ "$k" -eq "${#pend[@]}" ] || exit 1
2070
+ pend=("${next[@]+"${next[@]}"}")
2071
+ done
2072
+ for ((i = 0; i < n; i++)); do
2073
+ if [ "${kind[i]}" = f ]; then
2074
+ parent="${cur[i]%/*}"
2075
+ cd -P -- "${parent:-/}" 2>/dev/null || exit 1
2076
+ phys[i]="${PWD%/}/${cur[i]##*/}"
1676
2077
  fi
2078
+ printf '%s%s%s\n' "${kind[i]}" "$IS_SKI_US" "${phys[i]}"
2079
+ done
2080
+ )
2081
+
2082
+ # _ski_link_var <path in source> — set _SKI_K to the index of the link at that
2083
+ # path, or -1.
2084
+ _ski_link_var() {
2085
+ local k=0
2086
+ _SKI_K=-1
2087
+ while [ "$k" -lt "${#_SKI_LREL[@]}" ]; do
2088
+ if [ "${_SKI_LREL[k]}" = "$1" ]; then
2089
+ _SKI_K="$k"
2090
+ return 0
2091
+ fi
2092
+ k=$((k + 1))
2093
+ done
2094
+ }
2095
+
2096
+ # _ski_loops <target> <open> — materializing the directory at <target> would
2097
+ # copy one of the <open> paths (newline-separated) being materialized, the
2098
+ # link itself included, so it would never end.
2099
+ _ski_loops() {
2100
+ local target="$1" open
2101
+ [ -n "$target" ] || return 0
2102
+ while IFS= read -r open; do
2103
+ case "$open" in "$target"|"$target"/*) return 0 ;; esac
2104
+ done <<< "$2"
2105
+ return 1
2106
+ }
2107
+
2108
+ # _ski_skill <name> — decide one top-level entry of the current source: a
2109
+ # rendered skill (with its plan and warnings), a skill left out, or no skill.
2110
+ _ski_skill() {
2111
+ local name="$1" key content open="" state rel link reason nl=$'\n'
2112
+ key="$_SKI_DIR/$name"
2113
+ _SKI_ENTRIES="$nl"
2114
+ _SKI_DROPS=""
2115
+ _ski_link_var "$name"
2116
+ if [ "$_SKI_K" -ge 0 ]; then
2117
+ # The skill directory is itself a link. A test through one that
2118
+ # leaves the source only stats it; nothing is read from outside.
2119
+ case "${_SKI_LSTAT[_SKI_K]}:${_SKI_LKIND[_SKI_K]}" in
2120
+ ok:d) ;;
2121
+ outside:d)
2122
+ _ski_warn "$name" "" "$name" outside
2123
+ return 0
2124
+ ;;
2125
+ dangling:*)
2126
+ _ski_warn "$name" "" "$name" dangling
2127
+ return 0
2128
+ ;;
2129
+ *) return 0 ;;
2130
+ esac
2131
+ content="${_SKI_LTREL[_SKI_K]}"
2132
+ if _ski_loops "$content" ""; then
2133
+ [ ! -f "$_SKI_DIR/$name/SKILL.md" ] || _ski_warn "$name" "" "$name" loop
2134
+ return 0
2135
+ fi
2136
+ _SKI_ENTRIES="$_SKI_ENTRIES$key$IS_SKI_US${IS_SKI_US}d$IS_SKI_US${_SKI_LPHYS[_SKI_K]}$nl"
2137
+ open="$nl"
2138
+ else
2139
+ content="$name"
2140
+ fi
2141
+ _ski_expand "$key" "$content" "" "$open"
2142
+ # A skill is a directory whose SKILL.md renders as a regular file.
2143
+ case "$_SKI_ENTRIES" in
2144
+ *"$nl$key${IS_SKI_US}SKILL.md${IS_SKI_US}f$IS_SKI_US"*) state=ok ;;
2145
+ *"$nl$key${IS_SKI_US}SKILL.md${IS_SKI_US}x$IS_SKI_US"*) state=dropped ;;
2146
+ *"$nl$key${IS_SKI_US}SKILL.md$IS_SKI_US"*) state=none ;;
2147
+ *)
2148
+ state=none
2149
+ [ ! -f "$_SKI_DIR/$content/SKILL.md" ] || state=ok
2150
+ ;;
2151
+ esac
2152
+ case "$state" in
2153
+ none) return 0 ;;
2154
+ dropped)
2155
+ while IFS="$IS_SKI_US" read -r rel link reason; do
2156
+ [ "$rel" != "SKILL.md" ] || _ski_warn "$name" "" "$link" "$reason"
2157
+ done <<< "$_SKI_DROPS"
2158
+ return 0
2159
+ ;;
2160
+ esac
2161
+ IS_SKI_LIST="$IS_SKI_LIST$_SKI_DIR/$name/SKILL.md$nl"
2162
+ IS_SKI_KEYS="$IS_SKI_KEYS$key$nl"
2163
+ IS_SKI_PLAN="$IS_SKI_PLAN${_SKI_ENTRIES#"$nl"}"
2164
+ while IFS="$IS_SKI_US" read -r rel link reason; do
2165
+ [ -z "$rel" ] || _ski_warn "$name" "$rel" "$link" "$reason"
2166
+ done <<< "$_SKI_DROPS"
2167
+ }
2168
+
2169
+ # _ski_expand <key> <content> <prefix> <open> — plan every link inside the
2170
+ # source directory <content> as it lands at <prefix> in the skill copy,
2171
+ # recursing into each directory a link materializes. <open> holds the
2172
+ # locations of the links already being materialized, for _ski_loops.
2173
+ _ski_expand() {
2174
+ local key="$1" content="$2" prefix="$3" open="$4" k=0 rel out location
2175
+ while [ "$k" -lt "${#_SKI_LREL[@]}" ]; do
2176
+ rel="${_SKI_LREL[k]}"
2177
+ case "$rel" in
2178
+ "$content"/*) ;;
2179
+ *) k=$((k + 1)); continue ;;
2180
+ esac
2181
+ out="${prefix:+$prefix/}${rel#"$content"/}"
2182
+ case "${_SKI_LSTAT[k]}:${_SKI_LKIND[k]}" in
2183
+ ok:f)
2184
+ _SKI_ENTRIES="$_SKI_ENTRIES$key$IS_SKI_US$out${IS_SKI_US}f$IS_SKI_US${_SKI_LPHYS[k]}"$'\n'
2185
+ ;;
2186
+ ok:d)
2187
+ location="${rel%/*}"
2188
+ if _ski_loops "${_SKI_LTREL[k]}" "$open$location"; then
2189
+ _ski_drop "$key" "$out" "$rel" loop
2190
+ else
2191
+ _SKI_ENTRIES="$_SKI_ENTRIES$key$IS_SKI_US$out${IS_SKI_US}d$IS_SKI_US${_SKI_LPHYS[k]}"$'\n'
2192
+ _ski_expand "$key" "${_SKI_LTREL[k]}" "$out" "$open$location"$'\n'
2193
+ fi
2194
+ ;;
2195
+ *) _ski_drop "$key" "$out" "$rel" "${_SKI_LSTAT[k]}" ;;
2196
+ esac
2197
+ k=$((k + 1))
2198
+ done
2199
+ }
2200
+
2201
+ # _ski_drop <key> <path in copy> <path in source> <reason> — leave a link out.
2202
+ _ski_drop() {
2203
+ _SKI_ENTRIES="$_SKI_ENTRIES$1$IS_SKI_US$2${IS_SKI_US}x$IS_SKI_US"$'\n'
2204
+ _SKI_DROPS="$_SKI_DROPS$2$IS_SKI_US$3$IS_SKI_US$4"$'\n'
2205
+ }
2206
+
2207
+ # _ski_warn <skill> <path in copy> <path in source> <reason> — one WARNING:
2208
+ # line naming the skill and the link by its own path. An empty <path in copy>
2209
+ # means the whole skill is left out.
2210
+ _ski_warn() {
2211
+ local link="$_SKI_ROOT_REL/$3" what
2212
+ case "$4" in
2213
+ outside) what="is a symlink that resolves outside $_SKI_ROOT_REL" ;;
2214
+ dangling) what="is a dangling symlink" ;;
2215
+ loop) what="is a symlink to a directory that encloses it" ;;
2216
+ *) what="is a symlink to neither a file nor a directory" ;;
2217
+ esac
2218
+ if [ -n "$2" ]; then
2219
+ echo "WARNING: skill '$1' renders without $2: $link $what" >&2
2220
+ else
2221
+ echo "WARNING: skill '$1' is left out of every output: $link $what" >&2
1677
2222
  fi
1678
2223
  }
1679
2224
 
@@ -1696,6 +2241,9 @@ warn_unsynced() {
1696
2241
  # replay that answer. Only the engine sets it: a sync never writes into a
1697
2242
  # source (validate_output_path refuses), so each caller would otherwise re-run
1698
2243
  # the same find and sort for every source directory.
2244
+ #
2245
+ # Skills come from skill_source_inventory, which also decides what a symlink
2246
+ # inside a skills source renders as.
1699
2247
  read_source_artifact_files() {
1700
2248
  local repo_root="$1"
1701
2249
  local config_file="$2"
@@ -1703,8 +2251,15 @@ read_source_artifact_files() {
1703
2251
  local src f dir listing memo_key="" key_var="" list_var="" joined=""
1704
2252
 
1705
2253
  IS_SOURCE_FILES=()
2254
+ if [ "$section" = "skills" ]; then
2255
+ skill_source_inventory "$repo_root" "$config_file"
2256
+ while IFS= read -r f; do
2257
+ [ -n "$f" ] && IS_SOURCE_FILES+=("$f")
2258
+ done <<< "$IS_SKI_LIST"
2259
+ return 0
2260
+ fi
1706
2261
  case "${IS_SOURCE_FILES_MEMO:-0}:$section" in
1707
- 1:rules|1:agents|1:skills)
2262
+ 1:rules|1:agents)
1708
2263
  key_var="IS_SF_${section}_KEY"
1709
2264
  list_var="IS_SF_${section}_LIST"
1710
2265
  memo_key="$repo_root"$'\n'"$config_file"
@@ -1724,15 +2279,11 @@ read_source_artifact_files() {
1724
2279
  [ -d "$dir" ] || continue
1725
2280
  # Command substitution, so the pipeline's status is the assignment's:
1726
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.
1727
2284
  case "$section" in
1728
2285
  rules|agents)
1729
- listing="$(find "$dir" -maxdepth 1 -type f -name '*.md' -print | LC_ALL=C sort)" || {
1730
- echo "ERROR: cannot enumerate $section under '$dir' — refusing to render a partial list." >&2
1731
- exit 1
1732
- }
1733
- ;;
1734
- skills)
1735
- listing="$(find "$dir" -mindepth 1 -maxdepth 1 -type d -print | LC_ALL=C sort)" || {
2286
+ listing="$(find "$dir" -maxdepth 1 -name '*.md' \( -type f -o -type l \) -print | LC_ALL=C sort)" || {
1736
2287
  echo "ERROR: cannot enumerate $section under '$dir' — refusing to render a partial list." >&2
1737
2288
  exit 1
1738
2289
  }
@@ -1743,11 +2294,7 @@ read_source_artifact_files() {
1743
2294
  # A here-string redirect keeps this loop in the current shell, so the
1744
2295
  # array it fills survives and any failure inside it is this shell's.
1745
2296
  while IFS= read -r f; do
1746
- [ -n "$f" ] || continue
1747
- if [ "$section" = "skills" ]; then
1748
- [ -f "$f/SKILL.md" ] || continue
1749
- f="$f/SKILL.md"
1750
- fi
2297
+ [ -f "$f" ] || continue
1751
2298
  IS_SOURCE_FILES+=("$f")
1752
2299
  joined="$joined$f"$'\n'
1753
2300
  done <<< "$listing"
@@ -1758,6 +2305,556 @@ read_source_artifact_files() {
1758
2305
  fi
1759
2306
  }
1760
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
+
1761
2858
  # Apply the agents adapter's lexical output rule without consulting filesystem
1762
2859
  # state. An omitted output uses the generic adapter default directory.
1763
2860
  agents_output_path_var() {
@@ -1827,26 +2924,23 @@ context_files_bytes() {
1827
2924
  report_context_source_sizes() {
1828
2925
  local repo_root="$1"
1829
2926
  local config_file="$2"
1830
- local f path has_paths
1831
- local -a rule_files=() always_on_rules=() scoped_rules=() agent_files=() skill_files=()
1832
-
1833
- read_source_artifact_files "$repo_root" "$config_file" "rules"
1834
- [ "${#IS_SOURCE_FILES[@]}" -eq 0 ] || rule_files=("${IS_SOURCE_FILES[@]}")
1835
- if [ "${#rule_files[@]}" -gt 0 ]; then
1836
- while IFS=$'\x1f' read -r path has_paths; do
1837
- [ -n "$path" ] || continue
1838
- if [ "$has_paths" = "0" ]; then
1839
- always_on_rules+=("$path")
1840
- else
1841
- scoped_rules+=("$path")
1842
- fi
1843
- done < <(frontmatter_index "paths#" "${rule_files[@]}")
1844
- 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
1845
2939
 
1846
- read_source_artifact_files "$repo_root" "$config_file" "agents"
2940
+ source_catalog_winners_var "$repo_root" "$config_file" "agents"
1847
2941
  [ "${#IS_SOURCE_FILES[@]}" -eq 0 ] || agent_files=("${IS_SOURCE_FILES[@]}")
1848
2942
 
1849
- read_source_artifact_files "$repo_root" "$config_file" "skills"
2943
+ source_catalog_winners_var "$repo_root" "$config_file" "skills"
1850
2944
  [ "${#IS_SOURCE_FILES[@]}" -eq 0 ] || skill_files=("${IS_SOURCE_FILES[@]}")
1851
2945
 
1852
2946
  local always_bytes=0 custom_bytes=0 agents_output agents_bytes=0 agents_status="disabled"
@@ -2106,8 +3200,9 @@ IS_YAML_LIST_AWK='
2106
3200
  # Consults the load_yaml_list cache first: sync reads the same sections from
2107
3201
  # the same manifest dozens of times, and each awk spawn costs tens of
2108
3202
  # milliseconds on Windows. The cache is only ever populated by
2109
- # load_yaml_list, which the engine calls for a manifest it never mutates, so
2110
- # a CLI process that edits the manifest keeps reading the file directly.
3203
+ # load_yaml_list and load_yaml_lists. The engine never mutates its manifest;
3204
+ # CLI writers invalidate it through qmap_memo_reset; load_yaml_lists and the
3205
+ # project preloader also populate it.
2111
3206
  read_yaml_list() {
2112
3207
  local file="$1"
2113
3208
  local section="$2"
@@ -2215,22 +3310,17 @@ load_yaml_list() {
2215
3310
  esac
2216
3311
  }
2217
3312
 
2218
- # load_yaml_lists <file> <section>... — warm the load_yaml_list cache for several
2219
- # sections in one manifest pass. Each section runs read_yaml_list's own state
2220
- # machine and its reference expansion, so every cached value is what
2221
- # load_yaml_list would have stored. The same pass leaves the references that
2222
- # resolve to nothing in IS_YL_UNRESOLVED, one read_source_entries-shaped line
2223
- # each with the section in front, for the WARNING: lines `sync` prints.
2224
- load_yaml_lists() {
2225
- local file="$1" out line s v j
2226
- shift
2227
- for s in "$@"; do
2228
- case "$s" in
2229
- rules|agents|skills|ignore|submodules) ;;
2230
- *) return 1 ;;
2231
- esac
2232
- done
2233
- out="$(awk -v sections="$*" "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK"'
3313
+ # The manifest readers below share their awk programs, so the combined pass in
3314
+ # load_manifest_view reads each shape exactly as its single-purpose reader does.
3315
+ # Rule order matters when they are joined: the targets program ends its rules
3316
+ # with `next`, so it runs last and never hides a line from the others.
3317
+ #
3318
+ # The lists program carries the package-reference parser with it: it sees every
3319
+ # line, so `packages:` is collected wherever it stands, and each source list is
3320
+ # expanded exactly as read_yaml_list expands it. A reference that resolves to
3321
+ # nothing comes out as a `!` row, which _yaml_lists_store keeps in
3322
+ # IS_YL_UNRESOLVED.
3323
+ _IS_AWK_LISTS="$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK"'
2234
3324
  BEGIN { n = split(sections, want, " ") }
2235
3325
  { sub(/\r$/, ""); pkr_collect($0) }
2236
3326
  {
@@ -2261,42 +3351,33 @@ load_yaml_lists() {
2261
3351
  else print "!\037" s "\037" val "\037" PKR_STATE "\037" dir "\037" PKR_ALIAS "\037" PKR_HOLDERS
2262
3352
  }
2263
3353
  }
2264
- ' "$file")"
2265
- for s in "$@"; do
2266
- j=""
2267
- while IFS= read -r line; do
2268
- [ "${line%%$'\037'*}" = "$s" ] || continue
2269
- v="${line#*$'\037'}"
2270
- j="$j$v"$'\n'
2271
- done <<< "$out"
2272
- while [[ "$j" == *$'\n' ]]; do j="${j%$'\n'}"; done
2273
- printf -v "IS_YL_${s}_FILE" '%s' "$file"
2274
- printf -v "IS_YL_${s}_VAL" '%s' "$j"
2275
- done
2276
- IS_YL_UNRESOLVED=""
2277
- while IFS= read -r line; do
2278
- case "$line" in
2279
- "!"$'\037'*) IS_YL_UNRESOLVED="$IS_YL_UNRESOLVED${line#"!"$'\037'}"$'\n' ;;
2280
- esac
2281
- done <<< "$out"
2282
- }
3354
+ '
2283
3355
 
2284
- # load_targets_cache <file> — parse the whole targets: section once into the
2285
- # global IS_TGT_TSV (one `name<TAB>enabled<TAB>output` row per target),
2286
- # matching is_target_enabled and get_target_output semantics: inline and block
2287
- # forms, first occurrence wins, `enabled` defaults to 0 when the block ends
2288
- # without one and to empty at end of file. The engine warms this once;
2289
- # both readers consult it before spawning awk.
2290
- load_targets_cache() {
2291
- local file="$1"
2292
- if [ "${IS_TGT_FILE:-}" = "$file" ]; then
2293
- return 0
2294
- fi
2295
- IS_TGT_TSV="$(awk '
3356
+ # get_yaml_field: the first ` fkey:` inside the first `fsection:` block. A
3357
+ # top-level key closes the block; ftag prefixes the printed value.
3358
+ _IS_AWK_FIELD='
3359
+ { sub(/\r$/, "") }
3360
+ !fdone {
3361
+ if ($0 ~ "^" fsection ":") fin = 1
3362
+ else if (fin && /^[a-zA-Z]/) fdone = 1
3363
+ else if (fin && $0 ~ "^ " fkey ":") {
3364
+ fval = $0
3365
+ sub(/.*:[[:space:]]*["\047]?/, "", fval)
3366
+ sub(/["\047]?[[:space:]]*$/, "", fval)
3367
+ ffound = 1
3368
+ fdone = 1
3369
+ }
3370
+ }
3371
+ END { if (ffound) print ftag fval }
3372
+ '
3373
+
3374
+ # load_targets_cache: one `name<TAB>enabled<TAB>output` row per target, each
3375
+ # prefixed with ttag.
3376
+ _IS_AWK_TARGETS='
2296
3377
  function flush_target(at_sibling) {
2297
3378
  if (name == "") return
2298
3379
  if (enabled == "" && at_sibling) enabled = 0
2299
- printf "%s\t%s\t%s\n", name, enabled, output
3380
+ printf "%s%s\t%s\t%s\n", ttag, name, enabled, output
2300
3381
  name = ""
2301
3382
  }
2302
3383
  { sub(/\r$/, "") }
@@ -2332,10 +3413,147 @@ load_targets_cache() {
2332
3413
  output = val
2333
3414
  }
2334
3415
  END { flush_target(0) }
2335
- ' "$file")"
3416
+ '
3417
+
3418
+ # _yaml_lists_store <file> <awk-output> <section>... — fill the load_yaml_list
3419
+ # cache for each section from `<section><US><value>` rows, and IS_YL_UNRESOLVED
3420
+ # from the `!` rows: one read_source_entries-shaped line each, the section in
3421
+ # front.
3422
+ _yaml_lists_store() {
3423
+ local file="$1" out="$2" line v s k saved_ifs="$IFS"
3424
+ local -a lines=() names=() vals=()
3425
+ shift 2
3426
+ names=("$@")
3427
+ IFS=$'\n'
3428
+ set -f
3429
+ # shellcheck disable=SC2206 # rows are `<section><US><value>`, never empty
3430
+ lines=($out)
3431
+ set +f
3432
+ IFS="$saved_ifs"
3433
+ IS_YL_UNRESOLVED=""
3434
+ # One pass over the rows in this shell: reading a here-string again per
3435
+ # section costs a system call per byte on Git Bash.
3436
+ for line in "${lines[@]+"${lines[@]}"}"; do
3437
+ s="${line%%$'\037'*}"
3438
+ v="${line#*$'\037'}"
3439
+ if [ "$s" = "!" ]; then
3440
+ IS_YL_UNRESOLVED="$IS_YL_UNRESOLVED$v"$'\n'
3441
+ continue
3442
+ fi
3443
+ for ((k = 0; k < ${#names[@]}; k++)); do
3444
+ [ "${names[k]}" = "$s" ] || continue
3445
+ vals[k]="${vals[k]-}$v"$'\n'
3446
+ done
3447
+ done
3448
+ for ((k = 0; k < ${#names[@]}; k++)); do
3449
+ v="${vals[k]-}"
3450
+ while [[ "$v" == *$'\n' ]]; do v="${v%$'\n'}"; done
3451
+ printf -v "IS_YL_${names[k]}_FILE" '%s' "$file"
3452
+ printf -v "IS_YL_${names[k]}_VAL" '%s' "$v"
3453
+ done
3454
+ }
3455
+
3456
+ # load_yaml_lists <file> <section>... — warm the load_yaml_list cache for several
3457
+ # sections in one manifest pass. Each section runs read_yaml_list's own state
3458
+ # machine and its reference expansion, so every cached value is what
3459
+ # load_yaml_list would have stored. The same pass leaves the references that
3460
+ # resolve to nothing in IS_YL_UNRESOLVED, one read_source_entries-shaped line
3461
+ # each with the section in front, for the WARNING: lines `sync` prints.
3462
+ load_yaml_lists() {
3463
+ local file="$1" out s
3464
+ shift
3465
+ for s in "$@"; do
3466
+ case "$s" in
3467
+ rules|agents|skills|ignore|submodules) ;;
3468
+ *) return 1 ;;
3469
+ esac
3470
+ done
3471
+ out="$(awk -v sections="$*" "$_IS_AWK_LISTS" "$file")"
3472
+ _yaml_lists_store "$file" "$out" "$@"
3473
+ }
3474
+
3475
+ # load_targets_cache <file> — parse the whole targets: section once into the
3476
+ # global IS_TGT_TSV (one `name<TAB>enabled<TAB>output` row per target),
3477
+ # matching is_target_enabled and get_target_output semantics: inline and block
3478
+ # forms, first occurrence wins, `enabled` defaults to 0 when the block ends
3479
+ # without one and to empty at end of file. The engine warms this once;
3480
+ # both readers consult it before spawning awk.
3481
+ load_targets_cache() {
3482
+ local file="$1"
3483
+ if [ "${IS_TGT_FILE:-}" = "$file" ]; then
3484
+ return 0
3485
+ fi
3486
+ IS_TGT_TSV="$(awk "$_IS_AWK_TARGETS" "$file")"
3487
+ IS_TGT_FILE="$file"
3488
+ }
3489
+
3490
+ # load_manifest_view <file> <field-section> <field-key> <section>... — one
3491
+ # awk pass for what the CLI's preflight reads of a manifest: the
3492
+ # load_yaml_lists cache for each <section>, the targets cache, and the one
3493
+ # get_yaml_field answer that get_yaml_field_var then returns without a process.
3494
+ # Each reader's program is the one it runs alone, so the answers cannot differ.
3495
+ # A caller that overlaps the pass with other work runs manifest_view_run (it
3496
+ # prints) and hands the output to manifest_view_store itself.
3497
+ load_manifest_view() {
3498
+ local out
3499
+ out="$(manifest_view_run "$@")" || return 1
3500
+ manifest_view_store "$out" "$@"
3501
+ }
3502
+
3503
+ manifest_view_run() {
3504
+ local file="$1" fsection="$2" fkey="$3" s
3505
+ shift 3
3506
+ for s in "$@"; do
3507
+ case "$s" in
3508
+ rules|agents|skills|ignore|submodules) ;;
3509
+ *) return 1 ;;
3510
+ esac
3511
+ done
3512
+ awk -v sections="$*" -v fsection="$fsection" -v fkey="$fkey" \
3513
+ -v ftag=$'\036F' -v ttag=$'\036T' \
3514
+ "$_IS_AWK_LISTS$_IS_AWK_FIELD$_IS_AWK_TARGETS" "$file"
3515
+ }
3516
+
3517
+ # manifest_view_store <output> <file> <field-section> <field-key> <section>...
3518
+ manifest_view_store() {
3519
+ local out="$1" file="$2" fsection="$3" fkey="$4" line lists="" targets="" field="" found=0
3520
+ local saved_ifs="$IFS"
3521
+ local -a lines=()
3522
+ shift 4
3523
+ IFS=$'\n'
3524
+ set -f
3525
+ # shellcheck disable=SC2206 # every row is tagged or `<section><US>`, never empty
3526
+ lines=($out)
3527
+ set +f
3528
+ IFS="$saved_ifs"
3529
+ for line in "${lines[@]+"${lines[@]}"}"; do
3530
+ case "$line" in
3531
+ $'\036T'*) targets="$targets${line#??}"$'\n' ;;
3532
+ $'\036F'*) field="${line#??}"; found=1 ;;
3533
+ *) lists="$lists$line"$'\n' ;;
3534
+ esac
3535
+ done
3536
+ while [[ "$targets" == *$'\n' ]]; do targets="${targets%$'\n'}"; done
3537
+ _yaml_lists_store "$file" "$lists" "$@"
3538
+ IS_TGT_TSV="$targets"
2336
3539
  IS_TGT_FILE="$file"
3540
+ IS_YF_KEY="$file"$'\037'"$fsection"$'\037'"$fkey"
3541
+ IS_YF_VALUE="$field"
3542
+ IS_YF_FOUND="$found"
3543
+ }
3544
+
3545
+ # manifest_view_reset — forget every manifest answer cached in this shell. A
3546
+ # process that has the manifest rewritten (by a child it ran) calls it before
3547
+ # reading again.
3548
+ manifest_view_reset() {
3549
+ local s
3550
+ for s in rules agents skills ignore submodules; do
3551
+ unset "IS_YL_${s}_FILE" "IS_YL_${s}_VAL"
3552
+ done
3553
+ unset IS_TGT_FILE IS_TGT_TSV IS_YF_KEY IS_YF_VALUE IS_YF_FOUND IS_YL_UNRESOLVED
2337
3554
  }
2338
3555
 
3556
+
2339
3557
  # _targets_cache_row <target> — scan the cached TSV; sets IS_TGT_ROW_ENABLED /
2340
3558
  # IS_TGT_ROW_OUTPUT. Returns 1 when the target has no row (reader falls back
2341
3559
  # to empty, same as the awk scan finding nothing).
@@ -2557,18 +3775,23 @@ get_yaml_field() {
2557
3775
  local file="$1"
2558
3776
  local section="$2"
2559
3777
  local key="$3"
2560
- awk -v section="$section" -v key="$key" '
2561
- { sub(/\r$/, "") }
2562
- $0 ~ "^" section ":" { in_section = 1; next }
2563
- in_section && /^[a-zA-Z]/ { exit }
2564
- in_section && $0 ~ "^ " key ":" {
2565
- val = $0
2566
- sub(/.*:[[:space:]]*["\047]?/, "", val)
2567
- sub(/["\047]?[[:space:]]*$/, "", val)
2568
- print val
2569
- exit
2570
- }
2571
- ' "$file"
3778
+ if [ "${IS_YF_KEY:-}" = "$file"$'\037'"$section"$'\037'"$key" ]; then
3779
+ [ "$IS_YF_FOUND" != 1 ] || printf '%s\n' "$IS_YF_VALUE"
3780
+ return 0
3781
+ fi
3782
+ awk -v fsection="$section" -v fkey="$key" "$_IS_AWK_FIELD" "$file"
3783
+ }
3784
+
3785
+ # get_yaml_field_var <file> <section> <key> — get_yaml_field into
3786
+ # IS_YAML_FIELD, without a process when load_manifest_view read it.
3787
+ # shellcheck disable=SC2034 # IS_YAML_FIELD is the return channel
3788
+ get_yaml_field_var() {
3789
+ if [ "${IS_YF_KEY:-}" = "$1"$'\037'"$2"$'\037'"$3" ]; then
3790
+ IS_YAML_FIELD=""
3791
+ [ "$IS_YF_FOUND" != 1 ] || IS_YAML_FIELD="$IS_YF_VALUE"
3792
+ return 0
3793
+ fi
3794
+ IS_YAML_FIELD="$(get_yaml_field "$@")"
2572
3795
  }
2573
3796
 
2574
3797
  # Get project name from config.yaml (project.name)