@ainova-systems/intelligence 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/init.sh +2 -1
- package/cli/commands/registry.sh +1 -1
- package/cli/commands/source.sh +1 -1
- package/cli/commands/sync.sh +78 -6
- package/cli/intelligence +15 -2
- package/cli/internal/check.sh +39 -0
- package/cli/internal/convert-legacy.sh +1 -1
- package/cli/internal/package-search.sh +1 -1
- package/cli/internal/package-update.sh +2 -2
- package/cli/internal/restore.sh +3 -2
- package/cli/lib/cli-common.sh +145 -33
- package/cli/lib/gitignore.sh +19 -2
- package/cli/lib/lockfile.sh +14 -4
- package/cli/lib/manifest.sh +147 -1
- package/cli/lib/qmap.awk +97 -32
- package/cli/lib/registry.sh +1 -1
- package/cli/lib/sync-cache.sh +596 -142
- package/cli/lib/sync-lock.sh +19 -2
- package/engine/ENGINE_SHA +1 -1
- package/engine/VERSION +1 -1
- package/engine/adapters/_template.sh +32 -8
- package/engine/adapters/agents.sh +47 -52
- package/engine/adapters/antigravity.sh +71 -53
- package/engine/adapters/claude.sh +42 -57
- package/engine/adapters/codex.sh +77 -16
- package/engine/adapters/copilot.sh +54 -65
- package/engine/adapters/cursor.sh +88 -78
- package/engine/adapters/opencode.sh +23 -32
- package/engine/adapters/pi.sh +140 -42
- package/engine/lib/adapter-contract.sh +36 -4
- package/engine/lib/common.sh +1388 -165
- package/engine/lib/contract.sh +5 -0
- package/engine/sync.sh +145 -38
- package/package.json +1 -1
- package/packages/sync/references/adapters.md +48 -38
- package/packages/sync/references/conventions.md +61 -12
- package/packages/sync/references/onboarding-migration.md +4 -4
- package/packages/sync/rules/intelligence-authoring.md +2 -2
- package/packages/sync/skills/intelligence-learn-from-repository/SKILL.md +7 -5
- package/packages/sync/skills/intelligence-review-context/references/audit-checks.md +1 -1
- package/packages/sync/skills/intelligence-update-context/references/rules.md +5 -0
package/engine/lib/common.sh
CHANGED
|
@@ -71,6 +71,46 @@ IS_AWK_LIB='
|
|
|
71
71
|
return val
|
|
72
72
|
}
|
|
73
73
|
function base_name(p, q, m) { m = split(p, q, "/"); return q[m] }
|
|
74
|
+
# The name a source renders under (decision 0021) comes from the source
|
|
75
|
+
# catalog, which hands a batch its `<path> US <name>` lines through
|
|
76
|
+
# ENVIRON["IS_NAME_MAP"]. A path the map does not hold keeps its own name.
|
|
77
|
+
BEGIN {
|
|
78
|
+
is_nm_n = split(ENVIRON["IS_NAME_MAP"], is_nm_rows, "\n")
|
|
79
|
+
for (is_nm_i = 1; is_nm_i <= is_nm_n; is_nm_i++) {
|
|
80
|
+
is_nm_p = index(is_nm_rows[is_nm_i], sprintf("%c", 31))
|
|
81
|
+
if (is_nm_p > 0) ART_NAME[substr(is_nm_rows[is_nm_i], 1, is_nm_p - 1)] = substr(is_nm_rows[is_nm_i], is_nm_p + 1)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
# art_file(path, ext) — the output file name: the catalog name plus <ext>,
|
|
85
|
+
# or the source file name itself.
|
|
86
|
+
function art_file(p, ext) { return (p in ART_NAME) ? ART_NAME[p] ext : base_name(p) }
|
|
87
|
+
# art_name(path) — the catalog name, or the file name without `.md`.
|
|
88
|
+
function art_name(p, b) {
|
|
89
|
+
if (p in ART_NAME) return ART_NAME[p]
|
|
90
|
+
b = base_name(p); sub(/\.md$/, "", b); return b
|
|
91
|
+
}
|
|
92
|
+
# tgt_drop(line) — whether a frontmatter line belongs to `targets:` or
|
|
93
|
+
# `exclude_targets:` (decision 0021), which select adapters and are
|
|
94
|
+
# removed from every rendered copy. Call tgt_reset() at the start of each
|
|
95
|
+
# file and tgt_drop() for each of its frontmatter lines in order: a block
|
|
96
|
+
# list, or a flow list spanning lines, goes with its key.
|
|
97
|
+
function tgt_reset() { tgt_cont = "" }
|
|
98
|
+
function tgt_drop(s, v) {
|
|
99
|
+
if (tgt_cont == "block") {
|
|
100
|
+
if (s ~ /^[ \t]/ || s == "") return 1
|
|
101
|
+
tgt_cont = ""
|
|
102
|
+
} else if (tgt_cont == "flow") {
|
|
103
|
+
if (index(s, "]")) tgt_cont = ""
|
|
104
|
+
return 1
|
|
105
|
+
}
|
|
106
|
+
if (s !~ /^(targets|exclude_targets):/) return 0
|
|
107
|
+
v = substr(s, index(s, ":") + 1)
|
|
108
|
+
sub(/[ \t]+#.*$/, "", v)
|
|
109
|
+
gsub(/^[ \t]+|[ \t]+$/, "", v)
|
|
110
|
+
if (v == "") tgt_cont = "block"
|
|
111
|
+
else if (substr(v, 1, 1) == "[" && !index(v, "]")) tgt_cont = "flow"
|
|
112
|
+
return 1
|
|
113
|
+
}
|
|
74
114
|
'
|
|
75
115
|
|
|
76
116
|
# is_fin_awk_vars — fill the global IS_FIN_V array with the -v bindings the
|
|
@@ -119,23 +159,58 @@ finalize_output_file() {
|
|
|
119
159
|
finalize_copy_files() {
|
|
120
160
|
local dst="$1"
|
|
121
161
|
shift
|
|
162
|
+
finalize_copy_named "$dst" "" "$@"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# finalize_copy_named <dst_dir> <name-map> <src>... — finalize_copy_files with
|
|
166
|
+
# each source written as <dst_dir>/<name>.md when <name-map> holds it: the
|
|
167
|
+
# rendered names source_artifacts_var leaves in IS_SOURCE_NAME_MAP, so
|
|
168
|
+
# `.draft.md` and `me.local.md` land as `draft.md` and `me.md`.
|
|
169
|
+
finalize_copy_named() {
|
|
170
|
+
local dst="$1" map="$2" f
|
|
171
|
+
shift 2
|
|
122
172
|
[ "$#" -gt 0 ] || return 0
|
|
123
|
-
local f
|
|
124
173
|
# awk never reads a record from an empty source, so pre-create those to
|
|
125
174
|
# keep the old cp behavior of producing an empty output file.
|
|
126
175
|
for f in "$@"; do
|
|
127
|
-
[ -s "$f" ]
|
|
176
|
+
[ -s "$f" ] && continue
|
|
177
|
+
if source_name_map_var "$map" "$f"; then
|
|
178
|
+
: > "$dst/$IS_MAPPED_NAME.md"
|
|
179
|
+
else
|
|
180
|
+
: > "$dst/${f##*/}"
|
|
181
|
+
fi
|
|
128
182
|
done
|
|
129
183
|
is_fin_awk_vars
|
|
130
|
-
awk "${IS_FIN_V[@]}" -v dst="$dst" "$IS_AWK_LIB"'
|
|
184
|
+
IS_NAME_MAP="$map" awk "${IS_FIN_V[@]}" -v dst="$dst" "$IS_AWK_LIB"'
|
|
131
185
|
FNR == 1 {
|
|
132
186
|
if (out_file != "") close(out_file)
|
|
133
|
-
out_file = dst "/"
|
|
187
|
+
out_file = dst "/" art_file(FILENAME, ".md")
|
|
188
|
+
tgt_reset()
|
|
134
189
|
}
|
|
135
|
-
{ sub(/\r$/, "")
|
|
190
|
+
{ sub(/\r$/, "") }
|
|
191
|
+
FNR == 1 { cp_fm = ($0 == "---") }
|
|
192
|
+
FNR > 1 && cp_fm == 1 {
|
|
193
|
+
if ($0 == "---") cp_fm = 2
|
|
194
|
+
else if (tgt_drop($0)) next
|
|
195
|
+
}
|
|
196
|
+
{ print fin_line($0) > out_file }
|
|
136
197
|
' "$@"
|
|
137
198
|
}
|
|
138
199
|
|
|
200
|
+
# source_name_map_var <name-map> <path> — set IS_MAPPED_NAME to the name the
|
|
201
|
+
# map gives <path>; return 1 when it gives none. A map is "\n" followed by one
|
|
202
|
+
# `<path> US <name>` line per source.
|
|
203
|
+
source_name_map_var() {
|
|
204
|
+
local rest
|
|
205
|
+
IS_MAPPED_NAME=""
|
|
206
|
+
case "$1" in
|
|
207
|
+
*$'\n'"$2"$'\x1f'*) ;;
|
|
208
|
+
*) return 1 ;;
|
|
209
|
+
esac
|
|
210
|
+
rest="${1#*$'\n'"$2"$'\x1f'}"
|
|
211
|
+
IS_MAPPED_NAME="${rest%%$'\n'*}"
|
|
212
|
+
}
|
|
213
|
+
|
|
139
214
|
# frontmatter_index <keys-csv> <file>...
|
|
140
215
|
# One awk pass over many files; prints one row per file, in argument order:
|
|
141
216
|
# the path, then one value per requested key, all separated by \x1f (ASCII
|
|
@@ -394,6 +469,22 @@ emit_wrapped_bodies() {
|
|
|
394
469
|
' "${srcs[@]}"
|
|
395
470
|
}
|
|
396
471
|
|
|
472
|
+
# emit_rule_bodies <file>... — every rule's body without its frontmatter, in
|
|
473
|
+
# one awk, a blank line after each: how AGENTS.md inlines always-on rules, and
|
|
474
|
+
# how Codex's AGENTS.override.md adds the ones AGENTS.md does not carry.
|
|
475
|
+
emit_rule_bodies() {
|
|
476
|
+
[ "$#" -gt 0 ] || return 0
|
|
477
|
+
awk '
|
|
478
|
+
FNR == 1 { if (NR != 1) print ""; in_fm = 0; past_fm = 0 }
|
|
479
|
+
{ sub(/\r$/, "") }
|
|
480
|
+
/^---$/ {
|
|
481
|
+
if (!past_fm) { in_fm = !in_fm; if (!in_fm) { past_fm = 1 }; next }
|
|
482
|
+
}
|
|
483
|
+
past_fm || !in_fm { print }
|
|
484
|
+
END { print "" }
|
|
485
|
+
' "$@"
|
|
486
|
+
}
|
|
487
|
+
|
|
397
488
|
# Copy a markdown file with frontmatter, ensuring free-text string fields are
|
|
398
489
|
# wrapped in double quotes. Used by adapters that feed strict-YAML consumers
|
|
399
490
|
# (Codex CLI rejects unquoted colons / booleans). Idempotent — already-quoted
|
|
@@ -452,8 +543,11 @@ copy_md_with_quoted_frontmatter() {
|
|
|
452
543
|
# ship support files beside SKILL.md, and SKILL.md bodies point at them by
|
|
453
544
|
# relative path, so dropping them breaks the skill at runtime). Markdown is
|
|
454
545
|
# normalized to LF; every other file is copied byte-for-byte so potentially
|
|
455
|
-
# binary assets survive.
|
|
456
|
-
#
|
|
546
|
+
# binary assets survive. A symlink never reaches the output: the plan
|
|
547
|
+
# skill_source_inventory made turns a link inside its source into the regular
|
|
548
|
+
# file or directory it points at and leaves any other out, and the flush
|
|
549
|
+
# removes a link no plan covered (a copy from outside every skills source), so
|
|
550
|
+
# no host file outside the source is ever copied in (decision 0017).
|
|
457
551
|
#
|
|
458
552
|
# SKILL.md frontmatter is quoted for EVERY consumer, not only the strict-YAML
|
|
459
553
|
# ones. `argument-hint: [pr-number]` is a YAML *flow sequence*, so an unquoted
|
|
@@ -470,7 +564,7 @@ copy_md_with_quoted_frontmatter() {
|
|
|
470
564
|
# Usage: copy_skill_bundle "src/skill/dir" "dest/skill/dir"
|
|
471
565
|
copy_skill_bundle() {
|
|
472
566
|
_skill_bundles_reset
|
|
473
|
-
_skill_bundle_stage "$1" "$2"
|
|
567
|
+
_skill_bundle_stage "$1" "$2" || return 1
|
|
474
568
|
_skill_bundles_flush open
|
|
475
569
|
}
|
|
476
570
|
|
|
@@ -478,7 +572,8 @@ copy_skill_bundle() {
|
|
|
478
572
|
# copies every source skill directory into <dest_root>/<skill-name>, then one
|
|
479
573
|
# awk pass quotes and finalizes every bundled markdown file across all
|
|
480
574
|
# bundles. A later source with the same skill name overwrites file-by-file in
|
|
481
|
-
# order, exactly like the sequential per-bundle copies did.
|
|
575
|
+
# order, exactly like the sequential per-bundle copies did. A skill with links
|
|
576
|
+
# to resolve is copied from its materialized stage, in the same order.
|
|
482
577
|
copy_skill_bundle_dirs() {
|
|
483
578
|
copy_skill_bundle_dirs_for open "$@"
|
|
484
579
|
}
|
|
@@ -488,18 +583,59 @@ copy_skill_bundle_dirs() {
|
|
|
488
583
|
copy_skill_bundle_dirs_for() {
|
|
489
584
|
local effort_tool="$1" dest_root="$2"
|
|
490
585
|
shift 2
|
|
586
|
+
copy_skill_bundle_dirs_named "$effort_tool" "$dest_root" "" "$@"
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
# copy_skill_bundle_dirs_named <tool> <dest_root> <name-map> <src_dir>... — the
|
|
590
|
+
# batch form with each skill copied to <dest_root>/<name> when <name-map> holds
|
|
591
|
+
# its directory: the rendered names source_artifacts_var leaves in
|
|
592
|
+
# IS_SOURCE_NAME_MAP, so `deploy.local/` lands as `deploy/` and still matches
|
|
593
|
+
# its own `name: deploy`. Only a skill whose name differs from its directory
|
|
594
|
+
# costs a copy of its own.
|
|
595
|
+
copy_skill_bundle_dirs_named() {
|
|
596
|
+
local effort_tool="$1" dest_root="$2" map="$3"
|
|
597
|
+
shift 3
|
|
491
598
|
[ "$#" -gt 0 ] || return 0
|
|
492
|
-
local src dest
|
|
493
|
-
local -a srcs=()
|
|
599
|
+
local src from name dest i linked=0 renamed=0
|
|
600
|
+
local -a srcs=() names=()
|
|
494
601
|
for src in "$@"; do
|
|
495
|
-
|
|
602
|
+
src="${src%/}"
|
|
603
|
+
srcs+=("$src")
|
|
604
|
+
name="${src##*/}"
|
|
605
|
+
if source_name_map_var "$map" "$src" && [ "$IS_MAPPED_NAME" != "$name" ]; then
|
|
606
|
+
name="$IS_MAPPED_NAME"
|
|
607
|
+
renamed=1
|
|
608
|
+
fi
|
|
609
|
+
names+=("$name")
|
|
610
|
+
! _ski_planned "$src" || linked=1
|
|
496
611
|
done
|
|
497
612
|
mkdir -p "$dest_root"
|
|
498
|
-
|
|
613
|
+
if [ "$linked" = 0 ] && [ "$renamed" = 0 ]; then
|
|
614
|
+
cp -R "${srcs[@]}" "$dest_root/"
|
|
615
|
+
else
|
|
616
|
+
for ((i = 0; i < ${#srcs[@]}; i++)); do
|
|
617
|
+
src="${srcs[i]}"
|
|
618
|
+
from="$src"
|
|
619
|
+
if _ski_planned "$src"; then
|
|
620
|
+
_skill_bundle_materialize "$src" || return 1
|
|
621
|
+
from="$IS_SKI_STAGED"
|
|
622
|
+
fi
|
|
623
|
+
dest="$dest_root/${names[i]}"
|
|
624
|
+
if [ "${names[i]}" = "${src##*/}" ]; then
|
|
625
|
+
cp -R "$from" "$dest_root/" || return 1
|
|
626
|
+
elif [ -e "$dest" ]; then
|
|
627
|
+
# A second skill of one name merges file by file, as the
|
|
628
|
+
# batch copy of same-named directories does.
|
|
629
|
+
cp -R "$from/." "$dest/" || return 1
|
|
630
|
+
else
|
|
631
|
+
cp -R "$from" "$dest" || return 1
|
|
632
|
+
fi
|
|
633
|
+
[ "$from" = "$src" ] || rm -rf "${IS_SKI_STAGED%/*}"
|
|
634
|
+
done
|
|
635
|
+
fi
|
|
499
636
|
_skill_bundles_reset
|
|
500
|
-
for
|
|
501
|
-
|
|
502
|
-
_skill_bundle_note "$dest"
|
|
637
|
+
for name in "${names[@]}"; do
|
|
638
|
+
_skill_bundle_note "$dest_root/$name"
|
|
503
639
|
done
|
|
504
640
|
_skill_bundles_flush "$effort_tool"
|
|
505
641
|
}
|
|
@@ -513,24 +649,63 @@ _skill_bundles_reset() {
|
|
|
513
649
|
_skill_bundle_stage() {
|
|
514
650
|
local src="${1%/}" dest="$2"
|
|
515
651
|
mkdir -p "$dest"
|
|
516
|
-
|
|
652
|
+
if _ski_planned "$src"; then
|
|
653
|
+
_skill_bundle_materialize "$src" || return 1
|
|
654
|
+
cp -R "$IS_SKI_STAGED/." "$dest/" || return 1
|
|
655
|
+
rm -rf "${IS_SKI_STAGED%/*}"
|
|
656
|
+
else
|
|
657
|
+
cp -R "$src/." "$dest/"
|
|
658
|
+
fi
|
|
517
659
|
_skill_bundle_note "$dest"
|
|
518
660
|
}
|
|
519
661
|
|
|
662
|
+
# _skill_bundle_materialize <skill_dir> — copy a skill whose plan holds links
|
|
663
|
+
# into a fresh stage and apply the plan there, parents first: each planned link
|
|
664
|
+
# is replaced by a copy of the file or directory it resolves to inside its
|
|
665
|
+
# source, or removed. Sets IS_SKI_STAGED to <stage>/<name>; the caller copies
|
|
666
|
+
# it on and removes the stage. Spawns per link, never per file — a source
|
|
667
|
+
# without links never comes here.
|
|
668
|
+
_skill_bundle_materialize() {
|
|
669
|
+
local src="$1" key stage out rec rel act phys first=1
|
|
670
|
+
_ski_key_var "$src"
|
|
671
|
+
key="$_SKI_KEY"
|
|
672
|
+
stage="$(mktemp -d -t intelligence-skill-XXXXXX)" || return 1
|
|
673
|
+
out="$stage/${key##*/}"
|
|
674
|
+
while IFS= read -r rec; do
|
|
675
|
+
case "$rec" in
|
|
676
|
+
"$key$IS_SKI_US"*) ;;
|
|
677
|
+
*) continue ;;
|
|
678
|
+
esac
|
|
679
|
+
rec="${rec#"$key$IS_SKI_US"}"
|
|
680
|
+
rel="${rec%%"$IS_SKI_US"*}"
|
|
681
|
+
rec="${rec#*"$IS_SKI_US"}"
|
|
682
|
+
act="${rec%%"$IS_SKI_US"*}"
|
|
683
|
+
phys="${rec#*"$IS_SKI_US"}"
|
|
684
|
+
if [ "$first" = 1 ]; then
|
|
685
|
+
first=0
|
|
686
|
+
if [ -z "$rel" ]; then
|
|
687
|
+
# The skill directory itself is the link.
|
|
688
|
+
mkdir "$out" && cp -R "$phys/." "$out/" || return 1
|
|
689
|
+
continue
|
|
690
|
+
fi
|
|
691
|
+
cp -R "$src" "$stage/" || return 1
|
|
692
|
+
fi
|
|
693
|
+
# The copy holds the link itself here; `rm -f` never follows it and
|
|
694
|
+
# refuses a directory, so a plan that no longer matches stops the run.
|
|
695
|
+
rm -f "$out/$rel" || return 1
|
|
696
|
+
case "$act" in
|
|
697
|
+
f) cp "$phys" "$out/$rel" || return 1 ;;
|
|
698
|
+
d) mkdir "$out/$rel" && cp -R "$phys/." "$out/$rel/" || return 1 ;;
|
|
699
|
+
esac
|
|
700
|
+
done <<< "$IS_SKI_PLAN"
|
|
701
|
+
IS_SKI_STAGED="$out"
|
|
702
|
+
}
|
|
703
|
+
|
|
520
704
|
# Record a staged bundle for the flush pass: mark its SKILL.md for
|
|
521
705
|
# frontmatter quoting and deduplicate the destination.
|
|
522
706
|
_skill_bundle_note() {
|
|
523
707
|
local dest="$1"
|
|
524
|
-
|
|
525
|
-
# symlink. `[ -f ]` follows links, so quoting it would read the link's
|
|
526
|
-
# TARGET and write that content into a real file, turning
|
|
527
|
-
# `skills/x/SKILL.md -> /etc/…` into a copy of a host file inside the
|
|
528
|
-
# output. That is the leak the symlink-preserving copy exists to
|
|
529
|
-
# prevent, so skip the rewrite and say so (the same reason
|
|
530
|
-
# `find -type f` in the flush never matches a symlink).
|
|
531
|
-
if [ -L "$dest/SKILL.md" ]; then
|
|
532
|
-
echo " WARN: ${dest##*/}/SKILL.md is a symlink — emitted as-is (frontmatter not quoted, tokens not expanded)" >&2
|
|
533
|
-
elif [ -f "$dest/SKILL.md" ]; then
|
|
708
|
+
if [ -f "$dest/SKILL.md" ]; then
|
|
534
709
|
_SB_QUOTE_LIST="$_SB_QUOTE_LIST$dest/SKILL.md"$'\n'
|
|
535
710
|
fi
|
|
536
711
|
# The same skill name from a later source overwrites the earlier copy;
|
|
@@ -547,11 +722,25 @@ _skill_bundle_note() {
|
|
|
547
722
|
# _skill_bundles_flush <effort-tool>
|
|
548
723
|
_skill_bundles_flush() {
|
|
549
724
|
[ "${#_SB_DESTS[@]}" -gt 0 ] || return 0
|
|
550
|
-
local -a mds=()
|
|
551
|
-
local f quote_list="$_SB_QUOTE_LIST"
|
|
725
|
+
local -a mds=() links=()
|
|
726
|
+
local f listing quote_list="$_SB_QUOTE_LIST"
|
|
727
|
+
# Captured, not streamed: a listing cut short could leave a link behind.
|
|
728
|
+
listing="$(find "${_SB_DESTS[@]}" \( -type l -o -type f -name '*.md' \) -print)" || {
|
|
729
|
+
echo "ERROR: could not list the skill copies under ${_SB_DESTS[0]%/*}" >&2
|
|
730
|
+
return 1
|
|
731
|
+
}
|
|
552
732
|
while IFS= read -r f; do
|
|
553
|
-
[ -n "$f" ]
|
|
554
|
-
|
|
733
|
+
[ -n "$f" ] || continue
|
|
734
|
+
if [ -L "$f" ]; then links+=("$f"); else mds+=("$f"); fi
|
|
735
|
+
done <<< "$listing"
|
|
736
|
+
# A link only a copy from outside every skills source can bring: no plan
|
|
737
|
+
# resolved it, so it is not followed but removed.
|
|
738
|
+
if [ "${#links[@]}" -gt 0 ]; then
|
|
739
|
+
rm -f "${links[@]}" || return 1
|
|
740
|
+
for f in "${links[@]}"; do
|
|
741
|
+
echo "WARNING: ${f#"${REPO_ROOT:-}"/} was copied as a symlink from outside every skills source — removed; generated output never holds one" >&2
|
|
742
|
+
done
|
|
743
|
+
fi
|
|
555
744
|
_skill_bundles_reset
|
|
556
745
|
[ "${#mds[@]}" -gt 0 ] || return 0
|
|
557
746
|
effort_map_var "$1"
|
|
@@ -596,12 +785,14 @@ _skill_bundles_flush() {
|
|
|
596
785
|
out_file = FILENAME; line_n = 0
|
|
597
786
|
state = (FILENAME in QUOTE) ? "before" : ""
|
|
598
787
|
effort_seen = 0
|
|
788
|
+
tgt_reset()
|
|
599
789
|
}
|
|
600
790
|
{ sub(/\r$/, "") }
|
|
601
791
|
state == "before" {
|
|
602
792
|
if (FNR == 1 && $0 == "---") state = "in_fm"
|
|
603
793
|
else state = "after"
|
|
604
794
|
}
|
|
795
|
+
state == "in_fm" && FNR > 1 && $0 != "---" && tgt_drop($0) { next }
|
|
605
796
|
state == "in_fm" && FNR > 1 && substr($0, 1, 7) == "effort:" {
|
|
606
797
|
if (effort_seen++) next
|
|
607
798
|
val = fm_value_strip(substr($0, 8))
|
|
@@ -671,26 +862,27 @@ sync_open_skill_dirs() {
|
|
|
671
862
|
fi
|
|
672
863
|
mkdir -p "$output_dir"
|
|
673
864
|
|
|
674
|
-
local count=0 log=""
|
|
865
|
+
local count=0 log="" i rel
|
|
675
866
|
local -a skill_dirs=()
|
|
676
|
-
|
|
867
|
+
# Every tool that reads this directory reads all of it, so a skill lands
|
|
868
|
+
# here when it is for any of them (decision 0021). The engine names the
|
|
869
|
+
# enabled readers from their contracts.
|
|
870
|
+
normalize_path_var "$output_dir"
|
|
871
|
+
rel="${IS_NORM_PATH#"$repo_root"/}"
|
|
872
|
+
source_artifacts_var "$repo_root" "$config_file" skills \
|
|
873
|
+
"${IS_OPEN_SKILLS_READERS-antigravity codex opencode pi}" "$rel/{}"
|
|
677
874
|
# Guard on the count, then expand quoted: Bash 3.2 (macOS) errors on an
|
|
678
875
|
# empty array under `set -u`, and quoting keeps a path with a space or a
|
|
679
876
|
# glob character intact.
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
skill_dirs+=("$d/")
|
|
686
|
-
count=$((count + 1))
|
|
687
|
-
log+=" skill: $skill_name"$'\n'
|
|
688
|
-
done
|
|
689
|
-
fi
|
|
877
|
+
for ((i = 0; i < ${#IS_SOURCE_FILES[@]}; i++)); do
|
|
878
|
+
skill_dirs+=("${IS_SOURCE_FILES[i]%/SKILL.md}/")
|
|
879
|
+
count=$((count + 1))
|
|
880
|
+
log+=" skill: ${IS_SOURCE_NAMES[i]}"$'\n'
|
|
881
|
+
done
|
|
690
882
|
# copy_skill_bundle_dirs owns the frontmatter-quoting pass, so every
|
|
691
883
|
# target gets it — not just this open-standard dir.
|
|
692
884
|
if [ "$count" -gt 0 ]; then
|
|
693
|
-
|
|
885
|
+
copy_skill_bundle_dirs_named open "$output_dir" "$IS_SOURCE_NAME_MAP" "${skill_dirs[@]}"
|
|
694
886
|
open_skill_invocation_policies "$output_dir" || return 1
|
|
695
887
|
fi
|
|
696
888
|
|
|
@@ -711,19 +903,15 @@ sync_open_skill_dirs() {
|
|
|
711
903
|
# when the author's file sets none, and a file whose policy says otherwise, or
|
|
712
904
|
# that sync cannot read as saying it, refuses the render — an owner-only skill
|
|
713
905
|
# Codex can still select is the exact failure the field exists to prevent, so
|
|
714
|
-
# it never passes as a success.
|
|
906
|
+
# it never passes as a success. The tree holds no symlink — copy_skill_bundle_dirs
|
|
907
|
+
# renders a link inside its source as a regular file and leaves any other out —
|
|
908
|
+
# so a skill whose SKILL.md or agents/openai.yaml is a link in its source is
|
|
909
|
+
# read and enforced like any other.
|
|
715
910
|
open_skill_invocation_policies() {
|
|
716
911
|
local output_dir="$1" path dir flag rows refusals
|
|
717
912
|
local -a skill_mds=() policy_dirs=() policies=() authored=()
|
|
718
913
|
for path in "$output_dir"/*/SKILL.md; do
|
|
719
|
-
|
|
720
|
-
# A link is emitted as-is (see _skill_bundle_note): reading through it
|
|
721
|
-
# or writing beside it would reach outside the output tree.
|
|
722
|
-
if [ -L "$dir" ] || [ -L "$path" ]; then
|
|
723
|
-
echo " WARN: ${dir##*/}/SKILL.md is reached through a symlink — Codex gets no invocation policy derived for it" >&2
|
|
724
|
-
elif [ -f "$path" ]; then
|
|
725
|
-
skill_mds+=("$path")
|
|
726
|
-
fi
|
|
914
|
+
[ ! -f "$path" ] || skill_mds+=("$path")
|
|
727
915
|
done
|
|
728
916
|
[ "${#skill_mds[@]}" -gt 0 ] || return 0
|
|
729
917
|
# Captured, not streamed: a reader cut short must fail the render rather
|
|
@@ -736,10 +924,6 @@ open_skill_invocation_policies() {
|
|
|
736
924
|
[ "$flag" = "true" ] || continue
|
|
737
925
|
dir="${path%/SKILL.md}"
|
|
738
926
|
path="$dir/agents"
|
|
739
|
-
if [ -L "$path" ] || [ -L "$path/openai.yaml" ]; then
|
|
740
|
-
echo " WARN: ${dir##*/}/agents/openai.yaml is a symlink — left as-is, its invocation policy is not enforced" >&2
|
|
741
|
-
continue
|
|
742
|
-
fi
|
|
743
927
|
if [ -s "$path/openai.yaml" ]; then
|
|
744
928
|
authored+=("$path/openai.yaml")
|
|
745
929
|
continue
|
|
@@ -860,6 +1044,8 @@ enforce_authored_invocation_policies() {
|
|
|
860
1044
|
# Lint YAML frontmatter for common pitfalls (unquoted colons, leading tabs).
|
|
861
1045
|
# Print warnings to stderr; do not fail. Strict consumers (Codex CLI) reject
|
|
862
1046
|
# these files with cryptic messages — catching them in sync gives better DX.
|
|
1047
|
+
# Each finding is a `WARNING:` line: the author has to edit the source, so
|
|
1048
|
+
# `sync --compact` (the first sync `init` runs) must keep it.
|
|
863
1049
|
# Batched: one awk process lints every file passed.
|
|
864
1050
|
# The engine lints every source once, so this is also where an `effort:` off
|
|
865
1051
|
# IS_EFFORT_LEVELS is reported — read with frontmatter_index's semantics,
|
|
@@ -870,7 +1056,13 @@ enforce_authored_invocation_policies() {
|
|
|
870
1056
|
# Usage: lint_frontmatter_files "a.md" "b.md" ...
|
|
871
1057
|
lint_frontmatter_files() {
|
|
872
1058
|
[ "$#" -gt 0 ] || return 0
|
|
873
|
-
awk -v levels="$IS_EFFORT_LEVELS" "$IS_AWK_LIB"
|
|
1059
|
+
awk -v levels="$IS_EFFORT_LEVELS" "$IS_AWK_LIB$_IS_AWK_LINT" "$@"
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
# The lint rules, shared with source_frontmatter_scan, which runs them in the
|
|
1063
|
+
# same pass that reads routing fields. Every rule may end with `next`, so a
|
|
1064
|
+
# program that joins them puts its own rules first.
|
|
1065
|
+
_IS_AWK_LINT='
|
|
874
1066
|
BEGIN {
|
|
875
1067
|
nl = split(levels, LV, " ")
|
|
876
1068
|
allowed = ""
|
|
@@ -892,7 +1084,7 @@ lint_frontmatter_files() {
|
|
|
892
1084
|
}
|
|
893
1085
|
}
|
|
894
1086
|
in_fm && /^\t/ {
|
|
895
|
-
printf "
|
|
1087
|
+
printf "WARNING: %s:%d leading tab in frontmatter (use spaces)\n", FILENAME, FNR > "/dev/stderr"
|
|
896
1088
|
}
|
|
897
1089
|
in_fm && /^[a-zA-Z0-9_-]+:[[:space:]]+[^"\047|>[{]/ {
|
|
898
1090
|
value_start = index($0, ":") + 1
|
|
@@ -900,10 +1092,10 @@ lint_frontmatter_files() {
|
|
|
900
1092
|
sub(/^[[:space:]]+/, "", value)
|
|
901
1093
|
if (value ~ /:[[:space:]]/ || value ~ /:$/) {
|
|
902
1094
|
col = index(value, ":") + value_start
|
|
903
|
-
printf "
|
|
1095
|
+
printf "WARNING: %s:%d unquoted colon in value at column %d — wrap value in quotes\n", FILENAME, FNR, col > "/dev/stderr"
|
|
904
1096
|
}
|
|
905
1097
|
if (value ~ /"/) {
|
|
906
|
-
printf "
|
|
1098
|
+
printf "WARNING: %s:%d literal double quote in unquoted value — wrap value in single quotes or escape as \\\" so strict-YAML targets accept it\n", FILENAME, FNR > "/dev/stderr"
|
|
907
1099
|
}
|
|
908
1100
|
}
|
|
909
1101
|
# Field-length limits. Both Claude Code and the Agent Skills standard
|
|
@@ -922,11 +1114,10 @@ lint_frontmatter_files() {
|
|
|
922
1114
|
}
|
|
923
1115
|
limit = (key == "name") ? 64 : 1024
|
|
924
1116
|
if (length(val) > limit) {
|
|
925
|
-
printf "
|
|
1117
|
+
printf "WARNING: %s:%d %s is %d chars — over the %d-char limit; the skill/agent will be REJECTED at load time\n", FILENAME, FNR, key, length(val), limit > "/dev/stderr"
|
|
926
1118
|
}
|
|
927
1119
|
}
|
|
928
|
-
|
|
929
|
-
}
|
|
1120
|
+
'
|
|
930
1121
|
|
|
931
1122
|
# lint_frontmatter <file> — single-file form for project adapters.
|
|
932
1123
|
lint_frontmatter() {
|
|
@@ -1561,7 +1752,8 @@ warn_unsynced() {
|
|
|
1561
1752
|
load_yaml_list "$config_file" "$section"
|
|
1562
1753
|
while IFS= read -r src; do
|
|
1563
1754
|
[ -z "$src" ] && continue
|
|
1564
|
-
|
|
1755
|
+
normalize_path_var "$repo_root/$src"
|
|
1756
|
+
all_sources+=("${IS_NORM_PATH#"$repo_root/"}")
|
|
1565
1757
|
done <<< "$IS_YAML_LIST"
|
|
1566
1758
|
done
|
|
1567
1759
|
|
|
@@ -1655,11 +1847,7 @@ warn_unsynced() {
|
|
|
1655
1847
|
done
|
|
1656
1848
|
|
|
1657
1849
|
if [ "$matched" = false ]; then
|
|
1658
|
-
|
|
1659
|
-
echo ""
|
|
1660
|
-
echo "=== WARNING: Unsynced directories ==="
|
|
1661
|
-
fi
|
|
1662
|
-
echo " NOT SYNCED: $rel_dir"
|
|
1850
|
+
echo "WARNING: NOT SYNCED: $rel_dir" >&2
|
|
1663
1851
|
warnings=$((warnings + 1))
|
|
1664
1852
|
fi
|
|
1665
1853
|
done < <(find "$repo_root" \( -name ".git" -o -name "node_modules" -o -name "vendor" -o -name "dist" -o -name ".claude" -o -name ".cursor" -o -name ".github" -o -name ".codex" -o -name ".agents" -o -name ".intelligence" \) -prune -o -type d \( -name "rules" -o -name "agents" -o -name "skills" -o -name "Rules" -o -name "Agents" -o -name "Skills" \) -print 2>/dev/null)
|
|
@@ -1670,10 +1858,367 @@ warn_unsynced() {
|
|
|
1670
1858
|
# and hand-placing an entry in an ordered list decides which artifact
|
|
1671
1859
|
# wins. The engine may also run without the CLI around it.
|
|
1672
1860
|
if [ "${IS_CLI:-0}" = "1" ]; then
|
|
1673
|
-
echo "
|
|
1861
|
+
echo "WARNING: Wire one in: intelligence source add <rules|agents|skills> <path>" >&2
|
|
1674
1862
|
else
|
|
1675
|
-
echo "
|
|
1863
|
+
echo "WARNING: Add these paths to sources: in ${config_file##*/}" >&2
|
|
1864
|
+
fi
|
|
1865
|
+
fi
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
# --- Skill sources and the symlinks inside them (decision 0017) ---
|
|
1869
|
+
#
|
|
1870
|
+
# A symlink in a skills source renders as the regular file or directory it
|
|
1871
|
+
# resolves to while that stays inside the same source, and is left out with a
|
|
1872
|
+
# WARNING: line when it resolves outside it, nowhere, or to a directory that
|
|
1873
|
+
# encloses it. Generated output never holds a link, and nothing is ever copied
|
|
1874
|
+
# from outside the source (decision 0006). Every consumer takes that decision
|
|
1875
|
+
# from here, once per run: the skill list read_source_artifact_files returns,
|
|
1876
|
+
# which the source catalog reads, the membership skill_source_rendered
|
|
1877
|
+
# answers, and the plan copy_skill_bundle_dirs applies to its copy. A skill
|
|
1878
|
+
# rendered from a link is an ordinary skill from then on — quoting, token
|
|
1879
|
+
# expansion, the effort rendering and the Codex invocation policy all run on
|
|
1880
|
+
# the output copy.
|
|
1881
|
+
#
|
|
1882
|
+
# skill_source_inventory <repo_root> <config_file> fills, in the caller's
|
|
1883
|
+
# shell and fail-closed like read_source_artifact_files:
|
|
1884
|
+
# IS_SKI_LIST <source>/<skill>/SKILL.md of every rendered skill, one a line,
|
|
1885
|
+
# sources in manifest order, names in byte order;
|
|
1886
|
+
# IS_SKI_KEYS "\n<source>/<skill>\n…", the same skills as a membership set;
|
|
1887
|
+
# IS_SKI_PLAN "\n" + one record a line for every link in a rendered skill,
|
|
1888
|
+
# parents first: <source>/<skill> US <path in the skill copy>
|
|
1889
|
+
# US f|d|x US <physical target>. f and d materialize the file or
|
|
1890
|
+
# directory at that path, x leaves the link out; an empty path
|
|
1891
|
+
# is the skill directory itself.
|
|
1892
|
+
# The resolution spawns processes only for a source that holds a link, and
|
|
1893
|
+
# then per source and per link-chain hop, never per file.
|
|
1894
|
+
IS_SKI_US=$'\x1f'
|
|
1895
|
+
|
|
1896
|
+
skill_source_inventory() {
|
|
1897
|
+
local repo_root="$1" config_file="$2" memo_key src dir listing
|
|
1898
|
+
memo_key="$repo_root"$'\n'"$config_file"
|
|
1899
|
+
if [ "${IS_SOURCE_FILES_MEMO:-0}" = 1 ] && [ "${IS_SKI_KEY:-}" = "$memo_key" ]; then
|
|
1900
|
+
return 0
|
|
1901
|
+
fi
|
|
1902
|
+
IS_SKI_KEY=""
|
|
1903
|
+
IS_SKI_LIST=""
|
|
1904
|
+
IS_SKI_KEYS=$'\n'
|
|
1905
|
+
IS_SKI_PLAN=$'\n'
|
|
1906
|
+
load_yaml_list "$config_file" "skills"
|
|
1907
|
+
while IFS= read -r src; do
|
|
1908
|
+
[ -n "$src" ] || continue
|
|
1909
|
+
dir="$repo_root/$src"
|
|
1910
|
+
while [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done
|
|
1911
|
+
[ -d "$dir" ] || continue
|
|
1912
|
+
# Every directory and link at any depth: the top level holds the
|
|
1913
|
+
# skills, and a link anywhere below one is part of its bundle. -H
|
|
1914
|
+
# lists a source that is itself a link through it, as a glob does.
|
|
1915
|
+
# Command substitution, so find or sort failing stops the run here
|
|
1916
|
+
# instead of yielding a short list.
|
|
1917
|
+
listing="$(find -H "$dir" -mindepth 1 \( -type d -o -type l \) -print | LC_ALL=C sort)" || {
|
|
1918
|
+
echo "ERROR: cannot enumerate skills under '$dir' — refusing to render a partial list." >&2
|
|
1919
|
+
exit 1
|
|
1920
|
+
}
|
|
1921
|
+
[ -n "$listing" ] || continue
|
|
1922
|
+
_ski_source "$repo_root" "$dir" "$listing"
|
|
1923
|
+
done <<< "$IS_YAML_LIST"
|
|
1924
|
+
[ "${IS_SOURCE_FILES_MEMO:-0}" != 1 ] || IS_SKI_KEY="$memo_key"
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
# skill_source_rendered <skill_dir> — the inventory renders this skill. No
|
|
1928
|
+
# built-in adapter calls it: they read skills through source_artifacts_var
|
|
1929
|
+
# (decision 0021). It stays for a project adapter written against earlier
|
|
1930
|
+
# releases that still lists a skills source with its own glob — that adapter
|
|
1931
|
+
# keeps skipping skills sync leaves out, but not local or targeted ones.
|
|
1932
|
+
skill_source_rendered() {
|
|
1933
|
+
_ski_key_var "$1"
|
|
1934
|
+
case "${IS_SKI_KEYS:-}" in
|
|
1935
|
+
*$'\n'"$_SKI_KEY"$'\n'*) return 0 ;;
|
|
1936
|
+
esac
|
|
1937
|
+
return 1
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
# _ski_key_var <skill_dir> — the key a skill is listed and planned under, its
|
|
1941
|
+
# source and name joined by one slash however the caller spelled a trailing
|
|
1942
|
+
# slash on either.
|
|
1943
|
+
_ski_key_var() {
|
|
1944
|
+
local path="$1" parent
|
|
1945
|
+
while [ "${path%/}" != "$path" ]; do path="${path%/}"; done
|
|
1946
|
+
parent="${path%/*}"
|
|
1947
|
+
while [ -n "$parent" ] && [ "${parent%/}" != "$parent" ]; do parent="${parent%/}"; done
|
|
1948
|
+
_SKI_KEY="$parent/${path##*/}"
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
# _ski_planned <skill_dir> — the skill has links the copy has to resolve.
|
|
1952
|
+
_ski_planned() {
|
|
1953
|
+
_ski_key_var "$1"
|
|
1954
|
+
case "${IS_SKI_PLAN:-}" in
|
|
1955
|
+
*$'\n'"$_SKI_KEY$IS_SKI_US"*) return 0 ;;
|
|
1956
|
+
esac
|
|
1957
|
+
return 1
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
# _ski_source <repo_root> <source_dir> <listing> — inventory one skills source.
|
|
1961
|
+
_ski_source() {
|
|
1962
|
+
local repo_root="$1" f rel
|
|
1963
|
+
local -a tops=() links=()
|
|
1964
|
+
_SKI_DIR="$2"
|
|
1965
|
+
_SKI_ROOT_REL="${2#"$repo_root"/}"
|
|
1966
|
+
_SKI_REPO="$repo_root"
|
|
1967
|
+
while IFS= read -r f; do
|
|
1968
|
+
[ -n "$f" ] || continue
|
|
1969
|
+
rel="${f#"$_SKI_DIR"/}"
|
|
1970
|
+
case "$rel" in
|
|
1971
|
+
*/*) [ ! -L "$f" ] || links+=("$f") ;;
|
|
1972
|
+
*)
|
|
1973
|
+
tops+=("$f")
|
|
1974
|
+
[ ! -L "$f" ] || links+=("$f")
|
|
1975
|
+
;;
|
|
1976
|
+
esac
|
|
1977
|
+
done <<< "$3"
|
|
1978
|
+
_SKI_LREL=(); _SKI_LSTAT=(); _SKI_LKIND=(); _SKI_LTREL=(); _SKI_LPHYS=()
|
|
1979
|
+
[ "${#links[@]}" -eq 0 ] || _ski_classify_links "${links[@]}"
|
|
1980
|
+
for f in "${tops[@]+"${tops[@]}"}"; do
|
|
1981
|
+
_ski_skill "${f##*/}"
|
|
1982
|
+
done
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
# _ski_classify_links <link>... — resolve every link of the current source and
|
|
1986
|
+
# record where it leads: ok (inside the source), outside, dangling, or other
|
|
1987
|
+
# (neither a file nor a directory). A link that cannot be resolved stops the
|
|
1988
|
+
# run, like any enumeration that cannot answer.
|
|
1989
|
+
_ski_classify_links() {
|
|
1990
|
+
local out root_phys rec kind phys i=0 link
|
|
1991
|
+
local -a recs=()
|
|
1992
|
+
out="$(_ski_physical "$_SKI_DIR" "$@")" || {
|
|
1993
|
+
echo "ERROR: cannot resolve the symlinks under '$_SKI_DIR' — refusing to render a partial list." >&2
|
|
1994
|
+
exit 1
|
|
1995
|
+
}
|
|
1996
|
+
while IFS= read -r rec; do recs+=("$rec"); done <<< "$out"
|
|
1997
|
+
if [ "${#recs[@]}" -ne $(($# + 1)) ]; then
|
|
1998
|
+
echo "ERROR: cannot resolve the symlinks under '$_SKI_DIR' — refusing to render a partial list." >&2
|
|
1999
|
+
exit 1
|
|
2000
|
+
fi
|
|
2001
|
+
root_phys="${recs[0]%/}"
|
|
2002
|
+
for link in "$@"; do
|
|
2003
|
+
rec="${recs[i + 1]}"
|
|
2004
|
+
kind="${rec%%"$IS_SKI_US"*}"
|
|
2005
|
+
phys="${rec#*"$IS_SKI_US"}"
|
|
2006
|
+
_SKI_LREL[i]="${link#"$_SKI_DIR"/}"
|
|
2007
|
+
_SKI_LKIND[i]="$kind"
|
|
2008
|
+
_SKI_LPHYS[i]="$phys"
|
|
2009
|
+
_SKI_LTREL[i]=""
|
|
2010
|
+
case "$kind" in
|
|
2011
|
+
d|f)
|
|
2012
|
+
case "$phys" in
|
|
2013
|
+
"$root_phys"/*) _SKI_LSTAT[i]=ok; _SKI_LTREL[i]="${phys#"$root_phys"/}" ;;
|
|
2014
|
+
"${root_phys:-/}") _SKI_LSTAT[i]=ok ;;
|
|
2015
|
+
*) _SKI_LSTAT[i]=outside ;;
|
|
2016
|
+
esac
|
|
2017
|
+
;;
|
|
2018
|
+
o) _SKI_LSTAT[i]=other ;;
|
|
2019
|
+
*) _SKI_LSTAT[i]=dangling ;;
|
|
2020
|
+
esac
|
|
2021
|
+
i=$((i + 1))
|
|
2022
|
+
done
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
# _ski_physical <source_dir> <link>... — print the source's physical path,
|
|
2026
|
+
# then one "<kind> US <physical target>" line per link: d (directory), f
|
|
2027
|
+
# (regular file), o (anything else) or x (dangling, or a link loop). Runs in a
|
|
2028
|
+
# subshell so it can change directory: `cd -P` resolves a directory, and a
|
|
2029
|
+
# file's chain is read with one readlink per hop for all links at once (no
|
|
2030
|
+
# readlink -f on macOS).
|
|
2031
|
+
_ski_physical() (
|
|
2032
|
+
local dir="$1" link target targets i k n hops=0 parent
|
|
2033
|
+
local -a kind=() cur=() phys=() pend=() next=() paths=()
|
|
2034
|
+
shift
|
|
2035
|
+
n=$#
|
|
2036
|
+
cd -P -- "$dir" 2>/dev/null || exit 1
|
|
2037
|
+
printf '%s\n' "$PWD"
|
|
2038
|
+
i=0
|
|
2039
|
+
for link in "$@"; do
|
|
2040
|
+
kind[i]=x; phys[i]=""; cur[i]="$link"
|
|
2041
|
+
if [ -d "$link" ]; then
|
|
2042
|
+
cd -P -- "$link" 2>/dev/null || exit 1
|
|
2043
|
+
kind[i]=d; phys[i]="$PWD"
|
|
2044
|
+
elif [ -f "$link" ]; then
|
|
2045
|
+
kind[i]=f; pend+=("$i")
|
|
2046
|
+
elif [ -e "$link" ]; then
|
|
2047
|
+
kind[i]=o
|
|
2048
|
+
fi
|
|
2049
|
+
i=$((i + 1))
|
|
2050
|
+
done
|
|
2051
|
+
while [ "${#pend[@]}" -gt 0 ]; do
|
|
2052
|
+
hops=$((hops + 1))
|
|
2053
|
+
[ "$hops" -le 40 ] || exit 1
|
|
2054
|
+
paths=()
|
|
2055
|
+
for i in "${pend[@]}"; do paths+=("${cur[i]}"); done
|
|
2056
|
+
targets="$(readlink "${paths[@]}")" || exit 1
|
|
2057
|
+
next=(); k=0
|
|
2058
|
+
while IFS= read -r target; do
|
|
2059
|
+
# More lines than links: a target held a newline.
|
|
2060
|
+
[ "$k" -lt "${#pend[@]}" ] || exit 1
|
|
2061
|
+
i="${pend[k]}"
|
|
2062
|
+
case "$target" in
|
|
2063
|
+
/*) cur[i]="$target" ;;
|
|
2064
|
+
*) cur[i]="${cur[i]%/*}/$target" ;;
|
|
2065
|
+
esac
|
|
2066
|
+
[ ! -L "${cur[i]}" ] || next+=("$i")
|
|
2067
|
+
k=$((k + 1))
|
|
2068
|
+
done <<< "$targets"
|
|
2069
|
+
[ "$k" -eq "${#pend[@]}" ] || exit 1
|
|
2070
|
+
pend=("${next[@]+"${next[@]}"}")
|
|
2071
|
+
done
|
|
2072
|
+
for ((i = 0; i < n; i++)); do
|
|
2073
|
+
if [ "${kind[i]}" = f ]; then
|
|
2074
|
+
parent="${cur[i]%/*}"
|
|
2075
|
+
cd -P -- "${parent:-/}" 2>/dev/null || exit 1
|
|
2076
|
+
phys[i]="${PWD%/}/${cur[i]##*/}"
|
|
1676
2077
|
fi
|
|
2078
|
+
printf '%s%s%s\n' "${kind[i]}" "$IS_SKI_US" "${phys[i]}"
|
|
2079
|
+
done
|
|
2080
|
+
)
|
|
2081
|
+
|
|
2082
|
+
# _ski_link_var <path in source> — set _SKI_K to the index of the link at that
|
|
2083
|
+
# path, or -1.
|
|
2084
|
+
_ski_link_var() {
|
|
2085
|
+
local k=0
|
|
2086
|
+
_SKI_K=-1
|
|
2087
|
+
while [ "$k" -lt "${#_SKI_LREL[@]}" ]; do
|
|
2088
|
+
if [ "${_SKI_LREL[k]}" = "$1" ]; then
|
|
2089
|
+
_SKI_K="$k"
|
|
2090
|
+
return 0
|
|
2091
|
+
fi
|
|
2092
|
+
k=$((k + 1))
|
|
2093
|
+
done
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
# _ski_loops <target> <open> — materializing the directory at <target> would
|
|
2097
|
+
# copy one of the <open> paths (newline-separated) being materialized, the
|
|
2098
|
+
# link itself included, so it would never end.
|
|
2099
|
+
_ski_loops() {
|
|
2100
|
+
local target="$1" open
|
|
2101
|
+
[ -n "$target" ] || return 0
|
|
2102
|
+
while IFS= read -r open; do
|
|
2103
|
+
case "$open" in "$target"|"$target"/*) return 0 ;; esac
|
|
2104
|
+
done <<< "$2"
|
|
2105
|
+
return 1
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
# _ski_skill <name> — decide one top-level entry of the current source: a
|
|
2109
|
+
# rendered skill (with its plan and warnings), a skill left out, or no skill.
|
|
2110
|
+
_ski_skill() {
|
|
2111
|
+
local name="$1" key content open="" state rel link reason nl=$'\n'
|
|
2112
|
+
key="$_SKI_DIR/$name"
|
|
2113
|
+
_SKI_ENTRIES="$nl"
|
|
2114
|
+
_SKI_DROPS=""
|
|
2115
|
+
_ski_link_var "$name"
|
|
2116
|
+
if [ "$_SKI_K" -ge 0 ]; then
|
|
2117
|
+
# The skill directory is itself a link. A test through one that
|
|
2118
|
+
# leaves the source only stats it; nothing is read from outside.
|
|
2119
|
+
case "${_SKI_LSTAT[_SKI_K]}:${_SKI_LKIND[_SKI_K]}" in
|
|
2120
|
+
ok:d) ;;
|
|
2121
|
+
outside:d)
|
|
2122
|
+
_ski_warn "$name" "" "$name" outside
|
|
2123
|
+
return 0
|
|
2124
|
+
;;
|
|
2125
|
+
dangling:*)
|
|
2126
|
+
_ski_warn "$name" "" "$name" dangling
|
|
2127
|
+
return 0
|
|
2128
|
+
;;
|
|
2129
|
+
*) return 0 ;;
|
|
2130
|
+
esac
|
|
2131
|
+
content="${_SKI_LTREL[_SKI_K]}"
|
|
2132
|
+
if _ski_loops "$content" ""; then
|
|
2133
|
+
[ ! -f "$_SKI_DIR/$name/SKILL.md" ] || _ski_warn "$name" "" "$name" loop
|
|
2134
|
+
return 0
|
|
2135
|
+
fi
|
|
2136
|
+
_SKI_ENTRIES="$_SKI_ENTRIES$key$IS_SKI_US${IS_SKI_US}d$IS_SKI_US${_SKI_LPHYS[_SKI_K]}$nl"
|
|
2137
|
+
open="$nl"
|
|
2138
|
+
else
|
|
2139
|
+
content="$name"
|
|
2140
|
+
fi
|
|
2141
|
+
_ski_expand "$key" "$content" "" "$open"
|
|
2142
|
+
# A skill is a directory whose SKILL.md renders as a regular file.
|
|
2143
|
+
case "$_SKI_ENTRIES" in
|
|
2144
|
+
*"$nl$key${IS_SKI_US}SKILL.md${IS_SKI_US}f$IS_SKI_US"*) state=ok ;;
|
|
2145
|
+
*"$nl$key${IS_SKI_US}SKILL.md${IS_SKI_US}x$IS_SKI_US"*) state=dropped ;;
|
|
2146
|
+
*"$nl$key${IS_SKI_US}SKILL.md$IS_SKI_US"*) state=none ;;
|
|
2147
|
+
*)
|
|
2148
|
+
state=none
|
|
2149
|
+
[ ! -f "$_SKI_DIR/$content/SKILL.md" ] || state=ok
|
|
2150
|
+
;;
|
|
2151
|
+
esac
|
|
2152
|
+
case "$state" in
|
|
2153
|
+
none) return 0 ;;
|
|
2154
|
+
dropped)
|
|
2155
|
+
while IFS="$IS_SKI_US" read -r rel link reason; do
|
|
2156
|
+
[ "$rel" != "SKILL.md" ] || _ski_warn "$name" "" "$link" "$reason"
|
|
2157
|
+
done <<< "$_SKI_DROPS"
|
|
2158
|
+
return 0
|
|
2159
|
+
;;
|
|
2160
|
+
esac
|
|
2161
|
+
IS_SKI_LIST="$IS_SKI_LIST$_SKI_DIR/$name/SKILL.md$nl"
|
|
2162
|
+
IS_SKI_KEYS="$IS_SKI_KEYS$key$nl"
|
|
2163
|
+
IS_SKI_PLAN="$IS_SKI_PLAN${_SKI_ENTRIES#"$nl"}"
|
|
2164
|
+
while IFS="$IS_SKI_US" read -r rel link reason; do
|
|
2165
|
+
[ -z "$rel" ] || _ski_warn "$name" "$rel" "$link" "$reason"
|
|
2166
|
+
done <<< "$_SKI_DROPS"
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
# _ski_expand <key> <content> <prefix> <open> — plan every link inside the
|
|
2170
|
+
# source directory <content> as it lands at <prefix> in the skill copy,
|
|
2171
|
+
# recursing into each directory a link materializes. <open> holds the
|
|
2172
|
+
# locations of the links already being materialized, for _ski_loops.
|
|
2173
|
+
_ski_expand() {
|
|
2174
|
+
local key="$1" content="$2" prefix="$3" open="$4" k=0 rel out location
|
|
2175
|
+
while [ "$k" -lt "${#_SKI_LREL[@]}" ]; do
|
|
2176
|
+
rel="${_SKI_LREL[k]}"
|
|
2177
|
+
case "$rel" in
|
|
2178
|
+
"$content"/*) ;;
|
|
2179
|
+
*) k=$((k + 1)); continue ;;
|
|
2180
|
+
esac
|
|
2181
|
+
out="${prefix:+$prefix/}${rel#"$content"/}"
|
|
2182
|
+
case "${_SKI_LSTAT[k]}:${_SKI_LKIND[k]}" in
|
|
2183
|
+
ok:f)
|
|
2184
|
+
_SKI_ENTRIES="$_SKI_ENTRIES$key$IS_SKI_US$out${IS_SKI_US}f$IS_SKI_US${_SKI_LPHYS[k]}"$'\n'
|
|
2185
|
+
;;
|
|
2186
|
+
ok:d)
|
|
2187
|
+
location="${rel%/*}"
|
|
2188
|
+
if _ski_loops "${_SKI_LTREL[k]}" "$open$location"; then
|
|
2189
|
+
_ski_drop "$key" "$out" "$rel" loop
|
|
2190
|
+
else
|
|
2191
|
+
_SKI_ENTRIES="$_SKI_ENTRIES$key$IS_SKI_US$out${IS_SKI_US}d$IS_SKI_US${_SKI_LPHYS[k]}"$'\n'
|
|
2192
|
+
_ski_expand "$key" "${_SKI_LTREL[k]}" "$out" "$open$location"$'\n'
|
|
2193
|
+
fi
|
|
2194
|
+
;;
|
|
2195
|
+
*) _ski_drop "$key" "$out" "$rel" "${_SKI_LSTAT[k]}" ;;
|
|
2196
|
+
esac
|
|
2197
|
+
k=$((k + 1))
|
|
2198
|
+
done
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
# _ski_drop <key> <path in copy> <path in source> <reason> — leave a link out.
|
|
2202
|
+
_ski_drop() {
|
|
2203
|
+
_SKI_ENTRIES="$_SKI_ENTRIES$1$IS_SKI_US$2${IS_SKI_US}x$IS_SKI_US"$'\n'
|
|
2204
|
+
_SKI_DROPS="$_SKI_DROPS$2$IS_SKI_US$3$IS_SKI_US$4"$'\n'
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
# _ski_warn <skill> <path in copy> <path in source> <reason> — one WARNING:
|
|
2208
|
+
# line naming the skill and the link by its own path. An empty <path in copy>
|
|
2209
|
+
# means the whole skill is left out.
|
|
2210
|
+
_ski_warn() {
|
|
2211
|
+
local link="$_SKI_ROOT_REL/$3" what
|
|
2212
|
+
case "$4" in
|
|
2213
|
+
outside) what="is a symlink that resolves outside $_SKI_ROOT_REL" ;;
|
|
2214
|
+
dangling) what="is a dangling symlink" ;;
|
|
2215
|
+
loop) what="is a symlink to a directory that encloses it" ;;
|
|
2216
|
+
*) what="is a symlink to neither a file nor a directory" ;;
|
|
2217
|
+
esac
|
|
2218
|
+
if [ -n "$2" ]; then
|
|
2219
|
+
echo "WARNING: skill '$1' renders without $2: $link $what" >&2
|
|
2220
|
+
else
|
|
2221
|
+
echo "WARNING: skill '$1' is left out of every output: $link $what" >&2
|
|
1677
2222
|
fi
|
|
1678
2223
|
}
|
|
1679
2224
|
|
|
@@ -1696,6 +2241,9 @@ warn_unsynced() {
|
|
|
1696
2241
|
# replay that answer. Only the engine sets it: a sync never writes into a
|
|
1697
2242
|
# source (validate_output_path refuses), so each caller would otherwise re-run
|
|
1698
2243
|
# the same find and sort for every source directory.
|
|
2244
|
+
#
|
|
2245
|
+
# Skills come from skill_source_inventory, which also decides what a symlink
|
|
2246
|
+
# inside a skills source renders as.
|
|
1699
2247
|
read_source_artifact_files() {
|
|
1700
2248
|
local repo_root="$1"
|
|
1701
2249
|
local config_file="$2"
|
|
@@ -1703,8 +2251,15 @@ read_source_artifact_files() {
|
|
|
1703
2251
|
local src f dir listing memo_key="" key_var="" list_var="" joined=""
|
|
1704
2252
|
|
|
1705
2253
|
IS_SOURCE_FILES=()
|
|
2254
|
+
if [ "$section" = "skills" ]; then
|
|
2255
|
+
skill_source_inventory "$repo_root" "$config_file"
|
|
2256
|
+
while IFS= read -r f; do
|
|
2257
|
+
[ -n "$f" ] && IS_SOURCE_FILES+=("$f")
|
|
2258
|
+
done <<< "$IS_SKI_LIST"
|
|
2259
|
+
return 0
|
|
2260
|
+
fi
|
|
1706
2261
|
case "${IS_SOURCE_FILES_MEMO:-0}:$section" in
|
|
1707
|
-
1:rules|1:agents
|
|
2262
|
+
1:rules|1:agents)
|
|
1708
2263
|
key_var="IS_SF_${section}_KEY"
|
|
1709
2264
|
list_var="IS_SF_${section}_LIST"
|
|
1710
2265
|
memo_key="$repo_root"$'\n'"$config_file"
|
|
@@ -1724,15 +2279,11 @@ read_source_artifact_files() {
|
|
|
1724
2279
|
[ -d "$dir" ] || continue
|
|
1725
2280
|
# Command substitution, so the pipeline's status is the assignment's:
|
|
1726
2281
|
# `find` or `sort` failing aborts here instead of yielding a short list.
|
|
2282
|
+
# A link to a regular file is one too, read where it points, as the
|
|
2283
|
+
# adapters' globs always read it; a dangling one is no artifact.
|
|
1727
2284
|
case "$section" in
|
|
1728
2285
|
rules|agents)
|
|
1729
|
-
listing="$(find "$dir" -maxdepth 1 -
|
|
1730
|
-
echo "ERROR: cannot enumerate $section under '$dir' — refusing to render a partial list." >&2
|
|
1731
|
-
exit 1
|
|
1732
|
-
}
|
|
1733
|
-
;;
|
|
1734
|
-
skills)
|
|
1735
|
-
listing="$(find "$dir" -mindepth 1 -maxdepth 1 -type d -print | LC_ALL=C sort)" || {
|
|
2286
|
+
listing="$(find "$dir" -maxdepth 1 -name '*.md' \( -type f -o -type l \) -print | LC_ALL=C sort)" || {
|
|
1736
2287
|
echo "ERROR: cannot enumerate $section under '$dir' — refusing to render a partial list." >&2
|
|
1737
2288
|
exit 1
|
|
1738
2289
|
}
|
|
@@ -1743,11 +2294,7 @@ read_source_artifact_files() {
|
|
|
1743
2294
|
# A here-string redirect keeps this loop in the current shell, so the
|
|
1744
2295
|
# array it fills survives and any failure inside it is this shell's.
|
|
1745
2296
|
while IFS= read -r f; do
|
|
1746
|
-
[ -
|
|
1747
|
-
if [ "$section" = "skills" ]; then
|
|
1748
|
-
[ -f "$f/SKILL.md" ] || continue
|
|
1749
|
-
f="$f/SKILL.md"
|
|
1750
|
-
fi
|
|
2297
|
+
[ -f "$f" ] || continue
|
|
1751
2298
|
IS_SOURCE_FILES+=("$f")
|
|
1752
2299
|
joined="$joined$f"$'\n'
|
|
1753
2300
|
done <<< "$listing"
|
|
@@ -1758,6 +2305,556 @@ read_source_artifact_files() {
|
|
|
1758
2305
|
fi
|
|
1759
2306
|
}
|
|
1760
2307
|
|
|
2308
|
+
# --- The source catalog (decision 0021) --------------------------------------
|
|
2309
|
+
#
|
|
2310
|
+
# Every renderer — AGENTS.md, every built-in adapter, the adapter template —
|
|
2311
|
+
# reads sources through source_artifacts_var, so one place decides what an
|
|
2312
|
+
# artifact is called, which artifact of a name renders, and where it may go:
|
|
2313
|
+
#
|
|
2314
|
+
# name the entry without a leading `.`, `.md` and a trailing `.local`:
|
|
2315
|
+
# `.environment.local.md` is the rule `environment`, `.draft.md`
|
|
2316
|
+
# the rule `draft`, the skill directory `deploy.local/` the skill
|
|
2317
|
+
# `deploy`. A leading dot means nothing.
|
|
2318
|
+
# local a rule or agent named `*.local.md`, a skill directory named
|
|
2319
|
+
# `*.local` — exactly the patterns `init` writes to .gitignore, so
|
|
2320
|
+
# Git and the engine never disagree. A local artifact never renders
|
|
2321
|
+
# into an output Git tracks (`committed`).
|
|
2322
|
+
# override across sources the later artifact of a name renders. Two of one
|
|
2323
|
+
# name in one directory, or a local and a non-local one anywhere,
|
|
2324
|
+
# refuse the sync: either would change what committed output holds
|
|
2325
|
+
# depending on which file a clone has.
|
|
2326
|
+
# targets `targets:` lists the adapters an artifact is for, every enabled
|
|
2327
|
+
# one without it; `exclude_targets:` removes adapters from that
|
|
2328
|
+
# set. Both take adapter ids and the group `committed` — every
|
|
2329
|
+
# output path Git tracks — and a local artifact excludes
|
|
2330
|
+
# `committed` implicitly.
|
|
2331
|
+
#
|
|
2332
|
+
# source_catalog_build fills, in the caller's shell and fail-closed like the
|
|
2333
|
+
# listing it reads, one row per source entry in listing order:
|
|
2334
|
+
# IS_CAT_SEC IS_CAT_PATH IS_CAT_NAME IS_CAT_LOCAL IS_CAT_WIN IS_CAT_SCOPED
|
|
2335
|
+
# IS_CAT_HAS_T IS_CAT_T IS_CAT_HAS_X IS_CAT_X
|
|
2336
|
+
# (section; the file, or a skill's SKILL.md; the rendered name; 1 when local;
|
|
2337
|
+
# 1 when no later source overrides it; 1 for a rule with `paths:`; whether
|
|
2338
|
+
# `targets:` / `exclude_targets:` are set, and their ids, space-separated),
|
|
2339
|
+
# IS_CAT_N, and IS_CAT_NEED_COMMITTED, 1 when rendering has to know which
|
|
2340
|
+
# outputs Git tracks. With IS_SOURCE_FILES_MEMO=1 a second call replays the
|
|
2341
|
+
# first.
|
|
2342
|
+
|
|
2343
|
+
# source_artifact_name_var <section> <entry> — IS_ART_NAME and IS_ART_LOCAL for
|
|
2344
|
+
# one file (rules, agents) or skill directory (skills) name.
|
|
2345
|
+
source_artifact_name_var() {
|
|
2346
|
+
local n="$2"
|
|
2347
|
+
IS_ART_LOCAL=0
|
|
2348
|
+
case "$1:$n" in
|
|
2349
|
+
skills:*.local|rules:*.local.md|agents:*.local.md) IS_ART_LOCAL=1 ;;
|
|
2350
|
+
esac
|
|
2351
|
+
n="${n#.}"
|
|
2352
|
+
[ "$1" = skills ] || n="${n%.md}"
|
|
2353
|
+
case "$n" in *.local) n="${n%.local}" ;; esac
|
|
2354
|
+
IS_ART_NAME="$n"
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
# shellcheck disable=SC2034 # IS_CAT_NEED_COMMITTED is read by engine/sync.sh
|
|
2358
|
+
source_catalog_build() {
|
|
2359
|
+
local repo_root="$1" config_file="$2" lint="${3:-}" key section f entry dir name
|
|
2360
|
+
local seen=$'\n' errors="" i n=0 prev rest rows path scoped kind has_t t has_x x
|
|
2361
|
+
local -a files=()
|
|
2362
|
+
key="$repo_root"$'\n'"$config_file"
|
|
2363
|
+
if [ "${IS_SOURCE_FILES_MEMO:-0}" = 1 ] && [ "${IS_CAT_KEY:-}" = "$key" ]; then
|
|
2364
|
+
return 0
|
|
2365
|
+
fi
|
|
2366
|
+
IS_CAT_KEY=""
|
|
2367
|
+
IS_CAT_SEC=(); IS_CAT_PATH=(); IS_CAT_NAME=(); IS_CAT_LOCAL=()
|
|
2368
|
+
IS_CAT_WIN=(); IS_CAT_SCOPED=()
|
|
2369
|
+
IS_CAT_HAS_T=(); IS_CAT_T=(); IS_CAT_HAS_X=(); IS_CAT_X=()
|
|
2370
|
+
IS_CAT_NEED_COMMITTED=0
|
|
2371
|
+
for section in rules agents skills; do
|
|
2372
|
+
read_source_artifact_files "$repo_root" "$config_file" "$section"
|
|
2373
|
+
for f in "${IS_SOURCE_FILES[@]+"${IS_SOURCE_FILES[@]}"}"; do
|
|
2374
|
+
if [ "$section" = skills ]; then
|
|
2375
|
+
dir="${f%/SKILL.md}"
|
|
2376
|
+
entry="${dir##*/}"
|
|
2377
|
+
dir="${dir%/*}"
|
|
2378
|
+
kind="skill"
|
|
2379
|
+
else
|
|
2380
|
+
entry="${f##*/}"
|
|
2381
|
+
dir="${f%/*}"
|
|
2382
|
+
kind="${section%s}"
|
|
2383
|
+
fi
|
|
2384
|
+
source_artifact_name_var "$section" "$entry"
|
|
2385
|
+
name="$IS_ART_NAME"
|
|
2386
|
+
IS_CAT_SEC[n]="$section"
|
|
2387
|
+
IS_CAT_PATH[n]="$f"
|
|
2388
|
+
IS_CAT_NAME[n]="$name"
|
|
2389
|
+
IS_CAT_LOCAL[n]="$IS_ART_LOCAL"
|
|
2390
|
+
IS_CAT_WIN[n]=1
|
|
2391
|
+
IS_CAT_SCOPED[n]=0
|
|
2392
|
+
IS_CAT_HAS_T[n]=0; IS_CAT_T[n]=""; IS_CAT_HAS_X[n]=0; IS_CAT_X[n]=""
|
|
2393
|
+
[ "$IS_ART_LOCAL" = 0 ] || IS_CAT_NEED_COMMITTED=1
|
|
2394
|
+
case "$seen" in
|
|
2395
|
+
*$'\n'"$section/$name"$'\t'*)
|
|
2396
|
+
rest="${seen##*$'\n'"$section/$name"$'\t'}"
|
|
2397
|
+
prev="${rest%%$'\n'*}"
|
|
2398
|
+
rest="${IS_CAT_PATH[prev]}"
|
|
2399
|
+
[ "$section" != skills ] || rest="${rest%/SKILL.md}"
|
|
2400
|
+
if [ "${IS_CAT_PATH[prev]}" = "$f" ]; then
|
|
2401
|
+
# One directory listed twice: the same artifact.
|
|
2402
|
+
IS_CAT_WIN[prev]=0
|
|
2403
|
+
elif [ "${rest%/*}" = "$dir" ]; then
|
|
2404
|
+
errors+="ERROR: ${IS_CAT_PATH[prev]#"$repo_root"/} and ${f#"$repo_root"/} are both the $kind '$name' in one directory — rename or remove one"$'\n'
|
|
2405
|
+
elif [ "${IS_CAT_LOCAL[prev]}" != "$IS_ART_LOCAL" ]; then
|
|
2406
|
+
errors+="ERROR: ${IS_CAT_PATH[prev]#"$repo_root"/} and ${f#"$repo_root"/} are both the $kind '$name', one local and one not — a local artifact cannot override or yield to a committed one; rename one"$'\n'
|
|
2407
|
+
else
|
|
2408
|
+
IS_CAT_WIN[prev]=0
|
|
2409
|
+
fi
|
|
2410
|
+
;;
|
|
2411
|
+
esac
|
|
2412
|
+
seen="$seen$section/$name"$'\t'"$n"$'\n'
|
|
2413
|
+
files+=("$f")
|
|
2414
|
+
n=$((n + 1))
|
|
2415
|
+
done
|
|
2416
|
+
done
|
|
2417
|
+
IS_CAT_N="$n"
|
|
2418
|
+
if [ -n "$errors" ]; then
|
|
2419
|
+
printf '%s' "$errors" >&2
|
|
2420
|
+
exit 1
|
|
2421
|
+
fi
|
|
2422
|
+
if [ "$n" -gt 0 ]; then
|
|
2423
|
+
# One pass reads what routing needs from every source's frontmatter
|
|
2424
|
+
# and, for the engine, lints it. Captured, not streamed: a short answer
|
|
2425
|
+
# must stop the run, not route the files it lost as always-on.
|
|
2426
|
+
rows="$(source_frontmatter_scan "$lint" "${files[@]}")" || {
|
|
2427
|
+
echo "ERROR: cannot read the frontmatter of the sources — refusing to render a partial list." >&2
|
|
2428
|
+
exit 1
|
|
2429
|
+
}
|
|
2430
|
+
i=0
|
|
2431
|
+
while IFS=$'\x1f' read -r path scoped has_t t has_x x; do
|
|
2432
|
+
[ "$i" -lt "$n" ] && [ "$path" = "${IS_CAT_PATH[i]}" ] || {
|
|
2433
|
+
echo "ERROR: cannot read the frontmatter of the sources — refusing to render a partial list." >&2
|
|
2434
|
+
exit 1
|
|
2435
|
+
}
|
|
2436
|
+
[ "${IS_CAT_SEC[i]}" != rules ] || [ "$scoped" = 0 ] || IS_CAT_SCOPED[i]=1
|
|
2437
|
+
IS_CAT_HAS_T[i]="$has_t"; IS_CAT_T[i]="$t"
|
|
2438
|
+
IS_CAT_HAS_X[i]="$has_x"; IS_CAT_X[i]="$x"
|
|
2439
|
+
case " $t $x " in *" committed "*) IS_CAT_NEED_COMMITTED=1 ;; esac
|
|
2440
|
+
i=$((i + 1))
|
|
2441
|
+
done <<< "$rows"
|
|
2442
|
+
if [ "$i" -ne "$n" ]; then
|
|
2443
|
+
echo "ERROR: cannot read the frontmatter of the sources — refusing to render a partial list." >&2
|
|
2444
|
+
exit 1
|
|
2445
|
+
fi
|
|
2446
|
+
fi
|
|
2447
|
+
[ "${IS_SOURCE_FILES_MEMO:-0}" != 1 ] || IS_CAT_KEY="$key"
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
# source_frontmatter_scan <lint> <file>... — one row per file in argument
|
|
2451
|
+
# order, \x1f-separated: the path; its `paths:` line count, read with
|
|
2452
|
+
# frontmatter_index's `paths#` semantics; then for `targets:` and for
|
|
2453
|
+
# `exclude_targets:` whether it is set and its ids, space-separated. Both take
|
|
2454
|
+
# the list syntax `paths:` takes — a block list or a flow list `[a, b]`, items
|
|
2455
|
+
# optionally quoted — and a bare value is a one-item list. The first
|
|
2456
|
+
# occurrence of a key wins, and a key with no value at all is not set
|
|
2457
|
+
# (`targets: []` is: it is the empty list). With <lint> = lint the same pass
|
|
2458
|
+
# runs lint_frontmatter_files over every file, its warnings on stderr.
|
|
2459
|
+
source_frontmatter_scan() {
|
|
2460
|
+
local do_lint=0
|
|
2461
|
+
[ "$1" != lint ] || do_lint=1
|
|
2462
|
+
shift
|
|
2463
|
+
awk -v levels="$IS_EFFORT_LEVELS" -v do_lint="$do_lint" "$IS_AWK_LIB"'
|
|
2464
|
+
BEGIN { SX_US = sprintf("%c", 31) }
|
|
2465
|
+
function sx_items(s, k, parts, m, j, v, q) {
|
|
2466
|
+
m = split(s, parts, ",")
|
|
2467
|
+
for (j = 1; j <= m; j++) {
|
|
2468
|
+
v = parts[j]
|
|
2469
|
+
gsub(/^[ \t]+|[ \t]+$/, "", v)
|
|
2470
|
+
q = substr(v, 1, 1)
|
|
2471
|
+
if (length(v) >= 2 && (q == "\"" || q == "\047") && substr(v, length(v), 1) == q)
|
|
2472
|
+
v = substr(v, 2, length(v) - 2)
|
|
2473
|
+
if (v == "") continue
|
|
2474
|
+
sx_v[k] = (sx_v[k] == "") ? v : sx_v[k] " " v
|
|
2475
|
+
sx_n[k]++
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
# The end of a block list: one without an item is a key with no value.
|
|
2479
|
+
function sx_close() {
|
|
2480
|
+
if (sx_mode == "block" && sx_n[sx_list] == 0) sx_has[sx_list] = 0
|
|
2481
|
+
sx_list = ""; sx_mode = ""
|
|
2482
|
+
}
|
|
2483
|
+
function sx_line(l, c, k, v) {
|
|
2484
|
+
if (sx_mode == "flow") {
|
|
2485
|
+
c = index(l, "]")
|
|
2486
|
+
if (c) { sx_items(substr(l, 1, c - 1), sx_list); sx_list = ""; sx_mode = "" }
|
|
2487
|
+
else sx_items(l, sx_list)
|
|
2488
|
+
return
|
|
2489
|
+
}
|
|
2490
|
+
if (sx_mode == "block") {
|
|
2491
|
+
if (l ~ /^[ \t]+-/) {
|
|
2492
|
+
v = l
|
|
2493
|
+
sub(/^[ \t]+-[ \t]*/, "", v)
|
|
2494
|
+
sub(/[ \t]+#.*$/, "", v)
|
|
2495
|
+
sx_items(v, sx_list)
|
|
2496
|
+
return
|
|
2497
|
+
}
|
|
2498
|
+
if (l ~ /^[ \t]*$/ || l ~ /^[ \t]+#/) return
|
|
2499
|
+
sx_close()
|
|
2500
|
+
}
|
|
2501
|
+
c = index(l, ":")
|
|
2502
|
+
if (!c) return
|
|
2503
|
+
k = substr(l, 1, c - 1)
|
|
2504
|
+
if (k == "paths") { sx_paths++; return }
|
|
2505
|
+
if ((k != "targets" && k != "exclude_targets") || sx_seen[k]) return
|
|
2506
|
+
sx_seen[k] = 1; sx_has[k] = 1
|
|
2507
|
+
v = substr(l, c + 1)
|
|
2508
|
+
sub(/[ \t]+#.*$/, "", v)
|
|
2509
|
+
gsub(/^[ \t]+|[ \t]+$/, "", v)
|
|
2510
|
+
if (v == "") { sx_list = k; sx_mode = "block"; return }
|
|
2511
|
+
if (substr(v, 1, 1) == "[") {
|
|
2512
|
+
v = substr(v, 2)
|
|
2513
|
+
c = index(v, "]")
|
|
2514
|
+
if (c) sx_items(substr(v, 1, c - 1), k)
|
|
2515
|
+
else { sx_items(v, k); sx_list = k; sx_mode = "flow" }
|
|
2516
|
+
return
|
|
2517
|
+
}
|
|
2518
|
+
sx_items(v, k)
|
|
2519
|
+
}
|
|
2520
|
+
function sx_store() {
|
|
2521
|
+
if (sx_cur == "") return
|
|
2522
|
+
sx_close()
|
|
2523
|
+
SXR[sx_cur] = sx_cur SX_US sx_paths SX_US sx_has["targets"] SX_US sx_v["targets"] \
|
|
2524
|
+
SX_US sx_has["exclude_targets"] SX_US sx_v["exclude_targets"]
|
|
2525
|
+
}
|
|
2526
|
+
FNR == 1 {
|
|
2527
|
+
sx_store()
|
|
2528
|
+
sx_cur = FILENAME; sx_paths = 0; sx_fm = 0; sx_list = ""; sx_mode = ""
|
|
2529
|
+
sx_seen["targets"] = 0; sx_has["targets"] = 0; sx_v["targets"] = ""; sx_n["targets"] = 0
|
|
2530
|
+
sx_seen["exclude_targets"] = 0; sx_has["exclude_targets"] = 0
|
|
2531
|
+
sx_v["exclude_targets"] = ""; sx_n["exclude_targets"] = 0
|
|
2532
|
+
}
|
|
2533
|
+
{
|
|
2534
|
+
sx_l = $0
|
|
2535
|
+
sub(/\r$/, "", sx_l)
|
|
2536
|
+
if (FNR == 1) sx_fm = (sx_l == "---")
|
|
2537
|
+
else if (sx_fm == 1) {
|
|
2538
|
+
if (sx_l == "---") { sx_close(); sx_fm = 2 }
|
|
2539
|
+
else sx_line(sx_l)
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
END {
|
|
2543
|
+
sx_store()
|
|
2544
|
+
for (sx_i = 1; sx_i < ARGC; sx_i++)
|
|
2545
|
+
print (ARGV[sx_i] in SXR) ? SXR[ARGV[sx_i]] : ARGV[sx_i] SX_US 0 SX_US 0 SX_US SX_US 0 SX_US
|
|
2546
|
+
}
|
|
2547
|
+
!do_lint { next }
|
|
2548
|
+
'"$_IS_AWK_LINT" "$@"
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
# The source catalog's committed check needs every enabled adapter's Git
|
|
2552
|
+
# policy, which only the engine reads (sync.sh: IS_COMMIT_POLICY, one
|
|
2553
|
+
# `adapter TAB kind TAB pattern` line per ignore and include record, and
|
|
2554
|
+
# IS_COMMIT_POLICY_SET=1). It reads it only when IS_CAT_NEED_COMMITTED says a
|
|
2555
|
+
# render depends on it, so a project without local artifacts never pays for
|
|
2556
|
+
# the manifest read the Copilot contract makes (decision 0020).
|
|
2557
|
+
#
|
|
2558
|
+
# output_committed <adapter> <repo-relative path> — true when Git tracks that
|
|
2559
|
+
# output: no `ignore` record of the adapter's contract covers it, or an
|
|
2560
|
+
# `include` record takes it back. AGENTS.md always belongs here.
|
|
2561
|
+
output_committed() {
|
|
2562
|
+
local adapter="$1" path="$2" a kind pat ignored=0 included=0
|
|
2563
|
+
[ "$adapter" != agents ] || return 0
|
|
2564
|
+
if [ "${IS_COMMIT_POLICY_SET:-0}" != 1 ]; then
|
|
2565
|
+
echo "ERROR: rendering a local artifact needs the Git policy of every enabled adapter; run it through the engine: intelligence sync" >&2
|
|
2566
|
+
exit 1
|
|
2567
|
+
fi
|
|
2568
|
+
while IFS=$'\t' read -r a kind pat; do
|
|
2569
|
+
[ "$a" = "$adapter" ] || continue
|
|
2570
|
+
case "$kind" in
|
|
2571
|
+
ignore) [ "$ignored" = 1 ] || ! policy_pattern_covers "$pat" "$path" || ignored=1 ;;
|
|
2572
|
+
include) [ "$included" = 1 ] || ! policy_pattern_covers "$pat" "$path" || included=1 ;;
|
|
2573
|
+
esac
|
|
2574
|
+
done <<< "$IS_COMMIT_POLICY"
|
|
2575
|
+
[ "$ignored" = 1 ] && [ "$included" = 0 ] && return 1
|
|
2576
|
+
return 0
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
# policy_pattern_covers <ignore-pattern> <path> — whether a contract's ignore
|
|
2580
|
+
# or include pattern covers a repo-relative path or one of its parents: a
|
|
2581
|
+
# trailing `/` names a directory, `*` and `?` match as Git's do inside one.
|
|
2582
|
+
policy_pattern_covers() {
|
|
2583
|
+
local pat="${1#./}" path="$2"
|
|
2584
|
+
pat="${pat#/}"
|
|
2585
|
+
case "$pat" in
|
|
2586
|
+
*/)
|
|
2587
|
+
pat="${pat%/}"
|
|
2588
|
+
case "$path" in "$pat"|"$pat"/*) return 0 ;; esac
|
|
2589
|
+
return 1
|
|
2590
|
+
;;
|
|
2591
|
+
esac
|
|
2592
|
+
# shellcheck disable=SC2254 # the pattern is a glob on purpose
|
|
2593
|
+
case "$path" in
|
|
2594
|
+
$pat|$pat/*) return 0 ;;
|
|
2595
|
+
esac
|
|
2596
|
+
return 1
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
# _cat_member <index> <adapter> <repo-relative output path> — whether the
|
|
2600
|
+
# catalog row renders into that adapter's output at that path: its targets
|
|
2601
|
+
# name the adapter, or `committed` while Git tracks the path, or it has none;
|
|
2602
|
+
# its exclusions name neither; and a local row never reaches a tracked path.
|
|
2603
|
+
_cat_member() {
|
|
2604
|
+
local i="$1" a="$2" path="$3"
|
|
2605
|
+
if [ "${IS_CAT_HAS_T[i]}" = 1 ]; then
|
|
2606
|
+
case " ${IS_CAT_T[i]} " in
|
|
2607
|
+
*" $a "*) ;;
|
|
2608
|
+
*" committed "*) output_committed "$a" "$path" || return 1 ;;
|
|
2609
|
+
*) return 1 ;;
|
|
2610
|
+
esac
|
|
2611
|
+
fi
|
|
2612
|
+
if [ "${IS_CAT_HAS_X[i]}" = 1 ]; then
|
|
2613
|
+
case " ${IS_CAT_X[i]} " in *" $a "*) return 1 ;; esac
|
|
2614
|
+
case " ${IS_CAT_X[i]} " in
|
|
2615
|
+
*" committed "*) ! output_committed "$a" "$path" || return 1 ;;
|
|
2616
|
+
esac
|
|
2617
|
+
fi
|
|
2618
|
+
if [ "${IS_CAT_LOCAL[i]}" = 1 ]; then
|
|
2619
|
+
! output_committed "$a" "$path" || return 1
|
|
2620
|
+
fi
|
|
2621
|
+
return 0
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
# _cat_routed <index> — the row selects its adapters: a local artifact, or one
|
|
2625
|
+
# with `targets:` or `exclude_targets:`. Every other row renders everywhere,
|
|
2626
|
+
# with no question asked.
|
|
2627
|
+
_cat_routed() {
|
|
2628
|
+
[ "${IS_CAT_LOCAL[$1]}" = 1 ] || [ "${IS_CAT_HAS_T[$1]}" = 1 ] || [ "${IS_CAT_HAS_X[$1]}" = 1 ]
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
# source_artifacts_var <repo_root> <config> <section> <adapters> <output>
|
|
2632
|
+
# — the artifacts of a section that render for one of <adapters> (space-
|
|
2633
|
+
# separated ids) into <output>, a repo-relative path in which `{}` stands for
|
|
2634
|
+
# the artifact's name (`.claude/rules/{}.md`, `.agents/skills/{}`). Fills, in
|
|
2635
|
+
# catalog order — sources in manifest order, entries in byte order:
|
|
2636
|
+
# IS_SOURCE_FILES the source file, or a skill's SKILL.md
|
|
2637
|
+
# IS_SOURCE_NAMES the rendered name
|
|
2638
|
+
# IS_SOURCE_SCOPED 1 for a rule with `paths:`
|
|
2639
|
+
# IS_SOURCE_IN_AGENTS 1 when AGENTS.md carries the artifact too
|
|
2640
|
+
# IS_SOURCE_NAME_MAP the name map finalize_copy_named and
|
|
2641
|
+
# copy_skill_bundle_dirs_named take (a skill keyed by
|
|
2642
|
+
# its directory)
|
|
2643
|
+
source_artifacts_var() {
|
|
2644
|
+
local repo_root="$1" config_file="$2" section="$3" adapters="$4" output="$5"
|
|
2645
|
+
local i a pre post path member key ph='{}'
|
|
2646
|
+
source_catalog_build "$repo_root" "$config_file"
|
|
2647
|
+
IS_SOURCE_FILES=(); IS_SOURCE_NAMES=(); IS_SOURCE_SCOPED=(); IS_SOURCE_IN_AGENTS=()
|
|
2648
|
+
IS_SOURCE_NAME_MAP=$'\n'
|
|
2649
|
+
case "$output" in
|
|
2650
|
+
*"$ph"*) pre="${output%%"$ph"*}"; post="${output#*"$ph"}" ;;
|
|
2651
|
+
*) pre="$output"; post="" ;;
|
|
2652
|
+
esac
|
|
2653
|
+
for ((i = 0; i < IS_CAT_N; i++)); do
|
|
2654
|
+
[ "${IS_CAT_SEC[i]}" = "$section" ] && [ "${IS_CAT_WIN[i]}" = 1 ] || continue
|
|
2655
|
+
if _cat_routed "$i"; then
|
|
2656
|
+
case "$output" in *"$ph"*) path="$pre${IS_CAT_NAME[i]}$post" ;; *) path="$output" ;; esac
|
|
2657
|
+
member=0
|
|
2658
|
+
for a in $adapters; do
|
|
2659
|
+
if _cat_member "$i" "$a" "$path"; then member=1; break; fi
|
|
2660
|
+
done
|
|
2661
|
+
[ "$member" = 1 ] || continue
|
|
2662
|
+
# AGENTS.md is always committed, so its path decides nothing.
|
|
2663
|
+
if _cat_member "$i" agents AGENTS.md; then
|
|
2664
|
+
IS_SOURCE_IN_AGENTS+=(1)
|
|
2665
|
+
else
|
|
2666
|
+
IS_SOURCE_IN_AGENTS+=(0)
|
|
2667
|
+
fi
|
|
2668
|
+
else
|
|
2669
|
+
IS_SOURCE_IN_AGENTS+=(1)
|
|
2670
|
+
fi
|
|
2671
|
+
IS_SOURCE_FILES+=("${IS_CAT_PATH[i]}")
|
|
2672
|
+
IS_SOURCE_NAMES+=("${IS_CAT_NAME[i]}")
|
|
2673
|
+
IS_SOURCE_SCOPED+=("${IS_CAT_SCOPED[i]}")
|
|
2674
|
+
key="${IS_CAT_PATH[i]}"
|
|
2675
|
+
[ "$section" != skills ] || key="${key%/SKILL.md}"
|
|
2676
|
+
IS_SOURCE_NAME_MAP="$IS_SOURCE_NAME_MAP$key"$'\x1f'"${IS_CAT_NAME[i]}"$'\n'
|
|
2677
|
+
done
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
# The checks below run in the engine, after preflight read every enabled
|
|
2681
|
+
# contract: IS_ENABLED_ADAPTERS and IS_AGENTS_READERS (the enabled adapters,
|
|
2682
|
+
# and those that require `agents` and so read AGENTS.md), IS_OPEN_SKILLS_READERS
|
|
2683
|
+
# and IS_ADAPTER_CLAIMS (every enabled adapter's owned and managed paths).
|
|
2684
|
+
|
|
2685
|
+
# source_catalog_check_ids <repo_root> <known adapter ids> — refuse a
|
|
2686
|
+
# `targets:` or `exclude_targets:` id that is neither an adapter, built-in or
|
|
2687
|
+
# the project's, nor `committed`. Every finding is printed before the refusal.
|
|
2688
|
+
source_catalog_check_ids() {
|
|
2689
|
+
local repo_root="$1" known=" $2 committed " i id field ids bad=0
|
|
2690
|
+
local -a list=()
|
|
2691
|
+
for ((i = 0; i < IS_CAT_N; i++)); do
|
|
2692
|
+
[ "${IS_CAT_WIN[i]}" = 1 ] || continue
|
|
2693
|
+
for field in targets exclude_targets; do
|
|
2694
|
+
if [ "$field" = targets ]; then
|
|
2695
|
+
[ "${IS_CAT_HAS_T[i]}" = 1 ] || continue
|
|
2696
|
+
ids="${IS_CAT_T[i]}"
|
|
2697
|
+
else
|
|
2698
|
+
[ "${IS_CAT_HAS_X[i]}" = 1 ] || continue
|
|
2699
|
+
ids="${IS_CAT_X[i]}"
|
|
2700
|
+
fi
|
|
2701
|
+
read -r -a list <<< "$ids"
|
|
2702
|
+
for id in "${list[@]+"${list[@]}"}"; do
|
|
2703
|
+
case "$known" in *" $id "*) continue ;; esac
|
|
2704
|
+
echo "ERROR: ${IS_CAT_PATH[i]#"$repo_root"/}: $field names '$id', which is no adapter — use one of: ${2// /, }, or the group committed" >&2
|
|
2705
|
+
bad=1
|
|
2706
|
+
done
|
|
2707
|
+
done
|
|
2708
|
+
done
|
|
2709
|
+
return "$bad"
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
# _adapter_paths_var <adapter> — IS_AP_PATHS, the adapter's owned and managed
|
|
2713
|
+
# paths as preflight registered them.
|
|
2714
|
+
_adapter_paths_var() {
|
|
2715
|
+
local a kind p
|
|
2716
|
+
IS_AP_PATHS=()
|
|
2717
|
+
while IFS=$'\t' read -r a kind p; do
|
|
2718
|
+
if [ "$a" = "$1" ] && [ -n "$p" ]; then IS_AP_PATHS+=("$p"); fi
|
|
2719
|
+
done <<< "${IS_ADAPTER_CLAIMS:-}"
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
# _cat_adapter_member <index> <adapter> — the row renders into any output of
|
|
2723
|
+
# the adapter: its set of adapters, read per path.
|
|
2724
|
+
_cat_adapter_member() {
|
|
2725
|
+
local p
|
|
2726
|
+
_adapter_paths_var "$2"
|
|
2727
|
+
for p in "${IS_AP_PATHS[@]+"${IS_AP_PATHS[@]}"}"; do
|
|
2728
|
+
if _cat_member "$1" "$2" "$p"; then return 0; fi
|
|
2729
|
+
done
|
|
2730
|
+
return 1
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
# source_catalog_check_local <repo_root> — refuse a local artifact whose
|
|
2734
|
+
# targets name a committed output: `committed` itself, `agents`, or an enabled
|
|
2735
|
+
# adapter all of whose paths Git tracks. The source exists in no other clone
|
|
2736
|
+
# and not in CI, which render that output without it.
|
|
2737
|
+
source_catalog_check_local() {
|
|
2738
|
+
local repo_root="$1" i id p tracked bad=0
|
|
2739
|
+
local -a list=()
|
|
2740
|
+
for ((i = 0; i < IS_CAT_N; i++)); do
|
|
2741
|
+
[ "${IS_CAT_WIN[i]}" = 1 ] && [ "${IS_CAT_LOCAL[i]}" = 1 ] && [ "${IS_CAT_HAS_T[i]}" = 1 ] || continue
|
|
2742
|
+
read -r -a list <<< "${IS_CAT_T[i]}"
|
|
2743
|
+
for id in "${list[@]+"${list[@]}"}"; do
|
|
2744
|
+
tracked=0
|
|
2745
|
+
case "$id" in
|
|
2746
|
+
committed|agents) tracked=1 ;;
|
|
2747
|
+
*)
|
|
2748
|
+
case " ${IS_ENABLED_ADAPTERS:-} " in *" $id "*) ;; *) continue ;; esac
|
|
2749
|
+
_adapter_paths_var "$id"
|
|
2750
|
+
[ "${#IS_AP_PATHS[@]}" -gt 0 ] || continue
|
|
2751
|
+
tracked=1
|
|
2752
|
+
for p in "${IS_AP_PATHS[@]}"; do
|
|
2753
|
+
if ! output_committed "$id" "$p"; then tracked=0; break; fi
|
|
2754
|
+
done
|
|
2755
|
+
;;
|
|
2756
|
+
esac
|
|
2757
|
+
[ "$tracked" = 1 ] || continue
|
|
2758
|
+
echo "ERROR: ${IS_CAT_PATH[i]#"$repo_root"/} is local, but its targets name $id, whose output Git tracks — a local artifact exists in no other clone, so it renders only into ignored output" >&2
|
|
2759
|
+
bad=1
|
|
2760
|
+
done
|
|
2761
|
+
done
|
|
2762
|
+
return "$bad"
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
# builtin_has_channel <adapter> <always|scoped|agent|skill> — whether a built-in
|
|
2766
|
+
# adapter has a channel of its own for that kind of artifact, AGENTS.md aside.
|
|
2767
|
+
# A project adapter's channels are its own business: it has one.
|
|
2768
|
+
builtin_has_channel() {
|
|
2769
|
+
case "$1:$2" in
|
|
2770
|
+
opencode:always|opencode:scoped|codex:scoped) return 1 ;;
|
|
2771
|
+
esac
|
|
2772
|
+
return 0
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
# source_catalog_warn <repo_root> — the WARNING: lines for an artifact that
|
|
2776
|
+
# selects its adapters (decision 0021): a shared path — AGENTS.md, or
|
|
2777
|
+
# .agents/skills/ — that delivers it to an enabled adapter outside its set, and
|
|
2778
|
+
# an adapter in its set that no channel reaches. An artifact with neither field
|
|
2779
|
+
# nor a `.local` name keeps the routing every sync had, and no new warning.
|
|
2780
|
+
source_catalog_warn() {
|
|
2781
|
+
local repo_root="$1" i a rel kind in_agents outside unreach landed what
|
|
2782
|
+
for ((i = 0; i < IS_CAT_N; i++)); do
|
|
2783
|
+
[ "${IS_CAT_WIN[i]}" = 1 ] || continue
|
|
2784
|
+
_cat_routed "$i" || continue
|
|
2785
|
+
rel="${IS_CAT_PATH[i]#"$repo_root"/}"
|
|
2786
|
+
case "${IS_CAT_SEC[i]}" in
|
|
2787
|
+
rules) kind=always; [ "${IS_CAT_SCOPED[i]}" = 0 ] || kind=scoped ;;
|
|
2788
|
+
agents) kind=agent ;;
|
|
2789
|
+
*) kind=skill; rel="${rel%/SKILL.md}/" ;;
|
|
2790
|
+
esac
|
|
2791
|
+
in_agents=0
|
|
2792
|
+
case " ${IS_ENABLED_ADAPTERS:-} " in
|
|
2793
|
+
*" agents "*) if _cat_member "$i" agents AGENTS.md; then in_agents=1; fi ;;
|
|
2794
|
+
esac
|
|
2795
|
+
if [ "$in_agents" = 1 ]; then
|
|
2796
|
+
outside=""
|
|
2797
|
+
for a in ${IS_AGENTS_READERS:-}; do
|
|
2798
|
+
_cat_adapter_member "$i" "$a" || outside="$outside${outside:+, }$a"
|
|
2799
|
+
done
|
|
2800
|
+
if [ -n "$outside" ]; then
|
|
2801
|
+
echo "WARNING: $rel still reaches $outside through AGENTS.md, which they read, although its targets leave them out" >&2
|
|
2802
|
+
fi
|
|
2803
|
+
fi
|
|
2804
|
+
if [ "$kind" = skill ]; then
|
|
2805
|
+
landed=0
|
|
2806
|
+
outside=""
|
|
2807
|
+
for a in ${IS_OPEN_SKILLS_READERS:-}; do
|
|
2808
|
+
if _cat_member "$i" "$a" ".agents/skills/${IS_CAT_NAME[i]}"; then
|
|
2809
|
+
landed=1
|
|
2810
|
+
else
|
|
2811
|
+
outside="$outside${outside:+, }$a"
|
|
2812
|
+
fi
|
|
2813
|
+
done
|
|
2814
|
+
if [ "$landed" = 1 ] && [ -n "$outside" ]; then
|
|
2815
|
+
echo "WARNING: $rel still reaches $outside through .agents/skills/, which they read too, although its targets leave them out" >&2
|
|
2816
|
+
fi
|
|
2817
|
+
fi
|
|
2818
|
+
unreach=""
|
|
2819
|
+
for a in ${IS_ENABLED_ADAPTERS:-}; do
|
|
2820
|
+
[ "$a" != agents ] || continue
|
|
2821
|
+
_cat_adapter_member "$i" "$a" || continue
|
|
2822
|
+
if [ "$in_agents" = 1 ]; then
|
|
2823
|
+
case " ${IS_AGENTS_READERS:-} " in *" $a "*) continue ;; esac
|
|
2824
|
+
fi
|
|
2825
|
+
builtin_has_channel "$a" "$kind" || unreach="$unreach${unreach:+, }$a"
|
|
2826
|
+
done
|
|
2827
|
+
if [ -n "$unreach" ]; then
|
|
2828
|
+
what="an always-on rule"
|
|
2829
|
+
[ "$kind" != scoped ] || what="a scoped rule"
|
|
2830
|
+
echo "WARNING: $rel cannot reach $unreach: no channel there carries $what outside AGENTS.md" >&2
|
|
2831
|
+
fi
|
|
2832
|
+
done
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
# output_rel_var <repo_root> <absolute output path> — IS_OUTPUT_REL, the
|
|
2836
|
+
# output's repo-relative path spelled as contracts and source_artifacts_var
|
|
2837
|
+
# spell it.
|
|
2838
|
+
# shellcheck disable=SC2034 # IS_OUTPUT_REL is the return channel read by adapters
|
|
2839
|
+
output_rel_var() {
|
|
2840
|
+
normalize_path_var "$2"
|
|
2841
|
+
IS_OUTPUT_REL="${IS_NORM_PATH#"$1"/}"
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
# source_catalog_winners_var <repo_root> <config> <section> — every artifact of
|
|
2845
|
+
# a section that renders anywhere, whatever its outputs: IS_SOURCE_FILES and
|
|
2846
|
+
# IS_SOURCE_SCOPED, for the adapter-agnostic context report.
|
|
2847
|
+
source_catalog_winners_var() {
|
|
2848
|
+
local i
|
|
2849
|
+
source_catalog_build "$1" "$2"
|
|
2850
|
+
IS_SOURCE_FILES=(); IS_SOURCE_SCOPED=()
|
|
2851
|
+
for ((i = 0; i < IS_CAT_N; i++)); do
|
|
2852
|
+
[ "${IS_CAT_SEC[i]}" = "$3" ] && [ "${IS_CAT_WIN[i]}" = 1 ] || continue
|
|
2853
|
+
IS_SOURCE_FILES+=("${IS_CAT_PATH[i]}")
|
|
2854
|
+
IS_SOURCE_SCOPED+=("${IS_CAT_SCOPED[i]}")
|
|
2855
|
+
done
|
|
2856
|
+
}
|
|
2857
|
+
|
|
1761
2858
|
# Apply the agents adapter's lexical output rule without consulting filesystem
|
|
1762
2859
|
# state. An omitted output uses the generic adapter default directory.
|
|
1763
2860
|
agents_output_path_var() {
|
|
@@ -1827,26 +2924,23 @@ context_files_bytes() {
|
|
|
1827
2924
|
report_context_source_sizes() {
|
|
1828
2925
|
local repo_root="$1"
|
|
1829
2926
|
local config_file="$2"
|
|
1830
|
-
local
|
|
1831
|
-
local -a
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
fi
|
|
1843
|
-
done < <(frontmatter_index "paths#" "${rule_files[@]}")
|
|
1844
|
-
fi
|
|
2927
|
+
local i
|
|
2928
|
+
local -a always_on_rules=() scoped_rules=() agent_files=() skill_files=()
|
|
2929
|
+
|
|
2930
|
+
# What renders anywhere: an artifact a later source overrides is no context.
|
|
2931
|
+
source_catalog_winners_var "$repo_root" "$config_file" "rules"
|
|
2932
|
+
for ((i = 0; i < ${#IS_SOURCE_FILES[@]}; i++)); do
|
|
2933
|
+
if [ "${IS_SOURCE_SCOPED[i]}" = 0 ]; then
|
|
2934
|
+
always_on_rules+=("${IS_SOURCE_FILES[i]}")
|
|
2935
|
+
else
|
|
2936
|
+
scoped_rules+=("${IS_SOURCE_FILES[i]}")
|
|
2937
|
+
fi
|
|
2938
|
+
done
|
|
1845
2939
|
|
|
1846
|
-
|
|
2940
|
+
source_catalog_winners_var "$repo_root" "$config_file" "agents"
|
|
1847
2941
|
[ "${#IS_SOURCE_FILES[@]}" -eq 0 ] || agent_files=("${IS_SOURCE_FILES[@]}")
|
|
1848
2942
|
|
|
1849
|
-
|
|
2943
|
+
source_catalog_winners_var "$repo_root" "$config_file" "skills"
|
|
1850
2944
|
[ "${#IS_SOURCE_FILES[@]}" -eq 0 ] || skill_files=("${IS_SOURCE_FILES[@]}")
|
|
1851
2945
|
|
|
1852
2946
|
local always_bytes=0 custom_bytes=0 agents_output agents_bytes=0 agents_status="disabled"
|
|
@@ -2106,8 +3200,9 @@ IS_YAML_LIST_AWK='
|
|
|
2106
3200
|
# Consults the load_yaml_list cache first: sync reads the same sections from
|
|
2107
3201
|
# the same manifest dozens of times, and each awk spawn costs tens of
|
|
2108
3202
|
# milliseconds on Windows. The cache is only ever populated by
|
|
2109
|
-
# load_yaml_list
|
|
2110
|
-
#
|
|
3203
|
+
# load_yaml_list and load_yaml_lists. The engine never mutates its manifest;
|
|
3204
|
+
# CLI writers invalidate it through qmap_memo_reset; load_yaml_lists and the
|
|
3205
|
+
# project preloader also populate it.
|
|
2111
3206
|
read_yaml_list() {
|
|
2112
3207
|
local file="$1"
|
|
2113
3208
|
local section="$2"
|
|
@@ -2215,22 +3310,17 @@ load_yaml_list() {
|
|
|
2215
3310
|
esac
|
|
2216
3311
|
}
|
|
2217
3312
|
|
|
2218
|
-
#
|
|
2219
|
-
#
|
|
2220
|
-
#
|
|
2221
|
-
#
|
|
2222
|
-
#
|
|
2223
|
-
#
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
rules|agents|skills|ignore|submodules) ;;
|
|
2230
|
-
*) return 1 ;;
|
|
2231
|
-
esac
|
|
2232
|
-
done
|
|
2233
|
-
out="$(awk -v sections="$*" "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK"'
|
|
3313
|
+
# The manifest readers below share their awk programs, so the combined pass in
|
|
3314
|
+
# load_manifest_view reads each shape exactly as its single-purpose reader does.
|
|
3315
|
+
# Rule order matters when they are joined: the targets program ends its rules
|
|
3316
|
+
# with `next`, so it runs last and never hides a line from the others.
|
|
3317
|
+
#
|
|
3318
|
+
# The lists program carries the package-reference parser with it: it sees every
|
|
3319
|
+
# line, so `packages:` is collected wherever it stands, and each source list is
|
|
3320
|
+
# expanded exactly as read_yaml_list expands it. A reference that resolves to
|
|
3321
|
+
# nothing comes out as a `!` row, which _yaml_lists_store keeps in
|
|
3322
|
+
# IS_YL_UNRESOLVED.
|
|
3323
|
+
_IS_AWK_LISTS="$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK"'
|
|
2234
3324
|
BEGIN { n = split(sections, want, " ") }
|
|
2235
3325
|
{ sub(/\r$/, ""); pkr_collect($0) }
|
|
2236
3326
|
{
|
|
@@ -2261,42 +3351,33 @@ load_yaml_lists() {
|
|
|
2261
3351
|
else print "!\037" s "\037" val "\037" PKR_STATE "\037" dir "\037" PKR_ALIAS "\037" PKR_HOLDERS
|
|
2262
3352
|
}
|
|
2263
3353
|
}
|
|
2264
|
-
|
|
2265
|
-
for s in "$@"; do
|
|
2266
|
-
j=""
|
|
2267
|
-
while IFS= read -r line; do
|
|
2268
|
-
[ "${line%%$'\037'*}" = "$s" ] || continue
|
|
2269
|
-
v="${line#*$'\037'}"
|
|
2270
|
-
j="$j$v"$'\n'
|
|
2271
|
-
done <<< "$out"
|
|
2272
|
-
while [[ "$j" == *$'\n' ]]; do j="${j%$'\n'}"; done
|
|
2273
|
-
printf -v "IS_YL_${s}_FILE" '%s' "$file"
|
|
2274
|
-
printf -v "IS_YL_${s}_VAL" '%s' "$j"
|
|
2275
|
-
done
|
|
2276
|
-
IS_YL_UNRESOLVED=""
|
|
2277
|
-
while IFS= read -r line; do
|
|
2278
|
-
case "$line" in
|
|
2279
|
-
"!"$'\037'*) IS_YL_UNRESOLVED="$IS_YL_UNRESOLVED${line#"!"$'\037'}"$'\n' ;;
|
|
2280
|
-
esac
|
|
2281
|
-
done <<< "$out"
|
|
2282
|
-
}
|
|
3354
|
+
'
|
|
2283
3355
|
|
|
2284
|
-
#
|
|
2285
|
-
#
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
3356
|
+
# get_yaml_field: the first ` fkey:` inside the first `fsection:` block. A
|
|
3357
|
+
# top-level key closes the block; ftag prefixes the printed value.
|
|
3358
|
+
_IS_AWK_FIELD='
|
|
3359
|
+
{ sub(/\r$/, "") }
|
|
3360
|
+
!fdone {
|
|
3361
|
+
if ($0 ~ "^" fsection ":") fin = 1
|
|
3362
|
+
else if (fin && /^[a-zA-Z]/) fdone = 1
|
|
3363
|
+
else if (fin && $0 ~ "^ " fkey ":") {
|
|
3364
|
+
fval = $0
|
|
3365
|
+
sub(/.*:[[:space:]]*["\047]?/, "", fval)
|
|
3366
|
+
sub(/["\047]?[[:space:]]*$/, "", fval)
|
|
3367
|
+
ffound = 1
|
|
3368
|
+
fdone = 1
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
END { if (ffound) print ftag fval }
|
|
3372
|
+
'
|
|
3373
|
+
|
|
3374
|
+
# load_targets_cache: one `name<TAB>enabled<TAB>output` row per target, each
|
|
3375
|
+
# prefixed with ttag.
|
|
3376
|
+
_IS_AWK_TARGETS='
|
|
2296
3377
|
function flush_target(at_sibling) {
|
|
2297
3378
|
if (name == "") return
|
|
2298
3379
|
if (enabled == "" && at_sibling) enabled = 0
|
|
2299
|
-
printf "%s\t%s\t%s\n", name, enabled, output
|
|
3380
|
+
printf "%s%s\t%s\t%s\n", ttag, name, enabled, output
|
|
2300
3381
|
name = ""
|
|
2301
3382
|
}
|
|
2302
3383
|
{ sub(/\r$/, "") }
|
|
@@ -2332,10 +3413,147 @@ load_targets_cache() {
|
|
|
2332
3413
|
output = val
|
|
2333
3414
|
}
|
|
2334
3415
|
END { flush_target(0) }
|
|
2335
|
-
|
|
3416
|
+
'
|
|
3417
|
+
|
|
3418
|
+
# _yaml_lists_store <file> <awk-output> <section>... — fill the load_yaml_list
|
|
3419
|
+
# cache for each section from `<section><US><value>` rows, and IS_YL_UNRESOLVED
|
|
3420
|
+
# from the `!` rows: one read_source_entries-shaped line each, the section in
|
|
3421
|
+
# front.
|
|
3422
|
+
_yaml_lists_store() {
|
|
3423
|
+
local file="$1" out="$2" line v s k saved_ifs="$IFS"
|
|
3424
|
+
local -a lines=() names=() vals=()
|
|
3425
|
+
shift 2
|
|
3426
|
+
names=("$@")
|
|
3427
|
+
IFS=$'\n'
|
|
3428
|
+
set -f
|
|
3429
|
+
# shellcheck disable=SC2206 # rows are `<section><US><value>`, never empty
|
|
3430
|
+
lines=($out)
|
|
3431
|
+
set +f
|
|
3432
|
+
IFS="$saved_ifs"
|
|
3433
|
+
IS_YL_UNRESOLVED=""
|
|
3434
|
+
# One pass over the rows in this shell: reading a here-string again per
|
|
3435
|
+
# section costs a system call per byte on Git Bash.
|
|
3436
|
+
for line in "${lines[@]+"${lines[@]}"}"; do
|
|
3437
|
+
s="${line%%$'\037'*}"
|
|
3438
|
+
v="${line#*$'\037'}"
|
|
3439
|
+
if [ "$s" = "!" ]; then
|
|
3440
|
+
IS_YL_UNRESOLVED="$IS_YL_UNRESOLVED$v"$'\n'
|
|
3441
|
+
continue
|
|
3442
|
+
fi
|
|
3443
|
+
for ((k = 0; k < ${#names[@]}; k++)); do
|
|
3444
|
+
[ "${names[k]}" = "$s" ] || continue
|
|
3445
|
+
vals[k]="${vals[k]-}$v"$'\n'
|
|
3446
|
+
done
|
|
3447
|
+
done
|
|
3448
|
+
for ((k = 0; k < ${#names[@]}; k++)); do
|
|
3449
|
+
v="${vals[k]-}"
|
|
3450
|
+
while [[ "$v" == *$'\n' ]]; do v="${v%$'\n'}"; done
|
|
3451
|
+
printf -v "IS_YL_${names[k]}_FILE" '%s' "$file"
|
|
3452
|
+
printf -v "IS_YL_${names[k]}_VAL" '%s' "$v"
|
|
3453
|
+
done
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
# load_yaml_lists <file> <section>... — warm the load_yaml_list cache for several
|
|
3457
|
+
# sections in one manifest pass. Each section runs read_yaml_list's own state
|
|
3458
|
+
# machine and its reference expansion, so every cached value is what
|
|
3459
|
+
# load_yaml_list would have stored. The same pass leaves the references that
|
|
3460
|
+
# resolve to nothing in IS_YL_UNRESOLVED, one read_source_entries-shaped line
|
|
3461
|
+
# each with the section in front, for the WARNING: lines `sync` prints.
|
|
3462
|
+
load_yaml_lists() {
|
|
3463
|
+
local file="$1" out s
|
|
3464
|
+
shift
|
|
3465
|
+
for s in "$@"; do
|
|
3466
|
+
case "$s" in
|
|
3467
|
+
rules|agents|skills|ignore|submodules) ;;
|
|
3468
|
+
*) return 1 ;;
|
|
3469
|
+
esac
|
|
3470
|
+
done
|
|
3471
|
+
out="$(awk -v sections="$*" "$_IS_AWK_LISTS" "$file")"
|
|
3472
|
+
_yaml_lists_store "$file" "$out" "$@"
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
# load_targets_cache <file> — parse the whole targets: section once into the
|
|
3476
|
+
# global IS_TGT_TSV (one `name<TAB>enabled<TAB>output` row per target),
|
|
3477
|
+
# matching is_target_enabled and get_target_output semantics: inline and block
|
|
3478
|
+
# forms, first occurrence wins, `enabled` defaults to 0 when the block ends
|
|
3479
|
+
# without one and to empty at end of file. The engine warms this once;
|
|
3480
|
+
# both readers consult it before spawning awk.
|
|
3481
|
+
load_targets_cache() {
|
|
3482
|
+
local file="$1"
|
|
3483
|
+
if [ "${IS_TGT_FILE:-}" = "$file" ]; then
|
|
3484
|
+
return 0
|
|
3485
|
+
fi
|
|
3486
|
+
IS_TGT_TSV="$(awk "$_IS_AWK_TARGETS" "$file")"
|
|
3487
|
+
IS_TGT_FILE="$file"
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
# load_manifest_view <file> <field-section> <field-key> <section>... — one
|
|
3491
|
+
# awk pass for what the CLI's preflight reads of a manifest: the
|
|
3492
|
+
# load_yaml_lists cache for each <section>, the targets cache, and the one
|
|
3493
|
+
# get_yaml_field answer that get_yaml_field_var then returns without a process.
|
|
3494
|
+
# Each reader's program is the one it runs alone, so the answers cannot differ.
|
|
3495
|
+
# A caller that overlaps the pass with other work runs manifest_view_run (it
|
|
3496
|
+
# prints) and hands the output to manifest_view_store itself.
|
|
3497
|
+
load_manifest_view() {
|
|
3498
|
+
local out
|
|
3499
|
+
out="$(manifest_view_run "$@")" || return 1
|
|
3500
|
+
manifest_view_store "$out" "$@"
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
manifest_view_run() {
|
|
3504
|
+
local file="$1" fsection="$2" fkey="$3" s
|
|
3505
|
+
shift 3
|
|
3506
|
+
for s in "$@"; do
|
|
3507
|
+
case "$s" in
|
|
3508
|
+
rules|agents|skills|ignore|submodules) ;;
|
|
3509
|
+
*) return 1 ;;
|
|
3510
|
+
esac
|
|
3511
|
+
done
|
|
3512
|
+
awk -v sections="$*" -v fsection="$fsection" -v fkey="$fkey" \
|
|
3513
|
+
-v ftag=$'\036F' -v ttag=$'\036T' \
|
|
3514
|
+
"$_IS_AWK_LISTS$_IS_AWK_FIELD$_IS_AWK_TARGETS" "$file"
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
# manifest_view_store <output> <file> <field-section> <field-key> <section>...
|
|
3518
|
+
manifest_view_store() {
|
|
3519
|
+
local out="$1" file="$2" fsection="$3" fkey="$4" line lists="" targets="" field="" found=0
|
|
3520
|
+
local saved_ifs="$IFS"
|
|
3521
|
+
local -a lines=()
|
|
3522
|
+
shift 4
|
|
3523
|
+
IFS=$'\n'
|
|
3524
|
+
set -f
|
|
3525
|
+
# shellcheck disable=SC2206 # every row is tagged or `<section><US>`, never empty
|
|
3526
|
+
lines=($out)
|
|
3527
|
+
set +f
|
|
3528
|
+
IFS="$saved_ifs"
|
|
3529
|
+
for line in "${lines[@]+"${lines[@]}"}"; do
|
|
3530
|
+
case "$line" in
|
|
3531
|
+
$'\036T'*) targets="$targets${line#??}"$'\n' ;;
|
|
3532
|
+
$'\036F'*) field="${line#??}"; found=1 ;;
|
|
3533
|
+
*) lists="$lists$line"$'\n' ;;
|
|
3534
|
+
esac
|
|
3535
|
+
done
|
|
3536
|
+
while [[ "$targets" == *$'\n' ]]; do targets="${targets%$'\n'}"; done
|
|
3537
|
+
_yaml_lists_store "$file" "$lists" "$@"
|
|
3538
|
+
IS_TGT_TSV="$targets"
|
|
2336
3539
|
IS_TGT_FILE="$file"
|
|
3540
|
+
IS_YF_KEY="$file"$'\037'"$fsection"$'\037'"$fkey"
|
|
3541
|
+
IS_YF_VALUE="$field"
|
|
3542
|
+
IS_YF_FOUND="$found"
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
# manifest_view_reset — forget every manifest answer cached in this shell. A
|
|
3546
|
+
# process that has the manifest rewritten (by a child it ran) calls it before
|
|
3547
|
+
# reading again.
|
|
3548
|
+
manifest_view_reset() {
|
|
3549
|
+
local s
|
|
3550
|
+
for s in rules agents skills ignore submodules; do
|
|
3551
|
+
unset "IS_YL_${s}_FILE" "IS_YL_${s}_VAL"
|
|
3552
|
+
done
|
|
3553
|
+
unset IS_TGT_FILE IS_TGT_TSV IS_YF_KEY IS_YF_VALUE IS_YF_FOUND IS_YL_UNRESOLVED
|
|
2337
3554
|
}
|
|
2338
3555
|
|
|
3556
|
+
|
|
2339
3557
|
# _targets_cache_row <target> — scan the cached TSV; sets IS_TGT_ROW_ENABLED /
|
|
2340
3558
|
# IS_TGT_ROW_OUTPUT. Returns 1 when the target has no row (reader falls back
|
|
2341
3559
|
# to empty, same as the awk scan finding nothing).
|
|
@@ -2557,18 +3775,23 @@ get_yaml_field() {
|
|
|
2557
3775
|
local file="$1"
|
|
2558
3776
|
local section="$2"
|
|
2559
3777
|
local key="$3"
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
3778
|
+
if [ "${IS_YF_KEY:-}" = "$file"$'\037'"$section"$'\037'"$key" ]; then
|
|
3779
|
+
[ "$IS_YF_FOUND" != 1 ] || printf '%s\n' "$IS_YF_VALUE"
|
|
3780
|
+
return 0
|
|
3781
|
+
fi
|
|
3782
|
+
awk -v fsection="$section" -v fkey="$key" "$_IS_AWK_FIELD" "$file"
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
# get_yaml_field_var <file> <section> <key> — get_yaml_field into
|
|
3786
|
+
# IS_YAML_FIELD, without a process when load_manifest_view read it.
|
|
3787
|
+
# shellcheck disable=SC2034 # IS_YAML_FIELD is the return channel
|
|
3788
|
+
get_yaml_field_var() {
|
|
3789
|
+
if [ "${IS_YF_KEY:-}" = "$1"$'\037'"$2"$'\037'"$3" ]; then
|
|
3790
|
+
IS_YAML_FIELD=""
|
|
3791
|
+
[ "$IS_YF_FOUND" != 1 ] || IS_YAML_FIELD="$IS_YF_VALUE"
|
|
3792
|
+
return 0
|
|
3793
|
+
fi
|
|
3794
|
+
IS_YAML_FIELD="$(get_yaml_field "$@")"
|
|
2572
3795
|
}
|
|
2573
3796
|
|
|
2574
3797
|
# Get project name from config.yaml (project.name)
|