@ainova-systems/intelligence 0.11.0-rc.1

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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/bin/intelligence.js +59 -0
  4. package/cli/commands/add.sh +133 -0
  5. package/cli/commands/doctor.sh +100 -0
  6. package/cli/commands/init.sh +96 -0
  7. package/cli/commands/install.sh +84 -0
  8. package/cli/commands/list.sh +28 -0
  9. package/cli/commands/migrate.sh +251 -0
  10. package/cli/commands/registry.sh +51 -0
  11. package/cli/commands/remove.sh +39 -0
  12. package/cli/commands/status.sh +34 -0
  13. package/cli/commands/sync.sh +22 -0
  14. package/cli/commands/update.sh +59 -0
  15. package/cli/commands/upgrade.sh +27 -0
  16. package/cli/intelligence +71 -0
  17. package/cli/lib/cli-common.sh +149 -0
  18. package/cli/lib/lockfile.sh +97 -0
  19. package/cli/lib/manifest.sh +211 -0
  20. package/cli/lib/registry.sh +141 -0
  21. package/cli/lib/semver.sh +127 -0
  22. package/engine/INIT.md +498 -0
  23. package/engine/agents/intelligence-architect.md +53 -0
  24. package/engine/agents/intelligence-operator.md +49 -0
  25. package/engine/docs/ADAPTERS.md +212 -0
  26. package/engine/docs/CLI.md +91 -0
  27. package/engine/docs/CONVENTIONS.md +440 -0
  28. package/engine/rules/intelligence-authoring.md +114 -0
  29. package/engine/scripts/VERSION +1 -0
  30. package/engine/scripts/adapters/_template.sh +86 -0
  31. package/engine/scripts/adapters/agents.sh +299 -0
  32. package/engine/scripts/adapters/claude.sh +136 -0
  33. package/engine/scripts/adapters/codex.sh +118 -0
  34. package/engine/scripts/adapters/copilot.sh +193 -0
  35. package/engine/scripts/adapters/cursor.sh +146 -0
  36. package/engine/scripts/adapters/opencode.sh +200 -0
  37. package/engine/scripts/adapters/pi.sh +256 -0
  38. package/engine/scripts/lib/common.sh +1602 -0
  39. package/engine/scripts/lib/layout.sh +51 -0
  40. package/engine/scripts/lib/migrations.sh +708 -0
  41. package/engine/scripts/sync.sh +311 -0
  42. package/engine/scripts/update.sh +237 -0
  43. package/engine/skills/intelligence-add-agent/SKILL.md +62 -0
  44. package/engine/skills/intelligence-add-rule/SKILL.md +54 -0
  45. package/engine/skills/intelligence-add-skill/SKILL.md +53 -0
  46. package/engine/skills/intelligence-extract-skill/SKILL.md +47 -0
  47. package/engine/skills/intelligence-install-adapter/SKILL.md +31 -0
  48. package/engine/skills/intelligence-learn-from-context/SKILL.md +69 -0
  49. package/engine/skills/intelligence-review-skills/SKILL.md +86 -0
  50. package/engine/skills/intelligence-sync/SKILL.md +18 -0
  51. package/engine/skills/intelligence-uninstall-adapter/SKILL.md +42 -0
  52. package/engine/skills/intelligence-update/SKILL.md +159 -0
  53. package/package.json +39 -0
  54. package/registry/index.yaml +15 -0
@@ -0,0 +1,1602 @@
1
+ #!/bin/bash
2
+ # intelligence-sync: Core library functions
3
+ # Source this file — never execute directly.
4
+ #
5
+ # Usage: source "$(dirname "$0")/lib/common.sh"
6
+
7
+ # --- File Utilities ---
8
+
9
+ # Convert CRLF to LF in a file (safe for Windows/Git Bash)
10
+ normalize_file_to_lf() {
11
+ local target="$1"
12
+ local tmp_file="$target.tmp"
13
+ awk '{ sub(/\r$/, ""); print }' "$target" > "$tmp_file"
14
+ mv "$tmp_file" "$target"
15
+ }
16
+
17
+ # --- Layout tokens -----------------------------------------------------------
18
+ #
19
+ # The umbrella folder is named by the project (`intelligence/`, `Intelligence/`,
20
+ # a codename) — so an artifact SHIPPED BY THE ENGINE cannot write that name
21
+ # down. A rule that scopes itself to the intelligence layer needs `paths:` to
22
+ # say "the umbrella", and an agent body needs to name the sync command. Both are
23
+ # spelled with tokens, expanded here at output time:
24
+ #
25
+ # <umbrella> -> the repo-relative umbrella dir (e.g. `Intelligence`)
26
+ # <module> -> the repo-relative engine module (e.g. `Intelligence/sync`)
27
+ #
28
+ # Values come from IS_UMBRELLA_REL / IS_MODULE_REL, which sync.sh derives from
29
+ # the detected layout (never hardcoded) and exports before any adapter runs.
30
+ # Expansion happens in EVERY generated file, frontmatter and body alike, so a
31
+ # scoped rule reaches Claude's `paths:`, Cursor's `globs:` and Copilot's
32
+ # `applyTo:` already carrying the project's real folder name.
33
+
34
+ # finalize_output_file <file>
35
+ # The single exit gate for every file an adapter writes: expand layout tokens,
36
+ # then normalize CRLF -> LF. Adapters MUST call this (not normalize_file_to_lf)
37
+ # on each output — a missed call ships a literal `<umbrella>` into an IDE.
38
+ finalize_output_file() {
39
+ local target="$1"
40
+ local umb="${IS_UMBRELLA_REL:-intelligence}"
41
+ local mod="${IS_MODULE_REL:-intelligence/sync}"
42
+ # `<sync-cmd>` is how engine content says "run a sync": vendored setups
43
+ # expand it to the script invocation (built from $mod, so it reproduces the
44
+ # exact pre-token string), the CLI overrides it via IS_SYNC_CMD
45
+ # (`intelligence sync`).
46
+ local sc="${IS_SYNC_CMD:-bash $mod/scripts/sync.sh}"
47
+ local tmp_file="$target.tmp"
48
+ # Literal (index-based) substitution, not gsub: a regex replacement would
49
+ # give `&` in a path its special meaning, and POSIX awk has no way to pass a
50
+ # replacement string verbatim.
51
+ awk -v umb="$umb" -v mod="$mod" -v sc="$sc" '
52
+ function repl(s, from, to, out, i) {
53
+ out = ""
54
+ while ((i = index(s, from)) > 0) {
55
+ out = out substr(s, 1, i - 1) to
56
+ s = substr(s, i + length(from))
57
+ }
58
+ return out s
59
+ }
60
+ {
61
+ sub(/\r$/, "")
62
+ $0 = repl($0, "<sync-cmd>", sc)
63
+ $0 = repl($0, "<module>", mod)
64
+ $0 = repl($0, "<umbrella>", umb)
65
+ print
66
+ }
67
+ ' "$target" > "$tmp_file"
68
+ mv "$tmp_file" "$target"
69
+ }
70
+
71
+ # Escape a string for safe interpolation into a TOML basic string ("..").
72
+ # Backslash and double-quote are escaped; control chars stripped.
73
+ toml_escape() {
74
+ local s="$1"
75
+ s="${s//\\/\\\\}"
76
+ s="${s//\"/\\\"}"
77
+ # Strip any literal newline / carriage return — TOML basic strings
78
+ # do not allow them; multi-line content belongs in `"""..."""`.
79
+ s="${s//$'\n'/ }"
80
+ s="${s//$'\r'/}"
81
+ printf '%s' "$s"
82
+ }
83
+
84
+ # Escape a string for safe interpolation into a YAML double-quoted scalar.
85
+ yaml_dq_escape() {
86
+ local s="$1"
87
+ s="${s//\\/\\\\}"
88
+ s="${s//\"/\\\"}"
89
+ s="${s//$'\n'/ }"
90
+ s="${s//$'\r'/}"
91
+ printf '%s' "$s"
92
+ }
93
+
94
+ # --- Source Resolution -------------------------------------------------------
95
+ #
96
+ # A `sources.*` entry is normally a LOCAL path resolved as `$repo_root/<entry>`.
97
+ # It may instead be a REMOTE git spec, which is materialized (shallow-cloned)
98
+ # and resolved to a local directory inside the clone. This is the SINGLE point
99
+ # where remote sources are detected and fetched — every adapter and sync.sh
100
+ # routes its `$repo_root/$src` through resolve_source_dir, so no other file
101
+ # needs to know about remote sources.
102
+ #
103
+ # Spec format (inline string, so read_yaml_list parses it unchanged):
104
+ # git+<url>[@<ref>][#<subpath>]
105
+ # <url> explicit-scheme URL (https/http/ssh/git/file). Other transports
106
+ # (notably the command-executing ext::/fd::) are rejected.
107
+ # @<ref> optional tag / branch / SHA — the segment after the last `@` in
108
+ # the post-scheme part, accepted only if it has no `/` (so
109
+ # userinfo like `ssh://git@host/...` is not mistaken for a ref;
110
+ # branch names containing `/` are unsupported — use a tag, SHA,
111
+ # or slashless branch, which is the recommended pin anyway).
112
+ # #<subpath> optional dir inside the clone holding rules/agents/skills.
113
+
114
+ # True (0) if a source token is a remote git spec.
115
+ source_is_remote() {
116
+ case "$1" in
117
+ git+*) return 0 ;;
118
+ *) return 1 ;;
119
+ esac
120
+ }
121
+
122
+ # True (0) if a source token references a pack declared under `packs:`
123
+ # (`@<name>` or `@<name>/<subpath>`).
124
+ source_is_pack() {
125
+ case "$1" in
126
+ @?*) return 0 ;;
127
+ *) return 1 ;;
128
+ esac
129
+ }
130
+
131
+ # True (0) if a source token is a plain repo-relative path — i.e. NOT a pack
132
+ # reference and NOT an inline remote spec. The inverse of "resolves through a
133
+ # clone", which is what every caller that pattern-matches a token against a
134
+ # real directory needs.
135
+ source_is_local_path() {
136
+ source_is_pack "$1" && return 1
137
+ source_is_remote "$1" && return 1
138
+ return 0
139
+ }
140
+
141
+ # Map an absolute file path to a repo-root-relative path for use as a link
142
+ # target inside a COMMITTED file (e.g. AGENTS.md). A file under $repo_root gets
143
+ # its repo-relative path. A file resolved OUTSIDE $repo_root comes from a remote
144
+ # source materialized in the transient clone cache and has NO stable,
145
+ # committable path, so this returns the empty string — callers MUST then emit
146
+ # the bare name, never the absolute path. The `${path#"$repo_root"/}` strip is a
147
+ # no-op when $path is not under $repo_root, which is how the out-of-repo case is
148
+ # detected.
149
+ repo_rel_link() {
150
+ local repo_root="$1" path="$2" rel
151
+ rel="${path#"$repo_root"/}"
152
+ [ "$rel" = "$path" ] && return 0
153
+ printf '%s' "$rel"
154
+ }
155
+
156
+ # Repo-root-relative path of an existing DIRECTORY — by identity, not spelling.
157
+ # `${dir#"$repo_root"/}` is a plain string strip, which silently yields the
158
+ # unchanged absolute path when the two were produced from different spellings of
159
+ # the same location. That is not hypothetical: Git Bash reaches the Windows temp
160
+ # dir through two mounts (`/tmp/...` and `/c/Users/.../Temp/...`) and `pwd`
161
+ # prints whichever one you arrived through, so `REPO_ROOT` (from `git
162
+ # rev-parse`) and `LS_UMBRELLA_DIR` (from the invocation path) can disagree
163
+ # character-by-character while naming the same directory. AGENTS.md is
164
+ # committed, so a failed strip would bake a machine-specific absolute path into
165
+ # version control.
166
+ #
167
+ # Walks up from <dir> comparing each ancestor to <repo_root> with `-ef`
168
+ # (device+inode — identity, immune to spelling), collecting basenames.
169
+ # Echoes "" when <dir> is not inside <repo_root>, or does not exist.
170
+ # Usage: rel="$(repo_rel_dir "$repo_root" "$LS_MODULE_DIR")" # -> intelligence/sync
171
+ repo_rel_dir() {
172
+ local repo_root="$1" dir="$2"
173
+ local cur rel="" parent base depth=0
174
+ cur="$(cd "$dir" 2>/dev/null && pwd)" || return 0
175
+ [ -d "$repo_root" ] || return 0
176
+ while [ "$depth" -lt 64 ]; do
177
+ if [ "$cur" -ef "$repo_root" ]; then
178
+ printf '%s' "$rel"
179
+ return 0
180
+ fi
181
+ parent="$(dirname "$cur")"
182
+ base="$(basename "$cur")"
183
+ [ "$parent" = "$cur" ] && return 0 # reached the filesystem root
184
+ rel="$base${rel:+/$rel}"
185
+ cur="$parent"
186
+ depth=$((depth + 1))
187
+ done
188
+ }
189
+
190
+ # Resolve a single source token to an absolute local directory.
191
+ # Local token -> "$repo_root/$token".
192
+ # Remote token -> shallow-clone into the run cache, echo "<clone>/<subpath>".
193
+ # ALWAYS returns 0 (echoes nothing on failure) so `set -e` callers using
194
+ # `dir="$(resolve_source_dir ...)"` never abort; the caller's existing
195
+ # `[ -d "$dir" ] || continue` guard then skips an unresolved source.
196
+ # Usage: dir="$(resolve_source_dir "$repo_root" "$src")"
197
+ resolve_source_dir() {
198
+ local repo_root="$1" token="$2" config_file="${3:-${IS_CONFIG_FILE:-}}"
199
+
200
+ # A declared pack (`@<name>[/<subpath>]`) keeps its url / ref / mirror in
201
+ # config.yaml, so the token itself carries nothing but the reference.
202
+ if source_is_pack "$token"; then
203
+ resolve_pack_source "$repo_root" "$config_file" "$token"
204
+ return 0
205
+ fi
206
+
207
+ if ! source_is_remote "$token"; then
208
+ printf '%s' "$repo_root/$token"
209
+ return 0
210
+ fi
211
+
212
+ # --- parse: git+<url>[@<ref>][#<subpath>] ---
213
+ # An inline spec is an ANONYMOUS pack: it has no declared name and no
214
+ # mirror, so it is always transient. Declare it under `packs:` to commit it.
215
+ local rest="${token#git+}"
216
+ local subpath="" urlref="$rest"
217
+ case "$rest" in
218
+ *\#*) subpath="${rest#*#}"; urlref="${rest%%#*}" ;;
219
+ esac
220
+
221
+ # ref = segment after the last `@` in the post-scheme part, only if it has
222
+ # no `/` (else it is userinfo such as `git@host`, not a ref).
223
+ local url="$urlref" ref="" after_scheme="${urlref#*://}"
224
+ case "$after_scheme" in
225
+ *@*)
226
+ local cand="${after_scheme##*@}"
227
+ case "$cand" in
228
+ */*|"") ;; # userinfo / empty -> no ref
229
+ *) ref="$cand"; url="${urlref%@$ref}" ;;
230
+ esac
231
+ ;;
232
+ esac
233
+
234
+ fetch_remote_source "$token" "$url" "$ref" "$subpath" ""
235
+ }
236
+
237
+ # Resolve `@<name>[/<subpath>]` against the `packs:` block and fetch it.
238
+ # An undeclared name is a HARD error: unlike a mistyped local path (which the
239
+ # caller's `[ -d ]` guard silently skips), a pack reference names something the
240
+ # config claims to know, so a typo must not quietly drop a whole rule set.
241
+ # Usage: resolve_pack_source "$repo_root" "$config_file" "@shared/rules"
242
+ resolve_pack_source() {
243
+ local repo_root="$1" config_file="$2" token="$3"
244
+
245
+ local rest="${token#@}" name subpath=""
246
+ case "$rest" in
247
+ */*) name="${rest%%/*}"; subpath="${rest#*/}" ;;
248
+ *) name="$rest" ;;
249
+ esac
250
+
251
+ local url ref mirror_rel mirror_abs=""
252
+ url="$(get_pack_field "$config_file" "$name" "url")"
253
+ # Defensive only: validate_pack_refs has already failed the run for an
254
+ # undeclared pack. It has to, because every caller invokes this inside `$( )`
255
+ # — an exit here would end the substitution subshell, not the sync.
256
+ if [ -z "$url" ]; then
257
+ echo " WARN: pack '$name' is not declared under 'packs:': $token" >&2
258
+ return 0
259
+ fi
260
+ ref="$(get_pack_field "$config_file" "$name" "ref")"
261
+ mirror_rel="$(get_pack_field "$config_file" "$name" "mirror")"
262
+ [ -n "$mirror_rel" ] && mirror_abs="$(resolve_mirror_dir "$repo_root" "$config_file" "$name" "$mirror_rel")"
263
+
264
+ fetch_remote_source "$token" "$url" "$ref" "$subpath" "$mirror_abs" "$mirror_rel"
265
+ }
266
+
267
+ # Shallow-clone <url>@<ref> into the run cache, echo "<clone>/<subpath>", and —
268
+ # when <mirror_abs> is set — additionally materialize it there so the content is
269
+ # committed. <token> is only used for messages.
270
+ # ALWAYS returns 0 (echoes nothing on failure), per resolve_source_dir's contract.
271
+ # Usage: fetch_remote_source <token> <url> <ref> <subpath> <mirror_abs> [<mirror_rel>]
272
+ fetch_remote_source() {
273
+ local token="$1" url="$2" ref="$3" subpath="$4" mirror="$5" mirror_rel="${6:-}"
274
+
275
+ # Reject path traversal in the subpath: a remote spec must not be able to
276
+ # escape the clone dir (e.g. `#../../etc`). Checked before any clone.
277
+ case "/$subpath/" in
278
+ */../*)
279
+ echo " WARN: remote source rejected (subpath traversal '..'): $token" >&2
280
+ return 0
281
+ ;;
282
+ esac
283
+
284
+ # Scheme whitelist — reject everything but plain fetch transports. The
285
+ # ext::/fd:: transports execute arbitrary commands on clone, so a malicious
286
+ # or mistyped config must never reach `git clone` with them.
287
+ case "$url" in
288
+ https://*|http://*|ssh://*|git://*|file://*) ;;
289
+ *)
290
+ echo " WARN: remote source rejected (unsupported scheme): $token" >&2
291
+ return 0
292
+ ;;
293
+ esac
294
+
295
+ if ! command -v git >/dev/null 2>&1; then
296
+ echo " WARN: remote source needs git, which is not installed: $token" >&2
297
+ return 0
298
+ fi
299
+
300
+ # Cache root: run-scoped (set + cleaned by sync.sh) or a stable fallback so
301
+ # direct adapter calls still avoid re-cloning the same spec within a run.
302
+ local cache_root="${IS_REMOTE_CACHE:-${TMPDIR:-/tmp}/intelligence-sync-remotes}"
303
+ mkdir -p "$cache_root" 2>/dev/null || true
304
+ # Key on repo URL + ref ONLY (not the subpath): sources that point at the
305
+ # same repo@ref but different subpaths (e.g. `...repo.git@main#rules` and
306
+ # `...repo.git@main#skills`) share a SINGLE clone; the subpath only selects
307
+ # a directory inside it. Different ref → different clone (distinct versions).
308
+ local key
309
+ key="$(printf '%s' "$url@$ref" | cksum | awk '{print $1 "-" $2}')"
310
+ local dest="$cache_root/$key"
311
+
312
+ if [ ! -d "$dest/.git" ]; then
313
+ rm -rf "$dest"
314
+ # Untrusted remote content: never materialize symlinks from the cloned
315
+ # repo. With core.symlinks=false git writes each symlink as a plain text
316
+ # file holding its target path, so a hostile link like `skills -> /etc`
317
+ # cannot make the copy pipeline read host files outside the clone.
318
+ #
319
+ # Line endings are pinned for the same reason the engine's own
320
+ # `.gitattributes` pins them: the checkout must not depend on the host's
321
+ # `core.autocrlf`. On Windows that default rewrites a pack declaring no
322
+ # attributes to CRLF, `materialize_pack` copies bytes verbatim, and the
323
+ # mirror lands CRLF inside the project repo — a phantom diff that returns
324
+ # on every sync. `autocrlf=false` covers packs with no attributes,
325
+ # `eol=lf` covers those that mark files `text`.
326
+ local git_cfg=(-c core.symlinks=false -c core.autocrlf=false -c core.eol=lf)
327
+ local ok=0
328
+ if [ -n "$ref" ]; then
329
+ if GIT_TERMINAL_PROMPT=0 git "${git_cfg[@]}" clone --depth 1 --branch "$ref" --quiet \
330
+ "$url" "$dest" 2>/dev/null; then
331
+ ok=1
332
+ else
333
+ # ref is likely a SHA (not a branch/tag) — full clone + checkout.
334
+ rm -rf "$dest"
335
+ if GIT_TERMINAL_PROMPT=0 git "${git_cfg[@]}" clone --quiet "$url" "$dest" 2>/dev/null \
336
+ && git -C "$dest" "${git_cfg[@]}" checkout --quiet "$ref" 2>/dev/null; then
337
+ ok=1
338
+ fi
339
+ fi
340
+ elif GIT_TERMINAL_PROMPT=0 git "${git_cfg[@]}" clone --depth 1 --quiet "$url" "$dest" 2>/dev/null; then
341
+ ok=1
342
+ fi
343
+ if [ "$ok" -ne 1 ]; then
344
+ rm -rf "$dest"
345
+ echo " WARN: remote source clone failed (url=$url ref=${ref:-<default>}): $token" >&2
346
+ return 0
347
+ fi
348
+ echo " remote: cloned $url${ref:+ @$ref}" >&2
349
+ fi
350
+
351
+ local out="$dest"
352
+ [ -n "$subpath" ] && out="$dest/$subpath"
353
+ if [ ! -d "$out" ]; then
354
+ echo " WARN: remote source subpath not found ('${subpath:-/}') in $url: $token" >&2
355
+ return 0
356
+ fi
357
+ # Containment (defense in depth on top of the `..` reject + symlink-free
358
+ # checkout): the resolved dir must stay inside the clone. Canonicalize both
359
+ # with `pwd -P` so a symlinked TMPDIR (e.g. macOS /tmp -> /private/tmp)
360
+ # resolves consistently on each side.
361
+ local real_dest real_out
362
+ real_dest="$(cd "$dest" 2>/dev/null && pwd -P)"
363
+ real_out="$(cd "$out" 2>/dev/null && pwd -P)"
364
+ case "${real_out:-/nonexistent}" in
365
+ "$real_dest"|"$real_dest"/*) ;;
366
+ *)
367
+ echo " WARN: remote source subpath escapes the clone ('${subpath:-/}'): $token" >&2
368
+ return 0
369
+ ;;
370
+ esac
371
+
372
+ # No `mirror:` -> the clone stays in the transient run cache and nothing
373
+ # lands in the repo. This is every inline `git+` spec, and any pack that
374
+ # declares no mirror.
375
+ if [ -z "$mirror" ]; then
376
+ printf '%s' "$out"
377
+ return 0
378
+ fi
379
+ materialize_pack "$dest" "$out" "$url" "$ref" "$subpath" "$mirror" "$mirror_rel"
380
+ return 0
381
+ }
382
+
383
+ # Copy a resolved remote source out of the transient clone into the pack's
384
+ # declared `mirror:` directory, so pack content is committed and an upstream
385
+ # bump shows up in `git diff` instead of only in the generated output. Echoes
386
+ # the materialized directory; on any failure echoes the clone dir instead, so a
387
+ # broken mirror degrades to the transient behaviour rather than losing the
388
+ # source.
389
+ #
390
+ # The directory is DECLARED, never derived — `mirror:` says exactly where the
391
+ # pack lives, so there is no name to sanitize and no collision to resolve.
392
+ #
393
+ # The FIRST token to touch a pack in a run wipes it (clearing content left by a
394
+ # previous ref, or by a source entry that has since been removed); later tokens
395
+ # for the same pack only replace their own subpath. The claim is recorded in the
396
+ # clone cache, which is what makes "wipe once per run" work across the separate
397
+ # subshells each resolve_source_dir call runs in.
398
+ #
399
+ # The wipe is guarded by the stamp: a NON-EMPTY directory with no `.pack` in it
400
+ # is never deleted — it belongs to the project, not to us.
401
+ # <mirror_rel> is the path as authored in config.yaml, used only in messages.
402
+ # Usage: materialize_pack <clone> <src_dir> <url> <ref> <subpath> <mirror> <mirror_rel>
403
+ materialize_pack() {
404
+ local clone="$1" src_dir="$2" url="$3" ref="$4" subpath="$5" pack_dir="$6" mirror_rel="${7:-}"
405
+
406
+ # The claim is keyed on the DIRECTORY, not on url@ref: it records "this run
407
+ # already cleared this path". Keying it on the clone would let two packs
408
+ # that share a url@ref but declare different mirrors claim each other's,
409
+ # leaving the second mirror unstamped and never pruned.
410
+ #
411
+ # Same cache-root fallback as the clone: the claim must exist even when the
412
+ # caller is not sync.sh, or every token would re-wipe the pack and only the
413
+ # last subpath would survive. That fallback root is NOT run-scoped, though,
414
+ # so the claim carries `$$` — stable across the command-substitution
415
+ # subshells of one run, different for the next. A claim left behind by an
416
+ # earlier run must never suppress this run's wipe: that would rebuild the
417
+ # mirror with no `.pack` in it and freeze it against the guard below.
418
+ local cache_root claim
419
+ cache_root="${IS_REMOTE_CACHE:-${TMPDIR:-/tmp}/intelligence-sync-remotes}"
420
+ mkdir -p "$cache_root" 2>/dev/null || true
421
+ claim="$cache_root/$$-$(printf '%s' "$pack_dir" | cksum | awk '{print $1 "-" $2}').packdir"
422
+
423
+ if [ ! -f "$claim" ]; then
424
+ # Refuse to wipe a directory that is not ours. Ownership is the PRESENCE
425
+ # of the stamp, not the url inside it: a mirror is declared per pack, so
426
+ # a stamped directory is this pack's even after its `url:` is edited —
427
+ # a moved or renamed upstream must refresh the mirror, not freeze it at
428
+ # the old content while the generated output silently follows the new.
429
+ if [ -d "$pack_dir" ] && [ -n "$(find "$pack_dir" -mindepth 1 -maxdepth 1 2>/dev/null)" ] \
430
+ && [ ! -f "$pack_dir/.pack" ]; then
431
+ echo " WARN: mirror '$pack_dir' holds content that is not a pack's (no .pack stamp) — skipping materialization" >&2
432
+ printf '%s' "$src_dir"
433
+ return 0
434
+ fi
435
+
436
+ rm -rf "$pack_dir"
437
+ if ! mkdir -p "$pack_dir"; then
438
+ echo " WARN: cannot create mirror dir '$pack_dir' — using the run cache" >&2
439
+ printf '%s' "$src_dir"
440
+ return 0
441
+ fi
442
+ {
443
+ printf 'url=%s\n' "$url"
444
+ printf 'ref=%s\n' "${ref:-<default>}"
445
+ printf 'sha=%s\n' "$(git -C "$clone" rev-parse HEAD 2>/dev/null || echo unknown)"
446
+ } > "$pack_dir/.pack"
447
+ printf '%s\n' "$pack_dir" > "$claim"
448
+ echo " pack: $url${ref:+ @$ref} -> ${mirror_rel:-$pack_dir}" >&2
449
+ fi
450
+
451
+ # Only a subpath is cleared here — clearing the pack root would delete the
452
+ # `.pack` stamp written above (and any sibling subpath already copied in
453
+ # this run). The root is already clean: the claim step wiped it.
454
+ local dest="$pack_dir"
455
+ if [ -n "$subpath" ]; then
456
+ dest="$pack_dir/$subpath"
457
+ rm -rf "$dest"
458
+ fi
459
+ mkdir -p "$dest"
460
+
461
+ # Copy the subpath's contents, skipping `.git` — it only exists when the
462
+ # source IS the clone root (no `#subpath`), and a nested `.git` inside the
463
+ # project repo would be recorded as a gitlink, which is exactly the
464
+ # untrackable state this whole feature exists to avoid.
465
+ local entry base
466
+ for entry in "$src_dir"/* "$src_dir"/.[!.]*; do
467
+ [ -e "$entry" ] || continue
468
+ base="${entry##*/}"
469
+ [ "$base" = ".git" ] && continue
470
+ cp -R "$entry" "$dest/"
471
+ done
472
+
473
+ printf '%s' "$dest"
474
+ return 0
475
+ }
476
+
477
+ # Copy a markdown file with frontmatter, ensuring free-text string fields are
478
+ # wrapped in double quotes. Used by adapters that feed strict-YAML consumers
479
+ # (Codex CLI rejects unquoted colons / booleans). Idempotent — already-quoted
480
+ # values pass through untouched. Operates only inside the first `---` ... `---`
481
+ # block; body is preserved verbatim.
482
+ #
483
+ # Quoted fields: description, argument-hint
484
+ # When wrapping an unquoted value, literal `\` and `"` inside it are escaped
485
+ # (`\\`, `\"`) so an inner quote — e.g. `Use as a quick "what do we have" view`
486
+ # — cannot prematurely terminate the generated double-quoted scalar. Values the
487
+ # author already wrapped (in `"` or `'`) pass through untouched.
488
+ #
489
+ # Usage: copy_md_with_quoted_frontmatter "src.md" "dst.md"
490
+ copy_md_with_quoted_frontmatter() {
491
+ local src="$1"
492
+ local dst="$2"
493
+ awk '
494
+ function yamlq(s, out, i, c) {
495
+ out = ""
496
+ for (i = 1; i <= length(s); i++) {
497
+ c = substr(s, i, 1)
498
+ if (c == "\\") out = out "\\\\"
499
+ else if (c == "\"") out = out "\\\""
500
+ else out = out c
501
+ }
502
+ return out
503
+ }
504
+ BEGIN { state = "before" }
505
+ { sub(/\r$/, "") }
506
+ state == "before" {
507
+ if (NR == 1 && $0 == "---") { state = "in_fm"; print; next }
508
+ state = "after"; print; next
509
+ }
510
+ state == "in_fm" {
511
+ if ($0 == "---") { state = "after"; print; next }
512
+ idx = index($0, ":")
513
+ if (idx == 0) { print; next }
514
+ key = substr($0, 1, idx - 1)
515
+ sub(/^[[:space:]]+/, "", key); sub(/[[:space:]]+$/, "", key)
516
+ if (key != "description" && key != "argument-hint") { print; next }
517
+ val = substr($0, idx + 1)
518
+ sub(/^[[:space:]]+/, "", val); sub(/[[:space:]]+$/, "", val)
519
+ if (val == "") { print; next }
520
+ first = substr(val, 1, 1)
521
+ last = substr(val, length(val), 1)
522
+ if ((first == "\"" && last == "\"") || (first == "\047" && last == "\047")) { print; next }
523
+ print key ": \"" yamlq(val) "\""
524
+ next
525
+ }
526
+ state == "after" { print }
527
+ ' "$src" > "$dst"
528
+ }
529
+
530
+ # Copy a skill directory in full: SKILL.md plus any bundled resources
531
+ # (references/, scripts/, assets/ — the Agent Skills standard lets a skill
532
+ # ship support files beside SKILL.md, and SKILL.md bodies point at them by
533
+ # relative path, so dropping them breaks the skill at runtime). Markdown is
534
+ # normalized to LF; every other file is copied byte-for-byte so potentially
535
+ # binary assets survive. `cp -R` copies symlinks as symlinks (POSIX), so a
536
+ # link inside a source skill never leaks host file content into the output.
537
+ #
538
+ # SKILL.md frontmatter is quoted for EVERY consumer, not only the strict-YAML
539
+ # ones. `argument-hint: [pr-number]` is a YAML *flow sequence*, so an unquoted
540
+ # hint arrives as a list and Claude Code refuses the whole skill with
541
+ # "argument-hint must be a string" — the skill silently disappears from the
542
+ # picker. Quoting is idempotent: an already-quoted value passes through
543
+ # untouched.
544
+ # Usage: copy_skill_bundle "src/skill/dir" "dest/skill/dir"
545
+ copy_skill_bundle() {
546
+ local src_dir="${1%/}"
547
+ local dest_dir="$2"
548
+ mkdir -p "$dest_dir"
549
+ cp -R "$src_dir/." "$dest_dir/"
550
+ # A symlinked SKILL.md is left exactly as `cp -R` produced it — a symlink.
551
+ # `[ -f ]` follows links, so quoting it would read the link's TARGET and
552
+ # write that content into a real file, turning `skills/x/SKILL.md -> /etc/…`
553
+ # into a copy of a host file inside the output. That is the leak the
554
+ # symlink-preserving copy exists to prevent, so skip the rewrite and say so
555
+ # (the same reason `find -type f` below never matches a symlink).
556
+ if [ -L "$dest_dir/SKILL.md" ]; then
557
+ echo " WARN: $(basename "$dest_dir")/SKILL.md is a symlink — emitted as-is (frontmatter not quoted, tokens not expanded)" >&2
558
+ elif [ -f "$dest_dir/SKILL.md" ]; then
559
+ copy_md_with_quoted_frontmatter "$dest_dir/SKILL.md" "$dest_dir/SKILL.md.tmp-q"
560
+ mv "$dest_dir/SKILL.md.tmp-q" "$dest_dir/SKILL.md"
561
+ fi
562
+ while IFS= read -r f; do
563
+ [ -n "$f" ] || continue
564
+ finalize_output_file "$f"
565
+ done < <(find "$dest_dir" -type f -name '*.md')
566
+ }
567
+
568
+ # Copy skill directories into an Agent Skills open-standard location.
569
+ # The destination is a directory whose immediate children are skill folders
570
+ # containing SKILL.md (e.g. .agents/skills/<name>/SKILL.md). Free-text
571
+ # frontmatter fields are quoted for strict YAML consumers; lenient consumers
572
+ # accept the result unchanged, so this one copy can be shared across tools.
573
+ #
574
+ # Owns the full lifecycle of `$output_dir`: removes every existing skill
575
+ # subfolder, recreates the directory, then populates it. Sibling FILES at
576
+ # `$output_dir` are preserved (only immediate subdirectories are pruned).
577
+ # Multiple adapters may target the same path (e.g. Codex + Pi both write to
578
+ # `.agents/skills/`); calls are idempotent because every caller writes the
579
+ # same content from `intelligence/skills/`. Adapters MUST NOT do their own
580
+ # clean / mkdir for this dir — the helper is the single owner.
581
+ #
582
+ # Usage: sync_open_skill_dirs "$REPO_ROOT" "$CONFIG_FILE" "$dest_dir"
583
+ sync_open_skill_dirs() {
584
+ local repo_root="$1"
585
+ local config_file="$2"
586
+ local output_dir="$3"
587
+
588
+ if [ -d "$output_dir" ]; then
589
+ # Prune both real subdirectories and symlinks (incl. dir-symlinks):
590
+ # "-type d" alone would leave a stale symlinked skill in place and
591
+ # break the "helper owns the full lifecycle" contract.
592
+ find "$output_dir" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -exec rm -rf {} +
593
+ fi
594
+ mkdir -p "$output_dir"
595
+
596
+ local count=0
597
+ while IFS= read -r src; do
598
+ [ -z "$src" ] && continue
599
+ local dir
600
+ dir="$(resolve_source_dir "$repo_root" "$src")"
601
+ [ -d "$dir" ] || continue
602
+ for d in "$dir"/*/; do
603
+ [ -d "$d" ] || continue
604
+ local skill_name
605
+ skill_name="$(basename "$d")"
606
+ [ -f "$d/SKILL.md" ] || continue
607
+ # copy_skill_bundle now owns the frontmatter-quoting pass, so every
608
+ # target gets it — not just this open-standard dir.
609
+ copy_skill_bundle "$d" "$output_dir/$skill_name"
610
+ count=$((count + 1))
611
+ echo " skill: $skill_name"
612
+ done
613
+ done < <(read_yaml_list "$config_file" "skills")
614
+
615
+ echo " -> Skills: $count"
616
+ }
617
+
618
+ # Lint YAML frontmatter for common pitfalls (unquoted colons, leading tabs).
619
+ # Print warnings to stderr; do not fail. Strict consumers (Codex CLI) reject
620
+ # these files with cryptic messages — catching them in sync gives better DX.
621
+ # Usage: lint_frontmatter "path/to/file.md"
622
+ lint_frontmatter() {
623
+ local file="$1"
624
+ awk -v f="$file" '
625
+ BEGIN { in_fm = 0; line = 0 }
626
+ { sub(/\r$/, ""); line++ }
627
+ line == 1 && $0 != "---" { exit }
628
+ line == 1 { in_fm = 1; next }
629
+ in_fm && $0 == "---" { exit }
630
+ in_fm && /^\t/ {
631
+ printf " WARN: %s:%d leading tab in frontmatter (use spaces)\n", f, line > "/dev/stderr"
632
+ }
633
+ in_fm && /^[a-zA-Z0-9_-]+:[[:space:]]+[^"\047|>[{]/ {
634
+ value_start = index($0, ":") + 1
635
+ value = substr($0, value_start)
636
+ sub(/^[[:space:]]+/, "", value)
637
+ if (value ~ /:[[:space:]]/ || value ~ /:$/) {
638
+ col = index(value, ":") + value_start
639
+ printf " WARN: %s:%d unquoted colon in value at column %d — wrap value in quotes\n", f, line, col > "/dev/stderr"
640
+ }
641
+ if (value ~ /"/) {
642
+ printf " WARN: %s:%d literal double quote in unquoted value — wrap value in single quotes or escape as \\\" so strict-YAML targets accept it\n", f, line > "/dev/stderr"
643
+ }
644
+ }
645
+ # Field-length limits. Both Claude Code and the Agent Skills standard
646
+ # reject an over-long description outright ("Skill description must be
647
+ # at most 1024 characters") and the skill vanishes from the picker with
648
+ # no other signal, so catching it at sync time is the only cheap warning
649
+ # the author ever gets. Measured on the value, quotes excluded; a block
650
+ # scalar (`description: |`) is skipped — its length is not on this line.
651
+ in_fm && /^(description|name):[[:space:]]*[^|>[:space:]]/ {
652
+ key = substr($0, 1, index($0, ":") - 1)
653
+ val = substr($0, index($0, ":") + 1)
654
+ sub(/^[[:space:]]+/, "", val); sub(/[[:space:]]+$/, "", val)
655
+ first = substr(val, 1, 1); last = substr(val, length(val), 1)
656
+ if ((first == "\"" && last == "\"") || (first == "\047" && last == "\047")) {
657
+ val = substr(val, 2, length(val) - 2)
658
+ }
659
+ limit = (key == "name") ? 64 : 1024
660
+ if (length(val) > limit) {
661
+ printf " WARN: %s:%d %s is %d chars — over the %d-char limit; the skill/agent will be REJECTED at load time\n", f, line, key, length(val), limit > "/dev/stderr"
662
+ }
663
+ }
664
+ ' "$file"
665
+ }
666
+
667
+ # --- Frontmatter Parsing ---
668
+
669
+ # Extract a single value from YAML frontmatter by key.
670
+ # Splits on the FIRST colon only, so values containing additional colons
671
+ # (e.g. `description: "Use when: fixing APIs"`) are preserved. Reads only
672
+ # inside the first `---` ... `---` frontmatter block; body content is ignored.
673
+ # Strips surrounding double or single quotes from the value.
674
+ # Usage: get_frontmatter_value "tier" "path/to/file.md"
675
+ get_frontmatter_value() {
676
+ local key="$1"
677
+ local file="$2"
678
+ awk -v k="$key" '
679
+ { sub(/\r$/, "") }
680
+ NR == 1 && $0 != "---" { exit }
681
+ NR == 1 { in_fm = 1; next }
682
+ in_fm && $0 == "---" { exit }
683
+ !in_fm { next }
684
+
685
+ {
686
+ idx = index($0, ":")
687
+ if (idx == 0) next
688
+ line_key = substr($0, 1, idx - 1)
689
+ if (line_key != k) next
690
+ val = substr($0, idx + 1)
691
+ sub(/^[[:space:]]+/, "", val)
692
+ sub(/[[:space:]]+$/, "", val)
693
+ n = length(val)
694
+ if (n >= 2) {
695
+ first = substr(val, 1, 1)
696
+ last = substr(val, n, 1)
697
+ if ((first == "\"" && last == "\"") || (first == "\047" && last == "\047")) {
698
+ val = substr(val, 2, n - 2)
699
+ }
700
+ }
701
+ print val
702
+ exit
703
+ }
704
+ ' "$file"
705
+ }
706
+
707
+ # Check if a file starts with YAML frontmatter (---)
708
+ has_frontmatter() {
709
+ local file="$1"
710
+ awk 'NR==1 { sub(/\r$/, ""); if ($0 == "---") print 1; else print 0; exit }' "$file"
711
+ }
712
+
713
+ # Strip YAML frontmatter, print body only.
714
+ # Reads the first `---` ... `---` block at the top of the file and emits
715
+ # everything after the closing fence verbatim. CRLF-safe (trailing \r stripped
716
+ # per line). If the file has no frontmatter, the entire file is printed.
717
+ # Shared by adapters that wrap source agent bodies into IDE-native templates
718
+ # (currently pi.sh, opencode.sh) — do NOT inline-duplicate this awk block.
719
+ # Usage: body="$(strip_frontmatter "path/to/file.md")"
720
+ strip_frontmatter() {
721
+ local file="$1"
722
+ awk '
723
+ BEGIN { in_fm = 0; past_fm = 0 }
724
+ { sub(/\r$/, "") }
725
+ # No frontmatter: first line is not a `---` fence — treat the whole
726
+ # file as body so the helper honors its "print everything if no
727
+ # frontmatter" contract instead of emitting nothing.
728
+ NR == 1 && $0 != "---" { past_fm = 1 }
729
+ /^---$/ {
730
+ if (!past_fm) {
731
+ in_fm = !in_fm
732
+ if (!in_fm) { past_fm = 1 }
733
+ next
734
+ }
735
+ }
736
+ past_fm { print }
737
+ ' "$file"
738
+ }
739
+
740
+ # Check if a file has a paths: field in frontmatter.
741
+ # Scoped to the first `---` ... `---` block — body content like a code
742
+ # example referencing `paths: foo` will not be miscounted.
743
+ has_paths() {
744
+ local file="$1"
745
+ awk '
746
+ { sub(/\r$/, "") }
747
+ NR == 1 && $0 != "---" { print 0; done=1; exit }
748
+ NR == 1 { in_fm = 1; next }
749
+ in_fm && $0 == "---" { print c+0; done=1; exit }
750
+ in_fm && /^paths:/ { c++ }
751
+ END { if (!done) print c+0 }
752
+ ' "$file"
753
+ }
754
+
755
+ # --- Tier/Access Mappings ---
756
+
757
+ # Hardcoded defaults: ide:tier -> model name.
758
+ # When you bump these, re-run sync in projects; any project whose config.yaml
759
+ # `models:` section diverges from these will print a drift warning so users
760
+ # know their override is now stale.
761
+ get_model_default() {
762
+ local ide="$1"
763
+ local tier="$2"
764
+ case "$ide:$tier" in
765
+ claude:heavy) echo "opus" ;;
766
+ claude:standard) echo "sonnet" ;;
767
+ claude:light) echo "haiku" ;;
768
+ cursor:heavy) echo "inherit" ;;
769
+ cursor:standard) echo "inherit" ;;
770
+ cursor:light) echo "fast" ;;
771
+ copilot:heavy) echo "gpt-5.6-sol" ;;
772
+ copilot:standard) echo "gpt-5.6-terra" ;;
773
+ copilot:light) echo "gpt-5.6-luna" ;;
774
+ codex:heavy) echo "gpt-5.6-sol" ;;
775
+ codex:standard) echo "gpt-5.6-terra" ;;
776
+ codex:light) echo "gpt-5.6-luna" ;;
777
+ opencode:heavy) echo "anthropic/claude-opus-4-8" ;;
778
+ opencode:standard) echo "anthropic/claude-sonnet-5" ;;
779
+ opencode:light) echo "anthropic/claude-haiku-4-5-20251001" ;;
780
+ *) echo "" ;;
781
+ esac
782
+ }
783
+
784
+ # Read a nested key from config.yaml: section -> sub -> key.
785
+ # Resolves `models.<ide>.<tier>` overrides and `packs.<name>.<field>`.
786
+ #
787
+ # The value strip is anchored at the FIRST colon (`[^:]*:`), never a greedy
788
+ # `.*:` — a greedy match cuts at the LAST colon on the line, which turns
789
+ # `url: https://host/repo.git` into `//host/repo.git`. An unquoted value also
790
+ # drops a trailing ` # comment`, per YAML; inside quotes a `#` is content.
791
+ #
792
+ # The sub-key is matched LITERALLY (`index(...) == 1`), never interpolated into
793
+ # a regex: a pack name may contain `.`, which as a pattern is any character, so
794
+ # `packs.a.b` would happily read a pack named `axb`.
795
+ get_nested_yaml_value() {
796
+ local file="$1"
797
+ local section="$2"
798
+ local sub="$3"
799
+ local key="$4"
800
+ awk -v section="$section" -v subname="$sub" -v key="$key" '
801
+ { sub(/\r$/, "") }
802
+ $0 ~ "^" section ":[[:space:]]*$" { in_section=1; in_sub=0; next }
803
+ in_section && /^[a-zA-Z]/ && $0 !~ "^" section ":" { in_section=0; in_sub=0 }
804
+ in_section && index($0, " " subname ":") == 1 {
805
+ if (substr($0, length(subname) + 4) ~ /^[[:space:]]*$/) { in_sub=1; next }
806
+ }
807
+ in_section && in_sub && /^ [A-Za-z0-9_]/ { in_sub=0 }
808
+ in_section && in_sub && index($0, " " key ":") == 1 {
809
+ val = $0
810
+ sub(/^[[:space:]]*[^:]*:[[:space:]]*/, "", val)
811
+ if (val ~ /^"/ || val ~ /^\047/) {
812
+ q = substr(val, 1, 1)
813
+ val = substr(val, 2)
814
+ i = index(val, q)
815
+ if (i > 0) val = substr(val, 1, i - 1)
816
+ } else {
817
+ sub(/[[:space:]]+#.*$/, "", val)
818
+ sub(/[[:space:]]+$/, "", val)
819
+ }
820
+ print val
821
+ exit
822
+ }
823
+ ' "$file"
824
+ }
825
+
826
+ # Resolve a model: config.yaml `models:` override wins, otherwise default.
827
+ # Usage: get_model "$CONFIG_FILE" "claude" "$tier"
828
+ get_model() {
829
+ local config_file="$1"
830
+ local ide="$2"
831
+ local tier="${3:-heavy}"
832
+ local override
833
+ if [ -n "$config_file" ] && [ -f "$config_file" ]; then
834
+ override=$(get_nested_yaml_value "$config_file" "models" "$ide" "$tier")
835
+ fi
836
+ if [ -n "${override:-}" ]; then
837
+ echo "$override"
838
+ else
839
+ get_model_default "$ide" "$tier"
840
+ fi
841
+ }
842
+
843
+ # Print info message for each model override that differs from the
844
+ # hardcoded default. Helps users notice when a script update brings new
845
+ # defaults that their config still overrides with the old value.
846
+ # One awk pass extracts every `<ide>.<tier>=<value>` triple under `models:`;
847
+ # comparison against defaults happens in shell.
848
+ report_model_drift() {
849
+ local config_file="$1"
850
+ [ -f "$config_file" ] || return 0
851
+
852
+ local triples
853
+ triples=$(awk '
854
+ { sub(/\r$/, "") }
855
+ /^models:[[:space:]]*$/ { in_models=1; next }
856
+ in_models && /^[a-zA-Z]/ { in_models=0; in_ide="" }
857
+ in_models && /^ [a-zA-Z][a-zA-Z0-9_-]*:[[:space:]]*$/ {
858
+ line=$0
859
+ sub(/^ /, "", line); sub(/:[[:space:]]*$/, "", line)
860
+ in_ide=line
861
+ next
862
+ }
863
+ in_models && in_ide && /^ [a-zA-Z][a-zA-Z0-9_-]*:/ {
864
+ line=$0; sub(/^ /, "", line)
865
+ key=line; sub(/:.*/, "", key)
866
+ val=line; sub(/[^:]+:[[:space:]]*/, "", val)
867
+ gsub(/^["\047]|["\047][[:space:]]*$/, "", val)
868
+ print in_ide "\t" key "\t" val
869
+ }
870
+ ' "$config_file")
871
+
872
+ [ -z "$triples" ] && return 0
873
+
874
+ local printed_header=0
875
+ while IFS=$'\t' read -r ide tier from_config; do
876
+ [ -z "$from_config" ] && continue
877
+ local default
878
+ default=$(get_model_default "$ide" "$tier")
879
+ [ "$from_config" = "$default" ] && continue
880
+ if [ $printed_header -eq 0 ]; then
881
+ echo ""
882
+ echo "=== Model overrides (config.yaml differs from intelligence-sync defaults) ==="
883
+ printed_header=1
884
+ fi
885
+ printf " %-8s %-9s config=%-20s default=%s\n" "$ide" "$tier" "\"$from_config\"" "\"$default\""
886
+ done <<< "$triples"
887
+
888
+ if [ $printed_header -eq 1 ]; then
889
+ echo " (To accept new defaults: remove the entry from config.yaml \`models:\` section.)"
890
+ fi
891
+ }
892
+
893
+ # Map access level to Claude tools string
894
+ map_access_to_claude_tools() {
895
+ local access="$1"
896
+ case "$access" in
897
+ readonly) echo "Read, Grep, Glob, Bash" ;;
898
+ # full: emit NO tools list at all. Confirmed empirically in Copilot (VSCode,
899
+ # reading .claude/agents): a closed tools list restricts the agent to exactly
900
+ # those tools and loses MCP; omitting the field lets it inherit every session
901
+ # tool, MCP servers included. tools: ["*"] did NOT enable MCP - omission does.
902
+ # (Claude Code behaves the same: no tools field = inherit all incl MCP.)
903
+ # NOTE: omission also inherits Pylance + all built-ins, which can push the
904
+ # request over Copilot's 128-tool cap and trigger virtual-tools grouping that
905
+ # intermittently hides MCP. The durable fix is an explicit allowlist that
906
+ # NAMES the MCP servers (umbraco-mcp/*, figma/*) and stays under 128; that
907
+ # needs the project's MCP server list, so it is tracked, not encoded here yet.
908
+ *) echo "" ;;
909
+ esac
910
+ }
911
+
912
+ # Map access level to Claude disallowedTools (empty if full access)
913
+ map_access_to_claude_disallowed() {
914
+ local access="$1"
915
+ case "$access" in
916
+ readonly) echo "Write, Edit" ;;
917
+ *) echo "" ;;
918
+ esac
919
+ }
920
+
921
+ # --- Validation ---
922
+
923
+ # Lexically canonicalize a path: collapse `//`, `.` and `..` by pure string
924
+ # surgery, so a path that does not exist yet still normalizes (realpath -m is
925
+ # not POSIX and `cd` only works on dirs that exist). Symlinks are NOT resolved
926
+ # — validate_output_path pairs this with a `cd -P` check for paths that do
927
+ # exist.
928
+ # Usage: canon="$(normalize_path "/repo/a/../b")" # -> /repo/b
929
+ normalize_path() {
930
+ local path="$1" p out=""
931
+ local -a parts
932
+ IFS='/' read -r -a parts <<< "$path"
933
+ for p in "${parts[@]+"${parts[@]}"}"; do
934
+ case "$p" in
935
+ ""|".") ;;
936
+ "..") out="${out%/*}" ;;
937
+ *) out="$out/$p" ;;
938
+ esac
939
+ done
940
+ printf '%s' "${out:-/}"
941
+ }
942
+
943
+ # Refuse to operate on output paths that could clobber content.
944
+ # Adapters `rm -rf` subdirectories of $output_dir, and the `agents` adapter
945
+ # overwrites whatever single file it is pointed at; if config.yaml aims an
946
+ # adapter at the repo root, outside the repo, at the intelligence source tree
947
+ # (whatever the user named it), or at any configured source directory, that
948
+ # write would destroy real work. Call this from sync.sh before invoking EVERY
949
+ # adapter — `agents` included: writing one file to an arbitrary path is a
950
+ # config-file-to-arbitrary-write path, no less than a cleanup is.
951
+ #
952
+ # All forbidden paths are derived dynamically — no folder name is
953
+ # hardcoded, so projects that renamed `intelligence/` (capital I, custom
954
+ # name) are protected the same way.
955
+ #
956
+ # Exits 1 with a clear message on rejection.
957
+ # Usage: validate_output_path "$REPO_ROOT" "$CONFIG_FILE" "$adapter" "$output_dir"
958
+ validate_output_path() {
959
+ local repo_root="$1"
960
+ local config_file="$2"
961
+ local adapter="$3"
962
+ local output_dir="$4"
963
+
964
+ # Canonicalize FIRST. Every check below is a string comparison, so a `../`
965
+ # left in the raw value would walk straight past all of them.
966
+ local canon
967
+ canon="$(normalize_path "$output_dir")"
968
+
969
+ case "$canon" in
970
+ ""|"/"|"$repo_root")
971
+ echo "ERROR: targets.$adapter.output resolves to repo root or empty path: '$output_dir'" >&2
972
+ echo " Refusing to run — the adapter would destroy repository content." >&2
973
+ exit 1
974
+ ;;
975
+ esac
976
+
977
+ # Must stay inside the repository.
978
+ case "$canon" in
979
+ "$repo_root"/*) ;;
980
+ *)
981
+ echo "ERROR: targets.$adapter.output escapes the repository: '$output_dir'" >&2
982
+ echo " Resolves to '$canon', outside '$repo_root'." >&2
983
+ exit 1
984
+ ;;
985
+ esac
986
+
987
+ # A symlink ANYWHERE on the path can still lead out of the repo, which the
988
+ # lexical pass above cannot see. The output itself usually does not exist on
989
+ # a first sync, so checking only an existing final directory would miss the
990
+ # common case (`.cursor` absent, but its parent `generated/` symlinked out).
991
+ # Walk up to the deepest component that does exist and resolve THAT
992
+ # physically. `-L` in the loop guard so a broken symlink is caught rather
993
+ # than stepped over.
994
+ local probe="$canon" parent
995
+ while [ ! -e "$probe" ] && [ ! -L "$probe" ]; do
996
+ parent="$(dirname "$probe")"
997
+ [ "$parent" = "$probe" ] && break
998
+ probe="$parent"
999
+ done
1000
+
1001
+ if [ -e "$probe" ] || [ -L "$probe" ]; then
1002
+ # A symlinked FILE target (e.g. `AGENTS.md` -> /etc/hosts, or a dangling
1003
+ # link) would be written straight through. Refuse rather than follow it;
1004
+ # a generated output is never legitimately a file symlink.
1005
+ if [ -L "$probe" ] && [ ! -d "$probe" ]; then
1006
+ echo "ERROR: targets.$adapter.output ('$output_dir') resolves through a symlink ('$probe')." >&2
1007
+ echo " Refusing to write through it." >&2
1008
+ exit 1
1009
+ fi
1010
+ # Symlinked directory: allowed only while its physical target stays
1011
+ # inside the repo (`pwd -P` on both sides so a symlinked repo root
1012
+ # resolves consistently).
1013
+ local probe_dir phys repo_phys
1014
+ if [ -d "$probe" ]; then probe_dir="$probe"; else probe_dir="$(dirname "$probe")"; fi
1015
+ phys="$(cd "$probe_dir" 2>/dev/null && pwd -P)" || phys=""
1016
+ repo_phys="$(cd "$repo_root" && pwd -P)"
1017
+ case "${phys:-/nonexistent}" in
1018
+ "$repo_phys"|"$repo_phys"/*) ;;
1019
+ *)
1020
+ echo "ERROR: targets.$adapter.output ('$output_dir') resolves through a symlink to '$phys'," >&2
1021
+ echo " which is outside the repository. Refusing to run." >&2
1022
+ exit 1
1023
+ ;;
1024
+ esac
1025
+ fi
1026
+
1027
+ local rel="${canon#"$repo_root"/}"
1028
+
1029
+ # Reject the intelligence source directory itself (parent of config.yaml).
1030
+ # Folder name is whatever the user chose — we read it from the filesystem.
1031
+ local intel_dir intel_rel
1032
+ intel_dir="$(cd "$(dirname "$config_file")" && pwd)"
1033
+ intel_rel="${intel_dir#"$repo_root"/}"
1034
+ if [ -n "$intel_rel" ] && [ "$intel_rel" != "$intel_dir" ]; then
1035
+ case "$rel" in
1036
+ "$intel_rel"|"$intel_rel"/*)
1037
+ echo "ERROR: targets.$adapter.output points into the intelligence source tree ('$intel_rel'): '$rel'" >&2
1038
+ echo " The adapter would overwrite or delete rules / agents / skills source files." >&2
1039
+ exit 1
1040
+ ;;
1041
+ esac
1042
+ fi
1043
+
1044
+ # Explicitly protected directories (colon-separated, repo-relative). In CLI
1045
+ # mode the manifest sits at the repo root, so the parent-of-config
1046
+ # derivation above degrades to a no-op — the CLI names the source tree and
1047
+ # the package store here instead. Unset (every vendored setup) → inert.
1048
+ if [ -n "${IS_PROTECTED_DIRS:-}" ]; then
1049
+ local -a prot_arr=()
1050
+ local prot
1051
+ IFS=':' read -r -a prot_arr <<< "$IS_PROTECTED_DIRS"
1052
+ for prot in "${prot_arr[@]+"${prot_arr[@]}"}"; do
1053
+ [ -n "$prot" ] || continue
1054
+ case "$rel" in
1055
+ "$prot"|"$prot"/*)
1056
+ echo "ERROR: targets.$adapter.output points into a protected directory ('$prot'): '$rel'" >&2
1057
+ echo " The adapter would overwrite or delete source or package content." >&2
1058
+ exit 1
1059
+ ;;
1060
+ esac
1061
+ done
1062
+ fi
1063
+
1064
+ # Reject any configured source directory (rules, agents, skills).
1065
+ local section src src_rel
1066
+ for section in rules agents skills; do
1067
+ while IFS= read -r src; do
1068
+ [ -z "$src" ] && continue
1069
+ # Remote sources never resolve to a local output path — skip them
1070
+ # so a `git+...` spec or an `@pack` reference is not pattern-matched
1071
+ # against the output dir. A mirrored pack is covered separately,
1072
+ # below, by its declared `mirror:`.
1073
+ source_is_local_path "$src" || continue
1074
+ src_rel="$(normalize_path "$repo_root/$src")"
1075
+ src_rel="${src_rel#"$repo_root"/}"
1076
+ case "$rel" in
1077
+ "$src_rel"|"$src_rel"/*)
1078
+ echo "ERROR: targets.$adapter.output ('$rel') overlaps a configured source ('$src')." >&2
1079
+ echo " The adapter would overwrite or delete source content." >&2
1080
+ exit 1
1081
+ ;;
1082
+ esac
1083
+ done < <(read_yaml_list "$config_file" "$section")
1084
+ done
1085
+
1086
+ # Reject any pack mirror — materialized pack content is source, and it is
1087
+ # committed, so an adapter cleanup aimed at it would delete work that is not
1088
+ # regenerated until the next successful clone.
1089
+ local mirror_rel
1090
+ while IFS= read -r mirror_rel; do
1091
+ [ -z "$mirror_rel" ] && continue
1092
+ case "$rel" in
1093
+ "$mirror_rel"|"$mirror_rel"/*)
1094
+ echo "ERROR: targets.$adapter.output ('$rel') points into a pack mirror ('$mirror_rel')." >&2
1095
+ echo " The adapter would delete materialized pack content." >&2
1096
+ exit 1
1097
+ ;;
1098
+ esac
1099
+ done < <(list_pack_mirrors "$repo_root" "$config_file")
1100
+ }
1101
+
1102
+ # Resolve and validate one pack's `mirror:` into an absolute directory.
1103
+ #
1104
+ # materialize_pack `rm -rf`s this path, so the same class of check that guards
1105
+ # adapter outputs applies — with one deliberate difference: a mirror is ALLOWED
1106
+ # inside the intelligence umbrella, since `<umbrella>/external/<pack>` is the
1107
+ # recommended place for it.
1108
+ #
1109
+ # Echoes the absolute path; exits 1 with a clear message on rejection.
1110
+ # Usage: resolve_mirror_dir "$REPO_ROOT" "$CONFIG_FILE" <pack-name> <mirror-rel>
1111
+ resolve_mirror_dir() {
1112
+ local repo_root="$1" config_file="$2" name="$3" rel="$4"
1113
+
1114
+ local canon
1115
+ canon="$(normalize_path "$repo_root/$rel")"
1116
+
1117
+ case "$canon" in
1118
+ ""|"/"|"$repo_root")
1119
+ echo "ERROR: packs.$name.mirror resolves to repo root or empty path: '$rel'" >&2
1120
+ exit 1
1121
+ ;;
1122
+ esac
1123
+ case "$canon" in
1124
+ "$repo_root"/*) ;;
1125
+ *)
1126
+ echo "ERROR: packs.$name.mirror escapes the repository: '$rel' (resolves to '$canon')." >&2
1127
+ exit 1
1128
+ ;;
1129
+ esac
1130
+
1131
+ # Never inside a configured source tree: a pack directory created there
1132
+ # would be `rm -rf`d alongside authored rules / agents / skills.
1133
+ local canon_rel section src src_rel
1134
+ canon_rel="${canon#"$repo_root"/}"
1135
+ for section in rules agents skills; do
1136
+ while IFS= read -r src; do
1137
+ [ -z "$src" ] && continue
1138
+ source_is_local_path "$src" || continue
1139
+ src_rel="$(normalize_path "$repo_root/$src")"
1140
+ src_rel="${src_rel#"$repo_root"/}"
1141
+ case "$canon_rel" in
1142
+ "$src_rel"|"$src_rel"/*)
1143
+ echo "ERROR: packs.$name.mirror ('$rel') is inside a configured source ('$src')." >&2
1144
+ echo " Materializing a pack there would overwrite authored content." >&2
1145
+ exit 1
1146
+ ;;
1147
+ esac
1148
+ done < <(read_yaml_list "$config_file" "$section")
1149
+ done
1150
+
1151
+ printf '%s' "$canon"
1152
+ }
1153
+
1154
+ # Fail the run on a `@<pack>` source that names a pack the config does not
1155
+ # declare, and on a declared `mirror:` that is unsafe to `rm -rf`.
1156
+ #
1157
+ # This runs UP FRONT, before any adapter, because resolve_source_dir is always
1158
+ # called inside `$( )`: an error raised down there would exit the substitution
1159
+ # subshell only, and the caller's `[ -d "$dir" ] || continue` guard would turn a
1160
+ # typo into a silently dropped rule set — the exact failure this feature exists
1161
+ # to remove. A missing local path stays a warning; a bad pack reference does not,
1162
+ # because the config claims to know that name.
1163
+ #
1164
+ # Exits 1 with a clear message on rejection.
1165
+ # Usage: validate_pack_refs "$REPO_ROOT" "$CONFIG_FILE"
1166
+ validate_pack_refs() {
1167
+ local repo_root="$1" config_file="$2"
1168
+ local section src name known bad=0
1169
+
1170
+ for section in rules agents skills; do
1171
+ while IFS= read -r src; do
1172
+ [ -z "$src" ] && continue
1173
+ source_is_pack "$src" || continue
1174
+ name="${src#@}"
1175
+ name="${name%%/*}"
1176
+ if [ -z "$(get_pack_field "$config_file" "$name" "url")" ]; then
1177
+ echo "ERROR: sources.$section entry '$src' references pack '$name', which has no 'packs.$name.url' in $config_file." >&2
1178
+ bad=1
1179
+ fi
1180
+ done < <(read_yaml_list "$config_file" "$section")
1181
+ done
1182
+
1183
+ if [ "$bad" -ne 0 ]; then
1184
+ known="$(read_yaml_keys "$config_file" "packs" | tr '\n' ' ')"
1185
+ echo " Declared packs: ${known:-<none>}" >&2
1186
+ # `targets:` accepts the flow form, so a user reasonably writes
1187
+ # `packs:\n shared: { url: … }` — which reads as zero declared packs and
1188
+ # makes the message above point at a typo that is not there. Scoped to
1189
+ # the `packs:` block: every shipped example writes `targets:` in flow
1190
+ # form, so an unscoped match would print this note on every failure.
1191
+ if awk '
1192
+ { sub(/\r$/, "") }
1193
+ /^packs:[[:space:]]*$/ { in_p = 1; next }
1194
+ /^[A-Za-z]/ { in_p = 0 }
1195
+ in_p && /^ [A-Za-z0-9_][A-Za-z0-9._-]*:[[:space:]]*\{/ { found = 1; exit }
1196
+ END { exit !found }
1197
+ ' "$config_file"; then
1198
+ echo " Note: a pack must be declared in block form — 'name:' on its own line," >&2
1199
+ echo " then indented 'url:' / 'ref:' / 'mirror:'. The '{ … }' form is not read here." >&2
1200
+ fi
1201
+ exit 1
1202
+ fi
1203
+
1204
+ # Validate every declared mirror once, before a single clone runs, so an
1205
+ # unsafe path fails the run rather than being discovered mid-materialization.
1206
+ #
1207
+ # Two packs sharing one mirror is refused here too: the wipe is claimed per
1208
+ # DIRECTORY, so the second pack would skip the clear and copy its subpaths
1209
+ # in beside the first's, leaving one directory holding two packs' content
1210
+ # under a single `.pack`. Nothing downstream can untangle that.
1211
+ local rel canon i
1212
+ local mirror_dirs=() mirror_owners=()
1213
+ while IFS= read -r name; do
1214
+ [ -z "$name" ] && continue
1215
+ rel="$(get_pack_field "$config_file" "$name" "mirror")"
1216
+ [ -n "$rel" ] || continue
1217
+ canon="$(resolve_mirror_dir "$repo_root" "$config_file" "$name" "$rel")"
1218
+ i=0
1219
+ while [ "$i" -lt "${#mirror_dirs[@]}" ]; do
1220
+ if [ "${mirror_dirs[$i]}" = "$canon" ]; then
1221
+ echo "ERROR: packs.$name.mirror ('$rel') is already the mirror of pack '${mirror_owners[$i]}'." >&2
1222
+ echo " Each pack needs its own directory — sharing one leaves a single '.pack' stamp" >&2
1223
+ echo " naming one pack over a directory holding both packs' content." >&2
1224
+ exit 1
1225
+ fi
1226
+ i=$((i + 1))
1227
+ done
1228
+ mirror_dirs+=("$canon"); mirror_owners+=("$name")
1229
+ done < <(read_yaml_keys "$config_file" "packs")
1230
+ }
1231
+
1232
+ # Every declared pack's `mirror:`, one repo-relative path per line (packs with
1233
+ # no mirror contribute nothing). Used by the guards that must not mistake
1234
+ # materialized pack content for either an adapter output or an unsynced source.
1235
+ # Usage: readarray -t mirrors < <(list_pack_mirrors "$REPO_ROOT" "$CONFIG_FILE")
1236
+ list_pack_mirrors() {
1237
+ local repo_root="$1" config_file="$2"
1238
+ local name rel canon
1239
+ while IFS= read -r name; do
1240
+ [ -z "$name" ] && continue
1241
+ rel="$(get_pack_field "$config_file" "$name" "mirror")"
1242
+ [ -n "$rel" ] || continue
1243
+ canon="$(normalize_path "$repo_root/$rel")"
1244
+ printf '%s\n' "${canon#"$repo_root"/}"
1245
+ done < <(read_yaml_keys "$config_file" "packs")
1246
+ }
1247
+
1248
+ # Warn about prompt directories not listed in sources.
1249
+ # Scans for `rules/` / `agents/` / `skills/` directories anywhere under the
1250
+ # intelligence source tree and any sibling tree with the same basename
1251
+ # (e.g. nested per-component intelligence folders). Anything found that is
1252
+ # not in `sources.*` is flagged. No folder name is hardcoded — the
1253
+ # intelligence directory is whatever holds `config.yaml`.
1254
+ warn_unsynced() {
1255
+ local repo_root="$1"
1256
+ local config_file="$2"
1257
+
1258
+ # Collect all configured source paths (local only — a remote spec and an
1259
+ # `@pack` reference are not filesystem dirs and cannot collide with an
1260
+ # unsynced local directory).
1261
+ local all_sources=()
1262
+ for section in rules agents skills; do
1263
+ while IFS= read -r src; do
1264
+ [ -z "$src" ] && continue
1265
+ source_is_local_path "$src" || continue
1266
+ all_sources+=("$src")
1267
+ done < <(read_yaml_list "$config_file" "$section")
1268
+ done
1269
+
1270
+ # Collect ignore + submodule patterns.
1271
+ local ignores=()
1272
+ while IFS= read -r ign; do
1273
+ [ -z "$ign" ] && continue
1274
+ ignores+=("$ign")
1275
+ done < <(read_yaml_list "$config_file" "ignore")
1276
+ while IFS= read -r sub; do
1277
+ [ -z "$sub" ] && continue
1278
+ ignores+=("$sub")
1279
+ done < <(read_yaml_list "$config_file" "submodules")
1280
+ # Materialized packs hold rules/ agents/ skills/ dirs that are reached
1281
+ # through their `@<pack>` source entry, never listed as local sources — so
1282
+ # the scan below would flag every one of them as unsynced.
1283
+ local mirror_rel
1284
+ while IFS= read -r mirror_rel; do
1285
+ [ -n "$mirror_rel" ] && ignores+=("${mirror_rel%/}")
1286
+ done < <(list_pack_mirrors "$repo_root" "$config_file")
1287
+
1288
+ # Derive the intelligence folder basename from config.yaml's location —
1289
+ # whatever the user named it (`intelligence`, `Intelligence`, `prompts`).
1290
+ # In CLI mode the manifest sits at the repo root, so that derivation would
1291
+ # yield the repo directory's own name and never match — the content dir
1292
+ # comes from the env contract instead.
1293
+ local intel_basename
1294
+ if [ "${IS_CLI:-0}" = "1" ]; then
1295
+ intel_basename="$(basename "${IS_UMBRELLA_REL:-intelligence}")"
1296
+ else
1297
+ intel_basename="$(basename "$(dirname "$config_file")")"
1298
+ fi
1299
+
1300
+ local warnings=0
1301
+
1302
+ while IFS= read -r found_dir; do
1303
+ local rel_dir="${found_dir#$repo_root/}"
1304
+
1305
+ # Skip generated output directories and common excludes. The package
1306
+ # store (.intelligence/) is CLI-managed content reached through its own
1307
+ # sources entries — flagging it would warn on every installed package.
1308
+ case "$rel_dir" in
1309
+ .claude/*|.cursor/*|.github/*|.codex/*|.agents/*|.intelligence/*|*/node_modules/*|*/vendor/*|*/dist/*) continue ;;
1310
+ esac
1311
+
1312
+ # Skip ignore/submodule patterns.
1313
+ local skip=false
1314
+ for ign in "${ignores[@]+"${ignores[@]}"}"; do
1315
+ case "$rel_dir" in
1316
+ "$ign"/*|*/"$ign"/*) skip=true; break ;;
1317
+ esac
1318
+ done
1319
+ [ "$skip" = true ] && continue
1320
+
1321
+ # Only flag directories whose ancestry includes a folder with the
1322
+ # same basename as the intelligence source dir (so we catch
1323
+ # `Intelligence/rules`, `apps/billing/intelligence/rules`, etc.,
1324
+ # but not unrelated `rules/` / `agents/` directories elsewhere).
1325
+ case "/$rel_dir/" in
1326
+ *"/$intel_basename/"*) ;;
1327
+ *) continue ;;
1328
+ esac
1329
+
1330
+ # Check if directory has content worth syncing.
1331
+ local has_content=false
1332
+ if [ -n "$(find "$found_dir" -maxdepth 1 -name '*.md' 2>/dev/null | head -1)" ]; then
1333
+ has_content=true
1334
+ fi
1335
+ if [ -n "$(find "$found_dir" -maxdepth 2 -name 'SKILL.md' 2>/dev/null | head -1)" ]; then
1336
+ has_content=true
1337
+ fi
1338
+ [ "$has_content" = false ] && continue
1339
+
1340
+ # Check if this directory is in any source array.
1341
+ local matched=false
1342
+ for src in "${all_sources[@]+"${all_sources[@]}"}"; do
1343
+ if [ "$rel_dir" = "$src" ]; then
1344
+ matched=true
1345
+ break
1346
+ fi
1347
+ done
1348
+
1349
+ if [ "$matched" = false ]; then
1350
+ if [ $warnings -eq 0 ]; then
1351
+ echo ""
1352
+ echo "=== WARNING: Unsynced directories ==="
1353
+ fi
1354
+ echo " NOT SYNCED: $rel_dir"
1355
+ warnings=$((warnings + 1))
1356
+ fi
1357
+ 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)
1358
+
1359
+ if [ $warnings -gt 0 ]; then
1360
+ echo " Add these paths to sources: in $(basename "$config_file")"
1361
+ fi
1362
+ }
1363
+
1364
+ # --- Config Parsing ---
1365
+
1366
+ # Read a simple list from config.yaml
1367
+ # Format: key:\n - "value1"\n - "value2"
1368
+ # Usage: readarray -t arr < <(read_yaml_list "config.yaml" "rules")
1369
+ read_yaml_list() {
1370
+ local file="$1"
1371
+ local section="$2"
1372
+ awk -v section="$section" '
1373
+ {
1374
+ sub(/\r$/, "")
1375
+ }
1376
+ /^[a-z]/ { current_section = ""; depth = 0 }
1377
+ /^ [a-z]/ { current_section = ""; depth = 0 }
1378
+ $0 ~ "^" section ":" { current_section = section; depth = 0; next }
1379
+ $0 ~ "^ " section ":" { current_section = section; depth = 2; next }
1380
+ current_section == section && depth == 0 && /^ - / {
1381
+ val = $0
1382
+ sub(/^ - /, "", val)
1383
+ gsub(/["\047]/, "", val)
1384
+ print val
1385
+ }
1386
+ current_section == section && depth == 2 && /^ - / {
1387
+ val = $0
1388
+ sub(/^ - /, "", val)
1389
+ gsub(/["\047]/, "", val)
1390
+ print val
1391
+ }
1392
+ ' "$file"
1393
+ }
1394
+
1395
+ # Check if a target is enabled in config.yaml (scoped to targets: section)
1396
+ # Usage: is_target_enabled "config.yaml" "claude"
1397
+ is_target_enabled() {
1398
+ local file="$1"
1399
+ local target="$2"
1400
+ awk -v target="$target" '
1401
+ { sub(/\r$/, "") }
1402
+
1403
+ # Enter/leave the targets: section
1404
+ /^targets:[[:space:]]*$/ { in_targets = 1; next }
1405
+ /^[a-zA-Z]/ { in_targets = 0 }
1406
+
1407
+ in_targets && $0 ~ "^ " target ":" {
1408
+ if ($0 ~ /enabled:[[:space:]]*true/) { print 1; exit }
1409
+ if ($0 ~ /enabled:[[:space:]]*false/) { print 0; exit }
1410
+ in_target = 1; next
1411
+ }
1412
+ in_target && /enabled:/ {
1413
+ if ($0 ~ /true/) { print 1 } else { print 0 }
1414
+ exit
1415
+ }
1416
+ in_target && /^ [a-zA-Z]/ { print 0; exit }
1417
+ ' "$file"
1418
+ }
1419
+
1420
+ # Get an arbitrary field from a target's config block.
1421
+ # Handles both inline (`claude: { enabled: true, output: ".claude" }`)
1422
+ # and block form. Uses POSIX awk only — no gawk-specific 3-arg match().
1423
+ # Usage: get_target_field "config.yaml" "claude" "output"
1424
+ get_target_field() {
1425
+ local file="$1"
1426
+ local target="$2"
1427
+ local field="$3"
1428
+ awk -v target="$target" -v field="$field" '
1429
+ { sub(/\r$/, "") }
1430
+ /^targets:[[:space:]]*$/ { in_targets = 1; next }
1431
+ /^[a-zA-Z]/ { in_targets = 0 }
1432
+
1433
+ in_targets && $0 ~ "^ " target ":" {
1434
+ line = $0
1435
+ inline_pat = "[ {,]" field ":[[:space:]]*"
1436
+ if (match(line, inline_pat)) {
1437
+ # Strip everything up to and including the field key.
1438
+ rest = substr(line, RSTART + RLENGTH)
1439
+ # Cut at the next comma or closing brace.
1440
+ if (match(rest, /[,}]/)) {
1441
+ rest = substr(rest, 1, RSTART - 1)
1442
+ }
1443
+ # Strip surrounding quotes and trailing space.
1444
+ gsub(/^["\047]|["\047][[:space:]]*$/, "", rest)
1445
+ sub(/[[:space:]]+$/, "", rest)
1446
+ if (rest != "") { print rest; exit }
1447
+ }
1448
+ in_target = 1; next
1449
+ }
1450
+ in_target && $0 ~ "^ " field ":[[:space:]]*" {
1451
+ val = $0
1452
+ sub(/.*:[[:space:]]*["\047]?/, "", val)
1453
+ sub(/["\047]?[[:space:]]*$/, "", val)
1454
+ print val
1455
+ exit
1456
+ }
1457
+ in_target && /^ [a-zA-Z]/ { exit }
1458
+ ' "$file"
1459
+ }
1460
+
1461
+ # Get output directory for a target (scoped to targets: section).
1462
+ # POSIX awk — no 3-arg match().
1463
+ # Usage: get_target_output "config.yaml" "claude"
1464
+ get_target_output() {
1465
+ local file="$1"
1466
+ local target="$2"
1467
+ awk -v target="$target" '
1468
+ { sub(/\r$/, "") }
1469
+
1470
+ /^targets:[[:space:]]*$/ { in_targets = 1; next }
1471
+ /^[a-zA-Z]/ { in_targets = 0 }
1472
+
1473
+ in_targets && $0 ~ "^ " target ":" {
1474
+ line = $0
1475
+ if (match(line, /output:[[:space:]]*/)) {
1476
+ rest = substr(line, RSTART + RLENGTH)
1477
+ if (match(rest, /[,}]/)) {
1478
+ rest = substr(rest, 1, RSTART - 1)
1479
+ }
1480
+ gsub(/^["\047]|["\047][[:space:]]*$/, "", rest)
1481
+ sub(/[[:space:]]+$/, "", rest)
1482
+ if (rest != "") { print rest; exit }
1483
+ }
1484
+ in_target = 1; next
1485
+ }
1486
+ in_target && /output:/ {
1487
+ val = $0
1488
+ sub(/.*output:[[:space:]]*["\047]?/, "", val)
1489
+ sub(/["\047]?[[:space:]]*}?$/, "", val)
1490
+ print val
1491
+ exit
1492
+ }
1493
+ in_target && /^ [a-zA-Z]/ { exit }
1494
+ ' "$file"
1495
+ }
1496
+
1497
+ # Read a multi-line block scalar (| or > style) from a target's field.
1498
+ # Usage: get_target_block "config.yaml" "agents" "header"
1499
+ # Reads YAML of the shape:
1500
+ # targets:
1501
+ # agents:
1502
+ # header: |
1503
+ # # Project
1504
+ # one-liner
1505
+ # Strips the common content indent from all lines in the block.
1506
+ get_target_block() {
1507
+ local file="$1"
1508
+ local target="$2"
1509
+ local field="$3"
1510
+ awk -v target="$target" -v field="$field" '
1511
+ { sub(/\r$/, "") }
1512
+
1513
+ /^targets:[[:space:]]*$/ { in_targets = 1; next }
1514
+ /^[a-zA-Z]/ { in_targets = 0 }
1515
+
1516
+ # State 0: looking for " <target>:" under targets
1517
+ state == 0 && in_targets && $0 ~ "^ " target ":[[:space:]]*$" {
1518
+ state = 1
1519
+ next
1520
+ }
1521
+
1522
+ # State 1: inside target block, looking for " <field>: |"
1523
+ state == 1 {
1524
+ if ($0 ~ /^[^ ]/) { exit } # top-level key — exit
1525
+ if ($0 ~ /^ [a-zA-Z]/) { exit } # another target — exit
1526
+ if ($0 ~ "^[[:space:]]+" field ":[[:space:]]*[|>][[:space:]]*$") {
1527
+ match($0, /^[[:space:]]+/)
1528
+ field_indent = RLENGTH
1529
+ state = 2
1530
+ block_indent = 0
1531
+ }
1532
+ next
1533
+ }
1534
+
1535
+ # State 2: collecting block contents
1536
+ state == 2 {
1537
+ if ($0 ~ /^[[:space:]]*$/) {
1538
+ print ""
1539
+ next
1540
+ }
1541
+ match($0, /^[[:space:]]*/)
1542
+ cur_indent = RLENGTH
1543
+ if (cur_indent <= field_indent) { exit }
1544
+ if (block_indent == 0) { block_indent = cur_indent }
1545
+ if (cur_indent < block_indent) { exit }
1546
+ print substr($0, block_indent + 1)
1547
+ }
1548
+ ' "$file"
1549
+ }
1550
+
1551
+ # Read a scalar field out of a top-level config block: `section:` -> ` key:`.
1552
+ # The single two-level reader — `get_nested_yaml_value` and `get_target_field`
1553
+ # cover the three-level shapes (`models.<ide>.<tier>`, `targets.<name>.<field>`).
1554
+ # Usage: get_yaml_field "config.yaml" "external" "dir"
1555
+ get_yaml_field() {
1556
+ local file="$1"
1557
+ local section="$2"
1558
+ local key="$3"
1559
+ awk -v section="$section" -v key="$key" '
1560
+ { sub(/\r$/, "") }
1561
+ $0 ~ "^" section ":" { in_section = 1; next }
1562
+ in_section && /^[a-zA-Z]/ { exit }
1563
+ in_section && $0 ~ "^ " key ":" {
1564
+ val = $0
1565
+ sub(/.*:[[:space:]]*["\047]?/, "", val)
1566
+ sub(/["\047]?[[:space:]]*$/, "", val)
1567
+ print val
1568
+ exit
1569
+ }
1570
+ ' "$file"
1571
+ }
1572
+
1573
+ # Get project name from config.yaml (project.name)
1574
+ get_project_name() {
1575
+ get_yaml_field "$1" "project" "name"
1576
+ }
1577
+
1578
+ # One field of a declared pack: `packs.<name>.<url|ref|mirror>`.
1579
+ # `mirror` empty means the pack is transient — cloned per run, never committed.
1580
+ get_pack_field() {
1581
+ get_nested_yaml_value "$1" "packs" "$2" "$3"
1582
+ }
1583
+
1584
+ # Immediate sub-keys of a top-level block, one per line (`packs:` → pack names).
1585
+ # Block form only: a pack always spans several lines, so the inline `{...}` form
1586
+ # that `get_target_field` accommodates has no use here.
1587
+ # Usage: readarray -t names < <(read_yaml_keys "config.yaml" "packs")
1588
+ read_yaml_keys() {
1589
+ local file="$1" section="$2"
1590
+ [ -f "$file" ] || return 0
1591
+ awk -v section="$section" '
1592
+ { sub(/\r$/, "") }
1593
+ $0 ~ "^" section ":[[:space:]]*$" { in_sec = 1; next }
1594
+ /^[A-Za-z]/ { in_sec = 0 }
1595
+ in_sec && /^ [A-Za-z0-9_][A-Za-z0-9._-]*:[[:space:]]*$/ {
1596
+ k = $0
1597
+ sub(/^[[:space:]]+/, "", k)
1598
+ sub(/:[[:space:]]*$/, "", k)
1599
+ print k
1600
+ }
1601
+ ' "$file"
1602
+ }