@ainova-systems/intelligence 0.11.2 → 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.
- package/engine/ENGINE_SHA +1 -1
- package/engine/VERSION +1 -1
- package/engine/adapters/agents.sh +75 -48
- package/engine/adapters/claude.sh +88 -58
- package/engine/adapters/codex.sh +39 -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 +746 -116
- package/engine/lib/contract.sh +2 -2
- package/engine/sync.sh +36 -14
- package/package.json +1 -1
- package/packages/sync/references/adapters.md +9 -1
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)
|
|
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
|
-
|
|
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,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
|
-
|
|
283
|
-
skill_name="$(basename "$d")"
|
|
654
|
+
skill_name="${d%/}"; skill_name="${skill_name##*/}"
|
|
284
655
|
[ -f "$d/SKILL.md" ] || continue
|
|
285
|
-
|
|
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
|
-
|
|
658
|
+
log+=" skill: $skill_name"$'\n'
|
|
290
659
|
done
|
|
291
|
-
done
|
|
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
|
-
|
|
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
|
-
#
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
{
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
-
' "
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
case "$
|
|
575
|
-
readonly)
|
|
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
|
-
*)
|
|
997
|
+
*) IS_CLAUDE_TOOLS="" ;;
|
|
587
998
|
esac
|
|
588
999
|
}
|
|
589
1000
|
|
|
590
1001
|
# Map access level to Claude disallowedTools (empty if full access)
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="$
|
|
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="$
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
748
|
-
src_rel="${
|
|
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
|
|
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
|
|
1231
|
+
done <<< "$IS_YAML_LIST"
|
|
776
1232
|
done
|
|
777
1233
|
|
|
778
1234
|
# Collect ignore + submodule patterns.
|
|
779
1235
|
local ignores=()
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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="$
|
|
1248
|
+
intel_basename="${IS_CONTENT_REL:-intelligence}"
|
|
1249
|
+
intel_basename="${intel_basename##*/}"
|
|
794
1250
|
else
|
|
795
|
-
intel_basename="$(
|
|
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
|
-
|
|
835
|
-
|
|
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 [
|
|
839
|
-
|
|
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 $
|
|
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
|
|