selftune 0.2.21 → 0.2.23

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 (108) hide show
  1. package/README.md +15 -8
  2. package/apps/local-dashboard/dist/assets/index-CwOtTrUS.css +1 -0
  3. package/apps/local-dashboard/dist/assets/index-f1HQpbeH.js +59 -0
  4. package/apps/local-dashboard/dist/assets/vendor-ui-jVSaIZey.js +12 -0
  5. package/apps/local-dashboard/dist/index.html +3 -3
  6. package/cli/selftune/adapters/cline/hook.ts +167 -0
  7. package/cli/selftune/adapters/cline/install.ts +197 -0
  8. package/cli/selftune/adapters/codex/hook.ts +296 -0
  9. package/cli/selftune/adapters/codex/install.ts +289 -0
  10. package/cli/selftune/adapters/opencode/hook.ts +222 -0
  11. package/cli/selftune/adapters/opencode/install.ts +543 -0
  12. package/cli/selftune/adapters/pi/hook.ts +273 -0
  13. package/cli/selftune/adapters/pi/install.ts +207 -0
  14. package/cli/selftune/constants.ts +10 -1
  15. package/cli/selftune/dashboard-contract.ts +14 -0
  16. package/cli/selftune/evolution/engines/judge-engine.ts +96 -0
  17. package/cli/selftune/evolution/engines/replay-engine.ts +158 -0
  18. package/cli/selftune/evolution/evidence.ts +2 -6
  19. package/cli/selftune/evolution/evolve-body.ts +73 -20
  20. package/cli/selftune/evolution/validate-body.ts +78 -42
  21. package/cli/selftune/evolution/validate-routing.ts +45 -104
  22. package/cli/selftune/hooks/auto-activate.ts +43 -37
  23. package/cli/selftune/hooks/skill-eval.ts +2 -1
  24. package/cli/selftune/hooks-shared/git-metadata.ts +149 -0
  25. package/cli/selftune/hooks-shared/hook-output.ts +105 -0
  26. package/cli/selftune/hooks-shared/normalize.ts +196 -0
  27. package/cli/selftune/hooks-shared/session-state.ts +76 -0
  28. package/cli/selftune/hooks-shared/skill-paths.ts +50 -0
  29. package/cli/selftune/hooks-shared/stdin-dispatch.ts +59 -0
  30. package/cli/selftune/hooks-shared/types.ts +91 -0
  31. package/cli/selftune/index.ts +76 -6
  32. package/cli/selftune/ingestors/pi-ingest.ts +726 -0
  33. package/cli/selftune/init.ts +11 -1
  34. package/cli/selftune/localdb/direct-write.ts +85 -0
  35. package/cli/selftune/localdb/materialize.ts +6 -7
  36. package/cli/selftune/localdb/queries.ts +126 -0
  37. package/cli/selftune/localdb/schema.ts +38 -0
  38. package/cli/selftune/observability.ts +8 -1
  39. package/cli/selftune/orchestrate.ts +43 -0
  40. package/cli/selftune/registry/client.ts +74 -0
  41. package/cli/selftune/registry/history.ts +54 -0
  42. package/cli/selftune/registry/index.ts +90 -0
  43. package/cli/selftune/registry/install.ts +141 -0
  44. package/cli/selftune/registry/list.ts +44 -0
  45. package/cli/selftune/registry/push.ts +171 -0
  46. package/cli/selftune/registry/rollback.ts +49 -0
  47. package/cli/selftune/registry/status.ts +62 -0
  48. package/cli/selftune/registry/sync.ts +125 -0
  49. package/cli/selftune/repair/skill-usage.ts +4 -1
  50. package/cli/selftune/status.ts +31 -0
  51. package/cli/selftune/sync.ts +127 -23
  52. package/cli/selftune/types.ts +2 -1
  53. package/cli/selftune/utils/jsonl.ts +1 -30
  54. package/cli/selftune/utils/llm-call.ts +99 -34
  55. package/cli/selftune/utils/skill-discovery.ts +22 -0
  56. package/node_modules/@selftune/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
  57. package/node_modules/@selftune/telemetry-contract/fixtures/golden.test.ts +0 -1
  58. package/node_modules/@selftune/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
  59. package/node_modules/@selftune/telemetry-contract/package.json +1 -1
  60. package/node_modules/@selftune/telemetry-contract/src/index.ts +1 -0
  61. package/node_modules/@selftune/telemetry-contract/src/schemas.ts +22 -4
  62. package/node_modules/@selftune/telemetry-contract/src/types.ts +1 -12
  63. package/node_modules/@selftune/telemetry-contract/tests/compatibility.test.ts +0 -1
  64. package/package.json +1 -1
  65. package/packages/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
  66. package/packages/telemetry-contract/fixtures/golden.test.ts +0 -1
  67. package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
  68. package/packages/telemetry-contract/package.json +1 -1
  69. package/packages/telemetry-contract/src/index.ts +1 -0
  70. package/packages/telemetry-contract/src/schemas.ts +22 -4
  71. package/packages/telemetry-contract/src/types.ts +1 -12
  72. package/packages/telemetry-contract/tests/compatibility.test.ts +0 -1
  73. package/packages/ui/AGENTS.md +16 -0
  74. package/packages/ui/README.md +1 -1
  75. package/packages/ui/package.json +1 -1
  76. package/packages/ui/src/components/ActivityTimeline.tsx +152 -168
  77. package/packages/ui/src/components/AnalyticsCharts.tsx +344 -0
  78. package/packages/ui/src/components/EvidenceViewer.tsx +153 -443
  79. package/packages/ui/src/components/EvolutionTimeline.tsx +34 -87
  80. package/packages/ui/src/components/InfoTip.tsx +1 -2
  81. package/packages/ui/src/components/InvocationsPanel.tsx +413 -0
  82. package/packages/ui/src/components/JobHistoryTimeline.tsx +156 -0
  83. package/packages/ui/src/components/OrchestrateRunsPanel.tsx +18 -36
  84. package/packages/ui/src/components/OverviewPanels.tsx +652 -0
  85. package/packages/ui/src/components/PipelineStatusBar.tsx +65 -0
  86. package/packages/ui/src/components/SkillReportGuide.tsx +215 -0
  87. package/packages/ui/src/components/SkillReportPanels.tsx +919 -0
  88. package/packages/ui/src/components/SkillsLibrary.tsx +437 -0
  89. package/packages/ui/src/components/index.ts +56 -1
  90. package/packages/ui/src/components/section-cards.tsx +18 -35
  91. package/packages/ui/src/components/skill-health-grid.tsx +47 -37
  92. package/packages/ui/src/lib/constants.tsx +0 -1
  93. package/packages/ui/src/primitives/card.tsx +1 -1
  94. package/packages/ui/src/primitives/checkbox.tsx +1 -1
  95. package/packages/ui/src/primitives/dropdown-menu.tsx +2 -2
  96. package/packages/ui/src/primitives/select.tsx +2 -2
  97. package/packages/ui/src/types.ts +172 -4
  98. package/skill/SKILL.md +26 -2
  99. package/skill/Workflows/Ingest.md +60 -2
  100. package/skill/Workflows/Initialize.md +54 -9
  101. package/skill/Workflows/PlatformHooks.md +109 -0
  102. package/skill/Workflows/Registry.md +99 -0
  103. package/skill/Workflows/Sync.md +3 -1
  104. package/apps/local-dashboard/dist/assets/index-D8O-RG1I.js +0 -60
  105. package/apps/local-dashboard/dist/assets/index-_EcLywDg.css +0 -1
  106. package/apps/local-dashboard/dist/assets/vendor-ui-CGEmUayx.js +0 -12
  107. package/cli/selftune/utils/html.ts +0 -27
  108. package/packages/ui/src/components/RecentActivityFeed.tsx +0 -117
@@ -7,6 +7,7 @@
7
7
  * - Codex rollout logs
8
8
  * - OpenCode session history
9
9
  * - OpenClaw session history
10
+ * - Pi session history
10
11
  *
11
12
  * After syncing raw session/query/telemetry records, it rebuilds the repaired
12
13
  * skill-usage overlay from Claude transcripts and Codex rollouts so monitoring,
@@ -25,6 +26,8 @@ import {
25
26
  OPENCLAW_AGENTS_DIR,
26
27
  OPENCLAW_INGEST_MARKER,
27
28
  OPENCODE_INGEST_MARKER,
29
+ PI_INGEST_MARKER,
30
+ PI_SESSIONS_DIR,
28
31
  QUERY_LOG,
29
32
  REPAIRED_SKILL_LOG,
30
33
  REPAIRED_SKILL_SESSIONS_MARKER,
@@ -56,7 +59,14 @@ import {
56
59
  readSessionsFromSqlite,
57
60
  writeSession as writeOpenCodeSession,
58
61
  } from "./ingestors/opencode-ingest.js";
62
+ import {
63
+ findPiSessions,
64
+ findPiSkillNames,
65
+ parsePiSession,
66
+ writeSession as writePiSession,
67
+ } from "./ingestors/pi-ingest.js";
59
68
  import { getDb } from "./localdb/db.js";
69
+ import { writeCronRunToDb } from "./localdb/direct-write.js";
60
70
  import { querySkillUsageRecords } from "./localdb/queries.js";
61
71
  import {
62
72
  persistRepairedSkillUsageToDb,
@@ -91,6 +101,7 @@ export interface SyncResult {
91
101
  codex: SyncStepResult;
92
102
  opencode: SyncStepResult;
93
103
  openclaw: SyncStepResult;
104
+ pi: SyncStepResult;
94
105
  };
95
106
  repair: {
96
107
  ran: boolean;
@@ -113,6 +124,7 @@ export interface SyncOptions {
113
124
  codexHome: string;
114
125
  opencodeDataDir: string;
115
126
  openclawAgentsDir: string;
127
+ piSessionsDir: string;
116
128
  skillLogPath: string;
117
129
  repairedSkillLogPath: string;
118
130
  repairedSessionsPath: string;
@@ -123,6 +135,7 @@ export interface SyncOptions {
123
135
  syncCodex: boolean;
124
136
  syncOpenCode: boolean;
125
137
  syncOpenClaw: boolean;
138
+ syncPi: boolean;
126
139
  rebuildSkillUsage: boolean;
127
140
  }
128
141
 
@@ -133,6 +146,7 @@ export interface SyncDeps {
133
146
  syncCodex?: (options: SyncOptions) => SyncStepResult;
134
147
  syncOpenCode?: (options: SyncOptions) => SyncStepResult;
135
148
  syncOpenClaw?: (options: SyncOptions) => SyncStepResult;
149
+ syncPi?: (options: SyncOptions) => SyncStepResult;
136
150
  rebuildSkillUsage?: (options: SyncOptions) => {
137
151
  repairedSessions: number;
138
152
  repairedRecords: number;
@@ -154,6 +168,7 @@ export function createDefaultSyncOptions(overrides: Partial<SyncOptions> = {}):
154
168
  codexHome: DEFAULT_CODEX_HOME,
155
169
  opencodeDataDir: DEFAULT_OPENCODE_DATA_DIR,
156
170
  openclawAgentsDir: OPENCLAW_AGENTS_DIR,
171
+ piSessionsDir: PI_SESSIONS_DIR,
157
172
  skillLogPath: SKILL_LOG,
158
173
  repairedSkillLogPath: REPAIRED_SKILL_LOG,
159
174
  repairedSessionsPath: REPAIRED_SKILL_SESSIONS_MARKER,
@@ -163,6 +178,7 @@ export function createDefaultSyncOptions(overrides: Partial<SyncOptions> = {}):
163
178
  syncCodex: true,
164
179
  syncOpenCode: true,
165
180
  syncOpenClaw: true,
181
+ syncPi: true,
166
182
  rebuildSkillUsage: true,
167
183
  ...overrides,
168
184
  };
@@ -356,6 +372,45 @@ function syncOpenClawSource(
356
372
  };
357
373
  }
358
374
 
375
+ function syncPiSource(options: SyncOptions, onProgress?: SyncProgressCallback): SyncStepResult {
376
+ if (!existsSync(options.piSessionsDir)) {
377
+ return { available: false, scanned: 0, synced: 0, skipped: 0 };
378
+ }
379
+
380
+ onProgress?.("scanning Pi sessions...");
381
+ const sinceTs = options.since ? options.since.getTime() : null;
382
+ const allSessions = findPiSessions(options.piSessionsDir, sinceTs);
383
+ const skillNames = findPiSkillNames();
384
+ const alreadyIngested = options.force ? new Set<string>() : loadMarker(PI_INGEST_MARKER);
385
+ const pending = allSessions.filter((session) => !alreadyIngested.has(session.sessionId));
386
+ onProgress?.(`found ${allSessions.length} sessions, ${pending.length} pending`);
387
+ const newIngested = new Set<string>();
388
+ let synced = 0;
389
+ let skipped = 0;
390
+
391
+ for (const sessionFile of pending) {
392
+ const session = parsePiSession(sessionFile.filePath, skillNames);
393
+ if (!session.session_id || !session.timestamp) {
394
+ skipped += 1;
395
+ continue;
396
+ }
397
+ writePiSession(session, options.dryRun);
398
+ newIngested.add(sessionFile.sessionId);
399
+ synced += 1;
400
+ }
401
+
402
+ if (!options.dryRun && newIngested.size > 0) {
403
+ saveMarker(PI_INGEST_MARKER, new Set([...alreadyIngested, ...newIngested]));
404
+ }
405
+
406
+ return {
407
+ available: true,
408
+ scanned: allSessions.length,
409
+ synced,
410
+ skipped,
411
+ };
412
+ }
413
+
359
414
  function rebuildSkillUsageOverlay(
360
415
  options: SyncOptions,
361
416
  onProgress?: SyncProgressCallback,
@@ -445,6 +500,7 @@ export function syncSources(
445
500
  const runCodex = deps.syncCodex;
446
501
  const runOpenCode = deps.syncOpenCode;
447
502
  const runOpenClaw = deps.syncOpenClaw;
503
+ const runPi = deps.syncPi;
448
504
  const runRepair = deps.rebuildSkillUsage;
449
505
  const runCreatorContributions = deps.stageCreatorContributions;
450
506
  const db = getDb();
@@ -485,6 +541,10 @@ export function syncSources(
485
541
  )
486
542
  : disabledStep;
487
543
 
544
+ const pi = options.syncPi
545
+ ? timePhase("pi", () => (runPi ? runPi(options) : syncPiSource(options, onProgress)), timings)
546
+ : disabledStep;
547
+
488
548
  const repair = options.rebuildSkillUsage
489
549
  ? timePhase(
490
550
  "repair",
@@ -512,10 +572,10 @@ export function syncSources(
512
572
 
513
573
  const totalElapsed = Math.round(performance.now() - totalStart);
514
574
 
515
- return {
575
+ const syncResult: SyncResult = {
516
576
  since: options.since ? options.since.toISOString() : null,
517
577
  dry_run: options.dryRun,
518
- sources: { claude, codex, opencode, openclaw },
578
+ sources: { claude, codex, opencode, openclaw, pi },
519
579
  repair: {
520
580
  ran: options.rebuildSkillUsage,
521
581
  repaired_sessions: repair.repairedSessions,
@@ -526,6 +586,8 @@ export function syncSources(
526
586
  timings,
527
587
  total_elapsed_ms: totalElapsed,
528
588
  };
589
+
590
+ return syncResult;
529
591
  }
530
592
 
531
593
  function formatMs(ms: number): string {
@@ -549,6 +611,7 @@ export async function cliMain(): Promise<void> {
549
611
  "codex-home": { type: "string", default: DEFAULT_CODEX_HOME },
550
612
  "opencode-data-dir": { type: "string", default: DEFAULT_OPENCODE_DATA_DIR },
551
613
  "openclaw-agents-dir": { type: "string", default: OPENCLAW_AGENTS_DIR },
614
+ "pi-sessions-dir": { type: "string", default: PI_SESSIONS_DIR },
552
615
  "skill-log": { type: "string", default: SKILL_LOG },
553
616
  "repaired-skill-log": { type: "string", default: REPAIRED_SKILL_LOG },
554
617
  "repaired-sessions-marker": { type: "string", default: REPAIRED_SKILL_SESSIONS_MARKER },
@@ -559,6 +622,7 @@ export async function cliMain(): Promise<void> {
559
622
  "no-codex": { type: "boolean", default: false },
560
623
  "no-opencode": { type: "boolean", default: false },
561
624
  "no-openclaw": { type: "boolean", default: false },
625
+ "no-pi": { type: "boolean", default: false },
562
626
  "no-repair": { type: "boolean", default: false },
563
627
  json: { type: "boolean", default: false },
564
628
  help: { type: "boolean", short: "h", default: false },
@@ -577,6 +641,7 @@ Options:
577
641
  --codex-home <dir> Codex home directory (default: ~/.codex)
578
642
  --opencode-data-dir <dir> OpenCode data directory
579
643
  --openclaw-agents-dir <dir> OpenClaw agents directory
644
+ --pi-sessions-dir <dir> Pi sessions directory
580
645
  --skill-log <path> Raw skill usage log path
581
646
  --repaired-skill-log <path> Repaired overlay log path
582
647
  --repaired-sessions-marker <p> Repaired session marker path
@@ -587,6 +652,7 @@ Options:
587
652
  --no-codex Skip Codex rollout ingest
588
653
  --no-opencode Skip OpenCode ingest
589
654
  --no-openclaw Skip OpenClaw ingest
655
+ --no-pi Skip Pi ingest
590
656
  --no-repair Skip rebuilt skill-usage overlay
591
657
  --json Output raw JSON instead of human-readable summary
592
658
  -h, --help Show this help`);
@@ -622,27 +688,64 @@ Options:
622
688
  process.stderr.write(`selftune sync${flags.length ? ` ${flags.join(" ")}` : ""}\n`);
623
689
  }
624
690
 
625
- const result = syncSources(
626
- createDefaultSyncOptions({
627
- projectsDir: values["projects-dir"] ?? CLAUDE_CODE_PROJECTS_DIR,
628
- codexHome: values["codex-home"] ?? DEFAULT_CODEX_HOME,
629
- opencodeDataDir: values["opencode-data-dir"] ?? DEFAULT_OPENCODE_DATA_DIR,
630
- openclawAgentsDir: values["openclaw-agents-dir"] ?? OPENCLAW_AGENTS_DIR,
631
- skillLogPath: values["skill-log"] ?? SKILL_LOG,
632
- repairedSkillLogPath: values["repaired-skill-log"] ?? REPAIRED_SKILL_LOG,
633
- repairedSessionsPath: values["repaired-sessions-marker"] ?? REPAIRED_SKILL_SESSIONS_MARKER,
634
- since,
635
- dryRun: values["dry-run"] ?? false,
636
- force: values.force ?? false,
637
- syncClaude: !(values["no-claude"] ?? false),
638
- syncCodex: !(values["no-codex"] ?? false),
639
- syncOpenCode: !(values["no-opencode"] ?? false),
640
- syncOpenClaw: !(values["no-openclaw"] ?? false),
641
- rebuildSkillUsage: !(values["no-repair"] ?? false),
642
- }),
643
- {},
644
- onProgress,
645
- );
691
+ const syncStartedAt = new Date();
692
+ const syncStart = performance.now();
693
+ let result: SyncResult;
694
+ try {
695
+ result = syncSources(
696
+ createDefaultSyncOptions({
697
+ projectsDir: values["projects-dir"] ?? CLAUDE_CODE_PROJECTS_DIR,
698
+ codexHome: values["codex-home"] ?? DEFAULT_CODEX_HOME,
699
+ opencodeDataDir: values["opencode-data-dir"] ?? DEFAULT_OPENCODE_DATA_DIR,
700
+ openclawAgentsDir: values["openclaw-agents-dir"] ?? OPENCLAW_AGENTS_DIR,
701
+ piSessionsDir: values["pi-sessions-dir"] ?? PI_SESSIONS_DIR,
702
+ skillLogPath: values["skill-log"] ?? SKILL_LOG,
703
+ repairedSkillLogPath: values["repaired-skill-log"] ?? REPAIRED_SKILL_LOG,
704
+ repairedSessionsPath: values["repaired-sessions-marker"] ?? REPAIRED_SKILL_SESSIONS_MARKER,
705
+ since,
706
+ dryRun: values["dry-run"] ?? false,
707
+ force: values.force ?? false,
708
+ syncClaude: !(values["no-claude"] ?? false),
709
+ syncCodex: !(values["no-codex"] ?? false),
710
+ syncOpenCode: !(values["no-opencode"] ?? false),
711
+ syncOpenClaw: !(values["no-openclaw"] ?? false),
712
+ syncPi: !(values["no-pi"] ?? false),
713
+ rebuildSkillUsage: !(values["no-repair"] ?? false),
714
+ }),
715
+ {},
716
+ onProgress,
717
+ );
718
+ } catch (err) {
719
+ const syncElapsed = Math.round(performance.now() - syncStart);
720
+ const message = err instanceof Error ? err.message : String(err);
721
+ writeCronRunToDb(getDb(), {
722
+ jobName: "sync",
723
+ startedAt: syncStartedAt.toISOString(),
724
+ elapsedMs: syncElapsed,
725
+ status: "error",
726
+ error: message,
727
+ });
728
+ throw err;
729
+ }
730
+
731
+ // Log successful sync run to unified cron_runs timeline
732
+ const syncElapsed = Math.round(performance.now() - syncStart);
733
+ const s = result.sources;
734
+ writeCronRunToDb(getDb(), {
735
+ jobName: "sync",
736
+ startedAt: syncStartedAt.toISOString(),
737
+ elapsedMs: syncElapsed,
738
+ status: "success",
739
+ metrics: {
740
+ total_synced:
741
+ s.claude.synced + s.codex.synced + s.opencode.synced + s.openclaw.synced + s.pi.synced,
742
+ claude_synced: s.claude.synced,
743
+ codex_synced: s.codex.synced,
744
+ opencode_synced: s.opencode.synced,
745
+ openclaw_synced: s.openclaw.synced,
746
+ pi_synced: s.pi.synced,
747
+ },
748
+ });
646
749
 
647
750
  if (jsonOutput) {
648
751
  console.log(JSON.stringify(result, null, 2));
@@ -662,6 +765,7 @@ Options:
662
765
  process.stderr.write(
663
766
  `${formatStepLine("OpenClaw", result.sources.openclaw, timingMap.get("openclaw"))}\n`,
664
767
  );
768
+ process.stderr.write(`${formatStepLine("Pi", result.sources.pi, timingMap.get("pi"))}\n`);
665
769
 
666
770
  if (result.repair.ran) {
667
771
  const repairTiming = timingMap.get("repair");
@@ -34,7 +34,7 @@ export type AlphaLinkState =
34
34
  | "ready";
35
35
 
36
36
  export interface SelftuneConfig {
37
- agent_type: "claude_code" | "codex" | "opencode" | "openclaw" | "unknown";
37
+ agent_type: "claude_code" | "codex" | "opencode" | "openclaw" | "pi" | "unknown";
38
38
  cli_path: string;
39
39
  llm_mode: "agent";
40
40
  agent_cli: string | null;
@@ -738,6 +738,7 @@ export interface BodyValidationResult {
738
738
  before_pass_rate?: number;
739
739
  after_pass_rate?: number;
740
740
  per_entry_results?: RoutingReplayEntryResult[];
741
+ before_entry_results?: RoutingReplayEntryResult[];
741
742
  }
742
743
 
743
744
  /** Configuration for which LLM model a role should use. */
@@ -1,9 +1,8 @@
1
1
  /**
2
- * JSONL read/write/append utilities.
2
+ * JSONL read utilities and marker file helpers.
3
3
  */
4
4
 
5
5
  import {
6
- appendFileSync,
7
6
  closeSync,
8
7
  existsSync,
9
8
  fstatSync,
@@ -15,10 +14,6 @@ import {
15
14
  } from "node:fs";
16
15
  import { dirname } from "node:path";
17
16
 
18
- import { createLogger } from "./logging.js";
19
- import type { LogType } from "./schema-validator.js";
20
- import { validateRecord } from "./schema-validator.js";
21
-
22
17
  /**
23
18
  * Read a JSONL file and return parsed records.
24
19
  * Skips blank lines and lines that fail to parse.
@@ -86,30 +81,6 @@ export function readJsonlFrom<T = Record<string, unknown>>(
86
81
  }
87
82
  }
88
83
 
89
- /**
90
- * Append a single record to a JSONL file. Creates parent directories if needed.
91
- * When logType is provided, validates the record and logs warnings on failure
92
- * but still writes the record (fail-open: hooks must never block).
93
- *
94
- * @deprecated Phase 3: JSONL writes removed. Retained for materializer/test utilities only.
95
- */
96
- export function appendJsonl(path: string, record: unknown, logType?: LogType): void {
97
- if (logType) {
98
- const result = validateRecord(record, logType);
99
- if (!result.valid) {
100
- const logger = createLogger("jsonl");
101
- for (const error of result.errors) {
102
- logger.warn(`Validation warning for ${logType}: ${error}`);
103
- }
104
- }
105
- }
106
- const dir = dirname(path);
107
- if (!existsSync(dir)) {
108
- mkdirSync(dir, { recursive: true });
109
- }
110
- appendFileSync(path, `${JSON.stringify(record)}\n`, "utf-8");
111
- }
112
-
113
84
  /**
114
85
  * Load a marker file (JSON array of strings) for idempotent ingestion.
115
86
  */
@@ -6,9 +6,9 @@
6
6
  * modules can reuse the same calling logic.
7
7
  */
8
8
 
9
- import { readFileSync, writeFileSync } from "node:fs";
9
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
- import { join } from "node:path";
11
+ import { dirname, join, resolve } from "node:path";
12
12
 
13
13
  import { AGENT_CANDIDATES } from "../constants.js";
14
14
  import { createLogger } from "./logging.js";
@@ -33,6 +33,40 @@ function resolveModelFlag(flag: string): string {
33
33
  return CLAUDE_MODEL_ALIASES[flag] ?? flag;
34
34
  }
35
35
 
36
+ /**
37
+ * Map selftune model aliases to OpenCode provider/model format.
38
+ * OpenCode uses "provider/model" syntax (e.g. "anthropic/claude-sonnet-4-20250514").
39
+ */
40
+ const OPENCODE_MODEL_MAP: Record<string, string> = {
41
+ haiku: "anthropic/claude-haiku-4-5-20251001",
42
+ sonnet: "anthropic/claude-sonnet-4-20250514",
43
+ opus: "anthropic/claude-opus-4-20250514",
44
+ };
45
+
46
+ /** Resolve a model alias to OpenCode's provider/model format. */
47
+ function resolveOpenCodeModel(flag: string): string {
48
+ return OPENCODE_MODEL_MAP[flag] ?? flag;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Bundled agent file loading (for codex inline prompt injection)
53
+ // ---------------------------------------------------------------------------
54
+
55
+ const BUNDLED_AGENT_DIR = resolve(dirname(import.meta.path), "..", "..", "..", "skill", "agents");
56
+
57
+ /**
58
+ * Read the bundled agent markdown file and return its body (without frontmatter).
59
+ * Used by codex path to inline agent instructions into the prompt since codex
60
+ * has no --agent flag.
61
+ */
62
+ function loadAgentInstructions(agentName: string): string | null {
63
+ const filePath = join(BUNDLED_AGENT_DIR, `${agentName}.md`);
64
+ if (!existsSync(filePath)) return null;
65
+ const content = readFileSync(filePath, "utf-8");
66
+ // Strip YAML frontmatter
67
+ return content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim();
68
+ }
69
+
36
70
  // ---------------------------------------------------------------------------
37
71
  // Agent detection
38
72
  // ---------------------------------------------------------------------------
@@ -155,7 +189,11 @@ export async function callViaAgent(
155
189
  } else if (agent === "codex") {
156
190
  cmd = ["codex", "exec", "--skip-git-repo-check", promptContent];
157
191
  } else if (agent === "opencode") {
158
- cmd = ["opencode", "-p", promptContent, "-f", "text", "-q"];
192
+ cmd = ["opencode", "run"];
193
+ if (modelFlag) {
194
+ cmd.push("--model", resolveOpenCodeModel(modelFlag));
195
+ }
196
+ cmd.push(promptContent);
159
197
  } else {
160
198
  throw new Error(`Unknown agent: ${agent}`);
161
199
  }
@@ -222,9 +260,9 @@ export async function callViaAgent(
222
260
  // Call LLM via named subagent (multi-turn, agentic)
223
261
  // ---------------------------------------------------------------------------
224
262
 
225
- /** Options for calling a named Claude Code subagent. */
263
+ /** Options for calling a named subagent (Claude Code or OpenCode). */
226
264
  export interface SubagentCallOptions {
227
- /** Name of the subagent (synced into ~/.claude/agents/ by selftune init/update). */
265
+ /** Name of the subagent (synced into ~/.claude/agents/ or opencode.json by selftune init/update). */
228
266
  agentName: string;
229
267
  /** The task prompt for the subagent. */
230
268
  prompt: string;
@@ -243,13 +281,13 @@ export interface SubagentCallOptions {
243
281
  }
244
282
 
245
283
  /**
246
- * Call a named Claude Code subagent in print mode. The subagent runs its
247
- * multi-turn workflow (reading files, running commands, etc.) and returns
248
- * the final text output.
284
+ * Call a named subagent in print mode. The subagent runs its multi-turn
285
+ * workflow (reading files, running commands, etc.) and returns the final
286
+ * text output.
249
287
  *
250
- * Unlike callViaAgent(), this does NOT use --bare (agents need discovery)
251
- * and passes --agent + --max-turns for agentic multi-turn behavior.
252
- * Only supports the claude CLI.
288
+ * Supports Claude Code (`claude --agent`), OpenCode (`opencode run --agent`),
289
+ * and Codex (`codex exec` with agent instructions inlined into the prompt).
290
+ * Auto-detects the available agent CLI.
253
291
  */
254
292
  export async function callViaSubagent(options: SubagentCallOptions): Promise<string> {
255
293
  const {
@@ -263,31 +301,58 @@ export async function callViaSubagent(options: SubagentCallOptions): Promise<str
263
301
  allowedTools,
264
302
  } = options;
265
303
 
266
- const cmd: string[] = [
267
- "claude",
268
- "-p",
269
- prompt,
270
- "--agent",
271
- agentName,
272
- "--max-turns",
273
- String(maxTurns),
274
- ];
275
-
276
- if (appendSystemPrompt) {
277
- cmd.push("--append-system-prompt", appendSystemPrompt);
278
- }
279
- if (modelFlag) {
280
- const resolved = resolveModelFlag(modelFlag);
281
- cmd.push("--model", resolved);
304
+ const agent = detectAgent();
305
+ if (!agent || (agent !== "claude" && agent !== "opencode" && agent !== "codex")) {
306
+ throw new Error(
307
+ `Subagent calls require 'claude', 'opencode', or 'codex' CLI in PATH (detected: ${agent ?? "none"})`,
308
+ );
282
309
  }
283
- if (effort) {
284
- cmd.push("--effort", effort);
285
- }
286
- if (allowedTools && allowedTools.length > 0) {
287
- cmd.push("--allowedTools", ...allowedTools);
310
+
311
+ let cmd: string[];
312
+
313
+ if (agent === "opencode") {
314
+ // OpenCode supports --agent and --model but not allowedTools, appendSystemPrompt, or maxTurns
315
+ if (allowedTools?.length || appendSystemPrompt) {
316
+ logger.warn(
317
+ `Subagent '${agentName}' on opencode: allowedTools and appendSystemPrompt are not supported and will be ignored`,
318
+ );
319
+ }
320
+ cmd = ["opencode", "run", "--agent", agentName];
321
+ if (modelFlag) {
322
+ cmd.push("--model", resolveOpenCodeModel(modelFlag));
323
+ }
324
+ cmd.push(prompt);
325
+ } else if (agent === "codex") {
326
+ // Codex has no --agent flag; inline the agent instructions into the prompt.
327
+ // allowedTools, appendSystemPrompt, maxTurns, and effort are not supported.
328
+ if (allowedTools?.length || appendSystemPrompt) {
329
+ logger.warn(
330
+ `Subagent '${agentName}' on codex: allowedTools and appendSystemPrompt are not supported and will be ignored`,
331
+ );
332
+ }
333
+ const agentInstructions = loadAgentInstructions(agentName);
334
+ const fullPrompt = agentInstructions ? `${agentInstructions}\n\n---\n\n${prompt}` : prompt;
335
+ cmd = ["codex", "exec", "--skip-git-repo-check", fullPrompt];
336
+ } else {
337
+ // Claude Code
338
+ cmd = ["claude", "-p", prompt, "--agent", agentName, "--max-turns", String(maxTurns)];
339
+
340
+ if (appendSystemPrompt) {
341
+ cmd.push("--append-system-prompt", appendSystemPrompt);
342
+ }
343
+ if (modelFlag) {
344
+ const resolved = resolveModelFlag(modelFlag);
345
+ cmd.push("--model", resolved);
346
+ }
347
+ if (effort) {
348
+ cmd.push("--effort", effort);
349
+ }
350
+ if (allowedTools && allowedTools.length > 0) {
351
+ cmd.push("--allowedTools", ...allowedTools);
352
+ }
353
+ // Skip permissions since this runs non-interactively in a pipeline
354
+ cmd.push("--dangerously-skip-permissions");
288
355
  }
289
- // Skip permissions since this runs non-interactively in a pipeline
290
- cmd.push("--dangerously-skip-permissions");
291
356
 
292
357
  const maxRetries = retryOpts?.maxRetries ?? DEFAULT_MAX_RETRIES;
293
358
  const initialBackoffMs = retryOpts?.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS;
@@ -263,6 +263,28 @@ export function classifySkillPath(
263
263
  return { skill_scope: "unknown" };
264
264
  }
265
265
 
266
+ const TEST_PATH_SEGMENTS = [
267
+ "/tests/",
268
+ "/__tests__/",
269
+ "/test/",
270
+ "/fixtures/",
271
+ "/sandbox/",
272
+ "/test-data/",
273
+ "/testdata/",
274
+ "/mock/",
275
+ "/mocks/",
276
+ ];
277
+
278
+ /**
279
+ * Check if a skill path is inside a test/fixture directory.
280
+ * Used to prevent test fixture skills from leaking into production data.
281
+ */
282
+ export function isTestFixturePath(skillPath: string): boolean {
283
+ if (!skillPath) return false;
284
+ const normalized = skillPath.toLowerCase();
285
+ return TEST_PATH_SEGMENTS.some((seg) => normalized.includes(seg));
286
+ }
287
+
266
288
  export function extractSkillNamesFromInstructions(
267
289
  text: string,
268
290
  knownSkillNames?: Iterable<string>,
@@ -7,7 +7,7 @@ import type { PushPayloadV2 } from "../src/schemas.js";
7
7
  export const evidenceOnlyPush: PushPayloadV2 = {
8
8
  schema_version: "2.0",
9
9
  client_version: "0.9.0",
10
- push_id: "d4e5f6a7-b8c9-0123-defa-234567890123",
10
+ push_id: "d4e5f6a7-b8c9-8123-9efa-234567890123",
11
11
  normalizer_version: "0.2.1",
12
12
  canonical: {
13
13
  sessions: [],
@@ -1,7 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
-
5
4
  import { CANONICAL_SCHEMA_VERSION } from "../src/types.js";
6
5
  import { isCanonicalRecord } from "../src/validators.js";
7
6
 
@@ -10,7 +10,7 @@ import type { PushPayloadV2 } from "../src/schemas.js";
10
10
  export const partialPushUnresolvedParents: PushPayloadV2 = {
11
11
  schema_version: "2.0",
12
12
  client_version: "0.9.0",
13
- push_id: "c3d4e5f6-a7b8-9012-cdef-123456789012",
13
+ push_id: "c3d4e5f6-a7b8-8012-8def-123456789012",
14
14
  normalizer_version: "0.2.1",
15
15
  canonical: {
16
16
  sessions: [],
@@ -13,9 +13,9 @@
13
13
  "type": "module",
14
14
  "exports": {
15
15
  ".": "./index.ts",
16
- "./schemas": "./src/schemas.ts",
17
16
  "./types": "./src/types.ts",
18
17
  "./validators": "./src/validators.ts",
18
+ "./schemas": "./src/schemas.ts",
19
19
  "./fixtures": "./fixtures/index.ts"
20
20
  },
21
21
  "dependencies": {
@@ -1,2 +1,3 @@
1
+ export * from "./schemas.js";
1
2
  export * from "./types.js";
2
3
  export * from "./validators.js";