@ainova-systems/intelligence 0.22.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.
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
@@ -117,6 +117,36 @@ sync_lock_held_by_check() {
117
117
  [ "$kind" = check ]
118
118
  }
119
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
+
120
150
  # sync_lock_acquire <root> — take <root>/.intelligence/sync.lock, or refuse.
121
151
  # A writer waits for each check that holds it for up to SYNC_LOCK_CHECK_WAIT
122
152
  # seconds; a check refuses at once.
@@ -127,6 +157,7 @@ sync_lock_acquire() {
127
157
  [ -d "$root/.intelligence" ] || mkdir -p "$root/.intelligence" || die "cannot create $root/.intelligence"
128
158
  while :; do
129
159
  took=0
160
+ SYNC_LOCK_TMPDIRS=()
130
161
  if mkdir "$lock" 2>/dev/null; then
131
162
  took=1
132
163
  elif sync_lock_is_stale "$lock" && mkdir "$lock.takeover" 2>/dev/null; then
@@ -136,10 +167,15 @@ sync_lock_acquire() {
136
167
  # Judge again under it: another run may have taken the lock over
137
168
  # since this one looked.
138
169
  if sync_lock_is_stale "$lock"; then
170
+ sync_lock_tmpdirs "$lock"
139
171
  rm -rf "$lock"
140
172
  mkdir "$lock" 2>/dev/null && took=1
141
173
  fi
142
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
143
179
  elif [ -d "$lock.takeover" ] && [ -n "$(find "$lock.takeover" -maxdepth 0 -mmin +1 2>/dev/null)" ]; then
144
180
  # A takeover runs for milliseconds. Removing an abandoned one is itself
145
181
  # a check-then-delete race, so it is left to a person.
@@ -147,6 +183,7 @@ sync_lock_acquire() {
147
183
  fi
148
184
  if [ "$took" = 1 ]; then
149
185
  sync_lock_write_owner "$lock" || { rm -rf "$lock"; die "cannot write $lock/owner"; }
186
+ sync_lock_remove_tmpdirs
150
187
  SYNC_LOCK_HELD="$lock"
151
188
  return 0
152
189
  fi
@@ -224,6 +261,9 @@ project_lock_hold() {
224
261
  sync_lock_release() {
225
262
  [ -n "$SYNC_LOCK_HELD" ] || return 0
226
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
227
267
  # A check's owner goes first: `rm -rf` alone could expose a moment with
228
268
  # the owner still there and the kind gone, and a command waiting for
229
269
  # the check would be refused in it. Without the owner, a waiter sees a
package/engine/ENGINE_SHA CHANGED
@@ -1 +1 @@
1
- ee05b0f09bc74b1914e04c067cce1e87a75bd18a
1
+ 4a95c37c037291cd55c305b72571decae77d7767
package/engine/VERSION CHANGED
@@ -1 +1 @@
1
- 0.22.0
1
+ 0.22.1
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.22.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"