@ainova-systems/intelligence 0.17.1 → 0.17.3

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.
@@ -118,6 +118,8 @@ adapter_remove() {
118
118
  read -r answer
119
119
  case "$answer" in y|Y|yes|YES) ;; *) echo "removal cancelled"; exit 0 ;; esac
120
120
  fi
121
+ # A render sources project adapters: never remove one under it.
122
+ project_lock_hold "$IP_ROOT"
121
123
  rm -f "$file"
122
124
  echo "removed: $content_dir/adapters/$name.sh"
123
125
  echo " generated output was kept"
@@ -136,6 +138,8 @@ case "$action" in
136
138
  [ -n "$name" ] && [ $# -eq 2 ] || die "usage: intelligence adapter <enable|disable> <name>"
137
139
  assert_valid_target_name "$name"
138
140
  require_cli_project
141
+ # Held through the final render: target-state and sync join it.
142
+ project_lock_hold "$IP_ROOT"
139
143
  ensure_project_current "$IP_ROOT"
140
144
  if [ "$action" = "enable" ]; then
141
145
  state_stage="$(mktemp -d -t intelligence-adapter-state-XXXXXX)"
@@ -29,6 +29,9 @@ run_sync() {
29
29
  detect_project || return $?
30
30
  case "$IP_MODE" in
31
31
  cli)
32
+ # One writer per project at a time: the store restore and the
33
+ # render below both rewrite shared state (sync-lock.sh).
34
+ project_lock_hold "$IP_ROOT"
32
35
  # `sync` is the normal fresh-clone command. A newer globally installed
33
36
  # CLI aligns the current Intelligence project first (except in CI, where a
34
37
  # tracked migration must be reviewed and committed locally), and a
@@ -105,6 +105,9 @@ if [ "$mode" = "ask" ]; then
105
105
  esac
106
106
  fi
107
107
 
108
+ # Held from alignment through the final render: the package pass and the sync
109
+ # it execs join this lock instead of taking their own.
110
+ project_lock_hold "$IP_ROOT"
108
111
  ensure_project_current "$IP_ROOT"
109
112
  apply_args=(--no-sync)
110
113
  [ -z "$only" ] || apply_args+=("$only")
package/cli/intelligence CHANGED
@@ -7,15 +7,21 @@
7
7
 
8
8
  set -euo pipefail
9
9
 
10
- CLI_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ case "$0" in
11
+ */*) CLI_DIR="${0%/*}"; [ -n "$CLI_DIR" ] || CLI_DIR="/" ;;
12
+ *) CLI_DIR="." ;;
13
+ esac
14
+ CLI_DIR="$(cd "$CLI_DIR" && pwd)"
11
15
  export CLI_DIR
12
16
 
13
17
  # Engine discovery, by role (sync.sh + VERSION). The repo and the npm package
14
18
  # ship one layout — <root>/{cli,engine,packages/sync} — so there is a single
15
- # candidate rather than a per-distribution list.
19
+ # candidate rather than a per-distribution list. CLI_DIR is already the logical
20
+ # `pwd` spelling, so its textual parent is what `cd "$CLI_DIR/.."` would print.
21
+ CLI_ROOT="${CLI_DIR%/*}"
16
22
  IS_ENGINE_DIR=""
17
23
  if [ -f "$CLI_DIR/../engine/sync.sh" ] && [ -f "$CLI_DIR/../engine/VERSION" ]; then
18
- IS_ENGINE_DIR="$(cd "$CLI_DIR/../engine" && pwd)"
24
+ IS_ENGINE_DIR="$CLI_ROOT/engine"
19
25
  fi
20
26
  if [ -z "$IS_ENGINE_DIR" ]; then
21
27
  echo "ERROR: bundled sync engine not found next to the CLI ($CLI_DIR)." >&2
@@ -29,7 +35,7 @@ export IS_ENGINE_DIR
29
35
  # engine dir: the store must receive rules/agents/skills, never engine scripts.
30
36
  IS_BUNDLED_PKG_DIR=""
31
37
  if [ -d "$CLI_DIR/../packages/sync" ]; then
32
- IS_BUNDLED_PKG_DIR="$(cd "$CLI_DIR/../packages/sync" && pwd)"
38
+ IS_BUNDLED_PKG_DIR="$CLI_ROOT/packages/sync"
33
39
  fi
34
40
  export IS_BUNDLED_PKG_DIR
35
41
 
@@ -15,6 +15,7 @@ while [ $# -gt 0 ]; do
15
15
  done
16
16
 
17
17
  require_cli_project
18
+ project_lock_hold "$IP_ROOT"
18
19
  manifest="$IP_ROOT/intelligence.yaml"
19
20
 
20
21
  stamp="$(read_schema_version "$manifest")"
@@ -102,7 +102,20 @@ while IFS= read -r name; do
102
102
  warn "$name is locked but not installed — run 'intelligence sync'"
103
103
  continue
104
104
  fi
105
- ok "$name @ $(pin_label "$m_ref" "$l_res" "$(qmap_field "$lock" "packages" "$name" "sha")")"
105
+ # Report what the store holds, never what the lock pins: they differ
106
+ # after a pull that moved the lock and left the ignored store behind.
107
+ l_url="$(qmap_field "$lock" "packages" "$name" "url")"
108
+ l_path="$(qmap_field "$lock" "packages" "$name" "path")"
109
+ l_sha="$(qmap_field "$lock" "packages" "$name" "sha")"
110
+ if ! store_record_get "$IP_ROOT" "$name"; then
111
+ warn "$name is installed, but the store does not record which commit it holds; the lock pins $(pin_label "$m_ref" "$l_res" "$l_sha") — run 'intelligence sync'"
112
+ elif [ "$IS_REC_URL" != "$l_url" ] || [ "$IS_REC_PATH" != "$l_path" ]; then
113
+ warn "$name was installed from ${IS_REC_URL}${IS_REC_PATH:+ (path $IS_REC_PATH)} but the lock names ${l_url}${l_path:+ (path $l_path)} — run 'intelligence sync'"
114
+ elif [ "$IS_REC_RESOLVED" != "$l_res" ] || [ "$IS_REC_SHA" != "$l_sha" ]; then
115
+ warn "$name holds $IS_REC_RESOLVED@$(short_sha "$IS_REC_SHA") but the lock pins $l_res@$(short_sha "$l_sha") — run 'intelligence sync'"
116
+ else
117
+ ok "$name @ $(pin_label "$m_ref" "$IS_REC_RESOLVED" "$IS_REC_SHA")"
118
+ fi
106
119
  fi
107
120
  done < <(qmap_keys "$manifest" "packages")
108
121
 
@@ -227,7 +227,38 @@ rewrite_source_value() {
227
227
  esac
228
228
  printf '%s' "$v"
229
229
  }
230
- in_packs=0 in_sources=0
230
+ # The project's own content stays where the legacy layout kept it. When that
231
+ # directory is not `intelligence/`, the manifest names it: the content directory
232
+ # is where project adapters live, what no output may overwrite, and the source of
233
+ # truth AGENTS.md points readers at. Left at the default, all three name a
234
+ # directory that only a case-insensitive filesystem confuses with the real one.
235
+ legacy_content_dir=""
236
+ case "$umbrella_rel" in
237
+ "$umbrella"|intelligence|*/*|.intelligence*|*[\"\'\\]*|*[[:cntrl:]]*) ;;
238
+ *)
239
+ # Only content that stays counts: the vendored module and pack mirrors
240
+ # move into the store and leave, so a config-only umbrella is not one.
241
+ if [ -z "$(get_yaml_field "$config" "project" "intelligence_dir")" ]; then
242
+ for section in rules agents skills; do
243
+ while IFS= read -r src; do
244
+ case "$src" in ""|git+*|@*) continue ;; esac
245
+ src_canon="$(normalize_path "$root/$src")"
246
+ case "$src_canon" in "$root"/*) src_rel="${src_canon#"$root"/}" ;; *) continue ;; esac
247
+ case "$src_rel" in "$module_rel"|"$module_rel"/*) continue ;; esac
248
+ leaving=0
249
+ while IFS="$LOCK_SEP" read -r _ _ _ _ mirror; do
250
+ [ -n "$mirror" ] || continue
251
+ mirror="$(normalize_path "$root/$mirror")"
252
+ case "$src_canon" in "$mirror"|"$mirror"/*) leaving=1 ;; esac
253
+ done < "$pack_rows"
254
+ [ "$leaving" -eq 0 ] || continue
255
+ case "$src_rel" in "$umbrella_rel"|"$umbrella_rel"/*) legacy_content_dir="$umbrella_rel" ;; esac
256
+ done < <(read_yaml_list "$config" "$section")
257
+ done
258
+ fi
259
+ ;;
260
+ esac
261
+ in_packs=0 in_sources=0 project_key=0 content_dir_written=0
231
262
  while IFS= read -r line || [ -n "$line" ]; do
232
263
  line="${line%$'\r'}"
233
264
  case "$line" in
@@ -236,6 +267,18 @@ while IFS= read -r line || [ -n "$line" ]; do
236
267
  sources:*) in_sources=1; in_packs=0; echo "$line"; continue ;;
237
268
  [!\ \#]*) in_packs=0; in_sources=0 ;;
238
269
  esac
270
+ case "$line" in project:*) project_key=1 ;; esac
271
+ if [ -n "$legacy_content_dir" ] && [ "$content_dir_written" -eq 0 ]; then
272
+ # Only the block form takes a child line; `project: {...}` does not.
273
+ key_part="${line%%#*}"
274
+ key_part="${key_part%"${key_part##*[![:space:]]}"}"
275
+ if [ "$key_part" = "project:" ]; then
276
+ echo "$line"
277
+ echo " intelligence_dir: \"$legacy_content_dir\""
278
+ content_dir_written=1
279
+ continue
280
+ fi
281
+ fi
239
282
  [ "$in_packs" -eq 1 ] && continue
240
283
  if [ "$in_sources" -eq 1 ]; then
241
284
  case "$line" in
@@ -261,6 +304,9 @@ while IFS= read -r line || [ -n "$line" ]; do
261
304
  fi
262
305
  echo "$line"
263
306
  done < "$config" > "$manifest_stage"
307
+ if [ -n "$legacy_content_dir" ] && [ "$content_dir_written" -eq 0 ] && [ "$project_key" -eq 0 ]; then
308
+ printf 'project:\n intelligence_dir: "%s"\n' "$legacy_content_dir" >> "$manifest_stage"
309
+ fi
264
310
  stamp_schema_version "$manifest_stage" "$eng"
265
311
 
266
312
  # packages: requested pins only; resolved source details live in the lock.
@@ -360,6 +406,11 @@ trap 'rollback; exit 130' INT TERM
360
406
  mv "$stage/.intelligence" "$root/.intelligence"
361
407
  cp "$manifest_stage" "$root/intelligence.yaml"
362
408
  lock_write_from_tsv "$root/intelligence.lock" "$lock_rows"
409
+ # The staged store holds exactly these rows; record them so sync trusts it.
410
+ while IFS="$LOCK_SEP" read -r rec_name _ rec_url rec_path rec_resolved rec_sha; do
411
+ [ -n "$rec_name" ] || continue
412
+ store_record_set "$root" "$rec_name" "$rec_url" "$rec_path" "$rec_resolved" "$rec_sha"
413
+ done < "$lock_rows"
363
414
  ensure_manifest_gitignore "$root" "$root/intelligence.yaml"
364
415
  ensure_manifest_publisher_ignores "$root" "$root/intelligence.yaml"
365
416
 
@@ -24,6 +24,7 @@ while [ $# -gt 0 ]; do
24
24
  done
25
25
 
26
26
  require_cli_project
27
+ project_lock_hold "$IP_ROOT"
27
28
  manifest="$IP_ROOT/intelligence.yaml"
28
29
  lock="$IP_ROOT/intelligence.lock"
29
30
 
@@ -122,6 +123,10 @@ if [ -z "$ref" ]; then
122
123
  fi
123
124
 
124
125
  rel=".intelligence/packages/$name"
126
+ # Forget what the directory held before replacing it: an interruption then
127
+ # leaves the package unrecorded, which the next sync repairs, never a stale
128
+ # record vouching for new bytes.
129
+ store_record_remove "$IP_ROOT" "$name"
125
130
  sha="$(fetch_package "$url" "${ref:-$resolved_tag}" "$path" "$IP_ROOT/$rel")"
126
131
 
127
132
  wire_package_sources "$manifest" "$name" "$rel" "$IP_ROOT"
@@ -139,6 +144,7 @@ else
139
144
  fi
140
145
 
141
146
  lock_upsert "$lock" "$name" "$requested" "$url" "$path" "${ref:-$resolved_tag}" "$sha"
147
+ store_record_set "$IP_ROOT" "$name" "$url" "$path" "${ref:-$resolved_tag}" "$sha"
142
148
 
143
149
  sections=""
144
150
  for s in rules agents skills; do
@@ -16,6 +16,7 @@ for a in "$@"; do
16
16
  done
17
17
 
18
18
  require_cli_project
19
+ project_lock_hold "$IP_ROOT"
19
20
 
20
21
  # Removing the engine-content package guts the outputs (meta-skills, the
21
22
  # authoring rule, both engine agents disappear) while everything still
@@ -42,6 +43,7 @@ unwire_package_sources "$manifest" "$rel"
42
43
  qmap_delete_key "$manifest" "packages" "$name"
43
44
  lock_remove "$lock" "$name"
44
45
  rm -rf "${IP_ROOT:?}/$rel"
46
+ store_record_remove "$IP_ROOT" "$name"
45
47
  # Drop the now-empty scope dir so the store stays tidy.
46
48
  rmdir "$IP_ROOT/.intelligence/packages/${name%%/*}" 2>/dev/null || true
47
49
  echo "- $name"
@@ -25,6 +25,7 @@ done
25
25
  || die "--latest needs the package to move: intelligence update @scope/name --latest"
26
26
 
27
27
  require_cli_project
28
+ [ "$preview" -eq 1 ] || project_lock_hold "$IP_ROOT"
28
29
  manifest="$IP_ROOT/intelligence.yaml"
29
30
  lock="$IP_ROOT/intelligence.lock"
30
31
 
@@ -173,11 +174,13 @@ while IFS= read -r name; do
173
174
  rm -rf "$staging"
174
175
  sha="$(fetch_package "$url" "$tag" "$path" "$staging")"
175
176
  unwire_package_sources "$manifest" "$rel"
177
+ store_record_remove "$IP_ROOT" "$name"
176
178
  rm -rf "${IP_ROOT:?}/$rel"
177
179
  mkdir -p "$(dirname "$IP_ROOT/$rel")"
178
180
  mv "$staging" "$IP_ROOT/$rel"
179
181
  wire_package_sources "$manifest" "$name" "$rel" "$IP_ROOT"
180
182
  lock_upsert "$lock" "$name" "$requested" "$url" "$path" "$tag" "$sha"
183
+ store_record_set "$IP_ROOT" "$name" "$url" "$path" "$tag" "$sha"
181
184
  # The widened intent is recorded only once its content is installed and
182
185
  # wired: a manifest saying ^0.6.1 over a failed fetch would describe a
183
186
  # state the project never reached.
@@ -19,6 +19,7 @@ while [ $# -gt 0 ]; do
19
19
  done
20
20
 
21
21
  require_cli_project
22
+ project_lock_hold "$IP_ROOT"
22
23
  manifest="$IP_ROOT/intelligence.yaml"
23
24
  lock="$IP_ROOT/intelligence.lock"
24
25
  validate_project_lock "$IP_ROOT" --restore
@@ -81,14 +82,16 @@ if [ -n "$missing" ]; then
81
82
  read -r resolved_tag _ <<< "$(remote_tag_for_version "$url" "$picked")"
82
83
  fi
83
84
  rel=".intelligence/packages/$name"
85
+ store_record_remove "$IP_ROOT" "$name"
84
86
  sha="$(fetch_package "$url" "${ref:-$resolved_tag}" "$path" "$IP_ROOT/$rel")"
85
87
  wire_package_sources "$manifest" "$name" "$rel" "$IP_ROOT"
86
88
  lock_upsert "$lock" "$name" "$range" "$url" "$path" "${ref:-$resolved_tag}" "$sha"
89
+ store_record_set "$IP_ROOT" "$name" "$url" "$path" "${ref:-$resolved_tag}" "$sha"
87
90
  echo "+ $name@${resolved_tag:-${ref:-HEAD}} (resolved)"
88
91
  done
89
92
  fi
90
93
 
91
- # Restore every locked package that is not already in the store.
94
+ # Restore every locked package the store does not hold at its locked commit.
92
95
  if [ -f "$lock" ]; then
93
96
  while IFS="$LOCK_SEP" read -r name _requested url path resolved sha; do
94
97
  [ -n "$name" ] || continue
@@ -96,10 +99,11 @@ if [ -f "$lock" ]; then
96
99
  # on their way into rm -rf paths, and its urls into git argv.
97
100
  assert_valid_pkg_name "$name"
98
101
  rel=".intelligence/packages/$name"
99
- # --frozen never trusts what is already on disk: a pre-existing
100
- # (possibly substituted) store dir would otherwise be synced with no
101
- # sha verification at all.
102
- if [ -d "$IP_ROOT/$rel" ] && [ "$force" -eq 0 ] && [ "$frozen" -eq 0 ]; then
102
+ # A directory counts only with a record of the lock row it satisfies:
103
+ # one present without it may hold any commit. --frozen never trusts
104
+ # what is already on disk: a pre-existing (possibly substituted) store
105
+ # dir would otherwise be synced with no sha verification at all.
106
+ if [ -d "$IP_ROOT/$rel" ] && [ "$force" -eq 0 ] && [ "$frozen" -eq 0 ] && store_record_matches "$IP_ROOT" "$name" "$url" "$path" "$resolved" "$sha"; then
103
107
  continue
104
108
  fi
105
109
  # Fetch into staging and commit to the store only after the integrity
@@ -108,9 +112,11 @@ if [ -f "$lock" ]; then
108
112
  staging="$IP_ROOT/.intelligence/.staging-$$"
109
113
  rm -rf "$staging"
110
114
  fetch_package "$url" "$resolved" "$path" "$staging" "$sha" >/dev/null
115
+ store_record_remove "$IP_ROOT" "$name"
111
116
  rm -rf "${IP_ROOT:?}/$rel"
112
117
  mkdir -p "$(dirname "$IP_ROOT/$rel")"
113
118
  mv "$staging" "$IP_ROOT/$rel"
119
+ store_record_set "$IP_ROOT" "$name" "$url" "$path" "$resolved" "$sha"
114
120
  wire_package_sources "$manifest" "$name" "$rel" "$IP_ROOT"
115
121
  echo "= $name@${resolved:-HEAD}"
116
122
  done < <(lock_to_tsv "$lock")
@@ -15,6 +15,7 @@ case "$action" in enable|disable) ;; *) die "internal target state: expected <en
15
15
  assert_valid_target_name "$name"
16
16
 
17
17
  require_cli_project
18
+ project_lock_hold "$IP_ROOT"
18
19
  manifest="$IP_ROOT/intelligence.yaml"
19
20
  content_dir="$(manifest_intelligence_dir "$manifest")"
20
21
 
@@ -87,6 +87,7 @@ source "$CLI_DIR/lib/adapter-lifecycle.sh"
87
87
  source "$CLI_DIR/lib/gitignore.sh"
88
88
  source "$CLI_DIR/lib/onboarding.sh"
89
89
  source "$CLI_DIR/lib/sync-cache.sh"
90
+ source "$CLI_DIR/lib/sync-lock.sh"
90
91
 
91
92
  # The engine-content package: OPTIONAL but auto-selected at init. Package by
92
93
  # UX (manifest entry, lockfile row, list/search/remove), bundle by mechanics —
@@ -97,12 +98,21 @@ source "$CLI_DIR/lib/sync-cache.sh"
97
98
  # Its identity is DATA shipped with the distribution (cli/engine-package.yaml),
98
99
  # never a name compiled into cli code — a fork edits the file.
99
100
  _EPKG="$CLI_DIR/engine-package.yaml"
100
- SYNC_PKG_NAME="$(top_scalar "$_EPKG" "name")"
101
- SYNC_PKG_URL="$(top_scalar "$_EPKG" "url")"
102
- SYNC_PKG_PATH="$(top_scalar "$_EPKG" "path")"
101
+ SYNC_PKG_NAME="" SYNC_PKG_URL="" SYNC_PKG_PATH=""
103
102
  # Read by commands (init seeds it) — per-file shellcheck cannot see that.
104
103
  # shellcheck disable=SC2034
105
- DEFAULT_REGISTRY_URL="$(top_scalar "$_EPKG" "default_registry")"
104
+ DEFAULT_REGISTRY_URL=""
105
+ # Every command loads this file first: read the four fields in one pass.
106
+ # shellcheck disable=SC2034 # DEFAULT_REGISTRY_URL is read by commands, as above
107
+ while IFS="$LOCK_SEP" read -r _epkg_key _epkg_value; do
108
+ case "$_epkg_key" in
109
+ name) SYNC_PKG_NAME="$_epkg_value" ;;
110
+ url) SYNC_PKG_URL="$_epkg_value" ;;
111
+ path) SYNC_PKG_PATH="$_epkg_value" ;;
112
+ default_registry) DEFAULT_REGISTRY_URL="$_epkg_value" ;;
113
+ esac
114
+ done <<< "$(_qmap_read tops "$_EPKG" '' 'name url path default_registry')"
115
+ unset _epkg_key _epkg_value
106
116
  [ -n "$SYNC_PKG_NAME" ] || die "corrupt CLI installation: $_EPKG is missing or has no 'name'"
107
117
  SYNC_PKG_STORE=".intelligence/packages/$SYNC_PKG_NAME"
108
118
 
@@ -116,15 +126,18 @@ SYNC_PKG_STORE=".intelligence/packages/$SYNC_PKG_NAME"
116
126
  # by role — scripts/sync.sh + scripts/VERSION — never by name.
117
127
  detect_project() {
118
128
  IP_MODE="none"; IP_ROOT=""; IP_UMBRELLA=""; IP_MODULE_DIR=""
119
- local dir="$PWD"
129
+ # PWD is already the logical `pwd` spelling, and so is each textual parent.
130
+ local dir="$PWD" parent
120
131
  while :; do
121
132
  if [ -f "$dir/intelligence.yaml" ]; then
122
133
  IP_MODE="cli"
123
- IP_ROOT="$(cd "$dir" && pwd)"
134
+ IP_ROOT="$dir"
124
135
  return 0
125
136
  fi
126
- [ "$dir" = "$(dirname "$dir")" ] && break
127
- dir="$(dirname "$dir")"
137
+ parent="${dir%/*}"
138
+ [ -n "$parent" ] || parent="/"
139
+ [ "$dir" = "$parent" ] && break
140
+ dir="$parent"
128
141
  done
129
142
  local root
130
143
  root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
@@ -257,10 +270,23 @@ default_target_output() {
257
270
  esac
258
271
  }
259
272
 
273
+ # bundled_engine_version_var sets IS_BUNDLED_ENGINE_VERSION, reading VERSION
274
+ # once per process; lifecycle preflight compares against it several times.
275
+ bundled_engine_version_var() {
276
+ [ -z "${IS_BUNDLED_ENGINE_VERSION_READ:-}" ] || return 0
277
+ local version=""
278
+ [ -r "$IS_ENGINE_DIR/VERSION" ] || {
279
+ echo "ERROR: cannot read $IS_ENGINE_DIR/VERSION" >&2
280
+ return 1
281
+ }
282
+ IFS= read -r -d '' version < "$IS_ENGINE_DIR/VERSION" || true
283
+ IS_BUNDLED_ENGINE_VERSION="${version//[$' \t\r\n']/}"
284
+ IS_BUNDLED_ENGINE_VERSION_READ=1
285
+ }
286
+
260
287
  bundled_engine_version() {
261
- local version
262
- version="$(< "$IS_ENGINE_DIR/VERSION")"
263
- printf '%s' "${version//[$' \t\r\n']/}"
288
+ bundled_engine_version_var
289
+ printf '%s' "$IS_BUNDLED_ENGINE_VERSION"
264
290
  }
265
291
 
266
292
  # --- The sync package's manifest/lock plumbing ---------------------------
@@ -277,9 +303,11 @@ sync_pkg_entry() {
277
303
  sync_pkg_install() {
278
304
  local root="$1" ver sha
279
305
  ver="$(bundled_engine_version)"
306
+ store_record_remove "$root" "$SYNC_PKG_NAME"
280
307
  sha="$(fetch_package "$SYNC_PKG_URL" "v$ver" "$SYNC_PKG_PATH" "$root/$SYNC_PKG_STORE")"
281
308
  wire_package_sources "$root/intelligence.yaml" "$SYNC_PKG_NAME" "$SYNC_PKG_STORE" "$root"
282
309
  lock_upsert "$root/intelligence.lock" "$SYNC_PKG_NAME" "$ver" "$SYNC_PKG_URL" "$SYNC_PKG_PATH" "v$ver" "$sha"
310
+ store_record_set "$root" "$SYNC_PKG_NAME" "$SYNC_PKG_URL" "$SYNC_PKG_PATH" "v$ver" "$sha"
283
311
  echo " engine content installed: $SYNC_PKG_STORE (v$ver)"
284
312
  }
285
313
 
@@ -345,15 +373,19 @@ assert_safe_content_dir() {
345
373
  # the admitted minor/patch gap; the predicate itself does not re-judge the gap.
346
374
  project_stamped_ahead() {
347
375
  local stamp
348
- stamp="$(read_schema_version "$1/intelligence.yaml")"
349
- [ -n "$stamp" ] && _ver_gt "$stamp" "$(bundled_engine_version)"
376
+ read_schema_version_var "$1/intelligence.yaml"
377
+ stamp="$IS_SCHEMA_VERSION"
378
+ bundled_engine_version_var
379
+ [ -n "$stamp" ] && _ver_gt "$stamp" "$IS_BUNDLED_ENGINE_VERSION"
350
380
  }
351
381
 
352
382
  project_needs_upgrade() {
353
- local root="$1" manifest="$1/intelligence.yaml" stamp eng pinned locked name
383
+ local root="$1" manifest="$1/intelligence.yaml" stamp eng pinned locked name url path rows
354
384
  [ -f "$manifest" ] || return 1
355
- stamp="$(read_schema_version "$manifest")"
356
- eng="$(bundled_engine_version)"
385
+ read_schema_version_var "$manifest"
386
+ stamp="$IS_SCHEMA_VERSION"
387
+ bundled_engine_version_var
388
+ eng="$IS_BUNDLED_ENGINE_VERSION"
357
389
  [ -z "$stamp" ] && return 0
358
390
  # A project stamped ahead belongs to a newer CLI. Aligning it here would
359
391
  # restamp the schema and re-pin the engine content DOWNWARD, and the next
@@ -363,14 +395,15 @@ project_needs_upgrade() {
363
395
  _ver_gt "$eng" "$stamp" && return 0
364
396
  [ -d "$root/.intelligence/engine" ] && return 0
365
397
 
366
- while IFS= read -r name; do
398
+ # One manifest pass for every package's fields, not a reader per field.
399
+ rows="$(_qmap_read fieldrows "$manifest" packages '' 'url path version')"
400
+ while IFS="$LOCK_SEP" read -r name url path pinned; do
367
401
  [ -n "$name" ] || continue
368
402
  # Early RC manifests mixed requested intent with resolved source
369
403
  # details. Source URL/path now live only in the required lockfile.
370
- [ -n "$(qmap_field "$manifest" "packages" "$name" "url")" ] && return 0
371
- [ -n "$(qmap_field "$manifest" "packages" "$name" "path")" ] && return 0
404
+ [ -n "$url" ] && return 0
405
+ [ -n "$path" ] && return 0
372
406
  if [ "$name" = "$SYNC_PKG_NAME" ]; then
373
- pinned="$(qmap_field "$manifest" "packages" "$name" "version")"
374
407
  locked="$(qmap_field "$root/intelligence.lock" "packages" "$name" "resolved")"
375
408
  [ "$pinned" = "$eng" ] || return 0
376
409
  if [ -f "$root/intelligence.lock" ]; then
@@ -378,7 +411,7 @@ project_needs_upgrade() {
378
411
  [ "${locked#v}" = "$eng" ] || return 0
379
412
  fi
380
413
  fi
381
- done < <(qmap_keys "$manifest" "packages")
414
+ done <<< "$rows"
382
415
  return 1
383
416
  }
384
417
 
@@ -402,8 +435,10 @@ ensure_project_current() {
402
435
  die "manifest declares packages but intelligence.lock is absent — restore the committed lock before running project lifecycle commands"
403
436
  fi
404
437
  project_needs_upgrade "$root" || return 0
405
- stamp="$(read_schema_version "$manifest")"
406
- eng="$(bundled_engine_version)"
438
+ read_schema_version_var "$manifest"
439
+ stamp="$IS_SCHEMA_VERSION"
440
+ bundled_engine_version_var
441
+ eng="$IS_BUNDLED_ENGINE_VERSION"
407
442
  if is_ci_environment && [ "$explicit" != "--explicit" ]; then
408
443
  die "project lifecycle requires alignment (stamp ${stamp:-unstamped}, engine $eng) — run 'intelligence init --apply' locally, review and commit the diff"
409
444
  fi
@@ -444,6 +479,65 @@ check_project_lock() {
444
479
  validate_project_lock "$1" "$mode"
445
480
  }
446
481
 
482
+ # --- What the store holds (decision 0012) ---------------------------------
483
+ # The store keeps plain files only, so which commit a package directory holds is
484
+ # recorded beside the packages, never inside one (the engine would render it):
485
+ # .intelligence/packages/.installed, one row per package — name, url, path,
486
+ # resolved, sha, separated like the lock's rows — written whenever a package is
487
+ # installed from the lock row it now satisfies. A directory without a row, or
488
+ # with one that differs from the lock, is not what the lock pins.
489
+ STORE_RECORD=".intelligence/packages/.installed"
490
+
491
+ # store_record_get <root> <name> — set IS_REC_URL / _PATH / _RESOLVED / _SHA;
492
+ # returns 1 when the store records nothing for <name>.
493
+ store_record_get() {
494
+ local file="$1/$STORE_RECORD" name url path resolved sha
495
+ IS_REC_URL="" IS_REC_PATH="" IS_REC_RESOLVED="" IS_REC_SHA=""
496
+ [ -f "$file" ] || return 1
497
+ while IFS="$LOCK_SEP" read -r name url path resolved sha; do
498
+ [ "$name" = "$2" ] || continue
499
+ IS_REC_URL="$url" IS_REC_PATH="$path" IS_REC_RESOLVED="$resolved" IS_REC_SHA="$sha"
500
+ return 0
501
+ done < "$file"
502
+ return 1
503
+ }
504
+
505
+ # store_record_matches <root> <name> <url> <path> <resolved> <sha>
506
+ store_record_matches() {
507
+ store_record_get "$1" "$2" || return 1
508
+ [ "$IS_REC_URL" = "$3" ] && [ "$IS_REC_PATH" = "$4" ] \
509
+ && [ "$IS_REC_RESOLVED" = "$5" ] && [ "$IS_REC_SHA" = "$6" ]
510
+ }
511
+
512
+ # store_record_set <root> <name> <url> <path> <resolved> <sha> — record what
513
+ # <name> now holds; store_record_remove <root> <name> forgets it. Both rewrite
514
+ # the file through a temp file, so a reader never sees half a row.
515
+ store_record_set() {
516
+ _store_record_write "$@"
517
+ }
518
+
519
+ store_record_remove() {
520
+ _store_record_write "$1" "$2"
521
+ }
522
+
523
+ _store_record_write() {
524
+ local root="$1" target="$2" file="$1/$STORE_RECORD" tmp name rest
525
+ mkdir -p "$root/.intelligence/packages" || die "cannot create $root/.intelligence/packages"
526
+ tmp="$file.tmp.$$"
527
+ {
528
+ if [ -f "$file" ]; then
529
+ while IFS="$LOCK_SEP" read -r name rest; do
530
+ [ -n "$name" ] && [ "$name" != "$target" ] || continue
531
+ printf '%s%s%s\n' "$name" "$LOCK_SEP" "$rest"
532
+ done < "$file"
533
+ fi
534
+ [ "$#" -lt 6 ] || printf '%s%s%s%s%s%s%s%s%s\n' "$target" "$LOCK_SEP" "$3" "$LOCK_SEP" "$4" "$LOCK_SEP" "$5" "$LOCK_SEP" "$6"
535
+ } > "$tmp" || { rm -f "$tmp"; die "cannot write $file"; }
536
+ mv -f "$tmp" "$file" || { rm -f "$tmp"; die "cannot write $file"; }
537
+ }
538
+
539
+ # project_store_missing <root> — a package the manifest or lock names is absent
540
+ # from the store, or holds something other than its lock row pins.
447
541
  project_store_missing() {
448
542
  local root="$1" manifest="$1/intelligence.yaml" name src
449
543
  while IFS= read -r name; do
@@ -460,6 +554,17 @@ project_store_missing() {
460
554
  esac
461
555
  done < <(read_yaml_list "$manifest" "$section")
462
556
  done
557
+ # Present is not enough: a `git pull` moves the lock and leaves the ignored
558
+ # store on the commit it held before.
559
+ local rows _requested url path resolved sha
560
+ [ -f "$root/intelligence.lock" ] || return 1
561
+ rows="$(lock_to_tsv "$root/intelligence.lock")"
562
+ while IFS="$LOCK_SEP" read -r name _requested url path resolved sha; do
563
+ [ -n "$name" ] || continue
564
+ assert_valid_pkg_name "$name"
565
+ [ -d "$root/.intelligence/packages/$name" ] || return 0
566
+ store_record_matches "$root" "$name" "$url" "$path" "$resolved" "$sha" || return 0
567
+ done <<< "$rows"
463
568
  return 1
464
569
  }
465
570
 
@@ -24,7 +24,8 @@ lock_validate() (
24
24
  # The same tokenizer supplies ordinary lock rows and strict metadata. Buffer
25
25
  # before consumption: a failed parser must never yield a partial package set.
26
26
  rows="$(_qmap_read lock "$lock" packages '' '' "$LOCKFILE_VERSION")" || return $?
27
- current="$(bundled_engine_version)"
27
+ bundled_engine_version_var
28
+ current="$IS_BUNDLED_ENGINE_VERSION"
28
29
  while IFS="$LOCK_SEP" read -r kind name requested url path resolved sha line; do
29
30
  case "$kind" in
30
31
  V)
package/cli/lib/qmap.awk CHANGED
@@ -177,7 +177,17 @@ END {
177
177
  exit 1
178
178
  }
179
179
  if (mode == "lock") print "V" sep top_value["lockfile_version"] sep top_value["engine_version"]
180
- if (mode == "rows" || mode == "lock" || mode == "keys") {
180
+ if (mode == "fieldrows") {
181
+ # One row per package key, as `keys` lists them: the name, then each
182
+ # field QMAP_FIELD names (space separated), as qmap_field decodes it.
183
+ count = split(wanted_field, fields, " ")
184
+ for (i = 1; i <= row_count; i++) {
185
+ name = row_name[i]
186
+ row = name
187
+ for (f = 1; f <= count; f++) row = row sep field_value[name SUBSEP fields[f]]
188
+ print row
189
+ }
190
+ } else if (mode == "rows" || mode == "lock" || mode == "keys") {
181
191
  for (i = 1; i <= row_count; i++) {
182
192
  name = row_name[i]
183
193
  if (mode == "keys") print name
@@ -187,5 +197,10 @@ END {
187
197
  else print row
188
198
  }
189
199
  }
200
+ } else if (mode == "tops") {
201
+ # Several top-level scalars in one pass: QMAP_KEY lists them, space
202
+ # separated; each present one prints as key<US>value.
203
+ count = split(wanted_key, tops, " ")
204
+ for (i = 1; i <= count; i++) if (tops[i] in top_value) print tops[i] sep top_value[tops[i]]
190
205
  } else if (found) print result
191
206
  }