@ainova-systems/intelligence 0.21.0 → 0.22.1

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.
@@ -106,6 +106,10 @@ run_check() {
106
106
  die "no intelligence project found here - run 'intelligence init'"
107
107
  ;;
108
108
  esac
109
+ # A check marks the lock it holds, so a sync started meanwhile waits for
110
+ # it instead of refusing (sync-lock.sh).
111
+ # shellcheck disable=SC2034 # read by sync-lock.sh when the lock is taken
112
+ SYNC_LOCK_KIND=check
109
113
  hold_project
110
114
  project_preflight "$IP_ROOT" || return $?
111
115
  if project_needs_upgrade "$IP_ROOT"; then
@@ -144,6 +148,11 @@ fi
144
148
  compact_output="$(mktemp -t intelligence-sync-XXXXXX)"
145
149
  trap 'rm -f "$compact_output"' EXIT
146
150
  rc=0
151
+ # The one line that must not wait for the buffer: a wait for a check holding
152
+ # the lock can last minutes (sync-lock.sh). Descriptor 9 keeps the real stderr.
153
+ exec 9>&2
154
+ # shellcheck disable=SC2034 # read by sync-lock.sh while the lock is taken
155
+ IS_LOCK_NOTICE_FD=9
147
156
  (run_sync) > "$compact_output" 2>&1 || rc=$?
148
157
  if [ "$rc" -ne 0 ]; then
149
158
  cat "$compact_output" >&2
package/cli/intelligence CHANGED
@@ -7,6 +7,12 @@
7
7
 
8
8
  set -euo pipefail
9
9
 
10
+ # Bash keeps a PWD inherited from its parent when it names the current
11
+ # directory, so a Windows caller can hand over `D:/x` or `D:\x` for what every
12
+ # other caller sees as `/d/x`. Taken once here, one spelling gives the project
13
+ # one root and the CLI one location, whoever started it.
14
+ case "$PWD" in [A-Za-z]:*) cd -P . ;; esac
15
+
10
16
  case "$0" in
11
17
  */*) CLI_DIR="${0%/*}"; [ -n "$CLI_DIR" ] || CLI_DIR="/" ;;
12
18
  *) CLI_DIR="." ;;
@@ -4,19 +4,19 @@
4
4
  #
5
5
  # The record keeps three fingerprints apart (decision 0018), so a check can say
6
6
  # which side moved:
7
- # tooling the CLI and engine code, plus the invocation and environment that
8
- # shape rendering — target filter, root, layout, bash, locale, PATH,
9
- # umask. A change here is not the project's: a check renders rather
10
- # than answer "sync needed" from it.
7
+ # tooling the CLI and engine code and the adapter filter. A change here is
8
+ # not the project's: a check renders rather than answer "sync
9
+ # needed" from it.
11
10
  # inputs the manifest, the lock, every configured source including skill
12
11
  # resources and installed packages, and the project adapter folder.
13
12
  # outputs every path the enabled built-in adapters own or manage.
14
- # Each fingerprint lists names, entry kinds and executable state beside content
15
- # hashes; timestamps never count. `.intelligence/sync-cache/state` holds the
13
+ # Each fingerprint lists relative names, entry kinds and executable state beside
14
+ # content hashes; timestamps, the project's location and the caller's
15
+ # environment never count. `.intelligence/sync-cache/state` holds the
16
16
  # record and the report a hit replays; `seal` holds the state file's own hash,
17
17
  # so a record is trusted only whole.
18
18
 
19
- SC_FORMAT='intelligence-sync-cache-v2'
19
+ SC_FORMAT='intelligence-sync-cache-v3'
20
20
  SC_SAFE_SEEN=$'\n'
21
21
 
22
22
  sync_cache_safe_path() {
@@ -155,6 +155,19 @@ sync_cache_dependencies() {
155
155
  }
156
156
 
157
157
  # --- Fingerprint ---------------------------------------------------------------
158
+ # sync_cache_label_var <path> — IS_SC_LABEL, the name a fingerprint records for
159
+ # a path: relative to the engine or CLI directory for tooling, to the project
160
+ # otherwise. Where the project or the CLI lives, and how that path is spelled,
161
+ # never decides whether the same files are current.
162
+ sync_cache_label_var() {
163
+ case "$1" in
164
+ "$IS_ENGINE_DIR"|"$IS_ENGINE_DIR"/*) IS_SC_LABEL="engine${1#"$IS_ENGINE_DIR"}" ;;
165
+ "$CLI_DIR"|"$CLI_DIR"/*) IS_SC_LABEL="cli${1#"$CLI_DIR"}" ;;
166
+ "$REPO_ROOT"/*) IS_SC_LABEL="${1#"$REPO_ROOT/"}" ;;
167
+ *) IS_SC_LABEL="$1" ;;
168
+ esac
169
+ }
170
+
158
171
  # _sync_cache_starts <group> <path>... — queue a group's start points. A missing
159
172
  # one is recorded as missing; a repeated one is walked once.
160
173
  _sync_cache_starts() {
@@ -171,7 +184,8 @@ _sync_cache_starts() {
171
184
  SC_STARTS+=("$path")
172
185
  SC_START_GROUPS+=("$group")
173
186
  else
174
- SC_MISSING+=("$group missing $path")
187
+ sync_cache_label_var "$path"
188
+ SC_MISSING+=("$group missing $IS_SC_LABEL")
175
189
  fi
176
190
  done
177
191
  }
@@ -190,7 +204,7 @@ _SC_AWK_LISTING='
190
204
  starts = split(ENVIRON["SC_AWK_STARTS"], rows, "\n")
191
205
  for (i = 1; i <= starts; i++) {
192
206
  split(rows[i], field, "\t")
193
- group[i] = field[1]; posix[i] = field[2]; native[i] = field[3]
207
+ group[i] = field[1]; posix[i] = field[2]; native[i] = field[3]; label[i] = field[4]
194
208
  }
195
209
  probe = ENVIRON["SC_AWK_EXEC_PROBE"]
196
210
  stream = (ENVIRON["SC_AWK_STREAM"] == 1)
@@ -209,7 +223,7 @@ _SC_AWK_LISTING='
209
223
  if (!last) { bad = 1; return }
210
224
  }
211
225
  g = group[last]
212
- listing[g] = listing[g] kind " " entry "\037"
226
+ listing[g] = listing[g] kind " " label[last] substr(entry, length(posix[last]) + 1) "\037"
213
227
  if (kind == "d") return
214
228
  k = ++files[g]
215
229
  path = native[last] substr(entry, length(posix[last]) + 1)
@@ -248,7 +262,7 @@ _SC_AWK_LISTING='
248
262
  # the listing stage tells kinds without a stat per entry. git hashes the files
249
263
  # while the walk is still running.
250
264
  sync_cache_fingerprint() {
251
- local target="$1" hashes i j n value ordered=0 starts_env="" state="" umask_value
265
+ local target="$1" hashes i j n value ordered=0 starts_env="" state=""
252
266
  local tooling inputs outputs listing saved_ifs="$IFS"
253
267
  local -a hash_lines=() expression=() keep=() starts=() groups=() natives=() parts=() count=()
254
268
  SC_STARTS=() SC_START_GROUPS=() SC_MISSING=() SC_START_SEEN=$'\n'
@@ -299,7 +313,8 @@ sync_cache_fingerprint() {
299
313
  for ((j = 0; j < ${#starts[@]}; j++)); do
300
314
  value="${starts[j]}"
301
315
  [ "$SC_TRANSLATE" != 1 ] || value="${natives[j]}"
302
- starts_env="$starts_env${groups[j]}"$'\t'"${starts[j]}"$'\t'"$value"$'\n'
316
+ sync_cache_label_var "${starts[j]}"
317
+ starts_env="$starts_env${groups[j]}"$'\t'"${starts[j]}"$'\t'"$value"$'\t'"$IS_SC_LABEL"$'\n'
303
318
  done
304
319
  starts_env="${starts_env%$'\n'}"
305
320
  expression=(\( ! -type d ! -type f -exec false {} + \)
@@ -359,15 +374,11 @@ sync_cache_fingerprint() {
359
374
  [ "${count[0]}" -eq 0 ] || { printf -v value '%s\037' "${hash_lines[@]:0:${count[0]}}"; tooling="$tooling$value"; }
360
375
  [ "${count[1]}" -eq 0 ] || { printf -v value '%s\037' "${hash_lines[@]:${count[0]}:${count[1]}}"; inputs="$inputs$value"; }
361
376
  [ "${count[2]}" -eq 0 ] || { printf -v value '%s\037' "${hash_lines[@]:$((count[0] + count[1])):${count[2]}}"; outputs="$outputs$value"; }
362
- # Explicit values that affect rendering, discovery or parsing. No secrets
363
- # or inherited environment dump are persisted. umask prints into a file so
364
- # reading it costs no subshell.
365
- umask > "$SC_SCRATCH.umask" || return 1
366
- IFS= read -r umask_value < "$SC_SCRATCH.umask" || return 1
367
- for value in "$target" "$REPO_ROOT" "$CONFIG_FILE" "$IS_CONTENT_REL" "$IS_MODULE_REL" \
368
- "$IS_SYNC_CMD" "$IS_MANIFEST_NAME" "$IS_PROTECTED_DIRS" "$BASH_VERSION" "${OSTYPE:-}" \
369
- "${LANG:-}" "${LC_ALL:-}" "${LC_CTYPE:-}" "${LC_COLLATE:-}" "${PATH:-}" "$umask_value" \
370
- "exec-probe=$SC_EXEC_PROBE" "walk-ordered=$ordered"; do
377
+ # The adapter filter selects what renders; the two listing flags say how
378
+ # the listings above were made. Nothing else of the invocation counts:
379
+ # the code, the manifest and the files decide what sync writes, wherever
380
+ # the project lives and whoever starts the CLI.
381
+ for value in "filter=$target" "exec-probe=$SC_EXEC_PROBE" "walk-ordered=$ordered"; do
371
382
  case "$value" in *[[:cntrl:]]*) return 1 ;; esac
372
383
  tooling="${tooling}env $value"$'\037'
373
384
  done
@@ -514,9 +525,15 @@ sync_with_cache() {
514
525
 
515
526
  # The write contract marks links whose spelling must remain unchanged: they
516
527
  # are compared, never dereferenced, and are not renderer read inputs.
528
+ #
529
+ # SC_CHECK_OUTPUTS holds the selected adapters' paths; SC_CHECK_ALL_OUTPUTS
530
+ # every enabled adapter's, since a filtered render still reads what the others
531
+ # generated. SC_CHECK_PROJECT_ADAPTER is 1 when an enabled adapter is the
532
+ # project's own code.
517
533
  sync_check_write_paths() {
518
534
  local filter="$1" dir file name i output adapter kind value
519
535
  local -a names=() files=() batch=()
536
+ SC_CHECK_PROJECT_ADAPTER=0
520
537
  for dir in "$IS_ENGINE_DIR/adapters" "$REPO_ROOT/$IS_CONTENT_REL/adapters"; do
521
538
  for file in "$dir"/*.sh; do
522
539
  [ -f "$file" ] || continue
@@ -530,41 +547,142 @@ sync_check_write_paths() {
530
547
  i=0
531
548
  while [ "$i" -lt "${#names[@]}" ]; do
532
549
  name="${names[i]}"; file="${files[i]}"; i=$((i + 1))
533
- [ -z "$filter" ] || [ "$name" = "$filter" ] || continue
534
550
  target_enabled_var "$CONFIG_FILE" "$name"
535
551
  [ "$IS_TGT_ENABLED" = 1 ] || continue
552
+ case "$file" in "$REPO_ROOT/$IS_CONTENT_REL/adapters/"*) SC_CHECK_PROJECT_ADAPTER=1 ;; esac
536
553
  target_output_var "$CONFIG_FILE" "$name"
537
554
  output="${IS_TGT_OUTPUT:-.$name}"
538
555
  batch+=("$name" "$file" "$output")
539
556
  done
540
- SC_CHECK_OUTPUTS=()
557
+ SC_CHECK_OUTPUTS=() SC_CHECK_ALL_OUTPUTS=()
541
558
  [ "${#batch[@]}" -gt 0 ] || return 0
542
559
  adapter_contract_records_batch "$SC_SCRATCH.check-contract" "${batch[@]}" > "$SC_SCRATCH.check-records" || return 1
543
560
  while IFS=$'\t' read -r adapter kind value; do
544
- case "$kind" in owned|managed) SC_CHECK_OUTPUTS+=("$value") ;; esac
561
+ case "$kind" in
562
+ owned|managed)
563
+ SC_CHECK_ALL_OUTPUTS+=("$value")
564
+ [ -z "$filter" ] || [ "$adapter" = "$filter" ] || continue
565
+ SC_CHECK_OUTPUTS+=("$value")
566
+ ;;
567
+ esac
545
568
  done < "$SC_SCRATCH.check-records"
546
569
  }
547
570
 
571
+ # sync_check_unlinked_path <rel> — true when no existing component of the
572
+ # project path, the last included, is a link. Copying such a path alone would
573
+ # leave its link pointing at something the copy does not hold.
574
+ sync_check_unlinked_path() {
575
+ local rest="$1" probe="$REPO_ROOT" part
576
+ while [ -n "$rest" ]; do
577
+ part="${rest%%/*}"
578
+ if [ "$part" = "$rest" ]; then rest=""; else rest="${rest#*/}"; fi
579
+ probe="$probe/$part"
580
+ [ ! -L "$probe" ] || return 1
581
+ [ -e "$probe" ] || return 0
582
+ done
583
+ }
584
+
585
+ # sync_check_copy_paths — SC_CHECK_COPY, the existing project paths a render
586
+ # by built-in adapters reads or writes, none inside another. Return 1 when only
587
+ # a copy of the whole project is faithful: a project adapter may read any file,
588
+ # and a link on the way to an input may point anywhere in the project.
589
+ # Everything else the engine reads in the project is a directory listing the
590
+ # check takes from the live tree (warn_unsynced).
591
+ sync_check_copy_paths() {
592
+ local section src rel other kept
593
+ local -a wanted=("${CONFIG_FILE#"$REPO_ROOT/"}" intelligence.lock .intelligence/packages
594
+ "$IS_CONTENT_REL/adapters" .agents/rules .agents/agents)
595
+ SC_CHECK_COPY=()
596
+ [ "$SC_CHECK_PROJECT_ADAPTER" = 0 ] || return 1
597
+ for section in rules agents skills; do
598
+ load_yaml_list "$CONFIG_FILE" "$section"
599
+ while IFS= read -r src; do
600
+ case "$src" in
601
+ ''|/*|..|../*|*/..|*/../*) continue ;; # Outside the project: read where it is.
602
+ esac
603
+ wanted+=("$src")
604
+ done <<< "$IS_YAML_LIST"
605
+ done
606
+ wanted+=(${SC_CHECK_ALL_OUTPUTS[@]+"${SC_CHECK_ALL_OUTPUTS[@]}"})
607
+ for rel in "${wanted[@]}"; do
608
+ normalize_path_var "/$rel"
609
+ rel="${IS_NORM_PATH#/}"
610
+ case "$rel" in ''|.git|.git/*) return 1 ;; esac
611
+ sync_check_unlinked_path "$rel" || return 1
612
+ [ -e "$REPO_ROOT/$rel" ] || continue
613
+ kept=()
614
+ for other in ${SC_CHECK_COPY[@]+"${SC_CHECK_COPY[@]}"}; do
615
+ case "$rel" in "$other"|"$other"/*) continue 2 ;; esac
616
+ case "$other" in "$rel"/*) continue ;; esac
617
+ kept+=("$other")
618
+ done
619
+ SC_CHECK_COPY=(${kept[@]+"${kept[@]}"} "$rel")
620
+ done
621
+ }
622
+
623
+ # sync_check_holds <private-project> <rel> — true when the private copy holds
624
+ # the project path: inside a copied entry, never a directory made only as the
625
+ # parent of one. An empty SC_CHECK_COPY means the whole project was copied.
626
+ sync_check_holds() {
627
+ local entry
628
+ [ -e "$1/$2" ] || [ -L "$1/$2" ] || return 1
629
+ [ "${#SC_CHECK_COPY[@]}" -gt 0 ] || return 0
630
+ for entry in "${SC_CHECK_COPY[@]}"; do
631
+ case "$2" in "$entry"|"$entry"/*) return 0 ;; esac
632
+ done
633
+ return 1
634
+ }
635
+
548
636
  # Check renders use a private project tree. A rollback in the live tree would
549
- # overwrite unrelated edits made while the renderer was running.
637
+ # overwrite unrelated edits made while the renderer was running. The tree holds
638
+ # only what the render reads and writes: a copy of the whole project took
639
+ # minutes and gigabytes where dependency and build trees sit beside the sources.
550
640
  sync_check_render() (
551
- local target="$1" verdict="$2" staging entry link target_link manifest_rel rel output is_output original_target original_root="$REPO_ROOT" rc=0
552
- local -a SC_CHECK_OUTPUTS=()
641
+ local target="$1" verdict="$2" staging entry link target_link manifest_rel rel output is_output original_target original_root="$REPO_ROOT" rc=0 parent
642
+ local -a SC_CHECK_OUTPUTS=() SC_CHECK_ALL_OUTPUTS=() SC_CHECK_COPY=() parents=() batch=()
643
+ local SC_CHECK_PROJECT_ADAPTER=0
553
644
  sync_check_write_paths "$target" || return 1
554
645
  manifest_rel="${CONFIG_FILE#"$REPO_ROOT/"}"
555
646
  staging="$(mktemp -d -t intelligence-check-XXXXXX)" || return 1
556
647
  trap 'rm -rf "$staging"' EXIT
557
648
  trap 'exit 130' INT
558
649
  trap 'exit 143' TERM
559
- mkdir "$staging/project" || { rm -rf "$staging"; return 1; }
560
- for entry in "$REPO_ROOT"/* "$REPO_ROOT"/.[!.]* "$REPO_ROOT"/..?*; do
561
- [ -e "$entry" ] || [ -L "$entry" ] || continue
562
- # Git history is not a renderer input. The project lock contains the
563
- # check's scratch buffers and must stay solely in the real project.
564
- [ "${entry##*/}" != .git ] || continue
565
- cp -a "$entry" "$staging/project/" || rc=$?
566
- [ "$rc" = 0 ] || break
567
- done
650
+ # A run killed outright never reaches its trap; the lock's release or
651
+ # takeover removes the copy it names here.
652
+ : > "$staging/.intelligence-check" || return 1
653
+ printf '%s\n' "$staging" > "$SC_SCRATCH.tmpdir" || return 1
654
+ mkdir "$staging/project" || return 1
655
+ if sync_check_copy_paths; then
656
+ # One copy per parent directory: on Git Bash each process costs tens
657
+ # of milliseconds.
658
+ for rel in ${SC_CHECK_COPY[@]+"${SC_CHECK_COPY[@]}"}; do
659
+ parent=.
660
+ case "$rel" in */*) parent="${rel%/*}" ;; esac
661
+ for entry in ${parents[@]+"${parents[@]}"}; do
662
+ [ "$entry" != "$parent" ] || continue 2
663
+ done
664
+ parents+=("$parent")
665
+ done
666
+ for parent in ${parents[@]+"${parents[@]}"}; do
667
+ batch=()
668
+ for rel in "${SC_CHECK_COPY[@]}"; do
669
+ entry=.
670
+ case "$rel" in */*) entry="${rel%/*}" ;; esac
671
+ [ "$entry" != "$parent" ] || batch+=("$REPO_ROOT/$rel")
672
+ done
673
+ mkdir -p "$staging/project/$parent" && cp -a "${batch[@]}" "$staging/project/$parent/" || { rc=1; break; }
674
+ done
675
+ else
676
+ SC_CHECK_COPY=()
677
+ for entry in "$REPO_ROOT"/* "$REPO_ROOT"/.[!.]* "$REPO_ROOT"/..?*; do
678
+ [ -e "$entry" ] || [ -L "$entry" ] || continue
679
+ # Git history is not a renderer input. The project lock contains the
680
+ # check's scratch buffers and must stay solely in the real project.
681
+ [ "${entry##*/}" != .git ] || continue
682
+ cp -a "$entry" "$staging/project/" || rc=$?
683
+ [ "$rc" = 0 ] || break
684
+ done
685
+ fi
568
686
  if [ "$rc" = 0 ]; then
569
687
  # Never remove or rewrite through a copied directory link: it may
570
688
  # still point at the live project or an external source.
@@ -575,7 +693,9 @@ sync_check_render() (
575
693
  while IFS= read -r -d '' link; do
576
694
  target_link="$(readlink "$link")" || { rc=1; break; }
577
695
  # Output links need no relocation: comparison never follows them.
578
- # Internal absolute links still move with their copied project.
696
+ # Internal absolute links still move with their copied project;
697
+ # one into a part of the project the copy does not hold keeps
698
+ # reading the live path, which a render only ever reads.
579
699
  rel="${link#"$staging/project/"}"
580
700
  is_output=0
581
701
  for output in ${SC_CHECK_OUTPUTS[@]+"${SC_CHECK_OUTPUTS[@]}"}; do
@@ -586,6 +706,7 @@ sync_check_render() (
586
706
  rm "$link" && ln -s "$staging/project" "$link" || { rc=1; break; }
587
707
  ;;
588
708
  "$REPO_ROOT"/*)
709
+ sync_check_holds "$staging/project" "${target_link#"$REPO_ROOT/"}" || continue
589
710
  rm "$link" && ln -s "$staging/project/${target_link#"$REPO_ROOT/"}" "$link" || { rc=1; break; }
590
711
  ;;
591
712
  /*) ;; # Absolute external links already retain their resolution.
@@ -596,7 +717,10 @@ sync_check_render() (
596
717
  original_target="$IS_NORM_PATH"
597
718
  case "$original_target" in
598
719
  "$REPO_ROOT") original_target="$staging/project" ;;
599
- "$REPO_ROOT"/*) original_target="$staging/project/${original_target#"$REPO_ROOT/"}" ;;
720
+ "$REPO_ROOT"/*)
721
+ entry="${original_target#"$REPO_ROOT/"}"
722
+ if sync_check_holds "$staging/project" "$entry"; then original_target="$staging/project/$entry"; fi
723
+ ;;
600
724
  esac
601
725
  normalize_path_var "${link%/*}/$target_link"
602
726
  [ "$IS_NORM_PATH" != "$original_target" ] || continue
@@ -12,9 +12,25 @@
12
12
  # join instead of refusing, and a holder that died can be told from one whose
13
13
  # writers still run. Taking over a dead holder's lock happens under a second
14
14
  # lock, so two runs that judged the same lock stale cannot both take it.
15
+ #
16
+ # `sync --check` holds the lock too, because it may restore the package store
17
+ # and compares a copy of the live outputs. It writes nothing a person keeps, so
18
+ # a holder that is a check marks its lock with a `kind` file, and a writing
19
+ # command that finds such a holder waits for it instead of refusing (decision
20
+ # 0022): a status check that runs every few minutes must never make the
21
+ # person's own sync fail. A check never waits in line — it refuses any holder
22
+ # at once — so it is never queued behind a writer; one that arrives as a lock is
23
+ # released can still take it first, and the waiting writer's bound then starts
24
+ # again for that check. A writing holder is refused at once, as before.
15
25
 
16
26
  SYNC_LOCK_HELD=""
17
27
  SYNC_LOCK_TOKEN=""
28
+ # What this process takes the lock for: "check" for `sync --check`, empty for
29
+ # every writer. Set before the lock is taken.
30
+ SYNC_LOCK_KIND=""
31
+ # How long a writer waits for each check that holds the lock, in seconds. Kept
32
+ # in the environment only so the lock suite can exercise the bound quickly.
33
+ SYNC_LOCK_CHECK_WAIT="${INTELLIGENCE_SYNC_LOCK_CHECK_WAIT:-300}"
18
34
 
19
35
  sync_lock_now() {
20
36
  if [ -n "${EPOCHSECONDS:-}" ]; then
@@ -47,6 +63,9 @@ sync_lock_pgid() {
47
63
  }
48
64
 
49
65
  sync_lock_write_owner() {
66
+ # The kind goes first, before anything that costs time: whoever can read
67
+ # the owner can read the kind too.
68
+ [ -z "$SYNC_LOCK_KIND" ] || printf '%s\n' "$SYNC_LOCK_KIND" > "$1/kind"
50
69
  sync_lock_now
51
70
  sync_lock_pgid
52
71
  SYNC_LOCK_TOKEN="$$.$IS_NOW.$RANDOM$RANDOM"
@@ -88,31 +107,126 @@ sync_lock_is_stale() {
88
107
  [ -d "$1" ] && [ -n "$(find "$1" -maxdepth 0 -mmin +1 2>/dev/null)" ]
89
108
  }
90
109
 
91
- # sync_lock_acquire <root> — take <root>/.intelligence/sync.lock or refuse.
110
+ # sync_lock_held_by_check <lock> — true when the holder marked its lock as a
111
+ # `sync --check`. A lock without the mark is a writer's, or an older CLI's.
112
+ sync_lock_held_by_check() {
113
+ local kind=""
114
+ # The group silences the redirection too: a kind released between the
115
+ # test and the read must not print a stray error a caller would show.
116
+ [ -f "$1/kind" ] && { IFS= read -r kind < "$1/kind"; } 2>/dev/null
117
+ [ "$kind" = check ]
118
+ }
119
+
120
+ # sync_lock_tmpdirs <lock> — SYNC_LOCK_TMPDIRS, the temporary directories
121
+ # outside the lock that its holder's runs named in `scratch.<pid>.tmpdir`
122
+ # files. A run killed outright never reaches the trap that removes its own, so
123
+ # whoever releases or takes over the lock removes them. A record is only a
124
+ # file in the project, so it names nothing to remove unless the directory is
125
+ # one the CLI made: an absolute, real `intelligence-check-*` directory holding
126
+ # the marker `.intelligence-check` that the check writes first.
127
+ sync_lock_tmpdirs() {
128
+ local record dir
129
+ SYNC_LOCK_TMPDIRS=()
130
+ for record in "$1"/scratch.*.tmpdir; do
131
+ [ -f "$record" ] || continue
132
+ dir=""
133
+ IFS= read -r dir < "$record" || [ -n "$dir" ] || continue
134
+ case "$dir" in /*) ;; *) continue ;; esac
135
+ case "$dir" in *[[:cntrl:]]*|*/./*|*/../*|*/.|*/..) continue ;; esac
136
+ case "${dir##*/}" in intelligence-check-?*) ;; *) continue ;; esac
137
+ [ -d "$dir" ] && [ ! -L "$dir" ] && [ -f "$dir/.intelligence-check" ] || continue
138
+ SYNC_LOCK_TMPDIRS+=("$dir")
139
+ done
140
+ }
141
+
142
+ # Removing an abandoned copy can take long; it never keeps the lock looking
143
+ # ownerless or a takeover in progress.
144
+ sync_lock_remove_tmpdirs() {
145
+ [ "${#SYNC_LOCK_TMPDIRS[@]}" -eq 0 ] || rm -rf "${SYNC_LOCK_TMPDIRS[@]}" 2>/dev/null || true
146
+ SYNC_LOCK_TMPDIRS=()
147
+ }
148
+ SYNC_LOCK_TMPDIRS=()
149
+
150
+ # sync_lock_acquire <root> — take <root>/.intelligence/sync.lock, or refuse.
151
+ # A writer waits for each check that holds it for up to SYNC_LOCK_CHECK_WAIT
152
+ # seconds; a check refuses at once.
92
153
  sync_lock_acquire() {
93
- local root="$1" lock="$1/.intelligence/sync.lock" took=0 age=""
154
+ local root="$1" lock="$1/.intelligence/sync.lock" took age="" started="$SECONDS" told=0 ownerless=""
155
+ local holder="" seen
94
156
  # A process start costs tens of milliseconds on Git Bash: skip the usual case.
95
157
  [ -d "$root/.intelligence" ] || mkdir -p "$root/.intelligence" || die "cannot create $root/.intelligence"
96
- if mkdir "$lock" 2>/dev/null; then
97
- took=1
98
- elif mkdir "$lock.takeover" 2>/dev/null; then
99
- # Judge again under the takeover lock: another run may have taken
100
- # it over since this one looked.
101
- if sync_lock_is_stale "$lock"; then
102
- rm -rf "$lock"
103
- mkdir "$lock" 2>/dev/null && took=1
158
+ while :; do
159
+ took=0
160
+ SYNC_LOCK_TMPDIRS=()
161
+ if mkdir "$lock" 2>/dev/null; then
162
+ took=1
163
+ elif sync_lock_is_stale "$lock" && mkdir "$lock.takeover" 2>/dev/null; then
164
+ # The takeover lock is taken only for a lock that already looks
165
+ # stale: a command waiting for a live check must never hold it,
166
+ # since one interrupted mid-wait would leave it behind for good.
167
+ # Judge again under it: another run may have taken the lock over
168
+ # since this one looked.
169
+ if sync_lock_is_stale "$lock"; then
170
+ sync_lock_tmpdirs "$lock"
171
+ rm -rf "$lock"
172
+ mkdir "$lock" 2>/dev/null && took=1
173
+ fi
174
+ rmdir "$lock.takeover"
175
+ # Their records went with the lock: remove them now even when
176
+ # another run took it first. A lock this run took gets its owner
177
+ # before any of that time is spent.
178
+ [ "$took" = 1 ] || sync_lock_remove_tmpdirs
179
+ elif [ -d "$lock.takeover" ] && [ -n "$(find "$lock.takeover" -maxdepth 0 -mmin +1 2>/dev/null)" ]; then
180
+ # A takeover runs for milliseconds. Removing an abandoned one is itself
181
+ # a check-then-delete race, so it is left to a person.
182
+ die "a takeover of $lock was abandoned; if no intelligence command is running in this project, remove $lock.takeover"
104
183
  fi
105
- rmdir "$lock.takeover"
106
- elif [ -n "$(find "$lock.takeover" -maxdepth 0 -mmin +1 2>/dev/null)" ]; then
107
- # A takeover runs for milliseconds. Removing an abandoned one is itself
108
- # a check-then-delete race, so it is left to a person.
109
- die "a takeover of $lock was abandoned; if no intelligence command is running in this project, remove $lock.takeover"
110
- fi
111
- if [ "$took" = 1 ]; then
112
- sync_lock_write_owner "$lock" || { rm -rf "$lock"; die "cannot write $lock/owner"; }
113
- SYNC_LOCK_HELD="$lock"
114
- return 0
115
- fi
184
+ if [ "$took" = 1 ]; then
185
+ sync_lock_write_owner "$lock" || { rm -rf "$lock"; die "cannot write $lock/owner"; }
186
+ sync_lock_remove_tmpdirs
187
+ SYNC_LOCK_HELD="$lock"
188
+ return 0
189
+ fi
190
+ if [ "$SYNC_LOCK_KIND" != check ] && sync_lock_held_by_check "$lock"; then
191
+ # A later ownerless moment belongs to another holder's start or
192
+ # release, and gets its own few seconds.
193
+ ownerless=""
194
+ # The bound is per check: one that took the lock while this
195
+ # command waited starts its own count.
196
+ if sync_lock_read_owner "$lock"; then
197
+ seen="$IS_LOCK_PID $IS_LOCK_SINCE $IS_LOCK_TOKEN"
198
+ if [ "$seen" != "$holder" ]; then
199
+ holder="$seen"
200
+ started="$SECONDS"
201
+ fi
202
+ fi
203
+ [ $((SECONDS - started)) -lt "$SYNC_LOCK_CHECK_WAIT" ] || break
204
+ if [ "$told" = 0 ]; then
205
+ # A caller that buffers this command's output (sync --compact)
206
+ # names a descriptor for the terminal, so a wait of minutes
207
+ # is never silent.
208
+ # Not one `>&"${fd:-2}"`: the group's 2>/dev/null, which keeps
209
+ # a closed descriptor quiet, would swallow the default too.
210
+ if [ -n "${IS_LOCK_NOTICE_FD:-}" ]; then
211
+ { echo "waiting for intelligence sync --check to release $lock" >&"$IS_LOCK_NOTICE_FD"; } 2>/dev/null \
212
+ || echo "waiting for intelligence sync --check to release $lock" >&2
213
+ else
214
+ echo "waiting for intelligence sync --check to release $lock" >&2
215
+ fi
216
+ told=1
217
+ fi
218
+ elif ! sync_lock_read_owner "$lock"; then
219
+ # No owner: a holder between its mkdir and its owner file, or one
220
+ # being released. Both last moments, so look again for a few
221
+ # seconds from the first sight before calling it a run that is
222
+ # still starting.
223
+ [ -n "$ownerless" ] || ownerless="$SECONDS"
224
+ [ $((SECONDS - ownerless)) -lt 3 ] || break
225
+ else
226
+ break
227
+ fi
228
+ sleep 1
229
+ done
116
230
  if sync_lock_read_owner "$lock"; then
117
231
  sync_lock_now
118
232
  case "$IS_LOCK_SINCE" in ''|*[!0-9]*) ;; *) age=", for $((IS_NOW - IS_LOCK_SINCE))s" ;; esac
@@ -147,6 +261,14 @@ project_lock_hold() {
147
261
  sync_lock_release() {
148
262
  [ -n "$SYNC_LOCK_HELD" ] || return 0
149
263
  if sync_lock_read_owner "$SYNC_LOCK_HELD" && [ "$IS_LOCK_TOKEN" = "$SYNC_LOCK_TOKEN" ]; then
264
+ # While the owner still stands, so a waiter keeps waiting.
265
+ sync_lock_tmpdirs "$SYNC_LOCK_HELD"
266
+ sync_lock_remove_tmpdirs
267
+ # A check's owner goes first: `rm -rf` alone could expose a moment with
268
+ # the owner still there and the kind gone, and a command waiting for
269
+ # the check would be refused in it. Without the owner, a waiter sees a
270
+ # check or a lock still starting, and waits either way.
271
+ [ ! -f "$SYNC_LOCK_HELD/kind" ] || rm -f "$SYNC_LOCK_HELD/owner"
150
272
  rm -rf "$SYNC_LOCK_HELD"
151
273
  fi
152
274
  SYNC_LOCK_HELD=""
package/engine/ENGINE_SHA CHANGED
@@ -1 +1 @@
1
- af0a0fdbc3f505e4f063ae516c9243c92db6e376
1
+ 4a95c37c037291cd55c305b72571decae77d7767
package/engine/VERSION CHANGED
@@ -1 +1 @@
1
- 0.21.0
1
+ 0.22.1
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "intelligence",
3
+ "version": "__INTELLIGENCE_VERSION__",
4
+ "description": "Shows this project's Intelligence sync status above the prompt and runs intelligence sync from Claude Code",
5
+ "author": { "name": "Ainova Systems" }
6
+ }
@@ -0,0 +1,14 @@
1
+ # Intelligence sync status for Claude Code
2
+
3
+ `intelligence sync` renders this plugin into a project's
4
+ `.claude/skills/intelligence/` from the Intelligence engine's
5
+ `adapters/claude-plugin/` template when `targets.claude.plugin` is `true` in
6
+ `intelligence.yaml`. Change the template in the engine, never a rendered copy:
7
+ the next sync rewrites the copy, and setting the flag to `false` removes it.
8
+
9
+ It is a Claude Code plugin whose hooks module (`hooks/register.js`) checks
10
+ `intelligence sync --check` when a session starts and every five minutes,
11
+ shows the result in a line above the prompt while something needs attention,
12
+ and runs `intelligence sync` from a `Sync now` button there or the
13
+ `/intelligence sync` command. Its requirements and limits are defined in the
14
+ Intelligence CLI reference, `docs/cli.md`, under `intelligence sync`.
@@ -0,0 +1,3 @@
1
+ {
2
+ "modules": ["./register.js"]
3
+ }
@@ -0,0 +1,324 @@
1
+ // Intelligence sync status for Claude Code.
2
+ //
3
+ // `intelligence sync` renders this file from the engine's claude-plugin
4
+ // template when targets.claude.plugin is true (decision 0022). It is a Claude
5
+ // Code mod: a hooks module with no Node.js APIs, reaching the project only
6
+ // through the mods API `$`. It asks `intelligence sync --check` whether the
7
+ // generated output is current when a session starts and every five minutes,
8
+ // shows the answer in one line above the prompt while a check runs or
9
+ // something needs attention, and runs `intelligence sync` from a button there
10
+ // or the /intelligence command. It registers no tool and never submits a
11
+ // prompt: it informs the person, never Claude.
12
+
13
+ // A cold `sync --check` renders the project in a private copy, which takes
14
+ // minutes on Git Bash; a warm one answers from its record in seconds. The
15
+ // timeout stays just under the interval, so a slow check is waited for rather
16
+ // than reported as a failure. A tick never starts a check while one of ours is
17
+ // pending; one abandoned at the timeout can still finish beside the next.
18
+ const CHECK_EVERY_MS = 300000
19
+ const CHECK_TIMEOUT_MS = 290000
20
+ // `$.process.run` allows ten minutes at most, which a full sync may need: it
21
+ // first waits for any check holding the project lock (decision 0022).
22
+ const SYNC_TIMEOUT_MS = 600000
23
+ // Another command holding the project lock is asked again this soon, then
24
+ // after doubling pauses up to the regular interval, so a lock that stays held
25
+ // costs a few processes, not one every 15 seconds.
26
+ const RETRY_MS = 15000
27
+ // The spinner Claude Code draws while it works.
28
+ const FRAMES = ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢']
29
+ const FRAME_MS = 120
30
+ // The CLI's refusal when another command holds the project lock.
31
+ const LOCK_REFUSAL = /another intelligence sync is (running|starting)/
32
+ // The CLI's word that the project was stamped by a newer CLI than itself: a
33
+ // warning for a newer minor or patch, a refusal for a newer major.
34
+ const CLI_BEHIND = /newer than this CLI's engine|IS_STATUS=ahead-of-engine/
35
+
36
+ // What the last check found. `status` is one of unknown (no verdict yet), ok,
37
+ // stale, error, missing-cli or old-cli; `busy` is true while a sync started
38
+ // from here is running.
39
+ const state = { status: 'unknown', detail: '', checkedAt: null, busy: false }
40
+ // A check already in flight answers the next caller too, so a timer tick and
41
+ // a /intelligence check never run two CLI processes side by side.
42
+ let pendingCheck = null
43
+ let retry = null
44
+ let retryMs = RETRY_MS
45
+ let ticker = null
46
+ let frame = 0
47
+ // The terminal and the Desktop app draw the band above the prompt; elsewhere,
48
+ // such as the VS Code extension, the status line under the prompt carries the
49
+ // text. The footer under the prompt is Claude Code's own and stays untouched.
50
+ let band = false
51
+
52
+ // The CLI reports its result as one `IS_STATUS=<status> IS_DETAIL=<text>`
53
+ // line; everything else on stdout is progress.
54
+ function parseStatus(stdout) {
55
+ const match = /^IS_STATUS=(\S+)(?: IS_DETAIL=(.*))?$/m.exec(stdout || '')
56
+ return { status: match ? match[1] : '', detail: match && match[2] ? match[2].trim() : '' }
57
+ }
58
+
59
+ function firstLine(text) {
60
+ const line = (text || '').split(/\r?\n/).find((l) => l.trim() !== '')
61
+ return line ? line.trim() : ''
62
+ }
63
+
64
+ // A failing CLI prints its progress before the reason, so the first line is
65
+ // often a banner: prefer the line that says what went wrong.
66
+ function reasonLine(text) {
67
+ const line = (text || '').split(/\r?\n/).find((l) => /^\s*ERROR:/.test(l))
68
+ return line ? line.trim().replace(/^ERROR:\s*/, '') : firstLine(text)
69
+ }
70
+
71
+ function errorMessage(err) {
72
+ return String(err && err.message ? err.message : err)
73
+ }
74
+
75
+ function clock(date) {
76
+ const hh = String(date.getHours()).padStart(2, '0')
77
+ const mm = String(date.getMinutes()).padStart(2, '0')
78
+ return hh + ':' + mm
79
+ }
80
+
81
+ function setState(status, detail) {
82
+ // A verdict ends a run of retries; the next held lock starts at 15 s again.
83
+ retryMs = RETRY_MS
84
+ state.status = status
85
+ state.detail = detail || ''
86
+ state.checkedAt = new Date()
87
+ }
88
+
89
+ function animating() {
90
+ return state.busy || state.status === 'unknown'
91
+ }
92
+
93
+ // The band's line: one short phrase, with Claude Code's spinner while a check
94
+ // or a sync has not answered yet.
95
+ function label() {
96
+ if (state.busy) return FRAMES[frame] + ' intelligence: syncing'
97
+ switch (state.status) {
98
+ case 'stale':
99
+ return 'intelligence: sync needed'
100
+ case 'missing-cli':
101
+ return 'intelligence: CLI not found'
102
+ case 'old-cli':
103
+ return 'intelligence: update the CLI'
104
+ case 'error':
105
+ return 'intelligence: check failed'
106
+ default:
107
+ return FRAMES[frame] + ' intelligence: checking'
108
+ }
109
+ }
110
+
111
+ // The full sentence: the /intelligence reply, and the status line where no
112
+ // band draws. Claude Code prefixes a status line with the plugin's name.
113
+ function statusText() {
114
+ if (state.busy) return 'syncing…'
115
+ const at = state.checkedAt ? ' · ' + clock(state.checkedAt) : ''
116
+ switch (state.status) {
117
+ case 'ok':
118
+ return 'up to date' + at
119
+ case 'stale':
120
+ return 'sync needed — run /intelligence sync' + at
121
+ case 'missing-cli':
122
+ return 'CLI not found on PATH'
123
+ case 'old-cli':
124
+ return 'the intelligence CLI on PATH is older than this project — run: intelligence upgrade'
125
+ case 'error':
126
+ return 'check failed: ' + state.detail + at
127
+ default:
128
+ return 'checking…'
129
+ }
130
+ }
131
+
132
+ // Every state change redraws the band above the prompt; the spinner turns only
133
+ // while something is pending.
134
+ function show($) {
135
+ if (animating() && !ticker) {
136
+ ticker = $.clock.every(FRAME_MS, () => {
137
+ frame = (frame + 1) % FRAMES.length
138
+ $.ui.invalidate('ui.render')
139
+ })
140
+ } else if (!animating() && ticker) {
141
+ ticker.cancel()
142
+ ticker = null
143
+ }
144
+ $.ui.status(band ? undefined : statusText())
145
+ $.ui.invalidate('ui.render')
146
+ }
147
+
148
+ // Ask again soon after a command that held the project lock; the verdict
149
+ // shown meanwhile is the last one, never a guess.
150
+ function retrySoon($) {
151
+ if (retry) return
152
+ retry = $.clock.after(retryMs, () => {
153
+ retry = null
154
+ check($)
155
+ })
156
+ retryMs = Math.min(retryMs * 2, CHECK_EVERY_MS)
157
+ }
158
+
159
+ async function check($) {
160
+ if (state.busy) return
161
+ if (pendingCheck) return pendingCheck
162
+ pendingCheck = runCheck($).finally(() => {
163
+ pendingCheck = null
164
+ })
165
+ return pendingCheck
166
+ }
167
+
168
+ async function runCheck($) {
169
+ let result
170
+ let cwd = ''
171
+ try {
172
+ // The project root, not the shell's cwd: a session started in a
173
+ // subdirectory still checks the project the plugin was rendered for.
174
+ cwd = await $.session.root()
175
+ result = await $.process.run(['intelligence', 'sync', '--check'], { cwd, timeoutMs: CHECK_TIMEOUT_MS })
176
+ } catch (err) {
177
+ // The call rejects when the program cannot start or outlives its
178
+ // timeout; the result arrives whatever the exit code otherwise.
179
+ const message = errorMessage(err)
180
+ if (/failed to start/i.test(message)) setState('missing-cli', message)
181
+ else if (await lockHeld($, cwd)) retrySoon($)
182
+ else setState('error', message)
183
+ show($)
184
+ return
185
+ }
186
+ const parsed = parseStatus(result.stdout)
187
+ // An older CLI renders this project with its own engine, so its verdict
188
+ // says nothing about what this project's CLI would write, and its sync
189
+ // would drop this plugin: say so instead of offering one.
190
+ if (CLI_BEHIND.test((result.stderr || '') + ' ' + (result.stdout || ''))) setState('old-cli', reasonLine(result.stderr))
191
+ // Exit 0 is up to date and 2 is `out-of-date`, the only verdicts sync
192
+ // --check gives; anything else is the CLI refusing to answer.
193
+ else if (result.exitCode === 0) setState('ok', parsed.detail)
194
+ else if (result.exitCode === 2) setState('stale', parsed.detail || 'sync needed')
195
+ // Another holder of the project lock — someone's sync, or another
196
+ // session's check — is not a fault: the last verdict stays, and the check
197
+ // asks again shortly instead of queueing ahead of a waiting sync.
198
+ else if (LOCK_REFUSAL.test(result.stderr || '')) retrySoon($)
199
+ else setState('error', parsed.detail || reasonLine(result.stderr) || 'exit ' + result.exitCode)
200
+ show($)
201
+ }
202
+
203
+ // A check cut off at its timeout behind another command, or still finishing
204
+ // on its own, leaves the project lock in place: that is a command still
205
+ // running, not a failed check. The process the timeout abandoned keeps running.
206
+ async function lockHeld($, root) {
207
+ if (!root) return false
208
+ try {
209
+ return await $.fs.exists(root + '/.intelligence/sync.lock')
210
+ } catch {
211
+ return false
212
+ }
213
+ }
214
+
215
+ async function runSync($) {
216
+ if (state.busy) return
217
+ if (state.status === 'old-cli') {
218
+ $.ui.toast('update the intelligence CLI first: intelligence upgrade')
219
+ return 'not run'
220
+ }
221
+ state.busy = true
222
+ show($)
223
+ let outcome
224
+ let cwd = ''
225
+ try {
226
+ // A check of ours still running holds the project lock; the CLI would
227
+ // wait for it too, but waiting here keeps its late answer from landing
228
+ // after the sync's own.
229
+ if (pendingCheck) await pendingCheck
230
+ cwd = await $.session.root()
231
+ const result = await $.process.run(['intelligence', 'sync', '--compact'], { cwd, timeoutMs: SYNC_TIMEOUT_MS })
232
+ if (result.exitCode === 0) {
233
+ outcome = 'sync finished'
234
+ } else {
235
+ const reason = reasonLine(result.stderr) || parseStatus(result.stdout).detail || 'exit ' + result.exitCode
236
+ outcome = 'sync failed: ' + reason
237
+ }
238
+ } catch (err) {
239
+ // A sync past its timeout, which includes waiting for a check, keeps
240
+ // running and holding the lock: it has not failed yet.
241
+ if (!/failed to start/i.test(errorMessage(err)) && (await lockHeld($, cwd))) {
242
+ outcome = 'sync is still running; the status updates when it ends'
243
+ } else {
244
+ outcome = 'sync could not run: ' + errorMessage(err)
245
+ }
246
+ }
247
+ // The check, not the sync's own exit code, decides what the status shows:
248
+ // it is the same judgement the next timer tick would make. Until it answers
249
+ // the status is `checking`, so the band never offers `Sync now` again for
250
+ // the verdict the sync just replaced.
251
+ state.status = 'unknown'
252
+ state.detail = ''
253
+ state.busy = false
254
+ show($)
255
+ $.ui.toast(outcome)
256
+ await check($)
257
+ return outcome
258
+ }
259
+
260
+ export function register(on) {
261
+ on('session.start', async ($, e, next) => {
262
+ // A build without surfaces() draws in the terminal; the band is the default.
263
+ try {
264
+ band = (await $.session.surfaces()).some((s) => s === 'terminal' || s === 'desktop')
265
+ } catch {
266
+ band = true
267
+ }
268
+ // The label shows at once, turning, while a cold first check can take
269
+ // minutes.
270
+ show($)
271
+ // Neither the first check nor the timer is awaited: the session must
272
+ // never wait on the CLI.
273
+ check($)
274
+ $.clock.every(CHECK_EVERY_MS, () => check($))
275
+ // Registered last and guarded: a taken name throws, and a hook that
276
+ // throws is skipped from that line on.
277
+ try {
278
+ await $.command.register({
279
+ name: 'intelligence',
280
+ description: 'Show Intelligence sync status or run sync',
281
+ argumentHint: '[sync|check]',
282
+ immediate: true,
283
+ })
284
+ } catch (err) {
285
+ $.ui.log('/intelligence not registered: ' + errorMessage(err), { to: 'debug' })
286
+ }
287
+ return next(e)
288
+ })
289
+
290
+ on('command.run', { command: 'intelligence' }, async ($, e) => {
291
+ const args = (e.args || '').trim()
292
+ if (args === 'sync') {
293
+ if (state.busy) return { text: 'a sync started from here is still running' }
294
+ const outcome = await runSync($)
295
+ return { text: outcome + ' — ' + statusText() }
296
+ }
297
+ if (args === 'check') {
298
+ await check($)
299
+ return { text: statusText() }
300
+ }
301
+ if (args === '') {
302
+ const detail = state.detail && state.status !== 'ok' ? ' (' + state.detail + ')' : ''
303
+ return { text: statusText() + detail }
304
+ }
305
+ return { text: 'usage: /intelligence [sync|check]' }
306
+ })
307
+
308
+ // The band above the prompt, where Claude Code draws nothing of its own,
309
+ // carries the status while there is something to wait for or act on, and
310
+ // is gone while the project is up to date.
311
+ on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
312
+ if (state.status === 'ok' && !state.busy) return next(e)
313
+ const { Box, Text, Button } = $.ui.resolve(e)
314
+ const color = state.status === 'error' || state.status === 'old-cli' ? 'red' : state.status === 'stale' ? 'yellow' : undefined
315
+ const children = [Text({ color, dimColor: animating(), children: [label()] })]
316
+ // A digit hotkey on the band also fires from an empty prompt, typed and
317
+ // left for a moment; a letter would need the band focused first. Plain,
318
+ // because only a plain button shows its hotkey in the terminal.
319
+ if (state.status === 'stale' && !state.busy) {
320
+ children.push(Button({ key: 'sync', label: 'Sync now', hotkey: '1', plain: true, onPress: () => runSync($) }))
321
+ }
322
+ return Box({ flexDirection: 'row', columnGap: 2, children })
323
+ })
324
+ }
@@ -6,6 +6,11 @@
6
6
  # Skills: copy skill directories in full (SKILL.md + bundled resources),
7
7
  # effort -> Claude's effort level
8
8
  # Agents: tier -> model, effort -> effort, access -> tools/disallowedTools
9
+ # Plugin: with `targets.claude.plugin: true`, the Claude Code mod in
10
+ # adapters/claude-plugin/ is rendered into <output>/skills/intelligence/
11
+ # (decision 0022). Claude Code loads a plugin from a project's skills
12
+ # directory, so the plugin lives inside a path this contract already owns:
13
+ # ownership, Git policy, snapshots and `sync --check` need no new record.
9
14
  #
10
15
  # Every per-file loop batches its work into one awk process (see the batched
11
16
  # helpers in lib/common.sh): process spawns dominate sync time on Windows.
@@ -29,6 +34,52 @@ adapter_contract_claude() {
29
34
  adapter_contract_include "$output/settings.json"
30
35
  }
31
36
 
37
+ # claude_plugin_var <manifest> — the one reader of targets.claude.plugin. Sets
38
+ # IS_CLAUDE_PLUGIN to 1 when the project renders the Claude Code plugin and 0
39
+ # for the default; returns 1 for a value target_bool_var refuses.
40
+ claude_plugin_var() {
41
+ local rc=0
42
+ target_bool_var "$1" claude plugin || rc=1
43
+ IS_CLAUDE_PLUGIN="$IS_TARGET_BOOL"
44
+ return "$rc"
45
+ }
46
+
47
+ # The directory name the plugin takes under <output>/skills: a source skill
48
+ # rendered under this name would be overwritten, so sync refuses the pair.
49
+ IS_CLAUDE_PLUGIN_NAME=intelligence
50
+
51
+ # render_claude_plugin <output_dir> — copy the plugin template byte for byte,
52
+ # stamp the engine version into its manifest and normalize line endings. The
53
+ # template is in the engine's adapter directory, never in the content package:
54
+ # it is executable tooling, and the bundle copies all of engine/. It is found
55
+ # through the engine root, so a project's own claude.sh renders it too.
56
+ render_claude_plugin() {
57
+ local output_dir="$1" tpl
58
+ local dst="$output_dir/skills/$IS_CLAUDE_PLUGIN_NAME"
59
+ # Rendered beside the destination, under a name no skill can take, and
60
+ # swapped in only when it differs: a running session reloads the mod on
61
+ # every file change, and announces each reload in its transcript.
62
+ local tmp="$output_dir/skills/.$IS_CLAUDE_PLUGIN_NAME.render.$$"
63
+ engine_root_var
64
+ tpl="$IS_ENGINE_ROOT/adapters/claude-plugin"
65
+ engine_version_var
66
+ rm -rf "$tmp"
67
+ mkdir -p "$tmp/.claude-plugin" "$tmp/hooks"
68
+ cp "$tpl/hooks/hooks.json" "$tpl/hooks/register.js" "$tmp/hooks/"
69
+ cp "$tpl/README.md" "$tmp/README.md"
70
+ awk -v v="$IS_ENGINE_VERSION" '{ sub(/__INTELLIGENCE_VERSION__/, v); print }' \
71
+ "$tpl/.claude-plugin/plugin.json" > "$tmp/.claude-plugin/plugin.json"
72
+ finalize_output_files "$tmp/.claude-plugin/plugin.json" "$tmp/hooks/hooks.json" \
73
+ "$tmp/hooks/register.js" "$tmp/README.md"
74
+ if [ -d "$dst" ] && [ ! -L "$dst" ] && diff -r -q "$tmp" "$dst" >/dev/null 2>&1; then
75
+ rm -rf "$tmp"
76
+ else
77
+ rm -rf "$dst"
78
+ mv "$tmp" "$dst"
79
+ fi
80
+ echo " plugin: $IS_CLAUDE_PLUGIN_NAME (Claude Code mod $IS_ENGINE_VERSION)"
81
+ }
82
+
32
83
  # Sync rules to Claude format (copy as-is, normalize LF)
33
84
  sync_claude_rules() {
34
85
  local repo_root="$1"
@@ -53,13 +104,32 @@ sync_claude_skills() {
53
104
  local config_file="$2"
54
105
  local output_dir="$3"
55
106
 
56
- local i name
107
+ local i name clash="" nocase=0
57
108
  local -a skill_dirs=()
58
109
  # The catalog's skills are the ones the inventory renders, symlinked ones
59
110
  # included (decision 0017).
60
111
  output_rel_var "$repo_root" "$output_dir"
61
112
  source_artifacts_var "$repo_root" "$config_file" skills claude "$IS_OUTPUT_REL/skills/{}"
62
113
  [ "${#IS_SOURCE_FILES[@]}" -gt 0 ] || return 0
114
+ # The plugin takes one skill directory name; a skill rendered under it and
115
+ # the plugin would each erase the other, so neither is written. Names
116
+ # compare without case: on Windows and macOS `Intelligence/` is the same
117
+ # directory. (nocasematch, not ${name,,}: macOS ships bash 3.2.)
118
+ if [ "${IS_CLAUDE_PLUGIN:-0}" = 1 ]; then
119
+ shopt -q nocasematch && nocase=1
120
+ shopt -s nocasematch
121
+ for ((i = 0; i < ${#IS_SOURCE_FILES[@]}; i++)); do
122
+ if [[ "${IS_SOURCE_NAMES[i]}" == "$IS_CLAUDE_PLUGIN_NAME" ]]; then
123
+ clash="${IS_SOURCE_FILES[i]#"$repo_root/"}"
124
+ break
125
+ fi
126
+ done
127
+ [ "$nocase" = 1 ] || shopt -u nocasematch
128
+ if [ -n "$clash" ]; then
129
+ echo "ERROR: skill $clash renders as $IS_OUTPUT_REL/skills/$IS_CLAUDE_PLUGIN_NAME, the directory targets.claude.plugin: true reserves for the Claude Code plugin. Rename the skill or set targets.claude.plugin to false." >&2
130
+ return 1
131
+ fi
132
+ fi
63
133
  for ((i = 0; i < ${#IS_SOURCE_FILES[@]}; i++)); do
64
134
  skill_dirs+=("${IS_SOURCE_FILES[i]%/SKILL.md}/")
65
135
  done
@@ -156,18 +226,39 @@ sync_to_claude() {
156
226
 
157
227
  echo "=== Claude Code ==="
158
228
 
229
+ # The flag is read before anything is removed: a malformed value refuses
230
+ # the sync with the reason, and the output stays exactly as it was.
231
+ claude_plugin_var "$config_file" || {
232
+ echo "ERROR: targets.claude.plugin must be true or false: true renders the Claude Code plugin into ${output_dir#"$repo_root/"}/skills/$IS_CLAUDE_PLUGIN_NAME, false or omission does not." >&2
233
+ return 1
234
+ }
235
+
159
236
  # Clean generated content only — preserve any project-level files
160
237
  # the user manages directly (settings.json, settings.local.json,
161
238
  # commands/, statusline.sh, etc.).
162
239
  rm -rf "$output_dir/rules" "$output_dir/agents"
163
240
  if [ -d "$output_dir/skills" ]; then
164
- find "$output_dir/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -exec rm -rf {} +
241
+ # The plugin directory stays while the flag holds: Claude Code reloads
242
+ # a mod whose files change, so render_claude_plugin rewrites it only
243
+ # when its content differs. A skill of that name was refused above.
244
+ if [ "$IS_CLAUDE_PLUGIN" = 1 ]; then
245
+ find "$output_dir/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) \
246
+ ! -name "$IS_CLAUDE_PLUGIN_NAME" -exec rm -rf {} +
247
+ else
248
+ find "$output_dir/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -exec rm -rf {} +
249
+ fi
165
250
  fi
166
251
  mkdir -p "$output_dir/rules" "$output_dir/skills" "$output_dir/agents"
167
252
 
168
253
  sync_claude_rules "$repo_root" "$config_file" "$output_dir"
254
+ # Called plainly, never on the left of `||`: that would turn errexit off
255
+ # for every copy inside, and a failed one would no longer fail the sync.
169
256
  sync_claude_skills "$repo_root" "$config_file" "$output_dir"
170
257
  sync_claude_agents "$repo_root" "$config_file" "$output_dir"
258
+ # The skills renderer has refused a skill of the plugin's name by now, so
259
+ # the plugin never lands on top of one. Cleared above with every other
260
+ # skill directory, it exists only while the flag says so.
261
+ [ "$IS_CLAUDE_PLUGIN" = 0 ] || render_claude_plugin "$output_dir"
171
262
 
172
263
  count_matching_files "$output_dir/rules" "*.md" \
173
264
  "$output_dir/skills" "SKILL.md" "$output_dir/agents" "*.md"
@@ -55,19 +55,13 @@ adapter_contract_copilot() {
55
55
 
56
56
  # copilot_commit_output_var <manifest> — the one reader of
57
57
  # targets.copilot.commit_output. Sets IS_COPILOT_COMMIT_OUTPUT to 1 when the
58
- # project commits generated output and 0 for the default; returns 1 for any
59
- # value other than true or false. No manifest — a caller reading ownership
60
- # only — is the default.
58
+ # project commits generated output and 0 for the default; returns 1 for a value
59
+ # target_bool_var refuses.
61
60
  copilot_commit_output_var() {
62
- local manifest="$1" setting
63
- IS_COPILOT_COMMIT_OUTPUT=0
64
- [ -n "$manifest" ] && [ -f "$manifest" ] || return 0
65
- setting="$(get_target_field "$manifest" "copilot" "commit_output")"
66
- case "$setting" in
67
- ''|false) ;;
68
- true) IS_COPILOT_COMMIT_OUTPUT=1 ;;
69
- *) return 1 ;;
70
- esac
61
+ local rc=0
62
+ target_bool_var "$1" copilot commit_output || rc=1
63
+ IS_COPILOT_COMMIT_OUTPUT="$IS_TARGET_BOOL"
64
+ return "$rc"
71
65
  }
72
66
 
73
67
  # copilot_paths_rows <file>... — batch form of the old per-file `paths:`
@@ -3629,6 +3629,24 @@ is_target_enabled() {
3629
3629
  ' "$file"
3630
3630
  }
3631
3631
 
3632
+ # target_bool_var <manifest> <target> <field> — the one reading of a boolean
3633
+ # target field (targets.copilot.commit_output, targets.claude.plugin). Sets
3634
+ # IS_TARGET_BOOL to 1 for true and 0 for false, omission or an empty value;
3635
+ # returns 1 for anything else, so a typo never decides silently. No manifest —
3636
+ # a caller reading ownership only — is the default.
3637
+ # shellcheck disable=SC2034 # IS_TARGET_BOOL is the return channel
3638
+ target_bool_var() {
3639
+ local setting
3640
+ IS_TARGET_BOOL=0
3641
+ [ -n "$1" ] && [ -f "$1" ] || return 0
3642
+ setting="$(get_target_field "$1" "$2" "$3")"
3643
+ case "$setting" in
3644
+ ''|false) ;;
3645
+ true) IS_TARGET_BOOL=1 ;;
3646
+ *) return 1 ;;
3647
+ esac
3648
+ }
3649
+
3632
3650
  # Get an arbitrary field from a target's config block.
3633
3651
  # Handles both inline (`claude: { enabled: true, output: ".claude" }`)
3634
3652
  # and block form. Uses POSIX awk only — no gawk-specific 3-arg match().
@@ -100,18 +100,27 @@ is_status() {
100
100
  fi
101
101
  }
102
102
 
103
+ # engine_root_var — set IS_ENGINE_ROOT to the engine directory, one level above
104
+ # this lib. A project adapter that overrides a built-in finds the engine's own
105
+ # files through it, never beside itself.
106
+ engine_root_var() {
107
+ local lib="${BASH_SOURCE[0]}"
108
+ case "$lib" in
109
+ */*) lib="${lib%/*}" ;;
110
+ *) lib="." ;;
111
+ esac
112
+ IS_ENGINE_ROOT="$lib/.."
113
+ }
114
+
103
115
  # Engine version = VERSION one level above this lib (BASH_SOURCE works when
104
116
  # sourced). Empty if unreadable — callers treat empty as "no guard".
105
117
  # engine_version_var sets IS_ENGINE_VERSION, and IS_ENGINE_VERSION_FOUND=1 when
106
118
  # the file exists; it reads the file once per process.
107
119
  engine_version_var() {
108
120
  [ -z "${IS_ENGINE_VERSION_FOUND:-}" ] || return 0
109
- local lib="${BASH_SOURCE[0]}" vf content=""
110
- case "$lib" in
111
- */*) lib="${lib%/*}" ;;
112
- *) lib="." ;;
113
- esac
114
- vf="$lib/../VERSION"
121
+ local vf content=""
122
+ engine_root_var
123
+ vf="$IS_ENGINE_ROOT/VERSION"
115
124
  IS_ENGINE_VERSION=""
116
125
  IS_ENGINE_VERSION_FOUND=0
117
126
  [ -f "$vf" ] || return 0
package/engine/sync.sh CHANGED
@@ -742,10 +742,14 @@ SYNC_TX_ACTIVE=0
742
742
  # defaults move forward — e.g., gpt-5.5 -> gpt-5.6).
743
743
  # Concurrently, the scan runs beside the other two and beside removing the
744
744
  # snapshots; the buffers print in the order above.
745
+ # A check renders in a copy that holds only the project's inputs and outputs; the
746
+ # scan lists directories and reads no bytes, so it looks at the live project.
747
+ UNSYNCED_ROOT="$REPO_ROOT"
748
+ [ -z "${IS_SYNC_CHECK:-}" ] || [ -z "${IS_SYNC_CHECK_ROOT:-}" ] || UNSYNCED_ROOT="$IS_SYNC_CHECK_ROOT"
745
749
  if [ "$SYNC_PARALLEL" = 1 ]; then
746
750
  rm -rf "$SYNC_TX_DIR/data" &
747
751
  SYNC_BG_PIDS+=("$!")
748
- warn_unsynced "$REPO_ROOT" "$CONFIG_FILE" > "$SYNC_TX_DIR/unsynced" 2>&1 &
752
+ warn_unsynced "$UNSYNCED_ROOT" "$CONFIG_FILE" > "$SYNC_TX_DIR/unsynced" 2>&1 &
749
753
  SYNC_BG_PIDS+=("$!")
750
754
  {
751
755
  report_context_source_sizes "$REPO_ROOT" "$CONFIG_FILE"
@@ -758,7 +762,7 @@ if [ "$SYNC_PARALLEL" = 1 ]; then
758
762
  else
759
763
  rm -rf "$SYNC_TX_DIR"
760
764
  trap - EXIT INT TERM
761
- warn_unsynced "$REPO_ROOT" "$CONFIG_FILE"
765
+ warn_unsynced "$UNSYNCED_ROOT" "$CONFIG_FILE"
762
766
  report_context_source_sizes "$REPO_ROOT" "$CONFIG_FILE"
763
767
  report_model_drift "$CONFIG_FILE"
764
768
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ainova-systems/intelligence",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
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"
@@ -129,6 +129,17 @@ Code ignore theirs, and never `.github/` itself. `targets.copilot.commit_output:
129
129
  true` keeps them tracked for Copilot on github.com; the setting is defined in
130
130
  [Generated output and version control](conventions.md#generated-output-and-version-control).
131
131
 
132
+ The Claude adapter renders a Claude Code plugin into `<output>/skills/intelligence/`
133
+ when `targets.claude.plugin` is `true` (decision 0022): a mod that checks
134
+ `intelligence sync --check` at session start and every five minutes, shows the
135
+ verdict in a line above the prompt and runs sync from a button there or
136
+ `/intelligence sync`.
137
+ The template is `engine/adapters/claude-plugin/`, copied with the engine version
138
+ stamped into `plugin.json`. It lives inside the owned `skills` path, so it needs
139
+ no contract record of its own. The field's values, the name collision it refuses
140
+ and the plugin's limits are defined in the
141
+ [CLI reference](https://github.com/ainova-systems/intelligence/blob/main/docs/cli.md#intelligence-sync).
142
+
132
143
  The engine calls:
133
144
 
134
145
  ```text
@@ -373,7 +384,7 @@ For a built-in adapter, add the same assertions to the repository's smoke or lif
373
384
  |---|---|
374
385
  | `agents` | `AGENTS.md` |
375
386
  | `antigravity` | `.agents/rules`, `.agents/agents`, `.agents/skills` |
376
- | `claude` | `.claude/rules`, `.claude/agents`, `.claude/skills` |
387
+ | `claude` | `.claude/rules`, `.claude/agents`, `.claude/skills` (with `plugin: true`, `.claude/skills/intelligence` is the Claude Code plugin) |
377
388
  | `cursor` | `.cursor/rules`, `.cursor/agents`, `.cursor/skills` |
378
389
  | `copilot` | `.github/instructions`, `.github/agents`, `.github/skills` |
379
390
  | `codex` | `.codex/agents`, `.agents/skills`, marker-owned `AGENTS.override.md` |