@ainova-systems/intelligence 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -356,7 +356,8 @@ manifest="$root/intelligence.yaml"
356
356
  } > "$manifest"
357
357
 
358
358
  # The package store is restorable state, never committed — the npm model.
359
- ensure_base_gitignore "$root"
359
+ # Local rules, agents and skills stay on this machine (decision 0021).
360
+ ensure_base_gitignore "$root" "$content_dir"
360
361
  for target in $targets; do
361
362
  ensure_target_gitignore "$root" "$manifest" "$target"
362
363
  done
@@ -198,6 +198,14 @@ while IFS= read -r target; do
198
198
  ok "adapter $target contract v1"
199
199
  done < <(target_names "$manifest")
200
200
 
201
+ # A local rule, agent or skill is one these patterns name (decision 0021): the
202
+ # engine keeps it out of every committed output, and without the pattern Git
203
+ # would still offer to commit the file itself.
204
+ while IFS= read -r pattern; do
205
+ ignore_file_has_line "$IP_ROOT/.gitignore" "$pattern" \
206
+ || warn "local artifact Git policy is missing '$pattern' — run 'intelligence init'"
207
+ done < <(local_artifact_ignore_patterns "$content_dir")
208
+
201
209
  # Lock orphans.
202
210
  if [ "$lock_valid" -eq 1 ] && [ -f "$lock" ]; then
203
211
  while IFS= read -r name; do
@@ -214,6 +222,7 @@ fi
214
222
  # is exactly why doctor names it). Each entry is judged by the directory the
215
223
  # engine renders it as, read through the same parser: a package reference by
216
224
  # its store path, one that resolves to nothing by why.
225
+ local_specs=()
217
226
  for section in rules agents skills; do
218
227
  seen=$'\n'
219
228
  while IFS=$'\037' read -r src state dir alias holders; do
@@ -269,10 +278,40 @@ for section in rules agents skills; do
269
278
  # authoring later needs no config edit.
270
279
  *) ok "sources.$section '$src' — not created yet (optional)" ;;
271
280
  esac
281
+ continue
272
282
  fi
283
+ # A project-owned source is where a tracked file can carry a local
284
+ # name; store content is restorable state Git never tracks.
285
+ case "$dir" in
286
+ .intelligence/*) ;;
287
+ *)
288
+ if [ "$section" = skills ]; then
289
+ local_specs+=(":(glob)${dir%/}/*.local/SKILL.md")
290
+ else
291
+ local_specs+=(":(glob)${dir%/}/*.local.md")
292
+ fi
293
+ ;;
294
+ esac
273
295
  done < <(read_source_entries "$manifest" "$section")
274
296
  done
275
297
 
298
+ # A name makes an artifact local (decision 0021), and .gitignore never applies
299
+ # to a file Git already tracks: one committed before its name meant anything,
300
+ # or added by hand, renders only into ignored output while every clone holds
301
+ # it. Git and the engine would disagree about it, so it is named here.
302
+ if [ "${#local_specs[@]}" -gt 0 ] \
303
+ && git -C "$IP_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
304
+ if tracked_local="$(git -C "$IP_ROOT" -c core.quotePath=false ls-files -- "${local_specs[@]}")"; then
305
+ while IFS= read -r path; do
306
+ [ -n "$path" ] || continue
307
+ case "$path" in */SKILL.md) path="${path%/SKILL.md}/" ;; esac
308
+ warn "$path is tracked by Git, but its name makes it local: sync renders it only into ignored output — rename it to share it, or 'git rm --cached' it to keep it on this machine"
309
+ done <<< "$tracked_local"
310
+ else
311
+ warn "tracked local artifacts could not be listed — 'git ls-files' failed"
312
+ fi
313
+ fi
314
+
276
315
  if [ "$problems" -eq 0 ]; then
277
316
  echo "all good."
278
317
  else
@@ -312,10 +312,26 @@ ensure_gitignore_header() {
312
312
  fi
313
313
  }
314
314
 
315
+ # local_artifact_ignore_patterns <content-dir> — the patterns that make a rule,
316
+ # agent or skill local (decision 0021): a file or skill directory with one of
317
+ # these names never reaches committed output, and Git never commits it. The
318
+ # engine recognizes the same names, so the two cannot disagree; `init` writes
319
+ # them and `status --check` reports one missing.
320
+ local_artifact_ignore_patterns() {
321
+ local dir="${1#./}"
322
+ while [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done
323
+ printf '%s\n' "$dir/rules/*.local.md" "$dir/agents/*.local.md" "$dir/skills/*.local/"
324
+ }
325
+
326
+ # ensure_base_gitignore <root> <content-dir> — the package store and the local
327
+ # artifact patterns of the project's content directory.
315
328
  ensure_base_gitignore() {
316
- local root="$1"
329
+ local root="$1" content_dir="$2" line
317
330
  ensure_gitignore_header "$root"
318
331
  gitignore_add_line "$root" '.intelligence/'
332
+ while IFS= read -r line; do
333
+ gitignore_add_line "$root" "$line"
334
+ done < <(local_artifact_ignore_patterns "$content_dir")
319
335
  }
320
336
 
321
337
  ensure_target_gitignore() {
@@ -343,7 +359,7 @@ ensure_target_gitignore() {
343
359
 
344
360
  ensure_manifest_gitignore() {
345
361
  local root="$1" manifest="$2" target
346
- ensure_base_gitignore "$root"
362
+ ensure_base_gitignore "$root" "$(manifest_intelligence_dir "$manifest")"
347
363
  while IFS= read -r target; do
348
364
  [ -n "$target" ] || continue
349
365
  if [ "$(is_target_enabled "$manifest" "$target")" = "1" ]; then
@@ -420,6 +436,7 @@ managed_gitignore_patterns() {
420
436
  local root="$1" manifest="$2" content_dir target output records kind value
421
437
  content_dir="$(manifest_intelligence_dir "$manifest")"
422
438
  printf '%s\n' '.intelligence/' "$content_dir/_backup/"
439
+ local_artifact_ignore_patterns "$content_dir"
423
440
  while IFS= read -r target; do
424
441
  [ -n "$target" ] || continue
425
442
  [ "$(is_target_enabled "$manifest" "$target")" = "1" ] || continue
package/engine/ENGINE_SHA CHANGED
@@ -1 +1 @@
1
- c8058f91133b86479a03d7ae950191e2d21b68a6
1
+ af0a0fdbc3f505e4f063ae516c9243c92db6e376
package/engine/VERSION CHANGED
@@ -1 +1 @@
1
- 0.20.0
1
+ 0.21.0
@@ -30,9 +30,17 @@
30
30
  # map_effort(tool, effort) — the tool's level for a neutral effort; "" when none
31
31
  # map_access_to_claude_tools(access) — full->"" (no tools list; inherits all), readonly->restricted
32
32
  # map_access_to_claude_disallowed(access) — readonly->"Write, Edit", full->""
33
- # read_yaml_list(manifest, section) — read a source list from intelligence.yaml,
34
- # package references as their store paths
33
+ # source_artifacts_var(root, config, section, adapter, output)
34
+ # — the sources this adapter renders (below)
35
+ # finalize_copy_named(dir, map, file...) — copy and finalize files under their rendered names
36
+ # copy_skill_bundle_dirs_named(tool, dir, map, skill_dir...) — copy skills under their rendered names
35
37
  # get_target_field(config, target, field) — read a target's config field
38
+ #
39
+ # Read sources through source_artifacts_var, never with a glob of your own: it
40
+ # names each artifact (`.draft.md` is `draft`, `me.local.md` is `me`), renders
41
+ # only the later of two same-named artifacts, and leaves a local artifact out of
42
+ # every output path Git tracks (decision 0021). An adapter that globs sources
43
+ # itself can write a teammate's local file into a committed output.
36
44
 
37
45
  # Project adapters are sourced by engine/sync.sh after the shared library is
38
46
  # loaded. Keep this template position-independent: after scaffolding it lives
@@ -61,13 +69,24 @@ adapter_contract_<name>() {
61
69
  # - Copy as-is (like Claude)
62
70
  # - Convert paths: to globs: (like Cursor)
63
71
  # - Merge into single file (like Copilot)
72
+ #
73
+ # source_artifacts_var takes the repo-relative path an artifact lands at, `{}`
74
+ # standing for its name, and fills IS_SOURCE_FILES (the source file, or a
75
+ # skill's SKILL.md), IS_SOURCE_NAMES, IS_SOURCE_SCOPED (1 for a rule with
76
+ # `paths:`), IS_SOURCE_IN_AGENTS (1 when AGENTS.md carries the artifact too)
77
+ # and IS_SOURCE_NAME_MAP, the names finalize_copy_named and
78
+ # copy_skill_bundle_dirs_named write under. It lists only the artifacts whose
79
+ # `targets:` / `exclude_targets:` select this adapter; the shared helpers drop
80
+ # both fields from every copy, and a frontmatter copied by hand must too.
64
81
  sync_<name>_rules() {
65
82
  local repo_root="$1"
66
83
  local config_file="$2"
67
84
  local output_dir="$3"
68
85
 
69
- # TODO: Implement rule sync logic
70
- echo " rules: not implemented"
86
+ output_rel_var "$repo_root" "$output_dir"
87
+ source_artifacts_var "$repo_root" "$config_file" rules <name> "$IS_OUTPUT_REL/rules/{}.md"
88
+ # TODO: Render each of IS_SOURCE_FILES into $output_dir/rules/ under its IS_SOURCE_NAMES entry
89
+ echo " rules: ${#IS_SOURCE_FILES[@]} found, not rendered"
71
90
  }
72
91
 
73
92
  # Sync skills for <agent-name>
@@ -76,8 +95,11 @@ sync_<name>_skills() {
76
95
  local config_file="$2"
77
96
  local output_dir="$3"
78
97
 
79
- # TODO: Implement skill sync logic
80
- echo " skills: not implemented"
98
+ output_rel_var "$repo_root" "$output_dir"
99
+ source_artifacts_var "$repo_root" "$config_file" skills <name> "$IS_OUTPUT_REL/skills/{}"
100
+ # TODO: Copy each skill directory ("${IS_SOURCE_FILES[i]%/SKILL.md}") with
101
+ # copy_skill_bundle_dirs_named <name> "$output_dir/skills" "$IS_SOURCE_NAME_MAP" <dir>...
102
+ echo " skills: ${#IS_SOURCE_FILES[@]} found, not rendered"
81
103
  }
82
104
 
83
105
  # Sync agents for <agent-name>
@@ -86,8 +108,10 @@ sync_<name>_agents() {
86
108
  local config_file="$2"
87
109
  local output_dir="$3"
88
110
 
89
- # TODO: Implement agent sync logic
90
- echo " agents: not implemented"
111
+ output_rel_var "$repo_root" "$output_dir"
112
+ source_artifacts_var "$repo_root" "$config_file" agents <name> "$IS_OUTPUT_REL/agents/{}.md"
113
+ # TODO: Render each of IS_SOURCE_FILES into $output_dir/agents/ under its IS_SOURCE_NAMES entry
114
+ echo " agents: ${#IS_SOURCE_FILES[@]} found, not rendered"
91
115
  }
92
116
 
93
117
  # Main entry point
@@ -60,15 +60,19 @@ agents_md_append_agents_table() {
60
60
  local rows=""
61
61
  local count=0
62
62
 
63
- local -a files=()
64
- read_source_artifact_files "$repo_root" "$config_file" "agents"
65
- [ "${#IS_SOURCE_FILES[@]}" -eq 0 ] || files=("${IS_SOURCE_FILES[@]}")
63
+ local -a files=() names=()
64
+ source_artifacts_var "$repo_root" "$config_file" agents agents "${output#"$repo_root"/}"
65
+ if [ "${#IS_SOURCE_FILES[@]}" -gt 0 ]; then
66
+ files=("${IS_SOURCE_FILES[@]}")
67
+ names=("${IS_SOURCE_NAMES[@]}")
68
+ fi
66
69
 
67
70
  if [ "${#files[@]}" -gt 0 ]; then
68
- local path tier access desc name
71
+ local path tier access desc name i=0
72
+ # frontmatter_index prints one row per file, in argument order.
69
73
  while IFS=$'\x1f' read -r path tier access desc; do
70
- [ -n "$path" ] || continue
71
- name="${path##*/}"; name="${name%.md}"
74
+ name="${names[i]}"
75
+ i=$((i + 1))
72
76
  repo_rel_link_var "$repo_root" "$path"
73
77
  if [ -n "$IS_REPO_REL" ]; then
74
78
  rows+="| [$name]($IS_REPO_REL) | ${tier:--} | ${access:--} | ${desc:--} |"$'\n'
@@ -101,21 +105,24 @@ agents_md_append_skills_table() {
101
105
  local rows=""
102
106
  local count=0
103
107
 
104
- local dirname
105
- local -a skill_files=()
106
- read_source_artifact_files "$repo_root" "$config_file" "skills"
107
- [ "${#IS_SOURCE_FILES[@]}" -eq 0 ] || skill_files=("${IS_SOURCE_FILES[@]}")
108
+ local name
109
+ local -a skill_files=() names=()
110
+ source_artifacts_var "$repo_root" "$config_file" skills agents "${output#"$repo_root"/}"
111
+ if [ "${#IS_SOURCE_FILES[@]}" -gt 0 ]; then
112
+ skill_files=("${IS_SOURCE_FILES[@]}")
113
+ names=("${IS_SOURCE_NAMES[@]}")
114
+ fi
108
115
 
109
116
  if [ "${#skill_files[@]}" -gt 0 ]; then
110
- local path desc
117
+ local path desc i=0
111
118
  while IFS=$'\x1f' read -r path desc; do
112
- [ -n "$path" ] || continue
113
- dirname="${path%/SKILL.md}"; dirname="${dirname##*/}"
119
+ name="${names[i]}"
120
+ i=$((i + 1))
114
121
  repo_rel_link_var "$repo_root" "$path"
115
122
  if [ -n "$IS_REPO_REL" ]; then
116
- rows+="| [$dirname]($IS_REPO_REL) | ${desc:--} |"$'\n'
123
+ rows+="| [$name]($IS_REPO_REL) | ${desc:--} |"$'\n'
117
124
  else
118
- rows+="| $dirname | ${desc:--} |"$'\n'
125
+ rows+="| $name | ${desc:--} |"$'\n'
119
126
  fi
120
127
  count=$((count + 1))
121
128
  done < <(frontmatter_index "description" "${skill_files[@]}")
@@ -144,30 +151,25 @@ agents_md_append_rules_list() {
144
151
  local count=0
145
152
  local global_rule_files=()
146
153
 
147
- local -a files=()
148
- read_source_artifact_files "$repo_root" "$config_file" "rules"
149
- [ "${#IS_SOURCE_FILES[@]}" -eq 0 ] || files=("${IS_SOURCE_FILES[@]}")
150
-
151
- if [ "${#files[@]}" -gt 0 ]; then
152
- local path hp name scope
153
- while IFS=$'\x1f' read -r path hp; do
154
- [ -n "$path" ] || continue
155
- name="${path##*/}"; name="${name%.md}"
156
- scope="global"
157
- if [ "$hp" != "0" ]; then
158
- scope="scoped"
159
- else
160
- global_rule_files+=("$path")
161
- fi
162
- repo_rel_link_var "$repo_root" "$path"
163
- if [ -n "$IS_REPO_REL" ]; then
164
- lines+="- [$name]($IS_REPO_REL) ($scope)"$'\n'
165
- else
166
- lines+="- $name ($scope)"$'\n'
167
- fi
168
- count=$((count + 1))
169
- done < <(frontmatter_index "paths#" "${files[@]}")
170
- fi
154
+ local i path name scope
155
+ source_artifacts_var "$repo_root" "$config_file" rules agents "${output#"$repo_root"/}"
156
+ for ((i = 0; i < ${#IS_SOURCE_FILES[@]}; i++)); do
157
+ path="${IS_SOURCE_FILES[i]}"
158
+ name="${IS_SOURCE_NAMES[i]}"
159
+ scope="global"
160
+ if [ "${IS_SOURCE_SCOPED[i]}" = 1 ]; then
161
+ scope="scoped"
162
+ else
163
+ global_rule_files+=("$path")
164
+ fi
165
+ repo_rel_link_var "$repo_root" "$path"
166
+ if [ -n "$IS_REPO_REL" ]; then
167
+ lines+="- [$name]($IS_REPO_REL) ($scope)"$'\n'
168
+ else
169
+ lines+="- $name ($scope)"$'\n'
170
+ fi
171
+ count=$((count + 1))
172
+ done
171
173
 
172
174
  [ "$count" -eq 0 ] && return 0
173
175
 
@@ -196,17 +198,7 @@ agents_md_append_rules_list() {
196
198
  echo "<!-- Inlined from always-on rules in $content_rel/rules/ -->"
197
199
  echo ""
198
200
  } >> "$output"
199
- # One awk inlines every global rule body, with a blank line after
200
- # each — the same output the per-file passes produced.
201
- awk '
202
- FNR == 1 { if (NR != 1) print ""; in_fm = 0; past_fm = 0 }
203
- { sub(/\r$/, "") }
204
- /^---$/ {
205
- if (!past_fm) { in_fm = !in_fm; if (!in_fm) { past_fm = 1 }; next }
206
- }
207
- past_fm || !in_fm { print }
208
- END { print "" }
209
- ' "${global_rule_files[@]}" >> "$output"
201
+ emit_rule_bodies "${global_rule_files[@]}" >> "$output"
210
202
  echo " rules: ${#global_rule_files[@]} global rule(s) inlined"
211
203
  fi
212
204
  }
@@ -11,7 +11,11 @@
11
11
  # - Always-on (no `paths:`) -> SKIPPED here. The CLI docs tell a project to
12
12
  # "Create a GEMINI.md or AGENTS.md file at your workspace root", and the
13
13
  # `agents` adapter inlines always-on rule content into AGENTS.md. Emitting
14
- # copies under .agents/rules/ would double-load the same text.
14
+ # copies under .agents/rules/ would double-load the same text. An always-on
15
+ # rule AGENTS.md does not carry — a local one, or one whose targets leave
16
+ # `agents` out — becomes .agents/rules/<name>.md with `trigger: always_on`,
17
+ # the same key family as `trigger: glob` and just as undocumented
18
+ # (decision 0021).
15
19
  # - Path-scoped (with `paths:`) -> .agents/rules/<name>.md carrying
16
20
  # `trigger: glob` and `globs:`. NOT vendor-documented: the rules page names
17
21
  # four activation modes in prose (manual, always on, model decision, glob
@@ -95,45 +99,46 @@ sync_antigravity_rules() {
95
99
  antigravity_rule_limit "$config_file" || return 1
96
100
  rule_limit="$IS_AG_RULE_LIMIT"
97
101
 
98
- local src f
99
- local -a files=()
100
- load_yaml_list "$config_file" "rules"
101
- local list="$IS_YAML_LIST"
102
- while IFS= read -r src; do
103
- [ -z "$src" ] && continue
104
- local dir="$repo_root/$src"
105
- [ -d "$dir" ] || continue
106
- for f in "$dir"/*.md; do
107
- [ -f "$f" ] || continue
108
- files+=("$f")
109
- done
110
- done <<< "$list"
111
-
112
- # Skip always-on rules: AGENTS.md (canonical) carries them.
113
- local path hp base
114
- local -a scoped=()
115
- if [ "${#files[@]}" -gt 0 ]; then
116
- while IFS=$'\x1f' read -r path hp; do
117
- [ -n "$path" ] || continue
118
- [ "$hp" -eq 0 ] && continue
119
- scoped+=("$path")
120
- done < <(frontmatter_index "paths#" "${files[@]}")
121
- fi
122
- if [ "${#scoped[@]}" -eq 0 ]; then
102
+ # Skip always-on rules AGENTS.md (canonical) carries; one it does not
103
+ # carry takes the always_on trigger instead (decision 0021).
104
+ local i always="" always_count=0
105
+ local -a files=() names=() kinds=()
106
+ output_rel_var "$repo_root" "$output_dir"
107
+ source_artifacts_var "$repo_root" "$config_file" rules antigravity "$IS_OUTPUT_REL/rules/{}.md"
108
+ for ((i = 0; i < ${#IS_SOURCE_FILES[@]}; i++)); do
109
+ if [ "${IS_SOURCE_SCOPED[i]}" = 1 ]; then
110
+ kinds+=(scoped)
111
+ elif [ "${IS_SOURCE_IN_AGENTS[i]}" = 0 ]; then
112
+ kinds+=(always-on)
113
+ always="$always${IS_SOURCE_FILES[i]}"$'\n'
114
+ always_count=$((always_count + 1))
115
+ else
116
+ continue
117
+ fi
118
+ files+=("${IS_SOURCE_FILES[i]}")
119
+ names+=("${IS_SOURCE_NAMES[i]}")
120
+ done
121
+ if [ "${#files[@]}" -eq 0 ]; then
123
122
  echo " -> Rules: 0 scoped (AGENTS.md carries always-on)"
124
123
  return 0
125
124
  fi
126
125
 
127
- # Path-scoped rule -> Glob activation (paths -> globs). The rewrite is
128
- # anchored inside the frontmatter block: a body line may legitimately start
129
- # with `paths:` (a rule documenting rule syntax does exactly that), and
130
- # rewriting it would corrupt the text it teaches.
126
+ # Path-scoped rule -> Glob activation (paths -> globs); always-on rule ->
127
+ # `trigger: always_on`, with a frontmatter block of its own when the source
128
+ # has none. The rewrite is anchored inside the frontmatter block: a body
129
+ # line may legitimately start with `paths:` (a rule documenting rule
130
+ # syntax does exactly that), and rewriting it would corrupt the text it
131
+ # teaches.
131
132
  #
132
133
  # stdout carries nothing but the character-count overflow report, so the
133
134
  # limit check costs no extra process.
134
135
  local overflow
135
136
  is_fin_awk_vars
136
- overflow="$(awk "${IS_FIN_V[@]}" -v dst="$output_dir/rules" -v limit="$rule_limit" "$IS_AWK_LIB"'
137
+ overflow="$(IS_AG_ALWAYS="$always" IS_NAME_MAP="$IS_SOURCE_NAME_MAP" awk "${IS_FIN_V[@]}" -v dst="$output_dir/rules" -v limit="$rule_limit" "$IS_AWK_LIB"'
138
+ BEGIN {
139
+ n = split(ENVIRON["IS_AG_ALWAYS"], al, "\n")
140
+ for (k = 1; k <= n; k++) if (al[k] != "") ALWAYS[al[k]] = 1
141
+ }
137
142
  function emit(line, rendered) {
138
143
  rendered = fin_line(line)
139
144
  print rendered > out
@@ -141,11 +146,21 @@ sync_antigravity_rules() {
141
146
  }
142
147
  FNR == 1 {
143
148
  if (out != "") close(out)
144
- out = dst "/" base_name(FILENAME)
149
+ out = dst "/" art_file(FILENAME, ".md")
145
150
  chars[out] = 0
146
151
  fm = 0
152
+ tgt_reset()
147
153
  }
148
154
  { sub(/\r$/, "") }
155
+ FNR == 1 && (FILENAME in ALWAYS) {
156
+ emit("---")
157
+ emit("trigger: always_on")
158
+ if ($0 == "---") { fm = 1; next }
159
+ emit("---")
160
+ fm = 2
161
+ emit($0)
162
+ next
163
+ }
149
164
  FNR == 1 {
150
165
  if ($0 == "---") fm = 1
151
166
  emit($0)
@@ -153,6 +168,7 @@ sync_antigravity_rules() {
153
168
  next
154
169
  }
155
170
  fm == 1 && $0 == "---" { fm = 2; emit($0); next }
171
+ fm == 1 && tgt_drop($0) { next }
156
172
  fm == 1 { sub(/^paths:/, "globs:") }
157
173
  { emit($0) }
158
174
  END {
@@ -161,7 +177,7 @@ sync_antigravity_rules() {
161
177
  for (f in chars)
162
178
  if (chars[f] > limit) printf "%s\t%d\n", f, chars[f]
163
179
  }
164
- ' "${scoped[@]}" | sort)"
180
+ ' "${files[@]}" | sort)"
165
181
 
166
182
  local over_file over_chars
167
183
  while IFS=$'\t' read -r over_file over_chars; do
@@ -169,11 +185,18 @@ sync_antigravity_rules() {
169
185
  echo "WARNING: Antigravity limits a rule file to $rule_limit characters: ${over_file#"$repo_root/"} renders $over_chars; split the rule, shorten it, set targets.antigravity.warn_rule_limit to the effective limit, or set it to false to disable this warning." >&2
170
186
  done <<< "$overflow"
171
187
 
172
- for f in "${scoped[@]}"; do
173
- base="${f##*/}"
174
- echo " rule: $base (scoped)"
188
+ for ((i = 0; i < ${#files[@]}; i++)); do
189
+ # awk never reads a record from an empty source.
190
+ if [ "${kinds[i]}" = always-on ] && [ ! -s "${files[i]}" ]; then
191
+ printf -- '---\ntrigger: always_on\n---\n' > "$output_dir/rules/${names[i]}.md"
192
+ fi
193
+ echo " rule: ${names[i]}.md (${kinds[i]})"
175
194
  done
176
- echo " -> Rules: ${#scoped[@]} scoped"
195
+ if [ "$always_count" -gt 0 ]; then
196
+ echo " -> Rules: $(( ${#files[@]} - always_count )) scoped, $always_count always-on"
197
+ else
198
+ echo " -> Rules: ${#files[@]} scoped"
199
+ fi
177
200
  }
178
201
 
179
202
  sync_antigravity_agents() {
@@ -181,19 +204,13 @@ sync_antigravity_agents() {
181
204
  local config_file="$2"
182
205
  local agents_dir="$3"
183
206
 
184
- local src f
185
- local -a files=()
186
- load_yaml_list "$config_file" "agents"
187
- local list="$IS_YAML_LIST"
188
- while IFS= read -r src; do
189
- [ -z "$src" ] && continue
190
- local dir="$repo_root/$src"
191
- [ -d "$dir" ] || continue
192
- for f in "$dir"/*.md; do
193
- [ -f "$f" ] || continue
194
- files+=("$f")
195
- done
196
- done <<< "$list"
207
+ local -a files=() names=()
208
+ output_rel_var "$repo_root" "$agents_dir"
209
+ source_artifacts_var "$repo_root" "$config_file" agents antigravity "$IS_OUTPUT_REL/{}.md"
210
+ if [ "${#IS_SOURCE_FILES[@]}" -gt 0 ]; then
211
+ files=("${IS_SOURCE_FILES[@]}")
212
+ names=("${IS_SOURCE_NAMES[@]}")
213
+ fi
197
214
 
198
215
  local count=0
199
216
  if [ "${#files[@]}" -gt 0 ]; then
@@ -201,8 +218,7 @@ sync_antigravity_agents() {
201
218
 
202
219
  local path tier access description name spec="" report="" LS=$'\x1e'
203
220
  while IFS=$'\x1f' read -r path tier access description; do
204
- [ -n "$path" ] || continue
205
- name="${path##*/}"; name="${name%.md}"
221
+ name="${names[count]}"
206
222
  resolve_model_var "$tier"
207
223
 
208
224
  local header