@ainova-systems/intelligence 0.11.7 → 0.11.9

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.
@@ -123,6 +123,7 @@ case "$IP_MODE" in
123
123
  [ "$force" -eq 0 ] || die "--force applies only when converting a legacy Intelligence Sync project"
124
124
  if [ "$preview" -eq 1 ]; then
125
125
  check_version_compat "$IP_ROOT/intelligence.yaml"
126
+ validate_project_lock "$IP_ROOT"
126
127
  echo "project: Intelligence CLI at $IP_ROOT"
127
128
  if project_stamped_ahead "$IP_ROOT"; then
128
129
  echo " project schema $(read_schema_version "$IP_ROOT/intelligence.yaml") is ahead of engine $(bundled_engine_version) — left as is (update the CLI)"
@@ -12,22 +12,32 @@ esac
12
12
  detect_project
13
13
  case "$IP_MODE" in
14
14
  cli)
15
+ lock_valid=1
15
16
  echo "Project: $IP_ROOT (Intelligence project)"
16
17
  echo "Manifest: intelligence.yaml (schema_version $(read_schema_version "$IP_ROOT/intelligence.yaml"))"
17
- if [ -f "$IP_ROOT/intelligence.lock" ]; then
18
+ if ! check_project_lock "$IP_ROOT"; then
19
+ lock_valid=0
20
+ echo "Lockfile: INVALID (locked state unchecked)"
21
+ elif [ -f "$IP_ROOT/intelligence.lock" ]; then
18
22
  echo "Lockfile: intelligence.lock"
19
23
  elif [ -n "$(qmap_keys "$IP_ROOT/intelligence.yaml" "packages")" ]; then
20
24
  echo "Lockfile: MISSING (manifest declares packages)"
21
25
  else
22
26
  echo "Lockfile: none (no packages added yet)"
23
27
  fi
24
- sync_locked="$(qmap_field "$IP_ROOT/intelligence.lock" "packages" "$SYNC_PKG_NAME" "resolved")"
25
- if [ -n "$sync_locked" ]; then
28
+ sync_locked=""
29
+ if [ "$lock_valid" -eq 1 ]; then
30
+ sync_locked="$(qmap_field "$IP_ROOT/intelligence.lock" "packages" "$SYNC_PKG_NAME" "resolved")"
31
+ fi
32
+ if [ "$lock_valid" -eq 0 ]; then
33
+ echo "Content: unchecked"
34
+ elif [ -n "$sync_locked" ]; then
26
35
  echo "Content: $SYNC_PKG_NAME at $sync_locked"
27
36
  else
28
37
  echo "Content: no $SYNC_PKG_NAME (bare setup)"
29
38
  fi
30
39
  echo "Engine: $(bundled_engine_version) (bundled with the CLI)"
40
+ [ "$lock_valid" -eq 1 ] || exit 1
31
41
  ;;
32
42
  legacy)
33
43
  echo "Project: $IP_ROOT (legacy Intelligence Sync)"
@@ -22,6 +22,7 @@ done
22
22
  require_cli_project
23
23
  manifest="$IP_ROOT/intelligence.yaml"
24
24
  check_version_compat "$manifest"
25
+ validate_project_lock "$IP_ROOT"
25
26
  eng="$(bundled_engine_version)"
26
27
  stamp="$(read_schema_version "$manifest")"
27
28
  project_change=0
@@ -31,6 +31,12 @@ esac
31
31
  echo "project: $IP_ROOT"
32
32
  manifest="$IP_ROOT/intelligence.yaml"
33
33
  lock="$IP_ROOT/intelligence.lock"
34
+ lock_valid=1
35
+ if ! check_project_lock "$IP_ROOT"; then
36
+ problems=$((problems + 1))
37
+ lock_valid=0
38
+ note "locked package identities cannot be inspected; continuing independent project checks"
39
+ fi
34
40
 
35
41
  stamp="$(read_schema_version "$manifest")"
36
42
  eng="$(bundled_engine_version)"
@@ -50,7 +56,10 @@ fi
50
56
 
51
57
  # The engine-content package must sit at the bundled engine version — the
52
58
  # content documents exactly the scripts the CLI executes.
53
- sync_locked="$(qmap_field "$lock" "packages" "$SYNC_PKG_NAME" "resolved")"
59
+ sync_locked=""
60
+ if [ "$lock_valid" -eq 1 ]; then
61
+ sync_locked="$(qmap_field "$lock" "packages" "$SYNC_PKG_NAME" "resolved")"
62
+ fi
54
63
  if [ -n "$sync_locked" ]; then
55
64
  if [ "${sync_locked#v}" = "$eng" ]; then
56
65
  ok "$SYNC_PKG_NAME at $sync_locked (matches the engine)"
@@ -70,6 +79,10 @@ fi
70
79
  while IFS= read -r name; do
71
80
  [ -n "$name" ] || continue
72
81
  assert_valid_pkg_name "$name"
82
+ if [ "$lock_valid" -eq 0 ]; then
83
+ note "$name: locked state unchecked"
84
+ continue
85
+ fi
73
86
  if [ -z "$(qmap_field "$lock" "packages" "$name" "url")" ]; then
74
87
  warn "$name is in the manifest but not in intelligence.lock — run 'intelligence init'"
75
88
  else
@@ -143,7 +156,7 @@ while IFS= read -r target; do
143
156
  done < <(target_names "$manifest")
144
157
 
145
158
  # Lock orphans.
146
- if [ -f "$lock" ]; then
159
+ if [ "$lock_valid" -eq 1 ] && [ -f "$lock" ]; then
147
160
  while IFS= read -r name; do
148
161
  [ -n "$name" ] || continue
149
162
  found=0
@@ -165,7 +178,13 @@ for section in rules agents skills; do
165
178
  if [ ! -d "$IP_ROOT/$src" ]; then
166
179
  case "$src" in
167
180
  # Store content is restorable state: absent means un-installed.
168
- .intelligence/*) warn "sources.$section '$src' missing — run 'intelligence sync'" ;;
181
+ .intelligence/*)
182
+ if [ "$lock_valid" -eq 1 ]; then
183
+ warn "sources.$section '$src' missing — run 'intelligence sync'"
184
+ else
185
+ warn "sources.$section '$src' missing — resolve the lock problem first"
186
+ fi
187
+ ;;
169
188
  # A project-owned dir that does not exist yet is a legitimate
170
189
  # state — a project consuming only packages authors nothing of
171
190
  # its own — and the engine simply skips it. Declared up front so
@@ -183,7 +183,9 @@ while IFS="$LOCK_SEP" read -r pack name url ref mirror; do
183
183
  mkdir -p "$dest"
184
184
  cp -R "$root/$mirror/." "$dest/"
185
185
  if [ -f "$dest/.pack" ]; then
186
- sha="$(awk -F= '$1 == "sha" {print $2; exit}' "$dest/.pack")"
186
+ sha="$(awk -F= '{ sub(/\r$/, "") } $1 == "sha" {print $2; exit}' "$dest/.pack")"
187
+ [[ "$sha" =~ ^[0-9a-f]{40}$ || "$sha" =~ ^[0-9a-f]{64}$ ]] \
188
+ || die "pack '$pack' mirror '$mirror' has a missing or invalid recorded commit SHA in .pack — legacy project unchanged; restore its trusted ownership stamp from history or refresh the mirror with its archived engine, then rerun 'intelligence init'"
187
189
  rm -f "$dest/.pack"
188
190
  fi
189
191
  echo " pack '$pack' -> $name (from mirror $mirror)"
@@ -191,9 +193,8 @@ while IFS="$LOCK_SEP" read -r pack name url ref mirror; do
191
193
  sha="$(fetch_package "$url" "$ref" "" "$dest")"
192
194
  echo " pack '$pack' -> $name (fetched)"
193
195
  fi
194
- if [ -z "$sha" ] || [ "$sha" = "unknown" ]; then
195
- sha="$(GIT_TERMINAL_PROMPT=0 git ls-remote -- "$url" "$ref" 2>/dev/null | awk '{print $1; exit}')"
196
- fi
196
+ # A ref currently advertised by the remote cannot prove which commit
197
+ # produced an existing mirror. Only its recorded identity can do that.
197
198
  [ -n "$ref" ] || ref="HEAD"
198
199
  printf '%s\037%s\037%s\037%s\037%s\037%s\n' \
199
200
  "$name" "" "$url" "" "$ref" "$sha" >> "$lock_rows"
@@ -6,6 +6,8 @@ source "$CLI_DIR/lib/cli-common.sh"
6
6
  require_cli_project
7
7
  manifest="$IP_ROOT/intelligence.yaml"
8
8
  lock="$IP_ROOT/intelligence.lock"
9
+ lock_valid=1
10
+ if ! check_project_lock "$IP_ROOT"; then lock_valid=0; fi
9
11
 
10
12
  count=0
11
13
  while IFS= read -r name; do
@@ -13,9 +15,14 @@ while IFS= read -r name; do
13
15
  count=$((count + 1))
14
16
  range="$(qmap_field "$manifest" "packages" "$name" "version")"
15
17
  ref="$(qmap_field "$manifest" "packages" "$name" "ref")"
16
- resolved="$(qmap_field "$lock" "packages" "$name" "resolved")"
17
- sha="$(qmap_field "$lock" "packages" "$name" "sha")"
18
- if [ ! -f "$lock" ]; then
18
+ resolved="" sha=""
19
+ if [ "$lock_valid" -eq 1 ]; then
20
+ resolved="$(qmap_field "$lock" "packages" "$name" "resolved")"
21
+ sha="$(qmap_field "$lock" "packages" "$name" "sha")"
22
+ fi
23
+ if [ "$lock_valid" -eq 0 ]; then
24
+ state="invalid lock — locked state unchecked"
25
+ elif [ ! -f "$lock" ]; then
19
26
  state="lock missing — restore committed intelligence.lock"
20
27
  elif [ -d "$IP_ROOT/.intelligence/packages/$name" ]; then
21
28
  state="installed"
@@ -30,5 +37,8 @@ while IFS= read -r name; do
30
37
  done < <(qmap_keys "$manifest" "packages")
31
38
 
32
39
  if [ "$count" -eq 0 ]; then
33
- echo "No packages. Add one: intelligence package add @ainova-systems/core"
40
+ if [ "$lock_valid" -eq 1 ]; then
41
+ echo "No packages. Add one: intelligence package add @ainova-systems/core"
42
+ fi
34
43
  fi
44
+ [ "$lock_valid" -eq 1 ] || exit 1
@@ -2,8 +2,8 @@
2
2
  # Internal locked-store restore operation.
3
3
  #
4
4
  # Restore .intelligence/ from intelligence.lock — the fresh-clone command.
5
- # --frozen additionally refuses any manifest/lock divergence (CI mode) and
6
- # fails on a sha mismatch instead of re-resolving.
5
+ # --frozen additionally refuses manifest/lock divergence (CI mode).
6
+ # Locked packages always restore their recorded commit, never the ref's new HEAD.
7
7
  set -euo pipefail
8
8
  source "$CLI_DIR/lib/cli-common.sh"
9
9
 
@@ -21,6 +21,7 @@ done
21
21
  require_cli_project
22
22
  manifest="$IP_ROOT/intelligence.yaml"
23
23
  lock="$IP_ROOT/intelligence.lock"
24
+ validate_project_lock "$IP_ROOT" --restore
24
25
 
25
26
  # --frozen is the reproducibility contract: the lock must agree with the
26
27
  # manifest on the package SET and on every requested-intent field before a
@@ -89,7 +90,7 @@ fi
89
90
 
90
91
  # Restore every locked package that is not already in the store.
91
92
  if [ -f "$lock" ]; then
92
- while IFS="$LOCK_SEP" read -r name requested url path resolved sha; do
93
+ while IFS="$LOCK_SEP" read -r name _requested url path resolved sha; do
93
94
  [ -n "$name" ] || continue
94
95
  # The lock arrives with a cloned repo — its keys are untrusted input
95
96
  # on their way into rm -rf paths, and its urls into git argv.
@@ -106,17 +107,7 @@ if [ -f "$lock" ]; then
106
107
  # re-run (whose dir-exists check skips verification) would sync it.
107
108
  staging="$IP_ROOT/.intelligence/.staging-$$"
108
109
  rm -rf "$staging"
109
- got="$(fetch_package "$url" "$resolved" "$path" "$staging")"
110
- # An empty sha (bundle-seeded content on a dev build) means unknown,
111
- # not moved — only two known-and-different shas are a finding.
112
- if [ -n "$sha" ] && [ -n "$got" ] && [ "$got" != "$sha" ]; then
113
- if [ "$frozen" -eq 1 ]; then
114
- rm -rf "$staging"
115
- die "$name: fetched $got but the lock pins $sha — the ref moved; refusing under --frozen"
116
- fi
117
- echo " WARN: $name resolved to $got, lock had $sha — updating the lock" >&2
118
- lock_upsert "$lock" "$name" "$requested" "$url" "$path" "$resolved" "$got"
119
- fi
110
+ fetch_package "$url" "$resolved" "$path" "$staging" "$sha" >/dev/null
120
111
  rm -rf "${IP_ROOT:?}/$rel"
121
112
  mkdir -p "$(dirname "$IP_ROOT/$rel")"
122
113
  mv "$staging" "$IP_ROOT/$rel"
@@ -14,7 +14,7 @@ source "$IS_ENGINE_DIR/lib/contract.sh"
14
14
  source "$IS_ENGINE_DIR/lib/adapter-contract.sh"
15
15
 
16
16
  die() {
17
- echo "ERROR: $*" >&2
17
+ echo "ERROR: ${CLI_INPUT_CONTEXT:-}$*" >&2
18
18
  exit 1
19
19
  }
20
20
 
@@ -30,7 +30,7 @@ assert_safe_source_url() {
30
30
  local url="$1"
31
31
  case "$url" in
32
32
  ""|-*) die "unsafe source url '$url' — option-shaped or empty" ;;
33
- *[\"\'\ ]*) die "unsafe source url '$url' — quotes or spaces" ;;
33
+ *[\"\'\ ]*|*[[:cntrl:]]*) die "unsafe source url '$url' — quotes or whitespace" ;;
34
34
  https://*|http://*|ssh://*|git://*|file://*) ;;
35
35
  *@*:*) ;;
36
36
  *) die "unsafe source url '$url' — allowed: https, http, ssh, git, file, or user@host:path" ;;
@@ -43,7 +43,17 @@ assert_safe_ref() {
43
43
  [ -z "$ref" ] && return 0
44
44
  case "$ref" in
45
45
  -*) die "unsafe git ref '$ref' — option-shaped" ;;
46
- *[\"\'\ \\]*) die "unsafe git ref '$ref'" ;;
46
+ *[\"\'\ \\]*|*[[:cntrl:]]*) die "unsafe git ref '$ref'" ;;
47
+ esac
48
+ }
49
+
50
+ # Lexical package-subdirectory validation shared by acquisition and lock
51
+ # preflight. Physical containment of acquired content is a separate boundary.
52
+ assert_safe_package_path() {
53
+ # Preserve literal Git paths, but also guard Windows separator semantics.
54
+ local normalized="${1//\\//}"
55
+ case "$normalized" in
56
+ *..*|/*|[A-Za-z]:*|*[\"\']*|*[[:cntrl:]]*) die "unsafe path '$1'" ;;
47
57
  esac
48
58
  }
49
59
 
@@ -160,7 +170,9 @@ default_target_output() {
160
170
  }
161
171
 
162
172
  bundled_engine_version() {
163
- tr -d ' \t\r\n' < "$IS_ENGINE_DIR/VERSION"
173
+ local version
174
+ version="$(< "$IS_ENGINE_DIR/VERSION")"
175
+ printf '%s' "${version//[$' \t\r\n']/}"
164
176
  }
165
177
 
166
178
  # --- The sync package's manifest/lock plumbing ---------------------------
@@ -297,6 +309,7 @@ project_has_packages() {
297
309
  ensure_project_current() {
298
310
  local root="$1" explicit="${2:-}" manifest="$1/intelligence.yaml" stamp eng
299
311
  check_version_compat "$manifest" || return $?
312
+ validate_project_lock "$root" || return $?
300
313
  if project_has_packages "$root" && [ ! -f "$root/intelligence.lock" ]; then
301
314
  die "manifest declares packages but intelligence.lock is absent — restore the committed lock before running project lifecycle commands"
302
315
  fi
@@ -310,6 +323,39 @@ ensure_project_current() {
310
323
  bash "$CLI_DIR/internal/align-project.sh" --no-sync
311
324
  }
312
325
 
326
+ # A present lock is validated even when there is no missing store to restore.
327
+ # Keep this before alignment: repairing the sync pin must not rewrite bad input.
328
+ validate_project_lock() {
329
+ local lock="$1/intelligence.lock" output line rc=0
330
+ if [ -e "$lock" ] || [ -L "$lock" ]; then
331
+ output="$(lock_validate "$lock" "${2---metadata}" "$1/intelligence.yaml" 2>&1)" || rc=$?
332
+ while IFS= read -r line; do
333
+ [ -n "$line" ] || continue
334
+ case "$line" in
335
+ WARNING:*)
336
+ case "${IS_LOCK_WARNINGS:-}" in *"$line"*) continue ;; esac
337
+ # Suppress only duplicate messages across lifecycle children;
338
+ # the lock itself is always parsed and validated again.
339
+ export IS_LOCK_WARNINGS="${IS_LOCK_WARNINGS:-}$line"$'\n'
340
+ ;;
341
+ esac
342
+ echo "$line" >&2
343
+ done <<< "$output"
344
+ if [ "$rc" -ne 0 ]; then
345
+ echo "ERROR: invalid intelligence.lock — recovery: https://github.com/ainova-systems/intelligence/blob/main/docs/cli.md#recovering-a-lock" >&2
346
+ return 1
347
+ fi
348
+ fi
349
+ }
350
+
351
+ # Read-only diagnosis must distinguish usable installed metadata from metadata
352
+ # which could actually restore a missing store.
353
+ check_project_lock() {
354
+ local mode=--metadata
355
+ if project_store_missing "$1"; then mode=--restore; fi
356
+ validate_project_lock "$1" "$mode"
357
+ }
358
+
313
359
  project_store_missing() {
314
360
  local root="$1" manifest="$1/intelligence.yaml" name src
315
361
  while IFS= read -r name; do
@@ -10,38 +10,74 @@
10
10
  LOCKFILE_VERSION=1
11
11
  LOCK_SEP=$'\x1f'
12
12
 
13
- # top_scalar <file> <key> — one top-level scalar (`key: "value"`), quotes and
14
- # trailing comment stripped. The declared exception to "no new parsing": the
15
- # engine's read_schema_version is hardwired to its own key.
13
+ # lock_validate <lock> [--metadata|--restore] [manifest] — read-only preflight. A subshell
14
+ # contains the fail-fast input guards so status can report a failed verdict too.
15
+ lock_validate() (
16
+ local lock="$1" mode="${2---metadata}" manifest="${3:-}" count=0
17
+ local kind name requested url path resolved sha line rows current
18
+ local -x CLI_INPUT_CONTEXT=""
19
+ [ "$#" -le 3 ] || die "internal: too many lock validation arguments"
20
+ case "$mode" in
21
+ --metadata|--restore) ;;
22
+ *) die "internal: unknown lock validation mode '$mode'" ;;
23
+ esac
24
+ # The same tokenizer supplies ordinary lock rows and strict metadata. Buffer
25
+ # before consumption: a failed parser must never yield a partial package set.
26
+ rows="$(_qmap_read lock "$lock" packages '' '' "$LOCKFILE_VERSION")" || return $?
27
+ current="$(bundled_engine_version)"
28
+ while IFS="$LOCK_SEP" read -r kind name requested url path resolved sha line; do
29
+ case "$kind" in
30
+ V)
31
+ [[ "$requested" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
32
+ || die "$lock has missing or invalid engine_version"
33
+ continue
34
+ ;;
35
+ R) ;;
36
+ *) die "internal: unexpected lock record '$kind'" ;;
37
+ esac
38
+ count=$((count + 1))
39
+ CLI_INPUT_CONTEXT="$lock:$line: package $name, field name: "
40
+ assert_valid_pkg_name "$name"
41
+ CLI_INPUT_CONTEXT="$lock:$line: package $name, field url: "
42
+ assert_safe_source_url "$url"
43
+ CLI_INPUT_CONTEXT="$lock:$line: package $name, field resolved: "
44
+ [ -n "$resolved" ] || die "locked restore: $name has a missing resolved ref"
45
+ assert_safe_ref "$resolved"
46
+ CLI_INPUT_CONTEXT="$lock:$line: package $name, field path: "
47
+ assert_safe_package_path "$path"
48
+ CLI_INPUT_CONTEXT="$lock:$line: package $name, field sha: "
49
+ if [ -z "$sha" ]; then
50
+ if [ "$url" = "$SYNC_PKG_URL" ] && [ "$path" = "$SYNC_PKG_PATH" ] \
51
+ && [ "$resolved" = "v$current" ]; then
52
+ continue
53
+ fi
54
+ # engine_version records the writer CLI, which can differ from the
55
+ # preserved bundle pin after a same-major older CLI adds a package.
56
+ # Versioned bundle metadata admits alignment/installed content only;
57
+ # it must never authorize an unpinned cross-version acquisition.
58
+ if [ "$mode" = "--metadata" ] && [ "$url" = "$SYNC_PKG_URL" ] \
59
+ && [ "$path" = "$SYNC_PKG_PATH" ] && [[ "$resolved" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] \
60
+ && [ "${resolved%%.*}" = "v${current%%.*}" ]; then
61
+ echo "WARNING: $name at $resolved has no locked commit SHA; commit verification is unavailable for this development-bundle metadata" >&2
62
+ continue
63
+ fi
64
+ fi
65
+ [[ "$sha" =~ ^[0-9a-f]{40}$ || "$sha" =~ ^[0-9a-f]{64}$ ]] \
66
+ || die "locked restore: $name has a missing or invalid commit SHA — restore a valid intelligence.lock"
67
+ done <<< "$rows"
68
+ if [ "$count" -eq 0 ] && [ -n "$manifest" ] && [ -n "$(qmap_keys "$manifest" packages)" ]; then
69
+ die "$lock: packages is empty but the manifest declares packages — restore their recorded identities"
70
+ fi
71
+ )
72
+
73
+ # top_scalar <file> <key> — one top-level scalar through the shared tokenizer.
16
74
  top_scalar() {
17
- [ -f "$1" ] || return 0
18
- awk -v key="$2" '
19
- { sub(/\r$/, "") }
20
- $0 ~ "^" key ":" {
21
- v = substr($0, length(key) + 2)
22
- sub(/^[ \t]+/, "", v)
23
- if (v ~ /^"/) { v = substr(v, 2); q = index(v, "\""); if (q > 0) v = substr(v, 1, q - 1) }
24
- else { sub(/[ \t]+#.*$/, "", v); sub(/[ \t]+$/, "", v) }
25
- print v
26
- exit
27
- }
28
- ' "$1"
75
+ _qmap_read top "$1" '' "$2"
29
76
  }
30
77
 
31
78
  # lock_to_tsv <lock> — every locked package as one row.
32
79
  lock_to_tsv() {
33
- local lock="$1" name
34
- [ -f "$lock" ] || return 0
35
- while IFS= read -r name; do
36
- [ -n "$name" ] || continue
37
- printf '%s\037%s\037%s\037%s\037%s\037%s\n' \
38
- "$name" \
39
- "$(qmap_field "$lock" "packages" "$name" "requested")" \
40
- "$(qmap_field "$lock" "packages" "$name" "url")" \
41
- "$(qmap_field "$lock" "packages" "$name" "path")" \
42
- "$(qmap_field "$lock" "packages" "$name" "resolved")" \
43
- "$(qmap_field "$lock" "packages" "$name" "sha")"
44
- done < <(qmap_keys "$lock" "packages")
80
+ _qmap_read rows "$1" packages
45
81
  }
46
82
 
47
83
  # lock_write_from_tsv <lock> <tsv-file> — rebuild the lock. Entries are
@@ -58,7 +94,15 @@ lock_write_from_tsv() {
58
94
  # them (registry-supplied url/path included) must be escaped, or it
59
95
  # would break or alias the neighbouring field.
60
96
  awk -F"$LOCK_SEP" '
61
- function esc(s) { gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s); return s }
97
+ function esc(s, i, c, out) {
98
+ out = ""
99
+ for (i = 1; i <= length(s); i++) {
100
+ c = substr(s, i, 1)
101
+ if (c == "\\" || c == "\"") out = out "\\"
102
+ out = out c
103
+ }
104
+ return out
105
+ }
62
106
  NF >= 6 {
63
107
  print " \"" esc($1) "\":"
64
108
  if ($2 != "") print " requested: \"" esc($2) "\""
@@ -87,7 +87,8 @@ target_set_enabled() {
87
87
  case "$enabled" in true|false) ;; *) die "internal: invalid target state '$enabled'" ;; esac
88
88
  target_exists "$file" "$target" && target_missing=0
89
89
  output="$(_yq_esc "$output")"
90
- _qmap_stage "$file" -v target="$target" -v enabled="$enabled" -v output="$output" -v target_missing="$target_missing" '
90
+ QMAP_EDIT_VALUE="$output" _qmap_stage "$file" -v target="$target" -v enabled="$enabled" -v target_missing="$target_missing" '
91
+ BEGIN { output = ENVIRON["QMAP_EDIT_VALUE"] }
91
92
  function entry() { return " " target ": { enabled: " enabled ", output: \"" output "\" }" }
92
93
  function insert_missing_target() {
93
94
  if (in_targets && target_missing && !target_seen) { print entry(); target_seen = 1 }
@@ -154,71 +155,33 @@ target_set_enabled() {
154
155
  '
155
156
  }
156
157
 
157
- # qmap_keys <file> <block> — quoted keys, one per line.
158
- qmap_keys() {
159
- [ -f "$1" ] || return 0
160
- awk -v block="$2" '
161
- { sub(/\r$/, "") }
162
- $0 ~ "^" block ":[ \t]*$" { inb = 1; next }
163
- inb && /^[^ #]/ { inb = 0 }
164
- inb {
165
- if ($0 ~ /^ "/) {
166
- s = substr($0, 4)
167
- q = index(s, "\"")
168
- if (q > 0) print substr(s, 1, q - 1)
169
- }
170
- }
171
- ' "$1"
158
+ # _qmap_read <mode> <file> <block> [key] [field] [expected-lock-version]
159
+ # All scalar reads and strict lock records share one tokenizer. Environment
160
+ # transport preserves literal backslashes that awk -v would interpret again.
161
+ _qmap_read() {
162
+ local mode="$1" file="$2"
163
+ if [ ! -f "$file" ] || [ ! -r "$file" ]; then
164
+ case "$mode" in
165
+ lock|validate) echo "cannot read $file" >&2; return 1 ;;
166
+ *) return 0 ;;
167
+ esac
168
+ fi
169
+ QMAP_MODE="$mode" QMAP_FILE="$file" QMAP_BLOCK="${3:-}" \
170
+ QMAP_KEY="${4:-}" QMAP_FIELD="${5:-}" QMAP_EXPECTED_VERSION="${6:-}" \
171
+ LC_ALL=C awk -f "${BASH_SOURCE[0]%/*}/qmap.awk" < "$file"
172
172
  }
173
173
 
174
- # qmap_field <file> <block> <key> <field> — 4-indent field value under a
175
- # quoted key; strips surrounding quotes and a trailing unquoted comment.
176
- qmap_field() {
177
- [ -f "$1" ] || return 0
178
- awk -v block="$2" -v key="$3" -v field="$4" '
179
- { sub(/\r$/, "") }
180
- $0 ~ "^" block ":[ \t]*$" { inb = 1; next }
181
- inb && /^[^ #]/ { inb = 0; ink = 0 }
182
- inb && /^ "/ {
183
- s = substr($0, 4); q = index(s, "\"")
184
- ink = (q > 0 && substr(s, 1, q - 1) == key)
185
- next
186
- }
187
- inb && ink && /^ [A-Za-z_]/ {
188
- line = $0
189
- sub(/^ /, "", line)
190
- c = index(line, ":")
191
- if (c == 0) next
192
- if (substr(line, 1, c - 1) != field) next
193
- v = substr(line, c + 1)
194
- sub(/^[ \t]+/, "", v)
195
- if (v ~ /^"/) { v = substr(v, 2); q2 = index(v, "\""); if (q2 > 0) v = substr(v, 1, q2 - 1) }
196
- else { sub(/[ \t]+#.*$/, "", v); sub(/[ \t]+$/, "", v) }
197
- print v
198
- exit
199
- }
200
- ' "$1"
201
- }
174
+ # qmap_validate_document <file> <block> — structural validation only.
175
+ qmap_validate_document() { _qmap_read validate "$1" "$2"; }
202
176
 
203
- # qmap_value <file> <block> <key> — flat form: ` "key": "value"`.
204
- qmap_value() {
205
- [ -f "$1" ] || return 0
206
- awk -v block="$2" -v key="$3" '
207
- { sub(/\r$/, "") }
208
- $0 ~ "^" block ":[ \t]*$" { inb = 1; next }
209
- inb && /^[^ #]/ { inb = 0 }
210
- inb && /^ "/ {
211
- s = substr($0, 4); q = index(s, "\"")
212
- if (q == 0 || substr(s, 1, q - 1) != key) next
213
- v = substr(s, q + 1)
214
- sub(/^:[ \t]*/, "", v)
215
- if (v ~ /^"/) { v = substr(v, 2); q2 = index(v, "\""); if (q2 > 0) v = substr(v, 1, q2 - 1) }
216
- else { sub(/[ \t]+#.*$/, "", v); sub(/[ \t]+$/, "", v) }
217
- if (v != "") print v
218
- exit
219
- }
220
- ' "$1"
221
- }
177
+ # qmap_keys <file> <block> — quoted keys in document order.
178
+ qmap_keys() { _qmap_read keys "$1" "$2"; }
179
+
180
+ # qmap_field <file> <block> <key> <field> — decoded nested scalar.
181
+ qmap_field() { _qmap_read field "$1" "$2" "$3" "$4"; }
182
+
183
+ # qmap_value <file> <block> <key> — decoded flat scalar.
184
+ qmap_value() { _qmap_read value "$1" "$2" "$3"; }
222
185
 
223
186
  # _qmap_stage <file> <awk-program> [awk args…] — run an editing pass, verify
224
187
  # it produced output, commit.
@@ -245,7 +208,8 @@ qmap_set() {
245
208
  # url/path values can carry a `"`; escape before it reaches the quoted
246
209
  # scalar the writer emits.
247
210
  value="$(_yq_esc "$value")"
248
- _qmap_stage "$file" -v block="$block" -v key="$key" -v field="$field" -v value="$value" '
211
+ QMAP_EDIT_VALUE="$value" _qmap_stage "$file" -v block="$block" -v key="$key" -v field="$field" '
212
+ BEGIN { value = ENVIRON["QMAP_EDIT_VALUE"] }
249
213
  function keyline() { return " \"" key "\":" }
250
214
  function fieldline() { return " " field ": \"" value "\"" }
251
215
  function flush_key() {
@@ -292,7 +256,8 @@ qmap_set_value() {
292
256
  local file="$1" block="$2" key="$3" value="$4"
293
257
  [ -f "$file" ] || die "no such file: $file"
294
258
  value="$(_yq_esc "$value")"
295
- _qmap_stage "$file" -v block="$block" -v key="$key" -v value="$value" '
259
+ QMAP_EDIT_VALUE="$value" _qmap_stage "$file" -v block="$block" -v key="$key" '
260
+ BEGIN { value = ENVIRON["QMAP_EDIT_VALUE"] }
296
261
  function entry() { return " \"" key "\": \"" value "\"" }
297
262
  { sub(/\r$/, "") }
298
263
  $0 ~ "^" block ":[ \t]*$" { blockseen = 1; inb = 1; print; next }
@@ -0,0 +1,191 @@
1
+ # Shared tokenizer for the CLI-owned quoted-key map and scalar document shape.
2
+ # Reader modes ignore unrelated YAML. Lock mode validates the whole document
3
+ # before publishing rows, so a bad later entry never yields a partial result.
4
+ BEGIN {
5
+ mode = ENVIRON["QMAP_MODE"]
6
+ block = ENVIRON["QMAP_BLOCK"]
7
+ wanted_key = ENVIRON["QMAP_KEY"]
8
+ wanted_field = ENVIRON["QMAP_FIELD"]
9
+ expected_version = ENVIRON["QMAP_EXPECTED_VERSION"]
10
+ source_file = ENVIRON["QMAP_FILE"]
11
+ strict = (mode == "lock" || mode == "validate")
12
+ sep = sprintf("%c", 31)
13
+ single_quote = sprintf("%c", 39)
14
+ }
15
+
16
+ function error_at(reason, line) {
17
+ if (problem == "") {
18
+ problem = reason
19
+ problem_line = line
20
+ }
21
+ }
22
+
23
+ # quoted() also parses keys. quote_end leaves the caller at the delimiter;
24
+ # decoded contains literal bytes, with the writer's two escapes decoded once.
25
+ function quoted(s, i, c, escaped) {
26
+ decoded = ""
27
+ quote_end = 0
28
+ for (i = 2; i <= length(s); i++) {
29
+ c = substr(s, i, 1)
30
+ if (c == "\"") {
31
+ quote_end = i
32
+ return 1
33
+ }
34
+ if (c == "\\") {
35
+ if (++i > length(s)) return 0
36
+ escaped = substr(s, i, 1)
37
+ if (escaped == "\\" || escaped == "\"") c = escaped
38
+ else {
39
+ if (strict) return 0
40
+ c = "\\" escaped
41
+ }
42
+ }
43
+ if (strict && c ~ /[[:cntrl:]]/) return 0
44
+ decoded = decoded c
45
+ }
46
+ return 0
47
+ }
48
+
49
+ function scalar(s, tail) {
50
+ decoded = ""
51
+ empty_scalar = 0
52
+ sub(/^[ \t]+/, "", s)
53
+ if (s == "" || s ~ /^#/) {
54
+ empty_scalar = 1
55
+ return 1
56
+ }
57
+ if (s ~ /^"/) {
58
+ if (!quoted(s)) return 0
59
+ tail = substr(s, quote_end + 1)
60
+ return !strict || tail ~ /^([ \t]*|[ \t]+#.*)$/
61
+ }
62
+ sub(/[ \t]+#.*$/, "", s)
63
+ sub(/[ \t]+$/, "", s)
64
+ if (strict) {
65
+ if (s ~ /[[:cntrl:]]/ || substr(s, 1, 1) == single_quote) return 0
66
+ if (s ~ /^[!&*\[\]{}>|%@`]/ || s ~ /^[?:-]([ \t]|$)/ || s ~ /"|:[ \t]/) return 0
67
+ if (s ~ /^(null|Null|NULL|~|true|True|TRUE|false|False|FALSE)$/) return 0
68
+ }
69
+ decoded = s
70
+ return 1
71
+ }
72
+
73
+ # Tokenization is shared by permissive reads and strict lock validation.
74
+ function token(line, text, colon, tail) {
75
+ kind = ""
76
+ key = ""
77
+ value = ""
78
+ empty = 0
79
+ if (line ~ /^[A-Za-z_]/) {
80
+ kind = "top"
81
+ text = line
82
+ } else if (line ~ /^ [A-Za-z_]/) {
83
+ kind = "field"
84
+ text = substr(line, 5)
85
+ } else if (line ~ /^ "/) {
86
+ kind = "package"
87
+ text = substr(line, 3)
88
+ if (!quoted(text)) return 0
89
+ key = decoded
90
+ tail = substr(text, quote_end + 1)
91
+ if (substr(tail, 1, 1) != ":") return 0
92
+ if (!scalar(substr(tail, 2))) return 0
93
+ value = decoded
94
+ empty = empty_scalar
95
+ return 1
96
+ } else return 0
97
+ colon = index(text, ":")
98
+ if (!colon) return 0
99
+ key = substr(text, 1, colon - 1)
100
+ if (strict && key !~ /^[A-Za-z_][A-Za-z0-9_]*$/) return 0
101
+ if (!scalar(substr(text, colon + 1))) return 0
102
+ value = decoded
103
+ empty = empty_scalar
104
+ return 1
105
+ }
106
+
107
+ {
108
+ sub(/\r$/, "")
109
+ if ($0 ~ /^[ \t]*(#.*)?$/) next
110
+ if (!token($0)) {
111
+ if (strict) {
112
+ context = ""
113
+ if (key ~ /^[A-Za-z_][A-Za-z0-9_]*$/) {
114
+ if (kind == "field" && package != "") context = "package " package ", field " key ": "
115
+ else if (kind == "top") context = "field " key ": "
116
+ }
117
+ error_at(context "malformed lock structure or unsupported scalar", NR)
118
+ }
119
+ if ($0 ~ /^[^ #\t]/) { in_block = 0; package = "" }
120
+ next
121
+ }
122
+ if (kind == "top") {
123
+ in_block = (key == block && empty)
124
+ package = ""
125
+ if (strict && top_seen[key]++) error_at("duplicate top-level field " key, NR)
126
+ if (!(key in top_value)) top_value[key] = value
127
+ if (mode == "top" && key == wanted_key && !found) { result = value; found = 1 }
128
+ if (strict) {
129
+ if (key == block) {
130
+ block_seen = 1
131
+ if (!empty) error_at(block " must be a block map", NR)
132
+ } else if (empty) error_at("expected scalar for " key, NR)
133
+ }
134
+ next
135
+ }
136
+ if (kind == "package" && in_block) {
137
+ package = key
138
+ row_count++
139
+ row_name[row_count] = key
140
+ row_line[row_count] = NR
141
+ if (strict) {
142
+ if (key == "") error_at("empty package key", NR)
143
+ if (package_seen[key]++) error_at("duplicate package " key, NR)
144
+ if (!empty) error_at("expected field map for " key, NR)
145
+ }
146
+ if (mode == "value" && key == wanted_key && !found) { result = value; found = 1 }
147
+ next
148
+ }
149
+ if (kind == "field" && in_block && package != "") {
150
+ identity = package SUBSEP key
151
+ if (strict && field_seen[identity]++) error_at("duplicate field " package "." key, NR)
152
+ if (!(identity in field_value)) field_value[identity] = value
153
+ if (strict && empty) error_at("expected scalar for " package "." key, NR)
154
+ if (mode == "field" && package == wanted_key && key == wanted_field && !found) {
155
+ result = value
156
+ found = 1
157
+ }
158
+ next
159
+ }
160
+ if (strict) error_at("malformed lock structure: expected top-level scalar or quoted package field", NR)
161
+ }
162
+
163
+ END {
164
+ # Read the format header before interpreting a future body's shape. Errors
165
+ # were retained during the single pass so header placement does not matter.
166
+ if (mode == "lock" && top_value["lockfile_version"] != expected_version) {
167
+ version = top_value["lockfile_version"]
168
+ print source_file " has unsupported or missing lockfile_version '" (version == "" ? "<missing>" : version) "' — expected " expected_version > "/dev/stderr"
169
+ exit 1
170
+ }
171
+ if (strict && problem != "") {
172
+ print source_file ":" problem_line ": " problem > "/dev/stderr"
173
+ exit 1
174
+ }
175
+ if (strict && !block_seen) {
176
+ print source_file ": missing " block " block" > "/dev/stderr"
177
+ exit 1
178
+ }
179
+ if (mode == "lock") print "V" sep top_value["lockfile_version"] sep top_value["engine_version"]
180
+ if (mode == "rows" || mode == "lock" || mode == "keys") {
181
+ for (i = 1; i <= row_count; i++) {
182
+ name = row_name[i]
183
+ if (mode == "keys") print name
184
+ else {
185
+ row = name sep field_value[name SUBSEP "requested"] sep field_value[name SUBSEP "url"] sep field_value[name SUBSEP "path"] sep field_value[name SUBSEP "resolved"] sep field_value[name SUBSEP "sha"]
186
+ if (mode == "lock") print "R" sep row sep row_line[i]
187
+ else print row
188
+ }
189
+ }
190
+ } else if (found) print result
191
+ }
@@ -106,53 +106,87 @@ suggest_similar() {
106
106
  fi
107
107
  }
108
108
 
109
- # fetch_package <url> <ref> <subpath> <dest-dir>
109
+ # is_bundle_source <url> <ref> <subpath>
110
+ is_bundle_source() {
111
+ [ "$1" = "$SYNC_PKG_URL" ] && [ "$3" = "$SYNC_PKG_PATH" ] \
112
+ && [ "${2#v}" = "$(bundled_engine_version)" ]
113
+ }
114
+
115
+ # fetch_package <url> <ref> <subpath> <dest-dir> [locked-sha]
110
116
  # Shallow-clones url@ref (tag, branch, or SHA fallback), copies <subpath>
111
117
  # (or the repo root) into <dest-dir>, prints the resolved commit sha.
118
+ # A locked SHA selects and verifies the commit independently of the ref name.
112
119
  # The clone pins autocrlf/eol/symlinks exactly like the engine does, so the
113
120
  # store holds the bytes the package published.
114
121
  fetch_package() {
115
- local url="$1" ref="$2" subpath="$3" dest="$4"
122
+ local url="$1" ref="$2" subpath="$3" dest="$4" locked_sha="${5:-}" bundle_sha=""
116
123
  assert_safe_source_url "$url"
117
124
  assert_safe_ref "$ref"
125
+ assert_safe_package_path "$subpath"
118
126
  # Bundle seed: the engine-content package at the CLI's own version copies
119
127
  # from the npm bundle — no network, which keeps init, sync restoration and
120
128
  # archived-project conversion offline. Keyed on the
121
129
  # full (url, path, ref) triple; any other version or source falls through
122
130
  # to the normal clone.
123
- if [ "$url" = "$SYNC_PKG_URL" ] && [ "$subpath" = "$SYNC_PKG_PATH" ] \
124
- && [ "${ref#v}" = "$(bundled_engine_version)" ]; then
131
+ if is_bundle_source "$url" "$ref" "$subpath"; then
132
+ if [ -f "$IS_ENGINE_DIR/ENGINE_SHA" ]; then
133
+ bundle_sha="$(tr -d ' \t\r\n' < "$IS_ENGINE_DIR/ENGINE_SHA")"
134
+ fi
135
+ fi
136
+ if is_bundle_source "$url" "$ref" "$subpath" \
137
+ && { [ -z "$locked_sha" ] || [ -z "$bundle_sha" ] || [ "$locked_sha" = "$bundle_sha" ]; }; then
125
138
  [ -n "${IS_BUNDLED_PKG_DIR:-}" ] && [ -d "$IS_BUNDLED_PKG_DIR" ] \
126
139
  || die "bundled engine content not found next to the CLI — reinstall @ainova-systems/intelligence"
127
140
  rm -rf "$dest"
128
141
  mkdir -p "$dest"
129
142
  cp -R "$IS_BUNDLED_PKG_DIR/." "$dest/"
130
143
  rm -rf "$dest/.git"
131
- if [ -f "$IS_ENGINE_DIR/ENGINE_SHA" ]; then
132
- tr -d ' \t\r\n' < "$IS_ENGINE_DIR/ENGINE_SHA"
144
+ if [ "$#" -ge 5 ] && { [ -z "$locked_sha" ] || [ -z "$bundle_sha" ]; }; then
145
+ echo "WARNING: bundled sync content restored without commit verification (development bundle or lock has no SHA)" >&2
133
146
  fi
147
+ printf '%s' "$bundle_sha"
134
148
  return 0
135
149
  fi
136
150
  local tmp sha src
137
151
  local -a branch_arg=()
138
152
  [ -n "$ref" ] && branch_arg=(--branch "$ref")
139
153
  tmp="$(mktemp -d -t intelligence-pkg-XXXXXX 2>/dev/null || mktemp -d)"
140
- if ! GIT_TERMINAL_PROMPT=0 git -c core.symlinks=false -c core.autocrlf=false -c core.eol=lf \
154
+ if [ -n "$locked_sha" ]; then
155
+ local -a format_arg=()
156
+ [ "${#locked_sha}" -ne 64 ] || format_arg=(--object-format=sha256)
157
+ git init --quiet "${format_arg[@]+"${format_arg[@]}"}" "$tmp/clone" \
158
+ || { rm -rf "$tmp"; die "cannot stage locked commit $locked_sha"; }
159
+ # Request the object directly, including retained commits no longer named
160
+ # by a branch/tag. Servers that forbid SHA wants may still advertise a
161
+ # descendant; fetch their history before declaring the commit unavailable.
162
+ if ! GIT_TERMINAL_PROMPT=0 git -C "$tmp/clone" fetch --quiet --no-tags --depth 1 -- "$url" "$locked_sha" 2>/dev/null; then
163
+ GIT_TERMINAL_PROMPT=0 git -C "$tmp/clone" fetch --quiet --tags -- "$url" \
164
+ '+refs/heads/*:refs/remotes/locked/*' 2>/dev/null \
165
+ || { rm -rf "$tmp"; die "cannot fetch locked commit $locked_sha from $url — check source access and commit availability"; }
166
+ fi
167
+ [ "$(git -C "$tmp/clone" cat-file -t "$locked_sha" 2>/dev/null || true)" = commit ] \
168
+ || { rm -rf "$tmp"; die "locked commit $locked_sha unavailable from $url — refusing to substitute '$ref' or HEAD"; }
169
+ git -c core.symlinks=false -c core.autocrlf=false -c core.eol=lf \
170
+ -C "$tmp/clone" checkout --quiet --detach "$locked_sha" -- \
171
+ || { rm -rf "$tmp"; die "cannot check out locked commit $locked_sha from $url"; }
172
+ elif ! GIT_TERMINAL_PROMPT=0 git -c core.symlinks=false -c core.autocrlf=false -c core.eol=lf \
141
173
  clone --depth 1 "${branch_arg[@]+"${branch_arg[@]}"}" --quiet -- "$url" "$tmp/clone" 2>/dev/null; then
142
174
  # A SHA is not clonable with --branch — fall back to a full clone + checkout.
143
175
  rm -rf "$tmp/clone"
144
176
  GIT_TERMINAL_PROMPT=0 git -c core.symlinks=false -c core.autocrlf=false -c core.eol=lf \
145
177
  clone --quiet -- "$url" "$tmp/clone" 2>/dev/null \
146
178
  || { rm -rf "$tmp"; die "cannot clone $url"; }
147
- [ -z "$ref" ] || git -C "$tmp/clone" checkout --quiet "$ref" -- \
179
+ [ -z "$ref" ] || git -c core.symlinks=false -c core.autocrlf=false -c core.eol=lf \
180
+ -C "$tmp/clone" checkout --quiet "$ref" -- \
148
181
  || { rm -rf "$tmp"; die "ref '$ref' not found in $url"; }
149
182
  fi
150
183
  sha="$(git -C "$tmp/clone" rev-parse HEAD)"
184
+ if [ -n "$locked_sha" ] && [ "$sha" != "$locked_sha" ]; then
185
+ rm -rf "$tmp"
186
+ die "fetched $sha but the lock pins $locked_sha — refusing package replacement"
187
+ fi
151
188
  src="$tmp/clone"
152
189
  if [ -n "$subpath" ]; then
153
- case "$subpath" in
154
- *..*|/*|*[\"\']*) rm -rf "$tmp"; die "unsafe path '$subpath'" ;;
155
- esac
156
190
  src="$tmp/clone/$subpath"
157
191
  [ -d "$src" ] || { rm -rf "$tmp"; die "path '$subpath' not found in $url${ref:+@$ref}" ; }
158
192
  fi
package/engine/ENGINE_SHA CHANGED
@@ -1 +1 @@
1
- 00308bfbdcd92ebf37924bb1083cee142e655fd7
1
+ fe187a9bee5923603cec51798bd1ea5c2860593e
package/engine/VERSION CHANGED
@@ -1 +1 @@
1
- 0.11.7
1
+ 0.11.9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ainova-systems/intelligence",
3
- "version": "0.11.7",
3
+ "version": "0.11.9",
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"
@@ -11,12 +11,12 @@ context: fork
11
11
  was requested). For Intelligence projects, this command first aligns project schema/content
12
12
  with the installed CLI and restores a missing package store strictly from
13
13
  `intelligence.lock`.
14
- 2. Require final `IS_STATUS=ok`; relay per-adapter counts and any model-drift
15
- or unsynced-source warnings.
14
+ 2. Require final `IS_STATUS=ok`; relay per-adapter counts and any integrity,
15
+ model-drift or unsynced-source warnings.
16
16
  3. In CI, a required tracked project upgrade is intentionally refused. Report
17
17
  the instruction to run `intelligence init --apply` locally, review and
18
18
  commit its diff; never bypass the gate. A frozen restore refusal means the
19
- manifest and lock disagree or a pinned ref moved—report it without
19
+ manifest and lock disagree or the locked commit cannot be verified/restored—report it without
20
20
  hand-copying package content.
21
21
  4. On a probable Intelligence defect, first ask whether to collect a sanitized
22
22
  issue draft; collect nothing before approval and never use telemetry. Include