@ainova-systems/intelligence 0.11.3 → 0.11.5

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.
@@ -29,44 +29,175 @@ normalize_file_to_lf() {
29
29
  # scoped rule reaches Claude's `paths:`, Cursor's `globs:` and Copilot's
30
30
  # `applyTo:` already carrying the project's real folder name.
31
31
 
32
- # finalize_output_file <file>
32
+ # Process spawns dominate sync time on Git Bash for Windows: one fork costs
33
+ # tens of milliseconds against ~1ms on Linux, so a helper that runs awk per
34
+ # file turns a large project into minutes of pure process creation. Every hot
35
+ # helper therefore has a batched form that handles N files in ONE awk process,
36
+ # and adapters MUST use the batched forms inside per-file loops. The shared
37
+ # function library below keeps token expansion and frontmatter semantics in
38
+ # exactly one place across those batch programs.
39
+ #
40
+ # fin_line() uses literal (index-based) substitution, not gsub: a regex
41
+ # replacement would give `&` in a path its special meaning, and POSIX awk has
42
+ # no way to pass a replacement string verbatim. Token values arrive via the
43
+ # -v args produced by is_fin_awk_vars.
44
+ IS_AWK_LIB='
45
+ function is_repl(s, from, to, out, i) {
46
+ out = ""
47
+ while ((i = index(s, from)) > 0) {
48
+ out = out substr(s, 1, i - 1) to
49
+ s = substr(s, i + length(from))
50
+ }
51
+ return out s
52
+ }
53
+ function fin_line(s) {
54
+ s = is_repl(s, "<sync-cmd>", FIN_SC)
55
+ s = is_repl(s, "<manifest>", FIN_MF)
56
+ s = is_repl(s, "<module>", FIN_MOD)
57
+ s = is_repl(s, "<content-dir>", FIN_CONTENT)
58
+ return s
59
+ }
60
+ function fm_value_strip(val, n, first, last) {
61
+ sub(/^[[:space:]]+/, "", val)
62
+ sub(/[[:space:]]+$/, "", val)
63
+ n = length(val)
64
+ if (n >= 2) {
65
+ first = substr(val, 1, 1)
66
+ last = substr(val, n, 1)
67
+ if ((first == "\"" && last == "\"") || (first == "\047" && last == "\047")) {
68
+ val = substr(val, 2, n - 2)
69
+ }
70
+ }
71
+ return val
72
+ }
73
+ function base_name(p, q, m) { m = split(p, q, "/"); return q[m] }
74
+ '
75
+
76
+ # is_fin_awk_vars — fill the global IS_FIN_V array with the -v bindings the
77
+ # IS_AWK_LIB fin_line() function needs. Rebuilt on every call: the IS_* env
78
+ # contract is exported after this file is sourced.
79
+ is_fin_awk_vars() {
80
+ IS_FIN_V=(
81
+ -v "FIN_SC=${IS_SYNC_CMD:-intelligence sync}"
82
+ -v "FIN_MF=${IS_MANIFEST_NAME:-intelligence.yaml}"
83
+ -v "FIN_MOD=${IS_MODULE_REL:-.intelligence/packages/@ainova-systems/sync}"
84
+ -v "FIN_CONTENT=${IS_CONTENT_REL:-intelligence}"
85
+ )
86
+ }
87
+
88
+ # finalize_output_files <file>...
33
89
  # The single exit gate for every file an adapter writes: expand layout tokens,
34
- # then normalize CRLF -> LF. Adapters MUST call this (not normalize_file_to_lf)
35
- # on each output — a missed call ships a literal `<content-dir>` into an IDE.
90
+ # then normalize CRLF -> LF, for any number of files in one awk process. Each
91
+ # file is buffered in full and written back in place when the input moves to
92
+ # the next file, so no temp files and no per-file mv are needed; a failed run
93
+ # is covered by sync.sh's transaction restore.
94
+ finalize_output_files() {
95
+ [ "$#" -gt 0 ] || return 0
96
+ is_fin_awk_vars
97
+ awk "${IS_FIN_V[@]}" "$IS_AWK_LIB"'
98
+ function flush_file( i) {
99
+ if (out_file == "") return
100
+ for (i = 1; i <= line_n; i++) print buf[i] > out_file
101
+ close(out_file)
102
+ }
103
+ FNR == 1 { flush_file(); out_file = FILENAME; line_n = 0 }
104
+ { sub(/\r$/, ""); buf[++line_n] = fin_line($0) }
105
+ END { flush_file() }
106
+ ' "$@"
107
+ }
108
+
109
+ # finalize_output_file <file> — single-file form, kept for cold paths and
110
+ # project adapters. A missed call ships a literal `<content-dir>` into an IDE.
36
111
  finalize_output_file() {
37
- local target="$1"
38
- local content="${IS_CONTENT_REL:-intelligence}"
39
- local mod="${IS_MODULE_REL:-.intelligence/packages/@ainova-systems/sync}"
40
- local sc="${IS_SYNC_CMD:-intelligence sync}"
41
- local mf="${IS_MANIFEST_NAME:-intelligence.yaml}"
42
- local tmp_file="$target.tmp"
43
- # Literal (index-based) substitution, not gsub: a regex replacement would
44
- # give `&` in a path its special meaning, and POSIX awk has no way to pass a
45
- # replacement string verbatim.
46
- awk -v content="$content" -v mod="$mod" -v sc="$sc" -v mf="$mf" '
47
- function repl(s, from, to, out, i) {
48
- out = ""
49
- while ((i = index(s, from)) > 0) {
50
- out = out substr(s, 1, i - 1) to
51
- s = substr(s, i + length(from))
52
- }
53
- return out s
112
+ finalize_output_files "$1"
113
+ }
114
+
115
+ # finalize_copy_files <dst_dir> <src>...
116
+ # Copy every source file to <dst_dir>/<basename> with the finalize transform
117
+ # applied on the way, all in one awk process. Replaces per-file cp+finalize
118
+ # in adapter rule loops.
119
+ finalize_copy_files() {
120
+ local dst="$1"
121
+ shift
122
+ [ "$#" -gt 0 ] || return 0
123
+ local f
124
+ # awk never reads a record from an empty source, so pre-create those to
125
+ # keep the old cp behavior of producing an empty output file.
126
+ for f in "$@"; do
127
+ [ -s "$f" ] || : > "$dst/${f##*/}"
128
+ done
129
+ is_fin_awk_vars
130
+ awk "${IS_FIN_V[@]}" -v dst="$dst" "$IS_AWK_LIB"'
131
+ FNR == 1 {
132
+ if (out_file != "") close(out_file)
133
+ out_file = dst "/" base_name(FILENAME)
134
+ }
135
+ { sub(/\r$/, ""); print fin_line($0) > out_file }
136
+ ' "$@"
137
+ }
138
+
139
+ # frontmatter_index <keys-csv> <file>...
140
+ # One awk pass over many files; prints one row per file, in argument order:
141
+ # the path, then one value per requested key, all separated by \x1f (ASCII
142
+ # unit separator — never present in frontmatter values). Value semantics are
143
+ # get_frontmatter_value's exactly: first frontmatter block only, first key
144
+ # occurrence wins, first-colon split, symmetric quote strip. The special key
145
+ # `paths#` yields the has_paths count instead of a value. A file without
146
+ # frontmatter (or an empty file) still gets a row, with empty values.
147
+ frontmatter_index() {
148
+ local keys="$1"
149
+ shift
150
+ [ "$#" -gt 0 ] || return 0
151
+ awk -v keys="$keys" "$IS_AWK_LIB"'
152
+ BEGIN { US = sprintf("%c", 31); nk = split(keys, K, ",") }
153
+ function store( i, row) {
154
+ if (cur == "") return
155
+ row = cur
156
+ for (i = 1; i <= nk; i++) row = row US V[i]
157
+ R[cur] = row
54
158
  }
159
+ FNR == 1 {
160
+ store()
161
+ cur = FILENAME
162
+ for (ri = 1; ri <= nk; ri++) { V[ri] = (K[ri] == "paths#") ? 0 : ""; delete SEEN[ri] }
163
+ in_fm = 0; fm_done = 0
164
+ }
165
+ { sub(/\r$/, "") }
166
+ FNR == 1 && $0 == "---" { in_fm = 1; next }
167
+ FNR == 1 { fm_done = 1 }
168
+ in_fm && !fm_done && $0 == "---" { fm_done = 1; next }
169
+ fm_done || !in_fm { next }
55
170
  {
56
- sub(/\r$/, "")
57
- $0 = repl($0, "<sync-cmd>", sc)
58
- $0 = repl($0, "<manifest>", mf)
59
- $0 = repl($0, "<module>", mod)
60
- $0 = repl($0, "<content-dir>", content)
61
- print
171
+ idx = index($0, ":")
172
+ if (idx == 0) next
173
+ k = substr($0, 1, idx - 1)
174
+ for (ki = 1; ki <= nk; ki++) {
175
+ if (K[ki] == "paths#") {
176
+ if (k == "paths") V[ki]++
177
+ } else if (k == K[ki] && !(ki in SEEN)) {
178
+ SEEN[ki] = 1
179
+ V[ki] = fm_value_strip(substr($0, idx + 1))
180
+ }
181
+ }
62
182
  }
63
- ' "$target" > "$tmp_file"
64
- mv "$tmp_file" "$target"
183
+ END {
184
+ store()
185
+ for (ai = 1; ai < ARGC; ai++) {
186
+ if (ARGV[ai] in R) print R[ARGV[ai]]
187
+ else {
188
+ row = ARGV[ai]
189
+ for (ki = 1; ki <= nk; ki++) row = row US ((K[ki] == "paths#") ? 0 : "")
190
+ print row
191
+ }
192
+ }
193
+ }
194
+ ' "$@"
65
195
  }
66
196
 
67
197
  # Escape a string for safe interpolation into a TOML basic string ("..").
68
198
  # Backslash and double-quote are escaped; control chars stripped.
69
- toml_escape() {
199
+ # Fork-free form: sets IS_TOML_ESCAPED; the printing form wraps it.
200
+ toml_escape_var() {
70
201
  local s="$1"
71
202
  s="${s//\\/\\\\}"
72
203
  s="${s//\"/\\\"}"
@@ -74,17 +205,28 @@ toml_escape() {
74
205
  # do not allow them; multi-line content belongs in `"""..."""`.
75
206
  s="${s//$'\n'/ }"
76
207
  s="${s//$'\r'/}"
77
- printf '%s' "$s"
208
+ IS_TOML_ESCAPED="$s"
209
+ }
210
+
211
+ toml_escape() {
212
+ toml_escape_var "$1"
213
+ printf '%s' "$IS_TOML_ESCAPED"
78
214
  }
79
215
 
80
216
  # Escape a string for safe interpolation into a YAML double-quoted scalar.
81
- yaml_dq_escape() {
217
+ # Fork-free form: sets IS_YAML_ESCAPED; the printing form wraps it.
218
+ yaml_dq_escape_var() {
82
219
  local s="$1"
83
220
  s="${s//\\/\\\\}"
84
221
  s="${s//\"/\\\"}"
85
222
  s="${s//$'\n'/ }"
86
223
  s="${s//$'\r'/}"
87
- printf '%s' "$s"
224
+ IS_YAML_ESCAPED="$s"
225
+ }
226
+
227
+ yaml_dq_escape() {
228
+ yaml_dq_escape_var "$1"
229
+ printf '%s' "$IS_YAML_ESCAPED"
88
230
  }
89
231
 
90
232
  # --- Source Resolution -------------------------------------------------------
@@ -101,10 +243,20 @@ yaml_dq_escape() {
101
243
  # name, never the absolute path. The `${path#"$repo_root"/}` strip is a no-op
102
244
  # when $path is not under $repo_root, which is how that case is detected.
103
245
  repo_rel_link() {
246
+ repo_rel_link_var "$1" "$2"
247
+ printf '%s' "$IS_REPO_REL"
248
+ }
249
+
250
+ # repo_rel_link_var — fork-free form: sets IS_REPO_REL ("" when the path is
251
+ # not under the repo root) instead of printing.
252
+ repo_rel_link_var() {
104
253
  local repo_root="$1" path="$2" rel
105
254
  rel="${path#"$repo_root"/}"
106
- [ "$rel" = "$path" ] && return 0
107
- printf '%s' "$rel"
255
+ if [ "$rel" = "$path" ]; then
256
+ IS_REPO_REL=""
257
+ else
258
+ IS_REPO_REL="$rel"
259
+ fi
108
260
  }
109
261
 
110
262
  # Repo-root-relative path of an existing DIRECTORY — by identity, not spelling.
@@ -152,6 +304,95 @@ resolve_source_dir() {
152
304
  printf '%s' "$1/$2"
153
305
  }
154
306
 
307
+ # emit_wrapped_bodies <spec>
308
+ # Batch writer for adapters that wrap each source body in generated
309
+ # header/tail lines: one awk process emits every output file. <spec> holds
310
+ # one \n-separated record per file, fields separated by \x1f:
311
+ # src \x1f dst \x1f mode \x1f trim \x1f escape \x1f header \x1f tail
312
+ # where mode selects the body extraction (`strip` = strip_frontmatter
313
+ # semantics, `fence` = body only after a closed frontmatter fence, so a file
314
+ # without frontmatter yields nothing, `fence_nofm` = fence, or the whole file
315
+ # when it never opened one), trim=1 drops trailing blank body lines and emits
316
+ # a single blank line for an empty body (the $(...)-capture-then-echo
317
+ # semantics the per-file code had), escape=toml applies the TOML triple-quote
318
+ # body escaping, and header/tail are literal output lines each prefixed with
319
+ # \x1e. Every output line passes through fin_line. The spec travels through
320
+ # the environment — awk -v would corrupt backslashes in escaped header values.
321
+ emit_wrapped_bodies() {
322
+ local spec="$1"
323
+ [ -n "$spec" ] || return 0
324
+ local -a srcs=()
325
+ local rec
326
+ while IFS= read -r rec; do
327
+ [ -n "$rec" ] || continue
328
+ srcs+=("${rec%%$'\x1f'*}")
329
+ done <<< "$spec"
330
+ [ "${#srcs[@]}" -gt 0 ] || return 0
331
+ is_fin_awk_vars
332
+ IS_WRAP_SPEC="$spec" awk "${IS_FIN_V[@]}" "$IS_AWK_LIB"'
333
+ BEGIN {
334
+ US = sprintf("%c", 31); LS = sprintf("%c", 30)
335
+ n = split(ENVIRON["IS_WRAP_SPEC"], recs, "\n")
336
+ for (i = 1; i <= n; i++) {
337
+ if (recs[i] == "") continue
338
+ split(recs[i], f, US)
339
+ DST[f[1]] = f[2]; MODE[f[1]] = f[3]; TRIM[f[1]] = f[4]
340
+ ESC[f[1]] = f[5]; HEAD[f[1]] = f[6]; TAIL[f[1]] = f[7]
341
+ }
342
+ }
343
+ function emit_lines(block, m, parts, j) {
344
+ m = split(block, parts, LS)
345
+ for (j = 2; j <= m; j++) print fin_line(parts[j]) > out_file
346
+ }
347
+ function flush_file( j, last) {
348
+ if (out_file == "") return
349
+ emit_lines(HEAD[cur])
350
+ if (TRIM[cur] == "1") {
351
+ last = 0
352
+ for (j = 1; j <= body_n; j++) if (body[j] != "") last = j
353
+ if (last == 0) print fin_line("") > out_file
354
+ else for (j = 1; j <= last; j++) print fin_line(body[j]) > out_file
355
+ } else {
356
+ for (j = 1; j <= body_n; j++) print fin_line(body[j]) > out_file
357
+ }
358
+ emit_lines(TAIL[cur])
359
+ close(out_file)
360
+ out_file = ""
361
+ }
362
+ FNR == 1 {
363
+ flush_file()
364
+ cur = FILENAME; out_file = DST[cur]; SEENF[cur] = 1
365
+ body_n = 0; in_fm = 0; past_fm = 0
366
+ }
367
+ { sub(/\r$/, "") }
368
+ FNR == 1 && MODE[cur] == "strip" && $0 != "---" { past_fm = 1 }
369
+ /^---$/ {
370
+ if (!past_fm) { in_fm = !in_fm; if (!in_fm) past_fm = 1; next }
371
+ }
372
+ {
373
+ if (MODE[cur] == "fence_nofm") { if (!past_fm && in_fm) next }
374
+ else if (!past_fm) next
375
+ line = $0
376
+ if (ESC[cur] == "toml") {
377
+ line = is_repl(line, "\\", "\\\\")
378
+ line = is_repl(line, "\"\"\"", "\"\"\\\"")
379
+ }
380
+ body[++body_n] = line
381
+ }
382
+ END {
383
+ flush_file()
384
+ # An empty source never produces a record, so emit its header and
385
+ # tail here — the per-file code still wrote the wrapper.
386
+ for (i = 1; i < ARGC; i++) {
387
+ if (ARGV[i] in SEENF) continue
388
+ SEENF[ARGV[i]] = 1
389
+ cur = ARGV[i]; out_file = DST[cur]; body_n = 0
390
+ flush_file()
391
+ }
392
+ }
393
+ ' "${srcs[@]}"
394
+ }
395
+
155
396
  # Copy a markdown file with frontmatter, ensuring free-text string fields are
156
397
  # wrapped in double quotes. Used by adapters that feed strict-YAML consumers
157
398
  # (Codex CLI rejects unquoted colons / booleans). Idempotent — already-quoted
@@ -221,26 +462,146 @@ copy_md_with_quoted_frontmatter() {
221
462
  # untouched.
222
463
  # Usage: copy_skill_bundle "src/skill/dir" "dest/skill/dir"
223
464
  copy_skill_bundle() {
224
- local src_dir="${1%/}"
225
- local dest_dir="$2"
226
- mkdir -p "$dest_dir"
227
- cp -R "$src_dir/." "$dest_dir/"
228
- # A symlinked SKILL.md is left exactly as `cp -R` produced it — a symlink.
229
- # `[ -f ]` follows links, so quoting it would read the link's TARGET and
230
- # write that content into a real file, turning `skills/x/SKILL.md -> /etc/…`
231
- # into a copy of a host file inside the output. That is the leak the
232
- # symlink-preserving copy exists to prevent, so skip the rewrite and say so
233
- # (the same reason `find -type f` below never matches a symlink).
234
- if [ -L "$dest_dir/SKILL.md" ]; then
235
- echo " WARN: $(basename "$dest_dir")/SKILL.md is a symlink — emitted as-is (frontmatter not quoted, tokens not expanded)" >&2
236
- elif [ -f "$dest_dir/SKILL.md" ]; then
237
- copy_md_with_quoted_frontmatter "$dest_dir/SKILL.md" "$dest_dir/SKILL.md.tmp-q"
238
- mv "$dest_dir/SKILL.md.tmp-q" "$dest_dir/SKILL.md"
465
+ _skill_bundles_reset
466
+ _skill_bundle_stage "$1" "$2"
467
+ _skill_bundles_flush
468
+ }
469
+
470
+ # copy_skill_bundle_dirs <dest_root> <src_dir>... — batch form: ONE cp -R
471
+ # copies every source skill directory into <dest_root>/<skill-name>, then one
472
+ # awk pass quotes and finalizes every bundled markdown file across all
473
+ # bundles. A later source with the same skill name overwrites file-by-file in
474
+ # order, exactly like the sequential per-bundle copies did.
475
+ copy_skill_bundle_dirs() {
476
+ local dest_root="$1"
477
+ shift
478
+ [ "$#" -gt 0 ] || return 0
479
+ local src dest
480
+ local -a srcs=()
481
+ for src in "$@"; do
482
+ srcs+=("${src%/}")
483
+ done
484
+ mkdir -p "$dest_root"
485
+ cp -R "${srcs[@]}" "$dest_root/"
486
+ _skill_bundles_reset
487
+ for src in "${srcs[@]}"; do
488
+ dest="$dest_root/${src##*/}"
489
+ _skill_bundle_note "$dest"
490
+ done
491
+ _skill_bundles_flush
492
+ }
493
+
494
+ _skill_bundles_reset() {
495
+ _SB_QUOTE_LIST=""
496
+ _SB_SEEN=""
497
+ _SB_DESTS=()
498
+ }
499
+
500
+ _skill_bundle_stage() {
501
+ local src="${1%/}" dest="$2"
502
+ mkdir -p "$dest"
503
+ cp -R "$src/." "$dest/"
504
+ _skill_bundle_note "$dest"
505
+ }
506
+
507
+ # Record a staged bundle for the flush pass: mark its SKILL.md for
508
+ # frontmatter quoting and deduplicate the destination.
509
+ _skill_bundle_note() {
510
+ local dest="$1"
511
+ # A symlinked SKILL.md is left exactly as `cp -R` produced it — a
512
+ # symlink. `[ -f ]` follows links, so quoting it would read the link's
513
+ # TARGET and write that content into a real file, turning
514
+ # `skills/x/SKILL.md -> /etc/…` into a copy of a host file inside the
515
+ # output. That is the leak the symlink-preserving copy exists to
516
+ # prevent, so skip the rewrite and say so (the same reason
517
+ # `find -type f` in the flush never matches a symlink).
518
+ if [ -L "$dest/SKILL.md" ]; then
519
+ echo " WARN: ${dest##*/}/SKILL.md is a symlink — emitted as-is (frontmatter not quoted, tokens not expanded)" >&2
520
+ elif [ -f "$dest/SKILL.md" ]; then
521
+ _SB_QUOTE_LIST="$_SB_QUOTE_LIST$dest/SKILL.md"$'\n'
239
522
  fi
523
+ # The same skill name from a later source overwrites the earlier copy;
524
+ # keep one dest entry so the flush does not process the files twice.
525
+ case "${_SB_SEEN:-$'\n'}" in
526
+ *$'\n'"$dest"$'\n'*) ;;
527
+ *)
528
+ _SB_DESTS+=("$dest")
529
+ _SB_SEEN="${_SB_SEEN:-$'\n'}$dest"$'\n'
530
+ ;;
531
+ esac
532
+ }
533
+
534
+ _skill_bundles_flush() {
535
+ [ "${#_SB_DESTS[@]}" -gt 0 ] || return 0
536
+ local -a mds=()
537
+ local f quote_list="$_SB_QUOTE_LIST"
240
538
  while IFS= read -r f; do
241
- [ -n "$f" ] || continue
242
- finalize_output_file "$f"
243
- done < <(find "$dest_dir" -type f -name '*.md')
539
+ [ -n "$f" ] && mds+=("$f")
540
+ done < <(find "${_SB_DESTS[@]}" -type f -name '*.md')
541
+ _skill_bundles_reset
542
+ [ "${#mds[@]}" -gt 0 ] || return 0
543
+ # One awk: quote free-text frontmatter fields in each top-level SKILL.md
544
+ # (strict-YAML consumers reject unquoted colons; `argument-hint:
545
+ # [pr-number]` would otherwise arrive as a YAML flow sequence and the
546
+ # skill silently vanishes from the picker) and expand layout tokens in
547
+ # every bundled markdown file. Quoting is idempotent — already-quoted
548
+ # values pass through untouched. The quote list travels through the
549
+ # environment, like emit_wrapped_bodies specs.
550
+ is_fin_awk_vars
551
+ IS_QUOTE_LIST="$quote_list" awk "${IS_FIN_V[@]}" "$IS_AWK_LIB"'
552
+ BEGIN {
553
+ qn = split(ENVIRON["IS_QUOTE_LIST"], QL, "\n")
554
+ for (qi = 1; qi <= qn; qi++) if (QL[qi] != "") QUOTE[QL[qi]] = 1
555
+ }
556
+ function yamlq(s, out, i, c) {
557
+ out = ""
558
+ for (i = 1; i <= length(s); i++) {
559
+ c = substr(s, i, 1)
560
+ if (c == "\\") out = out "\\\\"
561
+ else if (c == "\"") out = out "\\\""
562
+ else out = out c
563
+ }
564
+ return out
565
+ }
566
+ function flush_file( i) {
567
+ if (out_file == "") return
568
+ for (i = 1; i <= line_n; i++) print buf[i] > out_file
569
+ close(out_file)
570
+ }
571
+ FNR == 1 {
572
+ flush_file()
573
+ out_file = FILENAME; line_n = 0
574
+ state = (FILENAME in QUOTE) ? "before" : ""
575
+ }
576
+ { sub(/\r$/, "") }
577
+ state == "before" {
578
+ if (FNR == 1 && $0 == "---") state = "in_fm"
579
+ else state = "after"
580
+ }
581
+ state == "in_fm" && FNR > 1 {
582
+ if ($0 == "---") state = "after"
583
+ else {
584
+ idx = index($0, ":")
585
+ if (idx > 0) {
586
+ key = substr($0, 1, idx - 1)
587
+ sub(/^[[:space:]]+/, "", key); sub(/[[:space:]]+$/, "", key)
588
+ if (key == "description" || key == "argument-hint") {
589
+ val = substr($0, idx + 1)
590
+ sub(/^[[:space:]]+/, "", val); sub(/[[:space:]]+$/, "", val)
591
+ if (val != "") {
592
+ first = substr(val, 1, 1)
593
+ last = substr(val, length(val), 1)
594
+ if (!((first == "\"" && last == "\"") || (first == "\047" && last == "\047"))) {
595
+ $0 = key ": \"" yamlq(val) "\""
596
+ }
597
+ }
598
+ }
599
+ }
600
+ }
601
+ }
602
+ { buf[++line_n] = fin_line($0) }
603
+ END { flush_file() }
604
+ ' "${mds[@]}"
244
605
  }
245
606
 
246
607
  # Copy skill directories into an Agent Skills open-standard location.
@@ -263,6 +624,15 @@ sync_open_skill_dirs() {
263
624
  local config_file="$2"
264
625
  local output_dir="$3"
265
626
 
627
+ # Several adapters share this destination in one run (Codex, Pi and
628
+ # opencode all feed .agents/skills/) and the contract requires them to
629
+ # write identical content, so the second and later calls replay the first
630
+ # call's output instead of pruning and re-copying every skill.
631
+ if [ "${IS_OPEN_SKILLS_DEST:-}" = "$output_dir" ] && [ "${IS_OPEN_SKILLS_CFG:-}" = "$config_file" ]; then
632
+ printf '%s' "$IS_OPEN_SKILLS_LOG"
633
+ return 0
634
+ fi
635
+
266
636
  if [ -d "$output_dir" ]; then
267
637
  # Prune both real subdirectories and symlinks (incl. dir-symlinks):
268
638
  # "-type d" alone would leave a stale symlinked skill in place and
@@ -271,42 +641,45 @@ sync_open_skill_dirs() {
271
641
  fi
272
642
  mkdir -p "$output_dir"
273
643
 
274
- local count=0
275
- while IFS= read -r src; do
276
- [ -z "$src" ] && continue
277
- local dir
278
- dir="$(resolve_source_dir "$repo_root" "$src")"
279
- [ -d "$dir" ] || continue
280
- for d in "$dir"/*/; do
281
- [ -d "$d" ] || continue
282
- local skill_name
283
- skill_name="$(basename "$d")"
284
- [ -f "$d/SKILL.md" ] || continue
285
- # copy_skill_bundle now owns the frontmatter-quoting pass, so every
286
- # target gets it — not just this open-standard dir.
287
- copy_skill_bundle "$d" "$output_dir/$skill_name"
288
- count=$((count + 1))
289
- echo " skill: $skill_name"
290
- done
291
- done < <(read_yaml_list "$config_file" "skills")
644
+ local count=0 log="" d skill_file skill_name
645
+ local -a skill_dirs=()
646
+ while IFS= read -r skill_file; do
647
+ [ -n "$skill_file" ] || continue
648
+ d="${skill_file%/SKILL.md}"
649
+ skill_name="${d##*/}"
650
+ skill_dirs+=("$d/")
651
+ count=$((count + 1))
652
+ log+=" skill: $skill_name"$'\n'
653
+ done < <(source_artifact_files "$repo_root" "$config_file" "skills")
654
+ # copy_skill_bundle_dirs owns the frontmatter-quoting pass, so every
655
+ # target gets it — not just this open-standard dir.
656
+ if [ "$count" -gt 0 ]; then
657
+ copy_skill_bundle_dirs "$output_dir" "${skill_dirs[@]}"
658
+ fi
292
659
 
293
- echo " -> Skills: $count"
660
+ log+=" -> Skills: $count"$'\n'
661
+ printf '%s' "$log"
662
+ IS_OPEN_SKILLS_DEST="$output_dir"
663
+ IS_OPEN_SKILLS_CFG="$config_file"
664
+ IS_OPEN_SKILLS_LOG="$log"
294
665
  }
295
666
 
296
667
  # Lint YAML frontmatter for common pitfalls (unquoted colons, leading tabs).
297
668
  # Print warnings to stderr; do not fail. Strict consumers (Codex CLI) reject
298
669
  # these files with cryptic messages — catching them in sync gives better DX.
299
- # Usage: lint_frontmatter "path/to/file.md"
300
- lint_frontmatter() {
301
- local file="$1"
302
- awk -v f="$file" '
303
- BEGIN { in_fm = 0; line = 0 }
304
- { sub(/\r$/, ""); line++ }
305
- line == 1 && $0 != "---" { exit }
306
- line == 1 { in_fm = 1; next }
307
- in_fm && $0 == "---" { exit }
670
+ # Batched: one awk process lints every file passed.
671
+ # Usage: lint_frontmatter_files "a.md" "b.md" ...
672
+ lint_frontmatter_files() {
673
+ [ "$#" -gt 0 ] || return 0
674
+ awk '
675
+ FNR == 1 { in_fm = 0; done = 0 }
676
+ { sub(/\r$/, "") }
677
+ done { next }
678
+ FNR == 1 && $0 != "---" { done = 1; next }
679
+ FNR == 1 { in_fm = 1; next }
680
+ in_fm && $0 == "---" { done = 1; next }
308
681
  in_fm && /^\t/ {
309
- printf " WARN: %s:%d leading tab in frontmatter (use spaces)\n", f, line > "/dev/stderr"
682
+ printf " WARN: %s:%d leading tab in frontmatter (use spaces)\n", FILENAME, FNR > "/dev/stderr"
310
683
  }
311
684
  in_fm && /^[a-zA-Z0-9_-]+:[[:space:]]+[^"\047|>[{]/ {
312
685
  value_start = index($0, ":") + 1
@@ -314,10 +687,10 @@ lint_frontmatter() {
314
687
  sub(/^[[:space:]]+/, "", value)
315
688
  if (value ~ /:[[:space:]]/ || value ~ /:$/) {
316
689
  col = index(value, ":") + value_start
317
- printf " WARN: %s:%d unquoted colon in value at column %d — wrap value in quotes\n", f, line, col > "/dev/stderr"
690
+ printf " WARN: %s:%d unquoted colon in value at column %d — wrap value in quotes\n", FILENAME, FNR, col > "/dev/stderr"
318
691
  }
319
692
  if (value ~ /"/) {
320
- printf " WARN: %s:%d literal double quote in unquoted value — wrap value in single quotes or escape as \\\" so strict-YAML targets accept it\n", f, line > "/dev/stderr"
693
+ printf " WARN: %s:%d literal double quote in unquoted value — wrap value in single quotes or escape as \\\" so strict-YAML targets accept it\n", FILENAME, FNR > "/dev/stderr"
321
694
  }
322
695
  }
323
696
  # Field-length limits. Both Claude Code and the Agent Skills standard
@@ -336,10 +709,15 @@ lint_frontmatter() {
336
709
  }
337
710
  limit = (key == "name") ? 64 : 1024
338
711
  if (length(val) > limit) {
339
- printf " WARN: %s:%d %s is %d chars — over the %d-char limit; the skill/agent will be REJECTED at load time\n", f, line, key, length(val), limit > "/dev/stderr"
712
+ printf " WARN: %s:%d %s is %d chars — over the %d-char limit; the skill/agent will be REJECTED at load time\n", FILENAME, FNR, key, length(val), limit > "/dev/stderr"
340
713
  }
341
714
  }
342
- ' "$file"
715
+ ' "$@"
716
+ }
717
+
718
+ # lint_frontmatter <file> — single-file form for project adapters.
719
+ lint_frontmatter() {
720
+ lint_frontmatter_files "$1"
343
721
  }
344
722
 
345
723
  # --- Frontmatter Parsing ---
@@ -518,6 +896,32 @@ get_model() {
518
896
  fi
519
897
  }
520
898
 
899
+ # load_model_tiers <config_file> <ide> — resolve the three standard tiers
900
+ # once per adapter run (IS_MODEL_HEAVY / IS_MODEL_STANDARD / IS_MODEL_LIGHT)
901
+ # so per-file loops map tier -> model without forking. resolve_model_var
902
+ # consumes them; a non-standard tier value still goes through get_model so a
903
+ # `models:` override for it keeps working.
904
+ load_model_tiers() {
905
+ local config_file="$1" ide="$2"
906
+ IS_MODEL_CFG="$config_file"
907
+ IS_MODEL_IDE="$ide"
908
+ IS_MODEL_HEAVY="$(get_model "$config_file" "$ide" "heavy")"
909
+ IS_MODEL_STANDARD="$(get_model "$config_file" "$ide" "standard")"
910
+ IS_MODEL_LIGHT="$(get_model "$config_file" "$ide" "light")"
911
+ }
912
+
913
+ # resolve_model_var <tier> — set IS_MODEL from the tiers load_model_tiers
914
+ # resolved. An empty tier resolves to heavy, like get_model's default.
915
+ # shellcheck disable=SC2034 # IS_MODEL is the return channel read by adapters
916
+ resolve_model_var() {
917
+ case "$1" in
918
+ heavy|"") IS_MODEL="$IS_MODEL_HEAVY" ;;
919
+ standard) IS_MODEL="$IS_MODEL_STANDARD" ;;
920
+ light) IS_MODEL="$IS_MODEL_LIGHT" ;;
921
+ *) IS_MODEL="$(get_model "$IS_MODEL_CFG" "$IS_MODEL_IDE" "$1")" ;;
922
+ esac
923
+ }
924
+
521
925
  # Print info message for each model override that differs from the
522
926
  # hardcoded default. Helps users notice when a script update brings new
523
927
  # defaults that their config still overrides with the old value.
@@ -568,11 +972,11 @@ report_model_drift() {
568
972
  fi
569
973
  }
570
974
 
571
- # Map access level to Claude tools string
572
- map_access_to_claude_tools() {
573
- local access="$1"
574
- case "$access" in
575
- readonly) echo "Read, Grep, Glob, Bash" ;;
975
+ # Map access level to Claude tools string (fork-free form: sets
976
+ # IS_CLAUDE_TOOLS; the printing form wraps it for compatibility)
977
+ map_access_to_claude_tools_var() {
978
+ case "$1" in
979
+ readonly) IS_CLAUDE_TOOLS="Read, Grep, Glob, Bash" ;;
576
980
  # full: emit NO tools list at all. Confirmed empirically in Copilot (VSCode,
577
981
  # reading .claude/agents): a closed tools list restricts the agent to exactly
578
982
  # those tools and loses MCP; omitting the field lets it inherit every session
@@ -583,19 +987,28 @@ map_access_to_claude_tools() {
583
987
  # intermittently hides MCP. The durable fix is an explicit allowlist that
584
988
  # NAMES the MCP servers (umbraco-mcp/*, figma/*) and stays under 128; that
585
989
  # needs the project's MCP server list, so it is tracked, not encoded here yet.
586
- *) echo "" ;;
990
+ *) IS_CLAUDE_TOOLS="" ;;
587
991
  esac
588
992
  }
589
993
 
590
994
  # Map access level to Claude disallowedTools (empty if full access)
591
- map_access_to_claude_disallowed() {
592
- local access="$1"
593
- case "$access" in
594
- readonly) echo "Write, Edit" ;;
595
- *) echo "" ;;
995
+ map_access_to_claude_disallowed_var() {
996
+ case "$1" in
997
+ readonly) IS_CLAUDE_DISALLOWED="Write, Edit" ;;
998
+ *) IS_CLAUDE_DISALLOWED="" ;;
596
999
  esac
597
1000
  }
598
1001
 
1002
+ map_access_to_claude_tools() {
1003
+ map_access_to_claude_tools_var "$1"
1004
+ echo "$IS_CLAUDE_TOOLS"
1005
+ }
1006
+
1007
+ map_access_to_claude_disallowed() {
1008
+ map_access_to_claude_disallowed_var "$1"
1009
+ echo "$IS_CLAUDE_DISALLOWED"
1010
+ }
1011
+
599
1012
  # --- Validation ---
600
1013
 
601
1014
  # Lexically canonicalize a path: collapse `//`, `.` and `..` by pure string
@@ -605,6 +1018,13 @@ map_access_to_claude_disallowed() {
605
1018
  # exist.
606
1019
  # Usage: canon="$(normalize_path "/repo/a/../b")" # -> /repo/b
607
1020
  normalize_path() {
1021
+ normalize_path_var "$1"
1022
+ printf '%s' "$IS_NORM_PATH"
1023
+ }
1024
+
1025
+ # normalize_path_var <path> — fork-free form: sets IS_NORM_PATH instead of
1026
+ # printing, so hot validation loops avoid a command-substitution subshell.
1027
+ normalize_path_var() {
608
1028
  local path="$1" p out=""
609
1029
  local -a parts
610
1030
  IFS='/' read -r -a parts <<< "$path"
@@ -615,7 +1035,7 @@ normalize_path() {
615
1035
  *) out="$out/$p" ;;
616
1036
  esac
617
1037
  done
618
- printf '%s' "${out:-/}"
1038
+ IS_NORM_PATH="${out:-/}"
619
1039
  }
620
1040
 
621
1041
  # Refuse to operate on output paths that could clobber content.
@@ -639,10 +1059,20 @@ validate_output_path() {
639
1059
  local adapter="$3"
640
1060
  local output_dir="$4"
641
1061
 
1062
+ # A full sync validates the same path several times (preflight, snapshot,
1063
+ # the adapter run itself, and shared paths like `.agents/skills` once per
1064
+ # adapter that manages them). Success depends only on the inputs below —
1065
+ # failures exit and are never memoized — so repeats return immediately.
1066
+ local memo_key="$repo_root|$config_file|$output_dir"
1067
+ case "${IS_VOP_MEMO:-$'\n'}" in
1068
+ *$'\n'"$memo_key"$'\n'*) return 0 ;;
1069
+ esac
1070
+
642
1071
  # Canonicalize FIRST. Every check below is a string comparison, so a `../`
643
1072
  # left in the raw value would walk straight past all of them.
644
1073
  local canon
645
- canon="$(normalize_path "$output_dir")"
1074
+ normalize_path_var "$output_dir"
1075
+ canon="$IS_NORM_PATH"
646
1076
 
647
1077
  case "$canon" in
648
1078
  ""|"/"|"$repo_root")
@@ -671,7 +1101,8 @@ validate_output_path() {
671
1101
  # than stepped over.
672
1102
  local probe="$canon" parent
673
1103
  while [ ! -e "$probe" ] && [ ! -L "$probe" ]; do
674
- parent="$(dirname "$probe")"
1104
+ parent="${probe%/*}"
1105
+ [ -n "$parent" ] || parent="/"
675
1106
  [ "$parent" = "$probe" ] && break
676
1107
  probe="$parent"
677
1108
  done
@@ -689,9 +1120,15 @@ validate_output_path() {
689
1120
  # inside the repo (`pwd -P` on both sides so a symlinked repo root
690
1121
  # resolves consistently).
691
1122
  local probe_dir phys repo_phys
692
- if [ -d "$probe" ]; then probe_dir="$probe"; else probe_dir="$(dirname "$probe")"; fi
1123
+ if [ -d "$probe" ]; then probe_dir="$probe"; else probe_dir="${probe%/*}"; [ -n "$probe_dir" ] || probe_dir="/"; fi
693
1124
  phys="$(cd "$probe_dir" 2>/dev/null && pwd -P)" || phys=""
694
- repo_phys="$(cd "$repo_root" && pwd -P)"
1125
+ if [ "${IS_VOP_REPO_PHYS_ROOT:-}" = "$repo_root" ]; then
1126
+ repo_phys="$IS_VOP_REPO_PHYS"
1127
+ else
1128
+ repo_phys="$(cd "$repo_root" && pwd -P)"
1129
+ IS_VOP_REPO_PHYS_ROOT="$repo_root"
1130
+ IS_VOP_REPO_PHYS="$repo_phys"
1131
+ fi
695
1132
  case "${phys:-/nonexistent}" in
696
1133
  "$repo_phys"|"$repo_phys"/*) ;;
697
1134
  *)
@@ -707,7 +1144,13 @@ validate_output_path() {
707
1144
  # Reject the intelligence source directory itself (parent of config.yaml).
708
1145
  # Folder name is whatever the user chose — we read it from the filesystem.
709
1146
  local intel_dir intel_rel
710
- intel_dir="$(cd "$(dirname "$config_file")" && pwd)"
1147
+ if [ "${IS_VOP_INTEL_KEY:-}" = "$config_file" ]; then
1148
+ intel_dir="$IS_VOP_INTEL_DIR"
1149
+ else
1150
+ intel_dir="$(cd "$(dirname "$config_file")" && pwd)"
1151
+ IS_VOP_INTEL_KEY="$config_file"
1152
+ IS_VOP_INTEL_DIR="$intel_dir"
1153
+ fi
711
1154
  intel_rel="${intel_dir#"$repo_root"/}"
712
1155
  if [ -n "$intel_rel" ] && [ "$intel_rel" != "$intel_dir" ]; then
713
1156
  case "$rel" in
@@ -740,12 +1183,14 @@ validate_output_path() {
740
1183
  fi
741
1184
 
742
1185
  # Reject any configured source directory (rules, agents, skills).
743
- local section src src_rel
1186
+ local section src src_rel src_list
744
1187
  for section in rules agents skills; do
1188
+ load_yaml_list "$config_file" "$section"
1189
+ src_list="$IS_YAML_LIST"
745
1190
  while IFS= read -r src; do
746
1191
  [ -z "$src" ] && continue
747
- src_rel="$(normalize_path "$repo_root/$src")"
748
- src_rel="${src_rel#"$repo_root"/}"
1192
+ normalize_path_var "$repo_root/$src"
1193
+ src_rel="${IS_NORM_PATH#"$repo_root"/}"
749
1194
  case "$rel" in
750
1195
  "$src_rel"|"$src_rel"/*)
751
1196
  echo "ERROR: targets.$adapter.output ('$rel') overlaps a configured source ('$src')." >&2
@@ -753,8 +1198,10 @@ validate_output_path() {
753
1198
  exit 1
754
1199
  ;;
755
1200
  esac
756
- done < <(read_yaml_list "$config_file" "$section")
1201
+ done <<< "$src_list"
757
1202
  done
1203
+
1204
+ IS_VOP_MEMO="${IS_VOP_MEMO:-$'\n'}$memo_key"$'\n'
758
1205
  }
759
1206
 
760
1207
  # Warn about prompt directories not listed in sources.
@@ -768,35 +1215,43 @@ warn_unsynced() {
768
1215
  local config_file="$2"
769
1216
 
770
1217
  local all_sources=()
1218
+ local src ign section
771
1219
  for section in rules agents skills; do
1220
+ load_yaml_list "$config_file" "$section"
772
1221
  while IFS= read -r src; do
773
1222
  [ -z "$src" ] && continue
774
1223
  all_sources+=("$src")
775
- done < <(read_yaml_list "$config_file" "$section")
1224
+ done <<< "$IS_YAML_LIST"
776
1225
  done
777
1226
 
778
1227
  # Collect ignore + submodule patterns.
779
1228
  local ignores=()
780
- while IFS= read -r ign; do
781
- [ -z "$ign" ] && continue
782
- ignores+=("$ign")
783
- done < <(read_yaml_list "$config_file" "ignore")
784
- while IFS= read -r sub; do
785
- [ -z "$sub" ] && continue
786
- ignores+=("$sub")
787
- done < <(read_yaml_list "$config_file" "submodules")
1229
+ for section in ignore submodules; do
1230
+ load_yaml_list "$config_file" "$section"
1231
+ while IFS= read -r ign; do
1232
+ [ -z "$ign" ] && continue
1233
+ ignores+=("$ign")
1234
+ done <<< "$IS_YAML_LIST"
1235
+ done
788
1236
 
789
1237
  # The manifest sits at the repo root, so the content dir cannot be derived
790
1238
  # from its location — it comes from the env contract the CLI exports.
791
1239
  local intel_basename
792
1240
  if [ "${IS_CLI:-0}" = "1" ]; then
793
- intel_basename="$(basename "${IS_CONTENT_REL:-intelligence}")"
1241
+ intel_basename="${IS_CONTENT_REL:-intelligence}"
1242
+ intel_basename="${intel_basename##*/}"
794
1243
  else
795
- intel_basename="$(basename "$(dirname "$config_file")")"
1244
+ intel_basename="$(dirname "$config_file")"
1245
+ intel_basename="${intel_basename##*/}"
796
1246
  fi
797
1247
 
798
1248
  local warnings=0
799
1249
 
1250
+ # Prune instead of post-filtering: the old scan walked every directory in
1251
+ # the repository — .git object stores and node_modules trees included —
1252
+ # and then discarded the hits, which alone took minutes in a large
1253
+ # monorepo. Results under these names were never actionable: generated
1254
+ # tool outputs, the package store, dependency and build trees.
800
1255
  while IFS= read -r found_dir; do
801
1256
  local rel_dir="${found_dir#$repo_root/}"
802
1257
 
@@ -830,13 +1285,22 @@ warn_unsynced() {
830
1285
  *) continue ;;
831
1286
  esac
832
1287
 
833
- # Check if directory has content worth syncing.
834
- local has_content=false
835
- if [ -n "$(find "$found_dir" -maxdepth 1 -name '*.md' 2>/dev/null | head -1)" ]; then
1288
+ # Check if directory has content worth syncing (globs, no subprocess:
1289
+ # any *.md directly inside, or a SKILL.md at depth one or two —
1290
+ # `-e` because the old `find -name` matched any entry type).
1291
+ local has_content=false f
1292
+ for f in "$found_dir"/*.md; do
1293
+ [ -e "$f" ] && has_content=true
1294
+ break
1295
+ done
1296
+ if [ "$has_content" = false ] && [ -e "$found_dir/SKILL.md" ]; then
836
1297
  has_content=true
837
1298
  fi
838
- if [ -n "$(find "$found_dir" -maxdepth 2 -name 'SKILL.md' 2>/dev/null | head -1)" ]; then
839
- has_content=true
1299
+ if [ "$has_content" = false ]; then
1300
+ for f in "$found_dir"/*/SKILL.md; do
1301
+ [ -e "$f" ] && has_content=true
1302
+ break
1303
+ done
840
1304
  fi
841
1305
  [ "$has_content" = false ] && continue
842
1306
 
@@ -857,11 +1321,114 @@ warn_unsynced() {
857
1321
  echo " NOT SYNCED: $rel_dir"
858
1322
  warnings=$((warnings + 1))
859
1323
  fi
860
- done < <(find "$repo_root" -type d \( -name "rules" -o -name "agents" -o -name "skills" -o -name "Rules" -o -name "Agents" -o -name "Skills" \) 2>/dev/null)
1324
+ 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)
861
1325
 
862
1326
  if [ $warnings -gt 0 ]; then
863
- echo " Add these paths to sources: in $(basename "$config_file")"
1327
+ echo " Add these paths to sources: in ${config_file##*/}"
1328
+ fi
1329
+ }
1330
+
1331
+ # Enumerate one manifest source kind using the same depth and ordering
1332
+ # everywhere. Source-list order is significant; entries inside each directory
1333
+ # use byte-order sorting for cross-platform determinism.
1334
+ source_artifact_files() {
1335
+ local repo_root="$1"
1336
+ local config_file="$2"
1337
+ local section="$3"
1338
+ local src f dir
1339
+
1340
+ load_yaml_list "$config_file" "$section"
1341
+ while IFS= read -r src; do
1342
+ [ -n "$src" ] || continue
1343
+ dir="$repo_root/$src"
1344
+ [ -d "$dir" ] || continue
1345
+ case "$section" in
1346
+ rules|agents)
1347
+ find "$dir" -maxdepth 1 -type f -name '*.md' -print | LC_ALL=C sort
1348
+ ;;
1349
+ skills)
1350
+ while IFS= read -r f; do
1351
+ [ -n "$f" ] || continue
1352
+ [ -f "$f/SKILL.md" ] && printf '%s\n' "$f/SKILL.md"
1353
+ done < <(find "$dir" -mindepth 1 -maxdepth 1 -type d -print | LC_ALL=C sort)
1354
+ ;;
1355
+ esac
1356
+ done <<< "$IS_YAML_LIST"
1357
+ }
1358
+
1359
+ # Apply the agents adapter's lexical output rule without consulting filesystem
1360
+ # state. An omitted output uses the generic adapter default directory.
1361
+ agents_output_path() {
1362
+ local output="${1:-.agents}"
1363
+ if [[ "$output" == */ ]] || [[ "$output" != *.md ]]; then
1364
+ output="${output%/}/AGENTS.md"
864
1365
  fi
1366
+ printf '%s\n' "$output"
1367
+ }
1368
+
1369
+ # Report source prompt pressure independently of adapter formats. "Always-on"
1370
+ # means rules without paths; "custom" means scoped rules, agent prompts and
1371
+ # skill entry points. Supporting skill assets are excluded because tools load
1372
+ # them only when a skill explicitly reads them. When the shared agents target
1373
+ # exists, also report its rendered size; policy for any tool-specific limit
1374
+ # stays in that tool's adapter.
1375
+ context_files_bytes() {
1376
+ if [ "$#" -eq 0 ]; then
1377
+ echo 0
1378
+ return 0
1379
+ fi
1380
+ LC_ALL=C wc -c "$@" | awk 'END { print $1 + 0 }'
1381
+ }
1382
+
1383
+ report_context_source_sizes() {
1384
+ local repo_root="$1"
1385
+ local config_file="$2"
1386
+ local f path has_paths
1387
+ local -a rule_files=() always_on_rules=() scoped_rules=() agent_files=() skill_files=()
1388
+
1389
+ while IFS= read -r f; do
1390
+ [ -n "$f" ] && rule_files+=("$f")
1391
+ done < <(source_artifact_files "$repo_root" "$config_file" "rules")
1392
+ if [ "${#rule_files[@]}" -gt 0 ]; then
1393
+ while IFS=$'\x1f' read -r path has_paths; do
1394
+ [ -n "$path" ] || continue
1395
+ if [ "$has_paths" = "0" ]; then
1396
+ always_on_rules+=("$path")
1397
+ else
1398
+ scoped_rules+=("$path")
1399
+ fi
1400
+ done < <(frontmatter_index "paths#" "${rule_files[@]}")
1401
+ fi
1402
+
1403
+ while IFS= read -r f; do
1404
+ [ -n "$f" ] && agent_files+=("$f")
1405
+ done < <(source_artifact_files "$repo_root" "$config_file" "agents")
1406
+
1407
+ while IFS= read -r f; do
1408
+ [ -n "$f" ] && skill_files+=("$f")
1409
+ done < <(source_artifact_files "$repo_root" "$config_file" "skills")
1410
+
1411
+ local always_bytes custom_bytes agents_output agents_bytes=0 agents_status="disabled"
1412
+ always_bytes="$(context_files_bytes "${always_on_rules[@]+"${always_on_rules[@]}"}")"
1413
+ custom_bytes="$(context_files_bytes \
1414
+ "${scoped_rules[@]+"${scoped_rules[@]}"}" \
1415
+ "${agent_files[@]+"${agent_files[@]}"}" \
1416
+ "${skill_files[@]+"${skill_files[@]}"}")"
1417
+ target_enabled_var "$config_file" "agents"
1418
+ if [ "$IS_TGT_ENABLED" = "1" ]; then
1419
+ agents_status="not-generated"
1420
+ target_output_var "$config_file" "agents"
1421
+ agents_output="$(agents_output_path "${IS_TGT_OUTPUT:-.agents}")"
1422
+ if [ -f "$repo_root/$agents_output" ]; then
1423
+ agents_bytes="$(context_files_bytes "$repo_root/$agents_output")"
1424
+ agents_status="generated"
1425
+ fi
1426
+ fi
1427
+
1428
+ printf 'CONTEXT: always-on=%s bytes (%s rules); custom=%s bytes (%s scoped rules, %s agents, %s skills); agents-md=%s bytes; agents-md-status=%s\n' \
1429
+ "$always_bytes" "${#always_on_rules[@]}" "$custom_bytes" \
1430
+ "${#scoped_rules[@]}" "${#agent_files[@]}" "${#skill_files[@]}" \
1431
+ "$agents_bytes" "$agents_status"
865
1432
  }
866
1433
 
867
1434
  # --- Config Parsing ---
@@ -869,9 +1436,29 @@ warn_unsynced() {
869
1436
  # Read a simple list from config.yaml
870
1437
  # Format: key:\n - "value1"\n - "value2"
871
1438
  # Usage: readarray -t arr < <(read_yaml_list "config.yaml" "rules")
1439
+ #
1440
+ # Consults the load_yaml_list cache first: sync reads the same sections from
1441
+ # the same manifest dozens of times, and each awk spawn costs tens of
1442
+ # milliseconds on Windows. The cache is only ever populated by
1443
+ # load_yaml_list, which the engine calls for a manifest it never mutates, so
1444
+ # a CLI process that edits the manifest keeps reading the file directly.
872
1445
  read_yaml_list() {
873
1446
  local file="$1"
874
1447
  local section="$2"
1448
+ case "$section" in
1449
+ rules|agents|skills|ignore|submodules)
1450
+ # Indirect expansion, not eval: the section name is allowlisted
1451
+ # above, but keeping manifest-derived values out of any evaluated
1452
+ # string is the safer shape.
1453
+ local file_var="IS_YL_${section}_FILE" val_var="IS_YL_${section}_VAL"
1454
+ local cached_file="${!file_var:-}" cached_val
1455
+ if [ -n "$cached_file" ] && [ "$cached_file" = "$file" ]; then
1456
+ cached_val="${!val_var:-}"
1457
+ [ -n "$cached_val" ] && printf '%s\n' "$cached_val"
1458
+ return 0
1459
+ fi
1460
+ ;;
1461
+ esac
875
1462
  awk -v section="$section" '
876
1463
  {
877
1464
  sub(/\r$/, "")
@@ -895,16 +1482,148 @@ read_yaml_list() {
895
1482
  ' "$file"
896
1483
  }
897
1484
 
1485
+ # load_yaml_list <file> <section> — fill the global IS_YAML_LIST with the
1486
+ # section's entries (newline-separated) and cache the result for
1487
+ # read_yaml_list and later load_yaml_list calls. Lets in-process loops
1488
+ # iterate a section without forking; the engine warms the cache once per run.
1489
+ # Only sections whose names are identifier-safe are cached.
1490
+ load_yaml_list() {
1491
+ local file="$1" section="$2"
1492
+ case "$section" in
1493
+ rules|agents|skills|ignore|submodules)
1494
+ # Indirect reads and printf -v writes, not eval — see the note in
1495
+ # read_yaml_list.
1496
+ local file_var="IS_YL_${section}_FILE" val_var="IS_YL_${section}_VAL"
1497
+ local cached_file="${!file_var:-}"
1498
+ if [ -n "$cached_file" ] && [ "$cached_file" = "$file" ]; then
1499
+ IS_YAML_LIST="${!val_var:-}"
1500
+ return 0
1501
+ fi
1502
+ IS_YAML_LIST="$(read_yaml_list "$file" "$section")"
1503
+ printf -v "$file_var" '%s' "$file"
1504
+ printf -v "$val_var" '%s' "$IS_YAML_LIST"
1505
+ ;;
1506
+ *)
1507
+ IS_YAML_LIST="$(read_yaml_list "$file" "$section")"
1508
+ ;;
1509
+ esac
1510
+ }
1511
+
1512
+ # load_targets_cache <file> — parse the whole targets: section once into the
1513
+ # global IS_TGT_TSV (one `name<TAB>enabled<TAB>output` row per target),
1514
+ # matching is_target_enabled and get_target_output semantics: inline and block
1515
+ # forms, first occurrence wins, `enabled` defaults to 0 when the block ends
1516
+ # without one and to empty at end of file. The engine warms this once;
1517
+ # both readers consult it before spawning awk.
1518
+ load_targets_cache() {
1519
+ local file="$1"
1520
+ if [ "${IS_TGT_FILE:-}" = "$file" ]; then
1521
+ return 0
1522
+ fi
1523
+ IS_TGT_TSV="$(awk '
1524
+ function flush_target(at_sibling) {
1525
+ if (name == "") return
1526
+ if (enabled == "" && at_sibling) enabled = 0
1527
+ printf "%s\t%s\t%s\n", name, enabled, output
1528
+ name = ""
1529
+ }
1530
+ { sub(/\r$/, "") }
1531
+ /^targets:[[:space:]]*$/ { in_targets = 1; next }
1532
+ /^[a-zA-Z]/ {
1533
+ if (in_targets) flush_target(0)
1534
+ in_targets = 0
1535
+ }
1536
+ in_targets && $0 ~ /^ [a-zA-Z0-9_-]+:/ {
1537
+ flush_target(1)
1538
+ line = $0
1539
+ name = substr(line, 3)
1540
+ sub(/:.*$/, "", name)
1541
+ enabled = ""; output = ""
1542
+ if (line ~ /enabled:[[:space:]]*true/) enabled = 1
1543
+ else if (line ~ /enabled:[[:space:]]*false/) enabled = 0
1544
+ if (match(line, /output:[[:space:]]*/)) {
1545
+ rest = substr(line, RSTART + RLENGTH)
1546
+ if (match(rest, /[,}]/)) rest = substr(rest, 1, RSTART - 1)
1547
+ gsub(/^["\047]|["\047][[:space:]]*$/, "", rest)
1548
+ sub(/[[:space:]]+$/, "", rest)
1549
+ output = rest
1550
+ }
1551
+ next
1552
+ }
1553
+ name != "" && enabled == "" && /^ enabled:/ {
1554
+ enabled = ($0 ~ /true/) ? 1 : 0
1555
+ }
1556
+ name != "" && output == "" && /^ output:/ {
1557
+ val = $0
1558
+ sub(/.*output:[[:space:]]*["\047]?/, "", val)
1559
+ sub(/["\047]?[[:space:]]*}?$/, "", val)
1560
+ output = val
1561
+ }
1562
+ END { flush_target(0) }
1563
+ ' "$file")"
1564
+ IS_TGT_FILE="$file"
1565
+ }
1566
+
1567
+ # _targets_cache_row <target> — scan the cached TSV; sets IS_TGT_ROW_ENABLED /
1568
+ # IS_TGT_ROW_OUTPUT. Returns 1 when the target has no row (reader falls back
1569
+ # to empty, same as the awk scan finding nothing).
1570
+ _targets_cache_row() {
1571
+ local target="$1" n e o
1572
+ IS_TGT_ROW_ENABLED=""
1573
+ IS_TGT_ROW_OUTPUT=""
1574
+ while IFS=$'\t' read -r n e o; do
1575
+ if [ "$n" = "$target" ]; then
1576
+ IS_TGT_ROW_ENABLED="$e"
1577
+ IS_TGT_ROW_OUTPUT="$o"
1578
+ return 0
1579
+ fi
1580
+ done <<< "$IS_TGT_TSV"
1581
+ return 1
1582
+ }
1583
+
1584
+ # target_enabled_var <file> <target> — fork-free is_target_enabled: sets
1585
+ # IS_TGT_ENABLED instead of printing. Falls back to the awk reader when the
1586
+ # cache does not cover the file.
1587
+ # shellcheck disable=SC2034 # IS_TGT_ENABLED is the return channel read by sync.sh
1588
+ target_enabled_var() {
1589
+ local file="$1" target="$2"
1590
+ if [ "${IS_TGT_FILE:-}" = "$file" ]; then
1591
+ _targets_cache_row "$target" || true
1592
+ IS_TGT_ENABLED="$IS_TGT_ROW_ENABLED"
1593
+ else
1594
+ IS_TGT_ENABLED="$(is_target_enabled "$file" "$target")"
1595
+ fi
1596
+ }
1597
+
1598
+ # target_output_var <file> <target> — fork-free get_target_output: sets
1599
+ # IS_TGT_OUTPUT instead of printing.
1600
+ # shellcheck disable=SC2034 # IS_TGT_OUTPUT is the return channel read by sync.sh
1601
+ target_output_var() {
1602
+ local file="$1" target="$2"
1603
+ if [ "${IS_TGT_FILE:-}" = "$file" ]; then
1604
+ _targets_cache_row "$target" || true
1605
+ IS_TGT_OUTPUT="$IS_TGT_ROW_OUTPUT"
1606
+ else
1607
+ IS_TGT_OUTPUT="$(get_target_output "$file" "$target")"
1608
+ fi
1609
+ }
1610
+
898
1611
  # Check if a target is enabled in config.yaml (scoped to targets: section)
899
1612
  # Usage: is_target_enabled "config.yaml" "claude"
900
1613
  is_target_enabled() {
901
1614
  local file="$1"
902
1615
  local target="$2"
1616
+ if [ "${IS_TGT_FILE:-}" = "$file" ]; then
1617
+ _targets_cache_row "$target" || true
1618
+ [ -n "$IS_TGT_ROW_ENABLED" ] && printf '%s\n' "$IS_TGT_ROW_ENABLED"
1619
+ return 0
1620
+ fi
903
1621
  awk -v target="$target" '
904
1622
  { sub(/\r$/, "") }
905
1623
 
906
1624
  # Enter/leave the targets: section
907
1625
  /^targets:[[:space:]]*$/ { in_targets = 1; next }
1626
+ in_target && /^[a-zA-Z]/ { exit }
908
1627
  /^[a-zA-Z]/ { in_targets = 0 }
909
1628
 
910
1629
  in_targets && $0 ~ "^ " target ":" {
@@ -912,11 +1631,11 @@ is_target_enabled() {
912
1631
  if ($0 ~ /enabled:[[:space:]]*false/) { print 0; exit }
913
1632
  in_target = 1; next
914
1633
  }
915
- in_target && /enabled:/ {
1634
+ in_target && /^ [a-zA-Z0-9_-]+:/ { print 0; exit }
1635
+ in_target && /^ enabled:/ {
916
1636
  if ($0 ~ /true/) { print 1 } else { print 0 }
917
1637
  exit
918
1638
  }
919
- in_target && /^ [a-zA-Z]/ { print 0; exit }
920
1639
  ' "$file"
921
1640
  }
922
1641
 
@@ -931,6 +1650,7 @@ get_target_field() {
931
1650
  awk -v target="$target" -v field="$field" '
932
1651
  { sub(/\r$/, "") }
933
1652
  /^targets:[[:space:]]*$/ { in_targets = 1; next }
1653
+ in_target && /^[a-zA-Z]/ { exit }
934
1654
  /^[a-zA-Z]/ { in_targets = 0 }
935
1655
 
936
1656
  in_targets && $0 ~ "^ " target ":" {
@@ -967,10 +1687,16 @@ get_target_field() {
967
1687
  get_target_output() {
968
1688
  local file="$1"
969
1689
  local target="$2"
1690
+ if [ "${IS_TGT_FILE:-}" = "$file" ]; then
1691
+ _targets_cache_row "$target" || true
1692
+ [ -n "$IS_TGT_ROW_OUTPUT" ] && printf '%s\n' "$IS_TGT_ROW_OUTPUT"
1693
+ return 0
1694
+ fi
970
1695
  awk -v target="$target" '
971
1696
  { sub(/\r$/, "") }
972
1697
 
973
1698
  /^targets:[[:space:]]*$/ { in_targets = 1; next }
1699
+ in_target && /^[a-zA-Z]/ { exit }
974
1700
  /^[a-zA-Z]/ { in_targets = 0 }
975
1701
 
976
1702
  in_targets && $0 ~ "^ " target ":" {
@@ -986,14 +1712,14 @@ get_target_output() {
986
1712
  }
987
1713
  in_target = 1; next
988
1714
  }
989
- in_target && /output:/ {
1715
+ in_target && /^ [a-zA-Z0-9_-]+:/ { exit }
1716
+ in_target && /^ output:/ {
990
1717
  val = $0
991
1718
  sub(/.*output:[[:space:]]*["\047]?/, "", val)
992
1719
  sub(/["\047]?[[:space:]]*}?$/, "", val)
993
1720
  print val
994
1721
  exit
995
1722
  }
996
- in_target && /^ [a-zA-Z]/ { exit }
997
1723
  ' "$file"
998
1724
  }
999
1725