@ainova-systems/intelligence 0.21.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.
@@ -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
@@ -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
- af0a0fdbc3f505e4f063ae516c9243c92db6e376
1
+ ee05b0f09bc74b1914e04c067cce1e87a75bd18a
package/engine/VERSION CHANGED
@@ -1 +1 @@
1
- 0.21.0
1
+ 0.22.0
@@ -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
+ }
@@ -6,6 +6,11 @@
6
6
  # Skills: copy skill directories in full (SKILL.md + bundled resources),
7
7
  # effort -> Claude's effort level
8
8
  # Agents: tier -> model, effort -> effort, access -> tools/disallowedTools
9
+ # Plugin: with `targets.claude.plugin: true`, the Claude Code mod in
10
+ # adapters/claude-plugin/ is rendered into <output>/skills/intelligence/
11
+ # (decision 0022). Claude Code loads a plugin from a project's skills
12
+ # directory, so the plugin lives inside a path this contract already owns:
13
+ # ownership, Git policy, snapshots and `sync --check` need no new record.
9
14
  #
10
15
  # Every per-file loop batches its work into one awk process (see the batched
11
16
  # helpers in lib/common.sh): process spawns dominate sync time on Windows.
@@ -29,6 +34,52 @@ adapter_contract_claude() {
29
34
  adapter_contract_include "$output/settings.json"
30
35
  }
31
36
 
37
+ # claude_plugin_var <manifest> — the one reader of targets.claude.plugin. Sets
38
+ # IS_CLAUDE_PLUGIN to 1 when the project renders the Claude Code plugin and 0
39
+ # for the default; returns 1 for a value target_bool_var refuses.
40
+ claude_plugin_var() {
41
+ local rc=0
42
+ target_bool_var "$1" claude plugin || rc=1
43
+ IS_CLAUDE_PLUGIN="$IS_TARGET_BOOL"
44
+ return "$rc"
45
+ }
46
+
47
+ # The directory name the plugin takes under <output>/skills: a source skill
48
+ # rendered under this name would be overwritten, so sync refuses the pair.
49
+ IS_CLAUDE_PLUGIN_NAME=intelligence
50
+
51
+ # render_claude_plugin <output_dir> — copy the plugin template byte for byte,
52
+ # stamp the engine version into its manifest and normalize line endings. The
53
+ # template is in the engine's adapter directory, never in the content package:
54
+ # it is executable tooling, and the bundle copies all of engine/. It is found
55
+ # through the engine root, so a project's own claude.sh renders it too.
56
+ render_claude_plugin() {
57
+ local output_dir="$1" tpl
58
+ local dst="$output_dir/skills/$IS_CLAUDE_PLUGIN_NAME"
59
+ # Rendered beside the destination, under a name no skill can take, and
60
+ # swapped in only when it differs: a running session reloads the mod on
61
+ # every file change, and announces each reload in its transcript.
62
+ local tmp="$output_dir/skills/.$IS_CLAUDE_PLUGIN_NAME.render.$$"
63
+ engine_root_var
64
+ tpl="$IS_ENGINE_ROOT/adapters/claude-plugin"
65
+ engine_version_var
66
+ rm -rf "$tmp"
67
+ mkdir -p "$tmp/.claude-plugin" "$tmp/hooks"
68
+ cp "$tpl/hooks/hooks.json" "$tpl/hooks/register.js" "$tmp/hooks/"
69
+ cp "$tpl/README.md" "$tmp/README.md"
70
+ awk -v v="$IS_ENGINE_VERSION" '{ sub(/__INTELLIGENCE_VERSION__/, v); print }' \
71
+ "$tpl/.claude-plugin/plugin.json" > "$tmp/.claude-plugin/plugin.json"
72
+ finalize_output_files "$tmp/.claude-plugin/plugin.json" "$tmp/hooks/hooks.json" \
73
+ "$tmp/hooks/register.js" "$tmp/README.md"
74
+ if [ -d "$dst" ] && [ ! -L "$dst" ] && diff -r -q "$tmp" "$dst" >/dev/null 2>&1; then
75
+ rm -rf "$tmp"
76
+ else
77
+ rm -rf "$dst"
78
+ mv "$tmp" "$dst"
79
+ fi
80
+ echo " plugin: $IS_CLAUDE_PLUGIN_NAME (Claude Code mod $IS_ENGINE_VERSION)"
81
+ }
82
+
32
83
  # Sync rules to Claude format (copy as-is, normalize LF)
33
84
  sync_claude_rules() {
34
85
  local repo_root="$1"
@@ -53,13 +104,32 @@ sync_claude_skills() {
53
104
  local config_file="$2"
54
105
  local output_dir="$3"
55
106
 
56
- local i name
107
+ local i name clash="" nocase=0
57
108
  local -a skill_dirs=()
58
109
  # The catalog's skills are the ones the inventory renders, symlinked ones
59
110
  # included (decision 0017).
60
111
  output_rel_var "$repo_root" "$output_dir"
61
112
  source_artifacts_var "$repo_root" "$config_file" skills claude "$IS_OUTPUT_REL/skills/{}"
62
113
  [ "${#IS_SOURCE_FILES[@]}" -gt 0 ] || return 0
114
+ # The plugin takes one skill directory name; a skill rendered under it and
115
+ # the plugin would each erase the other, so neither is written. Names
116
+ # compare without case: on Windows and macOS `Intelligence/` is the same
117
+ # directory. (nocasematch, not ${name,,}: macOS ships bash 3.2.)
118
+ if [ "${IS_CLAUDE_PLUGIN:-0}" = 1 ]; then
119
+ shopt -q nocasematch && nocase=1
120
+ shopt -s nocasematch
121
+ for ((i = 0; i < ${#IS_SOURCE_FILES[@]}; i++)); do
122
+ if [[ "${IS_SOURCE_NAMES[i]}" == "$IS_CLAUDE_PLUGIN_NAME" ]]; then
123
+ clash="${IS_SOURCE_FILES[i]#"$repo_root/"}"
124
+ break
125
+ fi
126
+ done
127
+ [ "$nocase" = 1 ] || shopt -u nocasematch
128
+ if [ -n "$clash" ]; then
129
+ echo "ERROR: skill $clash renders as $IS_OUTPUT_REL/skills/$IS_CLAUDE_PLUGIN_NAME, the directory targets.claude.plugin: true reserves for the Claude Code plugin. Rename the skill or set targets.claude.plugin to false." >&2
130
+ return 1
131
+ fi
132
+ fi
63
133
  for ((i = 0; i < ${#IS_SOURCE_FILES[@]}; i++)); do
64
134
  skill_dirs+=("${IS_SOURCE_FILES[i]%/SKILL.md}/")
65
135
  done
@@ -156,18 +226,39 @@ sync_to_claude() {
156
226
 
157
227
  echo "=== Claude Code ==="
158
228
 
229
+ # The flag is read before anything is removed: a malformed value refuses
230
+ # the sync with the reason, and the output stays exactly as it was.
231
+ claude_plugin_var "$config_file" || {
232
+ echo "ERROR: targets.claude.plugin must be true or false: true renders the Claude Code plugin into ${output_dir#"$repo_root/"}/skills/$IS_CLAUDE_PLUGIN_NAME, false or omission does not." >&2
233
+ return 1
234
+ }
235
+
159
236
  # Clean generated content only — preserve any project-level files
160
237
  # the user manages directly (settings.json, settings.local.json,
161
238
  # commands/, statusline.sh, etc.).
162
239
  rm -rf "$output_dir/rules" "$output_dir/agents"
163
240
  if [ -d "$output_dir/skills" ]; then
164
- find "$output_dir/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -exec rm -rf {} +
241
+ # The plugin directory stays while the flag holds: Claude Code reloads
242
+ # a mod whose files change, so render_claude_plugin rewrites it only
243
+ # when its content differs. A skill of that name was refused above.
244
+ if [ "$IS_CLAUDE_PLUGIN" = 1 ]; then
245
+ find "$output_dir/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) \
246
+ ! -name "$IS_CLAUDE_PLUGIN_NAME" -exec rm -rf {} +
247
+ else
248
+ find "$output_dir/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -exec rm -rf {} +
249
+ fi
165
250
  fi
166
251
  mkdir -p "$output_dir/rules" "$output_dir/skills" "$output_dir/agents"
167
252
 
168
253
  sync_claude_rules "$repo_root" "$config_file" "$output_dir"
254
+ # Called plainly, never on the left of `||`: that would turn errexit off
255
+ # for every copy inside, and a failed one would no longer fail the sync.
169
256
  sync_claude_skills "$repo_root" "$config_file" "$output_dir"
170
257
  sync_claude_agents "$repo_root" "$config_file" "$output_dir"
258
+ # The skills renderer has refused a skill of the plugin's name by now, so
259
+ # the plugin never lands on top of one. Cleared above with every other
260
+ # skill directory, it exists only while the flag says so.
261
+ [ "$IS_CLAUDE_PLUGIN" = 0 ] || render_claude_plugin "$output_dir"
171
262
 
172
263
  count_matching_files "$output_dir/rules" "*.md" \
173
264
  "$output_dir/skills" "SKILL.md" "$output_dir/agents" "*.md"
@@ -55,19 +55,13 @@ adapter_contract_copilot() {
55
55
 
56
56
  # copilot_commit_output_var <manifest> — the one reader of
57
57
  # targets.copilot.commit_output. Sets IS_COPILOT_COMMIT_OUTPUT to 1 when the
58
- # project commits generated output and 0 for the default; returns 1 for any
59
- # value other than true or false. No manifest — a caller reading ownership
60
- # only — is the default.
58
+ # project commits generated output and 0 for the default; returns 1 for a value
59
+ # target_bool_var refuses.
61
60
  copilot_commit_output_var() {
62
- local manifest="$1" setting
63
- IS_COPILOT_COMMIT_OUTPUT=0
64
- [ -n "$manifest" ] && [ -f "$manifest" ] || return 0
65
- setting="$(get_target_field "$manifest" "copilot" "commit_output")"
66
- case "$setting" in
67
- ''|false) ;;
68
- true) IS_COPILOT_COMMIT_OUTPUT=1 ;;
69
- *) return 1 ;;
70
- esac
61
+ local rc=0
62
+ target_bool_var "$1" copilot commit_output || rc=1
63
+ IS_COPILOT_COMMIT_OUTPUT="$IS_TARGET_BOOL"
64
+ return "$rc"
71
65
  }
72
66
 
73
67
  # copilot_paths_rows <file>... — batch form of the old per-file `paths:`
@@ -3629,6 +3629,24 @@ is_target_enabled() {
3629
3629
  ' "$file"
3630
3630
  }
3631
3631
 
3632
+ # target_bool_var <manifest> <target> <field> — the one reading of a boolean
3633
+ # target field (targets.copilot.commit_output, targets.claude.plugin). Sets
3634
+ # IS_TARGET_BOOL to 1 for true and 0 for false, omission or an empty value;
3635
+ # returns 1 for anything else, so a typo never decides silently. No manifest —
3636
+ # a caller reading ownership only — is the default.
3637
+ # shellcheck disable=SC2034 # IS_TARGET_BOOL is the return channel
3638
+ target_bool_var() {
3639
+ local setting
3640
+ IS_TARGET_BOOL=0
3641
+ [ -n "$1" ] && [ -f "$1" ] || return 0
3642
+ setting="$(get_target_field "$1" "$2" "$3")"
3643
+ case "$setting" in
3644
+ ''|false) ;;
3645
+ true) IS_TARGET_BOOL=1 ;;
3646
+ *) return 1 ;;
3647
+ esac
3648
+ }
3649
+
3632
3650
  # Get an arbitrary field from a target's config block.
3633
3651
  # Handles both inline (`claude: { enabled: true, output: ".claude" }`)
3634
3652
  # and block form. Uses POSIX awk only — no gawk-specific 3-arg match().
@@ -100,18 +100,27 @@ is_status() {
100
100
  fi
101
101
  }
102
102
 
103
+ # engine_root_var — set IS_ENGINE_ROOT to the engine directory, one level above
104
+ # this lib. A project adapter that overrides a built-in finds the engine's own
105
+ # files through it, never beside itself.
106
+ engine_root_var() {
107
+ local lib="${BASH_SOURCE[0]}"
108
+ case "$lib" in
109
+ */*) lib="${lib%/*}" ;;
110
+ *) lib="." ;;
111
+ esac
112
+ IS_ENGINE_ROOT="$lib/.."
113
+ }
114
+
103
115
  # Engine version = VERSION one level above this lib (BASH_SOURCE works when
104
116
  # sourced). Empty if unreadable — callers treat empty as "no guard".
105
117
  # engine_version_var sets IS_ENGINE_VERSION, and IS_ENGINE_VERSION_FOUND=1 when
106
118
  # the file exists; it reads the file once per process.
107
119
  engine_version_var() {
108
120
  [ -z "${IS_ENGINE_VERSION_FOUND:-}" ] || return 0
109
- local lib="${BASH_SOURCE[0]}" vf content=""
110
- case "$lib" in
111
- */*) lib="${lib%/*}" ;;
112
- *) lib="." ;;
113
- esac
114
- vf="$lib/../VERSION"
121
+ local vf content=""
122
+ engine_root_var
123
+ vf="$IS_ENGINE_ROOT/VERSION"
115
124
  IS_ENGINE_VERSION=""
116
125
  IS_ENGINE_VERSION_FOUND=0
117
126
  [ -f "$vf" ] || return 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ainova-systems/intelligence",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
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"
@@ -129,6 +129,17 @@ Code ignore theirs, and never `.github/` itself. `targets.copilot.commit_output:
129
129
  true` keeps them tracked for Copilot on github.com; the setting is defined in
130
130
  [Generated output and version control](conventions.md#generated-output-and-version-control).
131
131
 
132
+ The Claude adapter renders a Claude Code plugin into `<output>/skills/intelligence/`
133
+ when `targets.claude.plugin` is `true` (decision 0022): a mod that checks
134
+ `intelligence sync --check` at session start and every five minutes, shows the
135
+ verdict in a line above the prompt and runs sync from a button there or
136
+ `/intelligence sync`.
137
+ The template is `engine/adapters/claude-plugin/`, copied with the engine version
138
+ stamped into `plugin.json`. It lives inside the owned `skills` path, so it needs
139
+ no contract record of its own. The field's values, the name collision it refuses
140
+ and the plugin's limits are defined in the
141
+ [CLI reference](https://github.com/ainova-systems/intelligence/blob/main/docs/cli.md#intelligence-sync).
142
+
132
143
  The engine calls:
133
144
 
134
145
  ```text
@@ -373,7 +384,7 @@ For a built-in adapter, add the same assertions to the repository's smoke or lif
373
384
  |---|---|
374
385
  | `agents` | `AGENTS.md` |
375
386
  | `antigravity` | `.agents/rules`, `.agents/agents`, `.agents/skills` |
376
- | `claude` | `.claude/rules`, `.claude/agents`, `.claude/skills` |
387
+ | `claude` | `.claude/rules`, `.claude/agents`, `.claude/skills` (with `plugin: true`, `.claude/skills/intelligence` is the Claude Code plugin) |
377
388
  | `cursor` | `.cursor/rules`, `.cursor/agents`, `.cursor/skills` |
378
389
  | `copilot` | `.github/instructions`, `.github/agents`, `.github/skills` |
379
390
  | `codex` | `.codex/agents`, `.agents/skills`, marker-owned `AGENTS.override.md` |