stagent 0.9.6 → 0.11.0

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 (396) hide show
  1. package/README.md +20 -44
  2. package/dist/cli.js +66 -18
  3. package/docs/.coverage-gaps.json +144 -56
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +12 -2
  6. package/docs/features/chat.md +40 -5
  7. package/docs/features/cost-usage.md +1 -1
  8. package/docs/features/documents.md +5 -2
  9. package/docs/features/inbox-notifications.md +10 -2
  10. package/docs/features/keyboard-navigation.md +12 -3
  11. package/docs/features/provider-runtimes.md +20 -2
  12. package/docs/features/schedules.md +32 -4
  13. package/docs/features/settings.md +28 -5
  14. package/docs/features/shared-components.md +7 -3
  15. package/docs/features/tables.md +11 -2
  16. package/docs/features/tool-permissions.md +6 -2
  17. package/docs/features/workflows.md +14 -4
  18. package/docs/index.md +1 -1
  19. package/docs/journeys/developer.md +39 -2
  20. package/docs/journeys/personal-use.md +32 -8
  21. package/docs/journeys/power-user.md +45 -14
  22. package/docs/journeys/work-use.md +17 -8
  23. package/docs/manifest.json +15 -15
  24. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
  25. package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
  26. package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
  27. package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
  28. package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
  29. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  30. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  31. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  32. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  33. package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
  34. package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
  35. package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
  36. package/next.config.mjs +1 -0
  37. package/package.json +3 -2
  38. package/src/__tests__/instrumentation-smoke.test.ts +15 -0
  39. package/src/app/analytics/page.tsx +1 -21
  40. package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
  41. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  42. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  43. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  44. package/src/app/api/chat/export/route.ts +52 -0
  45. package/src/app/api/chat/files/search/route.ts +50 -0
  46. package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
  47. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  48. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  49. package/src/app/api/environment/skills/route.ts +13 -0
  50. package/src/app/api/instance/config/route.ts +41 -0
  51. package/src/app/api/instance/init/route.ts +34 -0
  52. package/src/app/api/instance/upgrade/check/route.ts +26 -0
  53. package/src/app/api/instance/upgrade/route.ts +96 -0
  54. package/src/app/api/instance/upgrade/status/route.ts +35 -0
  55. package/src/app/api/memory/route.ts +0 -11
  56. package/src/app/api/notifications/route.ts +4 -2
  57. package/src/app/api/projects/[id]/route.ts +5 -155
  58. package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
  59. package/src/app/api/schedules/[id]/execute/route.ts +111 -0
  60. package/src/app/api/schedules/[id]/route.ts +9 -1
  61. package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
  62. package/src/app/api/schedules/route.ts +3 -12
  63. package/src/app/api/settings/chat/pins/route.ts +94 -0
  64. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  65. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  66. package/src/app/api/settings/environment/route.ts +26 -0
  67. package/src/app/api/settings/openai/login/route.ts +22 -0
  68. package/src/app/api/settings/openai/logout/route.ts +7 -0
  69. package/src/app/api/settings/openai/route.ts +21 -1
  70. package/src/app/api/settings/providers/route.ts +35 -8
  71. package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
  72. package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
  73. package/src/app/api/tables/[id]/enrich/route.ts +147 -0
  74. package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
  75. package/src/app/api/tasks/[id]/execute/route.ts +52 -33
  76. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  77. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  78. package/src/app/api/workflows/[id]/resume/route.ts +59 -0
  79. package/src/app/api/workflows/[id]/status/route.ts +22 -8
  80. package/src/app/api/workspace/context/route.ts +2 -0
  81. package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
  82. package/src/app/chat/page.tsx +11 -0
  83. package/src/app/documents/page.tsx +4 -1
  84. package/src/app/inbox/page.tsx +12 -5
  85. package/src/app/layout.tsx +42 -21
  86. package/src/app/page.tsx +0 -2
  87. package/src/app/settings/page.tsx +8 -9
  88. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  89. package/src/components/chat/__tests__/chat-session-provider.test.tsx +573 -0
  90. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  91. package/src/components/chat/capability-banner.tsx +68 -0
  92. package/src/components/chat/chat-command-popover.tsx +670 -49
  93. package/src/components/chat/chat-input.tsx +104 -10
  94. package/src/components/chat/chat-message.tsx +12 -3
  95. package/src/components/chat/chat-session-provider.tsx +790 -0
  96. package/src/components/chat/chat-shell.tsx +151 -401
  97. package/src/components/chat/command-tab-bar.tsx +68 -0
  98. package/src/components/chat/conversation-template-picker.tsx +421 -0
  99. package/src/components/chat/help-dialog.tsx +39 -0
  100. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  101. package/src/components/chat/skill-row.tsx +147 -0
  102. package/src/components/documents/document-browser.tsx +37 -19
  103. package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
  104. package/src/components/instance/instance-section.tsx +382 -0
  105. package/src/components/instance/upgrade-badge.tsx +219 -0
  106. package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
  107. package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
  108. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  109. package/src/components/notifications/batch-proposal-review.tsx +20 -5
  110. package/src/components/notifications/inbox-list.tsx +11 -2
  111. package/src/components/notifications/notification-item.tsx +56 -2
  112. package/src/components/notifications/pending-approval-host.tsx +56 -37
  113. package/src/components/notifications/permission-response-actions.tsx +155 -1
  114. package/src/components/schedules/schedule-create-sheet.tsx +19 -1
  115. package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
  116. package/src/components/schedules/schedule-form.tsx +31 -0
  117. package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
  118. package/src/components/settings/auth-method-selector.tsx +19 -4
  119. package/src/components/settings/auth-status-badge.tsx +28 -3
  120. package/src/components/settings/environment-section.tsx +102 -0
  121. package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
  122. package/src/components/settings/openai-runtime-section.tsx +7 -1
  123. package/src/components/settings/providers-runtimes-section.tsx +138 -19
  124. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  125. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  126. package/src/components/shared/app-sidebar.tsx +4 -3
  127. package/src/components/shared/command-palette.tsx +266 -7
  128. package/src/components/shared/filter-hint.tsx +70 -0
  129. package/src/components/shared/filter-input.tsx +59 -0
  130. package/src/components/shared/saved-searches-manager.tsx +199 -0
  131. package/src/components/shared/theme-toggle.tsx +5 -24
  132. package/src/components/shared/workspace-indicator.tsx +61 -2
  133. package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
  134. package/src/components/tables/table-create-sheet.tsx +4 -0
  135. package/src/components/tables/table-enrichment-runs.tsx +103 -0
  136. package/src/components/tables/table-enrichment-sheet.tsx +538 -0
  137. package/src/components/tables/table-spreadsheet.tsx +29 -5
  138. package/src/components/tables/table-toolbar.tsx +10 -1
  139. package/src/components/tasks/kanban-board.tsx +1 -0
  140. package/src/components/tasks/kanban-column.tsx +53 -14
  141. package/src/components/tasks/task-bento-grid.tsx +31 -2
  142. package/src/components/tasks/task-card.tsx +29 -3
  143. package/src/components/tasks/task-chip-bar.tsx +54 -1
  144. package/src/components/tasks/task-result-renderer.tsx +1 -1
  145. package/src/components/workflows/delay-step-body.tsx +109 -0
  146. package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
  147. package/src/components/workflows/loop-status-view.tsx +1 -1
  148. package/src/components/workflows/shared/step-result.tsx +78 -0
  149. package/src/components/workflows/shared/workflow-header.tsx +141 -0
  150. package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
  151. package/src/components/workflows/swarm-dashboard.tsx +2 -15
  152. package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
  153. package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
  154. package/src/components/workflows/workflow-form-view.tsx +133 -16
  155. package/src/components/workflows/workflow-status-view.tsx +30 -740
  156. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  157. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  158. package/src/hooks/use-active-skills.ts +110 -0
  159. package/src/hooks/use-chat-autocomplete.ts +120 -7
  160. package/src/hooks/use-enriched-skills.ts +19 -0
  161. package/src/hooks/use-pinned-entries.ts +104 -0
  162. package/src/hooks/use-recent-user-messages.ts +19 -0
  163. package/src/hooks/use-saved-searches.ts +142 -0
  164. package/src/instrumentation-node.ts +94 -0
  165. package/src/instrumentation.ts +4 -48
  166. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  167. package/src/lib/agents/__tests__/claude-agent.test.ts +212 -0
  168. package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
  169. package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
  170. package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
  171. package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
  172. package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
  173. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  174. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  175. package/src/lib/agents/claude-agent.ts +217 -21
  176. package/src/lib/agents/execution-manager.ts +0 -35
  177. package/src/lib/agents/handoff/bus.ts +2 -2
  178. package/src/lib/agents/learned-context.ts +0 -12
  179. package/src/lib/agents/learning-session.ts +18 -5
  180. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  181. package/src/lib/agents/profiles/__tests__/registry.test.ts +53 -4
  182. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +97 -0
  183. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +36 -0
  184. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  185. package/src/lib/agents/profiles/registry.ts +18 -0
  186. package/src/lib/agents/profiles/types.ts +7 -1
  187. package/src/lib/agents/router.ts +3 -6
  188. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  189. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  190. package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
  191. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  192. package/src/lib/agents/runtime/catalog.ts +121 -0
  193. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  194. package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
  195. package/src/lib/agents/runtime/execution-target.ts +456 -0
  196. package/src/lib/agents/runtime/index.ts +4 -0
  197. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  198. package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
  199. package/src/lib/agents/runtime/openai-codex.ts +64 -60
  200. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  201. package/src/lib/agents/runtime/types.ts +8 -0
  202. package/src/lib/agents/task-dispatch.ts +220 -0
  203. package/src/lib/agents/tool-permissions.ts +16 -1
  204. package/src/lib/book/chapter-mapping.ts +11 -0
  205. package/src/lib/book/content.ts +10 -0
  206. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  207. package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
  208. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  209. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  210. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  211. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  212. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  213. package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
  214. package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
  215. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  216. package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
  217. package/src/lib/chat/__tests__/types.test.ts +28 -0
  218. package/src/lib/chat/active-skills.ts +31 -0
  219. package/src/lib/chat/active-streams.ts +27 -0
  220. package/src/lib/chat/clean-filter-input.ts +30 -0
  221. package/src/lib/chat/codex-engine.ts +46 -24
  222. package/src/lib/chat/command-tabs.ts +61 -0
  223. package/src/lib/chat/context-builder.ts +146 -4
  224. package/src/lib/chat/dismissals.ts +73 -0
  225. package/src/lib/chat/engine.ts +159 -18
  226. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  227. package/src/lib/chat/files/expand-mention.ts +76 -0
  228. package/src/lib/chat/files/search.ts +99 -0
  229. package/src/lib/chat/reconcile.ts +117 -0
  230. package/src/lib/chat/skill-composition.ts +210 -0
  231. package/src/lib/chat/skill-conflict.ts +105 -0
  232. package/src/lib/chat/stagent-tools.ts +7 -19
  233. package/src/lib/chat/stream-telemetry.ts +137 -0
  234. package/src/lib/chat/suggested-prompts.ts +28 -1
  235. package/src/lib/chat/system-prompt.ts +48 -1
  236. package/src/lib/chat/tool-catalog.ts +35 -4
  237. package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
  238. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  239. package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
  240. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  241. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  242. package/src/lib/chat/tools/__tests__/task-tools.test.ts +399 -0
  243. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +351 -0
  244. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  245. package/src/lib/chat/tools/document-tools.ts +29 -13
  246. package/src/lib/chat/tools/helpers.ts +41 -0
  247. package/src/lib/chat/tools/notification-tools.ts +9 -5
  248. package/src/lib/chat/tools/profile-tools.ts +120 -23
  249. package/src/lib/chat/tools/project-tools.ts +33 -0
  250. package/src/lib/chat/tools/schedule-tools.ts +44 -11
  251. package/src/lib/chat/tools/skill-tools.ts +183 -0
  252. package/src/lib/chat/tools/table-tools.ts +71 -0
  253. package/src/lib/chat/tools/task-tools.ts +89 -21
  254. package/src/lib/chat/tools/workflow-tools.ts +275 -32
  255. package/src/lib/chat/types.ts +15 -0
  256. package/src/lib/constants/settings.ts +10 -18
  257. package/src/lib/data/__tests__/clear.test.ts +56 -2
  258. package/src/lib/data/clear.ts +17 -16
  259. package/src/lib/data/delete-project.ts +171 -0
  260. package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
  261. package/src/lib/db/bootstrap.ts +62 -16
  262. package/src/lib/db/index.ts +5 -0
  263. package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
  264. package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
  265. package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
  266. package/src/lib/db/migrations/0026_drop_license.sql +3 -0
  267. package/src/lib/db/migrations/meta/_journal.json +21 -0
  268. package/src/lib/db/schema.ts +94 -23
  269. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  270. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  271. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  272. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  273. package/src/lib/environment/data.ts +9 -0
  274. package/src/lib/environment/list-skills.ts +176 -0
  275. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  276. package/src/lib/environment/parsers/skill.ts +26 -5
  277. package/src/lib/environment/profile-generator.ts +54 -0
  278. package/src/lib/environment/skill-enrichment.ts +106 -0
  279. package/src/lib/environment/skill-recommendations.ts +66 -0
  280. package/src/lib/environment/workspace-context.ts +13 -1
  281. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  282. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  283. package/src/lib/filters/parse.ts +86 -0
  284. package/src/lib/import/dedup.ts +4 -54
  285. package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
  286. package/src/lib/instance/__tests__/detect.test.ts +115 -0
  287. package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
  288. package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
  289. package/src/lib/instance/__tests__/settings.test.ts +83 -0
  290. package/src/lib/instance/__tests__/upgrade-poller.test.ts +181 -0
  291. package/src/lib/instance/bootstrap.ts +270 -0
  292. package/src/lib/instance/detect.ts +49 -0
  293. package/src/lib/instance/fingerprint.ts +76 -0
  294. package/src/lib/instance/git-ops.ts +95 -0
  295. package/src/lib/instance/settings.ts +61 -0
  296. package/src/lib/instance/types.ts +77 -0
  297. package/src/lib/instance/upgrade-poller.ts +205 -0
  298. package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
  299. package/src/lib/notifications/visibility.ts +33 -0
  300. package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
  301. package/src/lib/schedules/__tests__/config.test.ts +62 -0
  302. package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
  303. package/src/lib/schedules/__tests__/integration.test.ts +82 -0
  304. package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
  305. package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
  306. package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
  307. package/src/lib/schedules/collision-check.ts +105 -0
  308. package/src/lib/schedules/config.ts +53 -0
  309. package/src/lib/schedules/scheduler.ts +236 -17
  310. package/src/lib/schedules/slot-claim.ts +105 -0
  311. package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
  312. package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
  313. package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
  314. package/src/lib/settings/openai-auth.ts +105 -10
  315. package/src/lib/settings/openai-login-manager.ts +260 -0
  316. package/src/lib/settings/runtime-setup.ts +14 -4
  317. package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
  318. package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
  319. package/src/lib/tables/enrichment-planner.ts +454 -0
  320. package/src/lib/tables/enrichment.ts +328 -0
  321. package/src/lib/tables/query-builder.ts +5 -2
  322. package/src/lib/tables/trigger-evaluator.ts +3 -2
  323. package/src/lib/theme.ts +71 -0
  324. package/src/lib/usage/ledger.ts +2 -18
  325. package/src/lib/util/__tests__/similarity.test.ts +106 -0
  326. package/src/lib/util/similarity.ts +77 -0
  327. package/src/lib/utils/format-timestamp.ts +24 -0
  328. package/src/lib/utils/stagent-paths.ts +12 -0
  329. package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
  330. package/src/lib/validators/__tests__/settings.test.ts +10 -0
  331. package/src/lib/validators/blueprint.ts +70 -9
  332. package/src/lib/validators/profile.ts +2 -2
  333. package/src/lib/validators/settings.ts +3 -1
  334. package/src/lib/workflows/__tests__/delay.test.ts +196 -0
  335. package/src/lib/workflows/__tests__/engine.test.ts +8 -0
  336. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  337. package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
  338. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  339. package/src/lib/workflows/blueprints/instantiator.ts +22 -1
  340. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  341. package/src/lib/workflows/blueprints/types.ts +16 -2
  342. package/src/lib/workflows/delay.ts +106 -0
  343. package/src/lib/workflows/engine.ts +212 -7
  344. package/src/lib/workflows/loop-executor.ts +349 -24
  345. package/src/lib/workflows/post-action.ts +91 -0
  346. package/src/lib/workflows/types.ts +166 -1
  347. package/src/test/setup.ts +10 -0
  348. package/src/app/api/license/checkout/route.ts +0 -28
  349. package/src/app/api/license/portal/route.ts +0 -26
  350. package/src/app/api/license/route.ts +0 -89
  351. package/src/app/api/license/usage/route.ts +0 -63
  352. package/src/app/api/marketplace/browse/route.ts +0 -15
  353. package/src/app/api/marketplace/import/route.ts +0 -28
  354. package/src/app/api/marketplace/publish/route.ts +0 -40
  355. package/src/app/api/onboarding/email/route.ts +0 -53
  356. package/src/app/api/settings/telemetry/route.ts +0 -14
  357. package/src/app/api/sync/export/route.ts +0 -54
  358. package/src/app/api/sync/restore/route.ts +0 -37
  359. package/src/app/api/sync/sessions/route.ts +0 -24
  360. package/src/app/auth/callback/route.ts +0 -73
  361. package/src/app/marketplace/page.tsx +0 -19
  362. package/src/components/analytics/analytics-gate-card.tsx +0 -101
  363. package/src/components/marketplace/blueprint-card.tsx +0 -61
  364. package/src/components/marketplace/marketplace-browser.tsx +0 -131
  365. package/src/components/onboarding/email-capture-card.tsx +0 -104
  366. package/src/components/settings/activation-form.tsx +0 -95
  367. package/src/components/settings/cloud-account-section.tsx +0 -147
  368. package/src/components/settings/cloud-sync-section.tsx +0 -155
  369. package/src/components/settings/subscription-section.tsx +0 -410
  370. package/src/components/settings/telemetry-section.tsx +0 -80
  371. package/src/components/shared/premium-gate-overlay.tsx +0 -50
  372. package/src/components/shared/schedule-gate-dialog.tsx +0 -64
  373. package/src/components/shared/upgrade-banner.tsx +0 -112
  374. package/src/hooks/use-supabase-auth.ts +0 -79
  375. package/src/lib/billing/email.ts +0 -54
  376. package/src/lib/billing/products.ts +0 -80
  377. package/src/lib/billing/stripe.ts +0 -101
  378. package/src/lib/cloud/supabase-browser.ts +0 -32
  379. package/src/lib/cloud/supabase-client.ts +0 -56
  380. package/src/lib/license/__tests__/features.test.ts +0 -56
  381. package/src/lib/license/__tests__/key-format.test.ts +0 -88
  382. package/src/lib/license/__tests__/manager.test.ts +0 -64
  383. package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
  384. package/src/lib/license/cloud-validation.ts +0 -60
  385. package/src/lib/license/features.ts +0 -44
  386. package/src/lib/license/key-format.ts +0 -101
  387. package/src/lib/license/limit-check.ts +0 -111
  388. package/src/lib/license/limit-queries.ts +0 -51
  389. package/src/lib/license/manager.ts +0 -345
  390. package/src/lib/license/notifications.ts +0 -59
  391. package/src/lib/license/tier-limits.ts +0 -71
  392. package/src/lib/marketplace/marketplace-client.ts +0 -107
  393. package/src/lib/sync/cloud-sync.ts +0 -235
  394. package/src/lib/telemetry/conversion-events.ts +0 -71
  395. package/src/lib/telemetry/queue.ts +0 -122
  396. package/src/lib/validators/license.ts +0 -33
@@ -29,6 +29,12 @@ export const tasks = sqliteTable(
29
29
  .notNull(),
30
30
  assignedAgent: text("assigned_agent"),
31
31
  agentProfile: text("agent_profile"),
32
+ /** Runtime actually used for the most recent execution attempt. */
33
+ effectiveRuntimeId: text("effective_runtime_id"),
34
+ /** Model actually used for the most recent execution attempt. */
35
+ effectiveModelId: text("effective_model_id"),
36
+ /** Human-readable reason when execution fell back from the requested runtime/model. */
37
+ runtimeFallbackReason: text("runtime_fallback_reason"),
32
38
  priority: integer("priority").default(2).notNull(),
33
39
  result: text("result"),
34
40
  sessionId: text("session_id"),
@@ -40,6 +46,21 @@ export const tasks = sqliteTable(
40
46
  workflowRunNumber: integer("workflow_run_number"),
41
47
  /** Resolved per-task budget cap in USD — set by workflow engine for child tasks */
42
48
  maxBudgetUsd: real("max_budget_usd"),
49
+ /** When the slot for this task was atomically claimed */
50
+ slotClaimedAt: integer("slot_claimed_at", { mode: "timestamp" }),
51
+ /** Wall-clock expiry; reaper aborts tasks whose lease has passed */
52
+ leaseExpiresAt: integer("lease_expires_at", { mode: "timestamp" }),
53
+ /**
54
+ * Explicit terminal-state reason written by the runtime adapter at
55
+ * failure/abort transitions (e.g. 'turn_limit_exceeded', 'lease_expired',
56
+ * 'aborted', 'sdk_error'). Distinct from `result` — `result` holds the
57
+ * agent's final output text, while `failureReason` holds a machine-readable
58
+ * classifier that drives scheduler failure-streak logic without re-parsing
59
+ * error prose.
60
+ */
61
+ failureReason: text("failure_reason"),
62
+ /** Per-task turn budget copied from schedules.maxTurns at firing time */
63
+ maxTurns: integer("max_turns"),
43
64
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
44
65
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
45
66
  },
@@ -49,6 +70,7 @@ export const tasks = sqliteTable(
49
70
  index("idx_tasks_workflow_id").on(table.workflowId),
50
71
  index("idx_tasks_schedule_id").on(table.scheduleId),
51
72
  index("idx_tasks_agent_profile").on(table.agentProfile),
73
+ index("idx_tasks_running_scheduled").on(table.status, table.sourceType, table.leaseExpiresAt),
52
74
  ]
53
75
  );
54
76
 
@@ -65,6 +87,13 @@ export const workflows = sqliteTable("workflows", {
65
87
  runNumber: integer("run_number").default(0).notNull(),
66
88
  /** Runtime to use for all steps (nullable — falls back to system default) */
67
89
  runtimeId: text("runtime_id"),
90
+ /**
91
+ * Epoch millisecond timestamp at which a paused (delayed) workflow is due to resume.
92
+ * Null for workflows that are not waiting on a delay step. Indexed via
93
+ * idx_workflows_resume_at (partial index on non-null values) so the scheduler tick
94
+ * can efficiently find due workflows. See features/workflow-step-delays.md.
95
+ */
96
+ resumeAt: integer("resume_at"),
68
97
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
69
98
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
70
99
  });
@@ -210,6 +239,20 @@ export const schedules = sqliteTable(
210
239
  failureStreak: integer("failure_streak").default(0).notNull(),
211
240
  /** Detected reason for the most recent failure (turn_limit_exceeded, timeout, etc.) */
212
241
  lastFailureReason: text("last_failure_reason"),
242
+ /** Hard cap on turns per firing; NULL inherits the global MAX_TURNS setting */
243
+ maxTurns: integer("max_turns"),
244
+ /** Timestamp when maxTurns was last edited — drives first-breach grace */
245
+ maxTurnsSetAt: integer("max_turns_set_at", { mode: "timestamp" }),
246
+ /** Wall-clock lease override in seconds; NULL inherits global default (1200s) */
247
+ maxRunDurationSec: integer("max_run_duration_sec"),
248
+ /**
249
+ * Counter separate from failureStreak — increments only on maxTurns breach.
250
+ * Reset to 0 on any non-breach outcome (successful run, generic failure, or
251
+ * first-breach grace window after maxTurnsSetAt). Auto-pause at 5. This
252
+ * higher threshold + grace window protects users from tripping auto-pause
253
+ * via a misconfigured maxTurns edit.
254
+ */
255
+ turnBudgetBreachStreak: integer("turn_budget_breach_streak").default(0).notNull(),
213
256
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
214
257
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
215
258
  },
@@ -307,6 +350,7 @@ export const usageLedger = sqliteTable(
307
350
  "context_summarization",
308
351
  "chat_turn",
309
352
  "profile_assist",
353
+ "manual_force_bypass",
310
354
  ],
311
355
  }).notNull(),
312
356
  runtimeId: text("runtime_id").notNull(),
@@ -504,6 +548,26 @@ export const conversations = sqliteTable(
504
548
  .notNull(),
505
549
  sessionId: text("session_id"),
506
550
  contextScope: text("context_scope"), // JSON: context config overrides
551
+ /**
552
+ * Opaque skill ID of the Stagent-activated skill for this conversation.
553
+ * When set, the context builder injects that skill's SKILL.md into the
554
+ * Tier 0 system prompt every turn. Primary use case is Ollama (no
555
+ * SDK-native skill support); Claude and Codex can also use it as a
556
+ * programmatic skill-activation path alongside their native Skill tools.
557
+ *
558
+ * See `features/chat-ollama-native-skills.md`.
559
+ */
560
+ activeSkillId: text("active_skill_id"),
561
+ /**
562
+ * Composition v1 — array of additionally-activated skill IDs (beyond
563
+ * the legacy `activeSkillId`). Default `[]`. Read paths merge legacy
564
+ * + new and dedupe via `mergeActiveSkillIds`. Stored as JSON text.
565
+ *
566
+ * See `features/chat-skill-composition.md`.
567
+ */
568
+ activeSkillIds: text("active_skill_ids", { mode: "json" })
569
+ .$type<string[]>()
570
+ .default([] as unknown as string[]),
507
571
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
508
572
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
509
573
  },
@@ -1162,29 +1226,6 @@ export const snapshots = sqliteTable(
1162
1226
 
1163
1227
  export type SnapshotRow = InferSelectModel<typeof snapshots>;
1164
1228
 
1165
- // ── License ──────────────────────────────────────────────────────────
1166
-
1167
- export const license = sqliteTable("license", {
1168
- id: text("id").primaryKey(),
1169
- supabaseUserId: text("supabase_user_id"),
1170
- tier: text("tier", { enum: ["community", "solo", "operator", "scale"] })
1171
- .default("community")
1172
- .notNull(),
1173
- status: text("status", { enum: ["active", "inactive", "grace"] })
1174
- .default("inactive")
1175
- .notNull(),
1176
- email: text("email"),
1177
- activatedAt: integer("activated_at", { mode: "timestamp" }),
1178
- expiresAt: integer("expires_at", { mode: "timestamp" }),
1179
- lastValidatedAt: integer("last_validated_at", { mode: "timestamp" }),
1180
- gracePeriodExpiresAt: integer("grace_period_expires_at", { mode: "timestamp" }),
1181
- encryptedToken: text("encrypted_token"),
1182
- createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
1183
- updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
1184
- });
1185
-
1186
- export type LicenseRow = InferSelectModel<typeof license>;
1187
-
1188
1229
  // ── Workflow Execution Stats ─────────────────────────────────────────
1189
1230
 
1190
1231
  export const workflowExecutionStats = sqliteTable("workflow_execution_stats", {
@@ -1212,3 +1253,33 @@ export const workflowExecutionStats = sqliteTable("workflow_execution_stats", {
1212
1253
  });
1213
1254
 
1214
1255
  export type WorkflowExecutionStatsRow = InferSelectModel<typeof workflowExecutionStats>;
1256
+
1257
+ // ── Schedule Firing Metrics ───────────────────────────────────────────
1258
+
1259
+ export const scheduleFiringMetrics = sqliteTable(
1260
+ "schedule_firing_metrics",
1261
+ {
1262
+ id: text("id").primaryKey(),
1263
+ scheduleId: text("schedule_id")
1264
+ .references(() => schedules.id)
1265
+ .notNull(),
1266
+ taskId: text("task_id").references(() => tasks.id),
1267
+ firedAt: integer("fired_at", { mode: "timestamp" }).notNull(),
1268
+ slotClaimedAt: integer("slot_claimed_at", { mode: "timestamp" }),
1269
+ completedAt: integer("completed_at", { mode: "timestamp" }),
1270
+ slotWaitMs: integer("slot_wait_ms"),
1271
+ durationMs: integer("duration_ms"),
1272
+ turnCount: integer("turn_count"),
1273
+ maxTurnsAtFiring: integer("max_turns_at_firing"),
1274
+ eventLoopLagMs: real("event_loop_lag_ms"),
1275
+ peakRssMb: integer("peak_rss_mb"),
1276
+ chatStreamsActive: integer("chat_streams_active"),
1277
+ concurrentSchedules: integer("concurrent_schedules"),
1278
+ failureReason: text("failure_reason"),
1279
+ },
1280
+ (table) => [
1281
+ index("idx_sfm_schedule_time").on(table.scheduleId, table.firedAt),
1282
+ ]
1283
+ );
1284
+
1285
+ export type ScheduleFiringMetricRow = InferSelectModel<typeof scheduleFiringMetrics>;
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("@/lib/settings/helpers", () => ({
4
+ getSettingSync: vi.fn(),
5
+ }));
6
+
7
+ vi.mock("../profile-linker", () => ({
8
+ linkArtifactsToProfiles: vi.fn(),
9
+ }));
10
+
11
+ // The module under test depends on suggestProfilesTiered + createProfileFromSuggestion,
12
+ // which live in the same file. We test via the real function but stub its
13
+ // collaborators (getArtifacts + listProfiles) through their imported modules.
14
+ vi.mock("../data", () => ({
15
+ getArtifacts: vi.fn(() => []),
16
+ }));
17
+
18
+ vi.mock("@/lib/agents/profiles/registry", () => ({
19
+ listProfiles: vi.fn(() => []),
20
+ createProfile: vi.fn(),
21
+ }));
22
+
23
+ import { autoPromoteUnlinkedSkills } from "../profile-generator";
24
+ import { getSettingSync } from "@/lib/settings/helpers";
25
+ import { linkArtifactsToProfiles } from "../profile-linker";
26
+ import { getArtifacts } from "../data";
27
+ import { createProfile } from "@/lib/agents/profiles/registry";
28
+
29
+ const mockGetSettingSync = getSettingSync as ReturnType<typeof vi.fn>;
30
+ const mockLinker = linkArtifactsToProfiles as ReturnType<typeof vi.fn>;
31
+ const mockGetArtifacts = getArtifacts as ReturnType<typeof vi.fn>;
32
+ const mockCreateProfile = createProfile as ReturnType<typeof vi.fn>;
33
+
34
+ function unlinkedSkill(name: string) {
35
+ return {
36
+ id: `art-${name}`,
37
+ scanId: "scan-1",
38
+ tool: "claude-code",
39
+ category: "skill",
40
+ scope: "user",
41
+ name,
42
+ relPath: `${name}/SKILL.md`,
43
+ absPath: `/home/u/.claude/skills/${name}/SKILL.md`,
44
+ contentHash: "abc",
45
+ preview: `---\nname: ${name}\ndescription: A ${name} skill\n---\n`,
46
+ metadata: null,
47
+ sizeBytes: 100,
48
+ modifiedAt: new Date(),
49
+ createdAt: new Date(),
50
+ linkedProfileId: null,
51
+ };
52
+ }
53
+
54
+ describe("autoPromoteUnlinkedSkills", () => {
55
+ beforeEach(() => {
56
+ vi.clearAllMocks();
57
+ });
58
+
59
+ it("returns empty result when setting is disabled", () => {
60
+ mockGetSettingSync.mockReturnValue("false");
61
+ mockGetArtifacts.mockReturnValue([unlinkedSkill("alpha")]);
62
+
63
+ const result = autoPromoteUnlinkedSkills("scan-1");
64
+
65
+ expect(result.created).toEqual([]);
66
+ expect(mockCreateProfile).not.toHaveBeenCalled();
67
+ expect(mockLinker).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it("returns empty result when setting is missing (default off)", () => {
71
+ mockGetSettingSync.mockReturnValue(null);
72
+ mockGetArtifacts.mockReturnValue([unlinkedSkill("alpha")]);
73
+
74
+ const result = autoPromoteUnlinkedSkills("scan-1");
75
+
76
+ expect(result.created).toEqual([]);
77
+ expect(mockCreateProfile).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("creates profiles for every Tier 2 suggestion and re-links when enabled", () => {
81
+ mockGetSettingSync.mockReturnValue("true");
82
+ mockGetArtifacts.mockReturnValue([
83
+ unlinkedSkill("alpha"),
84
+ unlinkedSkill("beta"),
85
+ ]);
86
+
87
+ const result = autoPromoteUnlinkedSkills("scan-1");
88
+
89
+ expect(mockCreateProfile).toHaveBeenCalledTimes(2);
90
+ expect(result.created).toHaveLength(2);
91
+ expect(result.errors).toHaveLength(0);
92
+ expect(mockLinker).toHaveBeenCalledWith("scan-1");
93
+ });
94
+
95
+ it("counts 'already exists' failures as skipped, not errors", () => {
96
+ mockGetSettingSync.mockReturnValue("true");
97
+ mockGetArtifacts.mockReturnValue([unlinkedSkill("gamma")]);
98
+ mockCreateProfile.mockImplementationOnce(() => {
99
+ throw new Error("profile already exists");
100
+ });
101
+
102
+ const result = autoPromoteUnlinkedSkills("scan-1");
103
+
104
+ expect(result.created).toEqual([]);
105
+ expect(result.skipped).toHaveLength(1);
106
+ expect(result.errors).toEqual([]);
107
+ // No re-link when nothing was created
108
+ expect(mockLinker).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it("records non-duplicate errors and still re-links when some profiles succeeded", () => {
112
+ mockGetSettingSync.mockReturnValue("true");
113
+ mockGetArtifacts.mockReturnValue([
114
+ unlinkedSkill("delta"),
115
+ unlinkedSkill("epsilon"),
116
+ ]);
117
+ mockCreateProfile
118
+ .mockImplementationOnce(() => {
119
+ /* succeeds */
120
+ })
121
+ .mockImplementationOnce(() => {
122
+ throw new Error("disk full");
123
+ });
124
+
125
+ const result = autoPromoteUnlinkedSkills("scan-1");
126
+
127
+ expect(result.created).toHaveLength(1);
128
+ expect(result.errors).toHaveLength(1);
129
+ expect(result.errors[0].message).toBe("disk full");
130
+ expect(mockLinker).toHaveBeenCalledWith("scan-1");
131
+ });
132
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ vi.mock("../data", () => ({
4
+ getLatestScan: () => ({ id: "scan-1" }),
5
+ getArtifacts: () => [
6
+ {
7
+ id: "art-1",
8
+ scanId: "scan-1",
9
+ category: "skill",
10
+ tool: "claude-code",
11
+ scope: "user",
12
+ name: "code-reviewer",
13
+ relPath: ".claude/skills/code-reviewer",
14
+ absPath: "/u/.claude/skills/code-reviewer",
15
+ preview: "Review PRs",
16
+ sizeBytes: 100,
17
+ modifiedAt: new Date("2026-01-01T00:00:00Z").getTime(),
18
+ linkedProfileId: "code-reviewer-profile",
19
+ contentHash: "h",
20
+ metadata: null,
21
+ createdAt: new Date(),
22
+ },
23
+ {
24
+ id: "art-2",
25
+ scanId: "scan-1",
26
+ category: "skill",
27
+ tool: "codex",
28
+ scope: "user",
29
+ name: "code-reviewer",
30
+ relPath: ".agents/skills/code-reviewer",
31
+ absPath: "/u/.agents/skills/code-reviewer",
32
+ preview: "Review PRs",
33
+ sizeBytes: 100,
34
+ modifiedAt: new Date("2026-01-01T00:00:00Z").getTime(),
35
+ linkedProfileId: null,
36
+ contentHash: "h",
37
+ metadata: null,
38
+ createdAt: new Date(),
39
+ },
40
+ ],
41
+ }));
42
+
43
+ import { listSkillsEnriched } from "../list-skills";
44
+
45
+ describe("listSkillsEnriched", () => {
46
+ it("returns enriched skills with syncStatus and linkedProfileId populated", () => {
47
+ const nowMs = new Date("2026-04-14T00:00:00Z").getTime();
48
+ const enriched = listSkillsEnriched({ nowMs });
49
+ expect(enriched).toHaveLength(1);
50
+ expect(enriched[0].name).toBe("code-reviewer");
51
+ expect(enriched[0].syncStatus).toBe("synced");
52
+ expect(enriched[0].linkedProfileId).toBe("code-reviewer-profile");
53
+ expect(enriched[0].healthScore).toBe("healthy");
54
+ });
55
+ });
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ computeHealthScore,
4
+ computeSyncStatus,
5
+ type HealthScore,
6
+ type SyncStatus,
7
+ } from "../skill-enrichment";
8
+
9
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
10
+
11
+ describe("computeHealthScore", () => {
12
+ const NOW = new Date("2026-04-14T00:00:00Z").getTime();
13
+
14
+ it("returns 'healthy' for artifacts modified in the last 6 months", () => {
15
+ expect(computeHealthScore(NOW - 30 * MS_PER_DAY, NOW)).toBe("healthy");
16
+ expect(computeHealthScore(NOW - 179 * MS_PER_DAY, NOW)).toBe("healthy");
17
+ });
18
+
19
+ it("returns 'stale' for artifacts between 6 and 12 months old", () => {
20
+ expect(computeHealthScore(NOW - 200 * MS_PER_DAY, NOW)).toBe("stale");
21
+ expect(computeHealthScore(NOW - 364 * MS_PER_DAY, NOW)).toBe("stale");
22
+ });
23
+
24
+ it("returns 'aging' for artifacts over 12 months old", () => {
25
+ expect(computeHealthScore(NOW - 400 * MS_PER_DAY, NOW)).toBe("aging");
26
+ });
27
+
28
+ it("returns 'unknown' when modifiedAt is null", () => {
29
+ expect(computeHealthScore(null, NOW)).toBe("unknown");
30
+ });
31
+ });
32
+
33
+ describe("computeSyncStatus", () => {
34
+ it("returns 'synced' when both tools have the skill", () => {
35
+ expect(computeSyncStatus(["claude-code", "codex"])).toBe("synced");
36
+ });
37
+
38
+ it("returns 'claude-only' when only claude-code has it", () => {
39
+ expect(computeSyncStatus(["claude-code"])).toBe("claude-only");
40
+ });
41
+
42
+ it("returns 'codex-only' when only codex has it", () => {
43
+ expect(computeSyncStatus(["codex"])).toBe("codex-only");
44
+ });
45
+
46
+ it("returns 'shared' when only shared tool is present", () => {
47
+ expect(computeSyncStatus(["shared"])).toBe("shared");
48
+ });
49
+
50
+ it("returns 'synced' when claude + shared", () => {
51
+ expect(computeSyncStatus(["claude-code", "shared"])).toBe("synced");
52
+ });
53
+ });
54
+
55
+ import { enrichSkills, type EnrichedSkill } from "../skill-enrichment";
56
+ import type { SkillSummary } from "../list-skills";
57
+
58
+ const NOW = new Date("2026-04-14T00:00:00Z").getTime();
59
+ const DAY = 24 * 60 * 60 * 1000;
60
+
61
+ function skill(
62
+ id: string,
63
+ name: string,
64
+ tool: string,
65
+ overrides: Partial<SkillSummary> = {}
66
+ ): SkillSummary {
67
+ return {
68
+ id,
69
+ name,
70
+ tool,
71
+ scope: "user",
72
+ preview: "",
73
+ sizeBytes: 0,
74
+ absPath: `/tmp/${id}`,
75
+ ...overrides,
76
+ };
77
+ }
78
+
79
+ describe("enrichSkills", () => {
80
+ it("groups by name and computes syncStatus across tools", () => {
81
+ const out = enrichSkills(
82
+ [
83
+ skill("a", "research", "claude-code"),
84
+ skill("b", "research", "codex"),
85
+ skill("c", "standalone", "claude-code"),
86
+ ],
87
+ { modifiedAtMsByPath: {}, linkedProfilesByPath: {}, nowMs: NOW }
88
+ );
89
+ const bySkill: Record<string, EnrichedSkill> = {};
90
+ for (const s of out) bySkill[s.name] = s;
91
+ expect(bySkill.research.syncStatus).toBe("synced");
92
+ expect(bySkill.standalone.syncStatus).toBe("claude-only");
93
+ });
94
+
95
+ it("attaches linkedProfileId per artifact absPath", () => {
96
+ const out = enrichSkills(
97
+ [skill("x", "coder", "claude-code", { absPath: "/p/a" })],
98
+ {
99
+ modifiedAtMsByPath: {},
100
+ linkedProfilesByPath: { "/p/a": "code-reviewer" },
101
+ nowMs: NOW,
102
+ }
103
+ );
104
+ expect(out[0].linkedProfileId).toBe("code-reviewer");
105
+ });
106
+
107
+ it("assigns health from modifiedAtMsByPath", () => {
108
+ const out = enrichSkills(
109
+ [skill("x", "aging", "claude-code", { absPath: "/p/a" })],
110
+ {
111
+ modifiedAtMsByPath: { "/p/a": NOW - 400 * DAY },
112
+ linkedProfilesByPath: {},
113
+ nowMs: NOW,
114
+ }
115
+ );
116
+ expect(out[0].healthScore).toBe("aging");
117
+ });
118
+
119
+ it("merges duplicate absPaths (symlink case) to a single entry", () => {
120
+ const out = enrichSkills(
121
+ [
122
+ skill("a", "shared", "claude-code", { absPath: "/same" }),
123
+ skill("b", "shared", "codex", { absPath: "/same" }),
124
+ ],
125
+ { modifiedAtMsByPath: {}, linkedProfilesByPath: {}, nowMs: NOW }
126
+ );
127
+ expect(out).toHaveLength(1);
128
+ });
129
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { computeRecommendation } from "../skill-recommendations";
3
+ import type { EnrichedSkill } from "../skill-enrichment";
4
+
5
+ const mkSkill = (
6
+ name: string,
7
+ preview: string,
8
+ overrides: Partial<EnrichedSkill> = {}
9
+ ): EnrichedSkill => ({
10
+ id: name,
11
+ name,
12
+ tool: "claude-code",
13
+ scope: "user",
14
+ preview,
15
+ sizeBytes: 0,
16
+ absPath: `/p/${name}`,
17
+ healthScore: "healthy",
18
+ syncStatus: "claude-only",
19
+ linkedProfileId: null,
20
+ absPaths: [`/p/${name}`],
21
+ ...overrides,
22
+ });
23
+
24
+ describe("computeRecommendation", () => {
25
+ it("recommends a healthy skill whose keywords match 2+ in recent messages", () => {
26
+ const skills = [
27
+ mkSkill("code-reviewer", "Review pull requests for security"),
28
+ mkSkill("researcher", "Search the web for up-to-date information"),
29
+ ];
30
+ const rec = computeRecommendation(skills, [
31
+ "can you review this pull request for security issues?",
32
+ ]);
33
+ expect(rec?.name).toBe("code-reviewer");
34
+ });
35
+
36
+ it("returns null when no strong keyword match exists", () => {
37
+ const skills = [mkSkill("code-reviewer", "Review PRs for security")];
38
+ const rec = computeRecommendation(skills, ["hi there"]);
39
+ expect(rec).toBeNull();
40
+ });
41
+
42
+ it("excludes already-active skill", () => {
43
+ const skills = [mkSkill("code-reviewer", "Review pull requests security")];
44
+ const rec = computeRecommendation(
45
+ skills,
46
+ ["review this pull request for security"],
47
+ { activeSkillId: "code-reviewer" }
48
+ );
49
+ expect(rec).toBeNull();
50
+ });
51
+
52
+ it("excludes dismissed skills", () => {
53
+ const skills = [mkSkill("code-reviewer", "Review pull requests security")];
54
+ const rec = computeRecommendation(
55
+ skills,
56
+ ["review pull request security issues"],
57
+ { dismissedIds: new Set(["code-reviewer"]) }
58
+ );
59
+ expect(rec).toBeNull();
60
+ });
61
+
62
+ it("excludes broken/aging skills", () => {
63
+ const skills = [
64
+ mkSkill("code-reviewer", "Review pull requests security", {
65
+ healthScore: "aging",
66
+ }),
67
+ ];
68
+ const rec = computeRecommendation(skills, [
69
+ "review pull request security issues",
70
+ ]);
71
+ expect(rec).toBeNull();
72
+ });
73
+
74
+ it("ignores stopwords and requires ≥2 distinct meaningful hits", () => {
75
+ const skills = [mkSkill("researcher", "the and for a of in on")];
76
+ const rec = computeRecommendation(skills, ["the and for a of in on"]);
77
+ expect(rec).toBeNull();
78
+ });
79
+
80
+ it("returns null on empty message list", () => {
81
+ const rec = computeRecommendation(
82
+ [mkSkill("code-reviewer", "review pull request security")],
83
+ []
84
+ );
85
+ expect(rec).toBeNull();
86
+ });
87
+ });
@@ -78,6 +78,15 @@ export function createScan(
78
78
  console.warn("[environment] Profile linking failed (non-blocking):", err);
79
79
  }
80
80
 
81
+ // Auto-promote unlinked skills to profiles if the user opted in.
82
+ // Imported lazily to avoid a top-level circular import with profile-generator.ts.
83
+ // Fire-and-forget: auto-promote runs asynchronously and failures are logged only.
84
+ import("./profile-generator")
85
+ .then((m) => m.autoPromoteUnlinkedSkills(scanId))
86
+ .catch((err) =>
87
+ console.warn("[environment] Auto-promote failed (non-blocking):", err)
88
+ );
89
+
81
90
  return db
82
91
  .select()
83
92
  .from(environmentScans)