@ainova-systems/intelligence 0.11.0-rc.1 → 0.11.0-rc.10

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 (72) hide show
  1. package/README.md +19 -16
  2. package/cli/commands/adapter.sh +153 -0
  3. package/cli/commands/init.sh +171 -34
  4. package/cli/commands/package.sh +30 -0
  5. package/cli/commands/registry.sh +65 -30
  6. package/cli/commands/status.sh +20 -11
  7. package/cli/commands/sync.sh +9 -4
  8. package/cli/commands/update.sh +80 -46
  9. package/cli/engine-package.yaml +14 -0
  10. package/cli/intelligence +37 -26
  11. package/cli/internal/align-project.sh +134 -0
  12. package/cli/internal/check.sh +128 -0
  13. package/cli/internal/convert-legacy.sh +400 -0
  14. package/cli/{commands/add.sh → internal/package-add.sh} +37 -19
  15. package/cli/{commands/list.sh → internal/package-list.sh} +10 -5
  16. package/cli/{commands/remove.sh → internal/package-remove.sh} +16 -4
  17. package/cli/internal/package-search.sh +83 -0
  18. package/cli/internal/package-update.sh +103 -0
  19. package/cli/internal/restore.sh +130 -0
  20. package/cli/internal/target-state.sh +57 -0
  21. package/cli/lib/cli-common.sh +213 -63
  22. package/cli/lib/lockfile.sh +15 -9
  23. package/cli/lib/manifest.sh +257 -4
  24. package/cli/lib/registry.sh +96 -47
  25. package/cli/lib/semver.sh +12 -4
  26. package/engine/ENGINE_SHA +1 -0
  27. package/engine/{scripts/adapters → adapters}/_template.sh +10 -9
  28. package/engine/{scripts/adapters → adapters}/agents.sh +20 -25
  29. package/engine/{scripts/adapters → adapters}/opencode.sh +1 -1
  30. package/engine/{scripts/lib → lib}/common.sh +30 -538
  31. package/engine/lib/contract.sh +120 -0
  32. package/engine/sync.sh +238 -0
  33. package/package.json +5 -5
  34. package/{engine → packages/sync}/agents/intelligence-architect.md +5 -3
  35. package/{engine → packages/sync}/agents/intelligence-operator.md +10 -13
  36. package/packages/sync/references/adapters.md +252 -0
  37. package/packages/sync/references/conventions.md +385 -0
  38. package/{engine → packages/sync}/rules/intelligence-authoring.md +7 -7
  39. package/{engine → packages/sync}/skills/intelligence-add-agent/SKILL.md +7 -7
  40. package/{engine → packages/sync}/skills/intelligence-add-rule/SKILL.md +5 -5
  41. package/{engine → packages/sync}/skills/intelligence-add-skill/SKILL.md +5 -5
  42. package/{engine → packages/sync}/skills/intelligence-extract-skill/SKILL.md +2 -2
  43. package/packages/sync/skills/intelligence-install-adapter/SKILL.md +38 -0
  44. package/{engine → packages/sync}/skills/intelligence-learn-from-context/SKILL.md +3 -3
  45. package/packages/sync/skills/intelligence-learn-from-repository/SKILL.md +55 -0
  46. package/{engine → packages/sync}/skills/intelligence-review-skills/SKILL.md +7 -7
  47. package/packages/sync/skills/intelligence-sync/SKILL.md +20 -0
  48. package/packages/sync/skills/intelligence-uninstall-adapter/SKILL.md +24 -0
  49. package/packages/sync/skills/intelligence-update/SKILL.md +43 -0
  50. package/cli/commands/doctor.sh +0 -100
  51. package/cli/commands/install.sh +0 -84
  52. package/cli/commands/migrate.sh +0 -251
  53. package/cli/commands/upgrade.sh +0 -27
  54. package/engine/INIT.md +0 -498
  55. package/engine/docs/ADAPTERS.md +0 -212
  56. package/engine/docs/CLI.md +0 -91
  57. package/engine/docs/CONVENTIONS.md +0 -440
  58. package/engine/scripts/lib/layout.sh +0 -51
  59. package/engine/scripts/lib/migrations.sh +0 -708
  60. package/engine/scripts/sync.sh +0 -311
  61. package/engine/scripts/update.sh +0 -237
  62. package/engine/skills/intelligence-install-adapter/SKILL.md +0 -31
  63. package/engine/skills/intelligence-sync/SKILL.md +0 -18
  64. package/engine/skills/intelligence-uninstall-adapter/SKILL.md +0 -42
  65. package/engine/skills/intelligence-update/SKILL.md +0 -159
  66. package/registry/index.yaml +0 -15
  67. /package/engine/{scripts/VERSION → VERSION} +0 -0
  68. /package/engine/{scripts/adapters → adapters}/claude.sh +0 -0
  69. /package/engine/{scripts/adapters → adapters}/codex.sh +0 -0
  70. /package/engine/{scripts/adapters → adapters}/copilot.sh +0 -0
  71. /package/engine/{scripts/adapters → adapters}/cursor.sh +0 -0
  72. /package/engine/{scripts/adapters → adapters}/pi.sh +0 -0
@@ -16,16 +16,14 @@ normalize_file_to_lf() {
16
16
 
17
17
  # --- Layout tokens -----------------------------------------------------------
18
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:
19
+ # The content directory is named by the project (`intelligence/`,
20
+ # `Intelligence/`, a codename), so package-shipped artifacts cannot hardcode
21
+ # it. Layout-sensitive paths and commands use tokens expanded at output time:
24
22
  #
25
- # <umbrella> -> the repo-relative umbrella dir (e.g. `Intelligence`)
26
- # <module> -> the repo-relative engine module (e.g. `Intelligence/sync`)
23
+ # <content-dir> -> the repo-relative project content dir (e.g. `Intelligence`)
24
+ # <module> -> the repo-relative sync package store path
27
25
  #
28
- # Values come from IS_UMBRELLA_REL / IS_MODULE_REL, which sync.sh derives from
26
+ # Values come from IS_CONTENT_REL / IS_MODULE_REL, which the CLI derives from
29
27
  # the detected layout (never hardcoded) and exports before any adapter runs.
30
28
  # Expansion happens in EVERY generated file, frontmatter and body alike, so a
31
29
  # scoped rule reaches Claude's `paths:`, Cursor's `globs:` and Copilot's
@@ -34,21 +32,18 @@ normalize_file_to_lf() {
34
32
  # finalize_output_file <file>
35
33
  # The single exit gate for every file an adapter writes: expand layout tokens,
36
34
  # 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.
35
+ # on each output — a missed call ships a literal `<content-dir>` into an IDE.
38
36
  finalize_output_file() {
39
37
  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}"
38
+ local content="${IS_CONTENT_REL:-intelligence}"
39
+ local mod="${IS_MODULE_REL:-.intelligence/packages/@ainova-systems/sync}"
40
+ local sc="${IS_SYNC_CMD:-intelligence sync}"
41
+ local mf="${IS_MANIFEST_NAME:-intelligence.yaml}"
47
42
  local tmp_file="$target.tmp"
48
43
  # Literal (index-based) substitution, not gsub: a regex replacement would
49
44
  # give `&` in a path its special meaning, and POSIX awk has no way to pass a
50
45
  # replacement string verbatim.
51
- awk -v umb="$umb" -v mod="$mod" -v sc="$sc" '
46
+ awk -v content="$content" -v mod="$mod" -v sc="$sc" -v mf="$mf" '
52
47
  function repl(s, from, to, out, i) {
53
48
  out = ""
54
49
  while ((i = index(s, from)) > 0) {
@@ -60,8 +55,9 @@ finalize_output_file() {
60
55
  {
61
56
  sub(/\r$/, "")
62
57
  $0 = repl($0, "<sync-cmd>", sc)
58
+ $0 = repl($0, "<manifest>", mf)
63
59
  $0 = repl($0, "<module>", mod)
64
- $0 = repl($0, "<umbrella>", umb)
60
+ $0 = repl($0, "<content-dir>", content)
65
61
  print
66
62
  }
67
63
  ' "$target" > "$tmp_file"
@@ -93,59 +89,17 @@ yaml_dq_escape() {
93
89
 
94
90
  # --- Source Resolution -------------------------------------------------------
95
91
  #
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
- }
92
+ # Every `sources.*` entry is a repo-relative path resolved as
93
+ # `$repo_root/<entry>`. Fetching, version resolution and pinning belong to the
94
+ # CLI: by the time the engine runs, an installed package is an ordinary
95
+ # directory under the store, indistinguishable from project content.
140
96
 
141
97
  # Map an absolute file path to a repo-root-relative path for use as a link
142
98
  # 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.
99
+ # its repo-relative path; one resolved outside it has no stable, committable
100
+ # path, so this returns the empty string — callers MUST then emit the bare
101
+ # name, never the absolute path. The `${path#"$repo_root"/}` strip is a no-op
102
+ # when $path is not under $repo_root, which is how that case is detected.
149
103
  repo_rel_link() {
150
104
  local repo_root="$1" path="$2" rel
151
105
  rel="${path#"$repo_root"/}"
@@ -187,291 +141,15 @@ repo_rel_dir() {
187
141
  done
188
142
  }
189
143
 
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>".
144
+ # Resolve a single source token to an absolute local directory. Every token is
145
+ # a repo-relative path: the CLI resolves, fetches and pins packages, so by the
146
+ # time the engine runs a package is just a directory under the store.
193
147
  # ALWAYS returns 0 (echoes nothing on failure) so `set -e` callers using
194
148
  # `dir="$(resolve_source_dir ...)"` never abort; the caller's existing
195
149
  # `[ -d "$dir" ] || continue` guard then skips an unresolved source.
196
150
  # Usage: dir="$(resolve_source_dir "$repo_root" "$src")"
197
151
  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
152
+ printf '%s' "$1/$2"
475
153
  }
476
154
 
477
155
  # Copy a markdown file with frontmatter, ensuring free-text string fields are
@@ -1066,11 +744,6 @@ validate_output_path() {
1066
744
  for section in rules agents skills; do
1067
745
  while IFS= read -r src; do
1068
746
  [ -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
747
  src_rel="$(normalize_path "$repo_root/$src")"
1075
748
  src_rel="${src_rel#"$repo_root"/}"
1076
749
  case "$rel" in
@@ -1082,167 +755,6 @@ validate_output_path() {
1082
755
  esac
1083
756
  done < <(read_yaml_list "$config_file" "$section")
1084
757
  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
758
  }
1247
759
 
1248
760
  # Warn about prompt directories not listed in sources.
@@ -1255,14 +767,10 @@ warn_unsynced() {
1255
767
  local repo_root="$1"
1256
768
  local config_file="$2"
1257
769
 
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
770
  local all_sources=()
1262
771
  for section in rules agents skills; do
1263
772
  while IFS= read -r src; do
1264
773
  [ -z "$src" ] && continue
1265
- source_is_local_path "$src" || continue
1266
774
  all_sources+=("$src")
1267
775
  done < <(read_yaml_list "$config_file" "$section")
1268
776
  done
@@ -1277,22 +785,12 @@ warn_unsynced() {
1277
785
  [ -z "$sub" ] && continue
1278
786
  ignores+=("$sub")
1279
787
  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.
788
+
789
+ # The manifest sits at the repo root, so the content dir cannot be derived
790
+ # from its location — it comes from the env contract the CLI exports.
1293
791
  local intel_basename
1294
792
  if [ "${IS_CLI:-0}" = "1" ]; then
1295
- intel_basename="$(basename "${IS_UMBRELLA_REL:-intelligence}")"
793
+ intel_basename="$(basename "${IS_CONTENT_REL:-intelligence}")"
1296
794
  else
1297
795
  intel_basename="$(basename "$(dirname "$config_file")")"
1298
796
  fi
@@ -1575,12 +1073,6 @@ get_project_name() {
1575
1073
  get_yaml_field "$1" "project" "name"
1576
1074
  }
1577
1075
 
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
1076
  # Immediate sub-keys of a top-level block, one per line (`packs:` → pack names).
1585
1077
  # Block form only: a pack always spans several lines, so the inline `{...}` form
1586
1078
  # that `get_target_field` accommodates has no use here.