stagent 0.9.5 → 0.10.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 (277) hide show
  1. package/README.md +5 -42
  2. package/dist/cli.js +42 -18
  3. package/docs/.coverage-gaps.json +13 -55
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/provider-runtimes.md +4 -0
  6. package/docs/features/schedules.md +32 -4
  7. package/docs/features/settings.md +28 -5
  8. package/docs/features/tables.md +9 -2
  9. package/docs/features/workflows.md +10 -4
  10. package/docs/journeys/developer.md +15 -1
  11. package/docs/journeys/personal-use.md +21 -4
  12. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
  13. package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
  14. package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
  15. package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
  16. package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
  17. package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
  18. package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
  19. package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
  20. package/package.json +3 -2
  21. package/src/__tests__/instrumentation-smoke.test.ts +15 -0
  22. package/src/app/analytics/page.tsx +1 -21
  23. package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
  24. package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
  25. package/src/app/api/instance/config/route.ts +41 -0
  26. package/src/app/api/instance/init/route.ts +34 -0
  27. package/src/app/api/instance/upgrade/check/route.ts +26 -0
  28. package/src/app/api/instance/upgrade/route.ts +96 -0
  29. package/src/app/api/instance/upgrade/status/route.ts +35 -0
  30. package/src/app/api/memory/route.ts +0 -11
  31. package/src/app/api/notifications/route.ts +4 -2
  32. package/src/app/api/projects/[id]/route.ts +5 -155
  33. package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
  34. package/src/app/api/schedules/[id]/execute/route.ts +111 -0
  35. package/src/app/api/schedules/[id]/route.ts +9 -1
  36. package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
  37. package/src/app/api/schedules/route.ts +3 -12
  38. package/src/app/api/settings/openai/login/route.ts +22 -0
  39. package/src/app/api/settings/openai/logout/route.ts +7 -0
  40. package/src/app/api/settings/openai/route.ts +21 -1
  41. package/src/app/api/settings/providers/route.ts +35 -8
  42. package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
  43. package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
  44. package/src/app/api/tables/[id]/enrich/route.ts +147 -0
  45. package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
  46. package/src/app/api/tasks/[id]/execute/route.ts +0 -21
  47. package/src/app/api/workflows/[id]/resume/route.ts +59 -0
  48. package/src/app/api/workflows/[id]/status/route.ts +22 -8
  49. package/src/app/api/workspace/context/route.ts +2 -0
  50. package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
  51. package/src/app/chat/page.tsx +11 -0
  52. package/src/app/inbox/page.tsx +12 -5
  53. package/src/app/layout.tsx +42 -21
  54. package/src/app/page.tsx +0 -2
  55. package/src/app/settings/page.tsx +6 -9
  56. package/src/components/chat/__tests__/chat-session-provider.test.tsx +408 -0
  57. package/src/components/chat/chat-command-popover.tsx +2 -2
  58. package/src/components/chat/chat-input.tsx +2 -3
  59. package/src/components/chat/chat-session-provider.tsx +720 -0
  60. package/src/components/chat/chat-shell.tsx +92 -401
  61. package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
  62. package/src/components/instance/instance-section.tsx +382 -0
  63. package/src/components/instance/upgrade-badge.tsx +219 -0
  64. package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
  65. package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
  66. package/src/components/notifications/batch-proposal-review.tsx +20 -5
  67. package/src/components/notifications/inbox-list.tsx +11 -2
  68. package/src/components/notifications/notification-item.tsx +56 -2
  69. package/src/components/notifications/pending-approval-host.tsx +56 -37
  70. package/src/components/schedules/schedule-create-sheet.tsx +19 -1
  71. package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
  72. package/src/components/schedules/schedule-form.tsx +31 -0
  73. package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
  74. package/src/components/settings/auth-method-selector.tsx +19 -4
  75. package/src/components/settings/auth-status-badge.tsx +28 -3
  76. package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
  77. package/src/components/settings/openai-runtime-section.tsx +7 -1
  78. package/src/components/settings/providers-runtimes-section.tsx +138 -19
  79. package/src/components/shared/app-sidebar.tsx +4 -3
  80. package/src/components/shared/command-palette.tsx +4 -5
  81. package/src/components/shared/theme-toggle.tsx +5 -24
  82. package/src/components/shared/workspace-indicator.tsx +61 -2
  83. package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
  84. package/src/components/tables/table-create-sheet.tsx +4 -0
  85. package/src/components/tables/table-enrichment-runs.tsx +103 -0
  86. package/src/components/tables/table-enrichment-sheet.tsx +538 -0
  87. package/src/components/tables/table-spreadsheet.tsx +29 -5
  88. package/src/components/tables/table-toolbar.tsx +10 -1
  89. package/src/components/tasks/kanban-board.tsx +1 -0
  90. package/src/components/tasks/kanban-column.tsx +53 -14
  91. package/src/components/tasks/task-bento-grid.tsx +19 -0
  92. package/src/components/tasks/task-card.tsx +26 -3
  93. package/src/components/tasks/task-chip-bar.tsx +24 -0
  94. package/src/components/tasks/task-result-renderer.tsx +1 -1
  95. package/src/components/workflows/delay-step-body.tsx +109 -0
  96. package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
  97. package/src/components/workflows/loop-status-view.tsx +1 -1
  98. package/src/components/workflows/shared/step-result.tsx +78 -0
  99. package/src/components/workflows/shared/workflow-header.tsx +141 -0
  100. package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
  101. package/src/components/workflows/swarm-dashboard.tsx +2 -15
  102. package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
  103. package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
  104. package/src/components/workflows/workflow-form-view.tsx +133 -16
  105. package/src/components/workflows/workflow-status-view.tsx +30 -740
  106. package/src/instrumentation-node.ts +94 -0
  107. package/src/instrumentation.ts +4 -48
  108. package/src/lib/agents/__tests__/claude-agent.test.ts +199 -0
  109. package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
  110. package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
  111. package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
  112. package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
  113. package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
  114. package/src/lib/agents/claude-agent.ts +155 -18
  115. package/src/lib/agents/execution-manager.ts +0 -35
  116. package/src/lib/agents/learned-context.ts +0 -12
  117. package/src/lib/agents/learning-session.ts +18 -5
  118. package/src/lib/agents/profiles/__tests__/registry.test.ts +6 -4
  119. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +70 -0
  120. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +32 -0
  121. package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
  122. package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
  123. package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
  124. package/src/lib/agents/runtime/openai-codex.ts +29 -60
  125. package/src/lib/agents/runtime/types.ts +8 -0
  126. package/src/lib/book/chapter-mapping.ts +11 -0
  127. package/src/lib/book/content.ts +10 -0
  128. package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
  129. package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
  130. package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
  131. package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
  132. package/src/lib/chat/active-streams.ts +27 -0
  133. package/src/lib/chat/codex-engine.ts +16 -17
  134. package/src/lib/chat/context-builder.ts +5 -3
  135. package/src/lib/chat/engine.ts +50 -3
  136. package/src/lib/chat/reconcile.ts +117 -0
  137. package/src/lib/chat/stagent-tools.ts +1 -0
  138. package/src/lib/chat/stream-telemetry.ts +132 -0
  139. package/src/lib/chat/suggested-prompts.ts +28 -1
  140. package/src/lib/chat/system-prompt.ts +26 -1
  141. package/src/lib/chat/tool-catalog.ts +2 -1
  142. package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
  143. package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
  144. package/src/lib/chat/tools/__tests__/task-tools.test.ts +352 -0
  145. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +217 -0
  146. package/src/lib/chat/tools/document-tools.ts +29 -13
  147. package/src/lib/chat/tools/helpers.ts +39 -0
  148. package/src/lib/chat/tools/notification-tools.ts +9 -5
  149. package/src/lib/chat/tools/project-tools.ts +33 -0
  150. package/src/lib/chat/tools/schedule-tools.ts +44 -11
  151. package/src/lib/chat/tools/table-tools.ts +71 -0
  152. package/src/lib/chat/tools/task-tools.ts +84 -20
  153. package/src/lib/chat/tools/workflow-tools.ts +234 -32
  154. package/src/lib/constants/settings.ts +8 -18
  155. package/src/lib/data/__tests__/clear.test.ts +56 -2
  156. package/src/lib/data/clear.ts +20 -15
  157. package/src/lib/data/delete-project.ts +171 -0
  158. package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
  159. package/src/lib/db/bootstrap.ts +45 -16
  160. package/src/lib/db/index.ts +5 -0
  161. package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
  162. package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
  163. package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
  164. package/src/lib/db/migrations/0026_drop_license.sql +3 -0
  165. package/src/lib/db/migrations/meta/_journal.json +21 -0
  166. package/src/lib/db/schema.ts +68 -23
  167. package/src/lib/environment/workspace-context.ts +13 -1
  168. package/src/lib/import/dedup.ts +4 -54
  169. package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
  170. package/src/lib/instance/__tests__/detect.test.ts +115 -0
  171. package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
  172. package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
  173. package/src/lib/instance/__tests__/settings.test.ts +83 -0
  174. package/src/lib/instance/__tests__/upgrade-poller.test.ts +131 -0
  175. package/src/lib/instance/bootstrap.ts +270 -0
  176. package/src/lib/instance/detect.ts +49 -0
  177. package/src/lib/instance/fingerprint.ts +78 -0
  178. package/src/lib/instance/git-ops.ts +95 -0
  179. package/src/lib/instance/settings.ts +61 -0
  180. package/src/lib/instance/types.ts +77 -0
  181. package/src/lib/instance/upgrade-poller.ts +153 -0
  182. package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
  183. package/src/lib/notifications/visibility.ts +33 -0
  184. package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
  185. package/src/lib/schedules/__tests__/config.test.ts +62 -0
  186. package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
  187. package/src/lib/schedules/__tests__/integration.test.ts +82 -0
  188. package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
  189. package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
  190. package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
  191. package/src/lib/schedules/collision-check.ts +105 -0
  192. package/src/lib/schedules/config.ts +53 -0
  193. package/src/lib/schedules/scheduler.ts +232 -13
  194. package/src/lib/schedules/slot-claim.ts +105 -0
  195. package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
  196. package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
  197. package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
  198. package/src/lib/settings/openai-auth.ts +105 -10
  199. package/src/lib/settings/openai-login-manager.ts +260 -0
  200. package/src/lib/settings/runtime-setup.ts +14 -4
  201. package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
  202. package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
  203. package/src/lib/tables/enrichment-planner.ts +454 -0
  204. package/src/lib/tables/enrichment.ts +328 -0
  205. package/src/lib/tables/query-builder.ts +5 -2
  206. package/src/lib/tables/trigger-evaluator.ts +3 -2
  207. package/src/lib/theme.ts +71 -0
  208. package/src/lib/usage/ledger.ts +2 -18
  209. package/src/lib/util/__tests__/similarity.test.ts +106 -0
  210. package/src/lib/util/similarity.ts +77 -0
  211. package/src/lib/utils/format-timestamp.ts +24 -0
  212. package/src/lib/utils/stagent-paths.ts +12 -0
  213. package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
  214. package/src/lib/validators/__tests__/settings.test.ts +10 -0
  215. package/src/lib/validators/blueprint.ts +70 -9
  216. package/src/lib/validators/profile.ts +2 -2
  217. package/src/lib/validators/settings.ts +3 -1
  218. package/src/lib/workflows/__tests__/delay.test.ts +196 -0
  219. package/src/lib/workflows/__tests__/engine.test.ts +8 -0
  220. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  221. package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
  222. package/src/lib/workflows/blueprints/instantiator.ts +22 -1
  223. package/src/lib/workflows/blueprints/types.ts +10 -2
  224. package/src/lib/workflows/delay.ts +106 -0
  225. package/src/lib/workflows/engine.ts +207 -4
  226. package/src/lib/workflows/loop-executor.ts +349 -24
  227. package/src/lib/workflows/post-action.ts +91 -0
  228. package/src/lib/workflows/types.ts +166 -1
  229. package/src/app/api/license/checkout/route.ts +0 -28
  230. package/src/app/api/license/portal/route.ts +0 -26
  231. package/src/app/api/license/route.ts +0 -89
  232. package/src/app/api/license/usage/route.ts +0 -63
  233. package/src/app/api/marketplace/browse/route.ts +0 -15
  234. package/src/app/api/marketplace/import/route.ts +0 -28
  235. package/src/app/api/marketplace/publish/route.ts +0 -40
  236. package/src/app/api/onboarding/email/route.ts +0 -53
  237. package/src/app/api/settings/telemetry/route.ts +0 -14
  238. package/src/app/api/sync/export/route.ts +0 -54
  239. package/src/app/api/sync/restore/route.ts +0 -37
  240. package/src/app/api/sync/sessions/route.ts +0 -24
  241. package/src/app/auth/callback/route.ts +0 -73
  242. package/src/app/marketplace/page.tsx +0 -19
  243. package/src/components/analytics/analytics-gate-card.tsx +0 -101
  244. package/src/components/marketplace/blueprint-card.tsx +0 -61
  245. package/src/components/marketplace/marketplace-browser.tsx +0 -131
  246. package/src/components/onboarding/email-capture-card.tsx +0 -104
  247. package/src/components/settings/activation-form.tsx +0 -95
  248. package/src/components/settings/cloud-account-section.tsx +0 -147
  249. package/src/components/settings/cloud-sync-section.tsx +0 -155
  250. package/src/components/settings/subscription-section.tsx +0 -410
  251. package/src/components/settings/telemetry-section.tsx +0 -80
  252. package/src/components/shared/premium-gate-overlay.tsx +0 -50
  253. package/src/components/shared/schedule-gate-dialog.tsx +0 -64
  254. package/src/components/shared/upgrade-banner.tsx +0 -112
  255. package/src/hooks/use-supabase-auth.ts +0 -79
  256. package/src/lib/billing/email.ts +0 -54
  257. package/src/lib/billing/products.ts +0 -80
  258. package/src/lib/billing/stripe.ts +0 -101
  259. package/src/lib/cloud/supabase-browser.ts +0 -32
  260. package/src/lib/cloud/supabase-client.ts +0 -56
  261. package/src/lib/license/__tests__/features.test.ts +0 -56
  262. package/src/lib/license/__tests__/key-format.test.ts +0 -88
  263. package/src/lib/license/__tests__/manager.test.ts +0 -64
  264. package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
  265. package/src/lib/license/cloud-validation.ts +0 -60
  266. package/src/lib/license/features.ts +0 -44
  267. package/src/lib/license/key-format.ts +0 -101
  268. package/src/lib/license/limit-check.ts +0 -111
  269. package/src/lib/license/limit-queries.ts +0 -51
  270. package/src/lib/license/manager.ts +0 -345
  271. package/src/lib/license/notifications.ts +0 -59
  272. package/src/lib/license/tier-limits.ts +0 -71
  273. package/src/lib/marketplace/marketplace-client.ts +0 -107
  274. package/src/lib/sync/cloud-sync.ts +0 -235
  275. package/src/lib/telemetry/conversion-events.ts +0 -71
  276. package/src/lib/telemetry/queue.ts +0 -122
  277. package/src/lib/validators/license.ts +0 -33
@@ -40,6 +40,21 @@ export const tasks = sqliteTable(
40
40
  workflowRunNumber: integer("workflow_run_number"),
41
41
  /** Resolved per-task budget cap in USD — set by workflow engine for child tasks */
42
42
  maxBudgetUsd: real("max_budget_usd"),
43
+ /** When the slot for this task was atomically claimed */
44
+ slotClaimedAt: integer("slot_claimed_at", { mode: "timestamp" }),
45
+ /** Wall-clock expiry; reaper aborts tasks whose lease has passed */
46
+ leaseExpiresAt: integer("lease_expires_at", { mode: "timestamp" }),
47
+ /**
48
+ * Explicit terminal-state reason written by the runtime adapter at
49
+ * failure/abort transitions (e.g. 'turn_limit_exceeded', 'lease_expired',
50
+ * 'aborted', 'sdk_error'). Distinct from `result` — `result` holds the
51
+ * agent's final output text, while `failureReason` holds a machine-readable
52
+ * classifier that drives scheduler failure-streak logic without re-parsing
53
+ * error prose.
54
+ */
55
+ failureReason: text("failure_reason"),
56
+ /** Per-task turn budget copied from schedules.maxTurns at firing time */
57
+ maxTurns: integer("max_turns"),
43
58
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
44
59
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
45
60
  },
@@ -49,6 +64,7 @@ export const tasks = sqliteTable(
49
64
  index("idx_tasks_workflow_id").on(table.workflowId),
50
65
  index("idx_tasks_schedule_id").on(table.scheduleId),
51
66
  index("idx_tasks_agent_profile").on(table.agentProfile),
67
+ index("idx_tasks_running_scheduled").on(table.status, table.sourceType, table.leaseExpiresAt),
52
68
  ]
53
69
  );
54
70
 
@@ -65,6 +81,13 @@ export const workflows = sqliteTable("workflows", {
65
81
  runNumber: integer("run_number").default(0).notNull(),
66
82
  /** Runtime to use for all steps (nullable — falls back to system default) */
67
83
  runtimeId: text("runtime_id"),
84
+ /**
85
+ * Epoch millisecond timestamp at which a paused (delayed) workflow is due to resume.
86
+ * Null for workflows that are not waiting on a delay step. Indexed via
87
+ * idx_workflows_resume_at (partial index on non-null values) so the scheduler tick
88
+ * can efficiently find due workflows. See features/workflow-step-delays.md.
89
+ */
90
+ resumeAt: integer("resume_at"),
68
91
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
69
92
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
70
93
  });
@@ -210,6 +233,20 @@ export const schedules = sqliteTable(
210
233
  failureStreak: integer("failure_streak").default(0).notNull(),
211
234
  /** Detected reason for the most recent failure (turn_limit_exceeded, timeout, etc.) */
212
235
  lastFailureReason: text("last_failure_reason"),
236
+ /** Hard cap on turns per firing; NULL inherits the global MAX_TURNS setting */
237
+ maxTurns: integer("max_turns"),
238
+ /** Timestamp when maxTurns was last edited — drives first-breach grace */
239
+ maxTurnsSetAt: integer("max_turns_set_at", { mode: "timestamp" }),
240
+ /** Wall-clock lease override in seconds; NULL inherits global default (1200s) */
241
+ maxRunDurationSec: integer("max_run_duration_sec"),
242
+ /**
243
+ * Counter separate from failureStreak — increments only on maxTurns breach.
244
+ * Reset to 0 on any non-breach outcome (successful run, generic failure, or
245
+ * first-breach grace window after maxTurnsSetAt). Auto-pause at 5. This
246
+ * higher threshold + grace window protects users from tripping auto-pause
247
+ * via a misconfigured maxTurns edit.
248
+ */
249
+ turnBudgetBreachStreak: integer("turn_budget_breach_streak").default(0).notNull(),
213
250
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
214
251
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
215
252
  },
@@ -307,6 +344,7 @@ export const usageLedger = sqliteTable(
307
344
  "context_summarization",
308
345
  "chat_turn",
309
346
  "profile_assist",
347
+ "manual_force_bypass",
310
348
  ],
311
349
  }).notNull(),
312
350
  runtimeId: text("runtime_id").notNull(),
@@ -1162,29 +1200,6 @@ export const snapshots = sqliteTable(
1162
1200
 
1163
1201
  export type SnapshotRow = InferSelectModel<typeof snapshots>;
1164
1202
 
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
1203
  // ── Workflow Execution Stats ─────────────────────────────────────────
1189
1204
 
1190
1205
  export const workflowExecutionStats = sqliteTable("workflow_execution_stats", {
@@ -1212,3 +1227,33 @@ export const workflowExecutionStats = sqliteTable("workflow_execution_stats", {
1212
1227
  });
1213
1228
 
1214
1229
  export type WorkflowExecutionStatsRow = InferSelectModel<typeof workflowExecutionStats>;
1230
+
1231
+ // ── Schedule Firing Metrics ───────────────────────────────────────────
1232
+
1233
+ export const scheduleFiringMetrics = sqliteTable(
1234
+ "schedule_firing_metrics",
1235
+ {
1236
+ id: text("id").primaryKey(),
1237
+ scheduleId: text("schedule_id")
1238
+ .references(() => schedules.id)
1239
+ .notNull(),
1240
+ taskId: text("task_id").references(() => tasks.id),
1241
+ firedAt: integer("fired_at", { mode: "timestamp" }).notNull(),
1242
+ slotClaimedAt: integer("slot_claimed_at", { mode: "timestamp" }),
1243
+ completedAt: integer("completed_at", { mode: "timestamp" }),
1244
+ slotWaitMs: integer("slot_wait_ms"),
1245
+ durationMs: integer("duration_ms"),
1246
+ turnCount: integer("turn_count"),
1247
+ maxTurnsAtFiring: integer("max_turns_at_firing"),
1248
+ eventLoopLagMs: real("event_loop_lag_ms"),
1249
+ peakRssMb: integer("peak_rss_mb"),
1250
+ chatStreamsActive: integer("chat_streams_active"),
1251
+ concurrentSchedules: integer("concurrent_schedules"),
1252
+ failureReason: text("failure_reason"),
1253
+ },
1254
+ (table) => [
1255
+ index("idx_sfm_schedule_time").on(table.scheduleId, table.firedAt),
1256
+ ]
1257
+ );
1258
+
1259
+ export type ScheduleFiringMetricRow = InferSelectModel<typeof scheduleFiringMetrics>;
@@ -3,6 +3,8 @@ import { homedir } from "os";
3
3
  import { execFileSync } from "child_process";
4
4
  import { statSync } from "fs";
5
5
  import { join } from "path";
6
+ import { getStagentDataDir } from "@/lib/utils/stagent-paths";
7
+ import { isDevMode, isPrivateInstance } from "@/lib/instance/detect";
6
8
 
7
9
  /** The directory the user launched stagent from (falls back to process.cwd()). */
8
10
  export function getLaunchCwd(): string {
@@ -15,6 +17,8 @@ export interface WorkspaceContext {
15
17
  parentPath: string;
16
18
  gitBranch: string | null;
17
19
  isWorktree: boolean;
20
+ dataDir: string;
21
+ dataDirMismatch: boolean;
18
22
  }
19
23
 
20
24
  export function getWorkspaceContext(): WorkspaceContext {
@@ -47,5 +51,13 @@ export function getWorkspaceContext(): WorkspaceContext {
47
51
  // no .git at all
48
52
  }
49
53
 
50
- return { cwd, folderName, parentPath, gitBranch, isWorktree };
54
+ const rawDataDir = getStagentDataDir();
55
+ const dataDir = rawDataDir.startsWith(home)
56
+ ? "~" + rawDataDir.slice(home.length)
57
+ : rawDataDir;
58
+
59
+ // Red flag: non-main repo using the default shared DB instead of its own
60
+ const dataDirMismatch = !isDevMode(cwd) && !isPrivateInstance();
61
+
62
+ return { cwd, folderName, parentPath, gitBranch, isWorktree, dataDir, dataDirMismatch };
51
63
  }
@@ -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
+ });