@ainova-systems/intelligence 0.11.3 → 0.11.4

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)
54
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
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,52 @@ sync_open_skill_dirs() {
271
641
  fi
272
642
  mkdir -p "$output_dir"
273
643
 
274
- local count=0
644
+ local count=0 log="" d skill_name src list
645
+ local -a skill_dirs=()
646
+ load_yaml_list "$config_file" "skills"
647
+ list="$IS_YAML_LIST"
275
648
  while IFS= read -r src; do
276
649
  [ -z "$src" ] && continue
277
- local dir
278
- dir="$(resolve_source_dir "$repo_root" "$src")"
650
+ local dir="$repo_root/$src"
279
651
  [ -d "$dir" ] || continue
280
652
  for d in "$dir"/*/; do
281
653
  [ -d "$d" ] || continue
282
- local skill_name
283
- skill_name="$(basename "$d")"
654
+ skill_name="${d%/}"; skill_name="${skill_name##*/}"
284
655
  [ -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"
656
+ skill_dirs+=("$d")
288
657
  count=$((count + 1))
289
- echo " skill: $skill_name"
658
+ log+=" skill: $skill_name"$'\n'
290
659
  done
291
- done < <(read_yaml_list "$config_file" "skills")
660
+ done <<< "$list"
661
+ # copy_skill_bundle_dirs owns the frontmatter-quoting pass, so every
662
+ # target gets it — not just this open-standard dir.
663
+ if [ "$count" -gt 0 ]; then
664
+ copy_skill_bundle_dirs "$output_dir" "${skill_dirs[@]}"
665
+ fi
292
666
 
293
- echo " -> Skills: $count"
667
+ log+=" -> Skills: $count"$'\n'
668
+ printf '%s' "$log"
669
+ IS_OPEN_SKILLS_DEST="$output_dir"
670
+ IS_OPEN_SKILLS_CFG="$config_file"
671
+ IS_OPEN_SKILLS_LOG="$log"
294
672
  }
295
673
 
296
674
  # Lint YAML frontmatter for common pitfalls (unquoted colons, leading tabs).
297
675
  # Print warnings to stderr; do not fail. Strict consumers (Codex CLI) reject
298
676
  # 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 }
677
+ # Batched: one awk process lints every file passed.
678
+ # Usage: lint_frontmatter_files "a.md" "b.md" ...
679
+ lint_frontmatter_files() {
680
+ [ "$#" -gt 0 ] || return 0
681
+ awk '
682
+ FNR == 1 { in_fm = 0; done = 0 }
683
+ { sub(/\r$/, "") }
684
+ done { next }
685
+ FNR == 1 && $0 != "---" { done = 1; next }
686
+ FNR == 1 { in_fm = 1; next }
687
+ in_fm && $0 == "---" { done = 1; next }
308
688
  in_fm && /^\t/ {
309
- printf " WARN: %s:%d leading tab in frontmatter (use spaces)\n", f, line > "/dev/stderr"
689
+ printf " WARN: %s:%d leading tab in frontmatter (use spaces)\n", FILENAME, FNR > "/dev/stderr"
310
690
  }
311
691
  in_fm && /^[a-zA-Z0-9_-]+:[[:space:]]+[^"\047|>[{]/ {
312
692
  value_start = index($0, ":") + 1
@@ -314,10 +694,10 @@ lint_frontmatter() {
314
694
  sub(/^[[:space:]]+/, "", value)
315
695
  if (value ~ /:[[:space:]]/ || value ~ /:$/) {
316
696
  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"
697
+ printf " WARN: %s:%d unquoted colon in value at column %d — wrap value in quotes\n", FILENAME, FNR, col > "/dev/stderr"
318
698
  }
319
699
  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"
700
+ 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
701
  }
322
702
  }
323
703
  # Field-length limits. Both Claude Code and the Agent Skills standard
@@ -336,10 +716,15 @@ lint_frontmatter() {
336
716
  }
337
717
  limit = (key == "name") ? 64 : 1024
338
718
  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"
719
+ 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
720
  }
341
721
  }
342
- ' "$file"
722
+ ' "$@"
723
+ }
724
+
725
+ # lint_frontmatter <file> — single-file form for project adapters.
726
+ lint_frontmatter() {
727
+ lint_frontmatter_files "$1"
343
728
  }
344
729
 
345
730
  # --- Frontmatter Parsing ---
@@ -518,6 +903,32 @@ get_model() {
518
903
  fi
519
904
  }
520
905
 
906
+ # load_model_tiers <config_file> <ide> — resolve the three standard tiers
907
+ # once per adapter run (IS_MODEL_HEAVY / IS_MODEL_STANDARD / IS_MODEL_LIGHT)
908
+ # so per-file loops map tier -> model without forking. resolve_model_var
909
+ # consumes them; a non-standard tier value still goes through get_model so a
910
+ # `models:` override for it keeps working.
911
+ load_model_tiers() {
912
+ local config_file="$1" ide="$2"
913
+ IS_MODEL_CFG="$config_file"
914
+ IS_MODEL_IDE="$ide"
915
+ IS_MODEL_HEAVY="$(get_model "$config_file" "$ide" "heavy")"
916
+ IS_MODEL_STANDARD="$(get_model "$config_file" "$ide" "standard")"
917
+ IS_MODEL_LIGHT="$(get_model "$config_file" "$ide" "light")"
918
+ }
919
+
920
+ # resolve_model_var <tier> — set IS_MODEL from the tiers load_model_tiers
921
+ # resolved. An empty tier resolves to heavy, like get_model's default.
922
+ # shellcheck disable=SC2034 # IS_MODEL is the return channel read by adapters
923
+ resolve_model_var() {
924
+ case "$1" in
925
+ heavy|"") IS_MODEL="$IS_MODEL_HEAVY" ;;
926
+ standard) IS_MODEL="$IS_MODEL_STANDARD" ;;
927
+ light) IS_MODEL="$IS_MODEL_LIGHT" ;;
928
+ *) IS_MODEL="$(get_model "$IS_MODEL_CFG" "$IS_MODEL_IDE" "$1")" ;;
929
+ esac
930
+ }
931
+
521
932
  # Print info message for each model override that differs from the
522
933
  # hardcoded default. Helps users notice when a script update brings new
523
934
  # defaults that their config still overrides with the old value.
@@ -568,11 +979,11 @@ report_model_drift() {
568
979
  fi
569
980
  }
570
981
 
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" ;;
982
+ # Map access level to Claude tools string (fork-free form: sets
983
+ # IS_CLAUDE_TOOLS; the printing form wraps it for compatibility)
984
+ map_access_to_claude_tools_var() {
985
+ case "$1" in
986
+ readonly) IS_CLAUDE_TOOLS="Read, Grep, Glob, Bash" ;;
576
987
  # full: emit NO tools list at all. Confirmed empirically in Copilot (VSCode,
577
988
  # reading .claude/agents): a closed tools list restricts the agent to exactly
578
989
  # those tools and loses MCP; omitting the field lets it inherit every session
@@ -583,19 +994,28 @@ map_access_to_claude_tools() {
583
994
  # intermittently hides MCP. The durable fix is an explicit allowlist that
584
995
  # NAMES the MCP servers (umbraco-mcp/*, figma/*) and stays under 128; that
585
996
  # needs the project's MCP server list, so it is tracked, not encoded here yet.
586
- *) echo "" ;;
997
+ *) IS_CLAUDE_TOOLS="" ;;
587
998
  esac
588
999
  }
589
1000
 
590
1001
  # 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 "" ;;
1002
+ map_access_to_claude_disallowed_var() {
1003
+ case "$1" in
1004
+ readonly) IS_CLAUDE_DISALLOWED="Write, Edit" ;;
1005
+ *) IS_CLAUDE_DISALLOWED="" ;;
596
1006
  esac
597
1007
  }
598
1008
 
1009
+ map_access_to_claude_tools() {
1010
+ map_access_to_claude_tools_var "$1"
1011
+ echo "$IS_CLAUDE_TOOLS"
1012
+ }
1013
+
1014
+ map_access_to_claude_disallowed() {
1015
+ map_access_to_claude_disallowed_var "$1"
1016
+ echo "$IS_CLAUDE_DISALLOWED"
1017
+ }
1018
+
599
1019
  # --- Validation ---
600
1020
 
601
1021
  # Lexically canonicalize a path: collapse `//`, `.` and `..` by pure string
@@ -605,6 +1025,13 @@ map_access_to_claude_disallowed() {
605
1025
  # exist.
606
1026
  # Usage: canon="$(normalize_path "/repo/a/../b")" # -> /repo/b
607
1027
  normalize_path() {
1028
+ normalize_path_var "$1"
1029
+ printf '%s' "$IS_NORM_PATH"
1030
+ }
1031
+
1032
+ # normalize_path_var <path> — fork-free form: sets IS_NORM_PATH instead of
1033
+ # printing, so hot validation loops avoid a command-substitution subshell.
1034
+ normalize_path_var() {
608
1035
  local path="$1" p out=""
609
1036
  local -a parts
610
1037
  IFS='/' read -r -a parts <<< "$path"
@@ -615,7 +1042,7 @@ normalize_path() {
615
1042
  *) out="$out/$p" ;;
616
1043
  esac
617
1044
  done
618
- printf '%s' "${out:-/}"
1045
+ IS_NORM_PATH="${out:-/}"
619
1046
  }
620
1047
 
621
1048
  # Refuse to operate on output paths that could clobber content.
@@ -639,10 +1066,20 @@ validate_output_path() {
639
1066
  local adapter="$3"
640
1067
  local output_dir="$4"
641
1068
 
1069
+ # A full sync validates the same path several times (preflight, snapshot,
1070
+ # the adapter run itself, and shared paths like `.agents/skills` once per
1071
+ # adapter that manages them). Success depends only on the inputs below —
1072
+ # failures exit and are never memoized — so repeats return immediately.
1073
+ local memo_key="$repo_root|$config_file|$output_dir"
1074
+ case "${IS_VOP_MEMO:-$'\n'}" in
1075
+ *$'\n'"$memo_key"$'\n'*) return 0 ;;
1076
+ esac
1077
+
642
1078
  # Canonicalize FIRST. Every check below is a string comparison, so a `../`
643
1079
  # left in the raw value would walk straight past all of them.
644
1080
  local canon
645
- canon="$(normalize_path "$output_dir")"
1081
+ normalize_path_var "$output_dir"
1082
+ canon="$IS_NORM_PATH"
646
1083
 
647
1084
  case "$canon" in
648
1085
  ""|"/"|"$repo_root")
@@ -671,7 +1108,8 @@ validate_output_path() {
671
1108
  # than stepped over.
672
1109
  local probe="$canon" parent
673
1110
  while [ ! -e "$probe" ] && [ ! -L "$probe" ]; do
674
- parent="$(dirname "$probe")"
1111
+ parent="${probe%/*}"
1112
+ [ -n "$parent" ] || parent="/"
675
1113
  [ "$parent" = "$probe" ] && break
676
1114
  probe="$parent"
677
1115
  done
@@ -689,9 +1127,15 @@ validate_output_path() {
689
1127
  # inside the repo (`pwd -P` on both sides so a symlinked repo root
690
1128
  # resolves consistently).
691
1129
  local probe_dir phys repo_phys
692
- if [ -d "$probe" ]; then probe_dir="$probe"; else probe_dir="$(dirname "$probe")"; fi
1130
+ if [ -d "$probe" ]; then probe_dir="$probe"; else probe_dir="${probe%/*}"; [ -n "$probe_dir" ] || probe_dir="/"; fi
693
1131
  phys="$(cd "$probe_dir" 2>/dev/null && pwd -P)" || phys=""
694
- repo_phys="$(cd "$repo_root" && pwd -P)"
1132
+ if [ "${IS_VOP_REPO_PHYS_ROOT:-}" = "$repo_root" ]; then
1133
+ repo_phys="$IS_VOP_REPO_PHYS"
1134
+ else
1135
+ repo_phys="$(cd "$repo_root" && pwd -P)"
1136
+ IS_VOP_REPO_PHYS_ROOT="$repo_root"
1137
+ IS_VOP_REPO_PHYS="$repo_phys"
1138
+ fi
695
1139
  case "${phys:-/nonexistent}" in
696
1140
  "$repo_phys"|"$repo_phys"/*) ;;
697
1141
  *)
@@ -707,7 +1151,13 @@ validate_output_path() {
707
1151
  # Reject the intelligence source directory itself (parent of config.yaml).
708
1152
  # Folder name is whatever the user chose — we read it from the filesystem.
709
1153
  local intel_dir intel_rel
710
- intel_dir="$(cd "$(dirname "$config_file")" && pwd)"
1154
+ if [ "${IS_VOP_INTEL_KEY:-}" = "$config_file" ]; then
1155
+ intel_dir="$IS_VOP_INTEL_DIR"
1156
+ else
1157
+ intel_dir="$(cd "$(dirname "$config_file")" && pwd)"
1158
+ IS_VOP_INTEL_KEY="$config_file"
1159
+ IS_VOP_INTEL_DIR="$intel_dir"
1160
+ fi
711
1161
  intel_rel="${intel_dir#"$repo_root"/}"
712
1162
  if [ -n "$intel_rel" ] && [ "$intel_rel" != "$intel_dir" ]; then
713
1163
  case "$rel" in
@@ -740,12 +1190,14 @@ validate_output_path() {
740
1190
  fi
741
1191
 
742
1192
  # Reject any configured source directory (rules, agents, skills).
743
- local section src src_rel
1193
+ local section src src_rel src_list
744
1194
  for section in rules agents skills; do
1195
+ load_yaml_list "$config_file" "$section"
1196
+ src_list="$IS_YAML_LIST"
745
1197
  while IFS= read -r src; do
746
1198
  [ -z "$src" ] && continue
747
- src_rel="$(normalize_path "$repo_root/$src")"
748
- src_rel="${src_rel#"$repo_root"/}"
1199
+ normalize_path_var "$repo_root/$src"
1200
+ src_rel="${IS_NORM_PATH#"$repo_root"/}"
749
1201
  case "$rel" in
750
1202
  "$src_rel"|"$src_rel"/*)
751
1203
  echo "ERROR: targets.$adapter.output ('$rel') overlaps a configured source ('$src')." >&2
@@ -753,8 +1205,10 @@ validate_output_path() {
753
1205
  exit 1
754
1206
  ;;
755
1207
  esac
756
- done < <(read_yaml_list "$config_file" "$section")
1208
+ done <<< "$src_list"
757
1209
  done
1210
+
1211
+ IS_VOP_MEMO="${IS_VOP_MEMO:-$'\n'}$memo_key"$'\n'
758
1212
  }
759
1213
 
760
1214
  # Warn about prompt directories not listed in sources.
@@ -768,35 +1222,43 @@ warn_unsynced() {
768
1222
  local config_file="$2"
769
1223
 
770
1224
  local all_sources=()
1225
+ local src ign section
771
1226
  for section in rules agents skills; do
1227
+ load_yaml_list "$config_file" "$section"
772
1228
  while IFS= read -r src; do
773
1229
  [ -z "$src" ] && continue
774
1230
  all_sources+=("$src")
775
- done < <(read_yaml_list "$config_file" "$section")
1231
+ done <<< "$IS_YAML_LIST"
776
1232
  done
777
1233
 
778
1234
  # Collect ignore + submodule patterns.
779
1235
  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")
1236
+ for section in ignore submodules; do
1237
+ load_yaml_list "$config_file" "$section"
1238
+ while IFS= read -r ign; do
1239
+ [ -z "$ign" ] && continue
1240
+ ignores+=("$ign")
1241
+ done <<< "$IS_YAML_LIST"
1242
+ done
788
1243
 
789
1244
  # The manifest sits at the repo root, so the content dir cannot be derived
790
1245
  # from its location — it comes from the env contract the CLI exports.
791
1246
  local intel_basename
792
1247
  if [ "${IS_CLI:-0}" = "1" ]; then
793
- intel_basename="$(basename "${IS_CONTENT_REL:-intelligence}")"
1248
+ intel_basename="${IS_CONTENT_REL:-intelligence}"
1249
+ intel_basename="${intel_basename##*/}"
794
1250
  else
795
- intel_basename="$(basename "$(dirname "$config_file")")"
1251
+ intel_basename="$(dirname "$config_file")"
1252
+ intel_basename="${intel_basename##*/}"
796
1253
  fi
797
1254
 
798
1255
  local warnings=0
799
1256
 
1257
+ # Prune instead of post-filtering: the old scan walked every directory in
1258
+ # the repository — .git object stores and node_modules trees included —
1259
+ # and then discarded the hits, which alone took minutes in a large
1260
+ # monorepo. Results under these names were never actionable: generated
1261
+ # tool outputs, the package store, dependency and build trees.
800
1262
  while IFS= read -r found_dir; do
801
1263
  local rel_dir="${found_dir#$repo_root/}"
802
1264
 
@@ -830,13 +1292,22 @@ warn_unsynced() {
830
1292
  *) continue ;;
831
1293
  esac
832
1294
 
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
1295
+ # Check if directory has content worth syncing (globs, no subprocess:
1296
+ # any *.md directly inside, or a SKILL.md at depth one or two —
1297
+ # `-e` because the old `find -name` matched any entry type).
1298
+ local has_content=false f
1299
+ for f in "$found_dir"/*.md; do
1300
+ [ -e "$f" ] && has_content=true
1301
+ break
1302
+ done
1303
+ if [ "$has_content" = false ] && [ -e "$found_dir/SKILL.md" ]; then
836
1304
  has_content=true
837
1305
  fi
838
- if [ -n "$(find "$found_dir" -maxdepth 2 -name 'SKILL.md' 2>/dev/null | head -1)" ]; then
839
- has_content=true
1306
+ if [ "$has_content" = false ]; then
1307
+ for f in "$found_dir"/*/SKILL.md; do
1308
+ [ -e "$f" ] && has_content=true
1309
+ break
1310
+ done
840
1311
  fi
841
1312
  [ "$has_content" = false ] && continue
842
1313
 
@@ -857,10 +1328,10 @@ warn_unsynced() {
857
1328
  echo " NOT SYNCED: $rel_dir"
858
1329
  warnings=$((warnings + 1))
859
1330
  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)
1331
+ 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
1332
 
862
1333
  if [ $warnings -gt 0 ]; then
863
- echo " Add these paths to sources: in $(basename "$config_file")"
1334
+ echo " Add these paths to sources: in ${config_file##*/}"
864
1335
  fi
865
1336
  }
866
1337
 
@@ -869,9 +1340,29 @@ warn_unsynced() {
869
1340
  # Read a simple list from config.yaml
870
1341
  # Format: key:\n - "value1"\n - "value2"
871
1342
  # Usage: readarray -t arr < <(read_yaml_list "config.yaml" "rules")
1343
+ #
1344
+ # Consults the load_yaml_list cache first: sync reads the same sections from
1345
+ # the same manifest dozens of times, and each awk spawn costs tens of
1346
+ # milliseconds on Windows. The cache is only ever populated by
1347
+ # load_yaml_list, which the engine calls for a manifest it never mutates, so
1348
+ # a CLI process that edits the manifest keeps reading the file directly.
872
1349
  read_yaml_list() {
873
1350
  local file="$1"
874
1351
  local section="$2"
1352
+ case "$section" in
1353
+ rules|agents|skills|ignore|submodules)
1354
+ # Indirect expansion, not eval: the section name is allowlisted
1355
+ # above, but keeping manifest-derived values out of any evaluated
1356
+ # string is the safer shape.
1357
+ local file_var="IS_YL_${section}_FILE" val_var="IS_YL_${section}_VAL"
1358
+ local cached_file="${!file_var:-}" cached_val
1359
+ if [ -n "$cached_file" ] && [ "$cached_file" = "$file" ]; then
1360
+ cached_val="${!val_var:-}"
1361
+ [ -n "$cached_val" ] && printf '%s\n' "$cached_val"
1362
+ return 0
1363
+ fi
1364
+ ;;
1365
+ esac
875
1366
  awk -v section="$section" '
876
1367
  {
877
1368
  sub(/\r$/, "")
@@ -895,11 +1386,145 @@ read_yaml_list() {
895
1386
  ' "$file"
896
1387
  }
897
1388
 
1389
+ # load_yaml_list <file> <section> — fill the global IS_YAML_LIST with the
1390
+ # section's entries (newline-separated) and cache the result for
1391
+ # read_yaml_list and later load_yaml_list calls. Lets in-process loops
1392
+ # iterate a section without forking; the engine warms the cache once per run.
1393
+ # Only sections whose names are identifier-safe are cached.
1394
+ load_yaml_list() {
1395
+ local file="$1" section="$2"
1396
+ case "$section" in
1397
+ rules|agents|skills|ignore|submodules)
1398
+ # Indirect reads and printf -v writes, not eval — see the note in
1399
+ # read_yaml_list.
1400
+ local file_var="IS_YL_${section}_FILE" val_var="IS_YL_${section}_VAL"
1401
+ local cached_file="${!file_var:-}"
1402
+ if [ -n "$cached_file" ] && [ "$cached_file" = "$file" ]; then
1403
+ IS_YAML_LIST="${!val_var:-}"
1404
+ return 0
1405
+ fi
1406
+ IS_YAML_LIST="$(read_yaml_list "$file" "$section")"
1407
+ printf -v "$file_var" '%s' "$file"
1408
+ printf -v "$val_var" '%s' "$IS_YAML_LIST"
1409
+ ;;
1410
+ *)
1411
+ IS_YAML_LIST="$(read_yaml_list "$file" "$section")"
1412
+ ;;
1413
+ esac
1414
+ }
1415
+
1416
+ # load_targets_cache <file> — parse the whole targets: section once into the
1417
+ # global IS_TGT_TSV (one `name<TAB>enabled<TAB>output` row per target),
1418
+ # replicating is_target_enabled and get_target_output semantics: inline and
1419
+ # block forms, first occurrence wins, `enabled` defaults to 0 when the block
1420
+ # ends without one and to empty at end of file. The engine warms this once;
1421
+ # both readers consult it before spawning awk.
1422
+ load_targets_cache() {
1423
+ local file="$1"
1424
+ if [ "${IS_TGT_FILE:-}" = "$file" ]; then
1425
+ return 0
1426
+ fi
1427
+ IS_TGT_TSV="$(awk '
1428
+ function flush_target(at_sibling) {
1429
+ if (name == "") return
1430
+ if (enabled == "" && at_sibling) enabled = 0
1431
+ printf "%s\t%s\t%s\n", name, enabled, output
1432
+ name = ""
1433
+ }
1434
+ { sub(/\r$/, "") }
1435
+ /^targets:[[:space:]]*$/ { in_targets = 1; next }
1436
+ /^[a-zA-Z]/ { in_targets = 0 }
1437
+ # The per-target readers scanned forward for the FIRST line containing
1438
+ # `enabled:` / `output:` after the target header, checking it before
1439
+ # the block-end test — so a sibling header line could donate its own
1440
+ # inline values to a block that lacked them. These two rules run
1441
+ # before the header rule below to keep that reading.
1442
+ name != "" && enabled == "" && /enabled:/ {
1443
+ enabled = ($0 ~ /true/) ? 1 : 0
1444
+ }
1445
+ name != "" && output == "" && /output:/ {
1446
+ val = $0
1447
+ sub(/.*output:[[:space:]]*["\047]?/, "", val)
1448
+ sub(/["\047]?[[:space:]]*}?$/, "", val)
1449
+ output = val
1450
+ }
1451
+ in_targets && $0 ~ /^ [a-zA-Z0-9_-]+:/ {
1452
+ flush_target(1)
1453
+ line = $0
1454
+ name = substr(line, 3)
1455
+ sub(/:.*$/, "", name)
1456
+ enabled = ""; output = ""
1457
+ if (line ~ /enabled:[[:space:]]*true/) enabled = 1
1458
+ else if (line ~ /enabled:[[:space:]]*false/) enabled = 0
1459
+ if (match(line, /output:[[:space:]]*/)) {
1460
+ rest = substr(line, RSTART + RLENGTH)
1461
+ if (match(rest, /[,}]/)) rest = substr(rest, 1, RSTART - 1)
1462
+ gsub(/^["\047]|["\047][[:space:]]*$/, "", rest)
1463
+ sub(/[[:space:]]+$/, "", rest)
1464
+ output = rest
1465
+ }
1466
+ next
1467
+ }
1468
+ name != "" && /^ [a-zA-Z]/ { flush_target(1) }
1469
+ END { flush_target(0) }
1470
+ ' "$file")"
1471
+ IS_TGT_FILE="$file"
1472
+ }
1473
+
1474
+ # _targets_cache_row <target> — scan the cached TSV; sets IS_TGT_ROW_ENABLED /
1475
+ # IS_TGT_ROW_OUTPUT. Returns 1 when the target has no row (reader falls back
1476
+ # to empty, same as the awk scan finding nothing).
1477
+ _targets_cache_row() {
1478
+ local target="$1" n e o
1479
+ IS_TGT_ROW_ENABLED=""
1480
+ IS_TGT_ROW_OUTPUT=""
1481
+ while IFS=$'\t' read -r n e o; do
1482
+ if [ "$n" = "$target" ]; then
1483
+ IS_TGT_ROW_ENABLED="$e"
1484
+ IS_TGT_ROW_OUTPUT="$o"
1485
+ return 0
1486
+ fi
1487
+ done <<< "$IS_TGT_TSV"
1488
+ return 1
1489
+ }
1490
+
1491
+ # target_enabled_var <file> <target> — fork-free is_target_enabled: sets
1492
+ # IS_TGT_ENABLED instead of printing. Falls back to the awk reader when the
1493
+ # cache does not cover the file.
1494
+ # shellcheck disable=SC2034 # IS_TGT_ENABLED is the return channel read by sync.sh
1495
+ target_enabled_var() {
1496
+ local file="$1" target="$2"
1497
+ if [ "${IS_TGT_FILE:-}" = "$file" ]; then
1498
+ _targets_cache_row "$target" || true
1499
+ IS_TGT_ENABLED="$IS_TGT_ROW_ENABLED"
1500
+ else
1501
+ IS_TGT_ENABLED="$(is_target_enabled "$file" "$target")"
1502
+ fi
1503
+ }
1504
+
1505
+ # target_output_var <file> <target> — fork-free get_target_output: sets
1506
+ # IS_TGT_OUTPUT instead of printing.
1507
+ # shellcheck disable=SC2034 # IS_TGT_OUTPUT is the return channel read by sync.sh
1508
+ target_output_var() {
1509
+ local file="$1" target="$2"
1510
+ if [ "${IS_TGT_FILE:-}" = "$file" ]; then
1511
+ _targets_cache_row "$target" || true
1512
+ IS_TGT_OUTPUT="$IS_TGT_ROW_OUTPUT"
1513
+ else
1514
+ IS_TGT_OUTPUT="$(get_target_output "$file" "$target")"
1515
+ fi
1516
+ }
1517
+
898
1518
  # Check if a target is enabled in config.yaml (scoped to targets: section)
899
1519
  # Usage: is_target_enabled "config.yaml" "claude"
900
1520
  is_target_enabled() {
901
1521
  local file="$1"
902
1522
  local target="$2"
1523
+ if [ "${IS_TGT_FILE:-}" = "$file" ]; then
1524
+ _targets_cache_row "$target" || true
1525
+ [ -n "$IS_TGT_ROW_ENABLED" ] && printf '%s\n' "$IS_TGT_ROW_ENABLED"
1526
+ return 0
1527
+ fi
903
1528
  awk -v target="$target" '
904
1529
  { sub(/\r$/, "") }
905
1530
 
@@ -967,6 +1592,11 @@ get_target_field() {
967
1592
  get_target_output() {
968
1593
  local file="$1"
969
1594
  local target="$2"
1595
+ if [ "${IS_TGT_FILE:-}" = "$file" ]; then
1596
+ _targets_cache_row "$target" || true
1597
+ [ -n "$IS_TGT_ROW_OUTPUT" ] && printf '%s\n' "$IS_TGT_ROW_OUTPUT"
1598
+ return 0
1599
+ fi
970
1600
  awk -v target="$target" '
971
1601
  { sub(/\r$/, "") }
972
1602