diff --git a/README.md b/README.md index b7743f4e..a2bbab23 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,17 @@ What the verbs cover: **verified** store→disk write, statusline footer, and a background daemon with **local-only ($0) workers** (token-spending AI workers stay opt-in behind upstream's machine-wide budget). -- **status** — per-subsystem ✓/⚠/✗ (versions, natives, security, learning, aqe/RVF, - MCP, daemons, CLAUDE.md blocks, statusline), each drift row naming what `sync` - would do about it. +- **status** — per-subsystem ✓/⚠/✗ (versions, the kit's own version, natives, + security, learning, aqe/RVF, MCP, daemons, CLAUDE.md blocks, statusline), each + drift row naming what `sync` would do about it. - **sync** — the one convergence verb: upgrades first when a new release exists, - then re-heals everything an upgrade wipes, then re-checks and reports. + then re-heals everything an upgrade wipes, then re-checks and reports. It also + **self-updates the kit**: when a newer `@pacphi/agentic-kit` exists it installs + it as the *last* step (the new code applies from the next `ak` run, never + mid-sync). Prerelease installs (`4.0.0-alpha.*`) track the `next` npm dist-tag + as well as `latest`, so alphas see their successors; stable installs only ever + follow `latest`. `--no-upgrade` skips the self-update along with the package + upgrades. - **uninstall** — removes the kit's footprint (and any legacy shell-kit install); project data is never touched; `--purge` also offers to remove the global packages. diff --git a/src/commands/status.mjs b/src/commands/status.mjs index a3b5603d..718d3e52 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -11,7 +11,7 @@ import { listDaemons, staleDaemons } from '../lib/daemons.mjs'; import { scanRvf } from '../lib/rvf.mjs'; import { registry, syncBlocks } from '../lib/blocks.mjs'; import { loadKitConfig } from '../lib/config.mjs'; -import { driftReport } from '../lib/versions.mjs'; +import { driftReport, selfDrift } from '../lib/versions.mjs'; import { readJson } from '../lib/settings.mjs'; export const options = { @@ -43,6 +43,20 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { rows.push(row('versions', 'warn', `version check unavailable: ${e.message}`)); } + // self (the kit's own version — prerelease installs track the `next` tag) + try { + const s = await selfDrift({ pkgRoot }); + if (s.outdated) { + rows.push(row('self', 'warn', + `kit ${s.installed} installed, ${s.latest} available (${s.tag} tag)`, + 'sync self-updates the kit (runs last)')); + } else if (s.installed) { + rows.push(row('self', 'ok', `kit ${s.installed}${s.latest ? ' (latest)' : ''}`)); + } + } catch (e) { + rows.push(row('self', 'warn', `kit version check unavailable: ${e.message}`)); + } + // natives (better-sqlite3 in agentdb locations + aqe) try { const n = nativesStatus(); diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 7ac65049..7f9218d9 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -9,7 +9,7 @@ import { registry, syncBlocks } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; import { loadKitConfig } from '../lib/config.mjs'; -import { driftReport } from '../lib/versions.mjs'; +import { driftReport, selfDrift } from '../lib/versions.mjs'; import * as paths from '../lib/paths.mjs'; import { ok, warn, fail, bold, dim } from '../lib/output.mjs'; @@ -23,7 +23,7 @@ export async function run({ flags, pkgRoot }) { const cwd = process.cwd(); const rows = await collect({ pkgRoot, cwd }); const plan = rows.filter((r) => r.fix) - .filter((r) => !(flags['no-upgrade'] && r.subsystem === 'versions')); + .filter((r) => !(flags['no-upgrade'] && (r.subsystem === 'versions' || r.subsystem === 'self'))); if (plan.length === 0) { ok('nothing to do — all subsystems healthy'); return 0; } @@ -78,6 +78,14 @@ export async function run({ flags, pkgRoot }) { (r.applied || !r.reason ? ok : warn)(`statusline: ${r.applied ? `footer injected (v${r.version})` : r.reason ?? 'in sync'}`); } + // kit self-update — LAST, after every other heal: npm replaces the kit's + // files on disk, and the new code applies from the next ak run, so nothing + // after this point should depend on the kit's own modules being current. + if (subsystems.has('self') && !flags['no-upgrade']) { + const s = await selfDrift({ pkgRoot, force: true }); + if (s.outdated) report('self-update', await heal.selfUpdate(s.latest)); + } + // converge proof console.log(''); const after = await collect({ pkgRoot, cwd }); diff --git a/src/lib/heal.mjs b/src/lib/heal.mjs index 82aad8d3..6ad0bffa 100644 --- a/src/lib/heal.mjs +++ b/src/lib/heal.mjs @@ -8,6 +8,7 @@ import path from 'node:path'; import { run } from './exec.mjs'; import { rufloRoot, aqeRoot } from './paths.mjs'; import { agentdbLocations, bsq3IsNative, aidefencePresent } from './natives.mjs'; +import { KIT_PKG } from './versions.mjs'; import { scanRvf, quarantine } from './rvf.mjs'; // Packages whose install scripts must run for natives to build (npm >=11.17 @@ -80,6 +81,20 @@ export async function upgradePackage(pkg) { return { ok: r.code === 0, detail: r.code === 0 ? 'upgraded' : r.stderr.split('\n').slice(-3).join(' ') }; } +/** Upgrade the kit itself to a pinned version. Runs LAST in sync: npm + * replaces the kit's files on disk, so the new code applies from the next + * ak invocation — never mid-run. Pinning the exact version (not a dist-tag) + * installs precisely what the drift check saw. */ +export async function selfUpdate(version) { + const r = await run('npm', ['install', '-g', `${KIT_PKG}@${version}`], { timeout: 300_000 }); + return { + ok: r.code === 0, + detail: r.code === 0 + ? `kit upgraded to ${version} (applies from the next ak run)` + : `FAILED (${(r.stderr || `exit ${r.code}`).trim().split('\n').slice(-2).join(' ').slice(0, 200)})`, + }; +} + /** Stop all ruflo daemons before an upgrade (3.27+; best-effort). */ export async function stopAllDaemons() { const r = await run('ruflo', ['daemon', 'stop', '--all'], { timeout: 60_000 }); diff --git a/src/lib/versions.mjs b/src/lib/versions.mjs index 85f65256..80d3bf8e 100644 --- a/src/lib/versions.mjs +++ b/src/lib/versions.mjs @@ -16,20 +16,42 @@ export function installedVersion(pkg) { } } -async function latestVersion(pkg) { - const r = await run('npm', ['view', pkg, 'version'], { timeout: 20_000 }); +async function latestVersion(pkg, tag = 'latest') { + const r = await run('npm', ['view', `${pkg}@${tag}`, 'version'], { timeout: 20_000 }); return r.code === 0 ? r.stdout.trim() : null; } -const newer = (a, b) => { - // semver-lite compare, prerelease-insensitive (enough for drift detection) - const pa = String(a).split(/[.-]/).map(Number); - const pb = String(b).split(/[.-]/).map(Number); +/** Semver compare, prerelease-aware (4.0.0 > 4.0.0-alpha.1 > 4.0.0-alpha.0). + * Exported for tests. */ +export function cmpVersions(a, b) { + const parse = (v) => { + const [core, ...rest] = String(v).split('-'); + return { + core: core.split('.').map(Number), + pre: rest.length ? rest.join('-').split('.') : null, + }; + }; + const A = parse(a); const B = parse(b); for (let i = 0; i < 3; i++) { - if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0); + const d = (A.core[i] || 0) - (B.core[i] || 0); + if (d) return d; } - return false; -}; + if (!A.pre && !B.pre) return 0; + if (!A.pre) return 1; // a release outranks any prerelease of the same core + if (!B.pre) return -1; + for (let i = 0; i < Math.max(A.pre.length, B.pre.length); i++) { + const x = A.pre[i]; const y = B.pre[i]; + if (x === undefined) return -1; // shorter prerelease list is lower + if (y === undefined) return 1; + const nx = /^\d+$/.test(x); const ny = /^\d+$/.test(y); + if (nx && ny) { const d = Number(x) - Number(y); if (d) return d; } + else if (nx !== ny) return nx ? -1 : 1; // numeric identifiers < alphanumeric + else if (x !== y) return x < y ? -1 : 1; + } + return 0; +} + +const newer = (a, b) => cmpVersions(a, b) > 0; /** Drift report for the managed packages. Network hit at most once per TTL * window (cached in kit.json); force=true bypasses the cache. */ @@ -57,3 +79,37 @@ export async function driftReport({ force = false } = {}) { } return report; } + +export const KIT_PKG = '@pacphi/agentic-kit'; + +/** Drift for the kit itself. Installed = the running copy's package.json + * (pkgRoot). Prerelease installs also consult the `next` dist-tag — + * prereleases publish there, so `latest` alone would never see them; the + * higher of latest/next wins. Cached in kit.json alongside versionCheck. */ +export async function selfDrift({ pkgRoot, force = false } = {}) { + let installed = null; + try { + installed = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')).version; + } catch { /* unreadable pkgRoot: report as not installed */ } + const cfg = loadKitConfig(); + const ttlMs = (cfg.versionCheck?.ttlHours ?? 24) * 3600_000; + const cached = cfg.versionCheck?.self; + const fresh = !force && cached?.last && Date.now() - cached.last < ttlMs; + let best = fresh ? cached.best ?? null : null; + if (!fresh) { + const tags = installed?.includes('-') ? ['latest', 'next'] : ['latest']; + for (const tag of tags) { + const v = await latestVersion(KIT_PKG, tag); + if (v && (!best || newer(v, best.version))) best = { version: v, tag }; + } + cfg.versionCheck = { ...cfg.versionCheck, self: { last: Date.now(), best } }; + try { saveKitConfig(cfg); } catch { /* read-only envs: next call re-fetches */ } + } + return { + pkg: KIT_PKG, + installed, + latest: best?.version ?? null, + tag: best?.tag ?? null, + outdated: !!(installed && best && newer(best.version, installed)), + }; +} diff --git a/tests/kit/versions.test.mjs b/tests/kit/versions.test.mjs new file mode 100644 index 00000000..88446593 --- /dev/null +++ b/tests/kit/versions.test.mjs @@ -0,0 +1,52 @@ +// cmpVersions — the prerelease-aware comparator behind drift detection and +// kit self-update. The old comparator was prerelease-insensitive, which made +// 4.0.0-alpha.1 vs 4.0.0-alpha.0 compare equal and self-update impossible. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { cmpVersions } from '../../src/lib/versions.mjs'; + +const newer = (a, b) => cmpVersions(a, b) > 0; + +test('core versions compare numerically', () => { + assert.equal(newer('4.0.1', '4.0.0'), true); + assert.equal(newer('4.1.0', '4.0.9'), true); + assert.equal(newer('3.29.0', '3.28.0'), true); + assert.equal(newer('3.28.0', '3.29.0'), false); + assert.equal(newer('10.0.0', '9.9.9'), true); // numeric, not lexicographic +}); + +test('equal versions are not newer', () => { + assert.equal(cmpVersions('4.0.0', '4.0.0'), 0); + assert.equal(cmpVersions('4.0.0-alpha.1', '4.0.0-alpha.1'), 0); +}); + +test('release outranks any prerelease of the same core', () => { + assert.equal(newer('4.0.0', '4.0.0-alpha.1'), true); + assert.equal(newer('4.0.0-rc.9', '4.0.0'), false); +}); + +test('prerelease increments compare (the alpha.0 → alpha.1 case)', () => { + assert.equal(newer('4.0.0-alpha.1', '4.0.0-alpha.0'), true); + assert.equal(newer('4.0.0-alpha.0', '4.0.0-alpha.1'), false); +}); + +test('numeric prerelease identifiers compare numerically', () => { + assert.equal(newer('4.0.0-alpha.10', '4.0.0-alpha.9'), true); +}); + +test('prerelease channels compare lexically (alpha < beta < rc)', () => { + assert.equal(newer('4.0.0-beta.0', '4.0.0-alpha.9'), true); + assert.equal(newer('4.0.0-rc.0', '4.0.0-beta.9'), true); +}); + +test('numeric prerelease identifiers rank below alphanumeric ones', () => { + assert.equal(newer('4.0.0-alpha', '4.0.0-1'), true); // semver §11.4.3 +}); + +test('shorter prerelease list ranks below a longer prefix-equal one', () => { + assert.equal(newer('4.0.0-alpha.1', '4.0.0-alpha'), true); // semver §11.4.4 +}); + +test('higher core wins regardless of prerelease', () => { + assert.equal(newer('4.0.1-alpha.0', '4.0.0'), true); +});