@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.
- package/cli/commands/init.sh +2 -1
- package/cli/commands/sync.sh +9 -0
- package/cli/internal/check.sh +39 -0
- package/cli/lib/gitignore.sh +19 -2
- package/cli/lib/sync-lock.sh +103 -21
- package/engine/ENGINE_SHA +1 -1
- package/engine/VERSION +1 -1
- package/engine/adapters/_template.sh +32 -8
- package/engine/adapters/agents.sh +42 -50
- package/engine/adapters/antigravity.sh +68 -52
- package/engine/adapters/claude-plugin/.claude-plugin/plugin.json +6 -0
- package/engine/adapters/claude-plugin/README.md +14 -0
- package/engine/adapters/claude-plugin/hooks/hooks.json +3 -0
- package/engine/adapters/claude-plugin/hooks/register.js +324 -0
- package/engine/adapters/claude.sh +129 -59
- package/engine/adapters/codex.sh +74 -15
- package/engine/adapters/copilot.sh +56 -79
- package/engine/adapters/cursor.sh +85 -81
- package/engine/adapters/opencode.sh +20 -35
- package/engine/adapters/pi.sh +137 -41
- package/engine/lib/common.sh +753 -56
- package/engine/lib/contract.sh +15 -6
- package/engine/sync.sh +49 -33
- package/package.json +1 -1
- package/packages/sync/references/adapters.md +56 -40
- package/packages/sync/references/conventions.md +57 -11
- package/packages/sync/references/onboarding-migration.md +4 -4
- package/packages/sync/rules/intelligence-authoring.md +2 -2
- package/packages/sync/skills/intelligence-learn-from-repository/SKILL.md +7 -5
- package/packages/sync/skills/intelligence-review-context/references/audit-checks.md +1 -1
- package/packages/sync/skills/intelligence-update-context/references/rules.md +5 -0
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
local
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
[
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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)
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
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 "/"
|
|
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
|
-
' "${
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
205
|
-
name="${path##*/}"; name="${name%.md}"
|
|
221
|
+
name="${names[count]}"
|
|
206
222
|
resolve_model_var "$tier"
|
|
207
223
|
|
|
208
224
|
local header
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Intelligence sync status for Claude Code
|
|
2
|
+
|
|
3
|
+
`intelligence sync` renders this plugin into a project's
|
|
4
|
+
`.claude/skills/intelligence/` from the Intelligence engine's
|
|
5
|
+
`adapters/claude-plugin/` template when `targets.claude.plugin` is `true` in
|
|
6
|
+
`intelligence.yaml`. Change the template in the engine, never a rendered copy:
|
|
7
|
+
the next sync rewrites the copy, and setting the flag to `false` removes it.
|
|
8
|
+
|
|
9
|
+
It is a Claude Code plugin whose hooks module (`hooks/register.js`) checks
|
|
10
|
+
`intelligence sync --check` when a session starts and every five minutes,
|
|
11
|
+
shows the result in a line above the prompt while something needs attention,
|
|
12
|
+
and runs `intelligence sync` from a `Sync now` button there or the
|
|
13
|
+
`/intelligence sync` command. Its requirements and limits are defined in the
|
|
14
|
+
Intelligence CLI reference, `docs/cli.md`, under `intelligence sync`.
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Intelligence sync status for Claude Code.
|
|
2
|
+
//
|
|
3
|
+
// `intelligence sync` renders this file from the engine's claude-plugin
|
|
4
|
+
// template when targets.claude.plugin is true (decision 0022). It is a Claude
|
|
5
|
+
// Code mod: a hooks module with no Node.js APIs, reaching the project only
|
|
6
|
+
// through the mods API `$`. It asks `intelligence sync --check` whether the
|
|
7
|
+
// generated output is current when a session starts and every five minutes,
|
|
8
|
+
// shows the answer in one line above the prompt while a check runs or
|
|
9
|
+
// something needs attention, and runs `intelligence sync` from a button there
|
|
10
|
+
// or the /intelligence command. It registers no tool and never submits a
|
|
11
|
+
// prompt: it informs the person, never Claude.
|
|
12
|
+
|
|
13
|
+
// A cold `sync --check` renders the project in a private copy, which takes
|
|
14
|
+
// minutes on Git Bash; a warm one answers from its record in seconds. The
|
|
15
|
+
// timeout stays just under the interval, so a slow check is waited for rather
|
|
16
|
+
// than reported as a failure. A tick never starts a check while one of ours is
|
|
17
|
+
// pending; one abandoned at the timeout can still finish beside the next.
|
|
18
|
+
const CHECK_EVERY_MS = 300000
|
|
19
|
+
const CHECK_TIMEOUT_MS = 290000
|
|
20
|
+
// `$.process.run` allows ten minutes at most, which a full sync may need: it
|
|
21
|
+
// first waits for any check holding the project lock (decision 0022).
|
|
22
|
+
const SYNC_TIMEOUT_MS = 600000
|
|
23
|
+
// Another command holding the project lock is asked again this soon, then
|
|
24
|
+
// after doubling pauses up to the regular interval, so a lock that stays held
|
|
25
|
+
// costs a few processes, not one every 15 seconds.
|
|
26
|
+
const RETRY_MS = 15000
|
|
27
|
+
// The spinner Claude Code draws while it works.
|
|
28
|
+
const FRAMES = ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢']
|
|
29
|
+
const FRAME_MS = 120
|
|
30
|
+
// The CLI's refusal when another command holds the project lock.
|
|
31
|
+
const LOCK_REFUSAL = /another intelligence sync is (running|starting)/
|
|
32
|
+
// The CLI's word that the project was stamped by a newer CLI than itself: a
|
|
33
|
+
// warning for a newer minor or patch, a refusal for a newer major.
|
|
34
|
+
const CLI_BEHIND = /newer than this CLI's engine|IS_STATUS=ahead-of-engine/
|
|
35
|
+
|
|
36
|
+
// What the last check found. `status` is one of unknown (no verdict yet), ok,
|
|
37
|
+
// stale, error, missing-cli or old-cli; `busy` is true while a sync started
|
|
38
|
+
// from here is running.
|
|
39
|
+
const state = { status: 'unknown', detail: '', checkedAt: null, busy: false }
|
|
40
|
+
// A check already in flight answers the next caller too, so a timer tick and
|
|
41
|
+
// a /intelligence check never run two CLI processes side by side.
|
|
42
|
+
let pendingCheck = null
|
|
43
|
+
let retry = null
|
|
44
|
+
let retryMs = RETRY_MS
|
|
45
|
+
let ticker = null
|
|
46
|
+
let frame = 0
|
|
47
|
+
// The terminal and the Desktop app draw the band above the prompt; elsewhere,
|
|
48
|
+
// such as the VS Code extension, the status line under the prompt carries the
|
|
49
|
+
// text. The footer under the prompt is Claude Code's own and stays untouched.
|
|
50
|
+
let band = false
|
|
51
|
+
|
|
52
|
+
// The CLI reports its result as one `IS_STATUS=<status> IS_DETAIL=<text>`
|
|
53
|
+
// line; everything else on stdout is progress.
|
|
54
|
+
function parseStatus(stdout) {
|
|
55
|
+
const match = /^IS_STATUS=(\S+)(?: IS_DETAIL=(.*))?$/m.exec(stdout || '')
|
|
56
|
+
return { status: match ? match[1] : '', detail: match && match[2] ? match[2].trim() : '' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function firstLine(text) {
|
|
60
|
+
const line = (text || '').split(/\r?\n/).find((l) => l.trim() !== '')
|
|
61
|
+
return line ? line.trim() : ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A failing CLI prints its progress before the reason, so the first line is
|
|
65
|
+
// often a banner: prefer the line that says what went wrong.
|
|
66
|
+
function reasonLine(text) {
|
|
67
|
+
const line = (text || '').split(/\r?\n/).find((l) => /^\s*ERROR:/.test(l))
|
|
68
|
+
return line ? line.trim().replace(/^ERROR:\s*/, '') : firstLine(text)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function errorMessage(err) {
|
|
72
|
+
return String(err && err.message ? err.message : err)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function clock(date) {
|
|
76
|
+
const hh = String(date.getHours()).padStart(2, '0')
|
|
77
|
+
const mm = String(date.getMinutes()).padStart(2, '0')
|
|
78
|
+
return hh + ':' + mm
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function setState(status, detail) {
|
|
82
|
+
// A verdict ends a run of retries; the next held lock starts at 15 s again.
|
|
83
|
+
retryMs = RETRY_MS
|
|
84
|
+
state.status = status
|
|
85
|
+
state.detail = detail || ''
|
|
86
|
+
state.checkedAt = new Date()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function animating() {
|
|
90
|
+
return state.busy || state.status === 'unknown'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The band's line: one short phrase, with Claude Code's spinner while a check
|
|
94
|
+
// or a sync has not answered yet.
|
|
95
|
+
function label() {
|
|
96
|
+
if (state.busy) return FRAMES[frame] + ' intelligence: syncing'
|
|
97
|
+
switch (state.status) {
|
|
98
|
+
case 'stale':
|
|
99
|
+
return 'intelligence: sync needed'
|
|
100
|
+
case 'missing-cli':
|
|
101
|
+
return 'intelligence: CLI not found'
|
|
102
|
+
case 'old-cli':
|
|
103
|
+
return 'intelligence: update the CLI'
|
|
104
|
+
case 'error':
|
|
105
|
+
return 'intelligence: check failed'
|
|
106
|
+
default:
|
|
107
|
+
return FRAMES[frame] + ' intelligence: checking'
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The full sentence: the /intelligence reply, and the status line where no
|
|
112
|
+
// band draws. Claude Code prefixes a status line with the plugin's name.
|
|
113
|
+
function statusText() {
|
|
114
|
+
if (state.busy) return 'syncing…'
|
|
115
|
+
const at = state.checkedAt ? ' · ' + clock(state.checkedAt) : ''
|
|
116
|
+
switch (state.status) {
|
|
117
|
+
case 'ok':
|
|
118
|
+
return 'up to date' + at
|
|
119
|
+
case 'stale':
|
|
120
|
+
return 'sync needed — run /intelligence sync' + at
|
|
121
|
+
case 'missing-cli':
|
|
122
|
+
return 'CLI not found on PATH'
|
|
123
|
+
case 'old-cli':
|
|
124
|
+
return 'the intelligence CLI on PATH is older than this project — run: intelligence upgrade'
|
|
125
|
+
case 'error':
|
|
126
|
+
return 'check failed: ' + state.detail + at
|
|
127
|
+
default:
|
|
128
|
+
return 'checking…'
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Every state change redraws the band above the prompt; the spinner turns only
|
|
133
|
+
// while something is pending.
|
|
134
|
+
function show($) {
|
|
135
|
+
if (animating() && !ticker) {
|
|
136
|
+
ticker = $.clock.every(FRAME_MS, () => {
|
|
137
|
+
frame = (frame + 1) % FRAMES.length
|
|
138
|
+
$.ui.invalidate('ui.render')
|
|
139
|
+
})
|
|
140
|
+
} else if (!animating() && ticker) {
|
|
141
|
+
ticker.cancel()
|
|
142
|
+
ticker = null
|
|
143
|
+
}
|
|
144
|
+
$.ui.status(band ? undefined : statusText())
|
|
145
|
+
$.ui.invalidate('ui.render')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Ask again soon after a command that held the project lock; the verdict
|
|
149
|
+
// shown meanwhile is the last one, never a guess.
|
|
150
|
+
function retrySoon($) {
|
|
151
|
+
if (retry) return
|
|
152
|
+
retry = $.clock.after(retryMs, () => {
|
|
153
|
+
retry = null
|
|
154
|
+
check($)
|
|
155
|
+
})
|
|
156
|
+
retryMs = Math.min(retryMs * 2, CHECK_EVERY_MS)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function check($) {
|
|
160
|
+
if (state.busy) return
|
|
161
|
+
if (pendingCheck) return pendingCheck
|
|
162
|
+
pendingCheck = runCheck($).finally(() => {
|
|
163
|
+
pendingCheck = null
|
|
164
|
+
})
|
|
165
|
+
return pendingCheck
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function runCheck($) {
|
|
169
|
+
let result
|
|
170
|
+
let cwd = ''
|
|
171
|
+
try {
|
|
172
|
+
// The project root, not the shell's cwd: a session started in a
|
|
173
|
+
// subdirectory still checks the project the plugin was rendered for.
|
|
174
|
+
cwd = await $.session.root()
|
|
175
|
+
result = await $.process.run(['intelligence', 'sync', '--check'], { cwd, timeoutMs: CHECK_TIMEOUT_MS })
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// The call rejects when the program cannot start or outlives its
|
|
178
|
+
// timeout; the result arrives whatever the exit code otherwise.
|
|
179
|
+
const message = errorMessage(err)
|
|
180
|
+
if (/failed to start/i.test(message)) setState('missing-cli', message)
|
|
181
|
+
else if (await lockHeld($, cwd)) retrySoon($)
|
|
182
|
+
else setState('error', message)
|
|
183
|
+
show($)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
const parsed = parseStatus(result.stdout)
|
|
187
|
+
// An older CLI renders this project with its own engine, so its verdict
|
|
188
|
+
// says nothing about what this project's CLI would write, and its sync
|
|
189
|
+
// would drop this plugin: say so instead of offering one.
|
|
190
|
+
if (CLI_BEHIND.test((result.stderr || '') + ' ' + (result.stdout || ''))) setState('old-cli', reasonLine(result.stderr))
|
|
191
|
+
// Exit 0 is up to date and 2 is `out-of-date`, the only verdicts sync
|
|
192
|
+
// --check gives; anything else is the CLI refusing to answer.
|
|
193
|
+
else if (result.exitCode === 0) setState('ok', parsed.detail)
|
|
194
|
+
else if (result.exitCode === 2) setState('stale', parsed.detail || 'sync needed')
|
|
195
|
+
// Another holder of the project lock — someone's sync, or another
|
|
196
|
+
// session's check — is not a fault: the last verdict stays, and the check
|
|
197
|
+
// asks again shortly instead of queueing ahead of a waiting sync.
|
|
198
|
+
else if (LOCK_REFUSAL.test(result.stderr || '')) retrySoon($)
|
|
199
|
+
else setState('error', parsed.detail || reasonLine(result.stderr) || 'exit ' + result.exitCode)
|
|
200
|
+
show($)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A check cut off at its timeout behind another command, or still finishing
|
|
204
|
+
// on its own, leaves the project lock in place: that is a command still
|
|
205
|
+
// running, not a failed check. The process the timeout abandoned keeps running.
|
|
206
|
+
async function lockHeld($, root) {
|
|
207
|
+
if (!root) return false
|
|
208
|
+
try {
|
|
209
|
+
return await $.fs.exists(root + '/.intelligence/sync.lock')
|
|
210
|
+
} catch {
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function runSync($) {
|
|
216
|
+
if (state.busy) return
|
|
217
|
+
if (state.status === 'old-cli') {
|
|
218
|
+
$.ui.toast('update the intelligence CLI first: intelligence upgrade')
|
|
219
|
+
return 'not run'
|
|
220
|
+
}
|
|
221
|
+
state.busy = true
|
|
222
|
+
show($)
|
|
223
|
+
let outcome
|
|
224
|
+
let cwd = ''
|
|
225
|
+
try {
|
|
226
|
+
// A check of ours still running holds the project lock; the CLI would
|
|
227
|
+
// wait for it too, but waiting here keeps its late answer from landing
|
|
228
|
+
// after the sync's own.
|
|
229
|
+
if (pendingCheck) await pendingCheck
|
|
230
|
+
cwd = await $.session.root()
|
|
231
|
+
const result = await $.process.run(['intelligence', 'sync', '--compact'], { cwd, timeoutMs: SYNC_TIMEOUT_MS })
|
|
232
|
+
if (result.exitCode === 0) {
|
|
233
|
+
outcome = 'sync finished'
|
|
234
|
+
} else {
|
|
235
|
+
const reason = reasonLine(result.stderr) || parseStatus(result.stdout).detail || 'exit ' + result.exitCode
|
|
236
|
+
outcome = 'sync failed: ' + reason
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
// A sync past its timeout, which includes waiting for a check, keeps
|
|
240
|
+
// running and holding the lock: it has not failed yet.
|
|
241
|
+
if (!/failed to start/i.test(errorMessage(err)) && (await lockHeld($, cwd))) {
|
|
242
|
+
outcome = 'sync is still running; the status updates when it ends'
|
|
243
|
+
} else {
|
|
244
|
+
outcome = 'sync could not run: ' + errorMessage(err)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// The check, not the sync's own exit code, decides what the status shows:
|
|
248
|
+
// it is the same judgement the next timer tick would make. Until it answers
|
|
249
|
+
// the status is `checking`, so the band never offers `Sync now` again for
|
|
250
|
+
// the verdict the sync just replaced.
|
|
251
|
+
state.status = 'unknown'
|
|
252
|
+
state.detail = ''
|
|
253
|
+
state.busy = false
|
|
254
|
+
show($)
|
|
255
|
+
$.ui.toast(outcome)
|
|
256
|
+
await check($)
|
|
257
|
+
return outcome
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function register(on) {
|
|
261
|
+
on('session.start', async ($, e, next) => {
|
|
262
|
+
// A build without surfaces() draws in the terminal; the band is the default.
|
|
263
|
+
try {
|
|
264
|
+
band = (await $.session.surfaces()).some((s) => s === 'terminal' || s === 'desktop')
|
|
265
|
+
} catch {
|
|
266
|
+
band = true
|
|
267
|
+
}
|
|
268
|
+
// The label shows at once, turning, while a cold first check can take
|
|
269
|
+
// minutes.
|
|
270
|
+
show($)
|
|
271
|
+
// Neither the first check nor the timer is awaited: the session must
|
|
272
|
+
// never wait on the CLI.
|
|
273
|
+
check($)
|
|
274
|
+
$.clock.every(CHECK_EVERY_MS, () => check($))
|
|
275
|
+
// Registered last and guarded: a taken name throws, and a hook that
|
|
276
|
+
// throws is skipped from that line on.
|
|
277
|
+
try {
|
|
278
|
+
await $.command.register({
|
|
279
|
+
name: 'intelligence',
|
|
280
|
+
description: 'Show Intelligence sync status or run sync',
|
|
281
|
+
argumentHint: '[sync|check]',
|
|
282
|
+
immediate: true,
|
|
283
|
+
})
|
|
284
|
+
} catch (err) {
|
|
285
|
+
$.ui.log('/intelligence not registered: ' + errorMessage(err), { to: 'debug' })
|
|
286
|
+
}
|
|
287
|
+
return next(e)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
on('command.run', { command: 'intelligence' }, async ($, e) => {
|
|
291
|
+
const args = (e.args || '').trim()
|
|
292
|
+
if (args === 'sync') {
|
|
293
|
+
if (state.busy) return { text: 'a sync started from here is still running' }
|
|
294
|
+
const outcome = await runSync($)
|
|
295
|
+
return { text: outcome + ' — ' + statusText() }
|
|
296
|
+
}
|
|
297
|
+
if (args === 'check') {
|
|
298
|
+
await check($)
|
|
299
|
+
return { text: statusText() }
|
|
300
|
+
}
|
|
301
|
+
if (args === '') {
|
|
302
|
+
const detail = state.detail && state.status !== 'ok' ? ' (' + state.detail + ')' : ''
|
|
303
|
+
return { text: statusText() + detail }
|
|
304
|
+
}
|
|
305
|
+
return { text: 'usage: /intelligence [sync|check]' }
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// The band above the prompt, where Claude Code draws nothing of its own,
|
|
309
|
+
// carries the status while there is something to wait for or act on, and
|
|
310
|
+
// is gone while the project is up to date.
|
|
311
|
+
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
312
|
+
if (state.status === 'ok' && !state.busy) return next(e)
|
|
313
|
+
const { Box, Text, Button } = $.ui.resolve(e)
|
|
314
|
+
const color = state.status === 'error' || state.status === 'old-cli' ? 'red' : state.status === 'stale' ? 'yellow' : undefined
|
|
315
|
+
const children = [Text({ color, dimColor: animating(), children: [label()] })]
|
|
316
|
+
// A digit hotkey on the band also fires from an empty prompt, typed and
|
|
317
|
+
// left for a moment; a letter would need the band focused first. Plain,
|
|
318
|
+
// because only a plain button shows its hotkey in the terminal.
|
|
319
|
+
if (state.status === 'stale' && !state.busy) {
|
|
320
|
+
children.push(Button({ key: 'sync', label: 'Sync now', hotkey: '1', plain: true, onPress: () => runSync($) }))
|
|
321
|
+
}
|
|
322
|
+
return Box({ flexDirection: 'row', columnGap: 2, children })
|
|
323
|
+
})
|
|
324
|
+
}
|