@ainova-systems/intelligence 0.18.0 → 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 +559 -59
  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 +46 -9
  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
 
@@ -677,12 +707,23 @@ sync_open_skill_dirs() {
677
707
  # `agents/openai.yaml` beside SKILL.md. Source skills stay tool-neutral: the
678
708
  # policy file is derived here, in the open-standard tree Codex reads, so every
679
709
  # adapter sharing that tree writes the same bytes. A skill that ships its own
680
- # `agents/openai.yaml` keeps it — that is the author configuring Codex directly.
710
+ # `agents/openai.yaml` keeps the rest of it: the output copy gains the policy
711
+ # when the author's file sets none, and a file whose policy says otherwise, or
712
+ # that sync cannot read as saying it, refuses the render — an owner-only skill
713
+ # Codex can still select is the exact failure the field exists to prevent, so
714
+ # it never passes as a success.
681
715
  open_skill_invocation_policies() {
682
- local output_dir="$1" path flag rows
683
- local -a skill_mds=() policy_dirs=() policies=()
716
+ local output_dir="$1" path dir flag rows refusals
717
+ local -a skill_mds=() policy_dirs=() policies=() authored=()
684
718
  for path in "$output_dir"/*/SKILL.md; do
685
- [ -f "$path" ] && [ ! -L "$path" ] && skill_mds+=("$path")
719
+ dir="${path%/SKILL.md}"
720
+ # A link is emitted as-is (see _skill_bundle_note): reading through it
721
+ # or writing beside it would reach outside the output tree.
722
+ if [ -L "$dir" ] || [ -L "$path" ]; then
723
+ echo " WARN: ${dir##*/}/SKILL.md is reached through a symlink — Codex gets no invocation policy derived for it" >&2
724
+ elif [ -f "$path" ]; then
725
+ skill_mds+=("$path")
726
+ fi
686
727
  done
687
728
  [ "${#skill_mds[@]}" -gt 0 ] || return 0
688
729
  # Captured, not streamed: a reader cut short must fail the render rather
@@ -693,13 +734,32 @@ open_skill_invocation_policies() {
693
734
  }
694
735
  while IFS=$'\x1f' read -r path flag; do
695
736
  [ "$flag" = "true" ] || continue
696
- path="${path%/SKILL.md}/agents"
697
- # A symlinked `agents` would carry the write outside the output tree.
698
- [ -L "$path" ] && continue
699
- [ -e "$path/openai.yaml" ] || [ -L "$path/openai.yaml" ] && continue
737
+ dir="${path%/SKILL.md}"
738
+ path="$dir/agents"
739
+ if [ -L "$path" ] || [ -L "$path/openai.yaml" ]; then
740
+ echo " WARN: ${dir##*/}/agents/openai.yaml is a symlink — left as-is, its invocation policy is not enforced" >&2
741
+ continue
742
+ fi
743
+ if [ -s "$path/openai.yaml" ]; then
744
+ authored+=("$path/openai.yaml")
745
+ continue
746
+ fi
700
747
  policy_dirs+=("$path")
701
748
  policies+=("$path/openai.yaml")
702
749
  done <<< "$rows"
750
+ if [ "${#authored[@]}" -gt 0 ]; then
751
+ refusals="$(enforce_authored_invocation_policies "${authored[@]}")" || {
752
+ echo "ERROR: could not read or rewrite agents/openai.yaml under $output_dir" >&2
753
+ return 1
754
+ }
755
+ if [ -n "$refusals" ]; then
756
+ while IFS=$'\t' read -r path flag; do
757
+ path="${path%/agents/openai.yaml}"
758
+ echo "ERROR: ${path##*/} sets disable-model-invocation: true, but its agents/openai.yaml $flag — make them agree, or Codex can still select the skill" >&2
759
+ done <<< "$refusals"
760
+ return 1
761
+ fi
762
+ fi
703
763
  [ "${#policies[@]}" -gt 0 ] || return 0
704
764
  mkdir -p "${policy_dirs[@]}"
705
765
  for path in "${policies[@]}"; do
@@ -709,20 +769,128 @@ open_skill_invocation_policies() {
709
769
  finalize_output_files "${policies[@]}"
710
770
  }
711
771
 
772
+ # enforce_authored_invocation_policies <openai.yaml>... — one awk pass over
773
+ # author-owned Codex files of skills that set `disable-model-invocation: true`.
774
+ # A file already setting `policy.allow_implicit_invocation: false` is left
775
+ # alone; one without that key gains it, inside an existing `policy:` block or
776
+ # as a new block, every other line kept. Only the plain boolean on a direct
777
+ # child of `policy:` counts — the key Codex reads. A file that sets it
778
+ # otherwise, or whose `policy:` sync cannot read or extend safely, is printed
779
+ # as `<file>\t<reason>` and left untouched for the caller to refuse.
780
+ enforce_authored_invocation_policies() {
781
+ awk '
782
+ function reset() {
783
+ file = ""; n = 0; haspol = 0; inpol = 0; polline = 0
784
+ inline = ""; child = ""; val = ""; hasval = 0; bad = ""
785
+ }
786
+ # A one-line policy counts only as a flat flow mapping of plain
787
+ # scalars holding the key itself; a quoted or nested value is not read.
788
+ function inline_ok(s, body, parts, k, i, p) {
789
+ if (s !~ /^\{.*\}$/) return 0
790
+ body = substr(s, 2, length(s) - 2)
791
+ if (body ~ /[{}"\047]/ || index(body, "[") || index(body, "]")) return 0
792
+ k = split(body, parts, ",")
793
+ for (i = 1; i <= k; i++) {
794
+ p = parts[i]
795
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", p)
796
+ if (p ~ /^allow_implicit_invocation[[:space:]]*:[[:space:]]*false$/) return 1
797
+ }
798
+ return 0
799
+ }
800
+ function finish( i, ind) {
801
+ if (file == "") return
802
+ if (bad == "" && inline != "" && !inline_ok(inline))
803
+ bad = "has policy: " inline ", which does not plainly set allow_implicit_invocation: false"
804
+ if (bad == "" && hasval && val != "false")
805
+ bad = "sets policy.allow_implicit_invocation: " (val == "" ? "null" : val)
806
+ if (bad != "") { printf "%s\t%s\n", file, bad; reset(); return }
807
+ if (inline != "" || hasval) { reset(); return }
808
+ ind = (child != "") ? child : " "
809
+ for (i = 1; i <= n; i++) {
810
+ print L[i] > file
811
+ if (haspol && i == polline) print ind "allow_implicit_invocation: false" > file
812
+ }
813
+ if (!haspol) {
814
+ print "policy:" > file
815
+ print " allow_implicit_invocation: false" > file
816
+ }
817
+ close(file)
818
+ reset()
819
+ }
820
+ BEGIN { reset() }
821
+ FNR == 1 { finish(); file = FILENAME }
822
+ { sub(/\r$/, ""); L[++n] = $0 }
823
+ /^["\047]?policy["\047]?[[:space:]]*:/ {
824
+ if (haspol) bad = "has more than one policy: key"
825
+ haspol = 1; polline = n; inpol = 1
826
+ rest = $0
827
+ sub(/^[^:]*:[[:space:]]*/, "", rest)
828
+ sub(/[[:space:]]+#.*$/, "", rest)
829
+ sub(/^#.*$/, "", rest)
830
+ sub(/[[:space:]]+$/, "", rest)
831
+ if (rest != "") { inline = rest; inpol = 0 }
832
+ next
833
+ }
834
+ inpol && /^[^[:space:]#]/ { inpol = 0 }
835
+ # The first indented line fixes the depth of the direct children of
836
+ # policy:, and a deeper line belongs to another key, not the one Codex
837
+ # reads.
838
+ inpol && /^[[:space:]]+[^[:space:]#]/ {
839
+ match($0, /^[[:space:]]+/)
840
+ cur = substr($0, 1, RLENGTH)
841
+ if (child == "") {
842
+ child = cur
843
+ if ($0 ~ /^[[:space:]]+-([[:space:]]|$)/) bad = "has a policy: that is not a mapping"
844
+ } else if (length(cur) < length(child)) {
845
+ bad = "has a policy: block indented inconsistently"
846
+ }
847
+ if (cur == child && $0 ~ /^[[:space:]]+["\047]?allow_implicit_invocation["\047]?[[:space:]]*:/) {
848
+ v = $0
849
+ sub(/^[^:]*:[[:space:]]*/, "", v)
850
+ sub(/[[:space:]]+#.*$/, "", v)
851
+ sub(/^#.*$/, "", v)
852
+ sub(/[[:space:]]+$/, "", v)
853
+ hasval = 1; val = v
854
+ }
855
+ }
856
+ END { finish() }
857
+ ' "$@"
858
+ }
859
+
712
860
  # Lint YAML frontmatter for common pitfalls (unquoted colons, leading tabs).
713
861
  # Print warnings to stderr; do not fail. Strict consumers (Codex CLI) reject
714
862
  # these files with cryptic messages — catching them in sync gives better DX.
715
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).
716
870
  # Usage: lint_frontmatter_files "a.md" "b.md" ...
717
871
  lint_frontmatter_files() {
718
872
  [ "$#" -gt 0 ] || return 0
719
- awk '
720
- 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 }
721
883
  { sub(/\r$/, "") }
722
884
  done { next }
723
885
  FNR == 1 && $0 != "---" { done = 1; next }
724
886
  FNR == 1 { in_fm = 1; next }
725
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
+ }
726
894
  in_fm && /^\t/ {
727
895
  printf " WARN: %s:%d leading tab in frontmatter (use spaces)\n", FILENAME, FNR > "/dev/stderr"
728
896
  }
@@ -876,8 +1044,8 @@ get_model_default_var() {
876
1044
  cursor:heavy) IS_MODEL_DEFAULT="inherit" ;;
877
1045
  cursor:standard) IS_MODEL_DEFAULT="inherit" ;;
878
1046
  cursor:light) IS_MODEL_DEFAULT="inherit" ;;
879
- # GPT-6 has no mid model, so frontier and heavy share Astra; Codex
880
- # 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.
881
1049
  copilot:frontier) IS_MODEL_DEFAULT="gpt-6-astra" ;;
882
1050
  copilot:heavy) IS_MODEL_DEFAULT="gpt-6-astra" ;;
883
1051
  copilot:standard) IS_MODEL_DEFAULT="gpt-6.1-sol" ;;
@@ -1010,7 +1178,11 @@ load_model_tiers() {
1010
1178
  }
1011
1179
 
1012
1180
  # resolve_model_var <tier> — set IS_MODEL from the tiers load_model_tiers
1013
- # resolved. An empty tier resolves to heavy, like get_model's default.
1181
+ # resolved. An empty tier resolves to heavy, like get_model's default. A tier
1182
+ # the tool has no default for and the manifest overrides nowhere — a typo, as a
1183
+ # rule — still renders an empty model, but never silently: the run warns once
1184
+ # per tool and tier, unindented on purpose because `sync --compact` keeps only
1185
+ # `WARNING:` lines (decision 0016).
1014
1186
  # shellcheck disable=SC2034 # IS_MODEL is the return channel read by adapters
1015
1187
  resolve_model_var() {
1016
1188
  case "$1" in
@@ -1018,7 +1190,17 @@ resolve_model_var() {
1018
1190
  heavy|"") IS_MODEL="$IS_MODEL_HEAVY" ;;
1019
1191
  standard) IS_MODEL="$IS_MODEL_STANDARD" ;;
1020
1192
  light) IS_MODEL="$IS_MODEL_LIGHT" ;;
1021
- *) IS_MODEL="$(get_model "$IS_MODEL_CFG" "$IS_MODEL_IDE" "$1")" ;;
1193
+ *)
1194
+ IS_MODEL="$(get_model "$IS_MODEL_CFG" "$IS_MODEL_IDE" "$1")"
1195
+ [ -n "$IS_MODEL" ] && return 0
1196
+ case "${IS_MODEL_WARNED:-|}" in
1197
+ *"|$IS_MODEL_IDE:$1|"*) ;;
1198
+ *)
1199
+ IS_MODEL_WARNED="${IS_MODEL_WARNED:-|}$IS_MODEL_IDE:$1|"
1200
+ echo "WARNING: no $IS_MODEL_IDE model for tier '$1' — agents with it get an empty model; use frontier, heavy, standard or light, or set models.$IS_MODEL_IDE.$1" >&2
1201
+ ;;
1202
+ esac
1203
+ ;;
1022
1204
  esac
1023
1205
  }
1024
1206
 
@@ -1110,6 +1292,64 @@ map_access_to_claude_disallowed() {
1110
1292
  echo "$IS_CLAUDE_DISALLOWED"
1111
1293
  }
1112
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
+
1113
1353
  # --- Validation ---
1114
1354
 
1115
1355
  # Lexically canonicalize a path: collapse `//`, `.` and `..` by pure string
@@ -1655,9 +1895,213 @@ report_context_source_sizes() {
1655
1895
 
1656
1896
  # --- Config Parsing ---
1657
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
+
1658
2097
  # Read a simple list from config.yaml
1659
2098
  # Format: key:\n - "value1"\n - "value2"
1660
- # 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.
1661
2105
  #
1662
2106
  # Consults the load_yaml_list cache first: sync reads the same sections from
1663
2107
  # the same manifest dozens of times, and each awk spawn costs tens of
@@ -1681,27 +2125,67 @@ read_yaml_list() {
1681
2125
  fi
1682
2126
  ;;
1683
2127
  esac
1684
- awk -v section="$section" '
1685
- {
1686
- sub(/\r$/, "")
1687
- }
1688
- /^[a-z]/ { current_section = ""; depth = 0 }
1689
- /^ [a-z]/ { current_section = ""; depth = 0 }
1690
- $0 ~ "^" section ":" { current_section = section; depth = 0; next }
1691
- $0 ~ "^ " section ":" { current_section = section; depth = 2; next }
1692
- current_section == section && depth == 0 && /^ - / {
1693
- val = $0
1694
- sub(/^ - /, "", val)
1695
- gsub(/["\047]/, "", val)
1696
- print val
1697
- }
1698
- current_section == section && depth == 2 && /^ - / {
1699
- val = $0
1700
- sub(/^ - /, "", val)
1701
- gsub(/["\047]/, "", val)
1702
- 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
+ }
1703
2163
  }
1704
- ' "$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
1705
2189
  }
1706
2190
 
1707
2191
  # load_yaml_list <file> <section> — fill the global IS_YAML_LIST with the
@@ -1733,7 +2217,10 @@ load_yaml_list() {
1733
2217
 
1734
2218
  # load_yaml_lists <file> <section>... — warm the load_yaml_list cache for several
1735
2219
  # sections in one manifest pass. Each section runs read_yaml_list's own state
1736
- # 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.
1737
2224
  load_yaml_lists() {
1738
2225
  local file="$1" out line s v j
1739
2226
  shift
@@ -1743,9 +2230,9 @@ load_yaml_lists() {
1743
2230
  *) return 1 ;;
1744
2231
  esac
1745
2232
  done
1746
- out="$(awk -v sections="$*" '
2233
+ out="$(awk -v sections="$*" "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK"'
1747
2234
  BEGIN { n = split(sections, want, " ") }
1748
- { sub(/\r$/, "") }
2235
+ { sub(/\r$/, ""); pkr_collect($0) }
1749
2236
  {
1750
2237
  for (k = 1; k <= n; k++) {
1751
2238
  s = want[k]
@@ -1754,19 +2241,26 @@ load_yaml_lists() {
1754
2241
  if ($0 ~ "^" s ":") { cur[k] = s; dep[k] = 0; continue }
1755
2242
  if ($0 ~ "^ " s ":") { cur[k] = s; dep[k] = 2; continue }
1756
2243
  if (cur[k] == s && dep[k] == 0 && /^ - /) {
1757
- val = $0
1758
- sub(/^ - /, "", val)
1759
- gsub(/["\047]/, "", val)
1760
- print s "\037" val
2244
+ got_s[++ng] = s
2245
+ got_v[ng] = yaml_item(substr($0, 5))
1761
2246
  }
1762
2247
  if (cur[k] == s && dep[k] == 2 && /^ - /) {
1763
- val = $0
1764
- sub(/^ - /, "", val)
1765
- gsub(/["\047]/, "", val)
1766
- print s "\037" val
2248
+ got_s[++ng] = s
2249
+ got_v[ng] = yaml_item(substr($0, 7))
1767
2250
  }
1768
2251
  }
1769
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
+ }
1770
2264
  ' "$file")"
1771
2265
  for s in "$@"; do
1772
2266
  j=""
@@ -1779,6 +2273,12 @@ load_yaml_lists() {
1779
2273
  printf -v "IS_YL_${s}_FILE" '%s' "$file"
1780
2274
  printf -v "IS_YL_${s}_VAL" '%s' "$j"
1781
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"
1782
2282
  }
1783
2283
 
1784
2284
  # load_targets_cache <file> — parse the whole targets: section once into the