@ainova-systems/intelligence 0.18.1 → 0.19.0

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 (35) hide show
  1. package/cli/commands/init.sh +6 -1
  2. package/cli/commands/package.sh +8 -3
  3. package/cli/commands/source.sh +55 -16
  4. package/cli/intelligence +1 -1
  5. package/cli/internal/check.sh +64 -13
  6. package/cli/internal/package-add.sh +25 -4
  7. package/cli/internal/package-alias.sh +51 -0
  8. package/cli/internal/package-list.sh +16 -2
  9. package/cli/internal/package-remove.sh +3 -1
  10. package/cli/internal/package-update.sh +12 -4
  11. package/cli/lib/adapter-lifecycle.sh +5 -2
  12. package/cli/lib/cli-common.sh +4 -1
  13. package/cli/lib/gitignore.sh +128 -34
  14. package/cli/lib/manifest.sh +168 -20
  15. package/cli/lib/registry.sh +18 -13
  16. package/engine/ENGINE_SHA +1 -1
  17. package/engine/VERSION +1 -1
  18. package/engine/adapters/_template.sh +6 -2
  19. package/engine/adapters/agents.sh +3 -0
  20. package/engine/adapters/claude.sh +21 -10
  21. package/engine/adapters/codex.sh +8 -12
  22. package/engine/adapters/copilot.sh +46 -2
  23. package/engine/adapters/cursor.sh +6 -1
  24. package/engine/lib/adapter-contract.sh +17 -6
  25. package/engine/lib/common.sh +418 -50
  26. package/engine/sync.sh +9 -0
  27. package/package.json +1 -1
  28. package/packages/sync/references/adapters.md +39 -9
  29. package/packages/sync/references/conventions.md +44 -7
  30. package/packages/sync/references/onboarding-migration.md +3 -3
  31. package/packages/sync/skills/intelligence-learn-from-repository/SKILL.md +3 -2
  32. package/packages/sync/skills/intelligence-review-context/SKILL.md +2 -1
  33. package/packages/sync/skills/intelligence-update-context/SKILL.md +4 -2
  34. package/packages/sync/skills/intelligence-update-context/references/agents.md +7 -3
  35. package/packages/sync/skills/intelligence-update-context/references/skills.md +3 -1
@@ -293,9 +293,10 @@ repo_rel_dir() {
293
293
  done
294
294
  }
295
295
 
296
- # Resolve a single source token to an absolute local directory. Every token is
297
- # a repo-relative path: the CLI resolves, fetches and pins packages, so by the
298
- # time the engine runs a package is just a directory under the store.
296
+ # Resolve a single source entry to an absolute local directory. Every entry is
297
+ # a repo-relative path by the time it gets here: the CLI resolves, fetches and
298
+ # pins packages, so a package is just a directory under the store, and the list
299
+ # parser has already expanded a package reference to that directory.
299
300
  # ALWAYS returns 0 (echoes nothing on failure) so `set -e` callers using
300
301
  # `dir="$(resolve_source_dir ...)"` never abort; the caller's existing
301
302
  # `[ -d "$dir" ] || continue` guard then skips an unresolved source.
@@ -460,11 +461,17 @@ copy_md_with_quoted_frontmatter() {
460
461
  # "argument-hint must be a string" — the skill silently disappears from the
461
462
  # picker. Quoting is idempotent: an already-quoted value passes through
462
463
  # untouched.
464
+ #
465
+ # The same pass renders SKILL.md's `effort:` for the tool that reads the copy
466
+ # (map_effort_var): the first `effort:` line becomes the tool's level, and is
467
+ # dropped — with every later one — when the tool has no level for it. The
468
+ # plain forms render for the shared `open` tree, which keeps a neutral level as
469
+ # written and drops only an empty or off-scale one.
463
470
  # Usage: copy_skill_bundle "src/skill/dir" "dest/skill/dir"
464
471
  copy_skill_bundle() {
465
472
  _skill_bundles_reset
466
473
  _skill_bundle_stage "$1" "$2"
467
- _skill_bundles_flush
474
+ _skill_bundles_flush open
468
475
  }
469
476
 
470
477
  # copy_skill_bundle_dirs <dest_root> <src_dir>... — batch form: ONE cp -R
@@ -473,8 +480,14 @@ copy_skill_bundle() {
473
480
  # bundles. A later source with the same skill name overwrites file-by-file in
474
481
  # order, exactly like the sequential per-bundle copies did.
475
482
  copy_skill_bundle_dirs() {
476
- local dest_root="$1"
477
- shift
483
+ copy_skill_bundle_dirs_for open "$@"
484
+ }
485
+
486
+ # copy_skill_bundle_dirs_for <tool> <dest_root> <src_dir>... — the batch form
487
+ # for a tree one tool reads, rendering `effort:` for that tool.
488
+ copy_skill_bundle_dirs_for() {
489
+ local effort_tool="$1" dest_root="$2"
490
+ shift 2
478
491
  [ "$#" -gt 0 ] || return 0
479
492
  local src dest
480
493
  local -a srcs=()
@@ -488,7 +501,7 @@ copy_skill_bundle_dirs() {
488
501
  dest="$dest_root/${src##*/}"
489
502
  _skill_bundle_note "$dest"
490
503
  done
491
- _skill_bundles_flush
504
+ _skill_bundles_flush "$effort_tool"
492
505
  }
493
506
 
494
507
  _skill_bundles_reset() {
@@ -531,6 +544,7 @@ _skill_bundle_note() {
531
544
  esac
532
545
  }
533
546
 
547
+ # _skill_bundles_flush <effort-tool>
534
548
  _skill_bundles_flush() {
535
549
  [ "${#_SB_DESTS[@]}" -gt 0 ] || return 0
536
550
  local -a mds=()
@@ -540,18 +554,27 @@ _skill_bundles_flush() {
540
554
  done < <(find "${_SB_DESTS[@]}" -type f -name '*.md')
541
555
  _skill_bundles_reset
542
556
  [ "${#mds[@]}" -gt 0 ] || return 0
557
+ effort_map_var "$1"
543
558
  # One awk: quote free-text frontmatter fields in each top-level SKILL.md
544
559
  # (strict-YAML consumers reject unquoted colons; `argument-hint:
545
560
  # [pr-number]` would otherwise arrive as a YAML flow sequence and the
546
- # skill silently vanishes from the picker) and expand layout tokens in
547
- # every bundled markdown file. Quoting is idempotent — already-quoted
548
- # values pass through untouched. The quote list travels through the
549
- # environment, like emit_wrapped_bodies specs.
561
+ # skill silently vanishes from the picker), render its `effort:` for the
562
+ # tool, and expand layout tokens in every bundled markdown file. Quoting is
563
+ # idempotent — already-quoted values pass through untouched. The quote list
564
+ # and the effort map travel through the environment, like
565
+ # emit_wrapped_bodies specs. The effort key is matched exactly as
566
+ # frontmatter_index reads it, so the line rewritten is the one the
567
+ # lint judged.
550
568
  is_fin_awk_vars
551
- IS_QUOTE_LIST="$quote_list" awk "${IS_FIN_V[@]}" "$IS_AWK_LIB"'
569
+ IS_QUOTE_LIST="$quote_list" IS_EFFORT_MAP="$IS_EFFORT_MAP" awk "${IS_FIN_V[@]}" "$IS_AWK_LIB"'
552
570
  BEGIN {
553
571
  qn = split(ENVIRON["IS_QUOTE_LIST"], QL, "\n")
554
572
  for (qi = 1; qi <= qn; qi++) if (QL[qi] != "") QUOTE[QL[qi]] = 1
573
+ en = split(ENVIRON["IS_EFFORT_MAP"], EL, "\n")
574
+ for (ei = 1; ei <= en; ei++) {
575
+ eq = index(EL[ei], "=")
576
+ if (eq > 0) EFFORT[substr(EL[ei], 1, eq - 1)] = substr(EL[ei], eq + 1)
577
+ }
555
578
  }
556
579
  function yamlq(s, out, i, c) {
557
580
  out = ""
@@ -572,12 +595,19 @@ _skill_bundles_flush() {
572
595
  flush_file()
573
596
  out_file = FILENAME; line_n = 0
574
597
  state = (FILENAME in QUOTE) ? "before" : ""
598
+ effort_seen = 0
575
599
  }
576
600
  { sub(/\r$/, "") }
577
601
  state == "before" {
578
602
  if (FNR == 1 && $0 == "---") state = "in_fm"
579
603
  else state = "after"
580
604
  }
605
+ state == "in_fm" && FNR > 1 && substr($0, 1, 7) == "effort:" {
606
+ if (effort_seen++) next
607
+ val = fm_value_strip(substr($0, 8))
608
+ if (!(val in EFFORT)) next
609
+ if (EFFORT[val] != val) $0 = "effort: " EFFORT[val]
610
+ }
581
611
  state == "in_fm" && FNR > 1 {
582
612
  if ($0 == "---") state = "after"
583
613
  else {
@@ -660,7 +690,7 @@ sync_open_skill_dirs() {
660
690
  # copy_skill_bundle_dirs owns the frontmatter-quoting pass, so every
661
691
  # target gets it — not just this open-standard dir.
662
692
  if [ "$count" -gt 0 ]; then
663
- copy_skill_bundle_dirs "$output_dir" "${skill_dirs[@]}"
693
+ copy_skill_bundle_dirs_for open "$output_dir" "${skill_dirs[@]}"
664
694
  open_skill_invocation_policies "$output_dir" || return 1
665
695
  fi
666
696
 
@@ -831,16 +861,36 @@ enforce_authored_invocation_policies() {
831
861
  # Print warnings to stderr; do not fail. Strict consumers (Codex CLI) reject
832
862
  # these files with cryptic messages — catching them in sync gives better DX.
833
863
  # Batched: one awk process lints every file passed.
864
+ # The engine lints every source once, so this is also where an `effort:` off
865
+ # IS_EFFORT_LEVELS is reported — read with frontmatter_index's semantics,
866
+ # first occurrence only. Adapters render it as absent and say nothing, so the
867
+ # warning names the source exactly once however many tools render it. Unlike
868
+ # the lint notes it is an unindented `WARNING:` line, because `sync --compact`
869
+ # (and so `init`) keeps only those (decision 0015).
834
870
  # Usage: lint_frontmatter_files "a.md" "b.md" ...
835
871
  lint_frontmatter_files() {
836
872
  [ "$#" -gt 0 ] || return 0
837
- awk '
838
- FNR == 1 { in_fm = 0; done = 0 }
873
+ awk -v levels="$IS_EFFORT_LEVELS" "$IS_AWK_LIB"'
874
+ BEGIN {
875
+ nl = split(levels, LV, " ")
876
+ allowed = ""
877
+ for (li = 1; li <= nl; li++) {
878
+ LEVEL[LV[li]] = 1
879
+ allowed = allowed (li > 1 ? ", " : "") LV[li]
880
+ }
881
+ }
882
+ FNR == 1 { in_fm = 0; done = 0; effort_seen = 0 }
839
883
  { sub(/\r$/, "") }
840
884
  done { next }
841
885
  FNR == 1 && $0 != "---" { done = 1; next }
842
886
  FNR == 1 { in_fm = 1; next }
843
887
  in_fm && $0 == "---" { done = 1; next }
888
+ in_fm && substr($0, 1, 7) == "effort:" && !effort_seen++ {
889
+ effort = fm_value_strip(substr($0, 8))
890
+ if (effort != "" && !(effort in LEVEL)) {
891
+ printf "WARNING: %s:%d effort \"%s\" is not one of %s — ignored, so no tool receives an effort from this file\n", FILENAME, FNR, effort, allowed > "/dev/stderr"
892
+ }
893
+ }
844
894
  in_fm && /^\t/ {
845
895
  printf " WARN: %s:%d leading tab in frontmatter (use spaces)\n", FILENAME, FNR > "/dev/stderr"
846
896
  }
@@ -994,8 +1044,8 @@ get_model_default_var() {
994
1044
  cursor:heavy) IS_MODEL_DEFAULT="inherit" ;;
995
1045
  cursor:standard) IS_MODEL_DEFAULT="inherit" ;;
996
1046
  cursor:light) IS_MODEL_DEFAULT="inherit" ;;
997
- # GPT-6 has no mid model, so frontier and heavy share Astra; Codex
998
- # separates them by reasoning effort instead.
1047
+ # GPT-6 has no mid model, so frontier and heavy share Astra and render
1048
+ # identically; reasoning effort comes from `effort:`, never the tier.
999
1049
  copilot:frontier) IS_MODEL_DEFAULT="gpt-6-astra" ;;
1000
1050
  copilot:heavy) IS_MODEL_DEFAULT="gpt-6-astra" ;;
1001
1051
  copilot:standard) IS_MODEL_DEFAULT="gpt-6.1-sol" ;;
@@ -1132,7 +1182,7 @@ load_model_tiers() {
1132
1182
  # the tool has no default for and the manifest overrides nowhere — a typo, as a
1133
1183
  # rule — still renders an empty model, but never silently: the run warns once
1134
1184
  # per tool and tier, unindented on purpose because `sync --compact` keeps only
1135
- # `WARNING:` lines.
1185
+ # `WARNING:` lines (decision 0016).
1136
1186
  # shellcheck disable=SC2034 # IS_MODEL is the return channel read by adapters
1137
1187
  resolve_model_var() {
1138
1188
  case "$1" in
@@ -1242,6 +1292,64 @@ map_access_to_claude_disallowed() {
1242
1292
  echo "$IS_CLAUDE_DISALLOWED"
1243
1293
  }
1244
1294
 
1295
+ # --- Effort Mapping ---
1296
+
1297
+ # `effort:` is the tool-neutral reasoning effort an agent or skill asks for,
1298
+ # lowest first (decision 0015). It is independent of `tier`, which selects the
1299
+ # model only. A value off this scale — matched exactly, case included — is an
1300
+ # absent effort: lint_frontmatter_files warns about it and no tool receives
1301
+ # one, because a typo in another source's package must not block a sync.
1302
+ IS_EFFORT_LEVELS="low medium high xhigh max ultra"
1303
+
1304
+ # effort_levels_var <tool> — set IS_EFFORT_TOOL_LEVELS to the levels the tool's
1305
+ # native effort field accepts, in scale order; empty for a tool without one.
1306
+ # `open` is the shared Agent Skills tree (.agents/skills/), which keeps the
1307
+ # neutral value for the tools that read it.
1308
+ effort_levels_var() {
1309
+ case "$1" in
1310
+ claude) IS_EFFORT_TOOL_LEVELS="low medium high xhigh max" ;;
1311
+ codex|open) IS_EFFORT_TOOL_LEVELS="$IS_EFFORT_LEVELS" ;;
1312
+ # Copilot, Cursor, OpenCode, Antigravity and Pi have no per-agent
1313
+ # effort field: the tool's own setting applies.
1314
+ *) IS_EFFORT_TOOL_LEVELS="" ;;
1315
+ esac
1316
+ }
1317
+
1318
+ # map_effort_var <tool> <effort> — set IS_EFFORT to the level the tool receives:
1319
+ # the same level, or the nearest lower one the tool has (`ultra` is `max` for
1320
+ # Claude Code). Empty when the effort is absent or off the scale, or the tool
1321
+ # has no field. Which levels a particular model supports is the tool's call: a
1322
+ # manifest may override the model, and the tools fall back on their own.
1323
+ map_effort_var() {
1324
+ local level best=""
1325
+ IS_EFFORT=""
1326
+ effort_levels_var "$1"
1327
+ for level in $IS_EFFORT_LEVELS; do
1328
+ case " $IS_EFFORT_TOOL_LEVELS " in *" $level "*) best="$level" ;; esac
1329
+ if [ "$level" = "${2-}" ]; then
1330
+ IS_EFFORT="$best"
1331
+ return 0
1332
+ fi
1333
+ done
1334
+ }
1335
+
1336
+ map_effort() {
1337
+ map_effort_var "$1" "$2"
1338
+ echo "$IS_EFFORT"
1339
+ }
1340
+
1341
+ # effort_map_var <tool> — set IS_EFFORT_MAP to one `<neutral>=<native>` line per
1342
+ # level the tool maps, so a batched awk pass rewrites `effort:` in place with
1343
+ # map_effort_var's answers instead of a second copy of the mapping.
1344
+ effort_map_var() {
1345
+ local level map=""
1346
+ for level in $IS_EFFORT_LEVELS; do
1347
+ map_effort_var "$1" "$level"
1348
+ [ -z "$IS_EFFORT" ] || map+="$level=$IS_EFFORT"$'\n'
1349
+ done
1350
+ IS_EFFORT_MAP="$map"
1351
+ }
1352
+
1245
1353
  # --- Validation ---
1246
1354
 
1247
1355
  # Lexically canonicalize a path: collapse `//`, `.` and `..` by pure string
@@ -1787,9 +1895,213 @@ report_context_source_sizes() {
1787
1895
 
1788
1896
  # --- Config Parsing ---
1789
1897
 
1898
+ # --- Package references in sources -------------------------------------------
1899
+ # A `sources:` entry names a directory inside an installed package in one of
1900
+ # three spellings (decision 0019):
1901
+ #
1902
+ # .intelligence/packages/@scope/name/<dir> the store path: an ordinary
1903
+ # repository path, as the CLI writes it
1904
+ # @scope/name/<dir> by full name: a reference only while
1905
+ # `packages:` declares @scope/name,
1906
+ # otherwise an ordinary path as before
1907
+ # <alias>:<dir> by the alias that package's
1908
+ # `packages:` entry declares
1909
+ #
1910
+ # A reference renders exactly as the store path it stands for. The list parser
1911
+ # below expands it inside the awk pass that already reads the manifest; that
1912
+ # pass is the one point every reader goes through — adapters, the engine's own
1913
+ # loops and the CLI — so expansion costs no process, and the CLI sees exactly
1914
+ # what the engine renders (decision 0009). A reference that resolves to nothing
1915
+ # is left out of the list, so no reader can render it as a path: `sync` names it
1916
+ # in a WARNING: line and `status --check` reports it.
1917
+ #
1918
+ # An alias is two or more of A-Z a-z 0-9 . _ - and starts with a letter or digit:
1919
+ # no `/`, `:` or `@`, no whitespace or quotes, and never one letter that reads
1920
+ # like a drive. It is a lookup key, never part of a path. <dir> is one or more
1921
+ # `/`-separated segments, none of them empty, `.` or `..`, without a backslash,
1922
+ # so a reference never leaves its package.
1923
+ #
1924
+ # These functions are the only definition. The CLI's editors (lib/manifest.sh),
1925
+ # `source`, `package alias` and `status --check` load this same string:
1926
+ #
1927
+ # pkr_collect(line) feed every manifest line; records the names `packages:`
1928
+ # declares and the alias each one states
1929
+ # pkr_parse(entry) classify one entry: path, ok, unknown (an alias no
1930
+ # package declares), ambiguous (an alias several declare)
1931
+ # or invalid (a reference whose <dir> is malformed); sets
1932
+ # PKR_NAME, PKR_DIR, PKR_ALIAS and, when ambiguous,
1933
+ # PKR_HOLDERS
1934
+ # pkr_expand(entry) the directory the entry renders as, "" when it renders
1935
+ # nothing; leaves the state in PKR_STATE
1936
+ # pkr_alias_ok(a) whether <a> is a well-formed alias
1937
+ IS_PKG_REF_AWK='
1938
+ function pkr_seg_ok(s) { return s != "" && s != "." && s != ".." }
1939
+ # The CLI refuses any other package name (assert_valid_pkg_name): a name
1940
+ # becomes a store path, so one that is not a plain @scope/name is never
1941
+ # declared.
1942
+ function pkr_name_ok(name, p) {
1943
+ if (name !~ /^@[@A-Za-z0-9._-]*\/[@A-Za-z0-9._-]*$/) return 0
1944
+ p = index(name, "/")
1945
+ return pkr_seg_ok(substr(name, 2, p - 2)) && pkr_seg_ok(substr(name, p + 1))
1946
+ }
1947
+ # Enumerated, not a range: a range follows the locale in some awks.
1948
+ function pkr_alias_ok(a, i, c) {
1949
+ if (length(a) < 2) return 0
1950
+ for (i = 1; i <= length(a); i++) {
1951
+ c = substr(a, i, 1)
1952
+ if (index("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", c)) continue
1953
+ if (i > 1 && index("._-", c)) continue
1954
+ return 0
1955
+ }
1956
+ return 1
1957
+ }
1958
+ function pkr_dir_ok(dir, n, i, seg) {
1959
+ if (dir == "" || index(dir, "\\")) return 0
1960
+ n = split(dir, seg, "/")
1961
+ for (i = 1; i <= n; i++) if (!pkr_seg_ok(seg[i])) return 0
1962
+ return 1
1963
+ }
1964
+ # The quoted keys of the top-level packages: block and their alias fields,
1965
+ # read the way lib/qmap.awk reads what the CLI writes: a duplicated key is
1966
+ # one package, and the first alias a package states is the one it has.
1967
+ function pkr_collect(line, s, q, v) {
1968
+ if (line ~ /^packages:[ \t]*$/) { pkr_in = 1; pkr_cur = ""; return }
1969
+ if (!pkr_in || line ~ /^[ \t]*(#.*)?$/) return
1970
+ if (line ~ /^[^ \t]/) { pkr_in = 0; pkr_cur = ""; return }
1971
+ if (substr(line, 1, 3) == " \"") {
1972
+ pkr_cur = ""
1973
+ s = substr(line, 4)
1974
+ q = index(s, "\"")
1975
+ if (q < 2) return
1976
+ v = substr(s, 1, q - 1)
1977
+ if (!pkr_name_ok(v)) return
1978
+ if (!(v in pkr_seen)) { pkr_seen[v] = 1; PKR_NAMES[++PKR_N] = v }
1979
+ pkr_cur = v
1980
+ return
1981
+ }
1982
+ if (substr(line, 1, 4) != " ") { pkr_cur = ""; return }
1983
+ if (pkr_cur == "" || (pkr_cur in PKR_ALIAS_RAW) || substr(line, 5, 6) != "alias:") return
1984
+ v = substr(line, 11)
1985
+ sub(/^[ \t]+/, "", v)
1986
+ if (substr(v, 1, 1) == "\"") {
1987
+ v = substr(v, 2)
1988
+ q = index(v, "\"")
1989
+ if (q == 0) return
1990
+ v = substr(v, 1, q - 1)
1991
+ } else {
1992
+ sub(/[ \t]+#.*$/, "", v)
1993
+ sub(/[ \t]+$/, "", v)
1994
+ }
1995
+ PKR_ALIAS_RAW[pkr_cur] = v
1996
+ if (!pkr_alias_ok(v)) return
1997
+ pkr_holders[v] = (v in pkr_holders) ? pkr_holders[v] ", " pkr_cur : pkr_cur
1998
+ pkr_count[v]++
1999
+ pkr_alias[v] = pkr_cur
2000
+ }
2001
+ function pkr_parse(entry, c, p, i, rest, name) {
2002
+ PKR_NAME = ""; PKR_DIR = ""; PKR_ALIAS = ""; PKR_HOLDERS = ""
2003
+ if (substr(entry, 1, 1) == "@") {
2004
+ p = index(entry, "/")
2005
+ if (p == 0) return "path"
2006
+ rest = substr(entry, p + 1)
2007
+ i = index(rest, "/")
2008
+ name = i ? substr(entry, 1, p + i - 1) : entry
2009
+ if (!(name in pkr_seen)) return "path"
2010
+ PKR_NAME = name
2011
+ PKR_DIR = i ? substr(rest, i + 1) : ""
2012
+ return pkr_dir_ok(PKR_DIR) ? "ok" : "invalid"
2013
+ }
2014
+ c = index(entry, ":")
2015
+ if (c == 0 || !pkr_alias_ok(substr(entry, 1, c - 1))) return "path"
2016
+ PKR_ALIAS = substr(entry, 1, c - 1)
2017
+ PKR_DIR = substr(entry, c + 1)
2018
+ if (!pkr_dir_ok(PKR_DIR)) return "invalid"
2019
+ if (!(PKR_ALIAS in pkr_count)) return "unknown"
2020
+ if (pkr_count[PKR_ALIAS] > 1) { PKR_HOLDERS = pkr_holders[PKR_ALIAS]; return "ambiguous" }
2021
+ PKR_NAME = pkr_alias[PKR_ALIAS]
2022
+ return "ok"
2023
+ }
2024
+ function pkr_expand(entry) {
2025
+ PKR_STATE = pkr_parse(entry)
2026
+ if (PKR_STATE == "path") return entry
2027
+ if (PKR_STATE == "ok") return ".intelligence/packages/" PKR_NAME "/" PKR_DIR
2028
+ return ""
2029
+ }
2030
+ '
2031
+
2032
+ # The value of one block-sequence item in the manifest — the only reading of a
2033
+ # list entry. Every list reader below and the CLI's `sources:` editors
2034
+ # (lib/manifest.sh) load this string, so an entry the engine renders and the
2035
+ # entry an editor compares are the same text (decision 0009, point 7).
2036
+ #
2037
+ # yaml_item(text) <text> is what follows the item's `-`. Returns the value:
2038
+ # whitespace around it and a trailing `# comment` dropped,
2039
+ # then every quote removed, as the readers always did. A
2040
+ # comment starts at a `#` that follows whitespace outside
2041
+ # quotes; inside a quoted scalar a `#` is part of the value,
2042
+ # and so is one with no whitespace before it (`a#b`).
2043
+ IS_YAML_ITEM_AWK='
2044
+ function yaml_item(s, q, e, rest) {
2045
+ sub(/^[ \t]+/, "", s)
2046
+ q = substr(s, 1, 1)
2047
+ rest = s
2048
+ s = ""
2049
+ if (q == "\"" || q == "\047") {
2050
+ e = index(substr(rest, 2), q)
2051
+ if (e == 0) { s = rest; rest = "" }
2052
+ else { s = substr(rest, 1, e + 1); rest = substr(rest, e + 2) }
2053
+ }
2054
+ if (s == "" && substr(rest, 1, 1) == "#") rest = ""
2055
+ else if (match(rest, /[ \t]#/)) rest = substr(rest, 1, RSTART - 1)
2056
+ s = s rest
2057
+ sub(/[ \t]+$/, "", s)
2058
+ gsub(/["\047]/, "", s)
2059
+ return s
2060
+ }
2061
+ '
2062
+
2063
+ # The manifest list reader. `mode` selects what it prints for each entry:
2064
+ # expand the entry as the engine renders it: a reference becomes its store
2065
+ # path, one that resolves to nothing is left out
2066
+ # raw the entry as written — what an editor compares and rewrites
2067
+ # classify raw, pkr_parse state, expansion, alias and the packages sharing
2068
+ # it, separated by \037 — what `status --check` and `source` judge
2069
+ # Only rules, agents and skills hold sources; every other list reads raw.
2070
+ # Entries are held until the end of the file because `packages:` may follow
2071
+ # `sources:`.
2072
+ IS_YAML_LIST_AWK='
2073
+ { sub(/\r$/, ""); pkr_collect($0) }
2074
+ /^[a-z]/ { current_section = ""; depth = 0 }
2075
+ /^ [a-z]/ { current_section = ""; depth = 0 }
2076
+ $0 ~ "^" section ":" { current_section = section; depth = 0; next }
2077
+ $0 ~ "^ " section ":" { current_section = section; depth = 2; next }
2078
+ current_section == section && depth == 0 && /^ - / {
2079
+ vals[++nv] = yaml_item(substr($0, 5))
2080
+ }
2081
+ current_section == section && depth == 2 && /^ - / {
2082
+ vals[++nv] = yaml_item(substr($0, 7))
2083
+ }
2084
+ END {
2085
+ sources = section == "rules" || section == "agents" || section == "skills"
2086
+ n = nv + 0
2087
+ for (i = 1; i <= n; i++) {
2088
+ val = vals[i]
2089
+ if (!sources || mode == "raw") { print val; continue }
2090
+ dir = pkr_expand(val)
2091
+ if (mode == "classify") print val "\037" PKR_STATE "\037" dir "\037" PKR_ALIAS "\037" PKR_HOLDERS
2092
+ else if (PKR_STATE == "path" || PKR_STATE == "ok") print dir
2093
+ }
2094
+ }
2095
+ '
2096
+
1790
2097
  # Read a simple list from config.yaml
1791
2098
  # Format: key:\n - "value1"\n - "value2"
1792
- # Usage: readarray -t arr < <(read_yaml_list "config.yaml" "rules")
2099
+ # Usage: while IFS= read -r src; do ...; done < <(read_yaml_list "intelligence.yaml" "rules")
2100
+ #
2101
+ # A source list arrives with its package references expanded to store paths —
2102
+ # the spelling every renderer resolves as $REPO_ROOT/<entry> — and without the
2103
+ # ones that resolve to nothing. Editors that rewrite the manifest read it with
2104
+ # read_yaml_list_raw instead.
1793
2105
  #
1794
2106
  # Consults the load_yaml_list cache first: sync reads the same sections from
1795
2107
  # the same manifest dozens of times, and each awk spawn costs tens of
@@ -1813,27 +2125,67 @@ read_yaml_list() {
1813
2125
  fi
1814
2126
  ;;
1815
2127
  esac
1816
- awk -v section="$section" '
1817
- {
1818
- sub(/\r$/, "")
1819
- }
1820
- /^[a-z]/ { current_section = ""; depth = 0 }
1821
- /^ [a-z]/ { current_section = ""; depth = 0 }
1822
- $0 ~ "^" section ":" { current_section = section; depth = 0; next }
1823
- $0 ~ "^ " section ":" { current_section = section; depth = 2; next }
1824
- current_section == section && depth == 0 && /^ - / {
1825
- val = $0
1826
- sub(/^ - /, "", val)
1827
- gsub(/["\047]/, "", val)
1828
- print val
1829
- }
1830
- current_section == section && depth == 2 && /^ - / {
1831
- val = $0
1832
- sub(/^ - /, "", val)
1833
- gsub(/["\047]/, "", val)
1834
- print val
2128
+ awk -v section="$section" -v mode=expand "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK$IS_YAML_LIST_AWK" "$file"
2129
+ }
2130
+
2131
+ # read_yaml_list_raw <file> <section> — the entries exactly as written,
2132
+ # references included and never cached. The CLI's sources editors compare and
2133
+ # rewrite these, so an entry is matched as the user spelled it.
2134
+ read_yaml_list_raw() {
2135
+ awk -v section="$2" -v mode=raw "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK$IS_YAML_LIST_AWK" "$1"
2136
+ }
2137
+
2138
+ # read_source_entries <file> <section> — one \037-separated line per entry: as
2139
+ # written, its pkr_parse state (path, ok, unknown, ambiguous or invalid), the
2140
+ # directory it renders as ("" for none), the alias it names and, when several
2141
+ # packages declare that alias, their names. One pass of the parser the engine
2142
+ # renders through, so a verdict can never disagree with the render.
2143
+ read_source_entries() {
2144
+ awk -v section="$2" -v mode=classify "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK$IS_YAML_LIST_AWK" "$1"
2145
+ }
2146
+
2147
+ # read_package_aliases <file> — one \037-separated line per declared package that
2148
+ # states an alias: its name, the alias, and ok, ambiguous (another package states
2149
+ # it too) or invalid (not a well-formed alias, so it resolves nothing). An
2150
+ # invalid alias is manifest input that may hold anything, so its field is empty.
2151
+ read_package_aliases() {
2152
+ [ -f "$1" ] || return 0
2153
+ awk "$IS_PKG_REF_AWK"'
2154
+ { sub(/\r$/, ""); pkr_collect($0) }
2155
+ END {
2156
+ for (i = 1; i <= PKR_N; i++) {
2157
+ name = PKR_NAMES[i]
2158
+ if (!(name in PKR_ALIAS_RAW)) continue
2159
+ a = PKR_ALIAS_RAW[name]
2160
+ if (!pkr_alias_ok(a)) { print name "\037\037invalid"; continue }
2161
+ print name "\037" a "\037" (pkr_count[a] > 1 ? "ambiguous" : "ok")
2162
+ }
1835
2163
  }
1836
- ' "$file"
2164
+ ' "$1"
2165
+ }
2166
+
2167
+ # What pkr_alias_ok accepts, in the words every message that refuses an alias
2168
+ # uses.
2169
+ # shellcheck disable=SC2034
2170
+ IS_PKG_ALIAS_RULE="two or more of A-Z a-z 0-9 . _ -, starting with a letter or digit"
2171
+
2172
+ # pkg_alias_valid <alias> — true when <alias> is well formed (pkr_alias_ok). The
2173
+ # value travels through the environment: `awk -v` would interpret backslashes.
2174
+ pkg_alias_valid() {
2175
+ IS_PKR_CANDIDATE="$1" awk "$IS_PKG_REF_AWK"'BEGIN { exit !pkr_alias_ok(ENVIRON["IS_PKR_CANDIDATE"]) }'
2176
+ }
2177
+
2178
+ # source_reference_problem_var <state> <alias> <holders> — set IS_SOURCE_PROBLEM
2179
+ # to why a reference in that state renders nothing, "" for path and ok. The one
2180
+ # wording of `sync`'s WARNING: line and of `status --check`.
2181
+ # shellcheck disable=SC2034
2182
+ source_reference_problem_var() {
2183
+ case "$1" in
2184
+ unknown) IS_SOURCE_PROBLEM="names alias '$2', which no package in packages: declares" ;;
2185
+ ambiguous) IS_SOURCE_PROBLEM="names alias '$2', which several packages declare ($3) — an alias names one package" ;;
2186
+ invalid) IS_SOURCE_PROBLEM="names no directory inside its package — the part after the package must be one or more '/'-separated segments, none empty, '.' or '..'" ;;
2187
+ *) IS_SOURCE_PROBLEM="" ;;
2188
+ esac
1837
2189
  }
1838
2190
 
1839
2191
  # load_yaml_list <file> <section> — fill the global IS_YAML_LIST with the
@@ -1865,7 +2217,10 @@ load_yaml_list() {
1865
2217
 
1866
2218
  # load_yaml_lists <file> <section>... — warm the load_yaml_list cache for several
1867
2219
  # sections in one manifest pass. Each section runs read_yaml_list's own state
1868
- # machine, so every cached value is what load_yaml_list would have stored.
2220
+ # machine and its reference expansion, so every cached value is what
2221
+ # load_yaml_list would have stored. The same pass leaves the references that
2222
+ # resolve to nothing in IS_YL_UNRESOLVED, one read_source_entries-shaped line
2223
+ # each with the section in front, for the WARNING: lines `sync` prints.
1869
2224
  load_yaml_lists() {
1870
2225
  local file="$1" out line s v j
1871
2226
  shift
@@ -1875,9 +2230,9 @@ load_yaml_lists() {
1875
2230
  *) return 1 ;;
1876
2231
  esac
1877
2232
  done
1878
- out="$(awk -v sections="$*" '
2233
+ out="$(awk -v sections="$*" "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK"'
1879
2234
  BEGIN { n = split(sections, want, " ") }
1880
- { sub(/\r$/, "") }
2235
+ { sub(/\r$/, ""); pkr_collect($0) }
1881
2236
  {
1882
2237
  for (k = 1; k <= n; k++) {
1883
2238
  s = want[k]
@@ -1886,19 +2241,26 @@ load_yaml_lists() {
1886
2241
  if ($0 ~ "^" s ":") { cur[k] = s; dep[k] = 0; continue }
1887
2242
  if ($0 ~ "^ " s ":") { cur[k] = s; dep[k] = 2; continue }
1888
2243
  if (cur[k] == s && dep[k] == 0 && /^ - /) {
1889
- val = $0
1890
- sub(/^ - /, "", val)
1891
- gsub(/["\047]/, "", val)
1892
- print s "\037" val
2244
+ got_s[++ng] = s
2245
+ got_v[ng] = yaml_item(substr($0, 5))
1893
2246
  }
1894
2247
  if (cur[k] == s && dep[k] == 2 && /^ - /) {
1895
- val = $0
1896
- sub(/^ - /, "", val)
1897
- gsub(/["\047]/, "", val)
1898
- print s "\037" val
2248
+ got_s[++ng] = s
2249
+ got_v[ng] = yaml_item(substr($0, 7))
1899
2250
  }
1900
2251
  }
1901
2252
  }
2253
+ END {
2254
+ m = ng + 0
2255
+ for (i = 1; i <= m; i++) {
2256
+ s = got_s[i]
2257
+ val = got_v[i]
2258
+ if (s != "rules" && s != "agents" && s != "skills") { print s "\037" val; continue }
2259
+ dir = pkr_expand(val)
2260
+ if (PKR_STATE == "path" || PKR_STATE == "ok") print s "\037" dir
2261
+ else print "!\037" s "\037" val "\037" PKR_STATE "\037" dir "\037" PKR_ALIAS "\037" PKR_HOLDERS
2262
+ }
2263
+ }
1902
2264
  ' "$file")"
1903
2265
  for s in "$@"; do
1904
2266
  j=""
@@ -1911,6 +2273,12 @@ load_yaml_lists() {
1911
2273
  printf -v "IS_YL_${s}_FILE" '%s' "$file"
1912
2274
  printf -v "IS_YL_${s}_VAL" '%s' "$j"
1913
2275
  done
2276
+ IS_YL_UNRESOLVED=""
2277
+ while IFS= read -r line; do
2278
+ case "$line" in
2279
+ "!"$'\037'*) IS_YL_UNRESOLVED="$IS_YL_UNRESOLVED${line#"!"$'\037'}"$'\n' ;;
2280
+ esac
2281
+ done <<< "$out"
1914
2282
  }
1915
2283
 
1916
2284
  # load_targets_cache <file> — parse the whole targets: section once into the
package/engine/sync.sh CHANGED
@@ -102,6 +102,15 @@ echo ""
102
102
  load_targets_cache "$CONFIG_FILE"
103
103
  load_yaml_lists "$CONFIG_FILE" rules agents skills ignore submodules
104
104
 
105
+ # A package reference that resolves to nothing is left out of every list, so
106
+ # nothing renders from it — but it is a manifest error, not a directory that
107
+ # does not exist yet, and it is named rather than skipped in silence.
108
+ while IFS=$'\037' read -r section src state _dir alias holders; do
109
+ [ -n "$section" ] || continue
110
+ source_reference_problem_var "$state" "$alias" "$holders"
111
+ echo "WARNING: sources.$section '$src' $IS_SOURCE_PROBLEM — skipped; 'intelligence status --check' reports it" >&2
112
+ done <<< "${IS_YL_UNRESOLVED:-}"
113
+
105
114
  # Lint frontmatter across all source files (rules, agents, skills).
106
115
  # Catches issues like unquoted colons that strict YAML consumers reject.
107
116
  LINT_FILES=()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ainova-systems/intelligence",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Build, version and distribute AI agent intelligence across your organization — one CLI, versioned Intelligence Packages, and a sync engine for Claude Code, Cursor, Copilot, Codex, Pi and OpenCode.",
5
5
  "bin": {
6
6
  "intelligence": "bin/intelligence.js"