@ainova-systems/intelligence 0.20.0 → 0.22.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.
Files changed (31) hide show
  1. package/cli/commands/init.sh +2 -1
  2. package/cli/commands/sync.sh +9 -0
  3. package/cli/internal/check.sh +39 -0
  4. package/cli/lib/gitignore.sh +19 -2
  5. package/cli/lib/sync-lock.sh +103 -21
  6. package/engine/ENGINE_SHA +1 -1
  7. package/engine/VERSION +1 -1
  8. package/engine/adapters/_template.sh +32 -8
  9. package/engine/adapters/agents.sh +42 -50
  10. package/engine/adapters/antigravity.sh +68 -52
  11. package/engine/adapters/claude-plugin/.claude-plugin/plugin.json +6 -0
  12. package/engine/adapters/claude-plugin/README.md +14 -0
  13. package/engine/adapters/claude-plugin/hooks/hooks.json +3 -0
  14. package/engine/adapters/claude-plugin/hooks/register.js +324 -0
  15. package/engine/adapters/claude.sh +129 -59
  16. package/engine/adapters/codex.sh +74 -15
  17. package/engine/adapters/copilot.sh +56 -79
  18. package/engine/adapters/cursor.sh +85 -81
  19. package/engine/adapters/opencode.sh +20 -35
  20. package/engine/adapters/pi.sh +137 -41
  21. package/engine/lib/common.sh +753 -56
  22. package/engine/lib/contract.sh +15 -6
  23. package/engine/sync.sh +49 -33
  24. package/package.json +1 -1
  25. package/packages/sync/references/adapters.md +56 -40
  26. package/packages/sync/references/conventions.md +57 -11
  27. package/packages/sync/references/onboarding-migration.md +4 -4
  28. package/packages/sync/rules/intelligence-authoring.md +2 -2
  29. package/packages/sync/skills/intelligence-learn-from-repository/SKILL.md +7 -5
  30. package/packages/sync/skills/intelligence-review-context/references/audit-checks.md +1 -1
  31. package/packages/sync/skills/intelligence-update-context/references/rules.md +5 -0
@@ -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
@@ -106,6 +106,10 @@ run_check() {
106
106
  die "no intelligence project found here - run 'intelligence init'"
107
107
  ;;
108
108
  esac
109
+ # A check marks the lock it holds, so a sync started meanwhile waits for
110
+ # it instead of refusing (sync-lock.sh).
111
+ # shellcheck disable=SC2034 # read by sync-lock.sh when the lock is taken
112
+ SYNC_LOCK_KIND=check
109
113
  hold_project
110
114
  project_preflight "$IP_ROOT" || return $?
111
115
  if project_needs_upgrade "$IP_ROOT"; then
@@ -144,6 +148,11 @@ fi
144
148
  compact_output="$(mktemp -t intelligence-sync-XXXXXX)"
145
149
  trap 'rm -f "$compact_output"' EXIT
146
150
  rc=0
151
+ # The one line that must not wait for the buffer: a wait for a check holding
152
+ # the lock can last minutes (sync-lock.sh). Descriptor 9 keeps the real stderr.
153
+ exec 9>&2
154
+ # shellcheck disable=SC2034 # read by sync-lock.sh while the lock is taken
155
+ IS_LOCK_NOTICE_FD=9
147
156
  (run_sync) > "$compact_output" 2>&1 || rc=$?
148
157
  if [ "$rc" -ne 0 ]; then
149
158
  cat "$compact_output" >&2
@@ -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
@@ -12,9 +12,25 @@
12
12
  # join instead of refusing, and a holder that died can be told from one whose
13
13
  # writers still run. Taking over a dead holder's lock happens under a second
14
14
  # lock, so two runs that judged the same lock stale cannot both take it.
15
+ #
16
+ # `sync --check` holds the lock too, because it may restore the package store
17
+ # and compares a copy of the live outputs. It writes nothing a person keeps, so
18
+ # a holder that is a check marks its lock with a `kind` file, and a writing
19
+ # command that finds such a holder waits for it instead of refusing (decision
20
+ # 0022): a status check that runs every few minutes must never make the
21
+ # person's own sync fail. A check never waits in line — it refuses any holder
22
+ # at once — so it is never queued behind a writer; one that arrives as a lock is
23
+ # released can still take it first, and the waiting writer's bound then starts
24
+ # again for that check. A writing holder is refused at once, as before.
15
25
 
16
26
  SYNC_LOCK_HELD=""
17
27
  SYNC_LOCK_TOKEN=""
28
+ # What this process takes the lock for: "check" for `sync --check`, empty for
29
+ # every writer. Set before the lock is taken.
30
+ SYNC_LOCK_KIND=""
31
+ # How long a writer waits for each check that holds the lock, in seconds. Kept
32
+ # in the environment only so the lock suite can exercise the bound quickly.
33
+ SYNC_LOCK_CHECK_WAIT="${INTELLIGENCE_SYNC_LOCK_CHECK_WAIT:-300}"
18
34
 
19
35
  sync_lock_now() {
20
36
  if [ -n "${EPOCHSECONDS:-}" ]; then
@@ -47,6 +63,9 @@ sync_lock_pgid() {
47
63
  }
48
64
 
49
65
  sync_lock_write_owner() {
66
+ # The kind goes first, before anything that costs time: whoever can read
67
+ # the owner can read the kind too.
68
+ [ -z "$SYNC_LOCK_KIND" ] || printf '%s\n' "$SYNC_LOCK_KIND" > "$1/kind"
50
69
  sync_lock_now
51
70
  sync_lock_pgid
52
71
  SYNC_LOCK_TOKEN="$$.$IS_NOW.$RANDOM$RANDOM"
@@ -88,31 +107,89 @@ sync_lock_is_stale() {
88
107
  [ -d "$1" ] && [ -n "$(find "$1" -maxdepth 0 -mmin +1 2>/dev/null)" ]
89
108
  }
90
109
 
91
- # sync_lock_acquire <root> — take <root>/.intelligence/sync.lock or refuse.
110
+ # sync_lock_held_by_check <lock> — true when the holder marked its lock as a
111
+ # `sync --check`. A lock without the mark is a writer's, or an older CLI's.
112
+ sync_lock_held_by_check() {
113
+ local kind=""
114
+ # The group silences the redirection too: a kind released between the
115
+ # test and the read must not print a stray error a caller would show.
116
+ [ -f "$1/kind" ] && { IFS= read -r kind < "$1/kind"; } 2>/dev/null
117
+ [ "$kind" = check ]
118
+ }
119
+
120
+ # sync_lock_acquire <root> — take <root>/.intelligence/sync.lock, or refuse.
121
+ # A writer waits for each check that holds it for up to SYNC_LOCK_CHECK_WAIT
122
+ # seconds; a check refuses at once.
92
123
  sync_lock_acquire() {
93
- local root="$1" lock="$1/.intelligence/sync.lock" took=0 age=""
124
+ local root="$1" lock="$1/.intelligence/sync.lock" took age="" started="$SECONDS" told=0 ownerless=""
125
+ local holder="" seen
94
126
  # A process start costs tens of milliseconds on Git Bash: skip the usual case.
95
127
  [ -d "$root/.intelligence" ] || mkdir -p "$root/.intelligence" || die "cannot create $root/.intelligence"
96
- if mkdir "$lock" 2>/dev/null; then
97
- took=1
98
- elif mkdir "$lock.takeover" 2>/dev/null; then
99
- # Judge again under the takeover lock: another run may have taken
100
- # it over since this one looked.
101
- if sync_lock_is_stale "$lock"; then
102
- rm -rf "$lock"
103
- mkdir "$lock" 2>/dev/null && took=1
128
+ while :; do
129
+ took=0
130
+ if mkdir "$lock" 2>/dev/null; then
131
+ took=1
132
+ elif sync_lock_is_stale "$lock" && mkdir "$lock.takeover" 2>/dev/null; then
133
+ # The takeover lock is taken only for a lock that already looks
134
+ # stale: a command waiting for a live check must never hold it,
135
+ # since one interrupted mid-wait would leave it behind for good.
136
+ # Judge again under it: another run may have taken the lock over
137
+ # since this one looked.
138
+ if sync_lock_is_stale "$lock"; then
139
+ rm -rf "$lock"
140
+ mkdir "$lock" 2>/dev/null && took=1
141
+ fi
142
+ rmdir "$lock.takeover"
143
+ elif [ -d "$lock.takeover" ] && [ -n "$(find "$lock.takeover" -maxdepth 0 -mmin +1 2>/dev/null)" ]; then
144
+ # A takeover runs for milliseconds. Removing an abandoned one is itself
145
+ # a check-then-delete race, so it is left to a person.
146
+ die "a takeover of $lock was abandoned; if no intelligence command is running in this project, remove $lock.takeover"
104
147
  fi
105
- rmdir "$lock.takeover"
106
- elif [ -n "$(find "$lock.takeover" -maxdepth 0 -mmin +1 2>/dev/null)" ]; then
107
- # A takeover runs for milliseconds. Removing an abandoned one is itself
108
- # a check-then-delete race, so it is left to a person.
109
- die "a takeover of $lock was abandoned; if no intelligence command is running in this project, remove $lock.takeover"
110
- fi
111
- if [ "$took" = 1 ]; then
112
- sync_lock_write_owner "$lock" || { rm -rf "$lock"; die "cannot write $lock/owner"; }
113
- SYNC_LOCK_HELD="$lock"
114
- return 0
115
- fi
148
+ if [ "$took" = 1 ]; then
149
+ sync_lock_write_owner "$lock" || { rm -rf "$lock"; die "cannot write $lock/owner"; }
150
+ SYNC_LOCK_HELD="$lock"
151
+ return 0
152
+ fi
153
+ if [ "$SYNC_LOCK_KIND" != check ] && sync_lock_held_by_check "$lock"; then
154
+ # A later ownerless moment belongs to another holder's start or
155
+ # release, and gets its own few seconds.
156
+ ownerless=""
157
+ # The bound is per check: one that took the lock while this
158
+ # command waited starts its own count.
159
+ if sync_lock_read_owner "$lock"; then
160
+ seen="$IS_LOCK_PID $IS_LOCK_SINCE $IS_LOCK_TOKEN"
161
+ if [ "$seen" != "$holder" ]; then
162
+ holder="$seen"
163
+ started="$SECONDS"
164
+ fi
165
+ fi
166
+ [ $((SECONDS - started)) -lt "$SYNC_LOCK_CHECK_WAIT" ] || break
167
+ if [ "$told" = 0 ]; then
168
+ # A caller that buffers this command's output (sync --compact)
169
+ # names a descriptor for the terminal, so a wait of minutes
170
+ # is never silent.
171
+ # Not one `>&"${fd:-2}"`: the group's 2>/dev/null, which keeps
172
+ # a closed descriptor quiet, would swallow the default too.
173
+ if [ -n "${IS_LOCK_NOTICE_FD:-}" ]; then
174
+ { echo "waiting for intelligence sync --check to release $lock" >&"$IS_LOCK_NOTICE_FD"; } 2>/dev/null \
175
+ || echo "waiting for intelligence sync --check to release $lock" >&2
176
+ else
177
+ echo "waiting for intelligence sync --check to release $lock" >&2
178
+ fi
179
+ told=1
180
+ fi
181
+ elif ! sync_lock_read_owner "$lock"; then
182
+ # No owner: a holder between its mkdir and its owner file, or one
183
+ # being released. Both last moments, so look again for a few
184
+ # seconds from the first sight before calling it a run that is
185
+ # still starting.
186
+ [ -n "$ownerless" ] || ownerless="$SECONDS"
187
+ [ $((SECONDS - ownerless)) -lt 3 ] || break
188
+ else
189
+ break
190
+ fi
191
+ sleep 1
192
+ done
116
193
  if sync_lock_read_owner "$lock"; then
117
194
  sync_lock_now
118
195
  case "$IS_LOCK_SINCE" in ''|*[!0-9]*) ;; *) age=", for $((IS_NOW - IS_LOCK_SINCE))s" ;; esac
@@ -147,6 +224,11 @@ project_lock_hold() {
147
224
  sync_lock_release() {
148
225
  [ -n "$SYNC_LOCK_HELD" ] || return 0
149
226
  if sync_lock_read_owner "$SYNC_LOCK_HELD" && [ "$IS_LOCK_TOKEN" = "$SYNC_LOCK_TOKEN" ]; then
227
+ # A check's owner goes first: `rm -rf` alone could expose a moment with
228
+ # the owner still there and the kind gone, and a command waiting for
229
+ # the check would be refused in it. Without the owner, a waiter sees a
230
+ # check or a lock still starting, and waits either way.
231
+ [ ! -f "$SYNC_LOCK_HELD/kind" ] || rm -f "$SYNC_LOCK_HELD/owner"
150
232
  rm -rf "$SYNC_LOCK_HELD"
151
233
  fi
152
234
  SYNC_LOCK_HELD=""
package/engine/ENGINE_SHA CHANGED
@@ -1 +1 @@
1
- c8058f91133b86479a03d7ae950191e2d21b68a6
1
+ ee05b0f09bc74b1914e04c067cce1e87a75bd18a
package/engine/VERSION CHANGED
@@ -1 +1 @@
1
- 0.20.0
1
+ 0.22.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
  }