selftune 0.2.14 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/apps/local-dashboard/dist/assets/index-DOu3iLD9.js +16 -0
  2. package/apps/local-dashboard/dist/assets/vendor-ui-DIwlrGlb.js +12 -0
  3. package/apps/local-dashboard/dist/index.html +2 -2
  4. package/bin/run-hook.cjs +36 -0
  5. package/cli/selftune/analytics.ts +13 -11
  6. package/cli/selftune/badge/badge.ts +13 -9
  7. package/cli/selftune/canonical-export.ts +6 -6
  8. package/cli/selftune/contribute/contribute.ts +2 -1
  9. package/cli/selftune/cron/setup.ts +3 -1
  10. package/cli/selftune/dashboard-contract.ts +10 -0
  11. package/cli/selftune/dashboard.ts +10 -5
  12. package/cli/selftune/eval/baseline.ts +20 -30
  13. package/cli/selftune/eval/hooks-to-evals.ts +22 -12
  14. package/cli/selftune/eval/import-skillsbench.ts +21 -8
  15. package/cli/selftune/eval/unit-test-cli.ts +22 -11
  16. package/cli/selftune/evolution/description-quality.ts +224 -0
  17. package/cli/selftune/evolution/evolve-body.ts +17 -10
  18. package/cli/selftune/evolution/evolve.ts +94 -59
  19. package/cli/selftune/evolution/rollback.ts +7 -6
  20. package/cli/selftune/evolution/unblock-suggestions.ts +159 -0
  21. package/cli/selftune/grading/auto-grade.ts +24 -22
  22. package/cli/selftune/grading/grade-session.ts +21 -17
  23. package/cli/selftune/hooks/auto-activate.ts +12 -3
  24. package/cli/selftune/hooks/prompt-log.ts +7 -1
  25. package/cli/selftune/index.ts +66 -69
  26. package/cli/selftune/ingestors/claude-replay.ts +29 -14
  27. package/cli/selftune/ingestors/codex-rollout.ts +6 -1
  28. package/cli/selftune/init.ts +212 -36
  29. package/cli/selftune/monitoring/watch.ts +32 -16
  30. package/cli/selftune/orchestrate.ts +18 -17
  31. package/cli/selftune/routes/skill-report.ts +17 -0
  32. package/cli/selftune/schedule.ts +23 -9
  33. package/cli/selftune/sync.ts +7 -3
  34. package/cli/selftune/types.ts +45 -10
  35. package/cli/selftune/utils/cli-error.ts +102 -0
  36. package/cli/selftune/utils/hooks.ts +12 -2
  37. package/cli/selftune/workflows/workflows.ts +23 -17
  38. package/package.json +1 -1
  39. package/skill/SKILL.md +1 -1
  40. package/skill/Workflows/AutoActivation.md +1 -1
  41. package/skill/Workflows/Evolve.md +4 -0
  42. package/skill/Workflows/Initialize.md +8 -8
  43. package/skill/settings_snippet.json +35 -12
  44. package/apps/local-dashboard/dist/assets/index-DIrdlu2_.js +0 -16
  45. package/apps/local-dashboard/dist/assets/vendor-ui-7xD7fNEU.js +0 -12
@@ -55,6 +55,7 @@ import type {
55
55
  SessionTelemetryRecord,
56
56
  TranscriptMetrics,
57
57
  } from "../types.js";
58
+ import { CLIError, handleCLIError } from "../utils/cli-error.js";
58
59
  import { loadMarker, saveMarker } from "../utils/jsonl.js";
59
60
  import { isActionableQueryText } from "../utils/query-filter.js";
60
61
  import {
@@ -326,26 +327,36 @@ export function buildCanonicalRecordsFromReplay(session: ParsedSession): Canonic
326
327
 
327
328
  // --- CLI main ---
328
329
  export function cliMain(): void {
329
- const { values } = parseArgs({
330
- options: {
331
- "projects-dir": { type: "string", default: CLAUDE_CODE_PROJECTS_DIR },
332
- since: { type: "string" },
333
- "dry-run": { type: "boolean", default: false },
334
- force: { type: "boolean", default: false },
335
- verbose: { type: "boolean", short: "v", default: false },
336
- },
337
- strict: true,
338
- });
330
+ let values: Record<string, string | boolean | undefined>;
331
+ try {
332
+ ({ values } = parseArgs({
333
+ options: {
334
+ "projects-dir": { type: "string", default: CLAUDE_CODE_PROJECTS_DIR },
335
+ since: { type: "string" },
336
+ "dry-run": { type: "boolean", default: false },
337
+ force: { type: "boolean", default: false },
338
+ verbose: { type: "boolean", short: "v", default: false },
339
+ },
340
+ strict: true,
341
+ }));
342
+ } catch (err) {
343
+ throw new CLIError(
344
+ err instanceof Error ? err.message : String(err),
345
+ "INVALID_FLAG",
346
+ "selftune ingest claude --since 2026-01-01",
347
+ );
348
+ }
339
349
 
340
350
  const projectsDir = values["projects-dir"] ?? CLAUDE_CODE_PROJECTS_DIR;
341
351
  let since: Date | undefined;
342
352
  if (values.since) {
343
353
  since = new Date(values.since);
344
354
  if (Number.isNaN(since.getTime())) {
345
- console.error(
346
- `Error: Invalid --since date: "${values.since}". Use a valid date format (e.g., 2026-01-01).`,
355
+ throw new CLIError(
356
+ `Invalid --since date: "${values.since}"`,
357
+ "INVALID_FLAG",
358
+ "selftune ingest claude --since 2026-01-01",
347
359
  );
348
- process.exit(1);
349
360
  }
350
361
  }
351
362
 
@@ -401,5 +412,9 @@ export function cliMain(): void {
401
412
  }
402
413
 
403
414
  if (import.meta.main) {
404
- cliMain();
415
+ try {
416
+ cliMain();
417
+ } catch (err) {
418
+ handleCLIError(err);
419
+ }
405
420
  }
@@ -49,6 +49,7 @@ import type {
49
49
  SessionTelemetryRecord,
50
50
  SkillUsageRecord,
51
51
  } from "../types.js";
52
+ import { handleCLIError } from "../utils/cli-error.js";
52
53
  import { loadMarker, saveMarker } from "../utils/jsonl.js";
53
54
  import { extractActionableQueryText } from "../utils/query-filter.js";
54
55
  import {
@@ -717,5 +718,9 @@ export function cliMain(): void {
717
718
  }
718
719
 
719
720
  if (import.meta.main) {
720
- cliMain();
721
+ try {
722
+ cliMain();
723
+ } catch (err) {
724
+ handleCLIError(err);
725
+ }
721
726
  }
@@ -44,7 +44,8 @@ import {
44
44
  import { installAgentFiles } from "./claude-agents.js";
45
45
  import { CLAUDE_CODE_HOOK_KEYS, SELFTUNE_CONFIG_DIR, SELFTUNE_CONFIG_PATH } from "./constants.js";
46
46
  import type { AgentCommandGuidance, AlphaIdentity, SelftuneConfig } from "./types.js";
47
- import { hookKeyHasSelftuneEntry } from "./utils/hooks.js";
47
+ import { CLIError, handleCLIError } from "./utils/cli-error.js";
48
+ import { hookKeyHasSelftuneEntry, isSelftuneCommand } from "./utils/hooks.js";
48
49
  import { detectAgent } from "./utils/llm-call.js";
49
50
 
50
51
  export { installAgentFiles } from "./claude-agents.js";
@@ -228,10 +229,13 @@ const SETTINGS_SNIPPET_PATH = resolve(
228
229
  *
229
230
  * - Creates settings.json if it does not exist
230
231
  * - Creates the hooks section if it does not exist
231
- * - Only adds hook entries for keys that don't already have a selftune entry
232
- * - Never overwrites existing user hooks
232
+ * - Adds hook entries for keys that don't already have a selftune entry
233
+ * - Updates existing selftune entries with new attributes from the snippet
234
+ * (e.g. `if`, `statusMessage`, `async`, `timeout`) while preserving
235
+ * the resolved `command` path from the existing entry
236
+ * - Never overwrites existing non-selftune hooks
233
237
  *
234
- * Returns the list of hook keys that were added.
238
+ * Returns the list of hook keys that were added or updated.
235
239
  */
236
240
  export function installClaudeCodeHooks(options?: {
237
241
  settingsPath?: string;
@@ -278,43 +282,211 @@ export function installClaudeCodeHooks(options?: {
278
282
  }
279
283
  const existingHooks = settings.hooks as Record<string, unknown[]>;
280
284
 
281
- // Resolve the CLI hooks directory for path substitution
285
+ // Resolve the package root for path substitution
286
+ // cliPath points to cli/selftune/index.ts → package root is two levels up
282
287
  const cliPath = options?.cliPath;
283
- const hooksDir = cliPath ? `${dirname(cliPath)}/hooks` : null;
288
+ const packageRoot = cliPath ? resolve(dirname(cliPath), "..", "..").replace(/\\/g, "/") : null;
284
289
 
285
- const addedKeys: string[] = [];
290
+ const changedKeys: string[] = [];
286
291
 
287
292
  for (const key of Object.keys(snippetHooks)) {
288
- // Skip if this key already has a selftune entry
289
- if (hookKeyHasSelftuneEntry(existingHooks, key)) {
290
- continue;
291
- }
292
-
293
- // Get the snippet entries for this key, replacing /PATH/TO/ with actual path
293
+ // Get the snippet entries for this key, replacing /PATH/TO/ with actual package root
294
294
  let entries = snippetHooks[key];
295
- if (hooksDir) {
296
- // Deep clone and substitute paths
297
- const raw = JSON.stringify(entries).replace(/\/PATH\/TO\/cli\/selftune\/hooks/g, hooksDir);
295
+ if (packageRoot) {
296
+ // Deep clone and substitute all /PATH/TO/ references with the resolved package root
297
+ const raw = JSON.stringify(entries).replace(/\/PATH\/TO\//g, `${packageRoot}/`);
298
298
  entries = JSON.parse(raw);
299
299
  }
300
300
 
301
- // Merge: append to existing array or create new one
302
- if (Array.isArray(existingHooks[key])) {
303
- existingHooks[key] = [...existingHooks[key], ...entries];
301
+ if (hookKeyHasSelftuneEntry(existingHooks, key)) {
302
+ // Key already has selftune hooks — update them in-place with new attributes
303
+ // while preserving non-selftune entries and the resolved command paths
304
+ if (updateExistingSelftuneHooks(existingHooks, key, entries)) {
305
+ changedKeys.push(key);
306
+ }
304
307
  } else {
305
- existingHooks[key] = entries;
308
+ // No selftune entry yet — add the snippet entries
309
+ if (Array.isArray(existingHooks[key])) {
310
+ existingHooks[key] = [...existingHooks[key], ...entries];
311
+ } else {
312
+ existingHooks[key] = entries;
313
+ }
314
+ changedKeys.push(key);
306
315
  }
307
-
308
- addedKeys.push(key);
309
316
  }
310
317
 
311
- if (addedKeys.length > 0) {
318
+ if (changedKeys.length > 0) {
312
319
  // Ensure ~/.claude/ directory exists
313
320
  mkdirSync(dirname(settingsPath), { recursive: true });
314
321
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
315
322
  }
316
323
 
317
- return addedKeys;
324
+ return changedKeys;
325
+ }
326
+
327
+ /**
328
+ * Update existing selftune hook entries in-place with new attributes from the snippet.
329
+ *
330
+ * For each matcher group that contains selftune hooks, replaces ALL selftune
331
+ * hook entries with the full set of snippet entries while:
332
+ * - Resolving snippet commands using the package root from existing entries
333
+ * - Preserving non-selftune hooks in the same matcher group
334
+ * - Handling N→M changes (e.g. 2 hooks expanding to 4 with Write/Edit splits)
335
+ *
336
+ * Returns true if any entries were actually modified.
337
+ */
338
+ export function updateExistingSelftuneHooks(
339
+ existingHooks: Record<string, unknown[]>,
340
+ key: string,
341
+ snippetEntries: unknown[],
342
+ ): boolean {
343
+ const existingArray = existingHooks[key];
344
+ if (!Array.isArray(existingArray)) return false;
345
+
346
+ // Collect all snippet hooks (flattened from matcher groups)
347
+ const allSnippetHooks: Array<Record<string, unknown>> = [];
348
+ for (const group of snippetEntries) {
349
+ if (typeof group !== "object" || group === null) continue;
350
+ const g = group as Record<string, unknown>;
351
+ const hooks = g.hooks as Array<Record<string, unknown>> | undefined;
352
+ if (!Array.isArray(hooks)) continue;
353
+ allSnippetHooks.push(...hooks);
354
+ }
355
+
356
+ if (allSnippetHooks.length === 0) return false;
357
+
358
+ let modified = false;
359
+
360
+ for (let i = 0; i < existingArray.length; i++) {
361
+ const group = existingArray[i];
362
+ if (typeof group !== "object" || group === null) continue;
363
+ const g = group as Record<string, unknown>;
364
+ const hooks = g.hooks as Array<Record<string, unknown>> | undefined;
365
+
366
+ // Handle flat entries (direct { command: "..." } without nested hooks array).
367
+ // These are a legacy format from older selftune versions or manual installs.
368
+ if (!Array.isArray(hooks)) {
369
+ if (!isHookSelftune(g)) continue;
370
+ const pkgRoot = derivePackageRootFromCommand(typeof g.command === "string" ? g.command : "");
371
+
372
+ // Replace the flat entry with the full snippet group structure.
373
+ // If we can derive a package root, resolve /PATH/TO/ in the snippet.
374
+ // If not (e.g. "npx selftune hook ..."), use snippet entries as-is
375
+ // (they were already resolved by the caller if a cliPath was provided).
376
+ const resolvedEntries = snippetEntries.map((se) => {
377
+ if (!pkgRoot) return se;
378
+ const raw = JSON.stringify(se).replace(/\/PATH\/TO\//g, `${pkgRoot}/`);
379
+ return JSON.parse(raw);
380
+ });
381
+ existingArray.splice(i, 1, ...resolvedEntries);
382
+ modified = true;
383
+ continue;
384
+ }
385
+
386
+ // Derive package root from the first selftune hook in this group
387
+ let packageRoot: string | null = null;
388
+ for (const hook of hooks) {
389
+ if (isHookSelftune(hook)) {
390
+ packageRoot = derivePackageRootFromCommand(
391
+ typeof hook.command === "string" ? hook.command : "",
392
+ );
393
+ if (packageRoot) break;
394
+ }
395
+ }
396
+
397
+ // Check if this group has any selftune hooks at all
398
+ const hasSelftuneHooks = hooks.some(isHookSelftune);
399
+ if (!hasSelftuneHooks) continue;
400
+
401
+ // Build resolved snippet hooks using the derived package root.
402
+ // If no package root was derivable (e.g. "npx selftune hook ..."),
403
+ // use snippet hooks as-is (already resolved by caller if cliPath was provided).
404
+ const resolvedSnippetHooks = allSnippetHooks.map((snippetHook) => {
405
+ const cmd = typeof snippetHook.command === "string" ? snippetHook.command : "";
406
+ const resolvedCmd = packageRoot ? cmd.replace(/\/PATH\/TO\//g, `${packageRoot}/`) : cmd;
407
+ return { ...snippetHook, command: resolvedCmd };
408
+ });
409
+
410
+ // Check if anything actually changed (compare sorted JSON for order independence)
411
+ const oldSelftune = hooks.filter(isHookSelftune);
412
+ const oldSorted = JSON.stringify(sortKeys(oldSelftune));
413
+ const newSorted = JSON.stringify(sortKeys(resolvedSnippetHooks));
414
+ if (oldSorted !== newSorted) {
415
+ modified = true;
416
+ }
417
+
418
+ // Rebuild hooks preserving original ordering of non-selftune entries:
419
+ // replace the first selftune hook with all resolved snippet hooks,
420
+ // remove remaining old selftune hooks, keep non-selftune hooks in place
421
+ const updatedHooks: Array<Record<string, unknown>> = [];
422
+ let selftuneInserted = false;
423
+ for (const hook of hooks) {
424
+ if (isHookSelftune(hook)) {
425
+ if (!selftuneInserted) {
426
+ // Insert all resolved snippet hooks at the position of the first selftune hook
427
+ updatedHooks.push(...resolvedSnippetHooks);
428
+ selftuneInserted = true;
429
+ }
430
+ // Skip remaining old selftune hooks (replaced by snippet set above)
431
+ } else {
432
+ updatedHooks.push(hook);
433
+ }
434
+ }
435
+ g.hooks = updatedHooks;
436
+ }
437
+
438
+ return modified;
439
+ }
440
+
441
+ /** Check if a hook entry is a selftune-managed hook. Delegates to shared isSelftuneCommand. */
442
+ function isHookSelftune(hook: Record<string, unknown>): boolean {
443
+ return typeof hook.command === "string" && isSelftuneCommand(hook.command);
444
+ }
445
+
446
+ /** Sort object keys recursively for order-independent JSON comparison. */
447
+ function sortKeys(obj: unknown): unknown {
448
+ if (Array.isArray(obj)) return obj.map(sortKeys);
449
+ if (obj !== null && typeof obj === "object") {
450
+ const sorted: Record<string, unknown> = {};
451
+ for (const key of Object.keys(obj as Record<string, unknown>).sort()) {
452
+ sorted[key] = sortKeys((obj as Record<string, unknown>)[key]);
453
+ }
454
+ return sorted;
455
+ }
456
+ return obj;
457
+ }
458
+
459
+ /**
460
+ * Derive the selftune package root from an existing hook command.
461
+ * Supports both old format ("bun run .../cli/selftune/hooks/X.ts")
462
+ * and new format ("node .../bin/run-hook.cjs .../cli/selftune/hooks/X.ts").
463
+ *
464
+ * Handles paths with spaces (e.g. "/Users/Alice Smith/...") and
465
+ * optional surrounding quotes in the command string.
466
+ */
467
+ export function derivePackageRootFromCommand(command: string): string | null {
468
+ // Normalize: strip quotes, collapse backslashes (for Windows-style paths)
469
+ const normalized = command.replace(/["']/g, "").replace(/\\/g, "/");
470
+ // Split on the known directory marker and take the prefix.
471
+ // The command may contain the package root multiple times (e.g.
472
+ // "node /root/bin/run-hook.cjs /root/cli/selftune/hooks/script.ts")
473
+ // so we split on the LAST occurrence of the marker.
474
+ for (const marker of ["/cli/selftune/hooks/", "/bin/run-hook.cjs"]) {
475
+ const idx = normalized.lastIndexOf(marker);
476
+ if (idx === -1) continue;
477
+ // Everything before the marker is "<prefix> <package-root>" or just "<package-root>"
478
+ const beforeMarker = normalized.slice(0, idx);
479
+ // Find the start of the path: scan backwards from end for the path start.
480
+ // Paths start with / (Unix) or a drive letter like C:/ (Windows).
481
+ // The command prefix (e.g. "node " or "bun run ") precedes the path.
482
+ const pathMatch = beforeMarker.match(/.*\s(\/.*|[A-Za-z]:\/.*)/);
483
+ if (pathMatch) return pathMatch[1];
484
+ // No space prefix — the entire string is the path (e.g. no "node " prefix)
485
+ if (beforeMarker.startsWith("/") || /^[A-Za-z]:\//.test(beforeMarker)) {
486
+ return beforeMarker;
487
+ }
488
+ }
489
+ return null;
318
490
  }
319
491
 
320
492
  // ---------------------------------------------------------------------------
@@ -614,16 +786,16 @@ export async function runInit(opts: InitOptions): Promise<SelftuneConfig> {
614
786
  );
615
787
  }
616
788
 
617
- const addedHookKeys = installClaudeCodeHooks({
789
+ const changedHookKeys = installClaudeCodeHooks({
618
790
  settingsPath,
619
791
  cliPath,
620
792
  });
621
- if (addedHookKeys.length > 0) {
793
+ if (changedHookKeys.length > 0) {
622
794
  config.hooks_installed = true;
623
795
  // Re-write config with updated hooks_installed flag
624
796
  writeSelftuneConfig(configPath, config);
625
797
  console.error(
626
- `[INFO] Installed ${addedHookKeys.length} selftune hook(s) into ${settingsPath}: ${addedHookKeys.join(", ")}`,
798
+ `[INFO] Installed/updated ${changedHookKeys.length} selftune hook(s) in ${settingsPath}: ${changedHookKeys.join(", ")}`,
627
799
  );
628
800
  } else if (!config.hooks_installed) {
629
801
  // Re-check in case hooks were already present
@@ -692,8 +864,11 @@ export async function cliMain(): Promise<void> {
692
864
  try {
693
865
  validateAlphaMetadataFlags(values.alpha, values["alpha-email"], values["alpha-name"]);
694
866
  } catch (error) {
695
- console.error(error instanceof Error ? error.message : String(error));
696
- process.exit(1);
867
+ throw new CLIError(
868
+ error instanceof Error ? error.message : String(error),
869
+ "INVALID_FLAG",
870
+ "Pass --alpha along with --alpha-email and --alpha-name.",
871
+ );
697
872
  }
698
873
 
699
874
  // Check for existing config without force
@@ -890,10 +1065,11 @@ export async function cliMain(): Promise<void> {
890
1065
  });
891
1066
 
892
1067
  if (!scheduleResult.activated) {
893
- console.error(
894
- "Failed to activate the autonomous scheduler. Re-run with --schedule-format or use `selftune schedule --install --dry-run` to inspect the generated artifacts first.",
1068
+ throw new CLIError(
1069
+ "Failed to activate the autonomous scheduler.",
1070
+ "OPERATION_FAILED",
1071
+ "Re-run with --schedule-format or use `selftune schedule --install --dry-run` to inspect the generated artifacts first.",
895
1072
  );
896
- process.exit(1);
897
1073
  }
898
1074
 
899
1075
  console.log(
@@ -906,10 +1082,11 @@ export async function cliMain(): Promise<void> {
906
1082
  }),
907
1083
  );
908
1084
  } catch (err) {
909
- console.error(
1085
+ throw new CLIError(
910
1086
  `Failed to enable autonomy: ${err instanceof Error ? err.message : String(err)}`,
1087
+ "OPERATION_FAILED",
1088
+ "Re-run with --schedule-format or use `selftune schedule --install --dry-run` to inspect artifacts.",
911
1089
  );
912
- process.exit(1);
913
1090
  }
914
1091
  }
915
1092
  }
@@ -947,7 +1124,6 @@ if (isMain) {
947
1124
  console.error(JSON.stringify(err.payload));
948
1125
  process.exit(1);
949
1126
  }
950
- console.error(`[FATAL] ${err}`);
951
- process.exit(1);
1127
+ handleCLIError(err);
952
1128
  });
953
1129
  }
@@ -26,6 +26,7 @@ import type {
26
26
  SessionTelemetryRecord,
27
27
  SkillUsageRecord,
28
28
  } from "../types.js";
29
+ import { CLIError, handleCLIError } from "../utils/cli-error.js";
29
30
  import {
30
31
  filterActionableQueryRecords,
31
32
  filterActionableSkillUsageRecords,
@@ -354,34 +355,52 @@ Options:
354
355
  }
355
356
 
356
357
  if (!values.skill || !values["skill-path"]) {
357
- console.error("[ERROR] --skill and --skill-path are required");
358
- process.exit(1);
358
+ throw new CLIError(
359
+ "--skill and --skill-path are required.",
360
+ "MISSING_FLAG",
361
+ "Usage: selftune watch --skill <name> --skill-path <path>",
362
+ );
359
363
  }
360
364
  if ((values["sync-force"] ?? false) && !(values["sync-first"] ?? false)) {
361
- console.error("[ERROR] --sync-force requires --sync-first");
362
- process.exit(1);
365
+ throw new CLIError(
366
+ "--sync-force requires --sync-first.",
367
+ "INVALID_FLAG",
368
+ "Add --sync-first when using --sync-force.",
369
+ );
363
370
  }
364
371
 
365
372
  const rawWindow = values.window ?? "20";
366
373
  if (!/^\d+$/.test(rawWindow)) {
367
- console.error("[ERROR] --window must be a positive integer >= 1");
368
- process.exit(1);
374
+ throw new CLIError(
375
+ "--window must be a positive integer >= 1.",
376
+ "INVALID_FLAG",
377
+ "selftune watch --window 20",
378
+ );
369
379
  }
370
380
  const windowSessions = Number.parseInt(rawWindow, 10);
371
381
  if (windowSessions < 1) {
372
- console.error("[ERROR] --window must be a positive integer >= 1");
373
- process.exit(1);
382
+ throw new CLIError(
383
+ "--window must be a positive integer >= 1.",
384
+ "INVALID_FLAG",
385
+ "selftune watch --window 20",
386
+ );
374
387
  }
375
388
 
376
389
  const rawThreshold = values.threshold ?? "0.1";
377
390
  if (!/^\d+(\.\d+)?$/.test(rawThreshold)) {
378
- console.error("[ERROR] --threshold must be a finite number between 0 and 1");
379
- process.exit(1);
391
+ throw new CLIError(
392
+ "--threshold must be a finite number between 0 and 1.",
393
+ "INVALID_FLAG",
394
+ "selftune watch --threshold 0.1",
395
+ );
380
396
  }
381
397
  const regressionThreshold = Number.parseFloat(rawThreshold);
382
398
  if (regressionThreshold < 0 || regressionThreshold > 1) {
383
- console.error("[ERROR] --threshold must be a finite number between 0 and 1");
384
- process.exit(1);
399
+ throw new CLIError(
400
+ "--threshold must be a finite number between 0 and 1.",
401
+ "INVALID_FLAG",
402
+ "selftune watch --threshold 0.1",
403
+ );
385
404
  }
386
405
 
387
406
  const result = await watch({
@@ -399,8 +418,5 @@ Options:
399
418
  }
400
419
 
401
420
  if (import.meta.main) {
402
- cliMain().catch((err) => {
403
- console.error(`[FATAL] ${err}`);
404
- process.exit(1);
405
- });
421
+ cliMain().catch(handleCLIError);
406
422
  }
@@ -53,6 +53,7 @@ import type {
53
53
  SessionTelemetryRecord,
54
54
  SkillUsageRecord,
55
55
  } from "./types.js";
56
+ import { CLIError, handleCLIError } from "./utils/cli-error.js";
56
57
  import { detectAgent } from "./utils/llm-call.js";
57
58
  import { getSelftuneVersion, readConfiguredAgentType } from "./utils/selftune-meta.js";
58
59
  import {
@@ -1249,37 +1250,41 @@ Examples:
1249
1250
 
1250
1251
  const maxSkillsRaw = values["max-skills"] ?? "5";
1251
1252
  if (!/^\d+$/.test(maxSkillsRaw) || Number(maxSkillsRaw) < 1) {
1252
- console.error(
1253
- "[ERROR] --max-skills must be a positive integer. Retry with: selftune orchestrate --max-skills 5",
1253
+ throw new CLIError(
1254
+ "--max-skills must be a positive integer",
1255
+ "INVALID_FLAG",
1256
+ "selftune orchestrate --max-skills 5",
1254
1257
  );
1255
- process.exit(1);
1256
1258
  }
1257
1259
  const maxSkills = Number(maxSkillsRaw);
1258
1260
 
1259
1261
  const recentWindowRaw = values["recent-window"] ?? "48";
1260
1262
  if (!/^\d+$/.test(recentWindowRaw) || Number(recentWindowRaw) < 1) {
1261
- console.error(
1262
- "[ERROR] --recent-window must be a positive integer. Retry with: selftune orchestrate --recent-window 48",
1263
+ throw new CLIError(
1264
+ "--recent-window must be a positive integer",
1265
+ "INVALID_FLAG",
1266
+ "selftune orchestrate --recent-window 48",
1263
1267
  );
1264
- process.exit(1);
1265
1268
  }
1266
1269
  const recentWindow = Number(recentWindowRaw);
1267
1270
 
1268
1271
  const maxAutoGradeRaw = values["max-auto-grade"] ?? "5";
1269
1272
  if (!/^\d+$/.test(maxAutoGradeRaw)) {
1270
- console.error(
1271
- "[ERROR] --max-auto-grade must be a non-negative integer. Retry with: selftune orchestrate --max-auto-grade 5",
1273
+ throw new CLIError(
1274
+ "--max-auto-grade must be a non-negative integer",
1275
+ "INVALID_FLAG",
1276
+ "selftune orchestrate --max-auto-grade 5",
1272
1277
  );
1273
- process.exit(1);
1274
1278
  }
1275
1279
  const maxAutoGrade = Number(maxAutoGradeRaw);
1276
1280
 
1277
1281
  const loopIntervalRaw = values["loop-interval"] ?? "3600";
1278
1282
  if (!/^\d+$/.test(loopIntervalRaw) || (values.loop && Number(loopIntervalRaw) < 60)) {
1279
- console.error(
1280
- "[ERROR] --loop-interval must be an integer >= 60 (seconds). Retry with: selftune orchestrate --loop --loop-interval 3600",
1283
+ throw new CLIError(
1284
+ "--loop-interval must be an integer >= 60 (seconds)",
1285
+ "INVALID_FLAG",
1286
+ "selftune orchestrate --loop --loop-interval 3600",
1281
1287
  );
1282
- process.exit(1);
1283
1288
  }
1284
1289
  const loopInterval = Number(loopIntervalRaw);
1285
1290
 
@@ -1387,9 +1392,5 @@ Examples:
1387
1392
  }
1388
1393
 
1389
1394
  if (import.meta.main) {
1390
- cliMain().catch((err) => {
1391
- const message = err instanceof Error ? err.message : String(err);
1392
- console.error(`[FATAL] ${message}`);
1393
- process.exit(1);
1394
- });
1395
+ cliMain().catch(handleCLIError);
1395
1396
  }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { Database } from "bun:sqlite";
10
10
 
11
+ import { scoreDescription } from "../evolution/description-quality.js";
11
12
  import { getPendingProposals, getSkillReportPayload, safeParseJson } from "../localdb/queries.js";
12
13
 
13
14
  export function handleSkillReport(db: Database, skillName: string): Response {
@@ -203,6 +204,21 @@ export function handleSkillReport(db: Database, skillName: string): Response {
203
204
  completion_status: string | null;
204
205
  }>;
205
206
 
207
+ // 8. Description quality score — computed from latest evolution evidence
208
+ const latestEvidence = db
209
+ .query(
210
+ `SELECT proposed_text, original_text FROM evolution_evidence
211
+ WHERE skill_name = ? AND (proposed_text IS NOT NULL OR original_text IS NOT NULL)
212
+ ORDER BY timestamp DESC LIMIT 1`,
213
+ )
214
+ .get(skillName) as { proposed_text: string | null; original_text: string | null } | null;
215
+
216
+ // Use the most recent description: deployed proposed_text, or fallback to original_text
217
+ const currentDescriptionText = latestEvidence?.proposed_text ?? latestEvidence?.original_text;
218
+ const descriptionQuality = currentDescriptionText
219
+ ? scoreDescription(currentDescriptionText, skillName)
220
+ : null;
221
+
206
222
  return Response.json({
207
223
  ...report,
208
224
  evolution: evolutionWithSnapshot,
@@ -227,5 +243,6 @@ export function handleSkillReport(db: Database, skillName: string): Response {
227
243
  is_actionable: p.is_actionable === 1,
228
244
  })),
229
245
  session_metadata: sessionMeta,
246
+ description_quality: descriptionQuality,
230
247
  });
231
248
  }