diff --git a/README.md b/README.md
index 794f23a..7c03aa3 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,16 @@ Two minutes. No API keys. No external services. No configuration ceremony. Uses
npx selftune@latest doctor
```
+## Updating
+
+The skill and CLI ship together as one npm package. To update:
+
+```bash
+npx skills add selftune-dev/selftune
+```
+
+This reinstalls the latest version of both the skill (SKILL.md, workflows) and the CLI. `selftune doctor` will warn you when a newer version is available.
+
## Before / After
diff --git a/biome.json b/biome.json
index e2bf806..898b710 100644
--- a/biome.json
+++ b/biome.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
diff --git a/cli/selftune/badge/badge.ts b/cli/selftune/badge/badge.ts
index 4843823..7a74b89 100644
--- a/cli/selftune/badge/badge.ts
+++ b/cli/selftune/badge/badge.ts
@@ -30,7 +30,7 @@ Options:
const VALID_FORMATS = new Set(["svg", "markdown", "url"]);
-export function cliMain(): void {
+export async function cliMain(): Promise {
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
@@ -71,7 +71,7 @@ export function cliMain(): void {
const auditEntries = readJsonl(EVOLUTION_AUDIT_LOG);
// Run doctor for system health
- const doctorResult = doctor();
+ const doctorResult = await doctor();
// Compute status
const result = computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
diff --git a/cli/selftune/dashboard-server.ts b/cli/selftune/dashboard-server.ts
index b19e321..888156d 100644
--- a/cli/selftune/dashboard-server.ts
+++ b/cli/selftune/dashboard-server.ts
@@ -100,12 +100,12 @@ const MIME_TYPES: Record = {
".ico": "image/x-icon",
};
-function computeStatusFromLogs(): StatusResult {
+async function computeStatusFromLogs(): Promise {
const telemetry = readJsonl(TELEMETRY_LOG);
const skillRecords = readEffectiveSkillUsageRecords();
const queryRecords = readJsonl(QUERY_LOG);
const auditEntries = readJsonl(EVOLUTION_AUDIT_LOG);
- const doctorResult = doctor();
+ const doctorResult = await doctor();
return computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
}
@@ -531,7 +531,7 @@ export async function startDashboardServer(
// ---- GET /api/v2/doctor ---- System health diagnostics
if (url.pathname === "/api/v2/doctor" && req.method === "GET") {
- const result = doctor();
+ const result = await doctor();
return Response.json(result, { headers: corsHeaders() });
}
diff --git a/cli/selftune/evolution/evolve-body.ts b/cli/selftune/evolution/evolve-body.ts
index 9531220..1e77a5f 100644
--- a/cli/selftune/evolution/evolve-body.ts
+++ b/cli/selftune/evolution/evolve-body.ts
@@ -410,7 +410,7 @@ export async function evolveBody(
};
}
- if (lastProposal && lastValidation && lastValidation.improved) {
+ if (lastProposal && lastValidation?.improved) {
// Deploy: write updated SKILL.md
if (target === "routing") {
const updatedContent = replaceSection(
diff --git a/cli/selftune/evolution/evolve.ts b/cli/selftune/evolution/evolve.ts
index 107d99b..04aa834 100644
--- a/cli/selftune/evolution/evolve.ts
+++ b/cli/selftune/evolution/evolve.ts
@@ -841,7 +841,7 @@ export async function evolve(
// -----------------------------------------------------------------------
// Step 15: Update evolution memory
// -----------------------------------------------------------------------
- const wasDeployed = lastProposal !== null && lastValidation !== null && lastValidation.improved;
+ const wasDeployed = lastProposal && lastValidation?.improved;
const evolveResult: EvolveResult = withStats({
proposal: lastProposal,
validation: lastValidation,
diff --git a/cli/selftune/index.ts b/cli/selftune/index.ts
index fbc20fb..ea01793 100644
--- a/cli/selftune/index.ts
+++ b/cli/selftune/index.ts
@@ -326,7 +326,7 @@ Run 'selftune eval --help' for action-specific options.`);
}
case "doctor": {
const { doctor } = await import("./observability.js");
- const result = doctor();
+ const result = await doctor();
console.log(JSON.stringify(result, null, 2));
process.exit(result.healthy ? 0 : 1);
break;
diff --git a/cli/selftune/init.ts b/cli/selftune/init.ts
index 7b13440..49a8bf7 100644
--- a/cli/selftune/init.ts
+++ b/cli/selftune/init.ts
@@ -595,7 +595,7 @@ export async function cliMain(): Promise {
// Run doctor as post-check
const { doctor } = await import("./observability.js");
- const doctorResult = doctor();
+ const doctorResult = await doctor();
console.log(
JSON.stringify({
level: "info",
diff --git a/cli/selftune/observability.ts b/cli/selftune/observability.ts
index 2b46753..6c7d413 100644
--- a/cli/selftune/observability.ts
+++ b/cli/selftune/observability.ts
@@ -203,12 +203,73 @@ export function checkConfigHealth(): HealthCheck[] {
return [check];
}
-export function doctor(): DoctorResult {
+/**
+ * Compare two semver strings. Returns:
+ * -1 if a < b, 0 if equal, 1 if a > b.
+ * Handles standard x.y.z versions; pre-release tags are not compared.
+ */
+function compareSemver(a: string, b: string): -1 | 0 | 1 {
+ const pa = a.split(".").map(Number);
+ const pb = b.split(".").map(Number);
+ for (let i = 0; i < 3; i++) {
+ const va = pa[i] ?? 0;
+ const vb = pb[i] ?? 0;
+ if (va < vb) return -1;
+ if (va > vb) return 1;
+ }
+ return 0;
+}
+
+/** Check if the installed version is the latest on npm. Non-blocking, warns on stale. */
+export async function checkVersionHealth(): Promise {
+ const check: HealthCheck = {
+ name: "version_up_to_date",
+ path: "package.json",
+ status: "pass",
+ message: "",
+ };
+
+ try {
+ const pkgPath = join(import.meta.dir, "../../package.json");
+ const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 3000);
+ try {
+ const res = await fetch("https://registry.npmjs.org/selftune/latest", {
+ signal: controller.signal,
+ });
+
+ if (res.ok) {
+ const data = (await res.json()) as { version: string };
+ const latestVersion = data.version;
+ const cmp = compareSemver(currentVersion, latestVersion);
+ if (cmp >= 0) {
+ check.message = `v${currentVersion} (latest)`;
+ } else {
+ check.status = "warn";
+ check.message = `v${currentVersion} installed, v${latestVersion} available. Run: npx skills add selftune-dev/selftune`;
+ }
+ } else {
+ check.message = `v${currentVersion} (unable to check npm registry)`;
+ }
+ } finally {
+ clearTimeout(timeout);
+ }
+ } catch {
+ check.message = "Unable to check latest version (network unavailable)";
+ }
+
+ return [check];
+}
+
+export async function doctor(): Promise {
const allChecks = [
...checkConfigHealth(),
...checkLogHealth(),
...checkHookInstallation(),
...checkEvolutionHealth(),
+ ...(await checkVersionHealth()),
];
const passed = allChecks.filter((c) => c.status === "pass").length;
const failed = allChecks.filter((c) => c.status === "fail").length;
@@ -224,7 +285,7 @@ export function doctor(): DoctorResult {
}
if (import.meta.main) {
- const result = doctor();
+ const result = await doctor();
console.log(JSON.stringify(result, null, 2));
process.exit(result.healthy ? 0 : 1);
}
diff --git a/cli/selftune/orchestrate.ts b/cli/selftune/orchestrate.ts
index 78b6d7e..ba8bf6c 100644
--- a/cli/selftune/orchestrate.ts
+++ b/cli/selftune/orchestrate.ts
@@ -652,7 +652,7 @@ export async function orchestrate(
const skillRecords = _readSkillRecords();
const queryRecords = _readQueryRecords();
const auditEntries = _readAuditEntries();
- const doctorResult = _doctor();
+ const doctorResult = await _doctor();
const statusResult = _computeStatus(
telemetry,
diff --git a/cli/selftune/quickstart.ts b/cli/selftune/quickstart.ts
index bef54ae..55721f0 100644
--- a/cli/selftune/quickstart.ts
+++ b/cli/selftune/quickstart.ts
@@ -115,7 +115,7 @@ export async function quickstart(): Promise {
try {
const auditEntries = readJsonl(EVOLUTION_AUDIT_LOG);
- const doctorResult = doctor();
+ const doctorResult = await doctor();
const result = computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
const output = formatStatus(result);
diff --git a/cli/selftune/status.ts b/cli/selftune/status.ts
index af8df4f..0edf802 100644
--- a/cli/selftune/status.ts
+++ b/cli/selftune/status.ts
@@ -324,13 +324,13 @@ function colorize(text: string, hex: string): string {
// cliMain — reads logs, runs doctor, prints output
// ---------------------------------------------------------------------------
-export function cliMain(): void {
+export async function cliMain(): Promise {
try {
const telemetry = readJsonl(TELEMETRY_LOG);
const skillRecords = readEffectiveSkillUsageRecords();
const queryRecords = readJsonl(QUERY_LOG);
const auditEntries = readJsonl(EVOLUTION_AUDIT_LOG);
- const doctorResult = doctor();
+ const doctorResult = await doctor();
const result = computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
const output = formatStatus(result);
diff --git a/package.json b/package.json
index d6255e8..ebaa19d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "selftune",
- "version": "0.2.3",
+ "version": "0.2.4",
"description": "Self-improving skills CLI for AI agents",
"type": "module",
"license": "MIT",
@@ -69,7 +69,7 @@
"@selftune/telemetry-contract": "workspace:*"
},
"devDependencies": {
- "@biomejs/biome": "2.4.7",
+ "@biomejs/biome": "^2.4.7",
"@types/bun": "^1.1.0"
}
}
diff --git a/tests/observability.test.ts b/tests/observability.test.ts
index 542e455..d314ef9 100644
--- a/tests/observability.test.ts
+++ b/tests/observability.test.ts
@@ -151,8 +151,8 @@ describe("checkConfigHealth", () => {
});
describe("doctor", () => {
- test("returns structured result", () => {
- const result = doctor();
+ test("returns structured result", async () => {
+ const result = await doctor();
expect(result.command).toBe("doctor");
expect(result).toHaveProperty("timestamp");
expect(result).toHaveProperty("checks");
@@ -165,16 +165,16 @@ describe("doctor", () => {
);
});
- test("includes evolution health checks", () => {
- const result = doctor();
+ test("includes evolution health checks", async () => {
+ const result = await doctor();
const evolutionChecks = result.checks.filter(
(c) => c.name === "evolution_audit" || c.name === "log_evolution_audit",
);
expect(evolutionChecks.length).toBeGreaterThanOrEqual(1);
});
- test("doctor does not produce false positives from git hook checks", () => {
- const result = doctor();
+ test("doctor does not produce false positives from git hook checks", async () => {
+ const result = await doctor();
// With the git hook checks removed, doctor should not produce false
// positives from missing .git/hooks/ files
const gitHookChecks = result.checks.filter((c) => c.path?.includes(".git/hooks/"));