selftune 0.2.16 → 0.2.18

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 (40) hide show
  1. package/README.md +24 -19
  2. package/cli/selftune/alpha-upload/build-payloads.ts +14 -1
  3. package/cli/selftune/alpha-upload/client.ts +51 -1
  4. package/cli/selftune/alpha-upload/flush.ts +46 -5
  5. package/cli/selftune/alpha-upload/stage-canonical.ts +25 -4
  6. package/cli/selftune/alpha-upload-contract.ts +9 -0
  7. package/cli/selftune/constants.ts +82 -5
  8. package/cli/selftune/contribute/sanitize.ts +52 -5
  9. package/cli/selftune/dashboard-contract.ts +100 -0
  10. package/cli/selftune/dashboard-server.ts +2 -2
  11. package/cli/selftune/evolution/description-quality.ts +12 -11
  12. package/cli/selftune/evolution/evolve.ts +214 -51
  13. package/cli/selftune/evolution/validate-proposal.ts +9 -6
  14. package/cli/selftune/grading/grade-session.ts +20 -0
  15. package/cli/selftune/hooks/commit-track.ts +188 -0
  16. package/cli/selftune/hooks/prompt-log.ts +10 -1
  17. package/cli/selftune/hooks/session-stop.ts +2 -2
  18. package/cli/selftune/hooks/skill-eval.ts +15 -1
  19. package/cli/selftune/hooks/stdin-preview.ts +32 -0
  20. package/cli/selftune/localdb/direct-write.ts +69 -6
  21. package/cli/selftune/localdb/queries.ts +552 -7
  22. package/cli/selftune/localdb/schema.ts +46 -0
  23. package/cli/selftune/orchestrate.ts +32 -4
  24. package/cli/selftune/routes/overview.ts +41 -3
  25. package/cli/selftune/routes/skill-report.ts +88 -17
  26. package/cli/selftune/types.ts +31 -0
  27. package/cli/selftune/utils/transcript.ts +210 -1
  28. package/node_modules/@selftune/telemetry-contract/src/types.ts +11 -0
  29. package/package.json +1 -1
  30. package/packages/telemetry-contract/src/types.ts +11 -0
  31. package/skill/SKILL.md +29 -1
  32. package/skill/Workflows/Evolve.md +31 -13
  33. package/skill/Workflows/ExportCanonical.md +121 -0
  34. package/skill/Workflows/Hook.md +131 -0
  35. package/skill/Workflows/Initialize.md +9 -8
  36. package/skill/Workflows/Orchestrate.md +27 -5
  37. package/skill/Workflows/Quickstart.md +94 -0
  38. package/skill/Workflows/RepairSkillUsage.md +87 -0
  39. package/skill/Workflows/Uninstall.md +82 -0
  40. package/skill/settings_snippet.json +11 -0
@@ -8,12 +8,21 @@
8
8
  import type { Database } from "bun:sqlite";
9
9
 
10
10
  import type {
11
+ CommitRecord,
12
+ CommitSummary,
13
+ ExecutionMetrics,
11
14
  OrchestrateRunReport,
15
+ OverviewPaginatedPayload,
12
16
  OverviewPayload,
17
+ PaginatedResult,
18
+ PaginationCursor,
13
19
  PendingProposal,
14
20
  RecentActivityItem,
21
+ SkillReportPaginatedPayload,
15
22
  SkillReportPayload,
16
23
  SkillSummary,
24
+ SkillUsageRecord,
25
+ TelemetryRecord,
17
26
  } from "../dashboard-contract.js";
18
27
 
19
28
  /**
@@ -243,6 +252,386 @@ export function getSkillReportPayload(db: Database, skillName: string): SkillRep
243
252
  };
244
253
  }
245
254
 
255
+ // -- Cursor-based paginated queries -------------------------------------------
256
+
257
+ export interface OverviewPaginationOptions {
258
+ telemetry_cursor?: PaginationCursor | null;
259
+ telemetry_limit?: number;
260
+ skills_cursor?: PaginationCursor | null;
261
+ skills_limit?: number;
262
+ }
263
+
264
+ export interface SkillReportPaginationOptions {
265
+ invocations_cursor?: PaginationCursor | null;
266
+ invocations_limit?: number;
267
+ }
268
+
269
+ /**
270
+ * Build a paginated overview payload from SQLite.
271
+ *
272
+ * Uses (timestamp, session_id) composite cursors for stable backward pagination.
273
+ * When no cursor is provided, returns the first page starting from most recent.
274
+ */
275
+ export function getOverviewPayloadPaginated(
276
+ db: Database,
277
+ opts: OverviewPaginationOptions = {},
278
+ ): OverviewPaginatedPayload {
279
+ const telemetryLimit = opts.telemetry_limit ?? 1000;
280
+ const skillsLimit = opts.skills_limit ?? 2000;
281
+
282
+ // Paginated telemetry
283
+ const telemetry_page = paginateTelemetry(db, telemetryLimit, opts.telemetry_cursor ?? null);
284
+
285
+ // Paginated skill invocations
286
+ const skills_page = paginateSkillInvocations(db, skillsLimit, opts.skills_cursor ?? null);
287
+
288
+ // Non-paginated parts reuse existing logic
289
+ const evolution = db
290
+ .query(
291
+ `SELECT timestamp, proposal_id, skill_name, action, details
292
+ FROM evolution_audit
293
+ ORDER BY timestamp DESC
294
+ LIMIT 500`,
295
+ )
296
+ .all() as Array<{
297
+ timestamp: string;
298
+ proposal_id: string;
299
+ skill_name: string | null;
300
+ action: string;
301
+ details: string;
302
+ }>;
303
+
304
+ const counts = db
305
+ .query(
306
+ `SELECT
307
+ (SELECT COUNT(*) FROM session_telemetry) as telemetry,
308
+ (SELECT COUNT(*) FROM skill_invocations) as skills,
309
+ (SELECT COUNT(*) FROM evolution_audit) as evolution,
310
+ (SELECT COUNT(*) FROM evolution_evidence) as evidence,
311
+ (SELECT COUNT(*) FROM sessions) as sessions,
312
+ (SELECT COUNT(*) FROM prompts) as prompts`,
313
+ )
314
+ .get() as {
315
+ telemetry: number;
316
+ skills: number;
317
+ evolution: number;
318
+ evidence: number;
319
+ sessions: number;
320
+ prompts: number;
321
+ };
322
+
323
+ const unmatchedRows = db
324
+ .query(
325
+ `SELECT si.occurred_at AS timestamp, si.session_id, si.query
326
+ FROM skill_invocations si
327
+ WHERE si.triggered = 0
328
+ AND NOT EXISTS (
329
+ SELECT 1 FROM skill_invocations si2
330
+ WHERE si2.query = si.query AND si2.triggered = 1
331
+ )
332
+ ORDER BY si.occurred_at DESC
333
+ LIMIT 500`,
334
+ )
335
+ .all() as Array<{ timestamp: string; session_id: string; query: string }>;
336
+
337
+ const pending_proposals = getPendingProposals(db);
338
+ const active_sessions = getActiveSessionCount(db);
339
+ const recent_activity = getRecentActivity(db);
340
+
341
+ return {
342
+ telemetry_page,
343
+ skills_page,
344
+ evolution,
345
+ counts,
346
+ unmatched_queries: unmatchedRows,
347
+ pending_proposals,
348
+ active_sessions,
349
+ recent_activity,
350
+ };
351
+ }
352
+
353
+ /**
354
+ * Build a paginated skill report payload for a specific skill.
355
+ *
356
+ * Uses (occurred_at, skill_invocation_id) composite cursor for the recent
357
+ * invocations sub-query. Non-paginated fields (usage stats, evidence, sessions)
358
+ * are returned in full.
359
+ */
360
+ export function getSkillReportPayloadPaginated(
361
+ db: Database,
362
+ skillName: string,
363
+ opts: SkillReportPaginationOptions = {},
364
+ ): SkillReportPaginatedPayload {
365
+ const invocationsLimit = opts.invocations_limit ?? 100;
366
+
367
+ // Usage stats (unchanged)
368
+ const usageRow = db
369
+ .query(
370
+ `SELECT
371
+ COUNT(*) as total_checks,
372
+ SUM(CASE WHEN triggered = 1 THEN 1 ELSE 0 END) as triggered_count
373
+ FROM skill_invocations
374
+ WHERE skill_name = ?`,
375
+ )
376
+ .get(skillName) as { total_checks: number; triggered_count: number };
377
+
378
+ const total = usageRow.total_checks;
379
+ const triggered = usageRow.triggered_count;
380
+ const passRate = total > 0 ? triggered / total : 0;
381
+
382
+ // Paginated invocations
383
+ const invocations_page = paginateSkillReportInvocations(
384
+ db,
385
+ skillName,
386
+ invocationsLimit,
387
+ opts.invocations_cursor ?? null,
388
+ );
389
+
390
+ // Evidence (unchanged)
391
+ const evidenceRows = db
392
+ .query(
393
+ `SELECT proposal_id, target, stage, timestamp, rationale, confidence,
394
+ original_text, proposed_text, validation_json, details, eval_set_json
395
+ FROM evolution_evidence
396
+ WHERE skill_name = ?
397
+ ORDER BY timestamp DESC
398
+ LIMIT 200`,
399
+ )
400
+ .all(skillName) as Array<{
401
+ proposal_id: string;
402
+ target: string;
403
+ stage: string;
404
+ timestamp: string;
405
+ rationale: string | null;
406
+ confidence: number | null;
407
+ original_text: string | null;
408
+ proposed_text: string | null;
409
+ validation_json: string | null;
410
+ details: string | null;
411
+ eval_set_json: string | null;
412
+ }>;
413
+
414
+ const evidence = evidenceRows.map((row) => ({
415
+ proposal_id: row.proposal_id,
416
+ target: row.target,
417
+ stage: row.stage,
418
+ timestamp: row.timestamp,
419
+ rationale: row.rationale,
420
+ confidence: row.confidence,
421
+ original_text: row.original_text,
422
+ proposed_text: row.proposed_text,
423
+ validation: safeParseJson(row.validation_json),
424
+ details: row.details,
425
+ eval_set: safeParseJsonArray<Record<string, unknown>>(row.eval_set_json),
426
+ }));
427
+
428
+ const sessionsRow = db
429
+ .query(`SELECT COUNT(DISTINCT session_id) as c FROM skill_invocations WHERE skill_name = ?`)
430
+ .get(skillName) as { c: number };
431
+
432
+ return {
433
+ skill_name: skillName,
434
+ usage: {
435
+ total_checks: total,
436
+ triggered_count: triggered,
437
+ pass_rate: passRate,
438
+ },
439
+ invocations_page,
440
+ evidence,
441
+ sessions_with_skill: sessionsRow.c,
442
+ };
443
+ }
444
+
445
+ // -- Internal pagination helpers ------------------------------------------------
446
+
447
+ function paginateTelemetry(
448
+ db: Database,
449
+ limit: number,
450
+ cursor: PaginationCursor | null,
451
+ ): PaginatedResult<TelemetryRecord> {
452
+ // Fetch one extra to detect has_more
453
+ const fetchLimit = limit + 1;
454
+
455
+ let rows: Array<{
456
+ timestamp: string;
457
+ session_id: string;
458
+ skills_triggered_json: string | null;
459
+ errors_encountered: number;
460
+ total_tool_calls: number;
461
+ }>;
462
+
463
+ if (cursor) {
464
+ rows = db
465
+ .query(
466
+ `SELECT timestamp, session_id, skills_triggered_json, errors_encountered, total_tool_calls
467
+ FROM session_telemetry
468
+ WHERE (timestamp < ? OR (timestamp = ? AND session_id < ?))
469
+ ORDER BY timestamp DESC, session_id DESC
470
+ LIMIT ?`,
471
+ )
472
+ .all(cursor.timestamp, cursor.timestamp, String(cursor.id), fetchLimit) as typeof rows;
473
+ } else {
474
+ rows = db
475
+ .query(
476
+ `SELECT timestamp, session_id, skills_triggered_json, errors_encountered, total_tool_calls
477
+ FROM session_telemetry
478
+ ORDER BY timestamp DESC, session_id DESC
479
+ LIMIT ?`,
480
+ )
481
+ .all(fetchLimit) as typeof rows;
482
+ }
483
+
484
+ const hasMore = rows.length > limit;
485
+ const pageRows = hasMore ? rows.slice(0, limit) : rows;
486
+
487
+ const items: TelemetryRecord[] = pageRows.map((row) => ({
488
+ timestamp: row.timestamp,
489
+ session_id: row.session_id,
490
+ skills_triggered: safeParseJsonArray<string>(row.skills_triggered_json),
491
+ errors_encountered: row.errors_encountered,
492
+ total_tool_calls: row.total_tool_calls,
493
+ }));
494
+
495
+ const lastItem = pageRows[pageRows.length - 1];
496
+ const next_cursor: PaginationCursor | null =
497
+ hasMore && lastItem ? { timestamp: lastItem.timestamp, id: lastItem.session_id } : null;
498
+
499
+ return { items, next_cursor, has_more: hasMore };
500
+ }
501
+
502
+ function paginateSkillInvocations(
503
+ db: Database,
504
+ limit: number,
505
+ cursor: PaginationCursor | null,
506
+ ): PaginatedResult<SkillUsageRecord> {
507
+ const fetchLimit = limit + 1;
508
+
509
+ let rows: Array<{
510
+ occurred_at: string;
511
+ session_id: string;
512
+ skill_name: string;
513
+ skill_path: string;
514
+ query: string;
515
+ triggered: number;
516
+ source: string | null;
517
+ skill_invocation_id: string;
518
+ }>;
519
+
520
+ if (cursor) {
521
+ rows = db
522
+ .query(
523
+ `SELECT occurred_at, session_id, skill_name, skill_path, query, triggered, source, skill_invocation_id
524
+ FROM skill_invocations
525
+ WHERE (occurred_at < ? OR (occurred_at = ? AND skill_invocation_id < ?))
526
+ ORDER BY occurred_at DESC, skill_invocation_id DESC
527
+ LIMIT ?`,
528
+ )
529
+ .all(cursor.timestamp, cursor.timestamp, String(cursor.id), fetchLimit) as typeof rows;
530
+ } else {
531
+ rows = db
532
+ .query(
533
+ `SELECT occurred_at, session_id, skill_name, skill_path, query, triggered, source, skill_invocation_id
534
+ FROM skill_invocations
535
+ ORDER BY occurred_at DESC, skill_invocation_id DESC
536
+ LIMIT ?`,
537
+ )
538
+ .all(fetchLimit) as typeof rows;
539
+ }
540
+
541
+ const hasMore = rows.length > limit;
542
+ const pageRows = hasMore ? rows.slice(0, limit) : rows;
543
+
544
+ const items: SkillUsageRecord[] = pageRows.map((row) => ({
545
+ timestamp: row.occurred_at,
546
+ session_id: row.session_id,
547
+ skill_name: row.skill_name,
548
+ skill_path: row.skill_path,
549
+ query: row.query,
550
+ triggered: row.triggered === 1,
551
+ source: row.source,
552
+ }));
553
+
554
+ const lastRow = pageRows[pageRows.length - 1];
555
+ const next_cursor: PaginationCursor | null =
556
+ hasMore && lastRow ? { timestamp: lastRow.occurred_at, id: lastRow.skill_invocation_id } : null;
557
+
558
+ return { items, next_cursor, has_more: hasMore };
559
+ }
560
+
561
+ function paginateSkillReportInvocations(
562
+ db: Database,
563
+ skillName: string,
564
+ limit: number,
565
+ cursor: PaginationCursor | null,
566
+ ): PaginatedResult<{
567
+ timestamp: string;
568
+ session_id: string;
569
+ query: string;
570
+ triggered: boolean;
571
+ source: string | null;
572
+ }> {
573
+ const fetchLimit = limit + 1;
574
+
575
+ let rows: Array<{
576
+ occurred_at: string;
577
+ session_id: string;
578
+ query: string;
579
+ triggered: number;
580
+ source: string | null;
581
+ skill_invocation_id: string;
582
+ }>;
583
+
584
+ if (cursor) {
585
+ rows = db
586
+ .query(
587
+ `SELECT si.occurred_at, si.session_id, COALESCE(si.query, p.prompt_text) as query,
588
+ si.triggered, si.source, si.skill_invocation_id
589
+ FROM skill_invocations si
590
+ LEFT JOIN prompts p ON si.matched_prompt_id = p.prompt_id
591
+ WHERE si.skill_name = ?
592
+ AND (si.occurred_at < ? OR (si.occurred_at = ? AND si.skill_invocation_id < ?))
593
+ ORDER BY si.occurred_at DESC, si.skill_invocation_id DESC
594
+ LIMIT ?`,
595
+ )
596
+ .all(
597
+ skillName,
598
+ cursor.timestamp,
599
+ cursor.timestamp,
600
+ String(cursor.id),
601
+ fetchLimit,
602
+ ) as typeof rows;
603
+ } else {
604
+ rows = db
605
+ .query(
606
+ `SELECT si.occurred_at, si.session_id, COALESCE(si.query, p.prompt_text) as query,
607
+ si.triggered, si.source, si.skill_invocation_id
608
+ FROM skill_invocations si
609
+ LEFT JOIN prompts p ON si.matched_prompt_id = p.prompt_id
610
+ WHERE si.skill_name = ?
611
+ ORDER BY si.occurred_at DESC, si.skill_invocation_id DESC
612
+ LIMIT ?`,
613
+ )
614
+ .all(skillName, fetchLimit) as typeof rows;
615
+ }
616
+
617
+ const hasMore = rows.length > limit;
618
+ const pageRows = hasMore ? rows.slice(0, limit) : rows;
619
+
620
+ const items = pageRows.map((row) => ({
621
+ timestamp: row.occurred_at,
622
+ session_id: row.session_id,
623
+ query: row.query ?? "",
624
+ triggered: row.triggered === 1,
625
+ source: row.source,
626
+ }));
627
+
628
+ const lastRow = pageRows[pageRows.length - 1];
629
+ const next_cursor: PaginationCursor | null =
630
+ hasMore && lastRow ? { timestamp: lastRow.occurred_at, id: lastRow.skill_invocation_id } : null;
631
+
632
+ return { items, next_cursor, has_more: hasMore };
633
+ }
634
+
246
635
  /**
247
636
  * Get a summary list of all skills with aggregated stats.
248
637
  */
@@ -390,13 +779,12 @@ export function getActiveSessionCount(db: Database): number {
390
779
  * session is still in progress (no session_telemetry row yet).
391
780
  */
392
781
  export function getRecentActivity(db: Database, limit = 20): RecentActivityItem[] {
782
+ // Step 1: Fetch recent invocations without JOIN (avoids materializing full JOIN before LIMIT)
393
783
  const rows = db
394
784
  .query(
395
- `SELECT si.occurred_at, si.session_id, si.skill_name, si.query, si.triggered,
396
- CASE WHEN st.session_id IS NULL THEN 1 ELSE 0 END as is_live
397
- FROM skill_invocations si
398
- LEFT JOIN session_telemetry st ON si.session_id = st.session_id
399
- ORDER BY si.occurred_at DESC
785
+ `SELECT occurred_at, session_id, skill_name, query, triggered
786
+ FROM skill_invocations
787
+ ORDER BY occurred_at DESC
400
788
  LIMIT ?`,
401
789
  )
402
790
  .all(limit) as Array<{
@@ -405,16 +793,27 @@ export function getRecentActivity(db: Database, limit = 20): RecentActivityItem[
405
793
  skill_name: string;
406
794
  query: string;
407
795
  triggered: number;
408
- is_live: number;
409
796
  }>;
410
797
 
798
+ if (rows.length === 0) return [];
799
+
800
+ // Step 2: Batch lookup which sessions have completed (have a telemetry row)
801
+ const uniqueSessionIds = [...new Set(rows.map((r) => r.session_id))];
802
+ const placeholders = uniqueSessionIds.map(() => "?").join(",");
803
+ const completedRows = db
804
+ .query(
805
+ `SELECT DISTINCT session_id FROM session_telemetry WHERE session_id IN (${placeholders})`,
806
+ )
807
+ .all(...uniqueSessionIds) as Array<{ session_id: string }>;
808
+ const completedSessions = new Set(completedRows.map((r) => r.session_id));
809
+
411
810
  return rows.map((row) => ({
412
811
  timestamp: row.occurred_at,
413
812
  session_id: row.session_id,
414
813
  skill_name: row.skill_name,
415
814
  query: row.query ?? "",
416
815
  triggered: row.triggered === 1,
417
- is_live: row.is_live === 1,
816
+ is_live: !completedSessions.has(row.session_id),
418
817
  }));
419
818
  }
420
819
 
@@ -914,6 +1313,152 @@ export function getOldestPendingAge(db: Database): number | null {
914
1313
  }
915
1314
  }
916
1315
 
1316
+ // -- Execution metrics & commit tracking queries ------------------------------
1317
+
1318
+ /**
1319
+ * Aggregate execution_facts enrichment columns for a set of session IDs.
1320
+ *
1321
+ * Returns file change stats, cost totals, token breakdowns, artifact counts,
1322
+ * and session_type distribution across the provided sessions.
1323
+ */
1324
+ export function getExecutionMetrics(db: Database, sessionIds: string[]): ExecutionMetrics {
1325
+ const empty: ExecutionMetrics = {
1326
+ avg_files_changed: 0,
1327
+ total_lines_added: 0,
1328
+ total_lines_removed: 0,
1329
+ total_cost_usd: 0,
1330
+ avg_cost_usd: 0,
1331
+ cached_input_tokens_total: 0,
1332
+ reasoning_output_tokens_total: 0,
1333
+ artifact_count: 0,
1334
+ session_type_distribution: {},
1335
+ };
1336
+ if (sessionIds.length === 0) return empty;
1337
+
1338
+ const placeholders = sessionIds.map(() => "?").join(",");
1339
+ const row = db
1340
+ .query(
1341
+ `SELECT
1342
+ COALESCE(AVG(files_changed), 0) AS avg_files_changed,
1343
+ COALESCE(SUM(lines_added), 0) AS total_lines_added,
1344
+ COALESCE(SUM(lines_removed), 0) AS total_lines_removed,
1345
+ COALESCE(SUM(cost_usd), 0) AS total_cost_usd,
1346
+ COALESCE(AVG(cost_usd), 0) AS avg_cost_usd,
1347
+ COALESCE(SUM(cached_input_tokens), 0) AS cached_input_tokens_total,
1348
+ COALESCE(SUM(reasoning_output_tokens), 0) AS reasoning_output_tokens_total,
1349
+ COALESCE(SUM(artifact_count), 0) AS artifact_count
1350
+ FROM execution_facts
1351
+ WHERE session_id IN (${placeholders})`,
1352
+ )
1353
+ .get(...sessionIds) as {
1354
+ avg_files_changed: number;
1355
+ total_lines_added: number;
1356
+ total_lines_removed: number;
1357
+ total_cost_usd: number;
1358
+ avg_cost_usd: number;
1359
+ cached_input_tokens_total: number;
1360
+ reasoning_output_tokens_total: number;
1361
+ artifact_count: number;
1362
+ } | null;
1363
+
1364
+ // Session type distribution
1365
+ const typeRows = db
1366
+ .query(
1367
+ `SELECT session_type, COUNT(*) AS count
1368
+ FROM execution_facts
1369
+ WHERE session_id IN (${placeholders}) AND session_type IS NOT NULL
1370
+ GROUP BY session_type`,
1371
+ )
1372
+ .all(...sessionIds) as Array<{ session_type: string; count: number }>;
1373
+
1374
+ const session_type_distribution: Record<string, number> = {};
1375
+ for (const tr of typeRows) {
1376
+ session_type_distribution[tr.session_type] = tr.count;
1377
+ }
1378
+
1379
+ return {
1380
+ avg_files_changed: row?.avg_files_changed ?? 0,
1381
+ total_lines_added: row?.total_lines_added ?? 0,
1382
+ total_lines_removed: row?.total_lines_removed ?? 0,
1383
+ total_cost_usd: row?.total_cost_usd ?? 0,
1384
+ avg_cost_usd: row?.avg_cost_usd ?? 0,
1385
+ cached_input_tokens_total: row?.cached_input_tokens_total ?? 0,
1386
+ reasoning_output_tokens_total: row?.reasoning_output_tokens_total ?? 0,
1387
+ artifact_count: row?.artifact_count ?? 0,
1388
+ session_type_distribution,
1389
+ };
1390
+ }
1391
+
1392
+ /**
1393
+ * Get all commits tracked for a given session.
1394
+ */
1395
+ export function getSessionCommits(db: Database, sessionId: string): CommitRecord[] {
1396
+ return db
1397
+ .query(
1398
+ `SELECT commit_sha, commit_title, branch, repo_remote, timestamp
1399
+ FROM commit_tracking
1400
+ WHERE session_id = ?
1401
+ ORDER BY timestamp DESC`,
1402
+ )
1403
+ .all(sessionId) as CommitRecord[];
1404
+ }
1405
+
1406
+ /**
1407
+ * Aggregate commit stats for a skill by joining commit_tracking to skill_invocations
1408
+ * via shared session_id.
1409
+ */
1410
+ export function getSkillCommitSummary(db: Database, skillName: string): CommitSummary {
1411
+ const empty: CommitSummary = {
1412
+ total_commits: 0,
1413
+ unique_branches: 0,
1414
+ recent_commits: [],
1415
+ };
1416
+
1417
+ const statsRow = db
1418
+ .query(
1419
+ `WITH skill_sessions AS (
1420
+ SELECT DISTINCT session_id FROM skill_invocations WHERE skill_name = ?
1421
+ )
1422
+ SELECT
1423
+ COUNT(*) AS total_commits,
1424
+ COUNT(DISTINCT ct.branch) AS unique_branches
1425
+ FROM commit_tracking ct
1426
+ WHERE ct.session_id IN (SELECT session_id FROM skill_sessions)`,
1427
+ )
1428
+ .get(skillName) as { total_commits: number; unique_branches: number } | null;
1429
+
1430
+ if (!statsRow || statsRow.total_commits === 0) return empty;
1431
+
1432
+ const recentRows = db
1433
+ .query(
1434
+ `WITH skill_sessions AS (
1435
+ SELECT DISTINCT session_id FROM skill_invocations WHERE skill_name = ?
1436
+ )
1437
+ SELECT ct.commit_sha, ct.commit_title, ct.branch, ct.timestamp
1438
+ FROM commit_tracking ct
1439
+ WHERE ct.session_id IN (SELECT session_id FROM skill_sessions)
1440
+ ORDER BY ct.timestamp DESC
1441
+ LIMIT 20`,
1442
+ )
1443
+ .all(skillName) as Array<{
1444
+ commit_sha: string;
1445
+ commit_title: string | null;
1446
+ branch: string | null;
1447
+ timestamp: string;
1448
+ }>;
1449
+
1450
+ return {
1451
+ total_commits: statsRow.total_commits,
1452
+ unique_branches: statsRow.unique_branches,
1453
+ recent_commits: recentRows.map((r) => ({
1454
+ sha: r.commit_sha,
1455
+ title: r.commit_title ?? "",
1456
+ branch: r.branch ?? "",
1457
+ timestamp: r.timestamp,
1458
+ })),
1459
+ };
1460
+ }
1461
+
917
1462
  // -- Helpers ------------------------------------------------------------------
918
1463
 
919
1464
  export function safeParseJsonArray<T = string>(json: string | null): T[] {
@@ -260,6 +260,20 @@ CREATE TABLE IF NOT EXISTS upload_watermarks (
260
260
  updated_at TEXT NOT NULL
261
261
  )`;
262
262
 
263
+ // -- Commit tracking table ----------------------------------------------------
264
+
265
+ export const CREATE_COMMIT_TRACKING = `
266
+ CREATE TABLE IF NOT EXISTS commit_tracking (
267
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
268
+ session_id TEXT NOT NULL,
269
+ commit_sha TEXT NOT NULL,
270
+ commit_title TEXT,
271
+ branch TEXT,
272
+ repo_remote TEXT,
273
+ timestamp TEXT NOT NULL,
274
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
275
+ )`;
276
+
263
277
  // -- Metadata table -----------------------------------------------------------
264
278
 
265
279
  export const CREATE_META = `
@@ -317,6 +331,11 @@ export const CREATE_INDEXES = [
317
331
  `CREATE INDEX IF NOT EXISTS idx_staging_kind ON canonical_upload_staging(record_kind)`,
318
332
  `CREATE INDEX IF NOT EXISTS idx_staging_session ON canonical_upload_staging(session_id)`,
319
333
  `CREATE UNIQUE INDEX IF NOT EXISTS idx_staging_dedup ON canonical_upload_staging(record_kind, record_id)`,
334
+ // -- Commit tracking indexes ------------------------------------------------
335
+ `CREATE INDEX IF NOT EXISTS idx_commit_sha ON commit_tracking(commit_sha)`,
336
+ `CREATE INDEX IF NOT EXISTS idx_commit_session ON commit_tracking(session_id)`,
337
+ `CREATE INDEX IF NOT EXISTS idx_commit_ts ON commit_tracking(timestamp)`,
338
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_commit_dedup ON commit_tracking(session_id, commit_sha)`,
320
339
  ];
321
340
 
322
341
  /**
@@ -353,6 +372,31 @@ export const MIGRATIONS = [
353
372
  `ALTER TABLE execution_facts ADD COLUMN normalizer_version TEXT`,
354
373
  `ALTER TABLE execution_facts ADD COLUMN capture_mode TEXT`,
355
374
  `ALTER TABLE execution_facts ADD COLUMN raw_source_ref TEXT`,
375
+ // -- Win 2+3: File change metrics + token granularity + cost (execution_facts) --
376
+ `ALTER TABLE execution_facts ADD COLUMN files_changed INTEGER`,
377
+ `ALTER TABLE execution_facts ADD COLUMN lines_added INTEGER`,
378
+ `ALTER TABLE execution_facts ADD COLUMN lines_removed INTEGER`,
379
+ `ALTER TABLE execution_facts ADD COLUMN lines_modified INTEGER`,
380
+ `ALTER TABLE execution_facts ADD COLUMN cached_input_tokens INTEGER`,
381
+ `ALTER TABLE execution_facts ADD COLUMN reasoning_output_tokens INTEGER`,
382
+ `ALTER TABLE execution_facts ADD COLUMN cost_usd REAL`,
383
+ // -- Win 2+3: File change metrics + token granularity + cost (session_telemetry) --
384
+ `ALTER TABLE session_telemetry ADD COLUMN files_changed INTEGER`,
385
+ `ALTER TABLE session_telemetry ADD COLUMN lines_added INTEGER`,
386
+ `ALTER TABLE session_telemetry ADD COLUMN lines_removed INTEGER`,
387
+ `ALTER TABLE session_telemetry ADD COLUMN lines_modified INTEGER`,
388
+ `ALTER TABLE session_telemetry ADD COLUMN cached_input_tokens INTEGER`,
389
+ `ALTER TABLE session_telemetry ADD COLUMN reasoning_output_tokens INTEGER`,
390
+ `ALTER TABLE session_telemetry ADD COLUMN cost_usd REAL`,
391
+ // -- Generalized metrics: artifact count + session type --
392
+ `ALTER TABLE execution_facts ADD COLUMN artifact_count INTEGER`,
393
+ `ALTER TABLE execution_facts ADD COLUMN session_type TEXT`,
394
+ `ALTER TABLE session_telemetry ADD COLUMN artifact_count INTEGER`,
395
+ `ALTER TABLE session_telemetry ADD COLUMN session_type TEXT`,
396
+ // -- Session summary (heuristic, no LLM) --
397
+ `ALTER TABLE session_telemetry ADD COLUMN agent_summary TEXT`,
398
+ // -- SHA256 content hashing for upload dedup --
399
+ `ALTER TABLE canonical_upload_staging ADD COLUMN content_sha256 TEXT`,
356
400
  ];
357
401
 
358
402
  /** Indexes that depend on migration columns — must run AFTER MIGRATIONS. */
@@ -360,6 +404,7 @@ export const POST_MIGRATION_INDEXES = [
360
404
  `CREATE INDEX IF NOT EXISTS idx_skill_inv_query_triggered ON skill_invocations(query, triggered)`,
361
405
  `CREATE INDEX IF NOT EXISTS idx_skill_inv_scope ON skill_invocations(skill_name, skill_scope, occurred_at)`,
362
406
  `CREATE INDEX IF NOT EXISTS idx_skill_inv_dedup ON skill_invocations(session_id, skill_name, query, occurred_at, triggered)`,
407
+ `CREATE INDEX IF NOT EXISTS idx_staging_sha256 ON canonical_upload_staging(content_sha256)`,
363
408
  ];
364
409
 
365
410
  /** All DDL statements in creation order. */
@@ -379,6 +424,7 @@ export const ALL_DDL = [
379
424
  CREATE_UPLOAD_QUEUE,
380
425
  CREATE_UPLOAD_WATERMARKS,
381
426
  CREATE_CANONICAL_UPLOAD_STAGING,
427
+ CREATE_COMMIT_TRACKING,
382
428
  CREATE_META,
383
429
  ...CREATE_INDEXES,
384
430
  ];