@ainova-systems/intelligence 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,7 @@ intelligence sync
32
32
 
33
33
  | Command | What it does |
34
34
  |---|---|
35
- | `intelligence init [--preview\|--apply]` | Create, convert, restore or align a project |
35
+ | `intelligence init [--preview\|--apply]` | Create, convert or restore a project |
36
36
  | `intelligence sync [adapter] [--compact] [--force]` | Restore locked content, then render or reuse verified unchanged output; `--force` renders and refreshes repository discovery |
37
37
  | `intelligence update [@scope/name] [--latest] [--preview\|--apply]` | Plan or apply project and ranged-package updates; `--latest` crosses one package's range |
38
38
  | `intelligence upgrade [--next] [--preview\|--apply]` | Replace the installed CLI with the newest version on its npm channel |
@@ -47,7 +47,7 @@ adapter_create() {
47
47
  [ -n "$name" ] && [ $# -eq 0 ] || die "usage: intelligence adapter create <name>"
48
48
  assert_valid_target_name "$name"
49
49
  require_cli_project
50
- ensure_project_current "$IP_ROOT"
50
+ project_as_committed "$IP_ROOT"
51
51
  local manifest="$IP_ROOT/intelligence.yaml" content_dir repo_phys probe old_ifs part probe_phys
52
52
  local template adapter_dir adapter_phys dest tmp
53
53
  local -a parts
@@ -95,7 +95,7 @@ adapter_remove() {
95
95
  [ -n "$name" ] || die "usage: intelligence adapter remove <name> [--apply]"
96
96
  assert_valid_target_name "$name"
97
97
  require_cli_project
98
- ensure_project_current "$IP_ROOT"
98
+ project_as_committed "$IP_ROOT"
99
99
  manifest="$IP_ROOT/intelligence.yaml"
100
100
  content_dir="$(manifest_intelligence_dir "$manifest")"
101
101
  case "$content_dir" in
@@ -140,7 +140,7 @@ case "$action" in
140
140
  require_cli_project
141
141
  # Held through the final render: target-state and sync join it.
142
142
  project_lock_hold "$IP_ROOT"
143
- ensure_project_current "$IP_ROOT"
143
+ project_as_committed "$IP_ROOT"
144
144
  if [ "$action" = "enable" ]; then
145
145
  state_stage="$(mktemp -d -t intelligence-adapter-state-XXXXXX)"
146
146
  cp "$IP_ROOT/intelligence.yaml" "$state_stage/intelligence.yaml"
@@ -132,7 +132,11 @@ case "$IP_MODE" in
132
132
  if project_stamped_ahead "$IP_ROOT"; then
133
133
  echo " project schema $(read_schema_version "$IP_ROOT/intelligence.yaml") is ahead of engine $(bundled_engine_version) — left as is (update the CLI)"
134
134
  elif project_needs_upgrade "$IP_ROOT"; then
135
- echo " would align project lifecycle with engine $(bundled_engine_version)"
135
+ if [ "$IS_UPGRADE_KIND" = behind ]; then
136
+ echo " project versions trail engine $(bundled_engine_version) — kept as committed; intelligence update moves them"
137
+ else
138
+ echo " project needs a migration to engine $(bundled_engine_version) — run: intelligence update"
139
+ fi
136
140
  else
137
141
  echo " schema/content already match engine $(bundled_engine_version)"
138
142
  fi
@@ -151,11 +155,7 @@ case "$IP_MODE" in
151
155
  fi
152
156
  exit 0
153
157
  fi
154
- if [ "$apply" -eq 1 ]; then
155
- ensure_project_current "$IP_ROOT" --explicit
156
- else
157
- ensure_project_current "$IP_ROOT"
158
- fi
158
+ project_as_committed "$IP_ROOT"
159
159
  restore_project_store_if_missing "$IP_ROOT"
160
160
  ensure_manifest_gitignore "$IP_ROOT" "$IP_ROOT/intelligence.yaml"
161
161
  ensure_manifest_publisher_ignores "$IP_ROOT" "$IP_ROOT/intelligence.yaml"
@@ -10,17 +10,17 @@ shift
10
10
  case "$action" in
11
11
  add)
12
12
  require_cli_project
13
- ensure_project_current "$IP_ROOT"
13
+ project_as_committed "$IP_ROOT"
14
14
  exec bash "$CLI_DIR/internal/package-add.sh" "$@"
15
15
  ;;
16
16
  remove)
17
17
  require_cli_project
18
- ensure_project_current "$IP_ROOT"
18
+ project_as_committed "$IP_ROOT"
19
19
  exec bash "$CLI_DIR/internal/package-remove.sh" "$@"
20
20
  ;;
21
21
  alias)
22
22
  require_cli_project
23
- ensure_project_current "$IP_ROOT"
23
+ project_as_committed "$IP_ROOT"
24
24
  exec bash "$CLI_DIR/internal/package-alias.sh" "$@"
25
25
  ;;
26
26
  list)
@@ -40,7 +40,7 @@ case "$sub" in
40
40
  esac
41
41
  assert_safe_source_url "${url#git+}"
42
42
  require_cli_project
43
- ensure_project_current "$IP_ROOT"
43
+ project_as_committed "$IP_ROOT"
44
44
  # Fail closed: a URL that cannot produce an index is not a registry,
45
45
  # and recording it would turn a typo here into a confusing failure at
46
46
  # the first add. --force covers the one honest case: the registry is
@@ -71,7 +71,7 @@ case "$sub" in
71
71
  url="${2:-}"
72
72
  [ -n "$url" ] || die "usage: intelligence registry remove <url>"
73
73
  require_cli_project
74
- ensure_project_current "$IP_ROOT"
74
+ project_as_committed "$IP_ROOT"
75
75
  registries_remove "$IP_ROOT/intelligence.yaml" "$url"
76
76
  # The retired flat-map form also stored scope keys — drop one if the
77
77
  # argument names it, so old manifests clean up with the old command.
@@ -139,7 +139,7 @@ case "$action" in
139
139
  dir="$(normalize_source_dir "$dir")"
140
140
  assert_valid_source_dir "$IP_ROOT" "$dir"
141
141
  [ "$anchor" != "$dir" ] || die "cannot place '$dir' relative to itself"
142
- ensure_project_current "$IP_ROOT"
142
+ project_as_committed "$IP_ROOT"
143
143
  manifest="$IP_ROOT/intelligence.yaml"
144
144
 
145
145
  # The anchor may be spelled differently from the entry it means — a
@@ -189,7 +189,7 @@ case "$action" in
189
189
  require_cli_project
190
190
  manifest="$IP_ROOT/intelligence.yaml"
191
191
  assert_not_package_reference "$manifest" "$dir" remove
192
- ensure_project_current "$IP_ROOT"
192
+ project_as_committed "$IP_ROOT"
193
193
 
194
194
  if ! sources_has_entry "$manifest" "$section" "$dir"; then
195
195
  echo "not listed: sources.$section does not hold $dir"
@@ -58,11 +58,10 @@ run_sync() {
58
58
  # One writer per project at a time: the store restore and the
59
59
  # render below both rewrite shared state (sync-lock.sh).
60
60
  hold_project
61
- # `sync` is the normal fresh-clone command. A newer globally installed
62
- # CLI aligns the current Intelligence project first (except in CI, where a
63
- # tracked migration must be reviewed and committed locally), and a
64
- # missing ignored store is restored strictly from the committed lock.
65
- ensure_project_current "$IP_ROOT" || return $?
61
+ # `sync` is the normal fresh-clone command. It renders the project as
62
+ # committed, whichever CLI runs it, and restores a missing ignored
63
+ # store strictly from the committed lock.
64
+ project_as_committed "$IP_ROOT" || return $?
66
65
  restore_project_store_if_missing "$IP_ROOT" || return $?
67
66
  export_engine_env "$IP_ROOT" || return $?
68
67
  sync_with_cache "$target" "$force" || return $?
@@ -88,12 +87,10 @@ run_sync() {
88
87
  esac
89
88
  }
90
89
 
91
- # run_check — the same preflight as sync, except that tracked alignment is never
92
- # applied (it is reported as needed, in CI or not) and everything but the
93
- # verdict goes to stderr. Restoring the ignored package store from the lock is
94
- # allowed: it is reproducible state, and the comparison needs it.
90
+ # run_check — the same preflight as sync, with everything but the verdict on
91
+ # stderr. Restoring the ignored package store from the lock is allowed: it is
92
+ # reproducible state, and the comparison needs it.
95
93
  run_check() {
96
- local stamp
97
94
  detect_project || return $?
98
95
  case "$IP_MODE" in
99
96
  cli) ;;
@@ -111,15 +108,7 @@ run_check() {
111
108
  # shellcheck disable=SC2034 # read by sync-lock.sh when the lock is taken
112
109
  SYNC_LOCK_KIND=check
113
110
  hold_project
114
- project_preflight "$IP_ROOT" || return $?
115
- if project_needs_upgrade "$IP_ROOT"; then
116
- read_schema_version_var "$IP_ROOT/intelligence.yaml"
117
- stamp="$IS_SCHEMA_VERSION"
118
- bundled_engine_version_var
119
- IS_CHECK_OUT_OF_DATE=1
120
- is_status out-of-date "the project needs alignment (stamp ${stamp:-unstamped}, engine $IS_BUNDLED_ENGINE_VERSION); run 'intelligence init --apply' locally, review and commit the diff"
121
- return "$IS_RC_OUT_OF_DATE"
122
- fi
111
+ project_as_committed "$IP_ROOT" || return $?
123
112
  restore_project_store_if_missing "$IP_ROOT" keep-sources >&2 || return $?
124
113
  export_engine_env "$IP_ROOT" || return $?
125
114
  sync_check "$target" "$force" || return $?
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="." ;;
@@ -41,7 +41,7 @@ fi
41
41
  stamp="$(read_schema_version "$manifest")"
42
42
  eng="$(bundled_engine_version)"
43
43
  if [ -z "$stamp" ]; then
44
- warn "manifest has no schema_version — run 'intelligence init'"
44
+ warn "manifest has no schema_version — run 'intelligence update'"
45
45
  elif _ver_gt "$stamp" "$eng"; then
46
46
  if [ "$(_ver_major "$stamp")" -gt "$(_ver_major "$eng")" ]; then
47
47
  warn "manifest schema $stamp is a newer major than this CLI's engine $eng — update the CLI: intelligence upgrade (--next for a prerelease line)"
@@ -49,13 +49,17 @@ elif _ver_gt "$stamp" "$eng"; then
49
49
  note "manifest schema $stamp is newer than this CLI's engine $eng — the project uses a newer CLI; update it: intelligence upgrade (--next for a prerelease line)"
50
50
  fi
51
51
  elif _ver_gt "$eng" "$stamp"; then
52
- warn "manifest schema $stamp behind engine $eng — run 'intelligence init'"
52
+ if [ "$(_ver_major "$stamp")" = "$(_ver_major "$eng")" ]; then
53
+ note "manifest schema $stamp is older than this CLI's engine $eng — kept as committed; move it with: intelligence update"
54
+ else
55
+ warn "manifest schema $stamp is an older major than this CLI's engine $eng — run 'intelligence update'"
56
+ fi
53
57
  else
54
58
  ok "schema_version $stamp matches the engine"
55
59
  fi
56
60
 
57
- # The engine-content package must sit at the bundled engine version — the
58
- # content documents exactly the scripts the CLI executes.
61
+ # The engine-content package follows the project's own version: the stamp,
62
+ # not the CLI, decides which content renders (decision 0023).
59
63
  sync_locked=""
60
64
  if [ "$lock_valid" -eq 1 ]; then
61
65
  sync_locked="$(qmap_field "$lock" "packages" "$SYNC_PKG_NAME" "resolved")"
@@ -65,12 +69,14 @@ if [ -n "$sync_locked" ]; then
65
69
  ok "$SYNC_PKG_NAME at $sync_locked (matches the engine)"
66
70
  elif [ "${sync_locked#v}" = "$stamp" ] && _ver_gt "$stamp" "$eng"; then
67
71
  note "$SYNC_PKG_NAME at $sync_locked (matches the project schema; the engine is $eng)"
72
+ elif [ "${sync_locked#v}" = "$stamp" ] && [ "$(_ver_major "$stamp")" = "$(_ver_major "$eng")" ]; then
73
+ note "$SYNC_PKG_NAME at $sync_locked (matches the project schema; intelligence update moves it to $eng)"
68
74
  else
69
- warn "$SYNC_PKG_NAME locked at $sync_locked but the engine is $eng — run 'intelligence init'"
75
+ warn "$SYNC_PKG_NAME locked at $sync_locked but the engine is $eng — run 'intelligence update'"
70
76
  fi
71
77
  fi
72
78
  if [ -d "$IP_ROOT/.intelligence/engine" ]; then
73
- warn "stale .intelligence/engine directory (pre-package layout) — run 'intelligence init'"
79
+ warn "stale .intelligence/engine directory (pre-package layout) — run 'intelligence update'"
74
80
  fi
75
81
 
76
82
  # Manifest packages: names valid, requested intent only, locked and installed.
@@ -96,7 +102,7 @@ while IFS= read -r name; do
96
102
  || warn "$name pins ref '$m_ref' but the lock resolved '$l_res'"
97
103
  if [ -n "$(qmap_field "$manifest" "packages" "$name" "url")" ] \
98
104
  || [ -n "$(qmap_field "$manifest" "packages" "$name" "path")" ]; then
99
- warn "$name stores source details in the manifest — run 'intelligence init --apply' to move them to the lock-only model"
105
+ warn "$name stores source details in the manifest — run 'intelligence update' to move them to the lock-only model"
100
106
  fi
101
107
  if [ ! -d "$IP_ROOT/.intelligence/packages/$name" ]; then
102
108
  warn "$name is locked but not installed — run 'intelligence sync'"
@@ -344,17 +344,10 @@ export_engine_env() {
344
344
  }
345
345
 
346
346
  # --- Project lifecycle preflight -----------------------------------------
347
- # Public commands are intentionally few. They share this state gate so a CLI
348
- # installed at a newer engine version brings the current Intelligence project forward
349
- # before a mutating operation. The npm install itself cannot do that: it runs
350
- # outside any project and does not know which repositories the user owns.
351
-
352
- is_ci_environment() {
353
- case "${CI:-}" in
354
- 1|true|TRUE|True|yes|YES|Yes|on|ON|On) return 0 ;;
355
- *) return 1 ;;
356
- esac
357
- }
347
+ # Public commands are intentionally few. They share this state gate. Only
348
+ # `update` moves a project's versions forward (decision 0023); every other
349
+ # command works on the project as committed, or refuses one that needs a
350
+ # migration first.
358
351
 
359
352
  # Validate a repository-relative project content directory, including every
360
353
  # existing symlinked path component. Call before writing a new manifest.
@@ -395,23 +388,37 @@ project_stamped_ahead() {
395
388
  [ -n "$stamp" ] && _ver_gt "$stamp" "$IS_BUNDLED_ENGINE_VERSION"
396
389
  }
397
390
 
391
+ # project_needs_upgrade <root> — true when `update` would align the project.
392
+ # IS_UPGRADE_KIND says how far behind it is: `behind` when only the schema
393
+ # stamp or the sync content pin trails this engine within its major, which
394
+ # every command renders around, with IS_UPGRADE_DESC naming what trails;
395
+ # `migrate` when the project needs a change only alignment applies (no stamp,
396
+ # an older major, a legacy manifest shape, a sync content pin that is no
397
+ # version).
398
398
  project_needs_upgrade() {
399
399
  local root="$1" manifest="$1/intelligence.yaml" stamp eng pinned locked name url path rows
400
- local lname _requested _url _path resolved _sha
400
+ local lname _requested _url _path resolved _sha behind=0 pin_behind="" value
401
+ IS_UPGRADE_KIND="" IS_UPGRADE_DESC=""
401
402
  [ -f "$manifest" ] || return 1
402
403
  read_schema_version_var "$manifest"
403
404
  stamp="$IS_SCHEMA_VERSION"
404
405
  bundled_engine_version_var
405
406
  eng="$IS_BUNDLED_ENGINE_VERSION"
406
- [ -z "$stamp" ] && return 0
407
+ if [ -z "$stamp" ]; then IS_UPGRADE_KIND=migrate; return 0; fi
407
408
  # A project stamped ahead belongs to a newer CLI. Aligning it here would
408
409
  # restamp the schema and re-pin the engine content DOWNWARD, and the next
409
410
  # teammate on the current CLI would move both back: leave it as found.
410
411
  project_stamped_ahead "$root" && return 1
411
412
  _qmap_read_var top "$manifest" '' sync_version || true
412
- [ -z "$IS_QMAP_OUT" ] || return 0
413
- _ver_gt "$eng" "$stamp" && return 0
414
- [ -d "$root/.intelligence/engine" ] && return 0
413
+ if [ -n "$IS_QMAP_OUT" ] || [ -d "$root/.intelligence/engine" ]; then
414
+ IS_UPGRADE_KIND=migrate; return 0
415
+ fi
416
+ if _ver_gt "$eng" "$stamp"; then
417
+ if [ "$(_ver_major "$stamp")" != "$(_ver_major "$eng")" ]; then
418
+ IS_UPGRADE_KIND=migrate; return 0
419
+ fi
420
+ behind=1
421
+ fi
415
422
 
416
423
  # One manifest pass for every package's fields, not a reader per field.
417
424
  _qmap_read_var fieldrows "$manifest" packages '' 'url path version' || true
@@ -420,8 +427,7 @@ project_needs_upgrade() {
420
427
  [ -n "$name" ] || continue
421
428
  # Early RC manifests mixed requested intent with resolved source
422
429
  # details. Source URL/path now live only in the required lockfile.
423
- [ -n "$url" ] && return 0
424
- [ -n "$path" ] && return 0
430
+ if [ -n "$url" ] || [ -n "$path" ]; then IS_UPGRADE_KIND=migrate; return 0; fi
425
431
  if [ "$name" = "$SYNC_PKG_NAME" ]; then
426
432
  # The first lock row for the name: what qmap_field would decode.
427
433
  locked=""
@@ -431,14 +437,52 @@ project_needs_upgrade() {
431
437
  locked="$resolved"
432
438
  break
433
439
  done <<< "$IS_QMAP_OUT"
434
- [ "$pinned" = "$eng" ] || return 0
435
- if [ -f "$root/intelligence.lock" ]; then
436
- [ -n "$locked" ] || return 0
437
- [ "${locked#v}" = "$eng" ] || return 0
440
+ if [ -f "$root/intelligence.lock" ] && [ -z "$locked" ]; then
441
+ IS_UPGRADE_KIND=migrate; return 0
438
442
  fi
443
+ # The manifest pin, then the lock row. One ahead of this engine is
444
+ # a newer CLI's, left as found like a stamp ahead (decision 0005).
445
+ for value in "$pinned" "${locked#v}"; do
446
+ [ -n "$value" ] && [ "$value" != "$eng" ] || continue
447
+ case "$value" in [0-9]*.[0-9]*.[0-9]*) ;; *) IS_UPGRADE_KIND=migrate; return 0 ;; esac
448
+ _ver_gt "$eng" "$value" || continue
449
+ [ "$(_ver_major "$value")" = "$(_ver_major "$eng")" ] || { IS_UPGRADE_KIND=migrate; return 0; }
450
+ behind=1
451
+ [ -n "$pin_behind" ] || pin_behind="$value"
452
+ done
439
453
  fi
440
454
  done <<< "$rows"
441
- return 1
455
+ [ "$behind" = 1 ] || return 1
456
+ IS_UPGRADE_KIND=behind
457
+ # What the warning names: only the versions that actually trail.
458
+ if _ver_gt "$eng" "$stamp"; then IS_UPGRADE_DESC="schema $stamp"; fi
459
+ [ -z "$pin_behind" ] || IS_UPGRADE_DESC="${IS_UPGRADE_DESC:+$IS_UPGRADE_DESC, }$SYNC_PKG_NAME $pin_behind"
460
+ }
461
+
462
+ # project_as_committed <root> — the gate of every project-aware command but
463
+ # `update`: the preflight, then the project as committed. One whose versions
464
+ # only trail this engine within its major proceeds behind one warning and
465
+ # keeps its tracked bytes; one that needs a migration is refused with
466
+ # IS_STATUS=needs-update and exit 6.
467
+ project_as_committed() {
468
+ local root="$1" stamp
469
+ project_preflight "$root" || return $?
470
+ project_needs_upgrade "$root" || return 0
471
+ read_schema_version_var "$root/intelligence.yaml"
472
+ stamp="$IS_SCHEMA_VERSION"
473
+ bundled_engine_version_var
474
+ if [ "$IS_UPGRADE_KIND" = behind ]; then
475
+ # Once per command, however many processes check on the way to the
476
+ # engine; unindented, so `sync --compact` keeps it.
477
+ if [ "${IS_PROJECT_BEHIND_WARNED:-}" != "$stamp" ]; then
478
+ echo "WARNING: project versions ($IS_UPGRADE_DESC) trail this CLI's engine $IS_BUNDLED_ENGINE_VERSION — they stay as committed; move them with: intelligence update" >&2
479
+ export IS_PROJECT_BEHIND_WARNED="$stamp"
480
+ fi
481
+ return 0
482
+ fi
483
+ is_status needs-update "stamp=${stamp:-unstamped} engine=$IS_BUNDLED_ENGINE_VERSION"
484
+ echo "ERROR: the project needs a migration this command does not apply (schema ${stamp:-unstamped}, engine $IS_BUNDLED_ENGINE_VERSION) — run: intelligence update" >&2
485
+ return "$IS_RC_NEEDS_UPDATE"
442
486
  }
443
487
 
444
488
  # A manifest with packages but no lock has no trustworthy resolved state from
@@ -466,17 +510,16 @@ project_preflight() {
466
510
  fi
467
511
  }
468
512
 
513
+ # ensure_project_current <root> — align the project to this engine. `update`
514
+ # alone calls it (decision 0023).
469
515
  ensure_project_current() {
470
- local root="$1" explicit="${2:-}" manifest="$1/intelligence.yaml" stamp eng
516
+ local root="$1" manifest="$1/intelligence.yaml" stamp eng
471
517
  project_preflight "$root" || return $?
472
518
  project_needs_upgrade "$root" || return 0
473
519
  read_schema_version_var "$manifest"
474
520
  stamp="$IS_SCHEMA_VERSION"
475
521
  bundled_engine_version_var
476
522
  eng="$IS_BUNDLED_ENGINE_VERSION"
477
- if is_ci_environment && [ "$explicit" != "--explicit" ]; then
478
- die "project lifecycle requires alignment (stamp ${stamp:-unstamped}, engine $eng) — run 'intelligence init --apply' locally, review and commit the diff"
479
- fi
480
523
  echo " project alignment: stamp ${stamp:-unstamped}, engine $eng"
481
524
  bash "$CLI_DIR/internal/align-project.sh" --no-sync || return $?
482
525
  project_reads_refresh "$root"
@@ -70,6 +70,15 @@ lock_validate_checks() {
70
70
  continue
71
71
  fi
72
72
  fi
73
+ if [ -z "$sha" ] && [ "$url" = "$SYNC_PKG_URL" ] && [ "$path" = "$SYNC_PKG_PATH" ]; then
74
+ # Bundle metadata pins the content a CLI ships; only this CLI's own
75
+ # version restores without a commit. update moves an older pin to it;
76
+ # a newer one needs a newer CLI.
77
+ if [[ "$resolved" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && _ver_gt "${resolved#v}" "$current"; then
78
+ die "locked restore: $name $resolved has a missing or invalid commit SHA, and this CLI bundles v$current — update the CLI: intelligence upgrade"
79
+ fi
80
+ die "locked restore: $name $resolved has a missing or invalid commit SHA, and this CLI bundles v$current — run: intelligence update"
81
+ fi
73
82
  [[ "$sha" =~ ^[0-9a-f]{40}$ || "$sha" =~ ^[0-9a-f]{64}$ ]] \
74
83
  || die "locked restore: $name has a missing or invalid commit SHA — restore a valid intelligence.lock"
75
84
  done <<< "$rows"
@@ -128,16 +137,19 @@ lock_write_from_tsv() {
128
137
  }
129
138
 
130
139
  # lock_upsert <lock> <name> <requested> <url> <path> <resolved> <sha>
131
- # Replace-or-append one entry, keeping the others.
140
+ # Replace one entry where it stands, or append a new one, keeping the others:
141
+ # an update changes only that package's lines in the committed lock.
132
142
  lock_upsert() {
133
143
  local lock="$1" name="$2" requested="$3" url="$4" path="$5" resolved="$6" sha="$7"
134
- local tsv
144
+ local tsv row
135
145
  tsv="$(mktemp -t intelligence-lock-XXXXXX 2>/dev/null || mktemp)"
136
- {
137
- lock_to_tsv "$lock" | awk -F"$LOCK_SEP" -v n="$name" '$1 != n'
138
- printf '%s\037%s\037%s\037%s\037%s\037%s\n' \
139
- "$name" "$requested" "$url" "$path" "$resolved" "$sha"
140
- } > "$tsv"
146
+ printf -v row '%s\037%s\037%s\037%s\037%s\037%s' \
147
+ "$name" "$requested" "$url" "$path" "$resolved" "$sha"
148
+ lock_to_tsv "$lock" | IS_LOCK_ROW="$row" awk -F"$LOCK_SEP" -v n="$name" '
149
+ $1 == n { if (!done) print ENVIRON["IS_LOCK_ROW"]; done = 1; next }
150
+ { print }
151
+ END { if (!done) print ENVIRON["IS_LOCK_ROW"] }
152
+ ' > "$tsv"
141
153
  lock_write_from_tsv "$lock" "$tsv"
142
154
  rm -f "$tsv"
143
155
  }
@@ -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
+ 79201cedae74ec96e67c3aa94e267f6b1e226c23
package/engine/VERSION CHANGED
@@ -1 +1 @@
1
- 0.22.0
1
+ 0.23.0
@@ -196,6 +196,9 @@ async function runCheck($) {
196
196
  // session's check — is not a fault: the last verdict stays, and the check
197
197
  // asks again shortly instead of queueing ahead of a waiting sync.
198
198
  else if (LOCK_REFUSAL.test(result.stderr || '')) retrySoon($)
199
+ // A project that needs a migration names the command that applies it on
200
+ // stderr; the status detail only names the versions.
201
+ else if (parsed.status === 'needs-update') setState('error', reasonLine(result.stderr) || 'run intelligence update')
199
202
  else setState('error', parsed.detail || reasonLine(result.stderr) || 'exit ' + result.exitCode)
200
203
  show($)
201
204
  }
@@ -83,9 +83,9 @@ IS_RC_CONFIG_MISSING=2 # no manifest found
83
83
  IS_RC_AMBIGUOUS=3 # conflicting state; agent/human-only — bash never emits this itself
84
84
  IS_RC_AHEAD=4 # project stamped newer than this engine understands
85
85
  IS_RC_ABORTED_INCOMPLETE=5 # staged state incomplete; the project was left untouched
86
- IS_RC_NEEDS_UPDATE=6 # pending schema changes (stamp < engine) — run `intelligence init --apply`
86
+ IS_RC_NEEDS_UPDATE=6 # a migration only `intelligence update` applies (no stamp, older major)
87
87
  # `intelligence sync --check` only: generated files differ from what sync would
88
- # leave, or alignment is pending (IS_STATUS=out-of-date). It shares 2 with
88
+ # leave (IS_STATUS=out-of-date). It shares 2 with
89
89
  # config-missing, which a check never reports: the CLI found the manifest, and
90
90
  # the check turns any other failure that ends in 2 into 1.
91
91
  IS_RC_OUT_OF_DATE=2
package/engine/sync.sh CHANGED
@@ -39,10 +39,10 @@ _vc_rc=0
39
39
  check_version_compat "$_cf" || _vc_rc=$?
40
40
  if [ "$_vc_rc" -ne 0 ]; then exit "$_vc_rc"; fi
41
41
 
42
- # Schema gap → refuse. sync is a PURE synchronizer: it never changes schemas,
43
- # so the CLI lifecycle preflight must align the project first. An ABSENT stamp
44
- # means the same thing — a manifest with no
45
- # `schema_version` must not silently sync past a schema change.
42
+ # sync is a PURE synchronizer: it never changes a schema. The manifest is
43
+ # additive within a major, so an older stamp of the same major renders as
44
+ # committed behind one warning (decision 0023); an older major, or no stamp at
45
+ # all, needs the migration only `intelligence update` applies.
46
46
  read_schema_version_var "$_cf"
47
47
  _stamp="$IS_SCHEMA_VERSION"
48
48
  engine_version_var
@@ -51,13 +51,18 @@ _eng="$IS_ENGINE_VERSION"
51
51
  if [ -z "$_stamp" ]; then
52
52
  is_status needs-update "stamped= engine=$_eng (no schema_version)"
53
53
  echo "ERROR: the manifest has no schema_version — schema un-applied." >&2
54
- echo " Run: intelligence init --apply" >&2
54
+ echo " Run: intelligence update" >&2
55
55
  exit "$IS_RC_NEEDS_UPDATE"
56
56
  elif [ -n "$_eng" ] && _ver_gt "$_eng" "$_stamp"; then
57
- is_status needs-update "stamped=$_stamp engine=$_eng"
58
- echo "ERROR: project at $_stamp but engine is $_eng — pending schema changes." >&2
59
- echo " Run: intelligence init --apply" >&2
60
- exit "$IS_RC_NEEDS_UPDATE"
57
+ if [ "$(_ver_major "$_stamp")" != "$(_ver_major "$_eng")" ]; then
58
+ is_status needs-update "stamped=$_stamp engine=$_eng"
59
+ echo "ERROR: project at $_stamp but engine is $_eng — an older major schema." >&2
60
+ echo " Run: intelligence update" >&2
61
+ exit "$IS_RC_NEEDS_UPDATE"
62
+ fi
63
+ if [ "${IS_PROJECT_BEHIND_WARNED:-}" != "$_stamp" ]; then
64
+ echo "WARNING: project versions (schema $_stamp) trail this CLI's engine $_eng — they stay as committed; move them with: intelligence update" >&2
65
+ fi
61
66
  fi
62
67
 
63
68
  # Normalize REPO_ROOT and CONFIG_FILE to one `cd && pwd` spelling so
@@ -742,10 +747,14 @@ SYNC_TX_ACTIVE=0
742
747
  # defaults move forward — e.g., gpt-5.5 -> gpt-5.6).
743
748
  # Concurrently, the scan runs beside the other two and beside removing the
744
749
  # snapshots; the buffers print in the order above.
750
+ # A check renders in a copy that holds only the project's inputs and outputs; the
751
+ # scan lists directories and reads no bytes, so it looks at the live project.
752
+ UNSYNCED_ROOT="$REPO_ROOT"
753
+ [ -z "${IS_SYNC_CHECK:-}" ] || [ -z "${IS_SYNC_CHECK_ROOT:-}" ] || UNSYNCED_ROOT="$IS_SYNC_CHECK_ROOT"
745
754
  if [ "$SYNC_PARALLEL" = 1 ]; then
746
755
  rm -rf "$SYNC_TX_DIR/data" &
747
756
  SYNC_BG_PIDS+=("$!")
748
- warn_unsynced "$REPO_ROOT" "$CONFIG_FILE" > "$SYNC_TX_DIR/unsynced" 2>&1 &
757
+ warn_unsynced "$UNSYNCED_ROOT" "$CONFIG_FILE" > "$SYNC_TX_DIR/unsynced" 2>&1 &
749
758
  SYNC_BG_PIDS+=("$!")
750
759
  {
751
760
  report_context_source_sizes "$REPO_ROOT" "$CONFIG_FILE"
@@ -758,7 +767,7 @@ if [ "$SYNC_PARALLEL" = 1 ]; then
758
767
  else
759
768
  rm -rf "$SYNC_TX_DIR"
760
769
  trap - EXIT INT TERM
761
- warn_unsynced "$REPO_ROOT" "$CONFIG_FILE"
770
+ warn_unsynced "$UNSYNCED_ROOT" "$CONFIG_FILE"
762
771
  report_context_source_sizes "$REPO_ROOT" "$CONFIG_FILE"
763
772
  report_model_drift "$CONFIG_FILE"
764
773
  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.23.0",
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"
@@ -125,7 +125,7 @@ Practice:
125
125
 
126
126
  Commit `intelligence.lock`. It records requested versions, source URLs and paths, resolved refs and commit SHAs. After cloning, or after a pull that moved the lock, `intelligence sync` restores every package the store lacks or holds at another commit, strictly from that lock, before rendering; manifest/lock or SHA drift is refused. Re-run `package add` when deliberately changing a source.
127
127
 
128
- `@ainova-systems/sync` is ordinary package content exact-pinned to the bundled engine version. `intelligence init` installs it unless `--bare` is used. Lifecycle preflight keeps that pin and `schema_version` aligned with the installed CLI; package-range updates never move it independently.
128
+ `@ainova-systems/sync` is ordinary package content exact-pinned to the bundled engine version. `intelligence init` installs it unless `--bare` is used. Only `intelligence update` moves that pin and `schema_version` to the installed CLI; every other command keeps them as committed, and package-range updates never move the pin independently.
129
129
 
130
130
  ## Layout tokens
131
131
 
@@ -504,15 +504,15 @@ The permanent applied-schema key is the top-level scalar `schema_version` in `in
504
504
 
505
505
  The public lifecycle is deliberately compact:
506
506
 
507
- - `intelligence init [--preview|--apply]` is universal: it creates a new setup, aligns an existing Intelligence project, or plans/applies conversion of an eligible legacy Intelligence Sync project.
508
- - `intelligence sync [adapter] [--compact] [--force] [--check]` first aligns an existing Intelligence project with the installed CLI and restores every package the store lacks or holds at another commit, strictly from `intelligence.lock`. It skips rendering when local inputs and outputs match a previous successful run. Changes or missing outputs trigger rendering; `--force` always renders and refreshes the search for unconfigured source directories. An unchanged run replays the last full run's useful diagnostics. Compact mode shows context sizes, actionable warnings and final status on success, and all diagnostics on failure. In CI it refuses an alignment that would change tracked files and points to a local `intelligence init --apply` plus review/commit. `--check` changes no generated file: it prints one `IS_STATUS` line and exits 0 when generated files are up to date and 2 (`out-of-date`) when sync would change them or alignment is pending; it never applies alignment.
507
+ - `intelligence init [--preview|--apply]` is universal: it creates a new setup, restores an existing Intelligence project as committed, or plans/applies conversion of an eligible legacy Intelligence Sync project.
508
+ - `intelligence sync [adapter] [--compact] [--force] [--check]` first restores every package the store lacks or holds at another commit, strictly from `intelligence.lock`. It skips rendering when local inputs and outputs match a previous successful run. Changes or missing outputs trigger rendering; `--force` always renders and refreshes the search for unconfigured source directories. An unchanged run replays the last full run's useful diagnostics. Compact mode shows context sizes, actionable warnings and final status on success, and all diagnostics on failure. It renders an Intelligence project at its committed versions: versions that trail the installed CLI within its major render behind one warning naming `intelligence update`; a project that needs a migration first — no `schema_version` or an older major — is refused with `needs-update`, locally and in CI alike. `--check` changes no generated file: it prints one `IS_STATUS` line and exits 0 when generated files are up to date and 2 (`out-of-date`) when sync would change them; it never moves a version.
509
509
  - `intelligence update [@scope/name] [--preview|--apply]` is the only update surface. It prints the CLI/project/package plan; default mode prompts, `--preview` never writes, and `--apply` does not prompt. It never moves `ref:` pins.
510
510
  - `intelligence upgrade [--next] [--preview|--apply]` replaces the installed CLI with the newest version on its npm channel (`next` for a prerelease or with `--next`, otherwise `latest`) with the same modes. It touches no project, never downgrades, and refuses an installation that npm did not make.
511
511
  - `intelligence package add|remove|list|search` owns package inventory.
512
512
  - `intelligence adapter list|create|enable|disable|remove` owns adapter inventory and target state.
513
513
  - `intelligence status [--check]` reports state; `--check` runs deep consistency checks.
514
514
 
515
- Implement Intelligence schema changes as idempotent structural checks. Stage and verify replacement state before deleting or replacing prior state. A stale engine refuses a manifest whose `schema_version` is a newer major; a newer minor or patch within the same major warns once and proceeds without restamping the project. Normal project entry points close a behind-project gap through lifecycle preflight.
515
+ Implement Intelligence schema changes as idempotent structural checks. Stage and verify replacement state before deleting or replacing prior state. A stale engine refuses a manifest whose `schema_version` is a newer major; a newer minor or patch within the same major warns once and proceeds without restamping the project. A project behind the engine stays as committed until `intelligence update` closes the gap.
516
516
 
517
517
  Breaking changelog entries use a `### Breaking` checklist of verifiable post-conditions. The `intelligence-upgrade` skill reads every release across the version gap, chooses the package/CLI/project command sequence and verifies those conditions after the deterministic command completes.
518
518
 
@@ -526,11 +526,11 @@ The engine emits one machine-readable line, `IS_STATUS=<code> [IS_DETAIL=...]`,
526
526
  | `migrated` | 0 | Initialization converted an older layout |
527
527
  | `error` | 1 | Generic failure |
528
528
  | `config-missing` | 2 | Required manifest is absent |
529
- | `out-of-date` | 2 | `sync --check` only: sync would change generated files, or alignment is pending. A check never reports `config-missing`, and turns any other failure that would end in 2 into 1 |
529
+ | `out-of-date` | 2 | `sync --check` only: sync would change generated files. A check never reports `config-missing`, and turns any other failure that would end in 2 into 1 |
530
530
  | `ambiguous` | 3 | Conflicting state requiring agent/human judgment; reserved |
531
531
  | `ahead-of-engine` | 4 | Manifest schema is a newer major than the engine (a newer minor or patch warns and continues) |
532
532
  | `aborted-incomplete` | 5 | Staged replacement was incomplete; prior state remains |
533
- | `needs-update` | 6 | Project schema is behind the engine; rerun through a public lifecycle command |
533
+ | `needs-update` | 6 | The project needs a migration only `intelligence update` applies: no `schema_version`, or an older major |
534
534
 
535
535
  Callers capture the real code with `command || rc=$?`. Do not use `if ! command; then rc=$?`; inside that branch `$?` is the status of the negation.
536
536
 
@@ -28,8 +28,11 @@ mechanics; this skill supplies repository judgement.
28
28
  3. Run `intelligence status --check`. If the project is missing, inconsistent,
29
29
  or its first sync failed, run `intelligence init --preview`, show the exact
30
30
  repair plan, and request approval. After approval run
31
- `intelligence init --apply`. Do not reproduce manifest, package, adapter,
32
- backup, or ignore-file mechanics manually.
31
+ `intelligence init --apply`. When the check or the plan names
32
+ `intelligence update` instead, the project's versions need moving: show
33
+ `intelligence update --preview` and run `intelligence update --apply` only
34
+ after approval. Do not reproduce manifest, package, adapter, backup, or
35
+ ignore-file mechanics manually.
33
36
  4. Run `intelligence sync`. Sync is transactional: a failure restores every
34
37
  adapter-owned path. Resolve the reported cause and retry. Do not begin
35
38
  semantic migration until `IS_STATUS=ok` and `intelligence status --check`
@@ -8,16 +8,17 @@ context: fork
8
8
  # Sync intelligence
9
9
 
10
10
  1. Run `intelligence sync` (or `intelligence sync <adapter>` when one adapter
11
- was requested). For Intelligence projects, this command first aligns project schema/content
12
- with the installed CLI and restores a missing package store strictly from
13
- `intelligence.lock`. Unchanged inputs and outputs can reuse a successful run.
11
+ was requested). For Intelligence projects, this command renders the project
12
+ at its committed versions and first restores a missing package store strictly
13
+ from `intelligence.lock`. Unchanged inputs and outputs can reuse a successful run.
14
14
  Use `--force` when a fresh render or a new search for unconfigured source
15
15
  directories is requested; an unchanged run replays the previous diagnostics.
16
16
  2. Require final `IS_STATUS=ok`; relay reported counts and any integrity,
17
17
  model-drift or unsynced-source warnings.
18
- 3. In CI, a required tracked project upgrade is intentionally refused. Report
19
- the instruction to run `intelligence init --apply` locally, review and
20
- commit its diff; never bypass the gate. A frozen restore refusal means the
18
+ 3. A warning that project versions trail the CLI is not a failure: relay it.
19
+ `IS_STATUS=needs-update` (exit 6) means the project needs a migration only
20
+ `intelligence update` applies; report that, and never edit the manifest or
21
+ lock by hand to get past it. A frozen restore refusal means the
21
22
  manifest and lock disagree or the locked commit cannot be verified/restored—report it without
22
23
  hand-copying package content.
23
24
  4. On a probable Intelligence defect, first ask whether to collect a sanitized