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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<p align="center">
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
4 changes: 2 additions & 2 deletions cli/selftune/badge/badge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Options:

const VALID_FORMATS = new Set<BadgeFormat>(["svg", "markdown", "url"]);

export function cliMain(): void {
export async function cliMain(): Promise<void> {
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
Expand Down Expand Up @@ -71,7 +71,7 @@ export function cliMain(): void {
const auditEntries = readJsonl<EvolutionAuditEntry>(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);
Expand Down
6 changes: 3 additions & 3 deletions cli/selftune/dashboard-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,12 @@ const MIME_TYPES: Record<string, string> = {
".ico": "image/x-icon",
};

function computeStatusFromLogs(): StatusResult {
async function computeStatusFromLogs(): Promise<StatusResult> {
const telemetry = readJsonl<SessionTelemetryRecord>(TELEMETRY_LOG);
const skillRecords = readEffectiveSkillUsageRecords();
const queryRecords = readJsonl<QueryLogRecord>(QUERY_LOG);
const auditEntries = readJsonl<EvolutionAuditEntry>(EVOLUTION_AUDIT_LOG);
const doctorResult = doctor();
const doctorResult = await doctor();
return computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
}

Expand Down Expand Up @@ -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() });
}

Expand Down
2 changes: 1 addition & 1 deletion cli/selftune/evolution/evolve-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion cli/selftune/evolution/evolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion cli/selftune/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ Run 'selftune eval <action> --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;
Expand Down
2 changes: 1 addition & 1 deletion cli/selftune/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ export async function cliMain(): Promise<void> {

// Run doctor as post-check
const { doctor } = await import("./observability.js");
const doctorResult = doctor();
const doctorResult = await doctor();
console.log(
JSON.stringify({
level: "info",
Expand Down
65 changes: 63 additions & 2 deletions cli/selftune/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HealthCheck[]> {
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<DoctorResult> {
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;
Expand All @@ -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);
}
2 changes: 1 addition & 1 deletion cli/selftune/orchestrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion cli/selftune/quickstart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export async function quickstart(): Promise<void> {

try {
const auditEntries = readJsonl<EvolutionAuditEntry>(EVOLUTION_AUDIT_LOG);
const doctorResult = doctor();
const doctorResult = await doctor();

const result = computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
const output = formatStatus(result);
Expand Down
4 changes: 2 additions & 2 deletions cli/selftune/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
try {
const telemetry = readJsonl<SessionTelemetryRecord>(TELEMETRY_LOG);
const skillRecords = readEffectiveSkillUsageRecords();
const queryRecords = readJsonl<QueryLogRecord>(QUERY_LOG);
const auditEntries = readJsonl<EvolutionAuditEntry>(EVOLUTION_AUDIT_LOG);
const doctorResult = doctor();
const doctorResult = await doctor();

const result = computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
const output = formatStatus(result);
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -69,7 +69,7 @@
"@selftune/telemetry-contract": "workspace:*"
},
"devDependencies": {
"@biomejs/biome": "2.4.7",
"@biomejs/biome": "^2.4.7",
"@types/bun": "^1.1.0"
}
}
12 changes: 6 additions & 6 deletions tests/observability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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/"));
Expand Down