@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
@@ -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
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "intelligence",
3
+ "version": "__INTELLIGENCE_VERSION__",
4
+ "description": "Shows this project's Intelligence sync status above the prompt and runs intelligence sync from Claude Code",
5
+ "author": { "name": "Ainova Systems" }
6
+ }
@@ -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,3 @@
1
+ {
2
+ "modules": ["./register.js"]
3
+ }
@@ -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
+ }