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
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseFilterInput, matchesClauses } from "../parse";
3
+
4
+ describe("parseFilterInput", () => {
5
+ it("returns empty result for empty input", () => {
6
+ expect(parseFilterInput("")).toEqual({ clauses: [], rawQuery: "" });
7
+ });
8
+
9
+ it("returns input as rawQuery when no clauses present", () => {
10
+ const out = parseFilterInput("hello world");
11
+ expect(out.clauses).toEqual([]);
12
+ expect(out.rawQuery).toBe("hello world");
13
+ });
14
+
15
+ it("parses a single clause and strips it from rawQuery", () => {
16
+ const out = parseFilterInput("#status:blocked");
17
+ expect(out.clauses).toEqual([{ key: "status", value: "blocked" }]);
18
+ expect(out.rawQuery).toBe("");
19
+ });
20
+
21
+ it("parses multiple clauses with AND semantics", () => {
22
+ const out = parseFilterInput("#status:blocked #priority:high");
23
+ expect(out.clauses).toEqual([
24
+ { key: "status", value: "blocked" },
25
+ { key: "priority", value: "high" },
26
+ ]);
27
+ expect(out.rawQuery).toBe("");
28
+ });
29
+
30
+ it("preserves raw text between and around clauses", () => {
31
+ const out = parseFilterInput("auth #status:blocked service #priority:high");
32
+ expect(out.clauses).toEqual([
33
+ { key: "status", value: "blocked" },
34
+ { key: "priority", value: "high" },
35
+ ]);
36
+ expect(out.rawQuery).toBe("auth service");
37
+ });
38
+
39
+ it("treats `#123` as raw-query text, not a clause (no colon)", () => {
40
+ const out = parseFilterInput("see #123 for context");
41
+ expect(out.clauses).toEqual([]);
42
+ expect(out.rawQuery).toBe("see #123 for context");
43
+ });
44
+
45
+ it("treats `#1abc:val` as raw-query text (key must start with a letter)", () => {
46
+ const out = parseFilterInput("#1abc:val");
47
+ expect(out.clauses).toEqual([]);
48
+ expect(out.rawQuery).toBe("#1abc:val");
49
+ });
50
+
51
+ it("accepts hyphens and underscores in keys", () => {
52
+ const out = parseFilterInput("#created-by:me #user_id:42");
53
+ expect(out.clauses).toEqual([
54
+ { key: "created-by", value: "me" },
55
+ { key: "user_id", value: "42" },
56
+ ]);
57
+ });
58
+
59
+ it("preserves case of values verbatim (keys keep case too)", () => {
60
+ const out = parseFilterInput("#Status:Blocked");
61
+ expect(out.clauses).toEqual([{ key: "Status", value: "Blocked" }]);
62
+ });
63
+
64
+ it("accepts back-to-back clauses without space between them", () => {
65
+ const out = parseFilterInput("#a:1#b:2");
66
+ expect(out.clauses).toEqual([
67
+ { key: "a", value: "1" },
68
+ { key: "b", value: "2" },
69
+ ]);
70
+ expect(out.rawQuery).toBe("");
71
+ });
72
+
73
+ it("collapses extra whitespace in rawQuery", () => {
74
+ const out = parseFilterInput(" foo bar ");
75
+ expect(out.rawQuery).toBe("foo bar");
76
+ });
77
+
78
+ it("handles values with special chars except whitespace", () => {
79
+ const out = parseFilterInput("#path:src/lib/filters.ts");
80
+ expect(out.clauses).toEqual([{ key: "path", value: "src/lib/filters.ts" }]);
81
+ });
82
+ });
83
+
84
+ describe("matchesClauses", () => {
85
+ const task = { id: "t1", status: "blocked", priority: "high", type: "task" };
86
+
87
+ it("returns true when clauses list is empty", () => {
88
+ expect(matchesClauses(task, [], {})).toBe(true);
89
+ });
90
+
91
+ it("returns true when all clauses match via predicates", () => {
92
+ const out = matchesClauses(
93
+ task,
94
+ [{ key: "status", value: "blocked" }],
95
+ { status: (t, v) => t.status === v }
96
+ );
97
+ expect(out).toBe(true);
98
+ });
99
+
100
+ it("returns false when any clause fails", () => {
101
+ const out = matchesClauses(
102
+ task,
103
+ [
104
+ { key: "status", value: "blocked" },
105
+ { key: "priority", value: "low" },
106
+ ],
107
+ {
108
+ status: (t, v) => t.status === v,
109
+ priority: (t, v) => t.priority === v,
110
+ }
111
+ );
112
+ expect(out).toBe(false);
113
+ });
114
+
115
+ it("silently skips unknown keys (does not fail the match)", () => {
116
+ const out = matchesClauses(
117
+ task,
118
+ [
119
+ { key: "status", value: "blocked" },
120
+ { key: "totally-unknown", value: "xyz" },
121
+ ],
122
+ { status: (t, v) => t.status === v }
123
+ );
124
+ expect(out).toBe(true);
125
+ });
126
+
127
+ it("normalizes key lookup to lowercase", () => {
128
+ const out = matchesClauses(
129
+ task,
130
+ [{ key: "Status", value: "blocked" }],
131
+ { status: (t, v) => t.status === v }
132
+ );
133
+ expect(out).toBe(true);
134
+ });
135
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `#key:value` filter namespace parser.
3
+ *
4
+ * Pure function that extracts filter clauses from free-text input and returns
5
+ * the non-filter remainder as `rawQuery`. Designed to be reused across chat
6
+ * popovers (entity filtering) and list pages (URL state, FilterBar input).
7
+ *
8
+ * Syntax (v2):
9
+ * - `#key:value` — single clause. Keys are `[A-Za-z][\w-]*`, values are
10
+ * double-quoted strings `"..."` (may contain spaces or `#`) OR a whitespace/`#`-terminated bare run.
11
+ * - Multiple clauses may chain: `#status:blocked #priority:high` → two clauses.
12
+ * - Clauses may appear anywhere in the input; everything else becomes rawQuery.
13
+ * - Unknown keys pass through unchanged — the consumer decides what to do.
14
+ * - Tokens like `#123` (no colon) are treated as raw-query text, not clauses.
15
+ *
16
+ * Design notes:
17
+ * - AND-only. NOT/OR deferred to v2.
18
+ * - Case of keys is preserved; consumer normalizes if needed. Values are
19
+ * preserved verbatim (including case) — status codes are commonly lowercase.
20
+ * - rawQuery whitespace is collapsed to single spaces and trimmed so callers
21
+ * can feed it directly to a search input without extra cleanup.
22
+ */
23
+
24
+ export interface FilterClause {
25
+ key: string;
26
+ value: string;
27
+ }
28
+
29
+ export interface ParsedFilterInput {
30
+ clauses: FilterClause[];
31
+ rawQuery: string;
32
+ }
33
+
34
+ // Clause pattern: `#<key>:<value>`. Key must start with a letter to avoid
35
+ // eating `#123` hash references. Value may be either:
36
+ // - a double-quoted run of any non-quote chars: `"..."` (captured in group 2)
37
+ // - OR an unquoted whitespace/`#`-terminated run (captured in group 3)
38
+ // Exactly one of group 2 / group 3 will be defined per match.
39
+ const CLAUSE_PATTERN = /#([A-Za-z][\w-]*):(?:"([^"]*)"|([^\s#]+))/g;
40
+
41
+ export function parseFilterInput(input: string): ParsedFilterInput {
42
+ if (!input) return { clauses: [], rawQuery: "" };
43
+
44
+ const clauses: FilterClause[] = [];
45
+ let rawQuery = input;
46
+
47
+ // Replace each match with a single space to preserve word boundaries, then
48
+ // collapse whitespace. This is simpler than maintaining offsets and survives
49
+ // back-to-back clauses like `#a:1#b:2` (which we don't officially support
50
+ // but shouldn't crash on — the regex with `g` flag matches both).
51
+ rawQuery = rawQuery.replace(
52
+ CLAUSE_PATTERN,
53
+ (_match, key: string, quoted: string | undefined, bare: string | undefined) => {
54
+ const value = quoted !== undefined ? quoted : bare ?? "";
55
+ clauses.push({ key, value });
56
+ return " ";
57
+ }
58
+ );
59
+
60
+ rawQuery = rawQuery.replace(/\s+/g, " ").trim();
61
+
62
+ return { clauses, rawQuery };
63
+ }
64
+
65
+ /**
66
+ * Evaluate an object against a list of clauses using a caller-supplied
67
+ * predicate per key. Returns true if ALL clauses match (AND semantics) or
68
+ * the clauses list is empty.
69
+ *
70
+ * `predicates` maps known filter keys to value-checkers. Unknown keys are
71
+ * silently skipped (not considered a mismatch) so callers can layer their
72
+ * own matching logic without breaking on typos.
73
+ */
74
+ export function matchesClauses<T>(
75
+ item: T,
76
+ clauses: FilterClause[],
77
+ predicates: Record<string, (item: T, value: string) => boolean>
78
+ ): boolean {
79
+ if (clauses.length === 0) return true;
80
+ for (const clause of clauses) {
81
+ const predicate = predicates[clause.key.toLowerCase()];
82
+ if (!predicate) continue; // unknown key → skip, per spec
83
+ if (!predicate(item, clause.value)) return false;
84
+ }
85
+ return true;
86
+ }
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * Deduplication engine for profile import.
3
3
  * Three-tier matching: exact ID, name match, content similarity.
4
+ *
5
+ * Keyword / Jaccard / tag-overlap helpers are shared with the chat workflow
6
+ * dedup path — see `src/lib/util/similarity.ts`.
4
7
  */
5
8
 
6
9
  import type { ProfileConfig } from "@/lib/validators/profile";
7
10
  import type { AgentProfile } from "@/lib/agents/profiles/types";
11
+ import { extractKeywords, jaccard, tagOverlap } from "@/lib/util/similarity";
8
12
 
9
13
  export interface DedupResult {
10
14
  candidate: ProfileConfig;
@@ -15,60 +19,6 @@ export interface DedupResult {
15
19
  similarity?: number;
16
20
  }
17
21
 
18
- /** Common stop words to exclude from keyword extraction. */
19
- const STOP_WORDS = new Set([
20
- "the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
21
- "her", "was", "one", "our", "out", "has", "have", "that", "this", "with",
22
- "from", "they", "been", "will", "each", "make", "like", "into", "them",
23
- "some", "when", "what", "your", "should", "would", "could", "about",
24
- "which", "their", "other", "than", "then", "more", "also", "been",
25
- "only", "must", "does", "here", "just", "over", "such", "after",
26
- "before", "between", "through", "where", "these", "those", "being",
27
- "using", "ensure", "every", "following", "include",
28
- ]);
29
-
30
- /** Extract meaningful keywords from text. */
31
- function extractKeywords(text: string, limit = 20): Set<string> {
32
- const words = text
33
- .toLowerCase()
34
- .replace(/[^a-z0-9\s-]/g, " ")
35
- .split(/\s+/)
36
- .filter((w) => w.length > 3 && w.length < 30 && !STOP_WORDS.has(w));
37
-
38
- // Count frequency
39
- const freq = new Map<string, number>();
40
- for (const word of words) {
41
- freq.set(word, (freq.get(word) ?? 0) + 1);
42
- }
43
-
44
- // Sort by frequency, take top N
45
- const sorted = Array.from(freq.entries())
46
- .sort((a, b) => b[1] - a[1])
47
- .slice(0, limit)
48
- .map(([word]) => word);
49
-
50
- return new Set(sorted);
51
- }
52
-
53
- /** Jaccard similarity between two sets. */
54
- function jaccard(a: Set<string>, b: Set<string>): number {
55
- if (a.size === 0 && b.size === 0) return 0;
56
- let intersection = 0;
57
- for (const item of a) {
58
- if (b.has(item)) intersection++;
59
- }
60
- const union = a.size + b.size - intersection;
61
- return union === 0 ? 0 : intersection / union;
62
- }
63
-
64
- /** Tag overlap ratio (how many of candidate's tags match existing). */
65
- function tagOverlap(candidateTags: string[], existingTags: string[]): number {
66
- if (candidateTags.length === 0) return 0;
67
- const existingSet = new Set(existingTags.map((t) => t.toLowerCase()));
68
- const matches = candidateTags.filter((t) => existingSet.has(t.toLowerCase()));
69
- return matches.length / candidateTags.length;
70
- }
71
-
72
22
  /**
73
23
  * Check a batch of candidate profiles against all existing profiles for duplicates.
74
24
  */
@@ -0,0 +1,362 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { execFileSync } from "child_process";
3
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { tmpdir } from "os";
6
+
7
+ let tempDir: string;
8
+ let dataDir: string;
9
+
10
+ function runGit(args: string[], cwd: string) {
11
+ execFileSync("git", args, { cwd, stdio: "pipe" });
12
+ }
13
+
14
+ function initRepo(dir: string) {
15
+ runGit(["init", "-b", "main"], dir);
16
+ runGit(["config", "user.email", "test@example.com"], dir);
17
+ runGit(["config", "user.name", "Test"], dir);
18
+ writeFileSync(join(dir, "README.md"), "# test\n");
19
+ runGit(["add", "README.md"], dir);
20
+ runGit(["commit", "-m", "initial"], dir);
21
+ }
22
+
23
+ beforeEach(() => {
24
+ tempDir = mkdtempSync(join(tmpdir(), "stagent-bootstrap-repo-"));
25
+ dataDir = mkdtempSync(join(tmpdir(), "stagent-bootstrap-data-"));
26
+ initRepo(tempDir);
27
+ vi.resetModules();
28
+ vi.unstubAllEnvs();
29
+ vi.stubEnv("STAGENT_DATA_DIR", dataDir);
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.unstubAllEnvs();
34
+ rmSync(tempDir, { recursive: true, force: true });
35
+ rmSync(dataDir, { recursive: true, force: true });
36
+ });
37
+
38
+ describe("ensureInstanceConfig (Phase A)", () => {
39
+ it("generates a new instanceId on first call", async () => {
40
+ const { ensureInstanceConfig } = await import("../bootstrap");
41
+ const result = await ensureInstanceConfig();
42
+ expect(result.status).toBe("ok");
43
+ const { getInstanceConfig } = await import("../settings");
44
+ const config = getInstanceConfig();
45
+ expect(config).not.toBeNull();
46
+ expect(config!.instanceId).toMatch(/^[a-f0-9-]{36}$/);
47
+ expect(config!.branchName).toBe("local");
48
+ // STAGENT_DATA_DIR is stubbed to a temp dir (non-default), so this clone
49
+ // correctly registers as a private instance in the test environment.
50
+ expect(config!.isPrivateInstance).toBe(true);
51
+ expect(config!.createdAt).toBeGreaterThan(0);
52
+ });
53
+
54
+ it("does not regenerate instanceId on subsequent calls", async () => {
55
+ const { ensureInstanceConfig } = await import("../bootstrap");
56
+ await ensureInstanceConfig();
57
+ const { getInstanceConfig } = await import("../settings");
58
+ const firstId = getInstanceConfig()!.instanceId;
59
+ await ensureInstanceConfig();
60
+ const secondId = getInstanceConfig()!.instanceId;
61
+ expect(secondId).toBe(firstId);
62
+ });
63
+ });
64
+
65
+ describe("ensureLocalBranch (Phase A)", () => {
66
+ it("creates local branch at current HEAD when it does not exist", async () => {
67
+ const { createGitOps } = await import("../git-ops");
68
+ const { ensureLocalBranch } = await import("../bootstrap");
69
+ const ops = createGitOps(tempDir);
70
+ const mainSha = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
71
+ const result = ensureLocalBranch(ops);
72
+ expect(result.status).toBe("ok");
73
+ expect(ops.branchExists("local")).toBe(true);
74
+ expect(ops.getCurrentBranch()).toBe("local");
75
+ const localSha = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
76
+ expect(localSha).toBe(mainSha);
77
+ const mainShaAfter = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
78
+ expect(mainShaAfter).toBe(mainSha);
79
+ });
80
+
81
+ it("is a no-op when local branch already exists", async () => {
82
+ const { createGitOps } = await import("../git-ops");
83
+ const { ensureLocalBranch } = await import("../bootstrap");
84
+ const ops = createGitOps(tempDir);
85
+ ops.createAndCheckoutBranch("local");
86
+ const shaBefore = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
87
+ const result = ensureLocalBranch(ops);
88
+ expect(result.status).toBe("skipped");
89
+ expect(result.reason).toBe("branch_exists");
90
+ const shaAfter = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
91
+ expect(shaAfter).toBe(shaBefore);
92
+ });
93
+
94
+ it("creates local at current HEAD even when user has local commits on main", async () => {
95
+ writeFileSync(join(tempDir, "custom.txt"), "user work\n");
96
+ runGit(["add", "custom.txt"], tempDir);
97
+ runGit(["commit", "-m", "user customization"], tempDir);
98
+ const mainSha = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
99
+
100
+ const { createGitOps } = await import("../git-ops");
101
+ const { ensureLocalBranch } = await import("../bootstrap");
102
+ const ops = createGitOps(tempDir);
103
+ const result = ensureLocalBranch(ops);
104
+
105
+ expect(result.status).toBe("ok");
106
+ expect(ops.branchExists("local")).toBe(true);
107
+ const localSha = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
108
+ expect(localSha).toBe(mainSha);
109
+ const mainShaAfter = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
110
+ expect(mainShaAfter).toBe(mainSha);
111
+ });
112
+ });
113
+
114
+ describe("ensurePrePushHook (Phase B)", () => {
115
+ it("writes a pre-push hook with the STAGENT_HOOK_VERSION marker", async () => {
116
+ const { createGitOps } = await import("../git-ops");
117
+ const { ensurePrePushHook } = await import("../bootstrap");
118
+ const ops = createGitOps(tempDir);
119
+ const result = ensurePrePushHook(ops);
120
+ expect(result.status).toBe("ok");
121
+ const hookPath = join(tempDir, ".git", "hooks", "pre-push");
122
+ expect(existsSync(hookPath)).toBe(true);
123
+ const content = readFileSync(hookPath, "utf-8");
124
+ expect(content).toContain("STAGENT_HOOK_VERSION=");
125
+ expect(content).toContain("ALLOW_PRIVATE_PUSH");
126
+ const mode = statSync(hookPath).mode & 0o777;
127
+ expect(mode & 0o100).toBeTruthy();
128
+ });
129
+
130
+ it("is a no-op when a hook with matching version already exists", async () => {
131
+ const { createGitOps } = await import("../git-ops");
132
+ const { ensurePrePushHook } = await import("../bootstrap");
133
+ const ops = createGitOps(tempDir);
134
+ ensurePrePushHook(ops); // first install
135
+ const firstMtime = statSync(join(tempDir, ".git", "hooks", "pre-push")).mtimeMs;
136
+ const result = ensurePrePushHook(ops);
137
+ expect(result.status).toBe("skipped");
138
+ expect(result.reason).toBe("already_installed");
139
+ const secondMtime = statSync(join(tempDir, ".git", "hooks", "pre-push")).mtimeMs;
140
+ expect(secondMtime).toBe(firstMtime);
141
+ });
142
+
143
+ it("backs up a pre-existing non-stagent hook before installing", async () => {
144
+ const customHook = "#!/bin/sh\necho custom hook\n";
145
+ writeFileSync(join(tempDir, ".git", "hooks", "pre-push"), customHook);
146
+ chmodSync(join(tempDir, ".git", "hooks", "pre-push"), 0o755);
147
+ const { createGitOps } = await import("../git-ops");
148
+ const { ensurePrePushHook } = await import("../bootstrap");
149
+ const ops = createGitOps(tempDir);
150
+ const result = ensurePrePushHook(ops);
151
+ expect(result.status).toBe("ok");
152
+ const backupPath = join(tempDir, ".git", "hooks", "pre-push.stagent-backup");
153
+ expect(existsSync(backupPath)).toBe(true);
154
+ expect(readFileSync(backupPath, "utf-8")).toBe(customHook);
155
+ expect(readFileSync(join(tempDir, ".git", "hooks", "pre-push"), "utf-8"))
156
+ .toContain("STAGENT_HOOK_VERSION=");
157
+ });
158
+ });
159
+
160
+ describe("ensureBranchPushConfig (Phase B)", () => {
161
+ it("sets branch.local.pushRemote=no_push", async () => {
162
+ const { createGitOps } = await import("../git-ops");
163
+ const { ensureLocalBranch, ensureBranchPushConfig } = await import("../bootstrap");
164
+ const ops = createGitOps(tempDir);
165
+ ensureLocalBranch(ops);
166
+ const result = ensureBranchPushConfig(ops, ["local"]);
167
+ expect(result.status).toBe("ok");
168
+ const value = execFileSync("git", ["config", "--get", "branch.local.pushRemote"], { cwd: tempDir, encoding: "utf-8" }).trim();
169
+ expect(value).toBe("no_push");
170
+ });
171
+
172
+ it("handles multiple blocked branches", async () => {
173
+ const { createGitOps } = await import("../git-ops");
174
+ const { ensureBranchPushConfig } = await import("../bootstrap");
175
+ const ops = createGitOps(tempDir);
176
+ ops.createAndCheckoutBranch("wealth-mgr");
177
+ ops.createAndCheckoutBranch("investor-mgr");
178
+ const result = ensureBranchPushConfig(ops, ["wealth-mgr", "investor-mgr"]);
179
+ expect(result.status).toBe("ok");
180
+ expect(execFileSync("git", ["config", "--get", "branch.wealth-mgr.pushRemote"], { cwd: tempDir, encoding: "utf-8" }).trim()).toBe("no_push");
181
+ expect(execFileSync("git", ["config", "--get", "branch.investor-mgr.pushRemote"], { cwd: tempDir, encoding: "utf-8" }).trim()).toBe("no_push");
182
+ });
183
+ });
184
+
185
+ describe("resolveConsentDecision", () => {
186
+ it("returns {shouldRunPhaseB: false, reason: 'not_yet'} when consent is not_yet (default)", async () => {
187
+ const { resolveConsentDecision } = await import("../bootstrap");
188
+ const decision = await resolveConsentDecision();
189
+ expect(decision.shouldRunPhaseB).toBe(false);
190
+ expect(decision.reason).toBe("not_yet");
191
+ });
192
+
193
+ it("returns {shouldRunPhaseB: true} when consent is enabled", async () => {
194
+ const { setGuardrails } = await import("../settings");
195
+ await setGuardrails({
196
+ prePushHookInstalled: false,
197
+ prePushHookVersion: "",
198
+ pushRemoteBlocked: [],
199
+ consentStatus: "enabled",
200
+ firstBootCompletedAt: null,
201
+ });
202
+ const { resolveConsentDecision } = await import("../bootstrap");
203
+ const decision = await resolveConsentDecision();
204
+ expect(decision.shouldRunPhaseB).toBe(true);
205
+ expect(decision.reason).toBe("enabled");
206
+ });
207
+
208
+ it("returns {shouldRunPhaseB: false, reason: 'declined_permanently'}", async () => {
209
+ const { setGuardrails } = await import("../settings");
210
+ await setGuardrails({
211
+ prePushHookInstalled: false,
212
+ prePushHookVersion: "",
213
+ pushRemoteBlocked: [],
214
+ consentStatus: "declined_permanently",
215
+ firstBootCompletedAt: null,
216
+ });
217
+ const { resolveConsentDecision } = await import("../bootstrap");
218
+ const decision = await resolveConsentDecision();
219
+ expect(decision.shouldRunPhaseB).toBe(false);
220
+ expect(decision.reason).toBe("declined_permanently");
221
+ });
222
+
223
+ it("stamps firstBootCompletedAt on first call when it was null", async () => {
224
+ const { getGuardrails } = await import("../settings");
225
+ expect(getGuardrails().consentStatus).toBe("not_yet");
226
+ expect(getGuardrails().firstBootCompletedAt).toBeNull();
227
+ const { resolveConsentDecision } = await import("../bootstrap");
228
+ await resolveConsentDecision();
229
+ const after = getGuardrails();
230
+ expect(after.consentStatus).toBe("not_yet");
231
+ expect(after.firstBootCompletedAt).not.toBeNull();
232
+ });
233
+ });
234
+
235
+ describe("ensureInstance orchestrator", () => {
236
+ it("returns skipped with dev_mode_env when STAGENT_DEV_MODE=true", async () => {
237
+ vi.stubEnv("STAGENT_DEV_MODE", "true");
238
+ const { ensureInstance } = await import("../bootstrap");
239
+ const result = await ensureInstance(tempDir);
240
+ expect(result.skipped).toBe("dev_mode_env");
241
+ expect(result.steps).toEqual([]);
242
+ expect(existsSync(join(tempDir, ".git", "hooks", "pre-push"))).toBe(false);
243
+ const { createGitOps } = await import("../git-ops");
244
+ expect(createGitOps(tempDir).branchExists("local")).toBe(false);
245
+ });
246
+
247
+ it("returns skipped with dev_mode_sentinel when sentinel file exists", async () => {
248
+ writeFileSync(join(tempDir, ".git", "stagent-dev-mode"), "");
249
+ const { ensureInstance } = await import("../bootstrap");
250
+ const result = await ensureInstance(tempDir);
251
+ expect(result.skipped).toBe("dev_mode_sentinel");
252
+ expect(result.steps).toEqual([]);
253
+ });
254
+
255
+ it("returns skipped with no_git when .git directory is absent", async () => {
256
+ const noGitDir = mkdtempSync(join(tmpdir(), "stagent-nogit-"));
257
+ try {
258
+ const { ensureInstance } = await import("../bootstrap");
259
+ const result = await ensureInstance(noGitDir);
260
+ expect(result.skipped).toBe("no_git");
261
+ } finally {
262
+ rmSync(noGitDir, { recursive: true, force: true });
263
+ }
264
+ });
265
+
266
+ it("runs Phase A and stamps consent state on fresh clone (consent not_yet)", async () => {
267
+ const { ensureInstance } = await import("../bootstrap");
268
+ const result = await ensureInstance(tempDir);
269
+ expect(result.skipped).toBeUndefined();
270
+ const steps = result.steps.map((s) => s.step);
271
+ expect(steps).toContain("instance-config");
272
+ expect(steps).toContain("local-branch");
273
+ expect(steps).not.toContain("pre-push-hook");
274
+ expect(steps).not.toContain("branch-push-config");
275
+ const { createGitOps } = await import("../git-ops");
276
+ expect(createGitOps(tempDir).branchExists("local")).toBe(true);
277
+ expect(existsSync(join(tempDir, ".git", "hooks", "pre-push"))).toBe(false);
278
+ const { getGuardrails } = await import("../settings");
279
+ expect(getGuardrails().firstBootCompletedAt).not.toBeNull();
280
+ expect(getGuardrails().consentStatus).toBe("not_yet");
281
+ });
282
+
283
+ it("runs Phase B when consent is enabled", async () => {
284
+ const { setGuardrails } = await import("../settings");
285
+ await setGuardrails({
286
+ prePushHookInstalled: false,
287
+ prePushHookVersion: "",
288
+ pushRemoteBlocked: [],
289
+ consentStatus: "enabled",
290
+ firstBootCompletedAt: null,
291
+ });
292
+ const { ensureInstance } = await import("../bootstrap");
293
+ const result = await ensureInstance(tempDir);
294
+ const steps = result.steps.map((s) => s.step);
295
+ expect(steps).toContain("pre-push-hook");
296
+ expect(steps).toContain("branch-push-config");
297
+ expect(existsSync(join(tempDir, ".git", "hooks", "pre-push"))).toBe(true);
298
+ });
299
+
300
+ it("STAGENT_INSTANCE_MODE=true override beats STAGENT_DEV_MODE=true", async () => {
301
+ vi.stubEnv("STAGENT_DEV_MODE", "true");
302
+ vi.stubEnv("STAGENT_INSTANCE_MODE", "true");
303
+ const { ensureInstance } = await import("../bootstrap");
304
+ const result = await ensureInstance(tempDir);
305
+ expect(result.skipped).toBeUndefined();
306
+ expect(result.steps.length).toBeGreaterThan(0);
307
+ });
308
+
309
+ it("is a full no-op on the second call (idempotent)", async () => {
310
+ const { ensureInstance } = await import("../bootstrap");
311
+ await ensureInstance(tempDir);
312
+ const result = await ensureInstance(tempDir);
313
+ for (const step of result.steps) {
314
+ if (step.step === "instance-config" || step.step === "local-branch") {
315
+ expect(step.status).toBe("skipped");
316
+ }
317
+ }
318
+ });
319
+
320
+ it("skips ensureLocalBranch with warning when rebase is in progress", async () => {
321
+ mkdirSync(join(tempDir, ".git", "rebase-merge"));
322
+ const { ensureInstance } = await import("../bootstrap");
323
+ const result = await ensureInstance(tempDir);
324
+ const branchStep = result.steps.find((s) => s.step === "local-branch");
325
+ expect(branchStep?.status).toBe("skipped");
326
+ expect(branchStep?.reason).toBe("rebase_in_progress");
327
+ });
328
+
329
+ it("populates guardrails state after a Phase B run with consent=enabled", async () => {
330
+ // Regression test for the critical bug where ensureBranchPushConfig() set
331
+ // the git config values but never wrote the blocked branch list back to
332
+ // settings.instance.guardrails. The hook's grep would never match and all
333
+ // pushes would be silently allowed.
334
+ const { setGuardrails, getGuardrails } = await import("../settings");
335
+ await setGuardrails({
336
+ prePushHookInstalled: false,
337
+ prePushHookVersion: "",
338
+ pushRemoteBlocked: [],
339
+ consentStatus: "enabled",
340
+ firstBootCompletedAt: null,
341
+ });
342
+ const { ensureInstance, STAGENT_HOOK_VERSION } = await import("../bootstrap");
343
+ const result = await ensureInstance(tempDir);
344
+ expect(result.skipped).toBeUndefined();
345
+ const guardrails = getGuardrails();
346
+ expect(guardrails.prePushHookInstalled).toBe(true);
347
+ expect(guardrails.prePushHookVersion).toBe(STAGENT_HOOK_VERSION);
348
+ expect(guardrails.pushRemoteBlocked).toContain("local");
349
+ });
350
+
351
+ // NOTE: We do not test "single-clone user (STAGENT_DATA_DIR equals default)" at the
352
+ // orchestrator level here because vi.spyOn(os, "homedir") is not possible in ESM —
353
+ // Node's os module exports are non-configurable and cannot be redefined (vitest throws
354
+ // "Cannot redefine property: homedir"). Stubbing STAGENT_DATA_DIR to the real ~/.stagent
355
+ // would pollute the developer's live database, which is also unacceptable.
356
+ //
357
+ // The single-clone path is fully covered at the unit level by
358
+ // src/lib/instance/__tests__/detect.test.ts → "isPrivateInstance" describe block,
359
+ // specifically the test "returns false when STAGENT_DATA_DIR equals default ~/.stagent".
360
+ // That test directly exercises the detect.isPrivateInstance() function that
361
+ // ensureInstanceConfig() delegates to, making an orchestrator-level duplicate redundant.
362
+ });