@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
@@ -13,12 +13,13 @@ IS_CR=$'\r'
13
13
  IS_LF=$'\n'
14
14
  IS_CRLF=$'\r\n'
15
15
 
16
- # An ignore file checked out on Windows can hold CRLF, and a CR belongs to the
17
- # pattern Git matches — so these two helpers decide presence ignoring it, and
18
- # append in whatever ending the file already uses. Comparing with the CR
19
- # attached made every existing line look absent on Linux and macOS (where it is
20
- # part of the line, unlike under MSYS), so each alignment appended an LF copy of
21
- # a line that was already there and the file grew forever.
16
+ # An ignore file checked out on Windows can hold CRLF. Git drops one trailing
17
+ # CR from each line before matching, so `foo` and `foo<CR>` are the same rule:
18
+ # these helpers decide presence ignoring that CR, and append in whatever ending
19
+ # the file already uses. Comparing with the CR attached made every existing
20
+ # line look absent on Linux and macOS (where it is part of the line, unlike
21
+ # under MSYS), so each alignment appended an LF copy of a line that was already
22
+ # there and the file grew forever.
22
23
 
23
24
  # ignore_file_eol_var <file> — set IS_IGNORE_EOL from the file's first line.
24
25
  ignore_file_eol_var() {
@@ -32,12 +33,19 @@ ignore_file_eol_var() {
32
33
  }
33
34
 
34
35
  # ignore_file_has_line <file> <line> — true when the file lists exactly this
35
- # line, with or without a trailing CR.
36
+ # line, with or without a trailing CR. The one presence test for an ignore
37
+ # file: the writer and `status --check` both ask it, so a line one appends is a
38
+ # line the other reports present. Decided in bash, because an exact-line grep
39
+ # answers per platform — MSYS grep never sees the CR, Linux and macOS grep keep
40
+ # it as part of the line.
36
41
  ignore_file_has_line() {
37
- local file="$1" line="$2"
42
+ local file="$1" line="$2" entry=""
38
43
  [ -f "$file" ] || return 1
39
- grep -Fqx -- "$line" "$file" 2>/dev/null && return 0
40
- grep -Fqx -- "$line$IS_CR" "$file" 2>/dev/null
44
+ while IFS= read -r entry || [ -n "$entry" ]; do
45
+ [ "${entry%"$IS_CR"}" = "$line" ] && return 0
46
+ entry=""
47
+ done < "$file"
48
+ return 1
41
49
  }
42
50
 
43
51
  # ignore_file_append_line <file> <line> — append in the file's own ending.
@@ -88,29 +96,17 @@ gitignore_collapse_duplicates() {
88
96
  local root="$1" probe="$2"; shift 2
89
97
  local file="$root/.gitignore" tmp backup before=0 after=0
90
98
  local -a lines=() managed=("$@") last_idx=()
91
- local line key i mi n hdr=-1 keep final_newline=1
99
+ local key i mi n hdr keep final_newline
92
100
  [ -f "$file" ] || return 0
93
101
 
94
- # Read and write the file's own bytes, without awk: on Windows it reads in
95
- # text mode and would hand back every line stripped of its CR, rewriting a
96
- # CRLF file as LF. A line's CR is significant to Git, and either way the
97
- # lines this function does not remove must survive byte-for-byte.
98
- line=""
99
- while IFS= read -r line; do
100
- lines[${#lines[@]}]="$line"
101
- line=""
102
- done < "$file"
103
- # `read` returns 1 at EOF but still fills `line` when the file's last line
104
- # carries no newline; preserve that ending instead of adding one.
105
- if [ -n "$line" ]; then
106
- lines[${#lines[@]}]="$line"
107
- final_newline=0
108
- fi
109
- n=${#lines[@]}
110
- for ((i = 0; i < n; i++)); do
111
- if [ "${lines[i]%"$IS_CR"}" = "$IS_GITIGNORE_HEADER" ]; then hdr=$i; break; fi
112
- done
102
+ gitignore_read_var "$file"
103
+ hdr=$IS_GI_HEADER
113
104
  [ "$hdr" -ge 0 ] || return 0
105
+ # A header was found, so the array is not empty — Bash 3.2 under `set -u`
106
+ # cannot expand an empty one.
107
+ lines=("${IS_GI_LINES[@]}")
108
+ final_newline=$IS_GI_FINAL_NEWLINE
109
+ n=${#lines[@]}
114
110
 
115
111
  # Where each managed line last occurs below the header. Indexed arrays and
116
112
  # arithmetic `for` only — Bash 3.2 has no associative arrays.
@@ -161,6 +157,97 @@ gitignore_collapse_duplicates() {
161
157
  rm -f "$backup"
162
158
  }
163
159
 
160
+ # gitignore_read_var <file> — load the file's lines as stored, CRs included, into
161
+ # IS_GI_LINES. IS_GI_HEADER is the index of this policy's header (-1 when the
162
+ # file has none) and IS_GI_FINAL_NEWLINE is 0 when the last line ends without
163
+ # one. Every writer that rewrites .gitignore line by line reads it here.
164
+ #
165
+ # `read`, never awk: on Windows awk reads in text mode and would hand back every
166
+ # line stripped of its CR, rewriting a CRLF file as LF. The lines a writer does
167
+ # not remove must survive byte-for-byte, whatever their ending.
168
+ gitignore_read_var() {
169
+ local file="$1" line i
170
+ IS_GI_LINES=()
171
+ IS_GI_HEADER=-1
172
+ IS_GI_FINAL_NEWLINE=1
173
+ line=""
174
+ while IFS= read -r line; do
175
+ IS_GI_LINES[${#IS_GI_LINES[@]}]="$line"
176
+ line=""
177
+ done < "$file"
178
+ # `read` returns 1 at EOF but still fills `line` when the file's last line
179
+ # carries no newline; preserve that ending instead of adding one.
180
+ if [ -n "$line" ]; then
181
+ IS_GI_LINES[${#IS_GI_LINES[@]}]="$line"
182
+ IS_GI_FINAL_NEWLINE=0
183
+ fi
184
+ for ((i = 0; i < ${#IS_GI_LINES[@]}; i++)); do
185
+ if [ "${IS_GI_LINES[i]%"$IS_CR"}" = "$IS_GITIGNORE_HEADER" ]; then
186
+ IS_GI_HEADER=$i
187
+ break
188
+ fi
189
+ done
190
+ }
191
+
192
+ # gitignore_managed_has_line <root> <line> — true when the region this policy
193
+ # owns (its header to end of file) lists the line, with or without a CR.
194
+ gitignore_managed_has_line() {
195
+ local file="$1/.gitignore" want="$2" i
196
+ [ -f "$file" ] || return 1
197
+ gitignore_read_var "$file"
198
+ [ "$IS_GI_HEADER" -ge 0 ] || return 1
199
+ for ((i = IS_GI_HEADER + 1; i < ${#IS_GI_LINES[@]}; i++)); do
200
+ if [ "${IS_GI_LINES[i]%"$IS_CR"}" = "$want" ]; then
201
+ return 0
202
+ fi
203
+ done
204
+ return 1
205
+ }
206
+
207
+ # gitignore_remove_managed_lines <root> <line…> — take every copy of these lines
208
+ # out of the region this policy owns. A contract names them with `unignore` when
209
+ # the project's configuration keeps tracked what the default policy ignores
210
+ # (targets.copilot.commit_output), so the ignore an earlier alignment wrote is
211
+ # withdrawn by the writer that wrote it, not by a hand edit. Lines above the
212
+ # header predate Intelligence and stay — a hand-written copy of the same
213
+ # pattern included — and every kept line keeps its bytes and its own ending:
214
+ # only the file's last line can lack a newline, and none is added to it.
215
+ gitignore_remove_managed_lines() {
216
+ local root="$1"; shift
217
+ local file="$root/.gitignore" tmp key i mi n dropped=0
218
+ local -a remove=("$@") keep=()
219
+ [ "$#" -gt 0 ] && [ -f "$file" ] || return 0
220
+ gitignore_read_var "$file"
221
+ [ "$IS_GI_HEADER" -ge 0 ] || return 0
222
+ n=${#IS_GI_LINES[@]}
223
+ for ((i = 0; i < n; i++)); do
224
+ keep[i]=1
225
+ [ "$i" -gt "$IS_GI_HEADER" ] || continue
226
+ key="${IS_GI_LINES[i]%"$IS_CR"}"
227
+ for ((mi = 0; mi < ${#remove[@]}; mi++)); do
228
+ if [ "$key" = "${remove[mi]}" ]; then
229
+ keep[i]=0
230
+ dropped=1
231
+ break
232
+ fi
233
+ done
234
+ done
235
+ [ "$dropped" -eq 1 ] || return 0
236
+
237
+ tmp="$file.cli.tmp"
238
+ {
239
+ for ((i = 0; i < n; i++)); do
240
+ [ "${keep[i]}" -eq 1 ] || continue
241
+ if [ "$i" -eq $((n - 1)) ] && [ "$IS_GI_FINAL_NEWLINE" -eq 0 ]; then
242
+ printf '%s' "${IS_GI_LINES[i]}"
243
+ else
244
+ printf '%s\n' "${IS_GI_LINES[i]}"
245
+ fi
246
+ done
247
+ } > "$tmp"
248
+ mv -- "$tmp" "$file"
249
+ }
250
+
164
251
  # Git cannot re-include a child of an excluded directory, so an include needs an
165
252
  # explicit negation for every parent. A negation only wins when it comes after
166
253
  # the rule excluding the parent, and the only repair an append can make is to
@@ -233,18 +320,25 @@ ensure_base_gitignore() {
233
320
 
234
321
  ensure_target_gitignore() {
235
322
  local root="$1" manifest="$2" target="$3" content_dir output kind value records
323
+ local -a unignored=()
236
324
  ensure_gitignore_header "$root"
237
325
  content_dir="$(manifest_intelligence_dir "$manifest")"
238
326
  output="$(get_target_output "$manifest" "$target")"
239
327
  [ -n "$output" ] || output="$(default_target_output "$target")"
240
- records="$(adapter_records_for "$root" "$content_dir" "$target" "$output")" \
328
+ records="$(adapter_records_for "$root" "$content_dir" "$target" "$output" "$manifest")" \
241
329
  || die "adapter '$target' has an invalid ownership contract"
242
330
  while IFS=$'\t' read -r kind value; do
243
331
  case "$kind" in
244
- ignore) gitignore_add_line "$root" "$value" ;;
245
- include) gitignore_add_effective_include "$root" "$value" ;;
332
+ ignore) gitignore_add_line "$root" "$value" ;;
333
+ include) gitignore_add_effective_include "$root" "$value" ;;
334
+ unignore) unignored+=("$value") ;;
246
335
  esac
247
336
  done <<< "$records"
337
+ # One rewrite for all of them; Bash 3.2 cannot expand an empty array under
338
+ # `set -u`, hence the count guard.
339
+ if [ "${#unignored[@]}" -gt 0 ]; then
340
+ gitignore_remove_managed_lines "$root" "${unignored[@]}"
341
+ fi
248
342
  }
249
343
 
250
344
  ensure_manifest_gitignore() {
@@ -331,7 +425,7 @@ managed_gitignore_patterns() {
331
425
  [ "$(is_target_enabled "$manifest" "$target")" = "1" ] || continue
332
426
  output="$(get_target_output "$manifest" "$target")"
333
427
  [ -n "$output" ] || output="$(default_target_output "$target")"
334
- records="$(adapter_records_for "$root" "$content_dir" "$target" "$output")" \
428
+ records="$(adapter_records_for "$root" "$content_dir" "$target" "$output" "$manifest")" \
335
429
  || return 1
336
430
  while IFS=$'\t' read -r kind value; do
337
431
  case "$kind" in
@@ -2,9 +2,10 @@
2
2
  # The CLI-owned YAML shapes: quoted-key maps (`packages:`, `registries:` in
3
3
  # the manifest, `packages:` in the lock and in registry indexes).
4
4
  #
5
- # The engine deliberately never reads these blocks, and its readers cannot
6
- # hold `@scope/name` keys — so this is the CLI's single, deliberate parser,
7
- # scoped to exactly one shape:
5
+ # The engine reads none of these blocks beyond the names and aliases in
6
+ # `packages:`, which resolve package references in `sources:`
7
+ # (engine/lib/common.sh), and its list readers cannot hold `@scope/name` keys —
8
+ # so this is the CLI's single, deliberate parser, scoped to exactly one shape:
8
9
  #
9
10
  # block:
10
11
  # "@scope/name": # 2-space indent, key always quoted
@@ -407,12 +408,14 @@ registries_remove() {
407
408
  # meaning: a later entry overrides a same-named artifact from an earlier one.
408
409
  # Reading goes through the engine's own list parser, so the CLI sees exactly
409
410
  # what the engine will render instead of a second reading of the same file.
411
+ # The editors read it raw — a package reference as written, which is what they
412
+ # compare and rewrite — and read_source_entries says what each one renders.
410
413
 
411
- # sources_list_entries <file> <section> — entries of sources.<section>, one per
412
- # line, in manifest order.
414
+ # sources_list_entries <file> <section> — entries of sources.<section> as
415
+ # written, one per line, in manifest order.
413
416
  sources_list_entries() {
414
417
  [ -f "$1" ] || return 0
415
- read_yaml_list "$1" "$2"
418
+ read_yaml_list_raw "$1" "$2"
416
419
  }
417
420
 
418
421
  # sources_has_entry <file> <section> <entry> — true (0) when that section lists
@@ -439,7 +442,7 @@ sources_has_entry() {
439
442
  sources_add_entry() {
440
443
  local file="$1" section="$2" entry="$3" pos="${4:-last}" anchor="${5:-}"
441
444
  sources_has_entry "$file" "$section" "$entry" && return 0
442
- _qmap_stage "$file" -v section="$section" -v entry="$entry" -v pos="$pos" -v anchor="$anchor" '
445
+ _qmap_stage "$file" -v section="$section" -v entry="$entry" -v pos="$pos" -v anchor="$anchor" "$IS_YAML_ITEM_AWK"'
443
446
  function line() { return " - \"" entry "\"" }
444
447
  function place() { print line(); done = 1 }
445
448
  # Blank lines inside sources: are held back so an insert lands next to
@@ -447,13 +450,11 @@ sources_add_entry() {
447
450
  # one section from the next.
448
451
  function flush_tail( i) { for (i = 1; i <= ntail; i++) print tail[i]; blanks = ntail; ntail = 0 }
449
452
  function open_section() { flush_tail(); print " " section ":"; place(); if (blanks) print "" }
453
+ # The entry as the engine reads it (yaml_item, engine/lib/common.sh).
450
454
  function value(s, v) {
451
455
  v = s
452
- sub(/^[ \t]*-[ \t]*/, "", v)
453
- gsub(/["\x27]/, "", v)
454
- sub(/[ \t]+#.*$/, "", v)
455
- sub(/[ \t]+$/, "", v)
456
- return v
456
+ sub(/^[ \t]*-/, "", v)
457
+ return yaml_item(v)
457
458
  }
458
459
  BEGIN { anchored = (pos == "before" || pos == "after") }
459
460
  { sub(/\r$/, "") }
@@ -509,24 +510,171 @@ sources_add_entry_first() {
509
510
  }
510
511
 
511
512
  # sources_remove_entry <file> <section> <entry> — remove `- "entry"` from
512
- # sources.<section>.
513
+ # sources.<section>, matching each item as the engine reads it (yaml_item).
513
514
  sources_remove_entry() {
514
515
  local file="$1" section="$2" entry="$3"
515
516
  [ -f "$file" ] || return 0
516
- _qmap_stage "$file" -v section="$section" -v entry="$entry" '
517
+ _qmap_stage "$file" -v section="$section" -v entry="$entry" "$IS_YAML_ITEM_AWK"'
517
518
  { sub(/\r$/, "") }
518
519
  /^sources:[ \t]*$/ { ins = 1; print; next }
519
520
  ins && /^[^ #]/ { ins = 0; insec = 0 }
520
521
  ins && $0 ~ "^ " section ":[ \t]*$" { insec = 1; print; next }
521
522
  ins && /^ [A-Za-z_]/ { insec = 0 }
522
- insec {
523
+ insec && /^[ \t]*-/ {
523
524
  line = $0
524
- sub(/^[ \t]*-[ \t]*/, "", line)
525
- gsub(/["\x27]/, "", line)
526
- sub(/[ \t]+#.*$/, "", line)
527
- sub(/[ \t]+$/, "", line)
528
- if (line == entry) next
525
+ sub(/^[ \t]*-/, "", line)
526
+ if (yaml_item(line) == entry) next
529
527
  }
530
528
  { print }
531
529
  '
532
530
  }
531
+
532
+ # --- package references and aliases ------------------------------------------
533
+ # `sources:` may name a package's directory by its store path, by the package's
534
+ # full name or by the alias its `packages:` entry declares (decision 0019). The
535
+ # engine's list parser (IS_PKG_REF_AWK in engine/lib/common.sh) is the only
536
+ # definition of those spellings; everything below reads through it, so the CLI
537
+ # never judges an entry differently from the way the engine renders it. The CLI
538
+ # itself only ever writes store paths, and writes an alias only through
539
+ # `package add --alias` and `package alias`.
540
+
541
+ # assert_valid_alias <alias> — refuse an alias the engine would never resolve.
542
+ assert_valid_alias() {
543
+ pkg_alias_valid "$1" \
544
+ || die "invalid alias '$1' — expected $IS_PKG_ALIAS_RULE"
545
+ }
546
+
547
+ # package_alias_of <file> <@scope/name> — the alias that package declares, when it
548
+ # is well formed; nothing otherwise (an invalid one resolves nothing).
549
+ package_alias_of() {
550
+ local name alias verdict
551
+ while IFS=$'\037' read -r name alias verdict; do
552
+ [ "$name" = "$2" ] || continue
553
+ [ "$verdict" = invalid ] || printf '%s' "$alias"
554
+ return 0
555
+ done < <(read_package_aliases "$1")
556
+ }
557
+
558
+ # assert_alias_free <file> <@scope/name> <alias> — refuse an alias another package
559
+ # already declares: one alias names exactly one package.
560
+ assert_alias_free() {
561
+ local file="$1" self="$2" want="$3" name alias verdict
562
+ while IFS=$'\037' read -r name alias verdict; do
563
+ [ "$name" != "$self" ] && [ "$alias" = "$want" ] || continue
564
+ die "alias '$want' already names $name — one alias names one package; choose another"
565
+ done < <(read_package_aliases "$file")
566
+ }
567
+
568
+ # sources_alias_users <file> <alias> — every sources entry spelled <alias>:<dir>,
569
+ # one `sources.<section> '<entry>'` line each.
570
+ sources_alias_users() {
571
+ local file="$1" alias="$2" section entry
572
+ for section in rules agents skills; do
573
+ while IFS= read -r entry; do
574
+ case "$entry" in
575
+ "$alias":*) printf "sources.%s '%s'\n" "$section" "$entry" ;;
576
+ esac
577
+ done < <(sources_list_entries "$file" "$section")
578
+ done
579
+ }
580
+
581
+ # assert_alias_unused <file> <@scope/name> <alias> <remove|replace> — refuse to
582
+ # take away an alias `sources:` still spells entries with: they would stop
583
+ # resolving, and the package's content would leave the outputs in silence.
584
+ assert_alias_unused() {
585
+ local file="$1" name="$2" alias="$3" verb="$4" users
586
+ users="$(sources_alias_users "$file" "$alias")"
587
+ [ -n "$users" ] || return 0
588
+ echo "ERROR: cannot $verb alias '$alias' of $name — sources: still uses it:" >&2
589
+ printf '%s\n' "$users" | sed 's/^/ /' >&2
590
+ echo " Spell those entries $name/<dir> instead, then $verb the alias." >&2
591
+ exit 1
592
+ }
593
+
594
+ # sources_classify_entry <file> <entry> — `<state>\037<directory>` for an entry
595
+ # that is not in the manifest (yet): what the engine would render it as against
596
+ # the packages the manifest declares. States are pkr_parse's.
597
+ sources_classify_entry() {
598
+ local file="$1"
599
+ [ -f "$file" ] || file=/dev/null
600
+ IS_PKR_CANDIDATE="$2" awk "$IS_PKG_REF_AWK"'
601
+ { sub(/\r$/, ""); pkr_collect($0) }
602
+ END { dir = pkr_expand(ENVIRON["IS_PKR_CANDIDATE"]); print PKR_STATE "\037" dir }
603
+ ' "$file"
604
+ }
605
+
606
+ # sources_find_entry <file> <section> <entry> — print the entry of
607
+ # sources.<section>, as written, that names the same directory as <entry> in any
608
+ # spelling; fail when the section names it nowhere. An entry that renders
609
+ # nothing matches only itself.
610
+ sources_find_entry() {
611
+ local file="$1" section="$2" entry="$3" want _state raw _st dir _rest
612
+ [ -f "$file" ] || return 1
613
+ IFS=$'\037' read -r _state want <<< "$(sources_classify_entry "$file" "$entry")"
614
+ while IFS=$'\037' read -r raw _st dir _rest; do
615
+ if [ "$raw" = "$entry" ] || { [ -n "$want" ] && [ "$dir" = "$want" ]; }; then
616
+ printf '%s' "$raw"
617
+ return 0
618
+ fi
619
+ done < <(read_source_entries "$file" "$section")
620
+ return 1
621
+ }
622
+
623
+ # package_source_listed <file> <section> <store-dir> — true when sources.<section>
624
+ # already names <store-dir>, in whichever spelling.
625
+ package_source_listed() {
626
+ local _raw _st dir _rest
627
+ [ -f "$1" ] || return 1
628
+ while IFS=$'\037' read -r _raw _st dir _rest; do
629
+ [ "$dir" = "$3" ] && return 0
630
+ done < <(read_source_entries "$1" "$2")
631
+ return 1
632
+ }
633
+
634
+ # sources_remove_package <file> <@scope/name> [section...] — drop the package's
635
+ # entries from sources:, in every spelling. With sections, only the entry of
636
+ # sources.<section> that names the package's own <section> directory goes;
637
+ # without, every rules/agents/skills entry naming a directory inside the
638
+ # package. The references resolve through `packages:`, so call it while the
639
+ # package and its alias are still declared. Every other line survives byte for
640
+ # byte, and an unchanged file is not rewritten.
641
+ sources_remove_package() {
642
+ local file="$1" name="$2" tmp rc=0
643
+ shift 2
644
+ [ -f "$file" ] || return 0
645
+ tmp="$file.cli.tmp"
646
+ awk -v name="$name" -v only=" $* " "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK"'
647
+ BEGIN { store = ".intelligence/packages/" name }
648
+ FNR == 1 { pass++ }
649
+ pass == 1 { sub(/\r$/, ""); pkr_collect($0); next }
650
+ { sub(/\r$/, "") }
651
+ /^sources:[ \t]*$/ { ins = 1; print; next }
652
+ ins && /^[^ #]/ { ins = 0; sec = "" }
653
+ ins && /^ [A-Za-z_]/ {
654
+ sec = $0
655
+ sub(/^ /, "", sec)
656
+ sub(/:.*$/, "", sec)
657
+ if (sec != "rules" && sec != "agents" && sec != "skills") sec = ""
658
+ print
659
+ next
660
+ }
661
+ ins && sec != "" && /^[ \t]*-/ {
662
+ v = $0
663
+ sub(/^[ \t]*-/, "", v)
664
+ dir = pkr_expand(yaml_item(v))
665
+ if (only == " ") drop = dir == store || index(dir, store "/") == 1
666
+ else drop = index(only, " " sec " ") && dir == store "/" sec
667
+ if (drop) { changed = 1; next }
668
+ }
669
+ { print }
670
+ END { exit (changed ? 0 : 10) }
671
+ ' "$file" "$file" > "$tmp" || rc=$?
672
+ case "$rc" in
673
+ 0)
674
+ [ -s "$tmp" ] || { rm -f "$tmp"; die "internal: sources edit produced an empty file for $file"; }
675
+ mv "$tmp" "$file"
676
+ ;;
677
+ 10) rm -f "$tmp" ;;
678
+ *) rm -f "$tmp"; die "internal: sources edit refused (awk exit $rc) for $file" ;;
679
+ esac
680
+ }
@@ -200,25 +200,30 @@ fetch_package() {
200
200
  printf '%s' "$sha"
201
201
  }
202
202
 
203
- # wire_package_sources <manifest> <@scope/name> <store-rel-dir>
203
+ # wire_package_sources <manifest> <@scope/name> <store-rel-dir> <root>
204
204
  # The provides convention: whichever of rules/agents/skills the installed
205
- # package has becomes a sources entry — inserted FIRST in its section, so
206
- # project-owned entries come later and win on same-named artifacts.
205
+ # package has becomes a sources entry — its store path, inserted FIRST in its
206
+ # section, so project-owned entries come later and win on same-named artifacts.
207
+ # A section that already names that directory, in any spelling, keeps the entry
208
+ # where it stands: the person who wrote `@scope/name/<dir>` or `<alias>:<dir>`
209
+ # chose both the spelling and the position. Call it once `packages:` declares
210
+ # the package and its alias, which is what resolves those spellings.
207
211
  wire_package_sources() {
208
- local manifest="$1" name="$2" rel="$3" root="$4"
212
+ local manifest="$1" rel="$3" root="$4"
209
213
  local section
210
214
  for section in rules agents skills; do
211
- if [ -d "$root/$rel/$section" ]; then
212
- sources_add_entry_first "$manifest" "$section" "$rel/$section"
213
- fi
215
+ [ -d "$root/$rel/$section" ] || continue
216
+ package_source_listed "$manifest" "$section" "$rel/$section" && continue
217
+ sources_add_entry_first "$manifest" "$section" "$rel/$section"
214
218
  done
215
219
  }
216
220
 
217
- # unwire_package_sources <manifest> <store-rel-dir>
221
+ # unwire_package_sources <manifest> <@scope/name> [section...]
222
+ # Drop the package's sources entries in every spelling (sources_remove_package):
223
+ # every one without sections, as `package remove` does; only the named
224
+ # sections' own directories with them, as `update` does for the directories a
225
+ # new version no longer has. Call it while `packages:` still declares the
226
+ # package and its alias.
218
227
  unwire_package_sources() {
219
- local manifest="$1" rel="$2"
220
- local section
221
- for section in rules agents skills; do
222
- sources_remove_entry "$manifest" "$section" "$rel/$section"
223
- done
228
+ sources_remove_package "$@"
224
229
  }
package/engine/ENGINE_SHA CHANGED
@@ -1 +1 @@
1
- 1bf045d314f2c2f58a70d5a74130b5f5376b56e4
1
+ b1113b0658f6fddb23017e845a4ba79401caedbf
package/engine/VERSION CHANGED
@@ -1 +1 @@
1
- 0.18.0
1
+ 0.19.0
@@ -27,9 +27,11 @@
27
27
  # has_paths(file) — check if file has paths: field
28
28
  # get_model(config, ide, tier) — resolve model from config or default
29
29
  # get_model_default(ide, tier) — hardcoded default for ide:tier
30
+ # map_effort(tool, effort) — the tool's level for a neutral effort; "" when none
30
31
  # map_access_to_claude_tools(access) — full->"" (no tools list; inherits all), readonly->restricted
31
32
  # map_access_to_claude_disallowed(access) — readonly->"Write, Edit", full->""
32
- # read_yaml_list(manifest, section) — read a source list from intelligence.yaml
33
+ # read_yaml_list(manifest, section) — read a source list from intelligence.yaml,
34
+ # package references as their store paths
33
35
  # get_target_field(config, target, field) — read a target's config field
34
36
 
35
37
  # Project adapters are sourced by engine/sync.sh after the shared library is
@@ -40,7 +42,9 @@
40
42
  # configured target output passed as $1. `owned` paths are exclusively managed;
41
43
  # `managed` paths share a directory with another adapter or hand-authored files.
42
44
  # Add `legacy` inputs to the initial backup, `preserve` for settings that must
43
- # never be replaced, and explicit ignore/include records for Git policy.
45
+ # never be replaced, and explicit ignore/include records for Git policy. $2, when
46
+ # given, is the manifest: read it only to choose Git policy (ignore versus
47
+ # unignore), never ownership — callers that read ownership alone omit it.
44
48
  adapter_contract_<name>() {
45
49
  local output="${1%/}"
46
50
  adapter_contract_version 1
@@ -216,6 +216,9 @@ adapter_contract_agents() {
216
216
  adapter_contract_version 1
217
217
  adapter_contract_owned "$output"
218
218
  adapter_contract_legacy "AGENTS.md"
219
+ # No ignore record, deliberately: AGENTS.md is the tool-neutral fallback
220
+ # every tool reads, and a fresh clone has it only if it is committed —
221
+ # before anyone has run a sync.
219
222
  }
220
223
 
221
224
  sync_to_agents() {
@@ -3,8 +3,9 @@
3
3
  # Transforms source prompts to .claude/ format
4
4
  #
5
5
  # Rules: copy as-is (paths: frontmatter preserved)
6
- # Skills: copy skill directories in full (SKILL.md + bundled resources)
7
- # Agents: tier -> model, access -> tools/disallowedTools
6
+ # Skills: copy skill directories in full (SKILL.md + bundled resources),
7
+ # effort -> Claude's effort level
8
+ # Agents: tier -> model, effort -> effort, access -> tools/disallowedTools
8
9
  #
9
10
  # Every per-file loop batches its work into one awk process (see the batched
10
11
  # helpers in lib/common.sh): process spawns dominate sync time on Windows.
@@ -73,7 +74,7 @@ sync_claude_skills() {
73
74
  done
74
75
  done <<< "$list"
75
76
  [ "${#skill_dirs[@]}" -gt 0 ] || return 0
76
- copy_skill_bundle_dirs "$output_dir/skills" "${skill_dirs[@]}"
77
+ copy_skill_bundle_dirs_for claude "$output_dir/skills" "${skill_dirs[@]}"
77
78
  for d in "${skill_dirs[@]}"; do
78
79
  skill_name="${d%/}"
79
80
  echo " skill: ${skill_name##*/}"
@@ -81,7 +82,7 @@ sync_claude_skills() {
81
82
  }
82
83
 
83
84
  # Sync all agents to Claude format: one frontmatter pass resolves
84
- # tier/access for every file, one transform pass writes every output.
85
+ # tier/effort/access for every file, one transform pass writes every output.
85
86
  sync_claude_agents() {
86
87
  local repo_root="$1"
87
88
  local config_file="$2"
@@ -104,19 +105,22 @@ sync_claude_agents() {
104
105
 
105
106
  load_model_tiers "$config_file" "claude"
106
107
 
107
- local path tier access spec="" report=""
108
- while IFS=$'\x1f' read -r path tier access; do
108
+ local path tier access effort spec="" report=""
109
+ while IFS=$'\x1f' read -r path tier access effort; do
109
110
  [ -n "$path" ] || continue
110
111
  resolve_model_var "$tier"
112
+ map_effort_var claude "$effort"
111
113
  map_access_to_claude_tools_var "$access"
112
114
  map_access_to_claude_disallowed_var "$access"
113
- spec+="$path"$'\x1f'"$IS_MODEL"$'\x1f'"$IS_CLAUDE_TOOLS"$'\x1f'"$IS_CLAUDE_DISALLOWED"$'\n'
115
+ spec+="$path"$'\x1f'"$IS_MODEL"$'\x1f'"$IS_CLAUDE_TOOLS"$'\x1f'"$IS_CLAUDE_DISALLOWED"$'\x1f'"$IS_EFFORT"$'\n'
114
116
  report+=" agent: ${path##*/} (tier=$tier -> model=$IS_MODEL, access=$access)"$'\n'
115
- done < <(frontmatter_index "tier,access" "${files[@]}")
117
+ done < <(frontmatter_index "tier,access,effort" "${files[@]}")
116
118
 
117
119
  # The transform used to run per file with the values injected via -v;
118
120
  # the batched form carries them per file through the environment (awk -v
119
- # would reprocess backslash escapes).
121
+ # would reprocess backslash escapes). The first frontmatter `effort:` line
122
+ # becomes Claude's level in place, or goes when there is none; a later
123
+ # one goes too, since frontmatter_index read only the first.
120
124
  is_fin_awk_vars
121
125
  IS_MAP_SPEC="$spec" awk "${IS_FIN_V[@]}" -v dst="$output_dir/agents" "$IS_AWK_LIB"'
122
126
  BEGIN {
@@ -125,18 +129,25 @@ sync_claude_agents() {
125
129
  for (i = 1; i <= n; i++) {
126
130
  if (recs[i] == "") continue
127
131
  split(recs[i], f, US)
128
- MODEL[f[1]] = f[2]; TOOLS[f[1]] = f[3]; EXTRA[f[1]] = f[4]
132
+ MODEL[f[1]] = f[2]; TOOLS[f[1]] = f[3]; EXTRA[f[1]] = f[4]; EFFORT[f[1]] = f[5]
129
133
  }
130
134
  }
131
135
  FNR == 1 {
132
136
  if (out != "") close(out)
133
137
  out = dst "/" base_name(FILENAME)
134
138
  count = 0
139
+ fm = 0
140
+ effort_seen = 0
135
141
  }
136
142
  /^tier:/ { next }
137
143
  /^access:/ { next }
138
144
  { sub(/\r$/, "") }
145
+ FNR == 1 && $0 == "---" { fm = 1 }
139
146
  /^---$/ { count++ }
147
+ fm && count == 1 && substr($0, 1, 7) == "effort:" {
148
+ if (!effort_seen++ && EFFORT[FILENAME] != "") print fin_line("effort: " EFFORT[FILENAME]) > out
149
+ next
150
+ }
140
151
  count == 2 && /^---$/ {
141
152
  if (TOOLS[FILENAME] != "") print fin_line("tools: " TOOLS[FILENAME]) > out
142
153
  if (EXTRA[FILENAME] != "") print fin_line("disallowedTools: " EXTRA[FILENAME]) > out