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