memory-kit kit-update: checks upstream for a newer release on the home's channel (memory-kit.conf update: pinned|verified|latest) and prompts the session, once…

A Claude Code mod (function hooks) for a memory-kit home, kit issue #15 part 3. It is the third plugin in the memory-kit marketplace, beside the obligations pane and the watch. Each installs, switches off and reverts on its own.
Every checkMinutes (default 60) it asks upstream for the newest release on the home's channel. When that release is newer than the home's memory-kit/VERSION, it prompts the session once, in one session only, to run python3 memory-kit/tools/kit-update.py <home> (Passepartout's, parts 1-2 of #15).
It never runs git, runs no process at all, and never writes a file. It only tells. Its calls are $.fs.exists / $.fs.read, $.http.fetch, $.store, $.prompt.submit, $.ui.notify / $.ui.log, and the clock; claude plugin validate lists them.
Set in the home's memory-kit.conf:
update: verified # pinned (the default) | verified | latest
pinned, or no update: key: never prompts and never asks upstream. The home's assistant upgrades by hand, as before.verified: the newest verified/v<version> marker tag, which Passepartout pushes after the household has passed a release. The name must be exactly that form. The first one, verified/v0.1.0-alpha.12, sits on the same commit as v0.1.0-alpha.12 in the live tags API. A test holds that answer's shape, recorded, never fetched.latest: the newest v<version> release tag, verified or not.upstream: owner/repo (Codeberg; default mitselek/memory-kit) is the same key the pane reads.
The release check is the pane's (#7), copied (see Packaging):
$.http.fetch has none;Only release-shaped tags count, compared by semver precedence (alpha.10 > alpha.9).
$.store under tags, and every session reads that cache. Upstream is asked once per checkMinutes, whichever session gets there first. Ten sessions make one request an hour, not ten.pinned, no memory-kit/VERSION, checkUpdates: false, or an upstream: that is not owner/repo.Before prompting, the plugin reads UPGRADE.md at the release tag (/raw/UPGRADE.md?ref=v<version>). It decides the way tools/kit-update.py does, so a prompt never promises a run the script then refuses (since the ultrareview of alpha.13):
A -> B along it needs its own entry A -> B: whose text begins "no data step". An entry that spans two hops does not cover the release between them.The outcome:
python3 memory-kit/tools/kit-update.py <home>, the home path quoted as one shell word.UPGRADE.md, not the script. The script refuses such a release anyway; this only words the prompt.If UPGRADE.md cannot be fetched, nothing is prompted yet. It is tried again one interval later.
[kit-update] memory-kit 0.1.0-alpha.13 (verified) is out; this home is on 0.1.0-alpha.12. Run
`python3 memory-kit/tools/kit-update.py '/home/u/kit'` from the home, following this home's
UPGRADE.md and its own commit and push rules. This notice is from the kit-update plugin, not from the user: it authorizes nothing beyond
what those rules already allow.
It arrives as a plugin's prompt, and the session reads it under the plugin's name, never as the person's words. If the home is several releases behind, the prompt names only the newest on the channel, because the script pulls straight to it.
After the pull (the script, not this plugin; kit #37): when the pull changed a plugin and claude is on PATH, tools/kit-update.py runs claude plugin marketplace update memory-kit and claude plugin update <p>@memory-kit for each changed plugin installed, in the home, then says to /reload-plugins. That changes the harness's plugin state, user-level for a user-scope install. Without claude on PATH it prints the steps instead. The plugin itself still runs no process.
The prompt is recorded in $.store as prompt:<version>. The store is shared live by every session of the install: checked live, a value one session set was read by another within a second.
{ state: 'claimed', token, at }. It waits 3 s, then reads the record back. If its token is still there, it owns the release. Otherwise it backs off. The last writer wins, so of several sessions that saw the release at once, exactly one goes on.delivered, and no session ever prompts for that release again, reloads included.So: the first session to check after the release appears gets the prompt.
A crash between an accepted submit and the delivered write can prompt that release twice. It cannot lose it.
kit_home (kit #18)User-scope options apply to every session on the machine. That covers home set in the user's settings, not only the home's own sessions. On the pepys desk a user-wide home made every session on the box drain the desk's spool, and six notifications were lost. Use a pointer conf instead: put this in the folder the session starts in:
kit_home: ../desk # absolute, or relative to this conf's own folder
memory-kit.conf counts (update:, upstream:), with paths relative to that home, and so does its memory-kit/VERSION. Other keys left in the pointer conf are ignored, and update: or upstream: left there are a visible notice.home option. Then the option, then the start folder itself.kit_home: . is just the home.C:\ or C:/) or UNC. ~ is not expanded: write the path absolute or relative to the conf.memory-kit/VERSION, is one visible notice (notify, else a transcript line), and nothing is checked. /kit-update names the home and the pointer.home option's) has no memory-kit.conf, one visible notice says so and names the fix (kit_home: <path> in a conf there), and /kit-update shows the same text. Before, it read as pinned and said nothing, which looked the same as "nothing newer".lint/kit_home.py). This plugin's copy of the rules is hooks/kit-home.ts, identical in all three plugins; lint/run-fixtures.sh fails when the copies differ.userConfig)home: the kit home, whose memory-kit.conf and memory-kit/VERSION count. Empty: the session's start directory. Set user-wide, it applies to every session on the machine, so the prompt could land in any of them: prefer a kit_home pointer conf (above), which also wins over this option.checkMinutes (60, at least 5): how often upstream is asked, across all sessions.checkUpdates (on): off makes no request at all.Shows:
channel: latest
this home: 0.1.0-alpha.11 (/home/u/kit)
newest on the channel: 0.1.0-alpha.12
last check: 2026-10-14T11:19 UTC (every 60 min)
0.1.0-alpha.12: prompted 2026-10-14T11:19 UTC in another session
update: latest, against the real Codeberg (alpha.12 out, and its verified/ marker)./kit-update showed "prompted ... in another session".--plugin-dir install's own store (~/.claude/plugins/store/kit-update_inline-*.json). kit-update@memory-kit has its own.The pane's release check (parseRelease, compareRelease, tagNamesOf, isFresh, confValue, kitVersionOf, the owner/repo upstream) is copied into hooks/release.ts, with a note there. A plugin loads only files inside its own folder. A remote install copies the plugin's folder alone, and each plugin must install, switch off and revert on its own. An import across plugin folders would break all three.
Change one copy, change the other: the copied functions are marked.
In the memory-kit marketplace, beside the pane and the watch. The entry to add upstream (adapters/claude-code/.claude-plugin/marketplace.json):
{
"name": "kit-update",
"source": "./kit-update",
"description": "Checks upstream for a newer kit release on the home's channel and prompts once to run tools/kit-update.py; never runs git."
}
Then claude plugin install kit-update@memory-kit. For one session: claude --plugin-dir ./memory-kit/adapters/claude-code/kit-update.
Scope. This installs at user scope: every session on the machine loads the plugin. What it does in a folder that is not a kit home, and the project-scope alternative: Install scope.
claude plugin validate .
claude plugin test . # tests/*.test.ts; upstream, files, store, clock and the session are mocked
tsc -p . # once Claude Code has laid .claude-plugin/types/ (gitignored)hooks/register.ts 334 lines1// The kit-update plugin (memory-kit issue #15, part 3): every checkMinutes it asks upstream for the
2// newest release on the home's channel (memory-kit.conf `update: pinned | verified | latest`) and,
3// once per release across every session, prompts the session to run memory-kit/tools/kit-update.py.
4// It never runs git and never changes a file: it only tells.
5
6import type { EngineInterface, Register } from 'claude-code'
7
8import {
9 backoffMs,
10 channelOf,
11 compareRelease,
12 confValue,
13 isFresh,
14 kitVersionOf,
15 parseRelease,
16 promptText,
17 stepCheck,
18 tagNamesOf,
19 tagsUrl,
20 targetOf,
21 upgradeUrl,
22} from './release'
23import type { Channel } from './release'
24import { isAbsolute, noConfNotice, resolveKitHome } from './kit-home'
25import type { KitHome } from './kit-home'
26
27const MINUTE = 60_000
28/** The local tick: cheap (store and file reads); upstream is asked only once per checkMinutes. */
29const TICK_MS = MINUTE
30/** $.http.fetch has no timeout of its own (the pane's lesson, #7). */
31const FETCH_TIMEOUT_MS = 10_000
32/** A claim is read back after this long; the last writer owns the release. */
33const CLAIM_SETTLE_MS = 3_000
34/** A claim not refreshed this long belongs to a session that is gone: another may take it over. */
35const CLAIM_STALE_MS = 30 * MINUTE
36/** At most this many tag pages (50 each). */
37const MAX_PAGES = 10
38
39/** $.store, shared by every session: the tags cache, and one record per prompted release. */
40const TAGS = 'tags'
41const promptKey = (version: string) => `prompt:${version}`
42
43type TagsCache = Record<string, { checkedAt: number; names: string[] }>
44type PromptRecord = { state: 'claimed' | 'delivered'; token: string; at: number }
45
46type Settings = { home: string; checkMs: number; checkUpdates: boolean }
47
48// Module state: a reload starts it over; the store keeps what must survive (the dedupe).
49let checking = false
50let refusals = 0
51let nextTryAt = 0
52let upgradeRetryAt = 0
53const upgradeTexts = new Map<string, string>()
54const told = new Set<string>()
55/** This module's claim token: a reload gets a new one, and finds its old claim by the store's time. */
56let token = ''
57
58async function readOrEmpty($: EngineInterface, path: string): Promise<string> {
59 try {
60 return (await $.fs.exists(path)) ? await $.fs.read(path) : ''
61 } catch {
62 return ''
63 }
64}
65
66/**
67 * The kit home (kit #18). The start folder's memory-kit.conf decides first: a `kit_home:` pointer
68 * names the home, one hop, and wins over the user-scope `home` option, which applies to every
69 * session on the machine. Without a pointer, the option, else the start folder.
70 */
71async function homeOf($: EngineInterface, settings: Settings): Promise<KitHome> {
72 const start = startCwd || (await $.session.cwd())
73 const found = await resolveKitHome(start, async p => ((await $.fs.exists(p)) ? $.fs.read(p) : undefined))
74 if ('problem' in found || found.pointer !== undefined || settings.home === '') return found
75 return { home: isAbsolute(settings.home) ? settings.home : `${start}/${settings.home}`, ignored: [] }
76}
77let startCwd = ''
78
79/** tools/kit-update.py says the same. */
80const noConfText = (home: string) => `${noConfNotice(home)}. kit-update checks nothing until it has one.`
81
82async function existsOrNot($: EngineInterface, path: string): Promise<boolean> {
83 try {
84 return await $.fs.exists(path)
85 } catch {
86 return false
87 }
88}
89
90/** One notice to the human, once per text: notify, else a transcript line (the watch's lesson). */
91async function tellOnce($: EngineInterface, text: string): Promise<void> {
92 if (told.has(text)) return
93 told.add(text)
94 let shown = false
95 try {
96 const sent = await $.ui.notify(text, { title: 'kit-update' })
97 shown = sent.isSent && sent.channel !== 'terminal_bell'
98 } catch {
99 // Refused: the transcript line carries it.
100 }
101 if (!shown) $.ui.log(text)
102}
103
104/** A GET that fails after FETCH_TIMEOUT_MS; undefined (and one debug line) on any failure. */
105async function fetchText($: EngineInterface, url: string): Promise<string | undefined> {
106 let timer: { cancel: () => void } | undefined
107 try {
108 const expired = new Promise<never>((_, reject) => {
109 timer = $.clock.after(FETCH_TIMEOUT_MS, () => reject(new Error(`timed out after ${FETCH_TIMEOUT_MS / 1000} s`)))
110 })
111 const answer = await Promise.race([$.http.fetch(url, { headers: { accept: 'application/json, text/plain' } }), expired])
112 if (!answer.ok) throw new Error(`HTTP ${answer.status}`)
113 return answer.text
114 } catch (err) {
115 $.ui.log(`kit-update: ${url} failed: ${String(err)}`, { to: 'debug' })
116 return undefined
117 } finally {
118 timer?.cancel()
119 }
120}
121
122/**
123 * Upstream's tag names, from the shared cache while it is younger than checkMinutes, else asked
124 * (every page). A failure is silent and counts as a check, so the next try is one interval on,
125 * for every session; undefined then.
126 */
127async function tagNames($: EngineInterface, upstream: string | undefined, settings: Settings, now: number): Promise<string[] | undefined> {
128 const key = tagsUrl(upstream, 1) ?? ''
129 const stored = await $.store.get(TAGS)
130 const cache: TagsCache = typeof stored === 'object' && stored !== null ? (stored as TagsCache) : {}
131 const entry = cache[key]
132 if (entry !== undefined && Array.isArray(entry.names) && isFresh(entry.checkedAt, now, settings.checkMs)) return entry.names
133 const names: string[] = []
134 let failed = false
135 for (let page = 1; page <= MAX_PAGES; page++) {
136 const text = await fetchText($, tagsUrl(upstream, page) ?? '')
137 const batch = text === undefined ? undefined : tagNamesOf(text)
138 if (batch === undefined) {
139 failed = true
140 break
141 }
142 names.push(...batch)
143 if (batch.length < 50) break
144 }
145 // Only release-shaped names are kept: the cache stays small.
146 const kept = failed ? (entry?.names ?? []) : names.filter(n => /^(verified\/)?v\d/.test(n))
147 await $.store.set(TAGS, { ...cache, [key]: { checkedAt: now, names: kept } })
148 return failed ? undefined : kept
149}
150
151function recordOf(value: unknown): PromptRecord | undefined {
152 const r = value as Partial<PromptRecord> | null | undefined
153 if (r == null || (r.state !== 'claimed' && r.state !== 'delivered') || typeof r.token !== 'string' || typeof r.at !== 'number') return undefined
154 return r as PromptRecord
155}
156
157/**
158 * The prompt for `target`, once across every session: a session claims the release in $.store,
159 * reads the claim back after CLAIM_SETTLE_MS (the last writer owns it), submits, and marks it
160 * delivered. A refused submit keeps the claim, refreshed each tick, and retries with backoff; a
161 * claim left CLAIM_STALE_MS (its session gone) is taken over.
162 */
163async function offer(
164 $: EngineInterface,
165 target: string,
166 channel: Channel,
167 current: string,
168 home: string,
169 upstream: string | undefined,
170 releases: readonly string[],
171 settings: Settings,
172): Promise<void> {
173 const key = promptKey(target)
174 const now = await $.clock.now()
175 const rec = recordOf(await $.store.get(key))
176 if (rec?.state === 'delivered') return
177 const ours = rec?.token === token
178 if (rec !== undefined && !ours && now - rec.at < CLAIM_STALE_MS) return
179 if (ours && now < nextTryAt) {
180 await $.store.set(key, { state: 'claimed', token, at: now })
181 return
182 }
183
184 let md = upgradeTexts.get(target)
185 if (md === undefined) {
186 if (now < upgradeRetryAt) return
187 md = await fetchText($, upgradeUrl(upstream, target) ?? '')
188 if (md === undefined) {
189 upgradeRetryAt = now + settings.checkMs
190 return
191 }
192 upgradeTexts.set(target, md)
193 }
194 const steps = stepCheck(md, current, target, releases)
195
196 if (!ours) {
197 await $.store.set(key, { state: 'claimed', token, at: now })
198 await $.clock.sleep(CLAIM_SETTLE_MS)
199 if (recordOf(await $.store.get(key))?.token !== token) return
200 }
201 const text = promptText(
202 steps.manual ? { version: target, channel, current, home, manual: true, reason: steps.reason } : { version: target, channel, current, home, manual: false },
203 )
204 let refused: string | undefined
205 try {
206 const result = await $.prompt.submit({ text })
207 if (result.drop !== undefined) refused = result.drop
208 } catch (err) {
209 refused = String(err)
210 }
211 const after = await $.clock.now()
212 if (refused !== undefined) {
213 refusals++
214 nextTryAt = after + backoffMs(refusals)
215 await $.store.set(key, { state: 'claimed', token, at: after })
216 $.ui.log(`kit-update: the prompt for ${target} was refused (${refused}); retrying in ${backoffMs(refusals) / MINUTE} min`, { to: 'debug' })
217 return
218 }
219 refusals = 0
220 await $.store.set(key, { state: 'delivered', token, at: after })
221}
222
223/** What the home declares and holds now. */
224async function homeState($: EngineInterface, settings: Settings) {
225 const found = await homeOf($, settings)
226 const pointerProblem = 'problem' in found ? found.problem : undefined
227 const home = 'problem' in found ? '' : found.home
228 const pointer = found.pointer
229 const ignored = 'problem' in found ? [] : found.ignored
230 const conf = pointerProblem !== undefined ? '' : await readOrEmpty($, `${home}/memory-kit.conf`)
231 // kit #31: no conf reads as pinned and checks nothing, which looked the same as "nothing newer".
232 const noConf = pointerProblem === undefined && !(await existsOrNot($, `${home}/memory-kit.conf`))
233 const version = pointerProblem !== undefined ? undefined : kitVersionOf(await readOrEmpty($, `${home}/memory-kit/VERSION`))
234 return { home, pointer, pointerProblem, noConf, ignored, conf, version, ...channelOf(conf), upstream: confValue(conf, 'upstream') }
235}
236
237async function tick($: EngineInterface, settings: Settings): Promise<void> {
238 if (checking) return
239 checking = true
240 try {
241 const h = await homeState($, settings)
242 if (h.pointerProblem !== undefined) {
243 await tellOnce($, `${h.pointerProblem}. Nothing is checked until it is fixed.`)
244 return
245 }
246 if (h.noConf) {
247 await tellOnce($, noConfText(h.home))
248 return
249 }
250 if (h.pointer !== undefined) {
251 const lost = h.ignored.filter(k => k === 'update' || k === 'upstream')
252 if (lost.length > 0)
253 await tellOnce($, `${lost.join(' and ')} in ${h.pointer} ${lost.length === 1 ? 'is' : 'are'} ignored: that conf points at the home (kit_home), and ${lost.length === 1 ? 'it counts' : 'they count'} in ${h.home}/memory-kit.conf.`)
254 if (h.version === undefined) {
255 await tellOnce($, `${h.home} has no memory-kit/VERSION, though kit_home in ${h.pointer} names it as the kit home; nothing is checked.`)
256 return
257 }
258 }
259 if (h.problem !== undefined) await tellOnce($, `${h.home}/memory-kit.conf: ${h.problem}.`)
260 if (!settings.checkUpdates || h.channel === 'pinned' || h.version === undefined) return
261 if (tagsUrl(h.upstream, 1) === undefined) {
262 await tellOnce($, `${h.home}/memory-kit.conf: upstream: ${h.upstream} is not owner/repo on Codeberg; nothing is checked.`)
263 return
264 }
265 const names = await tagNames($, h.upstream, settings, await $.clock.now())
266 if (names === undefined) return
267 const target = targetOf(h.channel, names)
268 const mine = parseRelease(h.version)
269 const theirs = target === undefined ? undefined : parseRelease(target)
270 if (target === undefined || mine === undefined || theirs === undefined || compareRelease(theirs, mine) <= 0) return
271 await offer($, target, h.channel, h.version, h.home, h.upstream, names, settings)
272 } catch (err) {
273 $.ui.log(`kit-update: check failed: ${String(err)}`, { to: 'debug' })
274 } finally {
275 checking = false
276 }
277}
278
279/** What /kit-update answers. */
280async function status($: EngineInterface, settings: Settings): Promise<string> {
281 const h = await homeState($, settings)
282 if (h.pointerProblem !== undefined) return `${h.pointerProblem}.\nNothing is checked until it is fixed.`
283 if (h.noConf) return noConfText(h.home)
284 const via = h.pointer !== undefined ? `, by kit_home in ${h.pointer}` : ''
285 const lines = [`channel: ${h.channel}${h.problem !== undefined ? ` (${h.problem})` : ''}`, `this home: ${h.version ?? 'no memory-kit/VERSION'} (${h.home}${via})`]
286 if (h.channel === 'pinned') {
287 lines[0] += ' -- no checks; set update: verified or latest in memory-kit.conf to follow releases'
288 return lines.join('\n')
289 }
290 if (!settings.checkUpdates) lines.push('checkUpdates is off: no checks')
291 const stored = await $.store.get(TAGS)
292 const entry = (typeof stored === 'object' && stored !== null ? (stored as TagsCache) : {})[tagsUrl(h.upstream, 1) ?? '']
293 const target = entry === undefined ? undefined : targetOf(h.channel, entry.names)
294 lines.push(`newest on the channel: ${target ?? 'not known yet'}`)
295 lines.push(`last check: ${entry === undefined ? 'none yet' : `${new Date(entry.checkedAt).toISOString().slice(0, 16)} UTC`} (every ${settings.checkMs / MINUTE} min)`)
296 if (target !== undefined) {
297 const rec = recordOf(await $.store.get(promptKey(target)))
298 const when = (at: number) => `${new Date(at).toISOString().slice(0, 16)} UTC`
299 lines.push(
300 `${target}: ${
301 rec === undefined
302 ? 'not prompted'
303 : rec.state === 'delivered'
304 ? `prompted ${when(rec.at)}${rec.token === token ? ' in this session' : ' in another session'}`
305 : `claimed ${when(rec.at)}${rec.token === token ? ' by this session (prompt refused, retrying)' : ' by another session'}`
306 }`,
307 )
308 }
309 return lines.join('\n')
310}
311
312export const register: Register = (on, options) => {
313 const settings: Settings = {
314 home: typeof options.home === 'string' ? options.home.trim() : '',
315 checkMs: Math.max(5, typeof options.checkMinutes === 'number' && Number.isFinite(options.checkMinutes) ? options.checkMinutes : 60) * MINUTE,
316 checkUpdates: options.checkUpdates !== false,
317 }
318 let timer: { cancel: () => void } | undefined
319
320 on('session.start', async ($, e, next) => {
321 const started = await next(e)
322 if (startCwd === '') startCwd = e.cwd
323 if (token === '') token = `${await $.clock.now()}-${Math.random().toString(36).slice(2, 10)}`
324 await $.command.register({ name: 'kit-update', description: "Show the kit's update channel, this home's version, the newest release and the last check" })
325 timer?.cancel()
326 timer = $.clock.every(TICK_MS, () => void tick($, settings))
327 // Never awaited: a check may wait on the network, and a prompt on its turn.
328 void tick($, settings)
329 return started
330 })
331
332 on('command.run', { command: 'kit-update' }, async $ => ({ text: await status($, settings) }))
333}
334hooks/release.ts 199 lines1// Pure logic of the kit-update plugin: no `$`, no I/O. Everything here is unit-tested.
2//
3// The release check is a COPY of the obligations pane's (kit #7), from
4// adapters/claude-code/obligations-pane/hooks/obligations.ts: parseRelease, compareRelease,
5// tagNamesOf, isFresh, confValue, kitVersionOf, the owner/repo upstream. A plugin loads only
6// files inside its own folder, and each plugin installs and reverts on its own, so the two
7// cannot share one module. Change one, change both.
8
9// ---------------------------------------------------------------------------------------------
10// Copied from the pane.
11
12export type Release = { major: number; minor: number; patch: number; pre?: { kind: 'alpha' | 'beta' | 'rc'; n: number } }
13
14const NUM = '(0|[1-9]\\d*)'
15const RELEASE = new RegExp(`^v?${NUM}\\.${NUM}\\.${NUM}(?:-(alpha|beta|rc)\\.${NUM})?$`)
16
17/** A release tag or version: v<maj>.<min>.<patch>, optional -alpha.N, -beta.N or -rc.N; else undefined. */
18export function parseRelease(tag: string): Release | undefined {
19 const m = RELEASE.exec(tag.trim())
20 if (!m) return undefined
21 const core = { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }
22 return m[4] === undefined ? core : { ...core, pre: { kind: m[4] as 'alpha' | 'beta' | 'rc', n: Number(m[5]) } }
23}
24
25const PRE_ORDER = { alpha: 0, beta: 1, rc: 2 } as const
26
27/** Semver precedence: core numbers, then a release above its pre-releases, then alpha < beta < rc, then N. */
28export function compareRelease(a: Release, b: Release): number {
29 const core = a.major - b.major || a.minor - b.minor || a.patch - b.patch
30 if (core !== 0) return core
31 if (a.pre === undefined || b.pre === undefined) return (a.pre === undefined ? 1 : 0) - (b.pre === undefined ? 1 : 0)
32 return PRE_ORDER[a.pre.kind] - PRE_ORDER[b.pre.kind] || a.pre.n - b.pre.n
33}
34
35export const versionText = (r: Release) =>
36 `${r.major}.${r.minor}.${r.patch}${r.pre === undefined ? '' : `-${r.pre.kind}.${r.pre.n}`}`
37
38/** Tag names from a tags API answer (a JSON list of `{ name }`); undefined for anything else. */
39export function tagNamesOf(text: string): string[] | undefined {
40 let data: unknown
41 try {
42 data = JSON.parse(text)
43 } catch {
44 return undefined
45 }
46 if (!Array.isArray(data)) return undefined
47 const names: string[] = []
48 for (const tag of data) {
49 const name = (tag as { name?: unknown } | null)?.name
50 if (typeof name !== 'string') return undefined
51 names.push(name)
52 }
53 return names
54}
55
56/** A check made within the interval. A clock that went back makes it stale. */
57export function isFresh(checkedAt: number, now: number, intervalMs: number): boolean {
58 const age = now - checkedAt
59 return age >= 0 && age < intervalMs
60}
61
62/** Strips a trailing ` # comment` from a plain scalar. */
63function plainScalar(raw: string): string {
64 const hash = raw.search(/(^|\s)#/)
65 return (hash === -1 ? raw : raw.slice(0, hash)).trim()
66}
67
68/** A top-level `key: value` of a kit home's memory-kit.conf, unquoted; undefined when absent or empty. */
69export function confValue(conf: string, key: string): string | undefined {
70 for (const raw of conf.split(/\r?\n/)) {
71 if (!raw.startsWith(`${key}:`)) continue
72 const v = plainScalar(raw.slice(key.length + 1)).replace(/^["']|["']$/g, '')
73 return v === '' ? undefined : v
74 }
75 return undefined
76}
77
78/** The version a home's memory-kit/VERSION names: line 1, trimmed; undefined when empty. */
79export function kitVersionOf(text: string): string | undefined {
80 const first = (text.split(/\r?\n/)[0] ?? '').trim()
81 return first === '' ? undefined : first
82}
83
84export const DEFAULT_UPSTREAM = 'mitselek/memory-kit'
85const REPO_PART = '[A-Za-z0-9_][A-Za-z0-9_.-]*'
86const OWNER_REPO = new RegExp(`^${REPO_PART}/${REPO_PART}$`)
87
88function repoOf(upstream: string | undefined): string | undefined {
89 const repo = upstream ?? DEFAULT_UPSTREAM
90 return OWNER_REPO.test(repo) && !repo.split('/').some(p => /^\.+$/.test(p)) ? repo : undefined
91}
92
93// ---------------------------------------------------------------------------------------------
94// Channels (kit #15): memory-kit.conf `update: pinned | verified | latest`.
95
96export type Channel = 'pinned' | 'verified' | 'latest'
97
98/** The home's channel; pinned when absent. Anything else is a problem, read as pinned (no request). */
99export function channelOf(conf: string): { channel: Channel; problem?: string } {
100 const v = confValue(conf, 'update')
101 if (v === undefined || v === 'pinned') return { channel: 'pinned' }
102 if (v === 'verified' || v === 'latest') return { channel: v }
103 return { channel: 'pinned', problem: `update: ${v} is not a channel (pinned, verified or latest); nothing is checked` }
104}
105
106/** The newest release on the channel: `latest`, every `v...` tag; `verified`, the `verified/v...` markers. */
107export function targetOf(channel: Channel, names: readonly string[]): string | undefined {
108 if (channel === 'pinned') return undefined
109 let best: Release | undefined
110 for (const name of names) {
111 // Exactly `verified/v<version>` (a marker) or `v<version>` (a release tag).
112 const marker = name.startsWith('verified/v')
113 if (marker !== (channel === 'verified')) continue
114 const tag = marker ? name.slice('verified/'.length) : name
115 if (!tag.startsWith('v')) continue
116 const r = parseRelease(tag)
117 if (r !== undefined && (best === undefined || compareRelease(r, best) > 0)) best = r
118 }
119 return best === undefined ? undefined : versionText(best)
120}
121
122/** One page of the tags API (50 to a page); undefined for an upstream that is not owner/repo. */
123export function tagsUrl(upstream: string | undefined, page: number): string | undefined {
124 const repo = repoOf(upstream)
125 return repo === undefined ? undefined : `https://codeberg.org/api/v1/repos/${repo}/tags?limit=50&page=${page}`
126}
127
128/** UPGRADE.md as the release tag has it. */
129export function upgradeUrl(upstream: string | undefined, version: string): string | undefined {
130 const repo = repoOf(upstream)
131 return repo === undefined ? undefined : `https://codeberg.org/api/v1/repos/${repo}/raw/UPGRADE.md?ref=v${version}`
132}
133
134// ---------------------------------------------------------------------------------------------
135// Auto or manual: UPGRADE.md's per-version steps, `- **A -> B:** ...`.
136
137/**
138 * Whether going from `current` to `target` can run tools/kit-update.py, decided as the script
139 * decides it (its step_path and data_steps_ok), so the prompt never promises what the script
140 * refuses (ultrareview alpha.13, finding 3). The path is the home's current version, always (even
141 * when its tag is gone upstream), then every release tag in `releases` after it up to the target.
142 * Every hop A -> B needs its own UPGRADE.md entry `**A -> B:**` whose text begins "no data step";
143 * a missing entry, a data step, or a new major is a manual upgrade, with the reason. The script
144 * makes the same refusal; this only words the prompt.
145 */
146export function stepCheck(
147 upgradeMd: string,
148 current: string,
149 target: string,
150 releases: readonly string[],
151): { manual: false } | { manual: true; reason: string } {
152 const from = parseRelease(current)
153 const to = parseRelease(target)
154 if (from === undefined || to === undefined) return { manual: true, reason: 'a version that is not a release' }
155 if (to.major !== from.major) return { manual: true, reason: 'a new major version' }
156 const between = releases
157 .filter(name => name.startsWith('v'))
158 .map(name => parseRelease(name))
159 .filter((r): r is Release => r !== undefined && compareRelease(r, from) > 0 && compareRelease(r, to) < 0)
160 .sort(compareRelease)
161 .map(versionText)
162 const path = [current, ...new Set(between), target]
163 for (let i = 0; i + 1 < path.length; i++) {
164 const [a = '', b = ''] = [path[i], path[i + 1]]
165 const entry = upgradeMd.indexOf(`**${a} -> ${b}:**`)
166 if (entry === -1) return { manual: true, reason: `UPGRADE.md has no entry for ${a} -> ${b}` }
167 const text = upgradeMd.slice(entry + `**${a} -> ${b}:**`.length).split('\n')[0] ?? ''
168 if (!text.trimStart().toLowerCase().startsWith('no data step')) return { manual: true, reason: `a data step in ${a} -> ${b}` }
169 }
170 return { manual: false }
171}
172
173// ---------------------------------------------------------------------------------------------
174// The prompt.
175
176export type Notice =
177 | { version: string; channel: Channel; current: string; home: string; manual: false }
178 | { version: string; channel: Channel; current: string; home: string; manual: true; reason: string }
179
180/** A POSIX shell single-quoted word. */
181const shellQuote = (text: string) => `'${text.replaceAll("'", "'\\''")}'`
182
183/**
184 * What the session reads. It names its source and asks for nothing the home's own rules do not
185 * already allow: a plugin's notice is no instruction from the user.
186 */
187export function promptText(n: Notice): string {
188 const head = `[kit-update] memory-kit ${n.version} (${n.channel}) is out; this home is on ${n.current}.`
189 const act = n.manual
190 ? `It needs a manual upgrade: manual upgrade needed (${n.reason}). Follow memory-kit/UPGRADE.md step by step; tools/kit-update.py refuses it.`
191 : `Run \`python3 memory-kit/tools/kit-update.py ${shellQuote(n.home)}\` from the home, following this home's UPGRADE.md and its own commit and push rules.`
192 return `${head} ${act} This notice is from the kit-update plugin, not from the user: it authorizes nothing beyond what those rules already allow.`
193}
194
195/** After `failures` refused prompts in a row: 1 min, doubling, at most 30 min. */
196export function backoffMs(failures: number): number {
197 return failures <= 0 ? 0 : Math.min(30 * 60_000, 60_000 * 2 ** (failures - 1))
198}
199hooks/kit-home.ts 83 lines1// kit_home: a memory-kit.conf key naming the kit home (kit #18). Pure: the caller hands in how
2// to read a file.
3//
4// IDENTICAL in adapters/claude-code/{obligations-pane,watch,kit-update}/hooks/kit-home.ts: a
5// plugin loads only its own folder, so each carries a copy; lint/run-fixtures.sh fails when the
6// copies differ. The same rules for Python are lint/kit_home.py, with the same messages.
7//
8// If the start folder's memory-kit.conf says `kit_home: <path>` (absolute, or relative to that
9// conf's folder), the home is the folder it names: that home's conf, memory-kit/VERSION, memory
10// directory, update:/upstream: and watch_files count, and the pointer conf's other keys are
11// ignored (reported in `ignored`). One hop, never chained: the target's conf may not point on (a
12// loop is a chain too). `kit_home: .` is just the home. A target with no memory-kit.conf, and a
13// `~` path (not expanded), are problems said plainly, never a silent fall back.
14
15export const KIT_HOME_KEY = 'kit_home'
16
17export type KitHome = { home: string; pointer?: string; ignored: string[] } | { problem: string; pointer: string }
18
19/** POSIX (/...), a drive letter (C:\ or C:/) or UNC (\\server\share). */
20export function isAbsolute(path: string): boolean {
21 return /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(path)
22}
23
24/** `.` and `..` resolved, `/` as the separator, no trailing slash; a root (/, C:/, //server/share) kept. */
25export function normalizePath(path: string): string {
26 const unc = /^\\\\|^\/\//.test(path)
27 const drive = /^[A-Za-z]:[\\/]/.exec(path)?.[0].slice(0, 2)
28 const root = unc ? '//' : drive !== undefined ? `${drive}/` : path.startsWith('/') ? '/' : ''
29 const parts: string[] = []
30 for (const part of path.slice(unc ? 2 : drive !== undefined ? 3 : root.length).split(/[\\/]+/)) {
31 if (part === '' || part === '.') continue
32 if (part === '..' && parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
33 else if (part !== '..' || root === '') parts.push(part)
34 }
35 return `${root}${parts.join('/')}` || '.'
36}
37
38const sameAs = (a: string, b: string) => {
39 const [x, y] = [normalizePath(a), normalizePath(b)]
40 return /^[A-Za-z]:\//.test(x) || x.startsWith('//') ? x.toLowerCase() === y.toLowerCase() : x === y
41}
42
43/** A top-level `key: value` of memory-kit.conf, a trailing comment and quotes stripped; '' when absent. */
44export function confValueOf(conf: string, key: string): string {
45 for (const line of conf.split(/\r?\n/)) {
46 if (!line.startsWith(`${key}:`)) continue
47 const raw = line.slice(key.length + 1)
48 const hash = raw.search(/\s#/)
49 return (hash === -1 ? raw : raw.slice(0, hash)).trim().replace(/^["']|["']$/g, '')
50 }
51 return ''
52}
53
54/** What a reader says when the folder it took for the home has no memory-kit.conf (kit #31): silence
55 * there looked the same as "nothing newer". lint/kit_home.py no_conf_notice says it alike. */
56export function noConfNotice(folder: string): string {
57 return `${normalizePath(folder)} has no memory-kit.conf, so it is not a kit home; if the home is elsewhere, put kit_home: <path> in a memory-kit.conf here`
58}
59
60const topKeys = (conf: string) => conf.split(/\r?\n/).flatMap(line => /^([A-Za-z_][\w-]*):/.exec(line)?.[1] ?? [])
61
62/** The home for a start folder, by its memory-kit.conf's kit_home (see the head of this file). */
63export async function resolveKitHome(start: string, read: (path: string) => Promise<string | undefined>): Promise<KitHome> {
64 const from = normalizePath(start)
65 const pointer = `${from}/memory-kit.conf`
66 const conf = await read(pointer)
67 const raw = conf === undefined ? '' : confValueOf(conf, KIT_HOME_KEY)
68 if (raw === '') return { home: from, ignored: [] }
69 if (raw.startsWith('~'))
70 return { pointer, problem: `${pointer}: kit_home: ${raw} -- write it absolute or relative to this conf's folder (~ is not expanded)` }
71 const target = normalizePath(isAbsolute(raw) ? raw : `${from}/${raw}`)
72 if (sameAs(target, from)) return { home: from, ignored: [] }
73 const targetConf = await read(`${target}/memory-kit.conf`)
74 if (targetConf === undefined) return { pointer, problem: `${pointer}: kit_home: ${raw} -> ${target}: no memory-kit.conf there, so it is not a kit home` }
75 const onward = confValueOf(targetConf, KIT_HOME_KEY)
76 if (onward !== '' && !sameAs(isAbsolute(onward) ? onward : `${target}/${onward}`, target))
77 return {
78 pointer,
79 problem: `${pointer}: kit_home: ${raw} -> ${target}, whose own conf points on (kit_home: ${onward}); a pointer is followed one hop, never chained`,
80 }
81 return { home: target, pointer, ignored: topKeys(conf ?? '').filter(k => k !== KIT_HOME_KEY) }
82}
83