Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 15 additions & 1 deletion src/commands/status.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 10 additions & 2 deletions src/commands/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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; }

Expand Down Expand Up @@ -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 });
Expand Down
15 changes: 15 additions & 0 deletions src/lib/heal.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });
Expand Down
74 changes: 65 additions & 9 deletions src/lib/versions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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)),
};
}
52 changes: 52 additions & 0 deletions tests/kit/versions.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading