@ainova-systems/intelligence 0.17.1 → 0.17.2

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")"
@@ -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.
@@ -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
 
@@ -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
@@ -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
 
@@ -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
@@ -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 ---------------------------
@@ -345,15 +371,19 @@ assert_safe_content_dir() {
345
371
  # the admitted minor/patch gap; the predicate itself does not re-judge the gap.
346
372
  project_stamped_ahead() {
347
373
  local stamp
348
- stamp="$(read_schema_version "$1/intelligence.yaml")"
349
- [ -n "$stamp" ] && _ver_gt "$stamp" "$(bundled_engine_version)"
374
+ read_schema_version_var "$1/intelligence.yaml"
375
+ stamp="$IS_SCHEMA_VERSION"
376
+ bundled_engine_version_var
377
+ [ -n "$stamp" ] && _ver_gt "$stamp" "$IS_BUNDLED_ENGINE_VERSION"
350
378
  }
351
379
 
352
380
  project_needs_upgrade() {
353
- local root="$1" manifest="$1/intelligence.yaml" stamp eng pinned locked name
381
+ local root="$1" manifest="$1/intelligence.yaml" stamp eng pinned locked name url path rows
354
382
  [ -f "$manifest" ] || return 1
355
- stamp="$(read_schema_version "$manifest")"
356
- eng="$(bundled_engine_version)"
383
+ read_schema_version_var "$manifest"
384
+ stamp="$IS_SCHEMA_VERSION"
385
+ bundled_engine_version_var
386
+ eng="$IS_BUNDLED_ENGINE_VERSION"
357
387
  [ -z "$stamp" ] && return 0
358
388
  # A project stamped ahead belongs to a newer CLI. Aligning it here would
359
389
  # restamp the schema and re-pin the engine content DOWNWARD, and the next
@@ -363,14 +393,15 @@ project_needs_upgrade() {
363
393
  _ver_gt "$eng" "$stamp" && return 0
364
394
  [ -d "$root/.intelligence/engine" ] && return 0
365
395
 
366
- while IFS= read -r name; do
396
+ # One manifest pass for every package's fields, not a reader per field.
397
+ rows="$(_qmap_read fieldrows "$manifest" packages '' 'url path version')"
398
+ while IFS="$LOCK_SEP" read -r name url path pinned; do
367
399
  [ -n "$name" ] || continue
368
400
  # Early RC manifests mixed requested intent with resolved source
369
401
  # 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
402
+ [ -n "$url" ] && return 0
403
+ [ -n "$path" ] && return 0
372
404
  if [ "$name" = "$SYNC_PKG_NAME" ]; then
373
- pinned="$(qmap_field "$manifest" "packages" "$name" "version")"
374
405
  locked="$(qmap_field "$root/intelligence.lock" "packages" "$name" "resolved")"
375
406
  [ "$pinned" = "$eng" ] || return 0
376
407
  if [ -f "$root/intelligence.lock" ]; then
@@ -378,7 +409,7 @@ project_needs_upgrade() {
378
409
  [ "${locked#v}" = "$eng" ] || return 0
379
410
  fi
380
411
  fi
381
- done < <(qmap_keys "$manifest" "packages")
412
+ done <<< "$rows"
382
413
  return 1
383
414
  }
384
415
 
@@ -402,8 +433,10 @@ ensure_project_current() {
402
433
  die "manifest declares packages but intelligence.lock is absent — restore the committed lock before running project lifecycle commands"
403
434
  fi
404
435
  project_needs_upgrade "$root" || return 0
405
- stamp="$(read_schema_version "$manifest")"
406
- eng="$(bundled_engine_version)"
436
+ read_schema_version_var "$manifest"
437
+ stamp="$IS_SCHEMA_VERSION"
438
+ bundled_engine_version_var
439
+ eng="$IS_BUNDLED_ENGINE_VERSION"
407
440
  if is_ci_environment && [ "$explicit" != "--explicit" ]; then
408
441
  die "project lifecycle requires alignment (stamp ${stamp:-unstamped}, engine $eng) — run 'intelligence init --apply' locally, review and commit the diff"
409
442
  fi
@@ -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
  }
@@ -111,14 +111,17 @@ sync_cache_dependencies() {
111
111
  }
112
112
 
113
113
  sync_cache_input_hash() {
114
- local fingerprint
114
+ local fingerprint payload
115
115
  fingerprint="$(sync_cache_fingerprint "$1" "${SC_INPUTS[@]}")" || return 1
116
+ [ -n "${SC_UMASK:-}" ] || SC_UMASK="$(umask)"
116
117
  # Explicit environment values that affect rendering, discovery or parsing.
117
118
  # No secrets or inherited environment dump are persisted.
118
- printf '%s\n' "$fingerprint" "$2" "$REPO_ROOT" "$CONFIG_FILE" \
119
+ printf -v payload '%s\n' "$fingerprint" "$2" "$REPO_ROOT" "$CONFIG_FILE" \
119
120
  "$IS_CONTENT_REL" "$IS_MODULE_REL" "$IS_SYNC_CMD" "$IS_MANIFEST_NAME" \
120
121
  "$IS_PROTECTED_DIRS" "$BASH_VERSION" "${OSTYPE:-}" "${LANG:-}" \
121
- "${LC_ALL:-}" "${LC_CTYPE:-}" "${LC_COLLATE:-}" "${PATH:-}" "$(umask)" | git hash-object --stdin
122
+ "${LC_ALL:-}" "${LC_CTYPE:-}" "${LC_COLLATE:-}" "${PATH:-}" "$SC_UMASK"
123
+ # A here-string appends the final newline the payload already ends with.
124
+ git hash-object --stdin <<< "${payload%$'\n'}"
122
125
  }
123
126
 
124
127
  sync_cache_directory() {
@@ -164,6 +167,8 @@ sync_with_cache() (
164
167
  work="$(mktemp -d -t intelligence-cache-XXXXXX)" || return 1
165
168
  trap 'rm -rf "$work"' EXIT
166
169
  echo "Checking source files..."
170
+ # Read once here so both input hashes, one in a background job, reuse it.
171
+ SC_UMASK="$(umask)"
167
172
  if sync_cache_dependencies && sync_cache_directory; then
168
173
  before="$(sync_cache_input_hash "$work" "$target")" || before=""
169
174
  [ -z "$before" ] || cacheable=1
@@ -197,11 +202,19 @@ sync_with_cache() (
197
202
  if [ "$cacheable" = 1 ]; then
198
203
  (
199
204
  echo "Checking sync results..."
200
- after="$(sync_cache_input_hash "$work" "$target")" || return 0
205
+ # The input recheck and the output fingerprint only read files, so
206
+ # they run side by side, each in its own scratch directory.
207
+ local after_pid after_rc=0
208
+ mkdir "$work/after" "$work/outputs" || return 0
209
+ sync_cache_input_hash "$work/after" "$target" > "$work/after/hash" &
210
+ after_pid=$!
211
+ outputs="$(sync_cache_fingerprint "$work/outputs" "${SC_OUTPUTS[@]}")" || outputs=""
212
+ sync_cache_report "$work/log" > "$work/report" || outputs=""
213
+ report_hash="$(git hash-object --stdin < "$work/report")" || outputs=""
214
+ wait "$after_pid" || after_rc=$?
215
+ [ "$after_rc" = 0 ] && [ -n "$outputs" ] || return 0
216
+ IFS= read -r after < "$work/after/hash" || return 0
201
217
  [ "$before" = "$after" ] || return 0
202
- outputs="$(sync_cache_fingerprint "$work" "${SC_OUTPUTS[@]}")" || return 0
203
- sync_cache_report "$work/log" > "$work/report" || return 0
204
- report_hash="$(git hash-object --stdin < "$work/report")" || return 0
205
218
  # Recheck containment before publishing. mktemp+rename avoids following a
206
219
  # pre-existing state-file link, and readers see only complete records.
207
220
  sync_cache_directory create || return 0
@@ -0,0 +1,148 @@
1
+ #!/bin/bash
2
+ # One writer per project at a time. The engine clears and rewrites every
3
+ # adapter's output, the store restore and package operations replace package
4
+ # directories, and target and alignment edits rewrite the manifest. Two of these
5
+ # overlapping interleave their writes and both report success over a corrupted
6
+ # tree, so every script that writes shared project state holds this lock while it
7
+ # does: `project_lock_hold` at its start.
8
+ #
9
+ # `mkdir` is the lock: it creates or fails atomically on every filesystem the
10
+ # CLI supports. The owner file names the holder — pid, host, start time, process
11
+ # group and a token — so a refusal says who it is, a child of the holder can
12
+ # join instead of refusing, and a holder that died can be told from one whose
13
+ # writers still run. Taking over a dead holder's lock happens under a second
14
+ # lock, so two runs that judged the same lock stale cannot both take it.
15
+
16
+ SYNC_LOCK_HELD=""
17
+ SYNC_LOCK_TOKEN=""
18
+
19
+ sync_lock_now() {
20
+ if [ -n "${EPOCHSECONDS:-}" ]; then
21
+ IS_NOW="$EPOCHSECONDS"
22
+ else
23
+ IS_NOW="$(date +%s)"
24
+ fi
25
+ }
26
+
27
+ # sync_lock_pgid — set IS_PGID to this process's group, "" when unknown. The
28
+ # engine and its jobs inherit the group, so it outlives a killed wrapper for as
29
+ # long as any writer it started still runs.
30
+ sync_lock_pgid() {
31
+ local stat
32
+ local -a fields
33
+ IS_PGID=""
34
+ if [ -r "/proc/$$/stat" ] && IFS= read -r stat < "/proc/$$/stat"; then
35
+ # pid (comm) state ppid pgrp …: comm may hold spaces, so cut after it.
36
+ read -r -a fields <<< "${stat##*) }"
37
+ IS_PGID="${fields[2]:-}"
38
+ else
39
+ IS_PGID="$(ps -o pgid= -p "$$" 2>/dev/null || true)"
40
+ IS_PGID="${IS_PGID//[[:space:]]/}"
41
+ fi
42
+ case "$IS_PGID" in ''|*[!0-9]*|0|1) IS_PGID="" ;; esac
43
+ }
44
+
45
+ sync_lock_write_owner() {
46
+ sync_lock_now
47
+ sync_lock_pgid
48
+ SYNC_LOCK_TOKEN="$$.$IS_NOW.$RANDOM$RANDOM"
49
+ printf '%s %s %s %s %s\n' "$$" "${HOSTNAME:-unknown}" "$IS_NOW" "${IS_PGID:--}" "$SYNC_LOCK_TOKEN" > "$1/owner"
50
+ }
51
+
52
+ # sync_lock_read_owner <lock> — set IS_LOCK_PID / _HOST / _SINCE / _PGID /
53
+ # _TOKEN; returns 1 when the folder has no readable owner yet.
54
+ sync_lock_read_owner() {
55
+ IS_LOCK_PID="" IS_LOCK_HOST="" IS_LOCK_SINCE="" IS_LOCK_PGID="" IS_LOCK_TOKEN=""
56
+ [ -f "$1/owner" ] || return 1
57
+ read -r IS_LOCK_PID IS_LOCK_HOST IS_LOCK_SINCE IS_LOCK_PGID IS_LOCK_TOKEN < "$1/owner" \
58
+ || [ -n "$IS_LOCK_PID" ] || return 1
59
+ case "$IS_LOCK_PID" in ''|*[!0-9]*) return 1 ;; esac
60
+ case "$IS_LOCK_PGID" in ''|*[!0-9]*|0|1) IS_LOCK_PGID="" ;; esac
61
+ }
62
+
63
+ # sync_lock_holder_gone — true only when no process of the holder remains: its
64
+ # whole process group when the owner recorded one, else its pid. Only "No such
65
+ # process" proves that; a permission refusal is another user's live process,
66
+ # and any other answer keeps the lock.
67
+ sync_lock_holder_gone() {
68
+ local target="$IS_LOCK_PID" answer
69
+ [ -z "$IS_LOCK_PGID" ] || target="-$IS_LOCK_PGID"
70
+ answer="$(LC_ALL=C kill -0 -- "$target" 2>&1)" && return 1
71
+ case "$answer" in *"No such process"*) return 0 ;; esac
72
+ return 1
73
+ }
74
+
75
+ # sync_lock_is_stale <lock> — its holder can no longer write: gone from this
76
+ # machine, or a folder that never got an owner and is past a minute old. A
77
+ # holder on another machine (a shared filesystem) is never judged from here.
78
+ sync_lock_is_stale() {
79
+ if sync_lock_read_owner "$1"; then
80
+ [ "$IS_LOCK_HOST" = "${HOSTNAME:-unknown}" ] || return 1
81
+ sync_lock_holder_gone
82
+ return
83
+ fi
84
+ [ -d "$1" ] && [ -n "$(find "$1" -maxdepth 0 -mmin +1 2>/dev/null)" ]
85
+ }
86
+
87
+ # sync_lock_acquire <root> — take <root>/.intelligence/sync.lock or refuse.
88
+ sync_lock_acquire() {
89
+ local root="$1" lock="$1/.intelligence/sync.lock" took=0 age=""
90
+ mkdir -p "$root/.intelligence" || die "cannot create $root/.intelligence"
91
+ if mkdir "$lock" 2>/dev/null; then
92
+ took=1
93
+ elif mkdir "$lock.takeover" 2>/dev/null; then
94
+ # Judge again under the takeover lock: another run may have taken
95
+ # it over since this one looked.
96
+ if sync_lock_is_stale "$lock"; then
97
+ rm -rf "$lock"
98
+ mkdir "$lock" 2>/dev/null && took=1
99
+ fi
100
+ rmdir "$lock.takeover"
101
+ elif [ -n "$(find "$lock.takeover" -maxdepth 0 -mmin +1 2>/dev/null)" ]; then
102
+ # A takeover runs for milliseconds. Removing an abandoned one is itself
103
+ # a check-then-delete race, so it is left to a person.
104
+ die "a takeover of $lock was abandoned; if no intelligence command is running in this project, remove $lock.takeover"
105
+ fi
106
+ if [ "$took" = 1 ]; then
107
+ sync_lock_write_owner "$lock" || { rm -rf "$lock"; die "cannot write $lock/owner"; }
108
+ SYNC_LOCK_HELD="$lock"
109
+ return 0
110
+ fi
111
+ if sync_lock_read_owner "$lock"; then
112
+ sync_lock_now
113
+ case "$IS_LOCK_SINCE" in ''|*[!0-9]*) ;; *) age=", for $((IS_NOW - IS_LOCK_SINCE))s" ;; esac
114
+ die "another intelligence sync is running in this project (pid $IS_LOCK_PID on $IS_LOCK_HOST$age). Wait for it to finish; if no such process exists, remove $lock"
115
+ fi
116
+ die "another intelligence sync is starting in this project. Wait for it to finish; if none is running, remove $lock"
117
+ }
118
+
119
+ # project_lock_hold <root> — hold the project lock for the rest of this script.
120
+ # A script started by the holder joins it instead (INTELLIGENCE_SYNC_LOCK names
121
+ # the lock and its token): the holder releases it, unless `exec` made this
122
+ # process the holder, which then releases it on exit.
123
+ project_lock_hold() {
124
+ local root="$1" lock="$1/.intelligence/sync.lock" held="${INTELLIGENCE_SYNC_LOCK:-}"
125
+ if [ -n "$held" ] && [ "${held%%|*}" = "$lock" ] && sync_lock_read_owner "$lock" \
126
+ && [ -n "$IS_LOCK_TOKEN" ] && [ "$IS_LOCK_TOKEN" = "${held#*|}" ]; then
127
+ if [ "$IS_LOCK_PID" = "$$" ]; then
128
+ SYNC_LOCK_HELD="$lock"
129
+ SYNC_LOCK_TOKEN="$IS_LOCK_TOKEN"
130
+ trap 'sync_lock_release' EXIT
131
+ trap 'exit 130' INT TERM
132
+ fi
133
+ return 0
134
+ fi
135
+ sync_lock_acquire "$root"
136
+ export INTELLIGENCE_SYNC_LOCK="$lock|$SYNC_LOCK_TOKEN"
137
+ trap 'sync_lock_release' EXIT
138
+ trap 'exit 130' INT TERM
139
+ }
140
+
141
+ # sync_lock_release — drop the lock this process holds, and only that one.
142
+ sync_lock_release() {
143
+ [ -n "$SYNC_LOCK_HELD" ] || return 0
144
+ if sync_lock_read_owner "$SYNC_LOCK_HELD" && [ "$IS_LOCK_TOKEN" = "$SYNC_LOCK_TOKEN" ]; then
145
+ rm -rf "$SYNC_LOCK_HELD"
146
+ fi
147
+ SYNC_LOCK_HELD=""
148
+ }
package/engine/ENGINE_SHA CHANGED
@@ -1 +1 @@
1
- 3580413cb402a47d3defc33afbdd190ef23cc430
1
+ 97484ac7ccc2e093642b833edfa8cae7bfe47c9e
package/engine/VERSION CHANGED
@@ -1 +1 @@
1
- 0.17.1
1
+ 0.17.2
@@ -173,9 +173,7 @@ sync_to_claude() {
173
173
  sync_claude_skills "$repo_root" "$config_file" "$output_dir"
174
174
  sync_claude_agents "$repo_root" "$config_file" "$output_dir"
175
175
 
176
- local rules_count skills_count agents_count
177
- rules_count=$(find "$output_dir/rules" -name "*.md" 2>/dev/null | wc -l)
178
- skills_count=$(find "$output_dir/skills" -name "SKILL.md" 2>/dev/null | wc -l)
179
- agents_count=$(find "$output_dir/agents" -name "*.md" 2>/dev/null | wc -l)
180
- echo " -> Rules: $rules_count, Skills: $skills_count, Agents: $agents_count"
176
+ count_matching_files "$output_dir/rules" "*.md" \
177
+ "$output_dir/skills" "SKILL.md" "$output_dir/agents" "*.md"
178
+ echo " -> Rules: ${IS_FILE_COUNTS[0]}, Skills: ${IS_FILE_COUNTS[1]}, Agents: ${IS_FILE_COUNTS[2]}"
181
179
  }
@@ -204,9 +204,7 @@ sync_to_cursor() {
204
204
  sync_cursor_skills "$repo_root" "$config_file" "$output_dir"
205
205
  sync_cursor_agents "$repo_root" "$config_file" "$output_dir"
206
206
 
207
- local rules_count skills_count agents_count
208
- rules_count=$(find "$output_dir/rules" -name "*.mdc" 2>/dev/null | wc -l)
209
- skills_count=$(find "$output_dir/skills" -name "SKILL.md" 2>/dev/null | wc -l)
210
- agents_count=$(find "$output_dir/agents" -name "*.md" 2>/dev/null | wc -l)
211
- echo " -> Rules: $rules_count (.mdc), Skills: $skills_count, Agents: $agents_count"
207
+ count_matching_files "$output_dir/rules" "*.mdc" \
208
+ "$output_dir/skills" "SKILL.md" "$output_dir/agents" "*.md"
209
+ echo " -> Rules: ${IS_FILE_COUNTS[0]} (.mdc), Skills: ${IS_FILE_COUNTS[1]}, Agents: ${IS_FILE_COUNTS[2]}"
212
210
  }