@ainova-systems/intelligence 0.15.0 → 0.16.1
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.
- package/cli/commands/source.sh +177 -0
- package/cli/intelligence +5 -0
- package/cli/internal/check.sh +7 -0
- package/cli/lib/cli-common.sh +85 -0
- package/cli/lib/gitignore.sh +161 -18
- package/cli/lib/manifest.sh +97 -26
- package/engine/ENGINE_SHA +1 -1
- package/engine/VERSION +1 -1
- package/engine/lib/common.sh +9 -1
- package/package.json +1 -1
- package/packages/sync/references/conventions.md +2 -0
- package/packages/sync/skills/intelligence-update-context/SKILL.md +4 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# intelligence source <add|remove|list> — the `sources:` block of the manifest.
|
|
3
|
+
#
|
|
4
|
+
# Sources are an ORDERED list of directories the engine renders, and the order
|
|
5
|
+
# is the override rule: adapters copy in order, the last write wins. So the
|
|
6
|
+
# command's real job is placement, and its default is the end of the section —
|
|
7
|
+
# the project's own territory, where a directory the project added wins over
|
|
8
|
+
# every installed package. `--before` / `--after` cover the other intent:
|
|
9
|
+
# content that should behave like a package (a pack developed in the repository
|
|
10
|
+
# that ships it) belongs after the store entries and before the project's.
|
|
11
|
+
#
|
|
12
|
+
# Installed package content is NOT managed here: `.intelligence/` entries are
|
|
13
|
+
# written by `package add` and removed by `package remove`, and a hand-placed
|
|
14
|
+
# entry under the store does not survive the next lifecycle alignment.
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
source "$CLI_DIR/lib/cli-common.sh"
|
|
17
|
+
|
|
18
|
+
USAGE="usage: intelligence source <command>
|
|
19
|
+
add <rules|agents|skills> <dir> [--first|--last|--before <entry>|--after <entry>]
|
|
20
|
+
remove <rules|agents|skills> <dir>
|
|
21
|
+
list"
|
|
22
|
+
|
|
23
|
+
SECTIONS="rules agents skills"
|
|
24
|
+
|
|
25
|
+
assert_source_section() {
|
|
26
|
+
case "${1:-}" in
|
|
27
|
+
rules|agents|skills) ;;
|
|
28
|
+
"") die "$USAGE" ;;
|
|
29
|
+
*) die "unknown source kind '$1' — expected rules, agents or skills" ;;
|
|
30
|
+
esac
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# `normalize_source_dir` reduces `./x/`, `x/.` and `x/./y` to the one spelling
|
|
34
|
+
# the manifest stores; it lives beside the classifier in cli-common so a typed
|
|
35
|
+
# path and a manifest entry are judged as the same thing. Nothing else is
|
|
36
|
+
# repaired — a source entry decides which artifact wins, so a path the user did
|
|
37
|
+
# not type is the wrong kind of help.
|
|
38
|
+
|
|
39
|
+
# assert_valid_source_dir <root> <dir> — refuse what the engine cannot report.
|
|
40
|
+
# The shape checks are `source_entry_problem`, shared with `status --check` so
|
|
41
|
+
# one definition covers both the entry about to be written and the one already
|
|
42
|
+
# in the manifest; the store rule is this command's alone, because a
|
|
43
|
+
# `.intelligence/` entry is legitimate when `package add` wrote it.
|
|
44
|
+
assert_valid_source_dir() {
|
|
45
|
+
local root="$1" dir="$2" problem
|
|
46
|
+
[ -n "$dir" ] || die "$USAGE"
|
|
47
|
+
case "$dir" in
|
|
48
|
+
*\\*) die "invalid source '$dir' — manifest paths use '/': ${dir//\\//}" ;;
|
|
49
|
+
.intelligence|.intelligence/*)
|
|
50
|
+
die "'$dir' is inside the CLI-managed package store — install content with 'intelligence package add', and it is wired into sources automatically"
|
|
51
|
+
;;
|
|
52
|
+
esac
|
|
53
|
+
problem="$(source_entry_problem "$root" "$dir")"
|
|
54
|
+
[ -z "$problem" ] || die "invalid source '$dir': it $problem"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Print one section with its override direction and the state of each entry.
|
|
58
|
+
# Position is the whole point of the block, so every mutation ends by showing
|
|
59
|
+
# the order it produced rather than only naming what it wrote.
|
|
60
|
+
print_section() {
|
|
61
|
+
local manifest="$1" root="$2" section="$3" entry n=0 note
|
|
62
|
+
echo "sources.$section (a later entry overrides an earlier one):"
|
|
63
|
+
while IFS= read -r entry; do
|
|
64
|
+
[ -n "$entry" ] || continue
|
|
65
|
+
n=$((n + 1))
|
|
66
|
+
note=""
|
|
67
|
+
case "$entry" in
|
|
68
|
+
.intelligence/*) note=" package" ;;
|
|
69
|
+
esac
|
|
70
|
+
[ -d "$root/$entry" ] || note="$note MISSING"
|
|
71
|
+
printf ' %d. %s%s\n' "$n" "$entry" "$note"
|
|
72
|
+
done < <(sources_list_entries "$manifest" "$section")
|
|
73
|
+
[ "$n" -eq 0 ] && echo " (none)"
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
action="${1:-}"
|
|
78
|
+
[ -n "$action" ] || die "$USAGE"
|
|
79
|
+
shift
|
|
80
|
+
|
|
81
|
+
case "$action" in
|
|
82
|
+
add)
|
|
83
|
+
section="${1:-}"
|
|
84
|
+
dir="${2:-}"
|
|
85
|
+
assert_source_section "$section"
|
|
86
|
+
[ -n "$dir" ] || die "$USAGE"
|
|
87
|
+
shift 2
|
|
88
|
+
pos="last"
|
|
89
|
+
anchor=""
|
|
90
|
+
pos_given=0
|
|
91
|
+
while [ $# -gt 0 ]; do
|
|
92
|
+
case "$1" in
|
|
93
|
+
--first|--last)
|
|
94
|
+
[ "$pos_given" -eq 0 ] || die "only one position may be given"
|
|
95
|
+
pos="${1#--}"; pos_given=1; shift
|
|
96
|
+
;;
|
|
97
|
+
--before|--after)
|
|
98
|
+
[ "$pos_given" -eq 0 ] || die "only one position may be given"
|
|
99
|
+
[ -n "${2:-}" ] || die "$1 needs the entry to place this source next to"
|
|
100
|
+
pos="${1#--}"; anchor="$(normalize_source_dir "$2")"; pos_given=1; shift 2
|
|
101
|
+
;;
|
|
102
|
+
*) die "$USAGE" ;;
|
|
103
|
+
esac
|
|
104
|
+
done
|
|
105
|
+
|
|
106
|
+
require_cli_project
|
|
107
|
+
dir="$(normalize_source_dir "$dir")"
|
|
108
|
+
assert_valid_source_dir "$IP_ROOT" "$dir"
|
|
109
|
+
[ "$anchor" != "$dir" ] || die "cannot place '$dir' relative to itself"
|
|
110
|
+
ensure_project_current "$IP_ROOT"
|
|
111
|
+
manifest="$IP_ROOT/intelligence.yaml"
|
|
112
|
+
|
|
113
|
+
if [ -n "$anchor" ] && ! sources_has_entry "$manifest" "$section" "$anchor"; then
|
|
114
|
+
echo "ERROR: sources.$section does not list '$anchor'." >&2
|
|
115
|
+
print_section "$manifest" "$IP_ROOT" "$section" >&2
|
|
116
|
+
exit 1
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
if sources_has_entry "$manifest" "$section" "$dir"; then
|
|
120
|
+
if [ "$pos_given" -eq 0 ]; then
|
|
121
|
+
echo "already listed: sources.$section holds $dir"
|
|
122
|
+
print_section "$manifest" "$IP_ROOT" "$section"
|
|
123
|
+
exit 0
|
|
124
|
+
fi
|
|
125
|
+
sources_remove_entry "$manifest" "$section" "$dir"
|
|
126
|
+
sources_add_entry "$manifest" "$section" "$dir" "$pos" "$anchor"
|
|
127
|
+
echo "moved: $dir"
|
|
128
|
+
else
|
|
129
|
+
sources_add_entry "$manifest" "$section" "$dir" "$pos" "$anchor"
|
|
130
|
+
echo "added: $dir"
|
|
131
|
+
fi
|
|
132
|
+
if [ ! -d "$IP_ROOT/$dir" ]; then
|
|
133
|
+
echo " WARN: $dir does not exist yet — sync skips a missing source and renders it once the directory appears." >&2
|
|
134
|
+
fi
|
|
135
|
+
print_section "$manifest" "$IP_ROOT" "$section"
|
|
136
|
+
echo "Run 'intelligence sync' to render it."
|
|
137
|
+
;;
|
|
138
|
+
remove)
|
|
139
|
+
section="${1:-}"
|
|
140
|
+
dir="${2:-}"
|
|
141
|
+
assert_source_section "$section"
|
|
142
|
+
[ -n "$dir" ] || die "$USAGE"
|
|
143
|
+
[ $# -le 2 ] || die "$USAGE"
|
|
144
|
+
dir="$(normalize_source_dir "$dir")"
|
|
145
|
+
case "$dir" in
|
|
146
|
+
.intelligence|.intelligence/*)
|
|
147
|
+
die "'$dir' is installed package content — remove the package instead: intelligence package remove <@scope/name>"
|
|
148
|
+
;;
|
|
149
|
+
esac
|
|
150
|
+
|
|
151
|
+
require_cli_project
|
|
152
|
+
ensure_project_current "$IP_ROOT"
|
|
153
|
+
manifest="$IP_ROOT/intelligence.yaml"
|
|
154
|
+
|
|
155
|
+
if ! sources_has_entry "$manifest" "$section" "$dir"; then
|
|
156
|
+
echo "not listed: sources.$section does not hold $dir"
|
|
157
|
+
print_section "$manifest" "$IP_ROOT" "$section"
|
|
158
|
+
exit 0
|
|
159
|
+
fi
|
|
160
|
+
sources_remove_entry "$manifest" "$section" "$dir"
|
|
161
|
+
echo "removed: $dir"
|
|
162
|
+
echo " The directory is untouched; its artifacts leave the generated output at the next sync."
|
|
163
|
+
print_section "$manifest" "$IP_ROOT" "$section"
|
|
164
|
+
echo "Run 'intelligence sync' to rewrite the outputs."
|
|
165
|
+
;;
|
|
166
|
+
list)
|
|
167
|
+
[ $# -eq 0 ] || die "$USAGE"
|
|
168
|
+
require_cli_project
|
|
169
|
+
manifest="$IP_ROOT/intelligence.yaml"
|
|
170
|
+
for section in $SECTIONS; do
|
|
171
|
+
print_section "$manifest" "$IP_ROOT" "$section"
|
|
172
|
+
done
|
|
173
|
+
;;
|
|
174
|
+
*)
|
|
175
|
+
die "$USAGE"
|
|
176
|
+
;;
|
|
177
|
+
esac
|
package/cli/intelligence
CHANGED
|
@@ -55,6 +55,7 @@ Usage: intelligence <command> [args]
|
|
|
55
55
|
upgrade [--next] [--preview|--apply] Replace the installed CLI with the newest npm version
|
|
56
56
|
--next Follow the prerelease line instead of the stable one
|
|
57
57
|
package <command> add | remove | list | search
|
|
58
|
+
source <command> add | remove | list — the sources: block (own directories)
|
|
58
59
|
adapter <command> list | create | enable | disable | remove
|
|
59
60
|
status [--check] Project state; --check runs deep consistency checks
|
|
60
61
|
registry <list|add|remove> Manage the trusted registry list (add <repo-url>)
|
|
@@ -89,6 +90,10 @@ if [ ! -f "$cmd_file" ]; then
|
|
|
89
90
|
echo "ERROR: unknown command 'packages'. Use the singular command: intelligence package list" >&2
|
|
90
91
|
exit 1
|
|
91
92
|
;;
|
|
93
|
+
sources)
|
|
94
|
+
echo "ERROR: unknown command 'sources'. Use the singular command: intelligence source list" >&2
|
|
95
|
+
exit 1
|
|
96
|
+
;;
|
|
92
97
|
registries)
|
|
93
98
|
echo "ERROR: unknown command 'registries'. Use the singular command: intelligence registry list" >&2
|
|
94
99
|
exit 1
|
package/cli/internal/check.sh
CHANGED
|
@@ -175,6 +175,13 @@ for section in rules agents skills; do
|
|
|
175
175
|
case "$src" in
|
|
176
176
|
git+*|@*) continue ;;
|
|
177
177
|
esac
|
|
178
|
+
# A hand-edited entry the engine cannot render is worse than a missing
|
|
179
|
+
# one: it is skipped without a word and the sync still reports ok.
|
|
180
|
+
problem="$(source_entry_problem "$IP_ROOT" "$src")"
|
|
181
|
+
if [ -n "$problem" ]; then
|
|
182
|
+
warn "sources.$section '$src' $problem"
|
|
183
|
+
continue
|
|
184
|
+
fi
|
|
178
185
|
if [ ! -d "$IP_ROOT/$src" ]; then
|
|
179
186
|
case "$src" in
|
|
180
187
|
# Store content is restorable state: absent means un-installed.
|
package/cli/lib/cli-common.sh
CHANGED
|
@@ -156,6 +156,91 @@ require_cli_project() {
|
|
|
156
156
|
|
|
157
157
|
# --- Manifest basics (engine-readable shapes) ----------------------------
|
|
158
158
|
|
|
159
|
+
# normalize_source_dir_var <dir> — set IS_SOURCE_DIR to the spelling the
|
|
160
|
+
# manifest stores: `.` and empty segments dropped, no trailing slash. A path
|
|
161
|
+
# that reduces to nothing named the repository root, which is spelled `.` — so
|
|
162
|
+
# the caller judges one shape instead of several.
|
|
163
|
+
#
|
|
164
|
+
# Rebuilt segment by segment rather than with `${dir//\/.\//\/}`: bash 3.2, which
|
|
165
|
+
# is what macOS ships and CI runs, keeps the backslash of an escaped separator in
|
|
166
|
+
# the REPLACEMENT and produced `intelligence\/rules`, while bash 5 consumed it.
|
|
167
|
+
# shellcheck disable=SC2034
|
|
168
|
+
normalize_source_dir_var() {
|
|
169
|
+
local dir="$1" out="" seg rest lead=""
|
|
170
|
+
# A leading slash is meaning, not an empty segment: dropping it would turn
|
|
171
|
+
# an absolute path into a relative one and hide it from the classifier.
|
|
172
|
+
case "$dir" in /*) lead="/" ;; esac
|
|
173
|
+
rest="$dir"
|
|
174
|
+
while [ -n "$rest" ]; do
|
|
175
|
+
seg="${rest%%/*}"
|
|
176
|
+
if [ "$seg" = "$rest" ]; then rest=""; else rest="${rest#*/}"; fi
|
|
177
|
+
case "$seg" in ""|".") continue ;; esac
|
|
178
|
+
out="${out:+$out/}$seg"
|
|
179
|
+
done
|
|
180
|
+
if [ -n "$lead" ]; then
|
|
181
|
+
IS_SOURCE_DIR="$lead$out"
|
|
182
|
+
else
|
|
183
|
+
IS_SOURCE_DIR="${out:-.}"
|
|
184
|
+
fi
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
normalize_source_dir() {
|
|
188
|
+
normalize_source_dir_var "$1"
|
|
189
|
+
printf '%s' "$IS_SOURCE_DIR"
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# source_entry_problem <root> <entry> — one line naming why the engine cannot
|
|
193
|
+
# render this `sources:` entry, or nothing when the entry is sound. Most cases
|
|
194
|
+
# here are invisible at sync time: the engine resolves an entry as
|
|
195
|
+
# `$REPO_ROOT/<entry>` and skips whatever is not a directory, so a bad entry is
|
|
196
|
+
# a silent omission from a run that still reports ok. `source add` refuses one
|
|
197
|
+
# before it is written; `status --check` reports one already in the manifest.
|
|
198
|
+
source_entry_problem() {
|
|
199
|
+
local root="$1" entry="$2" seg rest norm
|
|
200
|
+
case "$entry" in
|
|
201
|
+
"") printf 'is empty'; return 0 ;;
|
|
202
|
+
*\\*) printf 'uses backslashes — manifest paths use "/"'; return 0 ;;
|
|
203
|
+
/*|[A-Za-z]:/*)
|
|
204
|
+
printf 'is an absolute path — the engine resolves every entry as $REPO_ROOT/<entry>, so it renders nothing'
|
|
205
|
+
return 0
|
|
206
|
+
;;
|
|
207
|
+
esac
|
|
208
|
+
# The root is the loud failure rather than the silent one: it IS a
|
|
209
|
+
# directory, so the engine reads every top-level *.md in it — README,
|
|
210
|
+
# CHANGELOG, docs — as an artifact of this section.
|
|
211
|
+
normalize_source_dir_var "$entry"
|
|
212
|
+
norm="$IS_SOURCE_DIR"
|
|
213
|
+
if [ "$norm" = "." ]; then
|
|
214
|
+
printf 'is the repository root — every top-level *.md there would be read as an artifact of this section'
|
|
215
|
+
return 0
|
|
216
|
+
fi
|
|
217
|
+
rest="$norm"
|
|
218
|
+
while [ -n "$rest" ]; do
|
|
219
|
+
seg="${rest%%/*}"
|
|
220
|
+
if [ "$seg" = ".." ]; then
|
|
221
|
+
printf 'leaves the repository — its artifacts render, but with bare names instead of links in AGENTS.md'
|
|
222
|
+
return 0
|
|
223
|
+
fi
|
|
224
|
+
[ "$seg" = "$rest" ] && break
|
|
225
|
+
rest="${rest#*/}"
|
|
226
|
+
done
|
|
227
|
+
# The entry is stored as a double-quoted YAML scalar and read back by a
|
|
228
|
+
# parser that strips quotes and a trailing ` # comment`.
|
|
229
|
+
case "$entry" in
|
|
230
|
+
*[\"\'\#\$\`]*|*:*)
|
|
231
|
+
printf 'holds a character the manifest cannot carry verbatim — allowed: letters, digits, . _ - / @'
|
|
232
|
+
return 0
|
|
233
|
+
;;
|
|
234
|
+
esac
|
|
235
|
+
# A symlink leaves the repository without a '..' anywhere in the path.
|
|
236
|
+
# repo_rel_dir compares by device+inode, so it answers for the real target.
|
|
237
|
+
if [ -d "$root/$entry" ] && [ -z "$(repo_rel_dir "$root" "$root/$entry")" ]; then
|
|
238
|
+
printf 'resolves outside the repository root'
|
|
239
|
+
return 0
|
|
240
|
+
fi
|
|
241
|
+
return 0
|
|
242
|
+
}
|
|
243
|
+
|
|
159
244
|
manifest_intelligence_dir() {
|
|
160
245
|
local manifest="$1" v
|
|
161
246
|
v="$(get_yaml_field "$manifest" "project" "intelligence_dir")"
|
package/cli/lib/gitignore.sh
CHANGED
|
@@ -1,12 +1,56 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Generated-output policy derived from each adapter's ownership contract.
|
|
3
3
|
|
|
4
|
+
# The line that separates what this policy manages from what the project wrote
|
|
5
|
+
# before Intelligence touched the file: everything the CLI appends lands after
|
|
6
|
+
# it, so it is also the boundary of what the CLI may rewrite.
|
|
7
|
+
IS_GITIGNORE_HEADER='# Intelligence generated state and tool output'
|
|
8
|
+
|
|
9
|
+
# Line endings held in variables, never written as `$'\r'` at the point of use:
|
|
10
|
+
# inside a command substitution bash does not expand that form, so the very same
|
|
11
|
+
# expression stops stripping the CR depending on where it is called from.
|
|
12
|
+
IS_CR=$'\r'
|
|
13
|
+
IS_LF=$'\n'
|
|
14
|
+
IS_CRLF=$'\r\n'
|
|
15
|
+
|
|
16
|
+
# An ignore file checked out on Windows can hold CRLF, and a CR belongs to the
|
|
17
|
+
# pattern Git matches — so these two helpers decide presence ignoring it, and
|
|
18
|
+
# append in whatever ending the file already uses. Comparing with the CR
|
|
19
|
+
# attached made every existing line look absent on Linux and macOS (where it is
|
|
20
|
+
# part of the line, unlike under MSYS), so each alignment appended an LF copy of
|
|
21
|
+
# a line that was already there and the file grew forever.
|
|
22
|
+
|
|
23
|
+
# ignore_file_eol_var <file> — set IS_IGNORE_EOL from the file's first line.
|
|
24
|
+
ignore_file_eol_var() {
|
|
25
|
+
local file="$1" first=""
|
|
26
|
+
IS_IGNORE_EOL="$IS_LF"
|
|
27
|
+
[ -s "$file" ] || return 0
|
|
28
|
+
IFS= read -r first < "$file" || true
|
|
29
|
+
case "$first" in
|
|
30
|
+
*"$IS_CR") IS_IGNORE_EOL="$IS_CRLF" ;;
|
|
31
|
+
esac
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# ignore_file_has_line <file> <line> — true when the file lists exactly this
|
|
35
|
+
# line, with or without a trailing CR.
|
|
36
|
+
ignore_file_has_line() {
|
|
37
|
+
local file="$1" line="$2"
|
|
38
|
+
[ -f "$file" ] || return 1
|
|
39
|
+
grep -Fqx -- "$line" "$file" 2>/dev/null && return 0
|
|
40
|
+
grep -Fqx -- "$line$IS_CR" "$file" 2>/dev/null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# ignore_file_append_line <file> <line> — append in the file's own ending.
|
|
44
|
+
ignore_file_append_line() {
|
|
45
|
+
local file="$1" line="$2"
|
|
46
|
+
ignore_file_eol_var "$file"
|
|
47
|
+
printf '%s%s' "$line" "$IS_IGNORE_EOL" >> "$file"
|
|
48
|
+
}
|
|
49
|
+
|
|
4
50
|
gitignore_add_line() {
|
|
5
51
|
local root="$1" line="$2" file="$1/.gitignore"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
fi
|
|
9
|
-
printf '%s\n' "$line" >> "$file"
|
|
52
|
+
ignore_file_has_line "$file" "$line" && return 0
|
|
53
|
+
ignore_file_append_line "$file" "$line"
|
|
10
54
|
}
|
|
11
55
|
|
|
12
56
|
gitignore_path_is_ignored() {
|
|
@@ -21,10 +65,102 @@ gitignore_tail_is() {
|
|
|
21
65
|
[ -f "$file" ] || return 1
|
|
22
66
|
local want tail_lines
|
|
23
67
|
want="$(printf '%s\n' "$@")"
|
|
24
|
-
|
|
68
|
+
# CR stripped for the comparison only — the chain is the same chain whether
|
|
69
|
+
# the file is LF or CRLF.
|
|
70
|
+
tail_lines="$(tail -n "$#" -- "$file" | tr -d '\r')"
|
|
25
71
|
[ "$tail_lines" = "$want" ]
|
|
26
72
|
}
|
|
27
73
|
|
|
74
|
+
# gitignore_collapse_duplicates <root> <probe-path> <line…> — keep only the LAST
|
|
75
|
+
# occurrence of each given line inside the region this policy owns (its header
|
|
76
|
+
# to end of file).
|
|
77
|
+
#
|
|
78
|
+
# This removes residue, never meaning: Git applies the last matching rule, so
|
|
79
|
+
# identical earlier copies of a line decide nothing. The copies exist because
|
|
80
|
+
# the 0.12.0 repair appended the whole negation chain on every alignment — a
|
|
81
|
+
# project carries one chain per run it made, and 0.12.1 only stopped the growth.
|
|
82
|
+
#
|
|
83
|
+
# Lines above the header predate Intelligence and are left untouched, including
|
|
84
|
+
# a hand-written copy of a line this policy also writes. The probe path is
|
|
85
|
+
# re-checked afterwards: a collapse that somehow changed what Git ignores is
|
|
86
|
+
# rolled back rather than committed.
|
|
87
|
+
gitignore_collapse_duplicates() {
|
|
88
|
+
local root="$1" probe="$2"; shift 2
|
|
89
|
+
local file="$root/.gitignore" tmp backup before=0 after=0
|
|
90
|
+
local -a lines=() managed=("$@") last_idx=()
|
|
91
|
+
local line key i mi n hdr=-1 keep final_newline=1
|
|
92
|
+
[ -f "$file" ] || return 0
|
|
93
|
+
|
|
94
|
+
# Read and write the file's own bytes, without awk: on Windows it reads in
|
|
95
|
+
# text mode and would hand back every line stripped of its CR, rewriting a
|
|
96
|
+
# CRLF file as LF. A line's CR is significant to Git, and either way the
|
|
97
|
+
# lines this function does not remove must survive byte-for-byte.
|
|
98
|
+
line=""
|
|
99
|
+
while IFS= read -r line; do
|
|
100
|
+
lines[${#lines[@]}]="$line"
|
|
101
|
+
line=""
|
|
102
|
+
done < "$file"
|
|
103
|
+
# `read` returns 1 at EOF but still fills `line` when the file's last line
|
|
104
|
+
# carries no newline; preserve that ending instead of adding one.
|
|
105
|
+
if [ -n "$line" ]; then
|
|
106
|
+
lines[${#lines[@]}]="$line"
|
|
107
|
+
final_newline=0
|
|
108
|
+
fi
|
|
109
|
+
n=${#lines[@]}
|
|
110
|
+
for ((i = 0; i < n; i++)); do
|
|
111
|
+
if [ "${lines[i]%"$IS_CR"}" = "$IS_GITIGNORE_HEADER" ]; then hdr=$i; break; fi
|
|
112
|
+
done
|
|
113
|
+
[ "$hdr" -ge 0 ] || return 0
|
|
114
|
+
|
|
115
|
+
# Where each managed line last occurs below the header. Indexed arrays and
|
|
116
|
+
# arithmetic `for` only — Bash 3.2 has no associative arrays.
|
|
117
|
+
for ((mi = 0; mi < ${#managed[@]}; mi++)); do
|
|
118
|
+
last_idx[mi]=-1
|
|
119
|
+
for ((i = hdr + 1; i < n; i++)); do
|
|
120
|
+
[ "${lines[i]%"$IS_CR"}" = "${managed[mi]}" ] && last_idx[mi]=$i
|
|
121
|
+
done
|
|
122
|
+
done
|
|
123
|
+
|
|
124
|
+
tmp="$file.cli.tmp"
|
|
125
|
+
{
|
|
126
|
+
for ((i = 0; i < n; i++)); do
|
|
127
|
+
keep=1
|
|
128
|
+
if [ "$i" -gt "$hdr" ]; then
|
|
129
|
+
key="${lines[i]%"$IS_CR"}"
|
|
130
|
+
for ((mi = 0; mi < ${#managed[@]}; mi++)); do
|
|
131
|
+
if [ "$key" = "${managed[mi]}" ] && [ "$i" -ne "${last_idx[mi]}" ]; then
|
|
132
|
+
keep=0
|
|
133
|
+
break
|
|
134
|
+
fi
|
|
135
|
+
done
|
|
136
|
+
fi
|
|
137
|
+
[ "$keep" -eq 1 ] || continue
|
|
138
|
+
if [ "$i" -eq $((n - 1)) ] && [ "$final_newline" -eq 0 ]; then
|
|
139
|
+
printf '%s' "${lines[i]}"
|
|
140
|
+
else
|
|
141
|
+
printf '%s\n' "${lines[i]}"
|
|
142
|
+
fi
|
|
143
|
+
done
|
|
144
|
+
} > "$tmp"
|
|
145
|
+
if [ ! -s "$tmp" ] || cmp -s "$tmp" "$file"; then
|
|
146
|
+
rm -f "$tmp"
|
|
147
|
+
return 0
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
before=0
|
|
151
|
+
gitignore_path_is_ignored "$root" "$probe" || before=$?
|
|
152
|
+
backup="$file.cli.bak"
|
|
153
|
+
cp -- "$file" "$backup"
|
|
154
|
+
mv -- "$tmp" "$file"
|
|
155
|
+
after=0
|
|
156
|
+
gitignore_path_is_ignored "$root" "$probe" || after=$?
|
|
157
|
+
if [ "$after" != "$before" ]; then
|
|
158
|
+
mv -- "$backup" "$file"
|
|
159
|
+
return 0
|
|
160
|
+
fi
|
|
161
|
+
rm -f "$backup"
|
|
162
|
+
}
|
|
163
|
+
|
|
28
164
|
# Git cannot re-include a child of an excluded directory, so an include needs an
|
|
29
165
|
# explicit negation for every parent. A negation only wins when it comes after
|
|
30
166
|
# the rule excluding the parent, and the only repair an append can make is to
|
|
@@ -59,7 +195,7 @@ gitignore_add_effective_include() {
|
|
|
59
195
|
gitignore_path_is_ignored "$root" "$value" || rc=$?
|
|
60
196
|
case "$rc" in
|
|
61
197
|
0) ;;
|
|
62
|
-
1|2) return 0 ;;
|
|
198
|
+
1|2) gitignore_collapse_duplicates "$root" "$value" "${negations[@]}"; return 0 ;;
|
|
63
199
|
*) return "$rc" ;;
|
|
64
200
|
esac
|
|
65
201
|
|
|
@@ -67,17 +203,25 @@ gitignore_add_effective_include() {
|
|
|
67
203
|
# chain last. Already last means appending cannot help — leave it, and let
|
|
68
204
|
# `status --check` report the re-inclusion this policy could not make
|
|
69
205
|
# effective rather than growing the file forever.
|
|
70
|
-
gitignore_tail_is "$root/.gitignore" "${negations[@]}"
|
|
71
|
-
|
|
206
|
+
if ! gitignore_tail_is "$root/.gitignore" "${negations[@]}"; then
|
|
207
|
+
for line in "${negations[@]}"; do
|
|
208
|
+
ignore_file_append_line "$root/.gitignore" "$line"
|
|
209
|
+
done
|
|
210
|
+
fi
|
|
211
|
+
# Either way the chain now stands last, so any earlier copy of it is the
|
|
212
|
+
# residue an older CLI appended; collapsing runs on both paths so a project
|
|
213
|
+
# converges on one copy whether or not this run had to move anything.
|
|
214
|
+
gitignore_collapse_duplicates "$root" "$value" "${negations[@]}"
|
|
72
215
|
}
|
|
73
216
|
|
|
74
217
|
ensure_gitignore_header() {
|
|
75
218
|
local root="$1" file="$1/.gitignore"
|
|
76
|
-
if
|
|
219
|
+
if ! ignore_file_has_line "$file" "$IS_GITIGNORE_HEADER"; then
|
|
77
220
|
if [ -f "$file" ] && [ -s "$file" ] && [ -n "$(tail -c 1 "$file" 2>/dev/null)" ]; then
|
|
78
|
-
|
|
221
|
+
ignore_file_eol_var "$file"
|
|
222
|
+
printf '%s' "$IS_IGNORE_EOL" >> "$file"
|
|
79
223
|
fi
|
|
80
|
-
|
|
224
|
+
ignore_file_append_line "$file" "$IS_GITIGNORE_HEADER"
|
|
81
225
|
fi
|
|
82
226
|
}
|
|
83
227
|
|
|
@@ -125,10 +269,8 @@ publisher_ignore_file_names() {
|
|
|
125
269
|
|
|
126
270
|
publisher_ignore_add_line() {
|
|
127
271
|
local file="$1" line="$2"
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
fi
|
|
131
|
-
printf '%s\n' "$line" >> "$file"
|
|
272
|
+
ignore_file_has_line "$file" "$line" && return 0
|
|
273
|
+
ignore_file_append_line "$file" "$line"
|
|
132
274
|
PUBLISH_IGNORE_FILE_CHANGED=1
|
|
133
275
|
}
|
|
134
276
|
|
|
@@ -148,11 +290,12 @@ ensure_manifest_publisher_ignores() {
|
|
|
148
290
|
file="$root/$rel"
|
|
149
291
|
[ -f "$file" ] || continue
|
|
150
292
|
PUBLISH_IGNORE_FILE_CHANGED=0
|
|
151
|
-
if !
|
|
293
|
+
if ! ignore_file_has_line "$file" '# Intelligence development context and generated output'; then
|
|
152
294
|
if [ -s "$file" ] && [ -n "$(tail -c 1 "$file" 2>/dev/null)" ]; then
|
|
153
|
-
|
|
295
|
+
ignore_file_eol_var "$file"
|
|
296
|
+
printf '%s' "$IS_IGNORE_EOL" >> "$file"
|
|
154
297
|
fi
|
|
155
|
-
|
|
298
|
+
ignore_file_append_line "$file" '# Intelligence development context and generated output'
|
|
156
299
|
PUBLISH_IGNORE_FILE_CHANGED=1
|
|
157
300
|
fi
|
|
158
301
|
publisher_ignore_add_path "$file" '.intelligence'
|
package/cli/lib/manifest.sh
CHANGED
|
@@ -184,11 +184,14 @@ qmap_field() { _qmap_read field "$1" "$2" "$3" "$4"; }
|
|
|
184
184
|
qmap_value() { _qmap_read value "$1" "$2" "$3"; }
|
|
185
185
|
|
|
186
186
|
# _qmap_stage <file> <awk-program> [awk args…] — run an editing pass, verify
|
|
187
|
-
# it produced output, commit.
|
|
187
|
+
# it produced output, commit. An editor that cannot place its edit exits
|
|
188
|
+
# non-zero; the staged file is dropped so a refused edit never reaches the
|
|
189
|
+
# manifest half-applied.
|
|
188
190
|
_qmap_stage() {
|
|
189
191
|
local file="$1"; shift
|
|
190
|
-
local tmp="$file.cli.tmp"
|
|
191
|
-
awk "$@" "$file" > "$tmp"
|
|
192
|
+
local tmp="$file.cli.tmp" rc=0
|
|
193
|
+
awk "$@" "$file" > "$tmp" || rc=$?
|
|
194
|
+
[ "$rc" -eq 0 ] || { rm -f "$tmp"; die "internal: manifest edit refused (awk exit $rc) for $file"; }
|
|
192
195
|
[ -s "$tmp" ] || { rm -f "$tmp"; die "internal: manifest edit produced an empty file for $file"; }
|
|
193
196
|
mv "$tmp" "$file"
|
|
194
197
|
}
|
|
@@ -399,44 +402,112 @@ registries_remove() {
|
|
|
399
402
|
{ print }
|
|
400
403
|
'
|
|
401
404
|
}
|
|
402
|
-
#
|
|
403
|
-
#
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
405
|
+
# --- sources: an ORDERED list of content directories ------------------------
|
|
406
|
+
# Adapters copy sources in order and the last write wins, so position carries
|
|
407
|
+
# meaning: a later entry overrides a same-named artifact from an earlier one.
|
|
408
|
+
# Reading goes through the engine's own list parser, so the CLI sees exactly
|
|
409
|
+
# what the engine will render instead of a second reading of the same file.
|
|
410
|
+
|
|
411
|
+
# sources_list_entries <file> <section> — entries of sources.<section>, one per
|
|
412
|
+
# line, in manifest order.
|
|
413
|
+
sources_list_entries() {
|
|
414
|
+
[ -f "$1" ] || return 0
|
|
415
|
+
read_yaml_list "$1" "$2"
|
|
408
416
|
}
|
|
409
417
|
|
|
418
|
+
# sources_has_entry <file> <section> <entry> — true (0) when that section lists
|
|
419
|
+
# exactly this entry. Section-scoped and exact on purpose: one directory may
|
|
420
|
+
# legitimately appear under two sections, and a substring test over the whole
|
|
421
|
+
# file would silently refuse the second add — and would match a bare `- docs`
|
|
422
|
+
# against a neighbouring `- docs/api`.
|
|
423
|
+
sources_has_entry() {
|
|
424
|
+
local file="$1" section="$2" entry="$3" listed
|
|
425
|
+
[ -f "$file" ] || return 1
|
|
426
|
+
while IFS= read -r listed; do
|
|
427
|
+
[ "$listed" = "$entry" ] && return 0
|
|
428
|
+
done < <(sources_list_entries "$file" "$section")
|
|
429
|
+
return 1
|
|
430
|
+
}
|
|
410
431
|
|
|
411
|
-
#
|
|
412
|
-
#
|
|
413
|
-
#
|
|
414
|
-
#
|
|
415
|
-
# the
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
432
|
+
# sources_add_entry <file> <section> <entry> [position] [anchor]
|
|
433
|
+
# Idempotent insert into sources.<section>, creating `sources:` and the section
|
|
434
|
+
# as needed. Position is `last` (default), `first`, `before` or `after`; the
|
|
435
|
+
# anchored forms take <anchor>, an entry the section already lists. The caller
|
|
436
|
+
# validates the anchor, and an edit that cannot be placed exits 3 rather than
|
|
437
|
+
# landing the entry somewhere else — a silently misplaced source changes which
|
|
438
|
+
# artifact wins.
|
|
439
|
+
sources_add_entry() {
|
|
440
|
+
local file="$1" section="$2" entry="$3" pos="${4:-last}" anchor="${5:-}"
|
|
441
|
+
sources_has_entry "$file" "$section" "$entry" && return 0
|
|
442
|
+
_qmap_stage "$file" -v section="$section" -v entry="$entry" -v pos="$pos" -v anchor="$anchor" '
|
|
420
443
|
function line() { return " - \"" entry "\"" }
|
|
444
|
+
function place() { print line(); done = 1 }
|
|
445
|
+
# Blank lines inside sources: are held back so an insert lands next to
|
|
446
|
+
# the entries it belongs with, not after the blank line that separates
|
|
447
|
+
# one section from the next.
|
|
448
|
+
function flush_tail( i) { for (i = 1; i <= ntail; i++) print tail[i]; blanks = ntail; ntail = 0 }
|
|
449
|
+
function open_section() { flush_tail(); print " " section ":"; place(); if (blanks) print "" }
|
|
450
|
+
function value(s, v) {
|
|
451
|
+
v = s
|
|
452
|
+
sub(/^[ \t]*-[ \t]*/, "", v)
|
|
453
|
+
gsub(/["\x27]/, "", v)
|
|
454
|
+
sub(/[ \t]+#.*$/, "", v)
|
|
455
|
+
sub(/[ \t]+$/, "", v)
|
|
456
|
+
return v
|
|
457
|
+
}
|
|
458
|
+
BEGIN { anchored = (pos == "before" || pos == "after") }
|
|
421
459
|
{ sub(/\r$/, "") }
|
|
422
460
|
/^sources:[ \t]*$/ { ins = 1; sourceseen = 1; print; next }
|
|
423
461
|
ins && /^[^ #]/ {
|
|
424
|
-
if (
|
|
462
|
+
if (insec) { if (!done && pos == "last") place(); insec = 0 }
|
|
463
|
+
if (!secseen && !done && !anchored) { open_section(); secseen = 1 }
|
|
464
|
+
else flush_tail()
|
|
425
465
|
ins = 0
|
|
426
466
|
}
|
|
427
|
-
ins && $0 ~ "^ " section ":[ \t]*$" {
|
|
428
|
-
|
|
467
|
+
ins && !insec && $0 ~ "^ " section ":[ \t]*$" {
|
|
468
|
+
flush_tail(); secseen = 1; insec = 1; print
|
|
469
|
+
if (pos == "first") place()
|
|
470
|
+
next
|
|
471
|
+
}
|
|
472
|
+
ins && insec && /^ [A-Za-z_]/ {
|
|
473
|
+
if (!done && pos == "last") place()
|
|
474
|
+
insec = 0
|
|
475
|
+
flush_tail()
|
|
476
|
+
}
|
|
477
|
+
insec && /^[ \t]*-/ {
|
|
478
|
+
flush_tail()
|
|
479
|
+
v = value($0)
|
|
480
|
+
if (pos == "before" && v == anchor && !done) place()
|
|
481
|
+
print
|
|
482
|
+
if (pos == "after" && v == anchor && !done) place()
|
|
483
|
+
next
|
|
484
|
+
}
|
|
485
|
+
ins && /^[ \t]*$/ { tail[++ntail] = $0; next }
|
|
486
|
+
ins { flush_tail(); print; next }
|
|
487
|
+
{ last = $0; print }
|
|
429
488
|
END {
|
|
430
|
-
if (
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
print " " section ":"
|
|
434
|
-
|
|
489
|
+
if (insec && !done && pos == "last") place()
|
|
490
|
+
flush_tail()
|
|
491
|
+
if (!done && !anchored) {
|
|
492
|
+
if (ins && !secseen) { print " " section ":"; place() }
|
|
493
|
+
else if (!sourceseen) {
|
|
494
|
+
if (last != "") print ""
|
|
495
|
+
print "sources:"; print " " section ":"; place()
|
|
496
|
+
}
|
|
435
497
|
}
|
|
498
|
+
if (!done) exit 3
|
|
436
499
|
}
|
|
437
500
|
'
|
|
438
501
|
}
|
|
439
502
|
|
|
503
|
+
# sources_add_entry_first <file> <section> <entry> — insert at the TOP of
|
|
504
|
+
# sources.<section>. Package wiring uses it so project-owned entries stay later
|
|
505
|
+
# in the list — adapters copy sources in order and the last write wins, which
|
|
506
|
+
# is exactly the documented "your file overrides the package's" behavior.
|
|
507
|
+
sources_add_entry_first() {
|
|
508
|
+
sources_add_entry "$1" "$2" "$3" first
|
|
509
|
+
}
|
|
510
|
+
|
|
440
511
|
# sources_remove_entry <file> <section> <entry> — remove `- "entry"` from
|
|
441
512
|
# sources.<section>.
|
|
442
513
|
sources_remove_entry() {
|
package/engine/ENGINE_SHA
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
365ec21e2f2d4d9ef323ed9f1387b3c3c4bef845
|
package/engine/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.16.1
|
package/engine/lib/common.sh
CHANGED
|
@@ -1335,7 +1335,15 @@ warn_unsynced() {
|
|
|
1335
1335
|
done < <(find "$repo_root" \( -name ".git" -o -name "node_modules" -o -name "vendor" -o -name "dist" -o -name ".claude" -o -name ".cursor" -o -name ".github" -o -name ".codex" -o -name ".agents" -o -name ".intelligence" \) -prune -o -type d \( -name "rules" -o -name "agents" -o -name "skills" -o -name "Rules" -o -name "Agents" -o -name "Skills" \) -print 2>/dev/null)
|
|
1336
1336
|
|
|
1337
1337
|
if [ $warnings -gt 0 ]; then
|
|
1338
|
-
|
|
1338
|
+
# Name the command that does it. A diagnostic that ends in "edit this
|
|
1339
|
+
# file yourself" is the one step nobody repeats the same way twice —
|
|
1340
|
+
# and hand-placing an entry in an ordered list decides which artifact
|
|
1341
|
+
# wins. The engine may also run without the CLI around it.
|
|
1342
|
+
if [ "${IS_CLI:-0}" = "1" ]; then
|
|
1343
|
+
echo " Wire one in: intelligence source add <rules|agents|skills> <path>"
|
|
1344
|
+
else
|
|
1345
|
+
echo " Add these paths to sources: in ${config_file##*/}"
|
|
1346
|
+
fi
|
|
1339
1347
|
fi
|
|
1340
1348
|
}
|
|
1341
1349
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ainova-systems/intelligence",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
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"
|
|
@@ -73,6 +73,8 @@ sources:
|
|
|
73
73
|
|
|
74
74
|
Missing project-owned source directories are skipped, so a package-only project need not create empty `rules/`, `agents/` or `skills/` directories. Source order matters: later files with the same artifact name overwrite earlier ones. Package sources are wired before project sources so the project can override a package artifact deliberately.
|
|
75
75
|
|
|
76
|
+
Project-owned entries are managed with `intelligence source add|remove|list` rather than by hand — it validates the path against the way the engine resolves it and prints the resulting order. `add` appends by default; `--before <entry>` / `--after <entry>` place content that should behave like a package ahead of the project's own directories.
|
|
77
|
+
|
|
76
78
|
The CLI owns package and registry blocks:
|
|
77
79
|
|
|
78
80
|
```yaml
|
|
@@ -24,7 +24,10 @@ the same procedure. Updating the layer can create a new artifact.
|
|
|
24
24
|
Prefer extending an existing owner, merging duplicates, or enforcing a
|
|
25
25
|
convention mechanically. Resolve the writable directory from the manifest;
|
|
26
26
|
create a pre-listed missing directory without changing the source list. Add a
|
|
27
|
-
source entry only when an accepted destination is outside the listed groups
|
|
27
|
+
source entry only when an accepted destination is outside the listed groups,
|
|
28
|
+
and add it with `intelligence source add <rules|agents|skills> <dir>` rather
|
|
29
|
+
than by editing the manifest: the list is ordered, and its order decides which
|
|
30
|
+
artifact wins.
|
|
28
31
|
|
|
29
32
|
3. Establish the evidence and artifact type. A constraint is a rule, a repeatable
|
|
30
33
|
procedure is a skill, and a persona or expertise boundary is an agent. Verify
|