@ainova-systems/intelligence 0.11.0-rc.5 → 0.11.0-rc.7

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 (53) hide show
  1. package/README.md +2 -2
  2. package/cli/commands/add.sh +26 -20
  3. package/cli/commands/init.sh +24 -9
  4. package/cli/commands/install.sh +55 -4
  5. package/cli/commands/migrate.sh +62 -9
  6. package/cli/commands/registry.sh +2 -9
  7. package/cli/commands/search.sh +4 -6
  8. package/cli/commands/sync.sh +1 -1
  9. package/cli/commands/update.sh +29 -5
  10. package/cli/engine-package.yaml +14 -0
  11. package/cli/intelligence +27 -15
  12. package/cli/lib/cli-common.sh +52 -17
  13. package/cli/lib/lockfile.sh +14 -8
  14. package/cli/lib/manifest.sh +59 -1
  15. package/cli/lib/registry.sh +41 -55
  16. package/cli/lib/semver.sh +4 -2
  17. package/engine/ENGINE_SHA +1 -0
  18. package/engine/{scripts/adapters → adapters}/agents.sh +10 -15
  19. package/engine/{scripts/lib → lib}/common.sh +15 -519
  20. package/engine/lib/contract.sh +120 -0
  21. package/engine/sync.sh +233 -0
  22. package/package.json +5 -5
  23. package/engine/INIT.md +0 -500
  24. package/engine/docs/CLI.md +0 -94
  25. package/engine/scripts/ENGINE_SHA +0 -1
  26. package/engine/scripts/lib/layout.sh +0 -51
  27. package/engine/scripts/lib/migrations.sh +0 -708
  28. package/engine/scripts/sync.sh +0 -311
  29. package/engine/scripts/update.sh +0 -237
  30. package/registry/index.yaml +0 -26
  31. /package/engine/{scripts/VERSION → VERSION} +0 -0
  32. /package/engine/{scripts/adapters → adapters}/_template.sh +0 -0
  33. /package/engine/{scripts/adapters → adapters}/claude.sh +0 -0
  34. /package/engine/{scripts/adapters → adapters}/codex.sh +0 -0
  35. /package/engine/{scripts/adapters → adapters}/copilot.sh +0 -0
  36. /package/engine/{scripts/adapters → adapters}/cursor.sh +0 -0
  37. /package/engine/{scripts/adapters → adapters}/opencode.sh +0 -0
  38. /package/engine/{scripts/adapters → adapters}/pi.sh +0 -0
  39. /package/{engine → packages/sync}/agents/intelligence-architect.md +0 -0
  40. /package/{engine → packages/sync}/agents/intelligence-operator.md +0 -0
  41. /package/{engine → packages/sync}/docs/ADAPTERS.md +0 -0
  42. /package/{engine → packages/sync}/docs/CONVENTIONS.md +0 -0
  43. /package/{engine → packages/sync}/rules/intelligence-authoring.md +0 -0
  44. /package/{engine → packages/sync}/skills/intelligence-add-agent/SKILL.md +0 -0
  45. /package/{engine → packages/sync}/skills/intelligence-add-rule/SKILL.md +0 -0
  46. /package/{engine → packages/sync}/skills/intelligence-add-skill/SKILL.md +0 -0
  47. /package/{engine → packages/sync}/skills/intelligence-extract-skill/SKILL.md +0 -0
  48. /package/{engine → packages/sync}/skills/intelligence-install-adapter/SKILL.md +0 -0
  49. /package/{engine → packages/sync}/skills/intelligence-learn-from-context/SKILL.md +0 -0
  50. /package/{engine → packages/sync}/skills/intelligence-review-skills/SKILL.md +0 -0
  51. /package/{engine → packages/sync}/skills/intelligence-sync/SKILL.md +0 -0
  52. /package/{engine → packages/sync}/skills/intelligence-uninstall-adapter/SKILL.md +0 -0
  53. /package/{engine → packages/sync}/skills/intelligence-update/SKILL.md +0 -0
@@ -97,59 +97,17 @@ yaml_dq_escape() {
97
97
 
98
98
  # --- Source Resolution -------------------------------------------------------
99
99
  #
100
- # A `sources.*` entry is normally a LOCAL path resolved as `$repo_root/<entry>`.
101
- # It may instead be a REMOTE git spec, which is materialized (shallow-cloned)
102
- # and resolved to a local directory inside the clone. This is the SINGLE point
103
- # where remote sources are detected and fetched — every adapter and sync.sh
104
- # routes its `$repo_root/$src` through resolve_source_dir, so no other file
105
- # needs to know about remote sources.
106
- #
107
- # Spec format (inline string, so read_yaml_list parses it unchanged):
108
- # git+<url>[@<ref>][#<subpath>]
109
- # <url> explicit-scheme URL (https/http/ssh/git/file). Other transports
110
- # (notably the command-executing ext::/fd::) are rejected.
111
- # @<ref> optional tag / branch / SHA — the segment after the last `@` in
112
- # the post-scheme part, accepted only if it has no `/` (so
113
- # userinfo like `ssh://git@host/...` is not mistaken for a ref;
114
- # branch names containing `/` are unsupported — use a tag, SHA,
115
- # or slashless branch, which is the recommended pin anyway).
116
- # #<subpath> optional dir inside the clone holding rules/agents/skills.
117
-
118
- # True (0) if a source token is a remote git spec.
119
- source_is_remote() {
120
- case "$1" in
121
- git+*) return 0 ;;
122
- *) return 1 ;;
123
- esac
124
- }
125
-
126
- # True (0) if a source token references a pack declared under `packs:`
127
- # (`@<name>` or `@<name>/<subpath>`).
128
- source_is_pack() {
129
- case "$1" in
130
- @?*) return 0 ;;
131
- *) return 1 ;;
132
- esac
133
- }
134
-
135
- # True (0) if a source token is a plain repo-relative path — i.e. NOT a pack
136
- # reference and NOT an inline remote spec. The inverse of "resolves through a
137
- # clone", which is what every caller that pattern-matches a token against a
138
- # real directory needs.
139
- source_is_local_path() {
140
- source_is_pack "$1" && return 1
141
- source_is_remote "$1" && return 1
142
- return 0
143
- }
100
+ # Every `sources.*` entry is a repo-relative path resolved as
101
+ # `$repo_root/<entry>`. Fetching, version resolution and pinning belong to the
102
+ # CLI: by the time the engine runs, an installed package is an ordinary
103
+ # directory under the store, indistinguishable from project content.
144
104
 
145
105
  # Map an absolute file path to a repo-root-relative path for use as a link
146
106
  # target inside a COMMITTED file (e.g. AGENTS.md). A file under $repo_root gets
147
- # its repo-relative path. A file resolved OUTSIDE $repo_root comes from a remote
148
- # source materialized in the transient clone cache and has NO stable,
149
- # committable path, so this returns the empty string — callers MUST then emit
150
- # the bare name, never the absolute path. The `${path#"$repo_root"/}` strip is a
151
- # no-op when $path is not under $repo_root, which is how the out-of-repo case is
152
- # detected.
107
+ # its repo-relative path; one resolved outside it has no stable, committable
108
+ # path, so this returns the empty string — callers MUST then emit the bare
109
+ # name, never the absolute path. The `${path#"$repo_root"/}` strip is a no-op
110
+ # when $path is not under $repo_root, which is how that case is detected.
153
111
  repo_rel_link() {
154
112
  local repo_root="$1" path="$2" rel
155
113
  rel="${path#"$repo_root"/}"
@@ -191,291 +149,15 @@ repo_rel_dir() {
191
149
  done
192
150
  }
193
151
 
194
- # Resolve a single source token to an absolute local directory.
195
- # Local token -> "$repo_root/$token".
196
- # Remote token -> shallow-clone into the run cache, echo "<clone>/<subpath>".
152
+ # Resolve a single source token to an absolute local directory. Every token is
153
+ # a repo-relative path: the CLI resolves, fetches and pins packages, so by the
154
+ # time the engine runs a package is just a directory under the store.
197
155
  # ALWAYS returns 0 (echoes nothing on failure) so `set -e` callers using
198
156
  # `dir="$(resolve_source_dir ...)"` never abort; the caller's existing
199
157
  # `[ -d "$dir" ] || continue` guard then skips an unresolved source.
200
158
  # Usage: dir="$(resolve_source_dir "$repo_root" "$src")"
201
159
  resolve_source_dir() {
202
- local repo_root="$1" token="$2" config_file="${3:-${IS_CONFIG_FILE:-}}"
203
-
204
- # A declared pack (`@<name>[/<subpath>]`) keeps its url / ref / mirror in
205
- # config.yaml, so the token itself carries nothing but the reference.
206
- if source_is_pack "$token"; then
207
- resolve_pack_source "$repo_root" "$config_file" "$token"
208
- return 0
209
- fi
210
-
211
- if ! source_is_remote "$token"; then
212
- printf '%s' "$repo_root/$token"
213
- return 0
214
- fi
215
-
216
- # --- parse: git+<url>[@<ref>][#<subpath>] ---
217
- # An inline spec is an ANONYMOUS pack: it has no declared name and no
218
- # mirror, so it is always transient. Declare it under `packs:` to commit it.
219
- local rest="${token#git+}"
220
- local subpath="" urlref="$rest"
221
- case "$rest" in
222
- *\#*) subpath="${rest#*#}"; urlref="${rest%%#*}" ;;
223
- esac
224
-
225
- # ref = segment after the last `@` in the post-scheme part, only if it has
226
- # no `/` (else it is userinfo such as `git@host`, not a ref).
227
- local url="$urlref" ref="" after_scheme="${urlref#*://}"
228
- case "$after_scheme" in
229
- *@*)
230
- local cand="${after_scheme##*@}"
231
- case "$cand" in
232
- */*|"") ;; # userinfo / empty -> no ref
233
- *) ref="$cand"; url="${urlref%@$ref}" ;;
234
- esac
235
- ;;
236
- esac
237
-
238
- fetch_remote_source "$token" "$url" "$ref" "$subpath" ""
239
- }
240
-
241
- # Resolve `@<name>[/<subpath>]` against the `packs:` block and fetch it.
242
- # An undeclared name is a HARD error: unlike a mistyped local path (which the
243
- # caller's `[ -d ]` guard silently skips), a pack reference names something the
244
- # config claims to know, so a typo must not quietly drop a whole rule set.
245
- # Usage: resolve_pack_source "$repo_root" "$config_file" "@shared/rules"
246
- resolve_pack_source() {
247
- local repo_root="$1" config_file="$2" token="$3"
248
-
249
- local rest="${token#@}" name subpath=""
250
- case "$rest" in
251
- */*) name="${rest%%/*}"; subpath="${rest#*/}" ;;
252
- *) name="$rest" ;;
253
- esac
254
-
255
- local url ref mirror_rel mirror_abs=""
256
- url="$(get_pack_field "$config_file" "$name" "url")"
257
- # Defensive only: validate_pack_refs has already failed the run for an
258
- # undeclared pack. It has to, because every caller invokes this inside `$( )`
259
- # — an exit here would end the substitution subshell, not the sync.
260
- if [ -z "$url" ]; then
261
- echo " WARN: pack '$name' is not declared under 'packs:': $token" >&2
262
- return 0
263
- fi
264
- ref="$(get_pack_field "$config_file" "$name" "ref")"
265
- mirror_rel="$(get_pack_field "$config_file" "$name" "mirror")"
266
- [ -n "$mirror_rel" ] && mirror_abs="$(resolve_mirror_dir "$repo_root" "$config_file" "$name" "$mirror_rel")"
267
-
268
- fetch_remote_source "$token" "$url" "$ref" "$subpath" "$mirror_abs" "$mirror_rel"
269
- }
270
-
271
- # Shallow-clone <url>@<ref> into the run cache, echo "<clone>/<subpath>", and —
272
- # when <mirror_abs> is set — additionally materialize it there so the content is
273
- # committed. <token> is only used for messages.
274
- # ALWAYS returns 0 (echoes nothing on failure), per resolve_source_dir's contract.
275
- # Usage: fetch_remote_source <token> <url> <ref> <subpath> <mirror_abs> [<mirror_rel>]
276
- fetch_remote_source() {
277
- local token="$1" url="$2" ref="$3" subpath="$4" mirror="$5" mirror_rel="${6:-}"
278
-
279
- # Reject path traversal in the subpath: a remote spec must not be able to
280
- # escape the clone dir (e.g. `#../../etc`). Checked before any clone.
281
- case "/$subpath/" in
282
- */../*)
283
- echo " WARN: remote source rejected (subpath traversal '..'): $token" >&2
284
- return 0
285
- ;;
286
- esac
287
-
288
- # Scheme whitelist — reject everything but plain fetch transports. The
289
- # ext::/fd:: transports execute arbitrary commands on clone, so a malicious
290
- # or mistyped config must never reach `git clone` with them.
291
- case "$url" in
292
- https://*|http://*|ssh://*|git://*|file://*) ;;
293
- *)
294
- echo " WARN: remote source rejected (unsupported scheme): $token" >&2
295
- return 0
296
- ;;
297
- esac
298
-
299
- if ! command -v git >/dev/null 2>&1; then
300
- echo " WARN: remote source needs git, which is not installed: $token" >&2
301
- return 0
302
- fi
303
-
304
- # Cache root: run-scoped (set + cleaned by sync.sh) or a stable fallback so
305
- # direct adapter calls still avoid re-cloning the same spec within a run.
306
- local cache_root="${IS_REMOTE_CACHE:-${TMPDIR:-/tmp}/intelligence-sync-remotes}"
307
- mkdir -p "$cache_root" 2>/dev/null || true
308
- # Key on repo URL + ref ONLY (not the subpath): sources that point at the
309
- # same repo@ref but different subpaths (e.g. `...repo.git@main#rules` and
310
- # `...repo.git@main#skills`) share a SINGLE clone; the subpath only selects
311
- # a directory inside it. Different ref → different clone (distinct versions).
312
- local key
313
- key="$(printf '%s' "$url@$ref" | cksum | awk '{print $1 "-" $2}')"
314
- local dest="$cache_root/$key"
315
-
316
- if [ ! -d "$dest/.git" ]; then
317
- rm -rf "$dest"
318
- # Untrusted remote content: never materialize symlinks from the cloned
319
- # repo. With core.symlinks=false git writes each symlink as a plain text
320
- # file holding its target path, so a hostile link like `skills -> /etc`
321
- # cannot make the copy pipeline read host files outside the clone.
322
- #
323
- # Line endings are pinned for the same reason the engine's own
324
- # `.gitattributes` pins them: the checkout must not depend on the host's
325
- # `core.autocrlf`. On Windows that default rewrites a pack declaring no
326
- # attributes to CRLF, `materialize_pack` copies bytes verbatim, and the
327
- # mirror lands CRLF inside the project repo — a phantom diff that returns
328
- # on every sync. `autocrlf=false` covers packs with no attributes,
329
- # `eol=lf` covers those that mark files `text`.
330
- local git_cfg=(-c core.symlinks=false -c core.autocrlf=false -c core.eol=lf)
331
- local ok=0
332
- if [ -n "$ref" ]; then
333
- if GIT_TERMINAL_PROMPT=0 git "${git_cfg[@]}" clone --depth 1 --branch "$ref" --quiet \
334
- "$url" "$dest" 2>/dev/null; then
335
- ok=1
336
- else
337
- # ref is likely a SHA (not a branch/tag) — full clone + checkout.
338
- rm -rf "$dest"
339
- if GIT_TERMINAL_PROMPT=0 git "${git_cfg[@]}" clone --quiet "$url" "$dest" 2>/dev/null \
340
- && git -C "$dest" "${git_cfg[@]}" checkout --quiet "$ref" 2>/dev/null; then
341
- ok=1
342
- fi
343
- fi
344
- elif GIT_TERMINAL_PROMPT=0 git "${git_cfg[@]}" clone --depth 1 --quiet "$url" "$dest" 2>/dev/null; then
345
- ok=1
346
- fi
347
- if [ "$ok" -ne 1 ]; then
348
- rm -rf "$dest"
349
- echo " WARN: remote source clone failed (url=$url ref=${ref:-<default>}): $token" >&2
350
- return 0
351
- fi
352
- echo " remote: cloned $url${ref:+ @$ref}" >&2
353
- fi
354
-
355
- local out="$dest"
356
- [ -n "$subpath" ] && out="$dest/$subpath"
357
- if [ ! -d "$out" ]; then
358
- echo " WARN: remote source subpath not found ('${subpath:-/}') in $url: $token" >&2
359
- return 0
360
- fi
361
- # Containment (defense in depth on top of the `..` reject + symlink-free
362
- # checkout): the resolved dir must stay inside the clone. Canonicalize both
363
- # with `pwd -P` so a symlinked TMPDIR (e.g. macOS /tmp -> /private/tmp)
364
- # resolves consistently on each side.
365
- local real_dest real_out
366
- real_dest="$(cd "$dest" 2>/dev/null && pwd -P)"
367
- real_out="$(cd "$out" 2>/dev/null && pwd -P)"
368
- case "${real_out:-/nonexistent}" in
369
- "$real_dest"|"$real_dest"/*) ;;
370
- *)
371
- echo " WARN: remote source subpath escapes the clone ('${subpath:-/}'): $token" >&2
372
- return 0
373
- ;;
374
- esac
375
-
376
- # No `mirror:` -> the clone stays in the transient run cache and nothing
377
- # lands in the repo. This is every inline `git+` spec, and any pack that
378
- # declares no mirror.
379
- if [ -z "$mirror" ]; then
380
- printf '%s' "$out"
381
- return 0
382
- fi
383
- materialize_pack "$dest" "$out" "$url" "$ref" "$subpath" "$mirror" "$mirror_rel"
384
- return 0
385
- }
386
-
387
- # Copy a resolved remote source out of the transient clone into the pack's
388
- # declared `mirror:` directory, so pack content is committed and an upstream
389
- # bump shows up in `git diff` instead of only in the generated output. Echoes
390
- # the materialized directory; on any failure echoes the clone dir instead, so a
391
- # broken mirror degrades to the transient behaviour rather than losing the
392
- # source.
393
- #
394
- # The directory is DECLARED, never derived — `mirror:` says exactly where the
395
- # pack lives, so there is no name to sanitize and no collision to resolve.
396
- #
397
- # The FIRST token to touch a pack in a run wipes it (clearing content left by a
398
- # previous ref, or by a source entry that has since been removed); later tokens
399
- # for the same pack only replace their own subpath. The claim is recorded in the
400
- # clone cache, which is what makes "wipe once per run" work across the separate
401
- # subshells each resolve_source_dir call runs in.
402
- #
403
- # The wipe is guarded by the stamp: a NON-EMPTY directory with no `.pack` in it
404
- # is never deleted — it belongs to the project, not to us.
405
- # <mirror_rel> is the path as authored in config.yaml, used only in messages.
406
- # Usage: materialize_pack <clone> <src_dir> <url> <ref> <subpath> <mirror> <mirror_rel>
407
- materialize_pack() {
408
- local clone="$1" src_dir="$2" url="$3" ref="$4" subpath="$5" pack_dir="$6" mirror_rel="${7:-}"
409
-
410
- # The claim is keyed on the DIRECTORY, not on url@ref: it records "this run
411
- # already cleared this path". Keying it on the clone would let two packs
412
- # that share a url@ref but declare different mirrors claim each other's,
413
- # leaving the second mirror unstamped and never pruned.
414
- #
415
- # Same cache-root fallback as the clone: the claim must exist even when the
416
- # caller is not sync.sh, or every token would re-wipe the pack and only the
417
- # last subpath would survive. That fallback root is NOT run-scoped, though,
418
- # so the claim carries `$$` — stable across the command-substitution
419
- # subshells of one run, different for the next. A claim left behind by an
420
- # earlier run must never suppress this run's wipe: that would rebuild the
421
- # mirror with no `.pack` in it and freeze it against the guard below.
422
- local cache_root claim
423
- cache_root="${IS_REMOTE_CACHE:-${TMPDIR:-/tmp}/intelligence-sync-remotes}"
424
- mkdir -p "$cache_root" 2>/dev/null || true
425
- claim="$cache_root/$$-$(printf '%s' "$pack_dir" | cksum | awk '{print $1 "-" $2}').packdir"
426
-
427
- if [ ! -f "$claim" ]; then
428
- # Refuse to wipe a directory that is not ours. Ownership is the PRESENCE
429
- # of the stamp, not the url inside it: a mirror is declared per pack, so
430
- # a stamped directory is this pack's even after its `url:` is edited —
431
- # a moved or renamed upstream must refresh the mirror, not freeze it at
432
- # the old content while the generated output silently follows the new.
433
- if [ -d "$pack_dir" ] && [ -n "$(find "$pack_dir" -mindepth 1 -maxdepth 1 2>/dev/null)" ] \
434
- && [ ! -f "$pack_dir/.pack" ]; then
435
- echo " WARN: mirror '$pack_dir' holds content that is not a pack's (no .pack stamp) — skipping materialization" >&2
436
- printf '%s' "$src_dir"
437
- return 0
438
- fi
439
-
440
- rm -rf "$pack_dir"
441
- if ! mkdir -p "$pack_dir"; then
442
- echo " WARN: cannot create mirror dir '$pack_dir' — using the run cache" >&2
443
- printf '%s' "$src_dir"
444
- return 0
445
- fi
446
- {
447
- printf 'url=%s\n' "$url"
448
- printf 'ref=%s\n' "${ref:-<default>}"
449
- printf 'sha=%s\n' "$(git -C "$clone" rev-parse HEAD 2>/dev/null || echo unknown)"
450
- } > "$pack_dir/.pack"
451
- printf '%s\n' "$pack_dir" > "$claim"
452
- echo " pack: $url${ref:+ @$ref} -> ${mirror_rel:-$pack_dir}" >&2
453
- fi
454
-
455
- # Only a subpath is cleared here — clearing the pack root would delete the
456
- # `.pack` stamp written above (and any sibling subpath already copied in
457
- # this run). The root is already clean: the claim step wiped it.
458
- local dest="$pack_dir"
459
- if [ -n "$subpath" ]; then
460
- dest="$pack_dir/$subpath"
461
- rm -rf "$dest"
462
- fi
463
- mkdir -p "$dest"
464
-
465
- # Copy the subpath's contents, skipping `.git` — it only exists when the
466
- # source IS the clone root (no `#subpath`), and a nested `.git` inside the
467
- # project repo would be recorded as a gitlink, which is exactly the
468
- # untrackable state this whole feature exists to avoid.
469
- local entry base
470
- for entry in "$src_dir"/* "$src_dir"/.[!.]*; do
471
- [ -e "$entry" ] || continue
472
- base="${entry##*/}"
473
- [ "$base" = ".git" ] && continue
474
- cp -R "$entry" "$dest/"
475
- done
476
-
477
- printf '%s' "$dest"
478
- return 0
160
+ printf '%s' "$1/$2"
479
161
  }
480
162
 
481
163
  # Copy a markdown file with frontmatter, ensuring free-text string fields are
@@ -1070,11 +752,6 @@ validate_output_path() {
1070
752
  for section in rules agents skills; do
1071
753
  while IFS= read -r src; do
1072
754
  [ -z "$src" ] && continue
1073
- # Remote sources never resolve to a local output path — skip them
1074
- # so a `git+...` spec or an `@pack` reference is not pattern-matched
1075
- # against the output dir. A mirrored pack is covered separately,
1076
- # below, by its declared `mirror:`.
1077
- source_is_local_path "$src" || continue
1078
755
  src_rel="$(normalize_path "$repo_root/$src")"
1079
756
  src_rel="${src_rel#"$repo_root"/}"
1080
757
  case "$rel" in
@@ -1086,167 +763,6 @@ validate_output_path() {
1086
763
  esac
1087
764
  done < <(read_yaml_list "$config_file" "$section")
1088
765
  done
1089
-
1090
- # Reject any pack mirror — materialized pack content is source, and it is
1091
- # committed, so an adapter cleanup aimed at it would delete work that is not
1092
- # regenerated until the next successful clone.
1093
- local mirror_rel
1094
- while IFS= read -r mirror_rel; do
1095
- [ -z "$mirror_rel" ] && continue
1096
- case "$rel" in
1097
- "$mirror_rel"|"$mirror_rel"/*)
1098
- echo "ERROR: targets.$adapter.output ('$rel') points into a pack mirror ('$mirror_rel')." >&2
1099
- echo " The adapter would delete materialized pack content." >&2
1100
- exit 1
1101
- ;;
1102
- esac
1103
- done < <(list_pack_mirrors "$repo_root" "$config_file")
1104
- }
1105
-
1106
- # Resolve and validate one pack's `mirror:` into an absolute directory.
1107
- #
1108
- # materialize_pack `rm -rf`s this path, so the same class of check that guards
1109
- # adapter outputs applies — with one deliberate difference: a mirror is ALLOWED
1110
- # inside the intelligence umbrella, since `<umbrella>/external/<pack>` is the
1111
- # recommended place for it.
1112
- #
1113
- # Echoes the absolute path; exits 1 with a clear message on rejection.
1114
- # Usage: resolve_mirror_dir "$REPO_ROOT" "$CONFIG_FILE" <pack-name> <mirror-rel>
1115
- resolve_mirror_dir() {
1116
- local repo_root="$1" config_file="$2" name="$3" rel="$4"
1117
-
1118
- local canon
1119
- canon="$(normalize_path "$repo_root/$rel")"
1120
-
1121
- case "$canon" in
1122
- ""|"/"|"$repo_root")
1123
- echo "ERROR: packs.$name.mirror resolves to repo root or empty path: '$rel'" >&2
1124
- exit 1
1125
- ;;
1126
- esac
1127
- case "$canon" in
1128
- "$repo_root"/*) ;;
1129
- *)
1130
- echo "ERROR: packs.$name.mirror escapes the repository: '$rel' (resolves to '$canon')." >&2
1131
- exit 1
1132
- ;;
1133
- esac
1134
-
1135
- # Never inside a configured source tree: a pack directory created there
1136
- # would be `rm -rf`d alongside authored rules / agents / skills.
1137
- local canon_rel section src src_rel
1138
- canon_rel="${canon#"$repo_root"/}"
1139
- for section in rules agents skills; do
1140
- while IFS= read -r src; do
1141
- [ -z "$src" ] && continue
1142
- source_is_local_path "$src" || continue
1143
- src_rel="$(normalize_path "$repo_root/$src")"
1144
- src_rel="${src_rel#"$repo_root"/}"
1145
- case "$canon_rel" in
1146
- "$src_rel"|"$src_rel"/*)
1147
- echo "ERROR: packs.$name.mirror ('$rel') is inside a configured source ('$src')." >&2
1148
- echo " Materializing a pack there would overwrite authored content." >&2
1149
- exit 1
1150
- ;;
1151
- esac
1152
- done < <(read_yaml_list "$config_file" "$section")
1153
- done
1154
-
1155
- printf '%s' "$canon"
1156
- }
1157
-
1158
- # Fail the run on a `@<pack>` source that names a pack the config does not
1159
- # declare, and on a declared `mirror:` that is unsafe to `rm -rf`.
1160
- #
1161
- # This runs UP FRONT, before any adapter, because resolve_source_dir is always
1162
- # called inside `$( )`: an error raised down there would exit the substitution
1163
- # subshell only, and the caller's `[ -d "$dir" ] || continue` guard would turn a
1164
- # typo into a silently dropped rule set — the exact failure this feature exists
1165
- # to remove. A missing local path stays a warning; a bad pack reference does not,
1166
- # because the config claims to know that name.
1167
- #
1168
- # Exits 1 with a clear message on rejection.
1169
- # Usage: validate_pack_refs "$REPO_ROOT" "$CONFIG_FILE"
1170
- validate_pack_refs() {
1171
- local repo_root="$1" config_file="$2"
1172
- local section src name known bad=0
1173
-
1174
- for section in rules agents skills; do
1175
- while IFS= read -r src; do
1176
- [ -z "$src" ] && continue
1177
- source_is_pack "$src" || continue
1178
- name="${src#@}"
1179
- name="${name%%/*}"
1180
- if [ -z "$(get_pack_field "$config_file" "$name" "url")" ]; then
1181
- echo "ERROR: sources.$section entry '$src' references pack '$name', which has no 'packs.$name.url' in $config_file." >&2
1182
- bad=1
1183
- fi
1184
- done < <(read_yaml_list "$config_file" "$section")
1185
- done
1186
-
1187
- if [ "$bad" -ne 0 ]; then
1188
- known="$(read_yaml_keys "$config_file" "packs" | tr '\n' ' ')"
1189
- echo " Declared packs: ${known:-<none>}" >&2
1190
- # `targets:` accepts the flow form, so a user reasonably writes
1191
- # `packs:\n shared: { url: … }` — which reads as zero declared packs and
1192
- # makes the message above point at a typo that is not there. Scoped to
1193
- # the `packs:` block: every shipped example writes `targets:` in flow
1194
- # form, so an unscoped match would print this note on every failure.
1195
- if awk '
1196
- { sub(/\r$/, "") }
1197
- /^packs:[[:space:]]*$/ { in_p = 1; next }
1198
- /^[A-Za-z]/ { in_p = 0 }
1199
- in_p && /^ [A-Za-z0-9_][A-Za-z0-9._-]*:[[:space:]]*\{/ { found = 1; exit }
1200
- END { exit !found }
1201
- ' "$config_file"; then
1202
- echo " Note: a pack must be declared in block form — 'name:' on its own line," >&2
1203
- echo " then indented 'url:' / 'ref:' / 'mirror:'. The '{ … }' form is not read here." >&2
1204
- fi
1205
- exit 1
1206
- fi
1207
-
1208
- # Validate every declared mirror once, before a single clone runs, so an
1209
- # unsafe path fails the run rather than being discovered mid-materialization.
1210
- #
1211
- # Two packs sharing one mirror is refused here too: the wipe is claimed per
1212
- # DIRECTORY, so the second pack would skip the clear and copy its subpaths
1213
- # in beside the first's, leaving one directory holding two packs' content
1214
- # under a single `.pack`. Nothing downstream can untangle that.
1215
- local rel canon i
1216
- local mirror_dirs=() mirror_owners=()
1217
- while IFS= read -r name; do
1218
- [ -z "$name" ] && continue
1219
- rel="$(get_pack_field "$config_file" "$name" "mirror")"
1220
- [ -n "$rel" ] || continue
1221
- canon="$(resolve_mirror_dir "$repo_root" "$config_file" "$name" "$rel")"
1222
- i=0
1223
- while [ "$i" -lt "${#mirror_dirs[@]}" ]; do
1224
- if [ "${mirror_dirs[$i]}" = "$canon" ]; then
1225
- echo "ERROR: packs.$name.mirror ('$rel') is already the mirror of pack '${mirror_owners[$i]}'." >&2
1226
- echo " Each pack needs its own directory — sharing one leaves a single '.pack' stamp" >&2
1227
- echo " naming one pack over a directory holding both packs' content." >&2
1228
- exit 1
1229
- fi
1230
- i=$((i + 1))
1231
- done
1232
- mirror_dirs+=("$canon"); mirror_owners+=("$name")
1233
- done < <(read_yaml_keys "$config_file" "packs")
1234
- }
1235
-
1236
- # Every declared pack's `mirror:`, one repo-relative path per line (packs with
1237
- # no mirror contribute nothing). Used by the guards that must not mistake
1238
- # materialized pack content for either an adapter output or an unsynced source.
1239
- # Usage: readarray -t mirrors < <(list_pack_mirrors "$REPO_ROOT" "$CONFIG_FILE")
1240
- list_pack_mirrors() {
1241
- local repo_root="$1" config_file="$2"
1242
- local name rel canon
1243
- while IFS= read -r name; do
1244
- [ -z "$name" ] && continue
1245
- rel="$(get_pack_field "$config_file" "$name" "mirror")"
1246
- [ -n "$rel" ] || continue
1247
- canon="$(normalize_path "$repo_root/$rel")"
1248
- printf '%s\n' "${canon#"$repo_root"/}"
1249
- done < <(read_yaml_keys "$config_file" "packs")
1250
766
  }
1251
767
 
1252
768
  # Warn about prompt directories not listed in sources.
@@ -1259,14 +775,10 @@ warn_unsynced() {
1259
775
  local repo_root="$1"
1260
776
  local config_file="$2"
1261
777
 
1262
- # Collect all configured source paths (local only — a remote spec and an
1263
- # `@pack` reference are not filesystem dirs and cannot collide with an
1264
- # unsynced local directory).
1265
778
  local all_sources=()
1266
779
  for section in rules agents skills; do
1267
780
  while IFS= read -r src; do
1268
781
  [ -z "$src" ] && continue
1269
- source_is_local_path "$src" || continue
1270
782
  all_sources+=("$src")
1271
783
  done < <(read_yaml_list "$config_file" "$section")
1272
784
  done
@@ -1281,19 +793,9 @@ warn_unsynced() {
1281
793
  [ -z "$sub" ] && continue
1282
794
  ignores+=("$sub")
1283
795
  done < <(read_yaml_list "$config_file" "submodules")
1284
- # Materialized packs hold rules/ agents/ skills/ dirs that are reached
1285
- # through their `@<pack>` source entry, never listed as local sources — so
1286
- # the scan below would flag every one of them as unsynced.
1287
- local mirror_rel
1288
- while IFS= read -r mirror_rel; do
1289
- [ -n "$mirror_rel" ] && ignores+=("${mirror_rel%/}")
1290
- done < <(list_pack_mirrors "$repo_root" "$config_file")
1291
-
1292
- # Derive the intelligence folder basename from config.yaml's location —
1293
- # whatever the user named it (`intelligence`, `Intelligence`, `prompts`).
1294
- # In CLI mode the manifest sits at the repo root, so that derivation would
1295
- # yield the repo directory's own name and never match — the content dir
1296
- # comes from the env contract instead.
796
+
797
+ # The manifest sits at the repo root, so the content dir cannot be derived
798
+ # from its location — it comes from the env contract the CLI exports.
1297
799
  local intel_basename
1298
800
  if [ "${IS_CLI:-0}" = "1" ]; then
1299
801
  intel_basename="$(basename "${IS_UMBRELLA_REL:-intelligence}")"
@@ -1579,12 +1081,6 @@ get_project_name() {
1579
1081
  get_yaml_field "$1" "project" "name"
1580
1082
  }
1581
1083
 
1582
- # One field of a declared pack: `packs.<name>.<url|ref|mirror>`.
1583
- # `mirror` empty means the pack is transient — cloned per run, never committed.
1584
- get_pack_field() {
1585
- get_nested_yaml_value "$1" "packs" "$2" "$3"
1586
- }
1587
-
1588
1084
  # Immediate sub-keys of a top-level block, one per line (`packs:` → pack names).
1589
1085
  # Block form only: a pack always spans several lines, so the inline `{...}` form
1590
1086
  # that `get_target_field` accommodates has no use here.