@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.
- package/cli/commands/sync.sh +5 -3
- package/cli/intelligence +1 -1
- package/engine/ENGINE_SHA +1 -1
- package/engine/VERSION +1 -1
- package/engine/adapters/agents.sh +68 -80
- package/engine/adapters/claude.sh +88 -58
- package/engine/adapters/codex.sh +79 -47
- package/engine/adapters/copilot.sh +120 -89
- package/engine/adapters/cursor.sh +110 -67
- package/engine/adapters/opencode.sh +77 -61
- package/engine/adapters/pi.sh +118 -80
- package/engine/lib/common.sh +854 -128
- package/engine/lib/contract.sh +2 -2
- package/engine/sync.sh +39 -14
- package/package.json +1 -1
- package/packages/sync/agents/intelligence-architect.md +2 -0
- package/packages/sync/references/adapters.md +13 -1
- package/packages/sync/references/conventions.md +1 -1
- package/packages/sync/rules/intelligence-authoring.md +1 -1
- package/packages/sync/skills/intelligence-compact-context/SKILL.md +118 -0
- package/packages/sync/skills/intelligence-compact-context/references/principles.md +58 -0
- package/packages/sync/skills/intelligence-review-skills/SKILL.md +18 -3
package/engine/lib/common.sh
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
35
|
-
#
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" ]
|
|
107
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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" ]
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
{
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
-
' "
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
case "$
|
|
575
|
-
readonly)
|
|
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
|
-
*)
|
|
990
|
+
*) IS_CLAUDE_TOOLS="" ;;
|
|
587
991
|
esac
|
|
588
992
|
}
|
|
589
993
|
|
|
590
994
|
# Map access level to Claude disallowedTools (empty if full access)
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="$
|
|
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="$
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
748
|
-
src_rel="${
|
|
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
|
|
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
|
|
1224
|
+
done <<< "$IS_YAML_LIST"
|
|
776
1225
|
done
|
|
777
1226
|
|
|
778
1227
|
# Collect ignore + submodule patterns.
|
|
779
1228
|
local ignores=()
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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="$
|
|
1241
|
+
intel_basename="${IS_CONTENT_REL:-intelligence}"
|
|
1242
|
+
intel_basename="${intel_basename##*/}"
|
|
794
1243
|
else
|
|
795
|
-
intel_basename="$(
|
|
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
|
-
|
|
835
|
-
|
|
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 [
|
|
839
|
-
|
|
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 $
|
|
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 &&
|
|
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 &&
|
|
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
|
|