selftune 0.2.22 → 0.2.24

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 (270) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +95 -15
  3. package/apps/local-dashboard/dist/assets/index-DgY2KGP-.css +1 -0
  4. package/apps/local-dashboard/dist/assets/index-Dmx7LPVX.js +15 -0
  5. package/apps/local-dashboard/dist/assets/vendor-react-C5oyHiV1.js +11 -0
  6. package/apps/local-dashboard/dist/assets/{vendor-table-BIiI3YhS.js → vendor-table-Bc_bbKd8.js} +1 -1
  7. package/apps/local-dashboard/dist/assets/vendor-ui-B3BPIYy7.js +1 -0
  8. package/apps/local-dashboard/dist/index.html +5 -5
  9. package/cli/selftune/adapters/codex/install.ts +310 -78
  10. package/cli/selftune/adapters/opencode/install.ts +3 -4
  11. package/cli/selftune/adapters/pi/hook.ts +273 -0
  12. package/cli/selftune/adapters/pi/install.ts +207 -0
  13. package/cli/selftune/alpha-upload/build-payloads.ts +3 -3
  14. package/cli/selftune/alpha-upload/stage-canonical.ts +17 -11
  15. package/cli/selftune/auto-update.ts +200 -8
  16. package/cli/selftune/canonical-export.ts +55 -25
  17. package/cli/selftune/command-surface.ts +397 -0
  18. package/cli/selftune/constants.ts +10 -1
  19. package/cli/selftune/contribute/contribute.ts +64 -13
  20. package/cli/selftune/contribution-config.ts +57 -3
  21. package/cli/selftune/contribution-preferences.ts +117 -0
  22. package/cli/selftune/contribution-signals.ts +8 -4
  23. package/cli/selftune/contribution-staging.ts +13 -2
  24. package/cli/selftune/contributions.ts +55 -121
  25. package/cli/selftune/creator-contributions.ts +29 -10
  26. package/cli/selftune/cron/setup.ts +7 -3
  27. package/cli/selftune/dashboard-contract.ts +87 -0
  28. package/cli/selftune/dashboard-server.ts +168 -17
  29. package/cli/selftune/dashboard.ts +350 -17
  30. package/cli/selftune/eval/baseline.ts +21 -5
  31. package/cli/selftune/eval/execution-eval.ts +170 -0
  32. package/cli/selftune/eval/family-overlap.ts +2 -2
  33. package/cli/selftune/eval/hooks-to-evals.ts +228 -82
  34. package/cli/selftune/eval/import-skillsbench.ts +2 -2
  35. package/cli/selftune/eval/invocation-classifier.ts +56 -0
  36. package/cli/selftune/eval/synthetic-evals.ts +5 -3
  37. package/cli/selftune/eval/unit-test-cli.ts +7 -4
  38. package/cli/selftune/evolution/apply-proposal.ts +295 -0
  39. package/cli/selftune/evolution/engines/judge-engine.ts +96 -0
  40. package/cli/selftune/evolution/engines/replay-engine.ts +180 -0
  41. package/cli/selftune/evolution/evidence.ts +2 -6
  42. package/cli/selftune/evolution/evolve-body.ts +152 -38
  43. package/cli/selftune/evolution/evolve.ts +244 -52
  44. package/cli/selftune/evolution/rollback.ts +0 -1
  45. package/cli/selftune/evolution/validate-body.ts +111 -49
  46. package/cli/selftune/evolution/validate-host-replay.ts +510 -60
  47. package/cli/selftune/evolution/validate-proposal.ts +11 -150
  48. package/cli/selftune/evolution/validate-routing.ts +51 -108
  49. package/cli/selftune/evolution/validation-contract.ts +91 -0
  50. package/cli/selftune/grading/auto-grade.ts +11 -7
  51. package/cli/selftune/grading/grade-session.ts +10 -16
  52. package/cli/selftune/hooks/skill-eval.ts +2 -1
  53. package/cli/selftune/hooks-shared/types.ts +1 -0
  54. package/cli/selftune/index.ts +58 -15
  55. package/cli/selftune/ingestors/claude-replay.ts +15 -10
  56. package/cli/selftune/ingestors/codex-wrapper.ts +3 -3
  57. package/cli/selftune/ingestors/opencode-ingest.ts +2 -2
  58. package/cli/selftune/ingestors/pi-ingest.ts +727 -0
  59. package/cli/selftune/init.ts +38 -4
  60. package/cli/selftune/localdb/direct-write.ts +120 -1
  61. package/cli/selftune/localdb/materialize.ts +6 -7
  62. package/cli/selftune/localdb/queries/cron.ts +34 -0
  63. package/cli/selftune/localdb/queries/dashboard.ts +834 -0
  64. package/cli/selftune/localdb/queries/evolution.ts +158 -0
  65. package/cli/selftune/localdb/queries/execution.ts +133 -0
  66. package/cli/selftune/localdb/queries/json.ts +18 -0
  67. package/cli/selftune/localdb/queries/monitoring.ts +263 -0
  68. package/cli/selftune/localdb/queries/raw.ts +95 -0
  69. package/cli/selftune/localdb/queries/staging.ts +270 -0
  70. package/cli/selftune/localdb/queries/trust.ts +392 -0
  71. package/cli/selftune/localdb/queries.ts +60 -2162
  72. package/cli/selftune/localdb/schema.ts +59 -0
  73. package/cli/selftune/monitoring/watch.ts +96 -29
  74. package/cli/selftune/normalization.ts +3 -0
  75. package/cli/selftune/observability.ts +12 -3
  76. package/cli/selftune/orchestrate/cli.ts +161 -0
  77. package/cli/selftune/orchestrate/execute.ts +295 -0
  78. package/cli/selftune/orchestrate/finalize.ts +157 -0
  79. package/cli/selftune/orchestrate/locks.ts +40 -0
  80. package/cli/selftune/orchestrate/plan.ts +131 -0
  81. package/cli/selftune/orchestrate/post-run.ts +59 -0
  82. package/cli/selftune/orchestrate/prepare.ts +334 -0
  83. package/cli/selftune/orchestrate/report.ts +182 -0
  84. package/cli/selftune/orchestrate/runtime.ts +120 -0
  85. package/cli/selftune/orchestrate/signals.ts +48 -0
  86. package/cli/selftune/orchestrate.ts +162 -1142
  87. package/cli/selftune/registry/client.ts +74 -0
  88. package/cli/selftune/registry/history.ts +54 -0
  89. package/cli/selftune/registry/index.ts +90 -0
  90. package/cli/selftune/registry/install.ts +141 -0
  91. package/cli/selftune/registry/list.ts +44 -0
  92. package/cli/selftune/registry/push.ts +171 -0
  93. package/cli/selftune/registry/rollback.ts +49 -0
  94. package/cli/selftune/registry/status.ts +62 -0
  95. package/cli/selftune/registry/sync.ts +125 -0
  96. package/cli/selftune/repair/skill-usage.ts +9 -3
  97. package/cli/selftune/routes/overview.ts +5 -2
  98. package/cli/selftune/routes/skill-report.ts +15 -2
  99. package/cli/selftune/schedule.ts +5 -5
  100. package/cli/selftune/status.ts +70 -2
  101. package/cli/selftune/sync.ts +127 -23
  102. package/cli/selftune/testing-readiness.ts +597 -0
  103. package/cli/selftune/types.ts +46 -5
  104. package/cli/selftune/uninstall.ts +2 -1
  105. package/cli/selftune/utils/canonical-log.ts +1 -9
  106. package/cli/selftune/utils/cli-error.ts +9 -0
  107. package/cli/selftune/utils/jsonl.ts +1 -30
  108. package/cli/selftune/utils/llm-call.ts +126 -6
  109. package/cli/selftune/utils/skill-discovery.ts +24 -0
  110. package/cli/selftune/workflows/proposals.ts +184 -0
  111. package/cli/selftune/workflows/skill-scaffold.ts +241 -0
  112. package/cli/selftune/workflows/workflows.ts +100 -26
  113. package/node_modules/@selftune/telemetry-contract/fixtures/complete-push.ts +1 -1
  114. package/node_modules/@selftune/telemetry-contract/fixtures/evidence-only-push.ts +2 -2
  115. package/node_modules/@selftune/telemetry-contract/fixtures/golden.test.ts +0 -1
  116. package/node_modules/@selftune/telemetry-contract/fixtures/partial-push-no-sessions.ts +1 -1
  117. package/node_modules/@selftune/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +2 -2
  118. package/node_modules/@selftune/telemetry-contract/package.json +1 -1
  119. package/node_modules/@selftune/telemetry-contract/src/index.ts +1 -0
  120. package/node_modules/@selftune/telemetry-contract/src/schemas.ts +63 -5
  121. package/node_modules/@selftune/telemetry-contract/src/types.ts +97 -7
  122. package/node_modules/@selftune/telemetry-contract/tests/compatibility.test.ts +0 -1
  123. package/package.json +25 -9
  124. package/packages/dashboard-core/AGENTS.md +18 -0
  125. package/packages/dashboard-core/README.md +30 -0
  126. package/packages/dashboard-core/index.ts +3 -0
  127. package/packages/dashboard-core/package.json +39 -0
  128. package/packages/dashboard-core/src/chrome/DashboardChrome.tsx +74 -0
  129. package/packages/dashboard-core/src/chrome/DashboardHeader.tsx +200 -0
  130. package/packages/dashboard-core/src/chrome/DashboardSidebar.tsx +219 -0
  131. package/packages/dashboard-core/src/chrome/RuntimeBadge.tsx +46 -0
  132. package/packages/dashboard-core/src/chrome/index.ts +14 -0
  133. package/packages/dashboard-core/src/chrome/types.ts +81 -0
  134. package/packages/dashboard-core/src/chrome/utils.ts +23 -0
  135. package/packages/dashboard-core/src/gates/FeatureGate.tsx +11 -0
  136. package/packages/dashboard-core/src/gates/LockedRoute.tsx +29 -0
  137. package/packages/dashboard-core/src/gates/UpgradeCard.tsx +89 -0
  138. package/packages/dashboard-core/src/gates/index.ts +3 -0
  139. package/packages/dashboard-core/src/host/DashboardHostProvider.tsx +62 -0
  140. package/packages/dashboard-core/src/host/adapter.ts +47 -0
  141. package/packages/dashboard-core/src/host/capabilities.ts +55 -0
  142. package/packages/dashboard-core/src/host/index.ts +3 -0
  143. package/packages/dashboard-core/src/models/analytics.ts +39 -0
  144. package/packages/dashboard-core/src/models/index.ts +4 -0
  145. package/packages/dashboard-core/src/models/overview.ts +98 -0
  146. package/packages/dashboard-core/src/models/runtime.ts +7 -0
  147. package/packages/dashboard-core/src/models/skills.ts +34 -0
  148. package/packages/dashboard-core/src/routes/index.ts +2 -0
  149. package/packages/dashboard-core/src/routes/manifest.test.ts +70 -0
  150. package/packages/dashboard-core/src/routes/manifest.ts +451 -0
  151. package/packages/dashboard-core/src/routes/types.ts +39 -0
  152. package/packages/dashboard-core/src/screens/analytics/AnalyticsScreen.tsx +278 -0
  153. package/packages/dashboard-core/src/screens/analytics/index.ts +1 -0
  154. package/packages/dashboard-core/src/screens/index.ts +37 -0
  155. package/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.test.ts +101 -0
  156. package/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.tsx +393 -0
  157. package/packages/dashboard-core/src/screens/overview/OverviewCompositionSurface.test.tsx +113 -0
  158. package/packages/dashboard-core/src/screens/overview/OverviewCompositionSurface.tsx +72 -0
  159. package/packages/dashboard-core/src/screens/overview/OverviewCoreSurface.tsx +71 -0
  160. package/packages/dashboard-core/src/screens/overview/OverviewOnboardingBanner.tsx +90 -0
  161. package/packages/dashboard-core/src/screens/overview/OverviewRunSummary.tsx +40 -0
  162. package/packages/dashboard-core/src/screens/overview/index.ts +16 -0
  163. package/packages/dashboard-core/src/screens/overview/types.ts +13 -0
  164. package/packages/dashboard-core/src/screens/skill-report/SkillReportDailyBreakdownSection.tsx +99 -0
  165. package/packages/dashboard-core/src/screens/skill-report/SkillReportDataQualityTabContent.tsx +35 -0
  166. package/packages/dashboard-core/src/screens/skill-report/SkillReportEvidenceRail.tsx +71 -0
  167. package/packages/dashboard-core/src/screens/skill-report/SkillReportEvidenceSection.tsx +63 -0
  168. package/packages/dashboard-core/src/screens/skill-report/SkillReportEvidenceTabContent.tsx +25 -0
  169. package/packages/dashboard-core/src/screens/skill-report/SkillReportInvocationsSection.tsx +24 -0
  170. package/packages/dashboard-core/src/screens/skill-report/SkillReportMissedQueriesSection.tsx +79 -0
  171. package/packages/dashboard-core/src/screens/skill-report/SkillReportScaffold.tsx +150 -0
  172. package/packages/dashboard-core/src/screens/skill-report/SkillReportSections.test.tsx +224 -0
  173. package/packages/dashboard-core/src/screens/skill-report/SkillReportTabs.test.tsx +76 -0
  174. package/packages/dashboard-core/src/screens/skill-report/SkillReportTabs.tsx +88 -0
  175. package/packages/dashboard-core/src/screens/skill-report/SkillReportTrendSection.tsx +33 -0
  176. package/packages/dashboard-core/src/screens/skill-report/SkillReportTrustBadge.tsx +67 -0
  177. package/packages/dashboard-core/src/screens/skill-report/index.ts +45 -0
  178. package/packages/dashboard-core/src/screens/skills/SkillsLibraryScreen.tsx +162 -0
  179. package/packages/dashboard-core/src/screens/skills/index.ts +6 -0
  180. package/packages/telemetry-contract/fixtures/complete-push.ts +1 -1
  181. package/packages/telemetry-contract/fixtures/evidence-only-push.ts +2 -2
  182. package/packages/telemetry-contract/fixtures/golden.test.ts +0 -1
  183. package/packages/telemetry-contract/fixtures/partial-push-no-sessions.ts +1 -1
  184. package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +2 -2
  185. package/packages/telemetry-contract/package.json +1 -1
  186. package/packages/telemetry-contract/src/index.ts +1 -0
  187. package/packages/telemetry-contract/src/schemas.ts +63 -5
  188. package/packages/telemetry-contract/src/types.ts +97 -7
  189. package/packages/telemetry-contract/tests/compatibility.test.ts +0 -1
  190. package/packages/ui/AGENTS.md +16 -0
  191. package/packages/ui/README.md +1 -1
  192. package/packages/ui/package.json +1 -1
  193. package/packages/ui/src/components/ActivityTimeline.tsx +152 -168
  194. package/packages/ui/src/components/AnalyticsCharts.tsx +344 -0
  195. package/packages/ui/src/components/EvidenceViewer.tsx +229 -464
  196. package/packages/ui/src/components/EvolutionTimeline.tsx +34 -87
  197. package/packages/ui/src/components/InfoTip.tsx +1 -2
  198. package/packages/ui/src/components/InvocationsPanel.tsx +413 -0
  199. package/packages/ui/src/components/JobHistoryTimeline.tsx +156 -0
  200. package/packages/ui/src/components/OrchestrateRunsPanel.tsx +18 -36
  201. package/packages/ui/src/components/OverviewPanels.tsx +693 -0
  202. package/packages/ui/src/components/PipelineStatusBar.tsx +65 -0
  203. package/packages/ui/src/components/SkillReportGuide.tsx +215 -0
  204. package/packages/ui/src/components/SkillReportPanels.tsx +919 -0
  205. package/packages/ui/src/components/SkillsLibrary.tsx +437 -0
  206. package/packages/ui/src/components/index.ts +56 -1
  207. package/packages/ui/src/components/section-cards.tsx +18 -35
  208. package/packages/ui/src/components/skill-health-grid.tsx +47 -37
  209. package/packages/ui/src/lib/constants.tsx +0 -1
  210. package/packages/ui/src/primitives/card.tsx +1 -1
  211. package/packages/ui/src/primitives/checkbox.tsx +1 -1
  212. package/packages/ui/src/primitives/dropdown-menu.tsx +2 -2
  213. package/packages/ui/src/primitives/select.tsx +2 -2
  214. package/packages/ui/src/primitives/tabs.tsx +7 -6
  215. package/packages/ui/src/types.ts +182 -4
  216. package/skill/SKILL.md +130 -318
  217. package/skill/agents/diagnosis-analyst.md +3 -3
  218. package/skill/agents/evolution-reviewer.md +3 -3
  219. package/skill/agents/integration-guide.md +3 -3
  220. package/skill/agents/pattern-analyst.md +2 -2
  221. package/skill/references/cli-quick-reference.md +89 -0
  222. package/skill/references/creator-playbook.md +131 -0
  223. package/skill/references/examples.md +48 -0
  224. package/skill/references/troubleshooting.md +47 -0
  225. package/skill/references/version-history.md +1 -1
  226. package/skill/selftune.contribute.json +11 -0
  227. package/skill/{Workflows → workflows}/Baseline.md +20 -1
  228. package/skill/{Workflows → workflows}/Contribute.md +23 -10
  229. package/skill/{Workflows → workflows}/Contributions.md +13 -5
  230. package/skill/workflows/CreateTestDeploy.md +170 -0
  231. package/skill/{Workflows → workflows}/CreatorContributions.md +18 -6
  232. package/skill/{Workflows → workflows}/Cron.md +1 -1
  233. package/skill/{Workflows → workflows}/Dashboard.md +20 -0
  234. package/skill/{Workflows → workflows}/Doctor.md +1 -1
  235. package/skill/{Workflows → workflows}/Evals.md +67 -2
  236. package/skill/{Workflows → workflows}/Evolve.md +119 -30
  237. package/skill/{Workflows → workflows}/EvolveBody.md +41 -1
  238. package/skill/{Workflows → workflows}/Grade.md +1 -1
  239. package/skill/{Workflows → workflows}/Ingest.md +60 -2
  240. package/skill/{Workflows → workflows}/Initialize.md +16 -9
  241. package/skill/{Workflows → workflows}/Orchestrate.md +13 -3
  242. package/skill/{Workflows → workflows}/PlatformHooks.md +19 -3
  243. package/skill/workflows/Registry.md +99 -0
  244. package/skill/{Workflows → workflows}/Schedule.md +3 -3
  245. package/skill/workflows/SignalsDashboard.md +87 -0
  246. package/skill/{Workflows → workflows}/Sync.md +3 -1
  247. package/skill/{Workflows → workflows}/UnitTest.md +19 -0
  248. package/skill/{Workflows → workflows}/Watch.md +42 -2
  249. package/skill/{Workflows → workflows}/Workflows.md +39 -2
  250. package/apps/local-dashboard/dist/assets/index-D8O-RG1I.js +0 -60
  251. package/apps/local-dashboard/dist/assets/index-_EcLywDg.css +0 -1
  252. package/apps/local-dashboard/dist/assets/vendor-react-CKkiCskZ.js +0 -11
  253. package/apps/local-dashboard/dist/assets/vendor-ui-CGEmUayx.js +0 -12
  254. package/cli/selftune/utils/html.ts +0 -27
  255. package/packages/ui/src/components/RecentActivityFeed.tsx +0 -117
  256. /package/skill/{Workflows → workflows}/AlphaUpload.md +0 -0
  257. /package/skill/{Workflows → workflows}/AutoActivation.md +0 -0
  258. /package/skill/{Workflows → workflows}/Badge.md +0 -0
  259. /package/skill/{Workflows → workflows}/Composability.md +0 -0
  260. /package/skill/{Workflows → workflows}/EvolutionMemory.md +0 -0
  261. /package/skill/{Workflows → workflows}/ExportCanonical.md +0 -0
  262. /package/skill/{Workflows → workflows}/Hook.md +0 -0
  263. /package/skill/{Workflows → workflows}/ImportSkillsBench.md +0 -0
  264. /package/skill/{Workflows → workflows}/Quickstart.md +0 -0
  265. /package/skill/{Workflows → workflows}/Recover.md +0 -0
  266. /package/skill/{Workflows → workflows}/RepairSkillUsage.md +0 -0
  267. /package/skill/{Workflows → workflows}/Replay.md +0 -0
  268. /package/skill/{Workflows → workflows}/Rollback.md +0 -0
  269. /package/skill/{Workflows → workflows}/Telemetry.md +0 -0
  270. /package/skill/{Workflows → workflows}/Uninstall.md +0 -0
@@ -19,18 +19,30 @@ import { join } from "node:path";
19
19
  // Types
20
20
  // ---------------------------------------------------------------------------
21
21
 
22
- interface CodexHookEntry {
23
- event: string;
24
- command: string;
25
- timeout_ms?: number;
26
- matchers?: string[];
27
- /** Marker field so selftune can identify its own hooks. */
22
+ type CodexHookEvent = "PreToolUse" | "PostToolUse" | "SessionStart" | "UserPromptSubmit" | "Stop";
23
+
24
+ type CodexHookHandler = Record<string, unknown> & {
25
+ command?: string;
28
26
  _selftune?: boolean;
29
- }
27
+ };
28
+
29
+ type CodexMatcherGroup = Record<string, unknown> & {
30
+ hooks: CodexHookHandler[];
31
+ };
32
+
33
+ type CodexHooksByEvent = Record<string, CodexMatcherGroup[]>;
34
+
35
+ type LegacyCodexHookEntry = Record<string, unknown> & {
36
+ event?: unknown;
37
+ command?: unknown;
38
+ timeout_ms?: unknown;
39
+ matchers?: unknown;
40
+ _selftune?: unknown;
41
+ };
30
42
 
31
- interface CodexHooksFile {
32
- hooks?: CodexHookEntry[];
33
- [key: string]: unknown;
43
+ interface ParsedCodexHooksFile {
44
+ hooksByEvent: CodexHooksByEvent;
45
+ otherFields: Record<string, unknown>;
34
46
  }
35
47
 
36
48
  // ---------------------------------------------------------------------------
@@ -39,40 +51,64 @@ interface CodexHooksFile {
39
51
 
40
52
  const DEFAULT_CODEX_HOME = join(homedir(), ".codex");
41
53
  const HOOKS_FILENAME = "hooks.json";
42
- const DEFAULT_TIMEOUT_MS = 10_000;
43
- const SESSION_TIMEOUT_MS = 30_000;
54
+ const DEFAULT_TIMEOUT_SEC = 10;
55
+ const SESSION_TIMEOUT_SEC = 30;
44
56
 
45
57
  /** The command Codex will run for each hook event. */
46
58
  const HOOK_COMMAND =
47
59
  'bash -c \'if [ -n "$SELFTUNE_CLI_PATH" ]; then exec "$SELFTUNE_CLI_PATH" codex hook; else exec npx -y selftune@latest codex hook; fi\'';
48
60
 
49
61
  /** Hook entries selftune installs into Codex. */
50
- const SELFTUNE_HOOKS: CodexHookEntry[] = [
51
- {
52
- event: "SessionStart",
53
- command: HOOK_COMMAND,
54
- timeout_ms: SESSION_TIMEOUT_MS,
55
- _selftune: true,
56
- },
57
- {
58
- event: "PreToolUse",
59
- command: HOOK_COMMAND,
60
- timeout_ms: DEFAULT_TIMEOUT_MS,
61
- _selftune: true,
62
- },
63
- {
64
- event: "PostToolUse",
65
- command: HOOK_COMMAND,
66
- timeout_ms: DEFAULT_TIMEOUT_MS,
67
- _selftune: true,
68
- },
69
- {
70
- event: "Stop",
71
- command: HOOK_COMMAND,
72
- timeout_ms: SESSION_TIMEOUT_MS,
73
- _selftune: true,
74
- },
75
- ];
62
+ const SELFTUNE_HOOKS: Record<Exclude<CodexHookEvent, "UserPromptSubmit">, CodexMatcherGroup[]> = {
63
+ SessionStart: [
64
+ {
65
+ hooks: [
66
+ {
67
+ type: "command",
68
+ command: HOOK_COMMAND,
69
+ timeout: SESSION_TIMEOUT_SEC,
70
+ _selftune: true,
71
+ },
72
+ ],
73
+ },
74
+ ],
75
+ PreToolUse: [
76
+ {
77
+ hooks: [
78
+ {
79
+ type: "command",
80
+ command: HOOK_COMMAND,
81
+ timeout: DEFAULT_TIMEOUT_SEC,
82
+ _selftune: true,
83
+ },
84
+ ],
85
+ },
86
+ ],
87
+ PostToolUse: [
88
+ {
89
+ hooks: [
90
+ {
91
+ type: "command",
92
+ command: HOOK_COMMAND,
93
+ timeout: DEFAULT_TIMEOUT_SEC,
94
+ _selftune: true,
95
+ },
96
+ ],
97
+ },
98
+ ],
99
+ Stop: [
100
+ {
101
+ hooks: [
102
+ {
103
+ type: "command",
104
+ command: HOOK_COMMAND,
105
+ timeout: SESSION_TIMEOUT_SEC,
106
+ _selftune: true,
107
+ },
108
+ ],
109
+ },
110
+ ],
111
+ };
76
112
 
77
113
  // ---------------------------------------------------------------------------
78
114
  // Helpers
@@ -87,51 +123,226 @@ function getCodexHome(): string {
87
123
  return process.env.CODEX_HOME ?? DEFAULT_CODEX_HOME;
88
124
  }
89
125
 
126
+ function isRecord(value: unknown): value is Record<string, unknown> {
127
+ return typeof value === "object" && value !== null && !Array.isArray(value);
128
+ }
129
+
130
+ function cloneHooksByEvent(hooksByEvent: CodexHooksByEvent): CodexHooksByEvent {
131
+ return Object.fromEntries(
132
+ Object.entries(hooksByEvent).map(([eventName, groups]) => [
133
+ eventName,
134
+ groups.map((group) => ({
135
+ ...group,
136
+ hooks: group.hooks.map((handler) => ({ ...handler })),
137
+ })),
138
+ ]),
139
+ );
140
+ }
141
+
142
+ function normalizeMatcherGroup(
143
+ value: unknown,
144
+ eventName: string,
145
+ index: number,
146
+ ): CodexMatcherGroup {
147
+ if (!isRecord(value)) {
148
+ throw new Error(`Invalid Codex hooks file: hooks.${eventName}[${index}] must be an object`);
149
+ }
150
+
151
+ if (!Array.isArray(value.hooks)) {
152
+ throw new Error(
153
+ `Invalid Codex hooks file: hooks.${eventName}[${index}].hooks must be an array`,
154
+ );
155
+ }
156
+
157
+ return {
158
+ ...value,
159
+ hooks: value.hooks.map((handler, handlerIndex) => {
160
+ if (!isRecord(handler)) {
161
+ throw new Error(
162
+ `Invalid Codex hooks file: hooks.${eventName}[${index}].hooks[${handlerIndex}] must be an object`,
163
+ );
164
+ }
165
+ return { ...handler };
166
+ }),
167
+ };
168
+ }
169
+
170
+ function normalizeEventMapHooks(value: unknown): CodexHooksByEvent {
171
+ if (!isRecord(value)) {
172
+ throw new Error(`Invalid Codex hooks file: "hooks" must be an object or legacy array`);
173
+ }
174
+
175
+ const hooksByEvent: CodexHooksByEvent = {};
176
+ for (const [eventName, groups] of Object.entries(value)) {
177
+ if (!Array.isArray(groups)) {
178
+ throw new Error(`Invalid Codex hooks file: hooks.${eventName} must be an array`);
179
+ }
180
+ hooksByEvent[eventName] = groups.map((group, index) =>
181
+ normalizeMatcherGroup(group, eventName, index),
182
+ );
183
+ }
184
+ return hooksByEvent;
185
+ }
186
+
187
+ function convertLegacyHooks(entries: unknown[]): CodexHooksByEvent {
188
+ const hooksByEvent: CodexHooksByEvent = {};
189
+
190
+ for (const [index, entry] of entries.entries()) {
191
+ if (!isRecord(entry) || typeof entry.event !== "string" || typeof entry.command !== "string") {
192
+ throw new Error(
193
+ `Invalid Codex hooks file: legacy hooks[${index}] must include string event and command`,
194
+ );
195
+ }
196
+
197
+ const legacyEntry = entry as LegacyCodexHookEntry;
198
+ const handler: CodexHookHandler = {
199
+ type: "command",
200
+ command: legacyEntry.command as string,
201
+ };
202
+
203
+ if (typeof legacyEntry.timeout_ms === "number" && Number.isFinite(legacyEntry.timeout_ms)) {
204
+ handler.timeout = Math.max(1, Math.ceil((legacyEntry.timeout_ms as number) / 1000));
205
+ }
206
+
207
+ if (legacyEntry._selftune === true) {
208
+ handler._selftune = true;
209
+ }
210
+
211
+ const matchers =
212
+ Array.isArray(legacyEntry.matchers) &&
213
+ legacyEntry.matchers.every((matcher) => typeof matcher === "string")
214
+ ? (legacyEntry.matchers as string[])
215
+ : [];
216
+
217
+ const groups = hooksByEvent[legacyEntry.event as string] ?? [];
218
+ if (matchers.length === 0) {
219
+ groups.push({ hooks: [{ ...handler }] });
220
+ } else {
221
+ for (const matcher of matchers) {
222
+ groups.push({ matcher, hooks: [{ ...handler }] });
223
+ }
224
+ }
225
+ hooksByEvent[legacyEntry.event as string] = groups;
226
+ }
227
+
228
+ return hooksByEvent;
229
+ }
230
+
231
+ function serializeHooksByEvent(hooksByEvent: CodexHooksByEvent): CodexHooksByEvent {
232
+ return Object.fromEntries(
233
+ Object.entries(hooksByEvent).map(([eventName, groups]) => [
234
+ eventName,
235
+ groups.map((group) => {
236
+ const { hooks, ...rest } = group;
237
+ return {
238
+ ...rest,
239
+ hooks: hooks.map((handler) => {
240
+ const { _selftune, ...serialized } = handler;
241
+ return serialized;
242
+ }),
243
+ };
244
+ }),
245
+ ]),
246
+ );
247
+ }
248
+
90
249
  /** Read and parse existing hooks.json, or return empty structure. */
91
- function readHooksFile(path: string): CodexHooksFile {
92
- if (!existsSync(path)) return { hooks: [] };
250
+ function readHooksFile(path: string): ParsedCodexHooksFile {
251
+ if (!existsSync(path)) return { hooksByEvent: {}, otherFields: {} };
93
252
  try {
94
253
  const raw = readFileSync(path, "utf-8").trim();
95
- if (!raw) return { hooks: [] };
96
- const parsed = JSON.parse(raw) as CodexHooksFile;
97
- if (parsed.hooks !== undefined && !Array.isArray(parsed.hooks)) {
98
- throw new Error(`Invalid Codex hooks file: "hooks" must be an array`);
254
+ if (!raw) return { hooksByEvent: {}, otherFields: {} };
255
+
256
+ const parsed = JSON.parse(raw) as unknown;
257
+ if (!isRecord(parsed)) {
258
+ throw new Error(`Invalid Codex hooks file: root must be an object`);
99
259
  }
100
- if (!Array.isArray(parsed.hooks)) parsed.hooks = [];
101
- return parsed;
260
+
261
+ const { hooks, ...otherFields } = parsed;
262
+ if (hooks === undefined) {
263
+ return { hooksByEvent: {}, otherFields };
264
+ }
265
+
266
+ if (Array.isArray(hooks)) {
267
+ return { hooksByEvent: convertLegacyHooks(hooks), otherFields };
268
+ }
269
+
270
+ return { hooksByEvent: normalizeEventMapHooks(hooks), otherFields };
102
271
  } catch (err) {
103
- throw new Error(`Failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`);
272
+ throw new Error(
273
+ `Failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
274
+ {
275
+ cause: err,
276
+ },
277
+ );
104
278
  }
105
279
  }
106
280
 
107
281
  /** Legacy command strings that identify selftune-installed hooks (before the _selftune marker). */
108
- const LEGACY_SELFTUNE_COMMANDS = [
282
+ const LEGACY_SELFTUNE_COMMANDS = new Set([
109
283
  "npx selftune codex hook",
110
284
  "npx -y selftune@latest codex hook",
111
285
  "npx -y selftune codex hook",
112
- ];
286
+ ]);
113
287
 
114
288
  /** Check if a hook entry was installed by selftune. */
115
- function isSelftuneHook(entry: CodexHookEntry): boolean {
289
+ function isSelftuneHook(entry: CodexHookHandler): boolean {
116
290
  if (entry._selftune === true) return true;
117
291
  // Exact match against known legacy commands only
118
- return typeof entry.command === "string" && LEGACY_SELFTUNE_COMMANDS.includes(entry.command);
292
+ if (typeof entry.command !== "string") return false;
293
+ return entry.command === HOOK_COMMAND || LEGACY_SELFTUNE_COMMANDS.has(entry.command);
294
+ }
295
+
296
+ function stripSelftuneHooks(existing: CodexHooksByEvent): {
297
+ hooksByEvent: CodexHooksByEvent;
298
+ removedCount: number;
299
+ } {
300
+ const hooksByEvent: CodexHooksByEvent = {};
301
+ let removedCount = 0;
302
+
303
+ for (const [eventName, groups] of Object.entries(existing)) {
304
+ const cleanedGroups: CodexMatcherGroup[] = [];
305
+
306
+ for (const group of groups) {
307
+ const preservedHooks = group.hooks.filter((handler) => !isSelftuneHook(handler));
308
+ removedCount += group.hooks.length - preservedHooks.length;
309
+ if (preservedHooks.length > 0) {
310
+ cleanedGroups.push({
311
+ ...group,
312
+ hooks: preservedHooks.map((handler) => ({ ...handler })),
313
+ });
314
+ }
315
+ }
316
+
317
+ if (cleanedGroups.length > 0) {
318
+ hooksByEvent[eventName] = cleanedGroups;
319
+ }
320
+ }
321
+
322
+ return { hooksByEvent, removedCount };
119
323
  }
120
324
 
121
325
  /** Merge selftune hooks into existing hooks, replacing any previous selftune entries. */
122
326
  export function mergeHooks(
123
- existing: CodexHookEntry[],
124
- incoming: CodexHookEntry[],
125
- ): CodexHookEntry[] {
126
- // Keep all non-selftune hooks
127
- const preserved = existing.filter((h) => !isSelftuneHook(h));
128
- // Append new selftune hooks
129
- return [...preserved, ...incoming];
327
+ existing: CodexHooksByEvent,
328
+ incoming: CodexHooksByEvent,
329
+ ): CodexHooksByEvent {
330
+ const { hooksByEvent } = stripSelftuneHooks(existing);
331
+ const merged = cloneHooksByEvent(hooksByEvent);
332
+
333
+ for (const [eventName, groups] of Object.entries(incoming)) {
334
+ merged[eventName] = [
335
+ ...(merged[eventName] ?? []),
336
+ ...cloneHooksByEvent({ [eventName]: groups })[eventName],
337
+ ];
338
+ }
339
+
340
+ return merged;
130
341
  }
131
342
 
132
343
  /** Remove all selftune hooks from the list. */
133
- export function removeSelftuneHooks(existing: CodexHookEntry[]): CodexHookEntry[] {
134
- return existing.filter((h) => !isSelftuneHook(h));
344
+ export function removeSelftuneHooks(existing: CodexHooksByEvent): CodexHooksByEvent {
345
+ return stripSelftuneHooks(existing).hooksByEvent;
135
346
  }
136
347
 
137
348
  // ---------------------------------------------------------------------------
@@ -150,13 +361,14 @@ export function installHooks(options: { dryRun?: boolean } = {}): InstallResult
150
361
  const hooksPath = getCodexHooksPath();
151
362
  const codexHome = getCodexHome();
152
363
  const hooksFile = readHooksFile(hooksPath);
153
- const existingHooks = hooksFile.hooks ?? [];
154
-
364
+ const existingHooks = hooksFile.hooksByEvent;
155
365
  const merged = mergeHooks(existingHooks, SELFTUNE_HOOKS);
366
+ const serializedExisting = serializeHooksByEvent(existingHooks);
367
+ const serializedMerged = serializeHooksByEvent(merged);
156
368
 
157
- // Check if anything changed
158
- const existingJson = JSON.stringify(existingHooks);
159
- const mergedJson = JSON.stringify(merged);
369
+ // Compare the persisted shape; _selftune markers are internal only.
370
+ const existingJson = JSON.stringify(serializedExisting);
371
+ const mergedJson = JSON.stringify(serializedMerged);
160
372
 
161
373
  if (existingJson === mergedJson) {
162
374
  return {
@@ -172,15 +384,27 @@ export function installHooks(options: { dryRun?: boolean } = {}): InstallResult
172
384
  if (!existsSync(codexHome)) {
173
385
  mkdirSync(codexHome, { recursive: true });
174
386
  }
175
- hooksFile.hooks = merged;
176
- writeFileSync(hooksPath, JSON.stringify(hooksFile, null, 2) + "\n", "utf-8");
387
+ writeFileSync(
388
+ hooksPath,
389
+ JSON.stringify(
390
+ {
391
+ ...hooksFile.otherFields,
392
+ hooks: serializedMerged,
393
+ },
394
+ null,
395
+ 2,
396
+ ) + "\n",
397
+ "utf-8",
398
+ );
177
399
  }
178
400
 
401
+ const { removedCount } = stripSelftuneHooks(existingHooks);
402
+
179
403
  return {
180
404
  hooksPath,
181
405
  action: "installed",
182
- hooksWritten: SELFTUNE_HOOKS.length,
183
- hooksRemoved: existingHooks.filter((h) => isSelftuneHook(h)).length,
406
+ hooksWritten: Object.keys(SELFTUNE_HOOKS).length,
407
+ hooksRemoved: removedCount,
184
408
  dryRun: options.dryRun ?? false,
185
409
  };
186
410
  }
@@ -188,10 +412,8 @@ export function installHooks(options: { dryRun?: boolean } = {}): InstallResult
188
412
  export function uninstallHooks(options: { dryRun?: boolean } = {}): InstallResult {
189
413
  const hooksPath = getCodexHooksPath();
190
414
  const hooksFile = readHooksFile(hooksPath);
191
- const existingHooks = hooksFile.hooks ?? [];
192
-
193
- const cleaned = removeSelftuneHooks(existingHooks);
194
- const removedCount = existingHooks.length - cleaned.length;
415
+ const existingHooks = hooksFile.hooksByEvent;
416
+ const { hooksByEvent: cleaned, removedCount } = stripSelftuneHooks(existingHooks);
195
417
 
196
418
  if (removedCount === 0) {
197
419
  return {
@@ -204,8 +426,18 @@ export function uninstallHooks(options: { dryRun?: boolean } = {}): InstallResul
204
426
  }
205
427
 
206
428
  if (!options.dryRun) {
207
- hooksFile.hooks = cleaned;
208
- writeFileSync(hooksPath, JSON.stringify(hooksFile, null, 2) + "\n", "utf-8");
429
+ writeFileSync(
430
+ hooksPath,
431
+ JSON.stringify(
432
+ {
433
+ ...hooksFile.otherFields,
434
+ hooks: serializeHooksByEvent(cleaned),
435
+ },
436
+ null,
437
+ 2,
438
+ ) + "\n",
439
+ "utf-8",
440
+ );
209
441
  }
210
442
 
211
443
  return {
@@ -225,9 +457,9 @@ export function uninstallHooks(options: { dryRun?: boolean } = {}): InstallResul
225
457
  * CLI entry point for `selftune codex install`.
226
458
  */
227
459
  export async function cliMain(): Promise<void> {
228
- const args = process.argv.slice(2);
229
- const dryRun = args.includes("--dry-run");
230
- const uninstall = args.includes("--uninstall");
460
+ const args = new Set(process.argv.slice(2));
461
+ const dryRun = args.has("--dry-run");
462
+ const uninstall = args.has("--uninstall");
231
463
 
232
464
  try {
233
465
  if (uninstall) {
@@ -140,6 +140,7 @@ interface OpenCodeAgentConfig {
140
140
 
141
141
  interface OpenCodeConfig {
142
142
  agent?: Record<string, OpenCodeAgentConfig>;
143
+ plugin?: string[];
143
144
  [key: string]: unknown;
144
145
  }
145
146
 
@@ -405,7 +406,7 @@ function doInstall(options: InstallOptions): void {
405
406
  // Clean up any legacy plugin array entries from previous installer versions
406
407
  if (Array.isArray(config.plugin)) {
407
408
  const before = config.plugin.length;
408
- config.plugin = (config.plugin as string[]).filter((p: string) => !p.includes(PLUGIN_FILENAME));
409
+ config.plugin = config.plugin.filter((p) => !p.includes(PLUGIN_FILENAME));
409
410
  if (config.plugin.length === 0) {
410
411
  delete config.plugin;
411
412
  }
@@ -446,9 +447,7 @@ function doUninstall(options: InstallOptions): void {
446
447
  // Remove legacy plugin array entries
447
448
  if (Array.isArray(config.plugin)) {
448
449
  const before = config.plugin.length;
449
- config.plugin = (config.plugin as string[]).filter(
450
- (p: string) => !p.includes(PLUGIN_FILENAME),
451
- );
450
+ config.plugin = config.plugin.filter((p) => !p.includes(PLUGIN_FILENAME));
452
451
  if (config.plugin.length === 0) {
453
452
  delete config.plugin;
454
453
  }