@ainova-systems/intelligence 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/cli/commands/init.sh +6 -1
  2. package/cli/commands/package.sh +8 -3
  3. package/cli/commands/registry.sh +1 -1
  4. package/cli/commands/source.sh +56 -17
  5. package/cli/commands/sync.sh +78 -6
  6. package/cli/intelligence +16 -3
  7. package/cli/internal/check.sh +64 -13
  8. package/cli/internal/convert-legacy.sh +1 -1
  9. package/cli/internal/package-add.sh +25 -4
  10. package/cli/internal/package-alias.sh +51 -0
  11. package/cli/internal/package-list.sh +16 -2
  12. package/cli/internal/package-remove.sh +3 -1
  13. package/cli/internal/package-search.sh +1 -1
  14. package/cli/internal/package-update.sh +14 -6
  15. package/cli/internal/restore.sh +3 -2
  16. package/cli/lib/adapter-lifecycle.sh +5 -2
  17. package/cli/lib/cli-common.sh +149 -34
  18. package/cli/lib/gitignore.sh +128 -34
  19. package/cli/lib/lockfile.sh +14 -4
  20. package/cli/lib/manifest.sh +315 -21
  21. package/cli/lib/qmap.awk +97 -32
  22. package/cli/lib/registry.sh +19 -14
  23. package/cli/lib/sync-cache.sh +596 -142
  24. package/cli/lib/sync-lock.sh +19 -2
  25. package/engine/ENGINE_SHA +1 -1
  26. package/engine/VERSION +1 -1
  27. package/engine/adapters/_template.sh +6 -2
  28. package/engine/adapters/agents.sh +8 -2
  29. package/engine/adapters/antigravity.sh +3 -1
  30. package/engine/adapters/claude.sh +29 -12
  31. package/engine/adapters/codex.sh +11 -13
  32. package/engine/adapters/copilot.sh +54 -4
  33. package/engine/adapters/cursor.sh +13 -2
  34. package/engine/adapters/opencode.sh +7 -1
  35. package/engine/adapters/pi.sh +3 -1
  36. package/engine/lib/adapter-contract.sh +51 -8
  37. package/engine/lib/common.sh +1072 -160
  38. package/engine/lib/contract.sh +5 -0
  39. package/engine/sync.sh +128 -28
  40. package/package.json +1 -1
  41. package/packages/sync/references/adapters.md +44 -9
  42. package/packages/sync/references/conventions.md +48 -8
  43. package/packages/sync/references/onboarding-migration.md +3 -3
  44. package/packages/sync/skills/intelligence-learn-from-repository/SKILL.md +3 -2
  45. package/packages/sync/skills/intelligence-review-context/SKILL.md +2 -1
  46. package/packages/sync/skills/intelligence-update-context/SKILL.md +4 -2
  47. package/packages/sync/skills/intelligence-update-context/references/agents.md +7 -3
  48. package/packages/sync/skills/intelligence-update-context/references/skills.md +3 -1
@@ -293,9 +293,10 @@ repo_rel_dir() {
293
293
  done
294
294
  }
295
295
 
296
- # Resolve a single source token to an absolute local directory. Every token is
297
- # a repo-relative path: the CLI resolves, fetches and pins packages, so by the
298
- # time the engine runs a package is just a directory under the store.
296
+ # Resolve a single source entry to an absolute local directory. Every entry is
297
+ # a repo-relative path by the time it gets here: the CLI resolves, fetches and
298
+ # pins packages, so a package is just a directory under the store, and the list
299
+ # parser has already expanded a package reference to that directory.
299
300
  # ALWAYS returns 0 (echoes nothing on failure) so `set -e` callers using
300
301
  # `dir="$(resolve_source_dir ...)"` never abort; the caller's existing
301
302
  # `[ -d "$dir" ] || continue` guard then skips an unresolved source.
@@ -451,8 +452,11 @@ copy_md_with_quoted_frontmatter() {
451
452
  # ship support files beside SKILL.md, and SKILL.md bodies point at them by
452
453
  # relative path, so dropping them breaks the skill at runtime). Markdown is
453
454
  # normalized to LF; every other file is copied byte-for-byte so potentially
454
- # binary assets survive. `cp -R` copies symlinks as symlinks (POSIX), so a
455
- # 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).
456
460
  #
457
461
  # SKILL.md frontmatter is quoted for EVERY consumer, not only the strict-YAML
458
462
  # ones. `argument-hint: [pr-number]` is a YAML *flow sequence*, so an unquoted
@@ -460,35 +464,62 @@ copy_md_with_quoted_frontmatter() {
460
464
  # "argument-hint must be a string" — the skill silently disappears from the
461
465
  # picker. Quoting is idempotent: an already-quoted value passes through
462
466
  # untouched.
467
+ #
468
+ # The same pass renders SKILL.md's `effort:` for the tool that reads the copy
469
+ # (map_effort_var): the first `effort:` line becomes the tool's level, and is
470
+ # dropped — with every later one — when the tool has no level for it. The
471
+ # plain forms render for the shared `open` tree, which keeps a neutral level as
472
+ # written and drops only an empty or off-scale one.
463
473
  # Usage: copy_skill_bundle "src/skill/dir" "dest/skill/dir"
464
474
  copy_skill_bundle() {
465
475
  _skill_bundles_reset
466
- _skill_bundle_stage "$1" "$2"
467
- _skill_bundles_flush
476
+ _skill_bundle_stage "$1" "$2" || return 1
477
+ _skill_bundles_flush open
468
478
  }
469
479
 
470
480
  # copy_skill_bundle_dirs <dest_root> <src_dir>... — batch form: ONE cp -R
471
481
  # copies every source skill directory into <dest_root>/<skill-name>, then one
472
482
  # awk pass quotes and finalizes every bundled markdown file across all
473
483
  # bundles. A later source with the same skill name overwrites file-by-file in
474
- # 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.
475
486
  copy_skill_bundle_dirs() {
476
- local dest_root="$1"
477
- shift
487
+ copy_skill_bundle_dirs_for open "$@"
488
+ }
489
+
490
+ # copy_skill_bundle_dirs_for <tool> <dest_root> <src_dir>... — the batch form
491
+ # for a tree one tool reads, rendering `effort:` for that tool.
492
+ copy_skill_bundle_dirs_for() {
493
+ local effort_tool="$1" dest_root="$2"
494
+ shift 2
478
495
  [ "$#" -gt 0 ] || return 0
479
- local src dest
496
+ local src dest linked=0
480
497
  local -a srcs=()
481
498
  for src in "$@"; do
482
- srcs+=("${src%/}")
499
+ src="${src%/}"
500
+ srcs+=("$src")
501
+ ! _ski_planned "$src" || linked=1
483
502
  done
484
503
  mkdir -p "$dest_root"
485
- 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
486
517
  _skill_bundles_reset
487
518
  for src in "${srcs[@]}"; do
488
519
  dest="$dest_root/${src##*/}"
489
520
  _skill_bundle_note "$dest"
490
521
  done
491
- _skill_bundles_flush
522
+ _skill_bundles_flush "$effort_tool"
492
523
  }
493
524
 
494
525
  _skill_bundles_reset() {
@@ -500,24 +531,63 @@ _skill_bundles_reset() {
500
531
  _skill_bundle_stage() {
501
532
  local src="${1%/}" dest="$2"
502
533
  mkdir -p "$dest"
503
- 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
504
541
  _skill_bundle_note "$dest"
505
542
  }
506
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
+
507
586
  # Record a staged bundle for the flush pass: mark its SKILL.md for
508
587
  # frontmatter quoting and deduplicate the destination.
509
588
  _skill_bundle_note() {
510
589
  local dest="$1"
511
- # A symlinked SKILL.md is left exactly as `cp -R` produced it — a
512
- # symlink. `[ -f ]` follows links, so quoting it would read the link's
513
- # TARGET and write that content into a real file, turning
514
- # `skills/x/SKILL.md -> /etc/…` into a copy of a host file inside the
515
- # output. That is the leak the symlink-preserving copy exists to
516
- # prevent, so skip the rewrite and say so (the same reason
517
- # `find -type f` in the flush never matches a symlink).
518
- if [ -L "$dest/SKILL.md" ]; then
519
- echo " WARN: ${dest##*/}/SKILL.md is a symlink — emitted as-is (frontmatter not quoted, tokens not expanded)" >&2
520
- elif [ -f "$dest/SKILL.md" ]; then
590
+ if [ -f "$dest/SKILL.md" ]; then
521
591
  _SB_QUOTE_LIST="$_SB_QUOTE_LIST$dest/SKILL.md"$'\n'
522
592
  fi
523
593
  # The same skill name from a later source overwrites the earlier copy;
@@ -531,27 +601,51 @@ _skill_bundle_note() {
531
601
  esac
532
602
  }
533
603
 
604
+ # _skill_bundles_flush <effort-tool>
534
605
  _skill_bundles_flush() {
535
606
  [ "${#_SB_DESTS[@]}" -gt 0 ] || return 0
536
- local -a mds=()
537
- 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
+ }
538
614
  while IFS= read -r f; do
539
- [ -n "$f" ] && mds+=("$f")
540
- 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
541
626
  _skill_bundles_reset
542
627
  [ "${#mds[@]}" -gt 0 ] || return 0
628
+ effort_map_var "$1"
543
629
  # One awk: quote free-text frontmatter fields in each top-level SKILL.md
544
630
  # (strict-YAML consumers reject unquoted colons; `argument-hint:
545
631
  # [pr-number]` would otherwise arrive as a YAML flow sequence and the
546
- # skill silently vanishes from the picker) and expand layout tokens in
547
- # every bundled markdown file. Quoting is idempotent — already-quoted
548
- # values pass through untouched. The quote list travels through the
549
- # environment, like emit_wrapped_bodies specs.
632
+ # skill silently vanishes from the picker), render its `effort:` for the
633
+ # tool, and expand layout tokens in every bundled markdown file. Quoting is
634
+ # idempotent — already-quoted values pass through untouched. The quote list
635
+ # and the effort map travel through the environment, like
636
+ # emit_wrapped_bodies specs. The effort key is matched exactly as
637
+ # frontmatter_index reads it, so the line rewritten is the one the
638
+ # lint judged.
550
639
  is_fin_awk_vars
551
- IS_QUOTE_LIST="$quote_list" awk "${IS_FIN_V[@]}" "$IS_AWK_LIB"'
640
+ IS_QUOTE_LIST="$quote_list" IS_EFFORT_MAP="$IS_EFFORT_MAP" awk "${IS_FIN_V[@]}" "$IS_AWK_LIB"'
552
641
  BEGIN {
553
642
  qn = split(ENVIRON["IS_QUOTE_LIST"], QL, "\n")
554
643
  for (qi = 1; qi <= qn; qi++) if (QL[qi] != "") QUOTE[QL[qi]] = 1
644
+ en = split(ENVIRON["IS_EFFORT_MAP"], EL, "\n")
645
+ for (ei = 1; ei <= en; ei++) {
646
+ eq = index(EL[ei], "=")
647
+ if (eq > 0) EFFORT[substr(EL[ei], 1, eq - 1)] = substr(EL[ei], eq + 1)
648
+ }
555
649
  }
556
650
  function yamlq(s, out, i, c) {
557
651
  out = ""
@@ -572,12 +666,19 @@ _skill_bundles_flush() {
572
666
  flush_file()
573
667
  out_file = FILENAME; line_n = 0
574
668
  state = (FILENAME in QUOTE) ? "before" : ""
669
+ effort_seen = 0
575
670
  }
576
671
  { sub(/\r$/, "") }
577
672
  state == "before" {
578
673
  if (FNR == 1 && $0 == "---") state = "in_fm"
579
674
  else state = "after"
580
675
  }
676
+ state == "in_fm" && FNR > 1 && substr($0, 1, 7) == "effort:" {
677
+ if (effort_seen++) next
678
+ val = fm_value_strip(substr($0, 8))
679
+ if (!(val in EFFORT)) next
680
+ if (EFFORT[val] != val) $0 = "effort: " EFFORT[val]
681
+ }
581
682
  state == "in_fm" && FNR > 1 {
582
683
  if ($0 == "---") state = "after"
583
684
  else {
@@ -660,7 +761,7 @@ sync_open_skill_dirs() {
660
761
  # copy_skill_bundle_dirs owns the frontmatter-quoting pass, so every
661
762
  # target gets it — not just this open-standard dir.
662
763
  if [ "$count" -gt 0 ]; then
663
- copy_skill_bundle_dirs "$output_dir" "${skill_dirs[@]}"
764
+ copy_skill_bundle_dirs_for open "$output_dir" "${skill_dirs[@]}"
664
765
  open_skill_invocation_policies "$output_dir" || return 1
665
766
  fi
666
767
 
@@ -681,19 +782,15 @@ sync_open_skill_dirs() {
681
782
  # when the author's file sets none, and a file whose policy says otherwise, or
682
783
  # that sync cannot read as saying it, refuses the render — an owner-only skill
683
784
  # Codex can still select is the exact failure the field exists to prevent, so
684
- # 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.
685
789
  open_skill_invocation_policies() {
686
790
  local output_dir="$1" path dir flag rows refusals
687
791
  local -a skill_mds=() policy_dirs=() policies=() authored=()
688
792
  for path in "$output_dir"/*/SKILL.md; do
689
- dir="${path%/SKILL.md}"
690
- # A link is emitted as-is (see _skill_bundle_note): reading through it
691
- # or writing beside it would reach outside the output tree.
692
- if [ -L "$dir" ] || [ -L "$path" ]; then
693
- echo " WARN: ${dir##*/}/SKILL.md is reached through a symlink — Codex gets no invocation policy derived for it" >&2
694
- elif [ -f "$path" ]; then
695
- skill_mds+=("$path")
696
- fi
793
+ [ ! -f "$path" ] || skill_mds+=("$path")
697
794
  done
698
795
  [ "${#skill_mds[@]}" -gt 0 ] || return 0
699
796
  # Captured, not streamed: a reader cut short must fail the render rather
@@ -706,10 +803,6 @@ open_skill_invocation_policies() {
706
803
  [ "$flag" = "true" ] || continue
707
804
  dir="${path%/SKILL.md}"
708
805
  path="$dir/agents"
709
- if [ -L "$path" ] || [ -L "$path/openai.yaml" ]; then
710
- echo " WARN: ${dir##*/}/agents/openai.yaml is a symlink — left as-is, its invocation policy is not enforced" >&2
711
- continue
712
- fi
713
806
  if [ -s "$path/openai.yaml" ]; then
714
807
  authored+=("$path/openai.yaml")
715
808
  continue
@@ -830,19 +923,41 @@ enforce_authored_invocation_policies() {
830
923
  # Lint YAML frontmatter for common pitfalls (unquoted colons, leading tabs).
831
924
  # Print warnings to stderr; do not fail. Strict consumers (Codex CLI) reject
832
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.
833
928
  # Batched: one awk process lints every file passed.
929
+ # The engine lints every source once, so this is also where an `effort:` off
930
+ # IS_EFFORT_LEVELS is reported — read with frontmatter_index's semantics,
931
+ # first occurrence only. Adapters render it as absent and say nothing, so the
932
+ # warning names the source exactly once however many tools render it. Unlike
933
+ # the lint notes it is an unindented `WARNING:` line, because `sync --compact`
934
+ # (and so `init`) keeps only those (decision 0015).
834
935
  # Usage: lint_frontmatter_files "a.md" "b.md" ...
835
936
  lint_frontmatter_files() {
836
937
  [ "$#" -gt 0 ] || return 0
837
- awk '
838
- FNR == 1 { in_fm = 0; done = 0 }
938
+ awk -v levels="$IS_EFFORT_LEVELS" "$IS_AWK_LIB"'
939
+ BEGIN {
940
+ nl = split(levels, LV, " ")
941
+ allowed = ""
942
+ for (li = 1; li <= nl; li++) {
943
+ LEVEL[LV[li]] = 1
944
+ allowed = allowed (li > 1 ? ", " : "") LV[li]
945
+ }
946
+ }
947
+ FNR == 1 { in_fm = 0; done = 0; effort_seen = 0 }
839
948
  { sub(/\r$/, "") }
840
949
  done { next }
841
950
  FNR == 1 && $0 != "---" { done = 1; next }
842
951
  FNR == 1 { in_fm = 1; next }
843
952
  in_fm && $0 == "---" { done = 1; next }
953
+ in_fm && substr($0, 1, 7) == "effort:" && !effort_seen++ {
954
+ effort = fm_value_strip(substr($0, 8))
955
+ if (effort != "" && !(effort in LEVEL)) {
956
+ printf "WARNING: %s:%d effort \"%s\" is not one of %s — ignored, so no tool receives an effort from this file\n", FILENAME, FNR, effort, allowed > "/dev/stderr"
957
+ }
958
+ }
844
959
  in_fm && /^\t/ {
845
- 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"
846
961
  }
847
962
  in_fm && /^[a-zA-Z0-9_-]+:[[:space:]]+[^"\047|>[{]/ {
848
963
  value_start = index($0, ":") + 1
@@ -850,10 +965,10 @@ lint_frontmatter_files() {
850
965
  sub(/^[[:space:]]+/, "", value)
851
966
  if (value ~ /:[[:space:]]/ || value ~ /:$/) {
852
967
  col = index(value, ":") + value_start
853
- 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"
854
969
  }
855
970
  if (value ~ /"/) {
856
- 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"
857
972
  }
858
973
  }
859
974
  # Field-length limits. Both Claude Code and the Agent Skills standard
@@ -872,7 +987,7 @@ lint_frontmatter_files() {
872
987
  }
873
988
  limit = (key == "name") ? 64 : 1024
874
989
  if (length(val) > limit) {
875
- 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"
876
991
  }
877
992
  }
878
993
  ' "$@"
@@ -994,8 +1109,8 @@ get_model_default_var() {
994
1109
  cursor:heavy) IS_MODEL_DEFAULT="inherit" ;;
995
1110
  cursor:standard) IS_MODEL_DEFAULT="inherit" ;;
996
1111
  cursor:light) IS_MODEL_DEFAULT="inherit" ;;
997
- # GPT-6 has no mid model, so frontier and heavy share Astra; Codex
998
- # separates them by reasoning effort instead.
1112
+ # GPT-6 has no mid model, so frontier and heavy share Astra and render
1113
+ # identically; reasoning effort comes from `effort:`, never the tier.
999
1114
  copilot:frontier) IS_MODEL_DEFAULT="gpt-6-astra" ;;
1000
1115
  copilot:heavy) IS_MODEL_DEFAULT="gpt-6-astra" ;;
1001
1116
  copilot:standard) IS_MODEL_DEFAULT="gpt-6.1-sol" ;;
@@ -1132,7 +1247,7 @@ load_model_tiers() {
1132
1247
  # the tool has no default for and the manifest overrides nowhere — a typo, as a
1133
1248
  # rule — still renders an empty model, but never silently: the run warns once
1134
1249
  # per tool and tier, unindented on purpose because `sync --compact` keeps only
1135
- # `WARNING:` lines.
1250
+ # `WARNING:` lines (decision 0016).
1136
1251
  # shellcheck disable=SC2034 # IS_MODEL is the return channel read by adapters
1137
1252
  resolve_model_var() {
1138
1253
  case "$1" in
@@ -1242,6 +1357,64 @@ map_access_to_claude_disallowed() {
1242
1357
  echo "$IS_CLAUDE_DISALLOWED"
1243
1358
  }
1244
1359
 
1360
+ # --- Effort Mapping ---
1361
+
1362
+ # `effort:` is the tool-neutral reasoning effort an agent or skill asks for,
1363
+ # lowest first (decision 0015). It is independent of `tier`, which selects the
1364
+ # model only. A value off this scale — matched exactly, case included — is an
1365
+ # absent effort: lint_frontmatter_files warns about it and no tool receives
1366
+ # one, because a typo in another source's package must not block a sync.
1367
+ IS_EFFORT_LEVELS="low medium high xhigh max ultra"
1368
+
1369
+ # effort_levels_var <tool> — set IS_EFFORT_TOOL_LEVELS to the levels the tool's
1370
+ # native effort field accepts, in scale order; empty for a tool without one.
1371
+ # `open` is the shared Agent Skills tree (.agents/skills/), which keeps the
1372
+ # neutral value for the tools that read it.
1373
+ effort_levels_var() {
1374
+ case "$1" in
1375
+ claude) IS_EFFORT_TOOL_LEVELS="low medium high xhigh max" ;;
1376
+ codex|open) IS_EFFORT_TOOL_LEVELS="$IS_EFFORT_LEVELS" ;;
1377
+ # Copilot, Cursor, OpenCode, Antigravity and Pi have no per-agent
1378
+ # effort field: the tool's own setting applies.
1379
+ *) IS_EFFORT_TOOL_LEVELS="" ;;
1380
+ esac
1381
+ }
1382
+
1383
+ # map_effort_var <tool> <effort> — set IS_EFFORT to the level the tool receives:
1384
+ # the same level, or the nearest lower one the tool has (`ultra` is `max` for
1385
+ # Claude Code). Empty when the effort is absent or off the scale, or the tool
1386
+ # has no field. Which levels a particular model supports is the tool's call: a
1387
+ # manifest may override the model, and the tools fall back on their own.
1388
+ map_effort_var() {
1389
+ local level best=""
1390
+ IS_EFFORT=""
1391
+ effort_levels_var "$1"
1392
+ for level in $IS_EFFORT_LEVELS; do
1393
+ case " $IS_EFFORT_TOOL_LEVELS " in *" $level "*) best="$level" ;; esac
1394
+ if [ "$level" = "${2-}" ]; then
1395
+ IS_EFFORT="$best"
1396
+ return 0
1397
+ fi
1398
+ done
1399
+ }
1400
+
1401
+ map_effort() {
1402
+ map_effort_var "$1" "$2"
1403
+ echo "$IS_EFFORT"
1404
+ }
1405
+
1406
+ # effort_map_var <tool> — set IS_EFFORT_MAP to one `<neutral>=<native>` line per
1407
+ # level the tool maps, so a batched awk pass rewrites `effort:` in place with
1408
+ # map_effort_var's answers instead of a second copy of the mapping.
1409
+ effort_map_var() {
1410
+ local level map=""
1411
+ for level in $IS_EFFORT_LEVELS; do
1412
+ map_effort_var "$1" "$level"
1413
+ [ -z "$IS_EFFORT" ] || map+="$level=$IS_EFFORT"$'\n'
1414
+ done
1415
+ IS_EFFORT_MAP="$map"
1416
+ }
1417
+
1245
1418
  # --- Validation ---
1246
1419
 
1247
1420
  # Lexically canonicalize a path: collapse `//`, `.` and `..` by pure string
@@ -1453,7 +1626,8 @@ warn_unsynced() {
1453
1626
  load_yaml_list "$config_file" "$section"
1454
1627
  while IFS= read -r src; do
1455
1628
  [ -z "$src" ] && continue
1456
- all_sources+=("$src")
1629
+ normalize_path_var "$repo_root/$src"
1630
+ all_sources+=("${IS_NORM_PATH#"$repo_root/"}")
1457
1631
  done <<< "$IS_YAML_LIST"
1458
1632
  done
1459
1633
 
@@ -1547,11 +1721,7 @@ warn_unsynced() {
1547
1721
  done
1548
1722
 
1549
1723
  if [ "$matched" = false ]; then
1550
- if [ $warnings -eq 0 ]; then
1551
- echo ""
1552
- echo "=== WARNING: Unsynced directories ==="
1553
- fi
1554
- echo " NOT SYNCED: $rel_dir"
1724
+ echo "WARNING: NOT SYNCED: $rel_dir" >&2
1555
1725
  warnings=$((warnings + 1))
1556
1726
  fi
1557
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)
@@ -1562,10 +1732,363 @@ warn_unsynced() {
1562
1732
  # and hand-placing an entry in an ordered list decides which artifact
1563
1733
  # wins. The engine may also run without the CLI around it.
1564
1734
  if [ "${IS_CLI:-0}" = "1" ]; then
1565
- 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
1566
1736
  else
1567
- echo " Add these paths to sources: in ${config_file##*/}"
1737
+ echo "WARNING: Add these paths to sources: in ${config_file##*/}" >&2
1738
+ fi
1739
+ fi
1740
+ }
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
1568
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
1569
2092
  fi
1570
2093
  }
1571
2094
 
@@ -1588,6 +2111,9 @@ warn_unsynced() {
1588
2111
  # replay that answer. Only the engine sets it: a sync never writes into a
1589
2112
  # source (validate_output_path refuses), so each caller would otherwise re-run
1590
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.
1591
2117
  read_source_artifact_files() {
1592
2118
  local repo_root="$1"
1593
2119
  local config_file="$2"
@@ -1595,8 +2121,15 @@ read_source_artifact_files() {
1595
2121
  local src f dir listing memo_key="" key_var="" list_var="" joined=""
1596
2122
 
1597
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
1598
2131
  case "${IS_SOURCE_FILES_MEMO:-0}:$section" in
1599
- 1:rules|1:agents|1:skills)
2132
+ 1:rules|1:agents)
1600
2133
  key_var="IS_SF_${section}_KEY"
1601
2134
  list_var="IS_SF_${section}_LIST"
1602
2135
  memo_key="$repo_root"$'\n'"$config_file"
@@ -1623,12 +2156,6 @@ read_source_artifact_files() {
1623
2156
  exit 1
1624
2157
  }
1625
2158
  ;;
1626
- skills)
1627
- listing="$(find "$dir" -mindepth 1 -maxdepth 1 -type d -print | LC_ALL=C sort)" || {
1628
- echo "ERROR: cannot enumerate $section under '$dir' — refusing to render a partial list." >&2
1629
- exit 1
1630
- }
1631
- ;;
1632
2159
  *) continue ;;
1633
2160
  esac
1634
2161
  [ -n "$listing" ] || continue
@@ -1636,10 +2163,6 @@ read_source_artifact_files() {
1636
2163
  # array it fills survives and any failure inside it is this shell's.
1637
2164
  while IFS= read -r f; do
1638
2165
  [ -n "$f" ] || continue
1639
- if [ "$section" = "skills" ]; then
1640
- [ -f "$f/SKILL.md" ] || continue
1641
- f="$f/SKILL.md"
1642
- fi
1643
2166
  IS_SOURCE_FILES+=("$f")
1644
2167
  joined="$joined$f"$'\n'
1645
2168
  done <<< "$listing"
@@ -1787,15 +2310,220 @@ report_context_source_sizes() {
1787
2310
 
1788
2311
  # --- Config Parsing ---
1789
2312
 
2313
+ # --- Package references in sources -------------------------------------------
2314
+ # A `sources:` entry names a directory inside an installed package in one of
2315
+ # three spellings (decision 0019):
2316
+ #
2317
+ # .intelligence/packages/@scope/name/<dir> the store path: an ordinary
2318
+ # repository path, as the CLI writes it
2319
+ # @scope/name/<dir> by full name: a reference only while
2320
+ # `packages:` declares @scope/name,
2321
+ # otherwise an ordinary path as before
2322
+ # <alias>:<dir> by the alias that package's
2323
+ # `packages:` entry declares
2324
+ #
2325
+ # A reference renders exactly as the store path it stands for. The list parser
2326
+ # below expands it inside the awk pass that already reads the manifest; that
2327
+ # pass is the one point every reader goes through — adapters, the engine's own
2328
+ # loops and the CLI — so expansion costs no process, and the CLI sees exactly
2329
+ # what the engine renders (decision 0009). A reference that resolves to nothing
2330
+ # is left out of the list, so no reader can render it as a path: `sync` names it
2331
+ # in a WARNING: line and `status --check` reports it.
2332
+ #
2333
+ # An alias is two or more of A-Z a-z 0-9 . _ - and starts with a letter or digit:
2334
+ # no `/`, `:` or `@`, no whitespace or quotes, and never one letter that reads
2335
+ # like a drive. It is a lookup key, never part of a path. <dir> is one or more
2336
+ # `/`-separated segments, none of them empty, `.` or `..`, without a backslash,
2337
+ # so a reference never leaves its package.
2338
+ #
2339
+ # These functions are the only definition. The CLI's editors (lib/manifest.sh),
2340
+ # `source`, `package alias` and `status --check` load this same string:
2341
+ #
2342
+ # pkr_collect(line) feed every manifest line; records the names `packages:`
2343
+ # declares and the alias each one states
2344
+ # pkr_parse(entry) classify one entry: path, ok, unknown (an alias no
2345
+ # package declares), ambiguous (an alias several declare)
2346
+ # or invalid (a reference whose <dir> is malformed); sets
2347
+ # PKR_NAME, PKR_DIR, PKR_ALIAS and, when ambiguous,
2348
+ # PKR_HOLDERS
2349
+ # pkr_expand(entry) the directory the entry renders as, "" when it renders
2350
+ # nothing; leaves the state in PKR_STATE
2351
+ # pkr_alias_ok(a) whether <a> is a well-formed alias
2352
+ IS_PKG_REF_AWK='
2353
+ function pkr_seg_ok(s) { return s != "" && s != "." && s != ".." }
2354
+ # The CLI refuses any other package name (assert_valid_pkg_name): a name
2355
+ # becomes a store path, so one that is not a plain @scope/name is never
2356
+ # declared.
2357
+ function pkr_name_ok(name, p) {
2358
+ if (name !~ /^@[@A-Za-z0-9._-]*\/[@A-Za-z0-9._-]*$/) return 0
2359
+ p = index(name, "/")
2360
+ return pkr_seg_ok(substr(name, 2, p - 2)) && pkr_seg_ok(substr(name, p + 1))
2361
+ }
2362
+ # Enumerated, not a range: a range follows the locale in some awks.
2363
+ function pkr_alias_ok(a, i, c) {
2364
+ if (length(a) < 2) return 0
2365
+ for (i = 1; i <= length(a); i++) {
2366
+ c = substr(a, i, 1)
2367
+ if (index("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", c)) continue
2368
+ if (i > 1 && index("._-", c)) continue
2369
+ return 0
2370
+ }
2371
+ return 1
2372
+ }
2373
+ function pkr_dir_ok(dir, n, i, seg) {
2374
+ if (dir == "" || index(dir, "\\")) return 0
2375
+ n = split(dir, seg, "/")
2376
+ for (i = 1; i <= n; i++) if (!pkr_seg_ok(seg[i])) return 0
2377
+ return 1
2378
+ }
2379
+ # The quoted keys of the top-level packages: block and their alias fields,
2380
+ # read the way lib/qmap.awk reads what the CLI writes: a duplicated key is
2381
+ # one package, and the first alias a package states is the one it has.
2382
+ function pkr_collect(line, s, q, v) {
2383
+ if (line ~ /^packages:[ \t]*$/) { pkr_in = 1; pkr_cur = ""; return }
2384
+ if (!pkr_in || line ~ /^[ \t]*(#.*)?$/) return
2385
+ if (line ~ /^[^ \t]/) { pkr_in = 0; pkr_cur = ""; return }
2386
+ if (substr(line, 1, 3) == " \"") {
2387
+ pkr_cur = ""
2388
+ s = substr(line, 4)
2389
+ q = index(s, "\"")
2390
+ if (q < 2) return
2391
+ v = substr(s, 1, q - 1)
2392
+ if (!pkr_name_ok(v)) return
2393
+ if (!(v in pkr_seen)) { pkr_seen[v] = 1; PKR_NAMES[++PKR_N] = v }
2394
+ pkr_cur = v
2395
+ return
2396
+ }
2397
+ if (substr(line, 1, 4) != " ") { pkr_cur = ""; return }
2398
+ if (pkr_cur == "" || (pkr_cur in PKR_ALIAS_RAW) || substr(line, 5, 6) != "alias:") return
2399
+ v = substr(line, 11)
2400
+ sub(/^[ \t]+/, "", v)
2401
+ if (substr(v, 1, 1) == "\"") {
2402
+ v = substr(v, 2)
2403
+ q = index(v, "\"")
2404
+ if (q == 0) return
2405
+ v = substr(v, 1, q - 1)
2406
+ } else {
2407
+ sub(/[ \t]+#.*$/, "", v)
2408
+ sub(/[ \t]+$/, "", v)
2409
+ }
2410
+ PKR_ALIAS_RAW[pkr_cur] = v
2411
+ if (!pkr_alias_ok(v)) return
2412
+ pkr_holders[v] = (v in pkr_holders) ? pkr_holders[v] ", " pkr_cur : pkr_cur
2413
+ pkr_count[v]++
2414
+ pkr_alias[v] = pkr_cur
2415
+ }
2416
+ function pkr_parse(entry, c, p, i, rest, name) {
2417
+ PKR_NAME = ""; PKR_DIR = ""; PKR_ALIAS = ""; PKR_HOLDERS = ""
2418
+ if (substr(entry, 1, 1) == "@") {
2419
+ p = index(entry, "/")
2420
+ if (p == 0) return "path"
2421
+ rest = substr(entry, p + 1)
2422
+ i = index(rest, "/")
2423
+ name = i ? substr(entry, 1, p + i - 1) : entry
2424
+ if (!(name in pkr_seen)) return "path"
2425
+ PKR_NAME = name
2426
+ PKR_DIR = i ? substr(rest, i + 1) : ""
2427
+ return pkr_dir_ok(PKR_DIR) ? "ok" : "invalid"
2428
+ }
2429
+ c = index(entry, ":")
2430
+ if (c == 0 || !pkr_alias_ok(substr(entry, 1, c - 1))) return "path"
2431
+ PKR_ALIAS = substr(entry, 1, c - 1)
2432
+ PKR_DIR = substr(entry, c + 1)
2433
+ if (!pkr_dir_ok(PKR_DIR)) return "invalid"
2434
+ if (!(PKR_ALIAS in pkr_count)) return "unknown"
2435
+ if (pkr_count[PKR_ALIAS] > 1) { PKR_HOLDERS = pkr_holders[PKR_ALIAS]; return "ambiguous" }
2436
+ PKR_NAME = pkr_alias[PKR_ALIAS]
2437
+ return "ok"
2438
+ }
2439
+ function pkr_expand(entry) {
2440
+ PKR_STATE = pkr_parse(entry)
2441
+ if (PKR_STATE == "path") return entry
2442
+ if (PKR_STATE == "ok") return ".intelligence/packages/" PKR_NAME "/" PKR_DIR
2443
+ return ""
2444
+ }
2445
+ '
2446
+
2447
+ # The value of one block-sequence item in the manifest — the only reading of a
2448
+ # list entry. Every list reader below and the CLI's `sources:` editors
2449
+ # (lib/manifest.sh) load this string, so an entry the engine renders and the
2450
+ # entry an editor compares are the same text (decision 0009, point 7).
2451
+ #
2452
+ # yaml_item(text) <text> is what follows the item's `-`. Returns the value:
2453
+ # whitespace around it and a trailing `# comment` dropped,
2454
+ # then every quote removed, as the readers always did. A
2455
+ # comment starts at a `#` that follows whitespace outside
2456
+ # quotes; inside a quoted scalar a `#` is part of the value,
2457
+ # and so is one with no whitespace before it (`a#b`).
2458
+ IS_YAML_ITEM_AWK='
2459
+ function yaml_item(s, q, e, rest) {
2460
+ sub(/^[ \t]+/, "", s)
2461
+ q = substr(s, 1, 1)
2462
+ rest = s
2463
+ s = ""
2464
+ if (q == "\"" || q == "\047") {
2465
+ e = index(substr(rest, 2), q)
2466
+ if (e == 0) { s = rest; rest = "" }
2467
+ else { s = substr(rest, 1, e + 1); rest = substr(rest, e + 2) }
2468
+ }
2469
+ if (s == "" && substr(rest, 1, 1) == "#") rest = ""
2470
+ else if (match(rest, /[ \t]#/)) rest = substr(rest, 1, RSTART - 1)
2471
+ s = s rest
2472
+ sub(/[ \t]+$/, "", s)
2473
+ gsub(/["\047]/, "", s)
2474
+ return s
2475
+ }
2476
+ '
2477
+
2478
+ # The manifest list reader. `mode` selects what it prints for each entry:
2479
+ # expand the entry as the engine renders it: a reference becomes its store
2480
+ # path, one that resolves to nothing is left out
2481
+ # raw the entry as written — what an editor compares and rewrites
2482
+ # classify raw, pkr_parse state, expansion, alias and the packages sharing
2483
+ # it, separated by \037 — what `status --check` and `source` judge
2484
+ # Only rules, agents and skills hold sources; every other list reads raw.
2485
+ # Entries are held until the end of the file because `packages:` may follow
2486
+ # `sources:`.
2487
+ IS_YAML_LIST_AWK='
2488
+ { sub(/\r$/, ""); pkr_collect($0) }
2489
+ /^[a-z]/ { current_section = ""; depth = 0 }
2490
+ /^ [a-z]/ { current_section = ""; depth = 0 }
2491
+ $0 ~ "^" section ":" { current_section = section; depth = 0; next }
2492
+ $0 ~ "^ " section ":" { current_section = section; depth = 2; next }
2493
+ current_section == section && depth == 0 && /^ - / {
2494
+ vals[++nv] = yaml_item(substr($0, 5))
2495
+ }
2496
+ current_section == section && depth == 2 && /^ - / {
2497
+ vals[++nv] = yaml_item(substr($0, 7))
2498
+ }
2499
+ END {
2500
+ sources = section == "rules" || section == "agents" || section == "skills"
2501
+ n = nv + 0
2502
+ for (i = 1; i <= n; i++) {
2503
+ val = vals[i]
2504
+ if (!sources || mode == "raw") { print val; continue }
2505
+ dir = pkr_expand(val)
2506
+ if (mode == "classify") print val "\037" PKR_STATE "\037" dir "\037" PKR_ALIAS "\037" PKR_HOLDERS
2507
+ else if (PKR_STATE == "path" || PKR_STATE == "ok") print dir
2508
+ }
2509
+ }
2510
+ '
2511
+
1790
2512
  # Read a simple list from config.yaml
1791
2513
  # Format: key:\n - "value1"\n - "value2"
1792
- # Usage: readarray -t arr < <(read_yaml_list "config.yaml" "rules")
2514
+ # Usage: while IFS= read -r src; do ...; done < <(read_yaml_list "intelligence.yaml" "rules")
2515
+ #
2516
+ # A source list arrives with its package references expanded to store paths —
2517
+ # the spelling every renderer resolves as $REPO_ROOT/<entry> — and without the
2518
+ # ones that resolve to nothing. Editors that rewrite the manifest read it with
2519
+ # read_yaml_list_raw instead.
1793
2520
  #
1794
2521
  # Consults the load_yaml_list cache first: sync reads the same sections from
1795
2522
  # the same manifest dozens of times, and each awk spawn costs tens of
1796
2523
  # milliseconds on Windows. The cache is only ever populated by
1797
- # load_yaml_list, which the engine calls for a manifest it never mutates, so
1798
- # 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.
1799
2527
  read_yaml_list() {
1800
2528
  local file="$1"
1801
2529
  local section="$2"
@@ -1813,27 +2541,67 @@ read_yaml_list() {
1813
2541
  fi
1814
2542
  ;;
1815
2543
  esac
1816
- awk -v section="$section" '
1817
- {
1818
- sub(/\r$/, "")
1819
- }
1820
- /^[a-z]/ { current_section = ""; depth = 0 }
1821
- /^ [a-z]/ { current_section = ""; depth = 0 }
1822
- $0 ~ "^" section ":" { current_section = section; depth = 0; next }
1823
- $0 ~ "^ " section ":" { current_section = section; depth = 2; next }
1824
- current_section == section && depth == 0 && /^ - / {
1825
- val = $0
1826
- sub(/^ - /, "", val)
1827
- gsub(/["\047]/, "", val)
1828
- print val
1829
- }
1830
- current_section == section && depth == 2 && /^ - / {
1831
- val = $0
1832
- sub(/^ - /, "", val)
1833
- gsub(/["\047]/, "", val)
1834
- print val
2544
+ awk -v section="$section" -v mode=expand "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK$IS_YAML_LIST_AWK" "$file"
2545
+ }
2546
+
2547
+ # read_yaml_list_raw <file> <section> — the entries exactly as written,
2548
+ # references included and never cached. The CLI's sources editors compare and
2549
+ # rewrite these, so an entry is matched as the user spelled it.
2550
+ read_yaml_list_raw() {
2551
+ awk -v section="$2" -v mode=raw "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK$IS_YAML_LIST_AWK" "$1"
2552
+ }
2553
+
2554
+ # read_source_entries <file> <section> — one \037-separated line per entry: as
2555
+ # written, its pkr_parse state (path, ok, unknown, ambiguous or invalid), the
2556
+ # directory it renders as ("" for none), the alias it names and, when several
2557
+ # packages declare that alias, their names. One pass of the parser the engine
2558
+ # renders through, so a verdict can never disagree with the render.
2559
+ read_source_entries() {
2560
+ awk -v section="$2" -v mode=classify "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK$IS_YAML_LIST_AWK" "$1"
2561
+ }
2562
+
2563
+ # read_package_aliases <file> — one \037-separated line per declared package that
2564
+ # states an alias: its name, the alias, and ok, ambiguous (another package states
2565
+ # it too) or invalid (not a well-formed alias, so it resolves nothing). An
2566
+ # invalid alias is manifest input that may hold anything, so its field is empty.
2567
+ read_package_aliases() {
2568
+ [ -f "$1" ] || return 0
2569
+ awk "$IS_PKG_REF_AWK"'
2570
+ { sub(/\r$/, ""); pkr_collect($0) }
2571
+ END {
2572
+ for (i = 1; i <= PKR_N; i++) {
2573
+ name = PKR_NAMES[i]
2574
+ if (!(name in PKR_ALIAS_RAW)) continue
2575
+ a = PKR_ALIAS_RAW[name]
2576
+ if (!pkr_alias_ok(a)) { print name "\037\037invalid"; continue }
2577
+ print name "\037" a "\037" (pkr_count[a] > 1 ? "ambiguous" : "ok")
2578
+ }
1835
2579
  }
1836
- ' "$file"
2580
+ ' "$1"
2581
+ }
2582
+
2583
+ # What pkr_alias_ok accepts, in the words every message that refuses an alias
2584
+ # uses.
2585
+ # shellcheck disable=SC2034
2586
+ IS_PKG_ALIAS_RULE="two or more of A-Z a-z 0-9 . _ -, starting with a letter or digit"
2587
+
2588
+ # pkg_alias_valid <alias> — true when <alias> is well formed (pkr_alias_ok). The
2589
+ # value travels through the environment: `awk -v` would interpret backslashes.
2590
+ pkg_alias_valid() {
2591
+ IS_PKR_CANDIDATE="$1" awk "$IS_PKG_REF_AWK"'BEGIN { exit !pkr_alias_ok(ENVIRON["IS_PKR_CANDIDATE"]) }'
2592
+ }
2593
+
2594
+ # source_reference_problem_var <state> <alias> <holders> — set IS_SOURCE_PROBLEM
2595
+ # to why a reference in that state renders nothing, "" for path and ok. The one
2596
+ # wording of `sync`'s WARNING: line and of `status --check`.
2597
+ # shellcheck disable=SC2034
2598
+ source_reference_problem_var() {
2599
+ case "$1" in
2600
+ unknown) IS_SOURCE_PROBLEM="names alias '$2', which no package in packages: declares" ;;
2601
+ ambiguous) IS_SOURCE_PROBLEM="names alias '$2', which several packages declare ($3) — an alias names one package" ;;
2602
+ invalid) IS_SOURCE_PROBLEM="names no directory inside its package — the part after the package must be one or more '/'-separated segments, none empty, '.' or '..'" ;;
2603
+ *) IS_SOURCE_PROBLEM="" ;;
2604
+ esac
1837
2605
  }
1838
2606
 
1839
2607
  # load_yaml_list <file> <section> — fill the global IS_YAML_LIST with the
@@ -1863,21 +2631,19 @@ load_yaml_list() {
1863
2631
  esac
1864
2632
  }
1865
2633
 
1866
- # load_yaml_lists <file> <section>... — warm the load_yaml_list cache for several
1867
- # sections in one manifest pass. Each section runs read_yaml_list's own state
1868
- # machine, so every cached value is what load_yaml_list would have stored.
1869
- load_yaml_lists() {
1870
- local file="$1" out line s v j
1871
- shift
1872
- for s in "$@"; do
1873
- case "$s" in
1874
- rules|agents|skills|ignore|submodules) ;;
1875
- *) return 1 ;;
1876
- esac
1877
- done
1878
- out="$(awk -v sections="$*" '
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"'
1879
2645
  BEGIN { n = split(sections, want, " ") }
1880
- { sub(/\r$/, "") }
2646
+ { sub(/\r$/, ""); pkr_collect($0) }
1881
2647
  {
1882
2648
  for (k = 1; k <= n; k++) {
1883
2649
  s = want[k]
@@ -1886,49 +2652,53 @@ load_yaml_lists() {
1886
2652
  if ($0 ~ "^" s ":") { cur[k] = s; dep[k] = 0; continue }
1887
2653
  if ($0 ~ "^ " s ":") { cur[k] = s; dep[k] = 2; continue }
1888
2654
  if (cur[k] == s && dep[k] == 0 && /^ - /) {
1889
- val = $0
1890
- sub(/^ - /, "", val)
1891
- gsub(/["\047]/, "", val)
1892
- print s "\037" val
2655
+ got_s[++ng] = s
2656
+ got_v[ng] = yaml_item(substr($0, 5))
1893
2657
  }
1894
2658
  if (cur[k] == s && dep[k] == 2 && /^ - /) {
1895
- val = $0
1896
- sub(/^ - /, "", val)
1897
- gsub(/["\047]/, "", val)
1898
- print s "\037" val
2659
+ got_s[++ng] = s
2660
+ got_v[ng] = yaml_item(substr($0, 7))
1899
2661
  }
1900
2662
  }
1901
2663
  }
1902
- ' "$file")"
1903
- for s in "$@"; do
1904
- j=""
1905
- while IFS= read -r line; do
1906
- [ "${line%%$'\037'*}" = "$s" ] || continue
1907
- v="${line#*$'\037'}"
1908
- j="$j$v"$'\n'
1909
- done <<< "$out"
1910
- while [[ "$j" == *$'\n' ]]; do j="${j%$'\n'}"; done
1911
- printf -v "IS_YL_${s}_FILE" '%s' "$file"
1912
- printf -v "IS_YL_${s}_VAL" '%s' "$j"
1913
- done
1914
- }
2664
+ END {
2665
+ m = ng + 0
2666
+ for (i = 1; i <= m; i++) {
2667
+ s = got_s[i]
2668
+ val = got_v[i]
2669
+ if (s != "rules" && s != "agents" && s != "skills") { print s "\037" val; continue }
2670
+ dir = pkr_expand(val)
2671
+ if (PKR_STATE == "path" || PKR_STATE == "ok") print s "\037" dir
2672
+ else print "!\037" s "\037" val "\037" PKR_STATE "\037" dir "\037" PKR_ALIAS "\037" PKR_HOLDERS
2673
+ }
2674
+ }
2675
+ '
1915
2676
 
1916
- # load_targets_cache <file> — parse the whole targets: section once into the
1917
- # global IS_TGT_TSV (one `name<TAB>enabled<TAB>output` row per target),
1918
- # matching is_target_enabled and get_target_output semantics: inline and block
1919
- # forms, first occurrence wins, `enabled` defaults to 0 when the block ends
1920
- # without one and to empty at end of file. The engine warms this once;
1921
- # both readers consult it before spawning awk.
1922
- load_targets_cache() {
1923
- local file="$1"
1924
- if [ "${IS_TGT_FILE:-}" = "$file" ]; then
1925
- return 0
1926
- fi
1927
- 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='
1928
2698
  function flush_target(at_sibling) {
1929
2699
  if (name == "") return
1930
2700
  if (enabled == "" && at_sibling) enabled = 0
1931
- printf "%s\t%s\t%s\n", name, enabled, output
2701
+ printf "%s%s\t%s\t%s\n", ttag, name, enabled, output
1932
2702
  name = ""
1933
2703
  }
1934
2704
  { sub(/\r$/, "") }
@@ -1964,10 +2734,147 @@ load_targets_cache() {
1964
2734
  output = val
1965
2735
  }
1966
2736
  END { flush_target(0) }
1967
- ' "$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")"
2808
+ IS_TGT_FILE="$file"
2809
+ }
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"
1968
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
1969
2875
  }
1970
2876
 
2877
+
1971
2878
  # _targets_cache_row <target> — scan the cached TSV; sets IS_TGT_ROW_ENABLED /
1972
2879
  # IS_TGT_ROW_OUTPUT. Returns 1 when the target has no row (reader falls back
1973
2880
  # to empty, same as the awk scan finding nothing).
@@ -2189,18 +3096,23 @@ get_yaml_field() {
2189
3096
  local file="$1"
2190
3097
  local section="$2"
2191
3098
  local key="$3"
2192
- awk -v section="$section" -v key="$key" '
2193
- { sub(/\r$/, "") }
2194
- $0 ~ "^" section ":" { in_section = 1; next }
2195
- in_section && /^[a-zA-Z]/ { exit }
2196
- in_section && $0 ~ "^ " key ":" {
2197
- val = $0
2198
- sub(/.*:[[:space:]]*["\047]?/, "", val)
2199
- sub(/["\047]?[[:space:]]*$/, "", val)
2200
- print val
2201
- exit
2202
- }
2203
- ' "$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 "$@")"
2204
3116
  }
2205
3117
 
2206
3118
  # Get project name from config.yaml (project.name)