@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.
- package/cli/commands/sync.sh +9 -0
- package/cli/intelligence +6 -0
- package/cli/lib/sync-cache.sh +162 -38
- package/cli/lib/sync-lock.sh +143 -21
- package/engine/ENGINE_SHA +1 -1
- package/engine/VERSION +1 -1
- package/engine/adapters/claude-plugin/.claude-plugin/plugin.json +6 -0
- package/engine/adapters/claude-plugin/README.md +14 -0
- package/engine/adapters/claude-plugin/hooks/hooks.json +3 -0
- package/engine/adapters/claude-plugin/hooks/register.js +324 -0
- package/engine/adapters/claude.sh +93 -2
- package/engine/adapters/copilot.sh +6 -12
- package/engine/lib/common.sh +18 -0
- package/engine/lib/contract.sh +15 -6
- package/engine/sync.sh +6 -2
- package/package.json +1 -1
- package/packages/sync/references/adapters.md +12 -1
package/cli/commands/sync.sh
CHANGED
|
@@ -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="." ;;
|
package/cli/lib/sync-cache.sh
CHANGED
|
@@ -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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
|
15
|
-
# hashes; timestamps
|
|
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-
|
|
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
|
-
|
|
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=""
|
|
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
|
-
|
|
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
|
-
#
|
|
363
|
-
#
|
|
364
|
-
#
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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"/*)
|
|
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
|
package/cli/lib/sync-lock.sh
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
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
|
-
|
|
97
|
-
took=
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
1
|
+
4a95c37c037291cd55c305b72571decae77d7767
|
package/engine/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.22.1
|
|
@@ -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,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
|
-
|
|
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
|
|
59
|
-
#
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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:`
|
package/engine/lib/common.sh
CHANGED
|
@@ -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().
|
package/engine/lib/contract.sh
CHANGED
|
@@ -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
|
|
110
|
-
|
|
111
|
-
|
|
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 "$
|
|
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 "$
|
|
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.
|
|
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` |
|