@ainova-systems/intelligence 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -452,8 +452,11 @@ copy_md_with_quoted_frontmatter() {
452
452
  # ship support files beside SKILL.md, and SKILL.md bodies point at them by
453
453
  # relative path, so dropping them breaks the skill at runtime). Markdown is
454
454
  # 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.
455
+ # binary assets survive. A symlink never reaches the output: the plan
456
+ # skill_source_inventory made turns a link inside its source into the regular
457
+ # file or directory it points at and leaves any other out, and the flush
458
+ # removes a link no plan covered (a copy from outside every skills source), so
459
+ # no host file outside the source is ever copied in (decision 0017).
457
460
  #
458
461
  # SKILL.md frontmatter is quoted for EVERY consumer, not only the strict-YAML
459
462
  # ones. `argument-hint: [pr-number]` is a YAML *flow sequence*, so an unquoted
@@ -470,7 +473,7 @@ copy_md_with_quoted_frontmatter() {
470
473
  # Usage: copy_skill_bundle "src/skill/dir" "dest/skill/dir"
471
474
  copy_skill_bundle() {
472
475
  _skill_bundles_reset
473
- _skill_bundle_stage "$1" "$2"
476
+ _skill_bundle_stage "$1" "$2" || return 1
474
477
  _skill_bundles_flush open
475
478
  }
476
479
 
@@ -478,7 +481,8 @@ copy_skill_bundle() {
478
481
  # copies every source skill directory into <dest_root>/<skill-name>, then one
479
482
  # awk pass quotes and finalizes every bundled markdown file across all
480
483
  # bundles. A later source with the same skill name overwrites file-by-file in
481
- # order, exactly like the sequential per-bundle copies did.
484
+ # order, exactly like the sequential per-bundle copies did. A skill with links
485
+ # to resolve is copied from its materialized stage, in the same order.
482
486
  copy_skill_bundle_dirs() {
483
487
  copy_skill_bundle_dirs_for open "$@"
484
488
  }
@@ -489,13 +493,27 @@ copy_skill_bundle_dirs_for() {
489
493
  local effort_tool="$1" dest_root="$2"
490
494
  shift 2
491
495
  [ "$#" -gt 0 ] || return 0
492
- local src dest
496
+ local src dest linked=0
493
497
  local -a srcs=()
494
498
  for src in "$@"; do
495
- srcs+=("${src%/}")
499
+ src="${src%/}"
500
+ srcs+=("$src")
501
+ ! _ski_planned "$src" || linked=1
496
502
  done
497
503
  mkdir -p "$dest_root"
498
- cp -R "${srcs[@]}" "$dest_root/"
504
+ if [ "$linked" = 0 ]; then
505
+ cp -R "${srcs[@]}" "$dest_root/"
506
+ else
507
+ for src in "${srcs[@]}"; do
508
+ if _ski_planned "$src"; then
509
+ _skill_bundle_materialize "$src" || return 1
510
+ cp -R "$IS_SKI_STAGED" "$dest_root/" || return 1
511
+ rm -rf "${IS_SKI_STAGED%/*}"
512
+ else
513
+ cp -R "$src" "$dest_root/" || return 1
514
+ fi
515
+ done
516
+ fi
499
517
  _skill_bundles_reset
500
518
  for src in "${srcs[@]}"; do
501
519
  dest="$dest_root/${src##*/}"
@@ -513,24 +531,63 @@ _skill_bundles_reset() {
513
531
  _skill_bundle_stage() {
514
532
  local src="${1%/}" dest="$2"
515
533
  mkdir -p "$dest"
516
- cp -R "$src/." "$dest/"
534
+ if _ski_planned "$src"; then
535
+ _skill_bundle_materialize "$src" || return 1
536
+ cp -R "$IS_SKI_STAGED/." "$dest/" || return 1
537
+ rm -rf "${IS_SKI_STAGED%/*}"
538
+ else
539
+ cp -R "$src/." "$dest/"
540
+ fi
517
541
  _skill_bundle_note "$dest"
518
542
  }
519
543
 
544
+ # _skill_bundle_materialize <skill_dir> — copy a skill whose plan holds links
545
+ # into a fresh stage and apply the plan there, parents first: each planned link
546
+ # is replaced by a copy of the file or directory it resolves to inside its
547
+ # source, or removed. Sets IS_SKI_STAGED to <stage>/<name>; the caller copies
548
+ # it on and removes the stage. Spawns per link, never per file — a source
549
+ # without links never comes here.
550
+ _skill_bundle_materialize() {
551
+ local src="$1" key stage out rec rel act phys first=1
552
+ _ski_key_var "$src"
553
+ key="$_SKI_KEY"
554
+ stage="$(mktemp -d -t intelligence-skill-XXXXXX)" || return 1
555
+ out="$stage/${key##*/}"
556
+ while IFS= read -r rec; do
557
+ case "$rec" in
558
+ "$key$IS_SKI_US"*) ;;
559
+ *) continue ;;
560
+ esac
561
+ rec="${rec#"$key$IS_SKI_US"}"
562
+ rel="${rec%%"$IS_SKI_US"*}"
563
+ rec="${rec#*"$IS_SKI_US"}"
564
+ act="${rec%%"$IS_SKI_US"*}"
565
+ phys="${rec#*"$IS_SKI_US"}"
566
+ if [ "$first" = 1 ]; then
567
+ first=0
568
+ if [ -z "$rel" ]; then
569
+ # The skill directory itself is the link.
570
+ mkdir "$out" && cp -R "$phys/." "$out/" || return 1
571
+ continue
572
+ fi
573
+ cp -R "$src" "$stage/" || return 1
574
+ fi
575
+ # The copy holds the link itself here; `rm -f` never follows it and
576
+ # refuses a directory, so a plan that no longer matches stops the run.
577
+ rm -f "$out/$rel" || return 1
578
+ case "$act" in
579
+ f) cp "$phys" "$out/$rel" || return 1 ;;
580
+ d) mkdir "$out/$rel" && cp -R "$phys/." "$out/$rel/" || return 1 ;;
581
+ esac
582
+ done <<< "$IS_SKI_PLAN"
583
+ IS_SKI_STAGED="$out"
584
+ }
585
+
520
586
  # Record a staged bundle for the flush pass: mark its SKILL.md for
521
587
  # frontmatter quoting and deduplicate the destination.
522
588
  _skill_bundle_note() {
523
589
  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
590
+ if [ -f "$dest/SKILL.md" ]; then
534
591
  _SB_QUOTE_LIST="$_SB_QUOTE_LIST$dest/SKILL.md"$'\n'
535
592
  fi
536
593
  # The same skill name from a later source overwrites the earlier copy;
@@ -547,11 +604,25 @@ _skill_bundle_note() {
547
604
  # _skill_bundles_flush <effort-tool>
548
605
  _skill_bundles_flush() {
549
606
  [ "${#_SB_DESTS[@]}" -gt 0 ] || return 0
550
- local -a mds=()
551
- local f quote_list="$_SB_QUOTE_LIST"
607
+ local -a mds=() links=()
608
+ local f listing quote_list="$_SB_QUOTE_LIST"
609
+ # Captured, not streamed: a listing cut short could leave a link behind.
610
+ listing="$(find "${_SB_DESTS[@]}" \( -type l -o -type f -name '*.md' \) -print)" || {
611
+ echo "ERROR: could not list the skill copies under ${_SB_DESTS[0]%/*}" >&2
612
+ return 1
613
+ }
552
614
  while IFS= read -r f; do
553
- [ -n "$f" ] && mds+=("$f")
554
- done < <(find "${_SB_DESTS[@]}" -type f -name '*.md')
615
+ [ -n "$f" ] || continue
616
+ if [ -L "$f" ]; then links+=("$f"); else mds+=("$f"); fi
617
+ done <<< "$listing"
618
+ # A link only a copy from outside every skills source can bring: no plan
619
+ # resolved it, so it is not followed but removed.
620
+ if [ "${#links[@]}" -gt 0 ]; then
621
+ rm -f "${links[@]}" || return 1
622
+ for f in "${links[@]}"; do
623
+ echo "WARNING: ${f#"${REPO_ROOT:-}"/} was copied as a symlink from outside every skills source — removed; generated output never holds one" >&2
624
+ done
625
+ fi
555
626
  _skill_bundles_reset
556
627
  [ "${#mds[@]}" -gt 0 ] || return 0
557
628
  effort_map_var "$1"
@@ -711,19 +782,15 @@ sync_open_skill_dirs() {
711
782
  # when the author's file sets none, and a file whose policy says otherwise, or
712
783
  # that sync cannot read as saying it, refuses the render — an owner-only skill
713
784
  # Codex can still select is the exact failure the field exists to prevent, so
714
- # it never passes as a success.
785
+ # it never passes as a success. The tree holds no symlink — copy_skill_bundle_dirs
786
+ # renders a link inside its source as a regular file and leaves any other out —
787
+ # so a skill whose SKILL.md or agents/openai.yaml is a link in its source is
788
+ # read and enforced like any other.
715
789
  open_skill_invocation_policies() {
716
790
  local output_dir="$1" path dir flag rows refusals
717
791
  local -a skill_mds=() policy_dirs=() policies=() authored=()
718
792
  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
793
+ [ ! -f "$path" ] || skill_mds+=("$path")
727
794
  done
728
795
  [ "${#skill_mds[@]}" -gt 0 ] || return 0
729
796
  # Captured, not streamed: a reader cut short must fail the render rather
@@ -736,10 +803,6 @@ open_skill_invocation_policies() {
736
803
  [ "$flag" = "true" ] || continue
737
804
  dir="${path%/SKILL.md}"
738
805
  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
806
  if [ -s "$path/openai.yaml" ]; then
744
807
  authored+=("$path/openai.yaml")
745
808
  continue
@@ -860,6 +923,8 @@ enforce_authored_invocation_policies() {
860
923
  # Lint YAML frontmatter for common pitfalls (unquoted colons, leading tabs).
861
924
  # Print warnings to stderr; do not fail. Strict consumers (Codex CLI) reject
862
925
  # these files with cryptic messages — catching them in sync gives better DX.
926
+ # Each finding is a `WARNING:` line: the author has to edit the source, so
927
+ # `sync --compact` (the first sync `init` runs) must keep it.
863
928
  # Batched: one awk process lints every file passed.
864
929
  # The engine lints every source once, so this is also where an `effort:` off
865
930
  # IS_EFFORT_LEVELS is reported — read with frontmatter_index's semantics,
@@ -892,7 +957,7 @@ lint_frontmatter_files() {
892
957
  }
893
958
  }
894
959
  in_fm && /^\t/ {
895
- printf " WARN: %s:%d leading tab in frontmatter (use spaces)\n", FILENAME, FNR > "/dev/stderr"
960
+ printf "WARNING: %s:%d leading tab in frontmatter (use spaces)\n", FILENAME, FNR > "/dev/stderr"
896
961
  }
897
962
  in_fm && /^[a-zA-Z0-9_-]+:[[:space:]]+[^"\047|>[{]/ {
898
963
  value_start = index($0, ":") + 1
@@ -900,10 +965,10 @@ lint_frontmatter_files() {
900
965
  sub(/^[[:space:]]+/, "", value)
901
966
  if (value ~ /:[[:space:]]/ || value ~ /:$/) {
902
967
  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"
968
+ printf "WARNING: %s:%d unquoted colon in value at column %d — wrap value in quotes\n", FILENAME, FNR, col > "/dev/stderr"
904
969
  }
905
970
  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"
971
+ 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
972
  }
908
973
  }
909
974
  # Field-length limits. Both Claude Code and the Agent Skills standard
@@ -922,7 +987,7 @@ lint_frontmatter_files() {
922
987
  }
923
988
  limit = (key == "name") ? 64 : 1024
924
989
  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"
990
+ 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
991
  }
927
992
  }
928
993
  ' "$@"
@@ -1561,7 +1626,8 @@ warn_unsynced() {
1561
1626
  load_yaml_list "$config_file" "$section"
1562
1627
  while IFS= read -r src; do
1563
1628
  [ -z "$src" ] && continue
1564
- all_sources+=("$src")
1629
+ normalize_path_var "$repo_root/$src"
1630
+ all_sources+=("${IS_NORM_PATH#"$repo_root/"}")
1565
1631
  done <<< "$IS_YAML_LIST"
1566
1632
  done
1567
1633
 
@@ -1655,11 +1721,7 @@ warn_unsynced() {
1655
1721
  done
1656
1722
 
1657
1723
  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"
1724
+ echo "WARNING: NOT SYNCED: $rel_dir" >&2
1663
1725
  warnings=$((warnings + 1))
1664
1726
  fi
1665
1727
  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,13 +1732,366 @@ warn_unsynced() {
1670
1732
  # and hand-placing an entry in an ordered list decides which artifact
1671
1733
  # wins. The engine may also run without the CLI around it.
1672
1734
  if [ "${IS_CLI:-0}" = "1" ]; then
1673
- echo " Wire one in: intelligence source add <rules|agents|skills> <path>"
1735
+ echo "WARNING: Wire one in: intelligence source add <rules|agents|skills> <path>" >&2
1674
1736
  else
1675
- echo " Add these paths to sources: in ${config_file##*/}"
1737
+ echo "WARNING: Add these paths to sources: in ${config_file##*/}" >&2
1676
1738
  fi
1677
1739
  fi
1678
1740
  }
1679
1741
 
1742
+ # --- Skill sources and the symlinks inside them (decision 0017) ---
1743
+ #
1744
+ # A symlink in a skills source renders as the regular file or directory it
1745
+ # resolves to while that stays inside the same source, and is left out with a
1746
+ # WARNING: line when it resolves outside it, nowhere, or to a directory that
1747
+ # encloses it. Generated output never holds a link, and nothing is ever copied
1748
+ # from outside the source (decision 0006). Every consumer takes that decision
1749
+ # 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.
1754
+ #
1755
+ # skill_source_inventory <repo_root> <config_file> fills, in the caller's
1756
+ # shell and fail-closed like read_source_artifact_files:
1757
+ # IS_SKI_LIST <source>/<skill>/SKILL.md of every rendered skill, one a line,
1758
+ # sources in manifest order, names in byte order;
1759
+ # IS_SKI_KEYS "\n<source>/<skill>\n…", the same skills as a membership set;
1760
+ # IS_SKI_PLAN "\n" + one record a line for every link in a rendered skill,
1761
+ # parents first: <source>/<skill> US <path in the skill copy>
1762
+ # US f|d|x US <physical target>. f and d materialize the file or
1763
+ # directory at that path, x leaves the link out; an empty path
1764
+ # is the skill directory itself.
1765
+ # The resolution spawns processes only for a source that holds a link, and
1766
+ # then per source and per link-chain hop, never per file.
1767
+ IS_SKI_US=$'\x1f'
1768
+
1769
+ skill_source_inventory() {
1770
+ local repo_root="$1" config_file="$2" memo_key src dir listing
1771
+ memo_key="$repo_root"$'\n'"$config_file"
1772
+ if [ "${IS_SOURCE_FILES_MEMO:-0}" = 1 ] && [ "${IS_SKI_KEY:-}" = "$memo_key" ]; then
1773
+ return 0
1774
+ fi
1775
+ IS_SKI_KEY=""
1776
+ IS_SKI_LIST=""
1777
+ IS_SKI_KEYS=$'\n'
1778
+ IS_SKI_PLAN=$'\n'
1779
+ load_yaml_list "$config_file" "skills"
1780
+ while IFS= read -r src; do
1781
+ [ -n "$src" ] || continue
1782
+ dir="$repo_root/$src"
1783
+ while [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done
1784
+ [ -d "$dir" ] || continue
1785
+ # Every directory and link at any depth: the top level holds the
1786
+ # skills, and a link anywhere below one is part of its bundle. -H
1787
+ # lists a source that is itself a link through it, as a glob does.
1788
+ # Command substitution, so find or sort failing stops the run here
1789
+ # instead of yielding a short list.
1790
+ listing="$(find -H "$dir" -mindepth 1 \( -type d -o -type l \) -print | LC_ALL=C sort)" || {
1791
+ echo "ERROR: cannot enumerate skills under '$dir' — refusing to render a partial list." >&2
1792
+ exit 1
1793
+ }
1794
+ [ -n "$listing" ] || continue
1795
+ _ski_source "$repo_root" "$dir" "$listing"
1796
+ done <<< "$IS_YAML_LIST"
1797
+ [ "${IS_SOURCE_FILES_MEMO:-0}" != 1 ] || IS_SKI_KEY="$memo_key"
1798
+ }
1799
+
1800
+ # skill_source_rendered <skill_dir> — the inventory renders this skill. For
1801
+ # adapters that list a skills source with their own glob.
1802
+ skill_source_rendered() {
1803
+ _ski_key_var "$1"
1804
+ case "${IS_SKI_KEYS:-}" in
1805
+ *$'\n'"$_SKI_KEY"$'\n'*) return 0 ;;
1806
+ esac
1807
+ return 1
1808
+ }
1809
+
1810
+ # _ski_key_var <skill_dir> — the key a skill is listed and planned under, its
1811
+ # source and name joined by one slash however the caller spelled a trailing
1812
+ # slash on either.
1813
+ _ski_key_var() {
1814
+ local path="$1" parent
1815
+ while [ "${path%/}" != "$path" ]; do path="${path%/}"; done
1816
+ parent="${path%/*}"
1817
+ while [ -n "$parent" ] && [ "${parent%/}" != "$parent" ]; do parent="${parent%/}"; done
1818
+ _SKI_KEY="$parent/${path##*/}"
1819
+ }
1820
+
1821
+ # _ski_planned <skill_dir> — the skill has links the copy has to resolve.
1822
+ _ski_planned() {
1823
+ _ski_key_var "$1"
1824
+ case "${IS_SKI_PLAN:-}" in
1825
+ *$'\n'"$_SKI_KEY$IS_SKI_US"*) return 0 ;;
1826
+ esac
1827
+ return 1
1828
+ }
1829
+
1830
+ # _ski_source <repo_root> <source_dir> <listing> — inventory one skills source.
1831
+ _ski_source() {
1832
+ local repo_root="$1" f rel
1833
+ local -a tops=() links=()
1834
+ _SKI_DIR="$2"
1835
+ _SKI_ROOT_REL="${2#"$repo_root"/}"
1836
+ _SKI_REPO="$repo_root"
1837
+ while IFS= read -r f; do
1838
+ [ -n "$f" ] || continue
1839
+ rel="${f#"$_SKI_DIR"/}"
1840
+ case "$rel" in
1841
+ */*) [ ! -L "$f" ] || links+=("$f") ;;
1842
+ *)
1843
+ tops+=("$f")
1844
+ [ ! -L "$f" ] || links+=("$f")
1845
+ ;;
1846
+ esac
1847
+ done <<< "$3"
1848
+ _SKI_LREL=(); _SKI_LSTAT=(); _SKI_LKIND=(); _SKI_LTREL=(); _SKI_LPHYS=()
1849
+ [ "${#links[@]}" -eq 0 ] || _ski_classify_links "${links[@]}"
1850
+ for f in "${tops[@]+"${tops[@]}"}"; do
1851
+ _ski_skill "${f##*/}"
1852
+ done
1853
+ }
1854
+
1855
+ # _ski_classify_links <link>... — resolve every link of the current source and
1856
+ # record where it leads: ok (inside the source), outside, dangling, or other
1857
+ # (neither a file nor a directory). A link that cannot be resolved stops the
1858
+ # run, like any enumeration that cannot answer.
1859
+ _ski_classify_links() {
1860
+ local out root_phys rec kind phys i=0 link
1861
+ local -a recs=()
1862
+ out="$(_ski_physical "$_SKI_DIR" "$@")" || {
1863
+ echo "ERROR: cannot resolve the symlinks under '$_SKI_DIR' — refusing to render a partial list." >&2
1864
+ exit 1
1865
+ }
1866
+ while IFS= read -r rec; do recs+=("$rec"); done <<< "$out"
1867
+ if [ "${#recs[@]}" -ne $(($# + 1)) ]; then
1868
+ echo "ERROR: cannot resolve the symlinks under '$_SKI_DIR' — refusing to render a partial list." >&2
1869
+ exit 1
1870
+ fi
1871
+ root_phys="${recs[0]%/}"
1872
+ for link in "$@"; do
1873
+ rec="${recs[i + 1]}"
1874
+ kind="${rec%%"$IS_SKI_US"*}"
1875
+ phys="${rec#*"$IS_SKI_US"}"
1876
+ _SKI_LREL[i]="${link#"$_SKI_DIR"/}"
1877
+ _SKI_LKIND[i]="$kind"
1878
+ _SKI_LPHYS[i]="$phys"
1879
+ _SKI_LTREL[i]=""
1880
+ case "$kind" in
1881
+ d|f)
1882
+ case "$phys" in
1883
+ "$root_phys"/*) _SKI_LSTAT[i]=ok; _SKI_LTREL[i]="${phys#"$root_phys"/}" ;;
1884
+ "${root_phys:-/}") _SKI_LSTAT[i]=ok ;;
1885
+ *) _SKI_LSTAT[i]=outside ;;
1886
+ esac
1887
+ ;;
1888
+ o) _SKI_LSTAT[i]=other ;;
1889
+ *) _SKI_LSTAT[i]=dangling ;;
1890
+ esac
1891
+ i=$((i + 1))
1892
+ done
1893
+ }
1894
+
1895
+ # _ski_physical <source_dir> <link>... — print the source's physical path,
1896
+ # then one "<kind> US <physical target>" line per link: d (directory), f
1897
+ # (regular file), o (anything else) or x (dangling, or a link loop). Runs in a
1898
+ # subshell so it can change directory: `cd -P` resolves a directory, and a
1899
+ # file's chain is read with one readlink per hop for all links at once (no
1900
+ # readlink -f on macOS).
1901
+ _ski_physical() (
1902
+ local dir="$1" link target targets i k n hops=0 parent
1903
+ local -a kind=() cur=() phys=() pend=() next=() paths=()
1904
+ shift
1905
+ n=$#
1906
+ cd -P -- "$dir" 2>/dev/null || exit 1
1907
+ printf '%s\n' "$PWD"
1908
+ i=0
1909
+ for link in "$@"; do
1910
+ kind[i]=x; phys[i]=""; cur[i]="$link"
1911
+ if [ -d "$link" ]; then
1912
+ cd -P -- "$link" 2>/dev/null || exit 1
1913
+ kind[i]=d; phys[i]="$PWD"
1914
+ elif [ -f "$link" ]; then
1915
+ kind[i]=f; pend+=("$i")
1916
+ elif [ -e "$link" ]; then
1917
+ kind[i]=o
1918
+ fi
1919
+ i=$((i + 1))
1920
+ done
1921
+ while [ "${#pend[@]}" -gt 0 ]; do
1922
+ hops=$((hops + 1))
1923
+ [ "$hops" -le 40 ] || exit 1
1924
+ paths=()
1925
+ for i in "${pend[@]}"; do paths+=("${cur[i]}"); done
1926
+ targets="$(readlink "${paths[@]}")" || exit 1
1927
+ next=(); k=0
1928
+ while IFS= read -r target; do
1929
+ # More lines than links: a target held a newline.
1930
+ [ "$k" -lt "${#pend[@]}" ] || exit 1
1931
+ i="${pend[k]}"
1932
+ case "$target" in
1933
+ /*) cur[i]="$target" ;;
1934
+ *) cur[i]="${cur[i]%/*}/$target" ;;
1935
+ esac
1936
+ [ ! -L "${cur[i]}" ] || next+=("$i")
1937
+ k=$((k + 1))
1938
+ done <<< "$targets"
1939
+ [ "$k" -eq "${#pend[@]}" ] || exit 1
1940
+ pend=("${next[@]+"${next[@]}"}")
1941
+ done
1942
+ for ((i = 0; i < n; i++)); do
1943
+ if [ "${kind[i]}" = f ]; then
1944
+ parent="${cur[i]%/*}"
1945
+ cd -P -- "${parent:-/}" 2>/dev/null || exit 1
1946
+ phys[i]="${PWD%/}/${cur[i]##*/}"
1947
+ fi
1948
+ printf '%s%s%s\n' "${kind[i]}" "$IS_SKI_US" "${phys[i]}"
1949
+ done
1950
+ )
1951
+
1952
+ # _ski_link_var <path in source> — set _SKI_K to the index of the link at that
1953
+ # path, or -1.
1954
+ _ski_link_var() {
1955
+ local k=0
1956
+ _SKI_K=-1
1957
+ while [ "$k" -lt "${#_SKI_LREL[@]}" ]; do
1958
+ if [ "${_SKI_LREL[k]}" = "$1" ]; then
1959
+ _SKI_K="$k"
1960
+ return 0
1961
+ fi
1962
+ k=$((k + 1))
1963
+ done
1964
+ }
1965
+
1966
+ # _ski_loops <target> <open> — materializing the directory at <target> would
1967
+ # copy one of the <open> paths (newline-separated) being materialized, the
1968
+ # link itself included, so it would never end.
1969
+ _ski_loops() {
1970
+ local target="$1" open
1971
+ [ -n "$target" ] || return 0
1972
+ while IFS= read -r open; do
1973
+ case "$open" in "$target"|"$target"/*) return 0 ;; esac
1974
+ done <<< "$2"
1975
+ return 1
1976
+ }
1977
+
1978
+ # _ski_skill <name> — decide one top-level entry of the current source: a
1979
+ # rendered skill (with its plan and warnings), a skill left out, or no skill.
1980
+ _ski_skill() {
1981
+ local name="$1" key content open="" state rel link reason nl=$'\n'
1982
+ key="$_SKI_DIR/$name"
1983
+ _SKI_ENTRIES="$nl"
1984
+ _SKI_DROPS=""
1985
+ _ski_link_var "$name"
1986
+ if [ "$_SKI_K" -ge 0 ]; then
1987
+ # The skill directory is itself a link. A test through one that
1988
+ # leaves the source only stats it; nothing is read from outside.
1989
+ case "${_SKI_LSTAT[_SKI_K]}:${_SKI_LKIND[_SKI_K]}" in
1990
+ ok:d) ;;
1991
+ outside:d)
1992
+ _ski_warn "$name" "" "$name" outside
1993
+ return 0
1994
+ ;;
1995
+ dangling:*)
1996
+ _ski_warn "$name" "" "$name" dangling
1997
+ return 0
1998
+ ;;
1999
+ *) return 0 ;;
2000
+ esac
2001
+ content="${_SKI_LTREL[_SKI_K]}"
2002
+ if _ski_loops "$content" ""; then
2003
+ [ ! -f "$_SKI_DIR/$name/SKILL.md" ] || _ski_warn "$name" "" "$name" loop
2004
+ return 0
2005
+ fi
2006
+ _SKI_ENTRIES="$_SKI_ENTRIES$key$IS_SKI_US${IS_SKI_US}d$IS_SKI_US${_SKI_LPHYS[_SKI_K]}$nl"
2007
+ open="$nl"
2008
+ else
2009
+ content="$name"
2010
+ fi
2011
+ _ski_expand "$key" "$content" "" "$open"
2012
+ # A skill is a directory whose SKILL.md renders as a regular file.
2013
+ case "$_SKI_ENTRIES" in
2014
+ *"$nl$key${IS_SKI_US}SKILL.md${IS_SKI_US}f$IS_SKI_US"*) state=ok ;;
2015
+ *"$nl$key${IS_SKI_US}SKILL.md${IS_SKI_US}x$IS_SKI_US"*) state=dropped ;;
2016
+ *"$nl$key${IS_SKI_US}SKILL.md$IS_SKI_US"*) state=none ;;
2017
+ *)
2018
+ state=none
2019
+ [ ! -f "$_SKI_DIR/$content/SKILL.md" ] || state=ok
2020
+ ;;
2021
+ esac
2022
+ case "$state" in
2023
+ none) return 0 ;;
2024
+ dropped)
2025
+ while IFS="$IS_SKI_US" read -r rel link reason; do
2026
+ [ "$rel" != "SKILL.md" ] || _ski_warn "$name" "" "$link" "$reason"
2027
+ done <<< "$_SKI_DROPS"
2028
+ return 0
2029
+ ;;
2030
+ esac
2031
+ IS_SKI_LIST="$IS_SKI_LIST$_SKI_DIR/$name/SKILL.md$nl"
2032
+ IS_SKI_KEYS="$IS_SKI_KEYS$key$nl"
2033
+ IS_SKI_PLAN="$IS_SKI_PLAN${_SKI_ENTRIES#"$nl"}"
2034
+ while IFS="$IS_SKI_US" read -r rel link reason; do
2035
+ [ -z "$rel" ] || _ski_warn "$name" "$rel" "$link" "$reason"
2036
+ done <<< "$_SKI_DROPS"
2037
+ }
2038
+
2039
+ # _ski_expand <key> <content> <prefix> <open> — plan every link inside the
2040
+ # source directory <content> as it lands at <prefix> in the skill copy,
2041
+ # recursing into each directory a link materializes. <open> holds the
2042
+ # locations of the links already being materialized, for _ski_loops.
2043
+ _ski_expand() {
2044
+ local key="$1" content="$2" prefix="$3" open="$4" k=0 rel out location
2045
+ while [ "$k" -lt "${#_SKI_LREL[@]}" ]; do
2046
+ rel="${_SKI_LREL[k]}"
2047
+ case "$rel" in
2048
+ "$content"/*) ;;
2049
+ *) k=$((k + 1)); continue ;;
2050
+ esac
2051
+ out="${prefix:+$prefix/}${rel#"$content"/}"
2052
+ case "${_SKI_LSTAT[k]}:${_SKI_LKIND[k]}" in
2053
+ ok:f)
2054
+ _SKI_ENTRIES="$_SKI_ENTRIES$key$IS_SKI_US$out${IS_SKI_US}f$IS_SKI_US${_SKI_LPHYS[k]}"$'\n'
2055
+ ;;
2056
+ ok:d)
2057
+ location="${rel%/*}"
2058
+ if _ski_loops "${_SKI_LTREL[k]}" "$open$location"; then
2059
+ _ski_drop "$key" "$out" "$rel" loop
2060
+ else
2061
+ _SKI_ENTRIES="$_SKI_ENTRIES$key$IS_SKI_US$out${IS_SKI_US}d$IS_SKI_US${_SKI_LPHYS[k]}"$'\n'
2062
+ _ski_expand "$key" "${_SKI_LTREL[k]}" "$out" "$open$location"$'\n'
2063
+ fi
2064
+ ;;
2065
+ *) _ski_drop "$key" "$out" "$rel" "${_SKI_LSTAT[k]}" ;;
2066
+ esac
2067
+ k=$((k + 1))
2068
+ done
2069
+ }
2070
+
2071
+ # _ski_drop <key> <path in copy> <path in source> <reason> — leave a link out.
2072
+ _ski_drop() {
2073
+ _SKI_ENTRIES="$_SKI_ENTRIES$1$IS_SKI_US$2${IS_SKI_US}x$IS_SKI_US"$'\n'
2074
+ _SKI_DROPS="$_SKI_DROPS$2$IS_SKI_US$3$IS_SKI_US$4"$'\n'
2075
+ }
2076
+
2077
+ # _ski_warn <skill> <path in copy> <path in source> <reason> — one WARNING:
2078
+ # line naming the skill and the link by its own path. An empty <path in copy>
2079
+ # means the whole skill is left out.
2080
+ _ski_warn() {
2081
+ local link="$_SKI_ROOT_REL/$3" what
2082
+ case "$4" in
2083
+ outside) what="is a symlink that resolves outside $_SKI_ROOT_REL" ;;
2084
+ dangling) what="is a dangling symlink" ;;
2085
+ loop) what="is a symlink to a directory that encloses it" ;;
2086
+ *) what="is a symlink to neither a file nor a directory" ;;
2087
+ esac
2088
+ if [ -n "$2" ]; then
2089
+ echo "WARNING: skill '$1' renders without $2: $link $what" >&2
2090
+ else
2091
+ echo "WARNING: skill '$1' is left out of every output: $link $what" >&2
2092
+ fi
2093
+ }
2094
+
1680
2095
  # Enumerate one manifest source kind using the same depth and ordering
1681
2096
  # everywhere. Source-list order is significant; entries inside each directory
1682
2097
  # use byte-order sorting for cross-platform determinism. The result lands in
@@ -1696,6 +2111,9 @@ warn_unsynced() {
1696
2111
  # replay that answer. Only the engine sets it: a sync never writes into a
1697
2112
  # source (validate_output_path refuses), so each caller would otherwise re-run
1698
2113
  # the same find and sort for every source directory.
2114
+ #
2115
+ # Skills come from skill_source_inventory, which also decides what a symlink
2116
+ # inside a skills source renders as.
1699
2117
  read_source_artifact_files() {
1700
2118
  local repo_root="$1"
1701
2119
  local config_file="$2"
@@ -1703,8 +2121,15 @@ read_source_artifact_files() {
1703
2121
  local src f dir listing memo_key="" key_var="" list_var="" joined=""
1704
2122
 
1705
2123
  IS_SOURCE_FILES=()
2124
+ if [ "$section" = "skills" ]; then
2125
+ skill_source_inventory "$repo_root" "$config_file"
2126
+ while IFS= read -r f; do
2127
+ [ -n "$f" ] && IS_SOURCE_FILES+=("$f")
2128
+ done <<< "$IS_SKI_LIST"
2129
+ return 0
2130
+ fi
1706
2131
  case "${IS_SOURCE_FILES_MEMO:-0}:$section" in
1707
- 1:rules|1:agents|1:skills)
2132
+ 1:rules|1:agents)
1708
2133
  key_var="IS_SF_${section}_KEY"
1709
2134
  list_var="IS_SF_${section}_LIST"
1710
2135
  memo_key="$repo_root"$'\n'"$config_file"
@@ -1731,12 +2156,6 @@ read_source_artifact_files() {
1731
2156
  exit 1
1732
2157
  }
1733
2158
  ;;
1734
- skills)
1735
- listing="$(find "$dir" -mindepth 1 -maxdepth 1 -type d -print | LC_ALL=C sort)" || {
1736
- echo "ERROR: cannot enumerate $section under '$dir' — refusing to render a partial list." >&2
1737
- exit 1
1738
- }
1739
- ;;
1740
2159
  *) continue ;;
1741
2160
  esac
1742
2161
  [ -n "$listing" ] || continue
@@ -1744,10 +2163,6 @@ read_source_artifact_files() {
1744
2163
  # array it fills survives and any failure inside it is this shell's.
1745
2164
  while IFS= read -r f; do
1746
2165
  [ -n "$f" ] || continue
1747
- if [ "$section" = "skills" ]; then
1748
- [ -f "$f/SKILL.md" ] || continue
1749
- f="$f/SKILL.md"
1750
- fi
1751
2166
  IS_SOURCE_FILES+=("$f")
1752
2167
  joined="$joined$f"$'\n'
1753
2168
  done <<< "$listing"
@@ -2106,8 +2521,9 @@ IS_YAML_LIST_AWK='
2106
2521
  # Consults the load_yaml_list cache first: sync reads the same sections from
2107
2522
  # the same manifest dozens of times, and each awk spawn costs tens of
2108
2523
  # 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.
2524
+ # load_yaml_list and load_yaml_lists. The engine never mutates its manifest;
2525
+ # CLI writers invalidate it through qmap_memo_reset; load_yaml_lists and the
2526
+ # project preloader also populate it.
2111
2527
  read_yaml_list() {
2112
2528
  local file="$1"
2113
2529
  local section="$2"
@@ -2215,22 +2631,17 @@ load_yaml_list() {
2215
2631
  esac
2216
2632
  }
2217
2633
 
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"'
2634
+ # The manifest readers below share their awk programs, so the combined pass in
2635
+ # load_manifest_view reads each shape exactly as its single-purpose reader does.
2636
+ # Rule order matters when they are joined: the targets program ends its rules
2637
+ # with `next`, so it runs last and never hides a line from the others.
2638
+ #
2639
+ # The lists program carries the package-reference parser with it: it sees every
2640
+ # line, so `packages:` is collected wherever it stands, and each source list is
2641
+ # expanded exactly as read_yaml_list expands it. A reference that resolves to
2642
+ # nothing comes out as a `!` row, which _yaml_lists_store keeps in
2643
+ # IS_YL_UNRESOLVED.
2644
+ _IS_AWK_LISTS="$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK"'
2234
2645
  BEGIN { n = split(sections, want, " ") }
2235
2646
  { sub(/\r$/, ""); pkr_collect($0) }
2236
2647
  {
@@ -2261,42 +2672,33 @@ load_yaml_lists() {
2261
2672
  else print "!\037" s "\037" val "\037" PKR_STATE "\037" dir "\037" PKR_ALIAS "\037" PKR_HOLDERS
2262
2673
  }
2263
2674
  }
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
- }
2675
+ '
2283
2676
 
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 '
2677
+ # get_yaml_field: the first ` fkey:` inside the first `fsection:` block. A
2678
+ # top-level key closes the block; ftag prefixes the printed value.
2679
+ _IS_AWK_FIELD='
2680
+ { sub(/\r$/, "") }
2681
+ !fdone {
2682
+ if ($0 ~ "^" fsection ":") fin = 1
2683
+ else if (fin && /^[a-zA-Z]/) fdone = 1
2684
+ else if (fin && $0 ~ "^ " fkey ":") {
2685
+ fval = $0
2686
+ sub(/.*:[[:space:]]*["\047]?/, "", fval)
2687
+ sub(/["\047]?[[:space:]]*$/, "", fval)
2688
+ ffound = 1
2689
+ fdone = 1
2690
+ }
2691
+ }
2692
+ END { if (ffound) print ftag fval }
2693
+ '
2694
+
2695
+ # load_targets_cache: one `name<TAB>enabled<TAB>output` row per target, each
2696
+ # prefixed with ttag.
2697
+ _IS_AWK_TARGETS='
2296
2698
  function flush_target(at_sibling) {
2297
2699
  if (name == "") return
2298
2700
  if (enabled == "" && at_sibling) enabled = 0
2299
- printf "%s\t%s\t%s\n", name, enabled, output
2701
+ printf "%s%s\t%s\t%s\n", ttag, name, enabled, output
2300
2702
  name = ""
2301
2703
  }
2302
2704
  { sub(/\r$/, "") }
@@ -2332,10 +2734,147 @@ load_targets_cache() {
2332
2734
  output = val
2333
2735
  }
2334
2736
  END { flush_target(0) }
2335
- ' "$file")"
2737
+ '
2738
+
2739
+ # _yaml_lists_store <file> <awk-output> <section>... — fill the load_yaml_list
2740
+ # cache for each section from `<section><US><value>` rows, and IS_YL_UNRESOLVED
2741
+ # from the `!` rows: one read_source_entries-shaped line each, the section in
2742
+ # front.
2743
+ _yaml_lists_store() {
2744
+ local file="$1" out="$2" line v s k saved_ifs="$IFS"
2745
+ local -a lines=() names=() vals=()
2746
+ shift 2
2747
+ names=("$@")
2748
+ IFS=$'\n'
2749
+ set -f
2750
+ # shellcheck disable=SC2206 # rows are `<section><US><value>`, never empty
2751
+ lines=($out)
2752
+ set +f
2753
+ IFS="$saved_ifs"
2754
+ IS_YL_UNRESOLVED=""
2755
+ # One pass over the rows in this shell: reading a here-string again per
2756
+ # section costs a system call per byte on Git Bash.
2757
+ for line in "${lines[@]+"${lines[@]}"}"; do
2758
+ s="${line%%$'\037'*}"
2759
+ v="${line#*$'\037'}"
2760
+ if [ "$s" = "!" ]; then
2761
+ IS_YL_UNRESOLVED="$IS_YL_UNRESOLVED$v"$'\n'
2762
+ continue
2763
+ fi
2764
+ for ((k = 0; k < ${#names[@]}; k++)); do
2765
+ [ "${names[k]}" = "$s" ] || continue
2766
+ vals[k]="${vals[k]-}$v"$'\n'
2767
+ done
2768
+ done
2769
+ for ((k = 0; k < ${#names[@]}; k++)); do
2770
+ v="${vals[k]-}"
2771
+ while [[ "$v" == *$'\n' ]]; do v="${v%$'\n'}"; done
2772
+ printf -v "IS_YL_${names[k]}_FILE" '%s' "$file"
2773
+ printf -v "IS_YL_${names[k]}_VAL" '%s' "$v"
2774
+ done
2775
+ }
2776
+
2777
+ # load_yaml_lists <file> <section>... — warm the load_yaml_list cache for several
2778
+ # sections in one manifest pass. Each section runs read_yaml_list's own state
2779
+ # machine and its reference expansion, so every cached value is what
2780
+ # load_yaml_list would have stored. The same pass leaves the references that
2781
+ # resolve to nothing in IS_YL_UNRESOLVED, one read_source_entries-shaped line
2782
+ # each with the section in front, for the WARNING: lines `sync` prints.
2783
+ load_yaml_lists() {
2784
+ local file="$1" out s
2785
+ shift
2786
+ for s in "$@"; do
2787
+ case "$s" in
2788
+ rules|agents|skills|ignore|submodules) ;;
2789
+ *) return 1 ;;
2790
+ esac
2791
+ done
2792
+ out="$(awk -v sections="$*" "$_IS_AWK_LISTS" "$file")"
2793
+ _yaml_lists_store "$file" "$out" "$@"
2794
+ }
2795
+
2796
+ # load_targets_cache <file> — parse the whole targets: section once into the
2797
+ # global IS_TGT_TSV (one `name<TAB>enabled<TAB>output` row per target),
2798
+ # matching is_target_enabled and get_target_output semantics: inline and block
2799
+ # forms, first occurrence wins, `enabled` defaults to 0 when the block ends
2800
+ # without one and to empty at end of file. The engine warms this once;
2801
+ # both readers consult it before spawning awk.
2802
+ load_targets_cache() {
2803
+ local file="$1"
2804
+ if [ "${IS_TGT_FILE:-}" = "$file" ]; then
2805
+ return 0
2806
+ fi
2807
+ IS_TGT_TSV="$(awk "$_IS_AWK_TARGETS" "$file")"
2336
2808
  IS_TGT_FILE="$file"
2337
2809
  }
2338
2810
 
2811
+ # load_manifest_view <file> <field-section> <field-key> <section>... — one
2812
+ # awk pass for what the CLI's preflight reads of a manifest: the
2813
+ # load_yaml_lists cache for each <section>, the targets cache, and the one
2814
+ # get_yaml_field answer that get_yaml_field_var then returns without a process.
2815
+ # Each reader's program is the one it runs alone, so the answers cannot differ.
2816
+ # A caller that overlaps the pass with other work runs manifest_view_run (it
2817
+ # prints) and hands the output to manifest_view_store itself.
2818
+ load_manifest_view() {
2819
+ local out
2820
+ out="$(manifest_view_run "$@")" || return 1
2821
+ manifest_view_store "$out" "$@"
2822
+ }
2823
+
2824
+ manifest_view_run() {
2825
+ local file="$1" fsection="$2" fkey="$3" s
2826
+ shift 3
2827
+ for s in "$@"; do
2828
+ case "$s" in
2829
+ rules|agents|skills|ignore|submodules) ;;
2830
+ *) return 1 ;;
2831
+ esac
2832
+ done
2833
+ awk -v sections="$*" -v fsection="$fsection" -v fkey="$fkey" \
2834
+ -v ftag=$'\036F' -v ttag=$'\036T' \
2835
+ "$_IS_AWK_LISTS$_IS_AWK_FIELD$_IS_AWK_TARGETS" "$file"
2836
+ }
2837
+
2838
+ # manifest_view_store <output> <file> <field-section> <field-key> <section>...
2839
+ manifest_view_store() {
2840
+ local out="$1" file="$2" fsection="$3" fkey="$4" line lists="" targets="" field="" found=0
2841
+ local saved_ifs="$IFS"
2842
+ local -a lines=()
2843
+ shift 4
2844
+ IFS=$'\n'
2845
+ set -f
2846
+ # shellcheck disable=SC2206 # every row is tagged or `<section><US>`, never empty
2847
+ lines=($out)
2848
+ set +f
2849
+ IFS="$saved_ifs"
2850
+ for line in "${lines[@]+"${lines[@]}"}"; do
2851
+ case "$line" in
2852
+ $'\036T'*) targets="$targets${line#??}"$'\n' ;;
2853
+ $'\036F'*) field="${line#??}"; found=1 ;;
2854
+ *) lists="$lists$line"$'\n' ;;
2855
+ esac
2856
+ done
2857
+ while [[ "$targets" == *$'\n' ]]; do targets="${targets%$'\n'}"; done
2858
+ _yaml_lists_store "$file" "$lists" "$@"
2859
+ IS_TGT_TSV="$targets"
2860
+ IS_TGT_FILE="$file"
2861
+ IS_YF_KEY="$file"$'\037'"$fsection"$'\037'"$fkey"
2862
+ IS_YF_VALUE="$field"
2863
+ IS_YF_FOUND="$found"
2864
+ }
2865
+
2866
+ # manifest_view_reset — forget every manifest answer cached in this shell. A
2867
+ # process that has the manifest rewritten (by a child it ran) calls it before
2868
+ # reading again.
2869
+ manifest_view_reset() {
2870
+ local s
2871
+ for s in rules agents skills ignore submodules; do
2872
+ unset "IS_YL_${s}_FILE" "IS_YL_${s}_VAL"
2873
+ done
2874
+ unset IS_TGT_FILE IS_TGT_TSV IS_YF_KEY IS_YF_VALUE IS_YF_FOUND IS_YL_UNRESOLVED
2875
+ }
2876
+
2877
+
2339
2878
  # _targets_cache_row <target> — scan the cached TSV; sets IS_TGT_ROW_ENABLED /
2340
2879
  # IS_TGT_ROW_OUTPUT. Returns 1 when the target has no row (reader falls back
2341
2880
  # to empty, same as the awk scan finding nothing).
@@ -2557,18 +3096,23 @@ get_yaml_field() {
2557
3096
  local file="$1"
2558
3097
  local section="$2"
2559
3098
  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"
3099
+ if [ "${IS_YF_KEY:-}" = "$file"$'\037'"$section"$'\037'"$key" ]; then
3100
+ [ "$IS_YF_FOUND" != 1 ] || printf '%s\n' "$IS_YF_VALUE"
3101
+ return 0
3102
+ fi
3103
+ awk -v fsection="$section" -v fkey="$key" "$_IS_AWK_FIELD" "$file"
3104
+ }
3105
+
3106
+ # get_yaml_field_var <file> <section> <key> — get_yaml_field into
3107
+ # IS_YAML_FIELD, without a process when load_manifest_view read it.
3108
+ # shellcheck disable=SC2034 # IS_YAML_FIELD is the return channel
3109
+ get_yaml_field_var() {
3110
+ if [ "${IS_YF_KEY:-}" = "$1"$'\037'"$2"$'\037'"$3" ]; then
3111
+ IS_YAML_FIELD=""
3112
+ [ "$IS_YF_FOUND" != 1 ] || IS_YAML_FIELD="$IS_YF_VALUE"
3113
+ return 0
3114
+ fi
3115
+ IS_YAML_FIELD="$(get_yaml_field "$@")"
2572
3116
  }
2573
3117
 
2574
3118
  # Get project name from config.yaml (project.name)