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
@@ -0,0 +1,1691 @@
1
+ # Instance Bootstrap & Branch Guardrails Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add an idempotent first-boot installer that establishes branch discipline (creates a `local` branch, installs a consent-gated pre-push hook) for every git-clone user of stagent, without breaking the canonical dev repo.
6
+
7
+ **Architecture:** New `src/lib/instance/` module with 5 files (types, detect, git-ops, settings, bootstrap). Two-phase execution: Phase A (non-destructive — instanceId generation + local branch creation) runs on every first boot; Phase B (pre-push hook + pushRemote config) requires explicit user consent via a first-boot notification. Layered dev-mode gates (`STAGENT_DEV_MODE` env var, `.git/stagent-dev-mode` sentinel, `STAGENT_INSTANCE_MODE` override) short-circuit the entire module in the canonical dev repo. Integrated into `src/instrumentation.ts` before scheduler startup.
8
+
9
+ **Tech Stack:** TypeScript, Node `execFileSync` (argv arrays, no shell), better-sqlite3 settings table, Drizzle ORM, vitest, Next.js `register()` instrumentation hook.
10
+
11
+ ---
12
+
13
+ ## NOT in scope
14
+
15
+ - **Upgrade detection / polling / badge** — Delivered by `upgrade-detection` feature; depends on this plan's `settings.instance` schema and `git-ops.ts` wrapper.
16
+ - **Upgrade session UI / upgrade-assistant profile** — Delivered by `upgrade-session` feature.
17
+ - **Machine fingerprint generator** — Explicitly descoped via REDUCE compression; delivered entirely by `instance-license-metering` feature when needed.
18
+ - **`src/lib/instance/hooks/pre-push.sh` standalone file** — Explicitly descoped via REDUCE compression; the hook template is a string constant in `bootstrap.ts`, avoiding file-resolution complexity across dev/tsup/npm/npx.
19
+ - **Cloud license seat counting** — Separate `instance-license-metering` workstream, server-side.
20
+ - **Visual diff/merge UI for conflict resolution** — `upgrade-session` scope.
21
+ - **Auto-apply upgrades without user consent** — Out of scope by product principle.
22
+ - **Multi-instance listing, switching, or presets** — Future "Instance Manager" feature; explicitly rejected for now.
23
+ - **Settings → Instance UI section** — Delivered by `upgrade-session` feature.
24
+ - **`src/app/api/instance/*` routes** — Delivered by `upgrade-session` feature.
25
+
26
+ ## What already exists
27
+
28
+ Reusable code and patterns confirmed during scope challenge:
29
+
30
+ - **`src/lib/utils/stagent-paths.ts:4`** — `getStagentDataDir()` provides the `STAGENT_DATA_DIR || ~/.stagent` fallback. Private-instance detection is a single comparison against `join(homedir(), ".stagent")`.
31
+ - **`src/lib/settings/helpers.ts`** — `getSettingSync(key)` and `setSetting(key, value)` are the canonical read/write helpers for the `settings` key-value table. Synchronous is safe (better-sqlite3).
32
+ - **`src/lib/db/schema.ts:284`** — `settings` table (`key` PK, `value` TEXT, `updatedAt` epoch). No schema changes needed; this plan stores JSON-in-TEXT per TDR-011.
33
+ - **`src/instrumentation.ts:1-30`** — Next.js `register()` hook with dynamic imports inside a `try/catch`. Pattern: import module, call startup function, log error but don't crash. The new `ensureInstance()` call follows the same shape.
34
+ - **`src/lib/notifications/actionable.ts:12-17`** — Notification actions model with `ApprovalActionId` union type. The consent prompt follows this pattern with custom action IDs.
35
+ - **`bin/sync-worktree.sh:8-19`** — Worktree detection via `git rev-parse --git-common-dir` vs `--git-dir`. Port the logic into `detect.ts` so the module correctly handles stagent worktree setups.
36
+ - **`src/lib/settings/__tests__/budget-guardrails.test.ts:1-29`** — Reference vitest pattern: `mkdtempSync` for temp dirs, `vi.stubEnv("STAGENT_DATA_DIR", tempDir)` for isolation, `vi.resetModules()` between test cases, dynamic imports for modules that read env at load time.
37
+ - **`better-sqlite3` API** — All DB operations are synchronous; no need for async/await in `settings.ts`.
38
+ - **`node:child_process` `execFileSync`** — Accepts `(file, args[])` with strict argv separation; does NOT invoke a shell. This is the only safe git invocation pattern for this module.
39
+
40
+ ## Error & Rescue Registry
41
+
42
+ | Error | Trigger | Impact | Rescue |
43
+ |---|---|---|---|
44
+ | `execFileSync("git", ...)` throws | Git not installed, corrupt repo, rebase in progress | `ensureInstance()` aborts this step | Catch in the specific `ensureX()` function, log to console, return failure for that step, continue with other steps. Bootstrap never crashes the app. |
45
+ | `writeFileSync` to `.git/hooks/pre-push` fails | Read-only FS, permission denied | Hook not installed | Log warning, mark guardrails as `installation_failed` in settings, continue boot |
46
+ | `settings.instance` JSON parse fails | DB corruption, concurrent write collision | Config read returns `null`, bootstrap re-generates | Wrap JSON.parse in try/catch; on failure, treat as missing config and re-run `ensureInstanceConfig()` |
47
+ | Pre-existing non-stagent pre-push hook | User has their own hook | Our install would overwrite it | Backup to `pre-push.stagent-backup` before writing ours; log warning |
48
+ | `.git/rebase-merge` present | User mid-rebase during `npm run dev` | Any git op might fail | `ensureLocalBranch()` detects the directory, skips branch creation, logs warning. User finishes rebase, next boot runs normally. |
49
+ | User sets `STAGENT_DEV_MODE=true` in production env by mistake | Env var leak from shell config | All guardrails disabled on a real instance | Non-issue — dev mode gate is opt-out; worst case user runs without guardrails until they remove the flag. Documented as safe. |
50
+ | Both dev-mode gates fail simultaneously on main dev repo | Contributor forgets env var AND sentinel is missing | Bootstrap runs in dev repo → creates `local` branch AND emits consent notification | Non-destructive Phase A is safe; Phase B is consent-gated so nothing breaks. User declines consent, manually deletes `local` branch if desired. |
51
+ | `notifications` table insert fails during consent creation | DB lock, disk full | Consent prompt missing, user never sees it | Log error, retry on next boot (consent state remains `not_yet`) |
52
+ | Settings write succeeds but consent notification insert fails | Partial failure mid-bootstrap | Inconsistent state | Wrap Phase A in a transaction where possible; for cross-table updates, use a reconciliation step on next boot that detects `consentStatus=not_yet` without a pending notification and re-creates it |
53
+
54
+ ---
55
+
56
+ ## File Structure
57
+
58
+ **New files:**
59
+ ```
60
+ src/lib/instance/
61
+ types.ts # InstanceConfig, Guardrails, EnsureResult, GitOps interfaces
62
+ detect.ts # isDevMode(), hasGitDir(), isPrivateInstance(), detectRebaseInProgress()
63
+ git-ops.ts # GitOps implementation + factory; injectable interface for tests
64
+ settings.ts # getInstanceConfig(), setInstanceConfig(), getGuardrails(), setGuardrails()
65
+ bootstrap.ts # ensureInstance() orchestrator + Phase A/B functions + consent flow + hook template constant
66
+ __tests__/
67
+ detect.test.ts
68
+ git-ops.test.ts
69
+ settings.test.ts
70
+ bootstrap.test.ts
71
+ ```
72
+
73
+ **Modified files:**
74
+ ```
75
+ src/instrumentation.ts # Add ensureInstance() call before scheduler startup
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Task 1: Define types and interfaces
81
+
82
+ **Files:**
83
+ - Create: `src/lib/instance/types.ts`
84
+
85
+ - [ ] **Step 1: Write the types file**
86
+
87
+ ```typescript
88
+ /**
89
+ * Instance bootstrap shared types.
90
+ * See features/instance-bootstrap.md for full design rationale.
91
+ */
92
+
93
+ export interface InstanceConfig {
94
+ instanceId: string;
95
+ branchName: string;
96
+ isPrivateInstance: boolean;
97
+ createdAt: number;
98
+ }
99
+
100
+ export type ConsentStatus = "not_yet" | "enabled" | "declined_permanently";
101
+
102
+ export interface Guardrails {
103
+ prePushHookInstalled: boolean;
104
+ prePushHookVersion: string;
105
+ pushRemoteBlocked: string[];
106
+ consentStatus: ConsentStatus;
107
+ firstBootCompletedAt: number | null;
108
+ }
109
+
110
+ export type EnsureSkipReason =
111
+ | "dev_mode_env"
112
+ | "dev_mode_sentinel"
113
+ | "no_git"
114
+ | "rebase_in_progress";
115
+
116
+ export type EnsureStepStatus = "ok" | "skipped" | "failed";
117
+
118
+ export interface EnsureStepResult {
119
+ step: string;
120
+ status: EnsureStepStatus;
121
+ reason?: string;
122
+ }
123
+
124
+ export interface EnsureResult {
125
+ skipped?: EnsureSkipReason;
126
+ steps: EnsureStepResult[];
127
+ }
128
+
129
+ /**
130
+ * Injectable wrapper around git commands.
131
+ * Real implementation in git-ops.ts uses execFileSync.
132
+ * Tests provide a mock implementation.
133
+ */
134
+ export interface GitOps {
135
+ /** Returns true if the current working directory is inside a git repo (not a worktree of the main repo). */
136
+ isGitRepo(): boolean;
137
+ /** Returns the absolute path to the .git directory for the current repo. */
138
+ getGitDir(): string;
139
+ /** Returns the currently checked-out branch name, or null if detached HEAD. */
140
+ getCurrentBranch(): string | null;
141
+ /** Returns true if a branch with the given name exists locally. */
142
+ branchExists(name: string): boolean;
143
+ /** Creates a new branch at the current HEAD and checks it out. */
144
+ createAndCheckoutBranch(name: string): void;
145
+ /** Sets a git config value. Throws on failure. */
146
+ setConfig(key: string, value: string): void;
147
+ }
148
+ ```
149
+
150
+ - [ ] **Step 2: Run tsc to verify types compile**
151
+
152
+ Run: `npx tsc --noEmit src/lib/instance/types.ts`
153
+ Expected: No output (types valid)
154
+
155
+ - [ ] **Step 3: Commit**
156
+
157
+ ```bash
158
+ git add src/lib/instance/types.ts
159
+ git commit -m "feat(instance): add type definitions for bootstrap module"
160
+ ```
161
+
162
+ ---
163
+
164
+ ## Task 2: Implement detect.ts (dev-mode gates, git presence, private instance detection)
165
+
166
+ **Files:**
167
+ - Create: `src/lib/instance/detect.ts`
168
+ - Test: `src/lib/instance/__tests__/detect.test.ts`
169
+
170
+ - [ ] **Step 1: Write the failing test**
171
+
172
+ ```typescript
173
+ // src/lib/instance/__tests__/detect.test.ts
174
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
175
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
176
+ import { join } from "path";
177
+ import { tmpdir, homedir } from "os";
178
+
179
+ let tempDir: string;
180
+ let gitDir: string;
181
+
182
+ beforeEach(() => {
183
+ tempDir = mkdtempSync(join(tmpdir(), "stagent-detect-"));
184
+ gitDir = join(tempDir, ".git");
185
+ mkdirSync(gitDir, { recursive: true });
186
+ vi.resetModules();
187
+ vi.unstubAllEnvs();
188
+ });
189
+
190
+ afterEach(() => {
191
+ vi.unstubAllEnvs();
192
+ rmSync(tempDir, { recursive: true, force: true });
193
+ });
194
+
195
+ async function loadDetect() {
196
+ return await import("../detect");
197
+ }
198
+
199
+ describe("isDevMode", () => {
200
+ it("returns true when STAGENT_DEV_MODE=true", async () => {
201
+ vi.stubEnv("STAGENT_DEV_MODE", "true");
202
+ const { isDevMode } = await loadDetect();
203
+ expect(isDevMode(tempDir)).toBe(true);
204
+ });
205
+
206
+ it("returns true when .git/stagent-dev-mode sentinel file exists", async () => {
207
+ writeFileSync(join(gitDir, "stagent-dev-mode"), "");
208
+ const { isDevMode } = await loadDetect();
209
+ expect(isDevMode(tempDir)).toBe(true);
210
+ });
211
+
212
+ it("returns false when neither gate is set", async () => {
213
+ const { isDevMode } = await loadDetect();
214
+ expect(isDevMode(tempDir)).toBe(false);
215
+ });
216
+
217
+ it("returns false when STAGENT_INSTANCE_MODE=true overrides env gate", async () => {
218
+ vi.stubEnv("STAGENT_DEV_MODE", "true");
219
+ vi.stubEnv("STAGENT_INSTANCE_MODE", "true");
220
+ const { isDevMode } = await loadDetect();
221
+ expect(isDevMode(tempDir)).toBe(false);
222
+ });
223
+
224
+ it("returns false when STAGENT_INSTANCE_MODE=true overrides sentinel gate", async () => {
225
+ writeFileSync(join(gitDir, "stagent-dev-mode"), "");
226
+ vi.stubEnv("STAGENT_INSTANCE_MODE", "true");
227
+ const { isDevMode } = await loadDetect();
228
+ expect(isDevMode(tempDir)).toBe(false);
229
+ });
230
+ });
231
+
232
+ describe("hasGitDir", () => {
233
+ it("returns true when .git directory exists", async () => {
234
+ const { hasGitDir } = await loadDetect();
235
+ expect(hasGitDir(tempDir)).toBe(true);
236
+ });
237
+
238
+ it("returns false when .git is absent", async () => {
239
+ rmSync(gitDir, { recursive: true, force: true });
240
+ const { hasGitDir } = await loadDetect();
241
+ expect(hasGitDir(tempDir)).toBe(false);
242
+ });
243
+ });
244
+
245
+ describe("isPrivateInstance", () => {
246
+ it("returns false when STAGENT_DATA_DIR is unset", async () => {
247
+ const { isPrivateInstance } = await loadDetect();
248
+ expect(isPrivateInstance()).toBe(false);
249
+ });
250
+
251
+ it("returns false when STAGENT_DATA_DIR equals default ~/.stagent", async () => {
252
+ vi.stubEnv("STAGENT_DATA_DIR", join(homedir(), ".stagent"));
253
+ const { isPrivateInstance } = await loadDetect();
254
+ expect(isPrivateInstance()).toBe(false);
255
+ });
256
+
257
+ it("returns true when STAGENT_DATA_DIR is a custom path", async () => {
258
+ vi.stubEnv("STAGENT_DATA_DIR", "/Users/navam/.stagent-wealth");
259
+ const { isPrivateInstance } = await loadDetect();
260
+ expect(isPrivateInstance()).toBe(true);
261
+ });
262
+ });
263
+
264
+ describe("detectRebaseInProgress", () => {
265
+ it("returns true when .git/rebase-merge exists", async () => {
266
+ mkdirSync(join(gitDir, "rebase-merge"));
267
+ const { detectRebaseInProgress } = await loadDetect();
268
+ expect(detectRebaseInProgress(tempDir)).toBe(true);
269
+ });
270
+
271
+ it("returns true when .git/rebase-apply exists", async () => {
272
+ mkdirSync(join(gitDir, "rebase-apply"));
273
+ const { detectRebaseInProgress } = await loadDetect();
274
+ expect(detectRebaseInProgress(tempDir)).toBe(true);
275
+ });
276
+
277
+ it("returns false when no rebase state directories exist", async () => {
278
+ const { detectRebaseInProgress } = await loadDetect();
279
+ expect(detectRebaseInProgress(tempDir)).toBe(false);
280
+ });
281
+ });
282
+ ```
283
+
284
+ - [ ] **Step 2: Run test to verify it fails**
285
+
286
+ Run: `npx vitest run src/lib/instance/__tests__/detect.test.ts`
287
+ Expected: FAIL with "Cannot find module '../detect'"
288
+
289
+ - [ ] **Step 3: Write the implementation**
290
+
291
+ ```typescript
292
+ // src/lib/instance/detect.ts
293
+ import { existsSync } from "fs";
294
+ import { join } from "path";
295
+ import { homedir } from "os";
296
+
297
+ /**
298
+ * Returns true if the current environment is the canonical stagent dev repo
299
+ * and should skip all instance bootstrap operations.
300
+ *
301
+ * Layered gates:
302
+ * 1. STAGENT_DEV_MODE=true env var (primary, per-developer)
303
+ * 2. .git/stagent-dev-mode sentinel file (secondary, git-dir-scoped)
304
+ *
305
+ * Override: STAGENT_INSTANCE_MODE=true forces bootstrap to run even in dev
306
+ * mode, so contributors can test the feature in the main repo.
307
+ */
308
+ export function isDevMode(cwd: string = process.cwd()): boolean {
309
+ // Opt-in override beats opt-out gates
310
+ if (process.env.STAGENT_INSTANCE_MODE === "true") return false;
311
+
312
+ // Gate 1: env var
313
+ if (process.env.STAGENT_DEV_MODE === "true") return true;
314
+
315
+ // Gate 2: sentinel file inside .git (never cloned, never committed)
316
+ if (existsSync(join(cwd, ".git", "stagent-dev-mode"))) return true;
317
+
318
+ return false;
319
+ }
320
+
321
+ /** Returns true if a .git directory exists at the given path. */
322
+ export function hasGitDir(cwd: string = process.cwd()): boolean {
323
+ return existsSync(join(cwd, ".git"));
324
+ }
325
+
326
+ /**
327
+ * Returns true if STAGENT_DATA_DIR is set to a non-default path,
328
+ * indicating this clone is running as an isolated private instance.
329
+ */
330
+ export function isPrivateInstance(): boolean {
331
+ const override = process.env.STAGENT_DATA_DIR;
332
+ if (!override) return false;
333
+ const defaultDir = join(homedir(), ".stagent");
334
+ return override !== defaultDir;
335
+ }
336
+
337
+ /**
338
+ * Returns true if a rebase is in progress in the current repo.
339
+ * Both rebase-merge (interactive) and rebase-apply (non-interactive) are detected.
340
+ */
341
+ export function detectRebaseInProgress(cwd: string = process.cwd()): boolean {
342
+ const gitDir = join(cwd, ".git");
343
+ return (
344
+ existsSync(join(gitDir, "rebase-merge")) ||
345
+ existsSync(join(gitDir, "rebase-apply"))
346
+ );
347
+ }
348
+ ```
349
+
350
+ - [ ] **Step 4: Run test to verify it passes**
351
+
352
+ Run: `npx vitest run src/lib/instance/__tests__/detect.test.ts`
353
+ Expected: PASS — 13 tests passing
354
+
355
+ - [ ] **Step 5: Commit**
356
+
357
+ ```bash
358
+ git add src/lib/instance/detect.ts src/lib/instance/__tests__/detect.test.ts
359
+ git commit -m "feat(instance): add dev-mode gates and clone detection"
360
+ ```
361
+
362
+ ---
363
+
364
+ ## Task 3: Implement git-ops.ts with injectable interface
365
+
366
+ **Files:**
367
+ - Create: `src/lib/instance/git-ops.ts`
368
+ - Test: `src/lib/instance/__tests__/git-ops.test.ts`
369
+
370
+ - [ ] **Step 1: Write the failing test (uses a real temp-dir git repo)**
371
+
372
+ ```typescript
373
+ // src/lib/instance/__tests__/git-ops.test.ts
374
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
375
+ import { execFileSync } from "child_process";
376
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
377
+ import { join } from "path";
378
+ import { tmpdir } from "os";
379
+
380
+ let tempDir: string;
381
+
382
+ function runGit(args: string[], cwd: string) {
383
+ execFileSync("git", args, { cwd, stdio: "pipe" });
384
+ }
385
+
386
+ beforeEach(() => {
387
+ tempDir = mkdtempSync(join(tmpdir(), "stagent-git-ops-"));
388
+ runGit(["init", "-b", "main"], tempDir);
389
+ runGit(["config", "user.email", "test@example.com"], tempDir);
390
+ runGit(["config", "user.name", "Test"], tempDir);
391
+ writeFileSync(join(tempDir, "README.md"), "# test\n");
392
+ runGit(["add", "README.md"], tempDir);
393
+ runGit(["commit", "-m", "initial"], tempDir);
394
+ });
395
+
396
+ afterEach(() => {
397
+ rmSync(tempDir, { recursive: true, force: true });
398
+ });
399
+
400
+ describe("RealGitOps", () => {
401
+ it("isGitRepo returns true in a real repo", async () => {
402
+ const { createGitOps } = await import("../git-ops");
403
+ const ops = createGitOps(tempDir);
404
+ expect(ops.isGitRepo()).toBe(true);
405
+ });
406
+
407
+ it("isGitRepo returns false outside a git repo", async () => {
408
+ const nonRepo = mkdtempSync(join(tmpdir(), "stagent-nogit-"));
409
+ try {
410
+ const { createGitOps } = await import("../git-ops");
411
+ const ops = createGitOps(nonRepo);
412
+ expect(ops.isGitRepo()).toBe(false);
413
+ } finally {
414
+ rmSync(nonRepo, { recursive: true, force: true });
415
+ }
416
+ });
417
+
418
+ it("getCurrentBranch returns main after init", async () => {
419
+ const { createGitOps } = await import("../git-ops");
420
+ const ops = createGitOps(tempDir);
421
+ expect(ops.getCurrentBranch()).toBe("main");
422
+ });
423
+
424
+ it("branchExists returns true for main, false for missing", async () => {
425
+ const { createGitOps } = await import("../git-ops");
426
+ const ops = createGitOps(tempDir);
427
+ expect(ops.branchExists("main")).toBe(true);
428
+ expect(ops.branchExists("local")).toBe(false);
429
+ });
430
+
431
+ it("createAndCheckoutBranch creates local at current HEAD", async () => {
432
+ const { createGitOps } = await import("../git-ops");
433
+ const ops = createGitOps(tempDir);
434
+ const mainSha = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
435
+ ops.createAndCheckoutBranch("local");
436
+ expect(ops.getCurrentBranch()).toBe("local");
437
+ expect(ops.branchExists("local")).toBe(true);
438
+ const localSha = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
439
+ expect(localSha).toBe(mainSha);
440
+ // main is not modified
441
+ const mainShaAfter = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
442
+ expect(mainShaAfter).toBe(mainSha);
443
+ });
444
+
445
+ it("setConfig writes branch.local.pushRemote", async () => {
446
+ const { createGitOps } = await import("../git-ops");
447
+ const ops = createGitOps(tempDir);
448
+ ops.createAndCheckoutBranch("local");
449
+ ops.setConfig("branch.local.pushRemote", "no_push");
450
+ const value = execFileSync("git", ["config", "--get", "branch.local.pushRemote"], { cwd: tempDir, encoding: "utf-8" }).trim();
451
+ expect(value).toBe("no_push");
452
+ });
453
+
454
+ it("getGitDir returns absolute path to .git directory", async () => {
455
+ const { createGitOps } = await import("../git-ops");
456
+ const ops = createGitOps(tempDir);
457
+ expect(ops.getGitDir()).toBe(join(tempDir, ".git"));
458
+ });
459
+ });
460
+ ```
461
+
462
+ - [ ] **Step 2: Run test to verify it fails**
463
+
464
+ Run: `npx vitest run src/lib/instance/__tests__/git-ops.test.ts`
465
+ Expected: FAIL with "Cannot find module '../git-ops'"
466
+
467
+ - [ ] **Step 3: Write the implementation**
468
+
469
+ ```typescript
470
+ // src/lib/instance/git-ops.ts
471
+ import { execFileSync } from "child_process";
472
+ import { existsSync } from "fs";
473
+ import { join } from "path";
474
+ import type { GitOps } from "./types";
475
+
476
+ /**
477
+ * Real git operations wrapper. All commands use execFileSync with argv arrays —
478
+ * no shell interpolation, ever. File is the literal "git"; user-provided values
479
+ * flow through the args array which git parses without shell involvement.
480
+ */
481
+ export function createGitOps(cwd: string = process.cwd()): GitOps {
482
+ function run(args: string[]): string {
483
+ return execFileSync("git", args, {
484
+ cwd,
485
+ encoding: "utf-8",
486
+ stdio: ["ignore", "pipe", "pipe"],
487
+ }).trim();
488
+ }
489
+
490
+ return {
491
+ isGitRepo(): boolean {
492
+ try {
493
+ run(["rev-parse", "--is-inside-work-tree"]);
494
+ return true;
495
+ } catch {
496
+ return false;
497
+ }
498
+ },
499
+
500
+ getGitDir(): string {
501
+ return join(cwd, ".git");
502
+ },
503
+
504
+ getCurrentBranch(): string | null {
505
+ try {
506
+ const branch = run(["rev-parse", "--abbrev-ref", "HEAD"]);
507
+ return branch === "HEAD" ? null : branch;
508
+ } catch {
509
+ return null;
510
+ }
511
+ },
512
+
513
+ branchExists(name: string): boolean {
514
+ try {
515
+ run(["rev-parse", "--verify", `refs/heads/${name}`]);
516
+ return true;
517
+ } catch {
518
+ return false;
519
+ }
520
+ },
521
+
522
+ createAndCheckoutBranch(name: string): void {
523
+ run(["checkout", "-b", name]);
524
+ },
525
+
526
+ setConfig(key: string, value: string): void {
527
+ run(["config", key, value]);
528
+ },
529
+ };
530
+ }
531
+
532
+ /** Test helper: detect if execFileSync would find git on this system. */
533
+ export function isGitAvailable(): boolean {
534
+ try {
535
+ execFileSync("git", ["--version"], { stdio: "ignore" });
536
+ return true;
537
+ } catch {
538
+ return false;
539
+ }
540
+ }
541
+ ```
542
+
543
+ - [ ] **Step 4: Run test to verify it passes**
544
+
545
+ Run: `npx vitest run src/lib/instance/__tests__/git-ops.test.ts`
546
+ Expected: PASS — 7 tests passing
547
+
548
+ - [ ] **Step 5: Commit**
549
+
550
+ ```bash
551
+ git add src/lib/instance/git-ops.ts src/lib/instance/__tests__/git-ops.test.ts
552
+ git commit -m "feat(instance): add injectable git-ops wrapper"
553
+ ```
554
+
555
+ ---
556
+
557
+ ## Task 4: Implement settings.ts (typed wrappers around settings table)
558
+
559
+ **Files:**
560
+ - Create: `src/lib/instance/settings.ts`
561
+ - Test: `src/lib/instance/__tests__/settings.test.ts`
562
+
563
+ - [ ] **Step 1: Write the failing test**
564
+
565
+ ```typescript
566
+ // src/lib/instance/__tests__/settings.test.ts
567
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
568
+ import { mkdtempSync, rmSync } from "fs";
569
+ import { join } from "path";
570
+ import { tmpdir } from "os";
571
+
572
+ let tempDir: string;
573
+
574
+ beforeEach(() => {
575
+ tempDir = mkdtempSync(join(tmpdir(), "stagent-instance-settings-"));
576
+ vi.resetModules();
577
+ vi.stubEnv("STAGENT_DATA_DIR", tempDir);
578
+ });
579
+
580
+ afterEach(() => {
581
+ vi.unstubAllEnvs();
582
+ rmSync(tempDir, { recursive: true, force: true });
583
+ });
584
+
585
+ async function loadModule() {
586
+ return await import("../settings");
587
+ }
588
+
589
+ describe("getInstanceConfig / setInstanceConfig", () => {
590
+ it("returns null before any config is written", async () => {
591
+ const { getInstanceConfig } = await loadModule();
592
+ expect(getInstanceConfig()).toBeNull();
593
+ });
594
+
595
+ it("round-trips a config through set/get", async () => {
596
+ const { setInstanceConfig, getInstanceConfig } = await loadModule();
597
+ setInstanceConfig({
598
+ instanceId: "abc-123",
599
+ branchName: "local",
600
+ isPrivateInstance: false,
601
+ createdAt: 1700000000,
602
+ });
603
+ const config = getInstanceConfig();
604
+ expect(config).toEqual({
605
+ instanceId: "abc-123",
606
+ branchName: "local",
607
+ isPrivateInstance: false,
608
+ createdAt: 1700000000,
609
+ });
610
+ });
611
+
612
+ it("returns null when stored value is corrupt JSON", async () => {
613
+ const { getSettingSync, setSetting } = await import("@/lib/settings/helpers");
614
+ await setSetting("instance", "not-valid-json");
615
+ const { getInstanceConfig } = await loadModule();
616
+ expect(getInstanceConfig()).toBeNull();
617
+ // Sanity: raw value is still the corrupt string
618
+ expect(getSettingSync("instance")).toBe("not-valid-json");
619
+ });
620
+ });
621
+
622
+ describe("getGuardrails / setGuardrails", () => {
623
+ it("returns defaults before any guardrails are written", async () => {
624
+ const { getGuardrails } = await loadModule();
625
+ expect(getGuardrails()).toEqual({
626
+ prePushHookInstalled: false,
627
+ prePushHookVersion: "",
628
+ pushRemoteBlocked: [],
629
+ consentStatus: "not_yet",
630
+ firstBootCompletedAt: null,
631
+ });
632
+ });
633
+
634
+ it("round-trips guardrails through set/get", async () => {
635
+ const { setGuardrails, getGuardrails } = await loadModule();
636
+ setGuardrails({
637
+ prePushHookInstalled: true,
638
+ prePushHookVersion: "1.0.0",
639
+ pushRemoteBlocked: ["local", "wealth-mgr"],
640
+ consentStatus: "enabled",
641
+ firstBootCompletedAt: 1700000000,
642
+ });
643
+ expect(getGuardrails()).toEqual({
644
+ prePushHookInstalled: true,
645
+ prePushHookVersion: "1.0.0",
646
+ pushRemoteBlocked: ["local", "wealth-mgr"],
647
+ consentStatus: "enabled",
648
+ firstBootCompletedAt: 1700000000,
649
+ });
650
+ });
651
+ });
652
+ ```
653
+
654
+ - [ ] **Step 2: Run test to verify it fails**
655
+
656
+ Run: `npx vitest run src/lib/instance/__tests__/settings.test.ts`
657
+ Expected: FAIL with "Cannot find module '../settings'"
658
+
659
+ - [ ] **Step 3: Write the implementation**
660
+
661
+ ```typescript
662
+ // src/lib/instance/settings.ts
663
+ import { getSettingSync, setSetting } from "@/lib/settings/helpers";
664
+ import type { InstanceConfig, Guardrails } from "./types";
665
+
666
+ const INSTANCE_KEY = "instance";
667
+ const GUARDRAILS_KEY = "instance.guardrails";
668
+
669
+ const DEFAULT_GUARDRAILS: Guardrails = {
670
+ prePushHookInstalled: false,
671
+ prePushHookVersion: "",
672
+ pushRemoteBlocked: [],
673
+ consentStatus: "not_yet",
674
+ firstBootCompletedAt: null,
675
+ };
676
+
677
+ function readJson<T>(key: string): T | null {
678
+ const raw = getSettingSync(key);
679
+ if (raw === null) return null;
680
+ try {
681
+ return JSON.parse(raw) as T;
682
+ } catch {
683
+ return null;
684
+ }
685
+ }
686
+
687
+ export function getInstanceConfig(): InstanceConfig | null {
688
+ return readJson<InstanceConfig>(INSTANCE_KEY);
689
+ }
690
+
691
+ export function setInstanceConfig(config: InstanceConfig): void {
692
+ // setSetting is async but wraps a sync better-sqlite3 call;
693
+ // fire-and-forget is safe here because the underlying operation completes synchronously.
694
+ void setSetting(INSTANCE_KEY, JSON.stringify(config));
695
+ }
696
+
697
+ export function getGuardrails(): Guardrails {
698
+ return readJson<Guardrails>(GUARDRAILS_KEY) ?? { ...DEFAULT_GUARDRAILS };
699
+ }
700
+
701
+ export function setGuardrails(guardrails: Guardrails): void {
702
+ void setSetting(GUARDRAILS_KEY, JSON.stringify(guardrails));
703
+ }
704
+ ```
705
+
706
+ - [ ] **Step 4: Run test to verify it passes**
707
+
708
+ Run: `npx vitest run src/lib/instance/__tests__/settings.test.ts`
709
+ Expected: PASS — 5 tests passing
710
+
711
+ - [ ] **Step 5: Commit**
712
+
713
+ ```bash
714
+ git add src/lib/instance/settings.ts src/lib/instance/__tests__/settings.test.ts
715
+ git commit -m "feat(instance): add typed settings helpers for instance config"
716
+ ```
717
+
718
+ ---
719
+
720
+ ## Task 5: Implement Phase A of bootstrap (instanceId + local branch creation)
721
+
722
+ **Files:**
723
+ - Create: `src/lib/instance/bootstrap.ts` (partial — Phase A only this task)
724
+ - Test: `src/lib/instance/__tests__/bootstrap.test.ts` (partial)
725
+
726
+ - [ ] **Step 1: Write the failing test**
727
+
728
+ ```typescript
729
+ // src/lib/instance/__tests__/bootstrap.test.ts
730
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
731
+ import { execFileSync } from "child_process";
732
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
733
+ import { join } from "path";
734
+ import { tmpdir } from "os";
735
+ import type { GitOps } from "../types";
736
+
737
+ let tempDir: string;
738
+ let dataDir: string;
739
+
740
+ function runGit(args: string[], cwd: string) {
741
+ execFileSync("git", args, { cwd, stdio: "pipe" });
742
+ }
743
+
744
+ function initRepo(dir: string) {
745
+ runGit(["init", "-b", "main"], dir);
746
+ runGit(["config", "user.email", "test@example.com"], dir);
747
+ runGit(["config", "user.name", "Test"], dir);
748
+ writeFileSync(join(dir, "README.md"), "# test\n");
749
+ runGit(["add", "README.md"], dir);
750
+ runGit(["commit", "-m", "initial"], dir);
751
+ }
752
+
753
+ beforeEach(() => {
754
+ tempDir = mkdtempSync(join(tmpdir(), "stagent-bootstrap-repo-"));
755
+ dataDir = mkdtempSync(join(tmpdir(), "stagent-bootstrap-data-"));
756
+ initRepo(tempDir);
757
+ vi.resetModules();
758
+ vi.unstubAllEnvs();
759
+ vi.stubEnv("STAGENT_DATA_DIR", dataDir);
760
+ });
761
+
762
+ afterEach(() => {
763
+ vi.unstubAllEnvs();
764
+ rmSync(tempDir, { recursive: true, force: true });
765
+ rmSync(dataDir, { recursive: true, force: true });
766
+ });
767
+
768
+ describe("ensureInstanceConfig (Phase A)", () => {
769
+ it("generates a new instanceId on first call", async () => {
770
+ const { ensureInstanceConfig } = await import("../bootstrap");
771
+ const result = ensureInstanceConfig(tempDir);
772
+ expect(result.status).toBe("ok");
773
+ const { getInstanceConfig } = await import("../settings");
774
+ const config = getInstanceConfig();
775
+ expect(config).not.toBeNull();
776
+ expect(config!.instanceId).toMatch(/^[a-f0-9-]{36}$/);
777
+ expect(config!.branchName).toBe("local");
778
+ expect(config!.isPrivateInstance).toBe(false);
779
+ expect(config!.createdAt).toBeGreaterThan(0);
780
+ });
781
+
782
+ it("does not regenerate instanceId on subsequent calls", async () => {
783
+ const { ensureInstanceConfig } = await import("../bootstrap");
784
+ ensureInstanceConfig(tempDir);
785
+ const { getInstanceConfig } = await import("../settings");
786
+ const firstId = getInstanceConfig()!.instanceId;
787
+ ensureInstanceConfig(tempDir);
788
+ const secondId = getInstanceConfig()!.instanceId;
789
+ expect(secondId).toBe(firstId);
790
+ });
791
+ });
792
+
793
+ describe("ensureLocalBranch (Phase A)", () => {
794
+ it("creates local branch at current HEAD when it does not exist", async () => {
795
+ const { createGitOps } = await import("../git-ops");
796
+ const { ensureLocalBranch } = await import("../bootstrap");
797
+ const ops = createGitOps(tempDir);
798
+ const mainSha = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
799
+ const result = ensureLocalBranch(ops);
800
+ expect(result.status).toBe("ok");
801
+ expect(ops.branchExists("local")).toBe(true);
802
+ expect(ops.getCurrentBranch()).toBe("local");
803
+ const localSha = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
804
+ expect(localSha).toBe(mainSha);
805
+ const mainShaAfter = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
806
+ expect(mainShaAfter).toBe(mainSha);
807
+ });
808
+
809
+ it("is a no-op when local branch already exists", async () => {
810
+ const { createGitOps } = await import("../git-ops");
811
+ const { ensureLocalBranch } = await import("../bootstrap");
812
+ const ops = createGitOps(tempDir);
813
+ ops.createAndCheckoutBranch("local");
814
+ const shaBefore = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
815
+ const result = ensureLocalBranch(ops);
816
+ expect(result.status).toBe("skipped");
817
+ expect(result.reason).toBe("branch_exists");
818
+ const shaAfter = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
819
+ expect(shaAfter).toBe(shaBefore);
820
+ });
821
+
822
+ it("creates local at current HEAD even when user has local commits on main", async () => {
823
+ // Simulate user with commits on main that diverge from origin
824
+ writeFileSync(join(tempDir, "custom.txt"), "user work\n");
825
+ runGit(["add", "custom.txt"], tempDir);
826
+ runGit(["commit", "-m", "user customization"], tempDir);
827
+ const mainSha = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
828
+
829
+ const { createGitOps } = await import("../git-ops");
830
+ const { ensureLocalBranch } = await import("../bootstrap");
831
+ const ops = createGitOps(tempDir);
832
+ const result = ensureLocalBranch(ops);
833
+
834
+ expect(result.status).toBe("ok");
835
+ expect(ops.branchExists("local")).toBe(true);
836
+ // local points at the user's customized HEAD, not origin/main
837
+ const localSha = execFileSync("git", ["rev-parse", "local"], { cwd: tempDir, encoding: "utf-8" }).trim();
838
+ expect(localSha).toBe(mainSha);
839
+ // main is unchanged
840
+ const mainShaAfter = execFileSync("git", ["rev-parse", "main"], { cwd: tempDir, encoding: "utf-8" }).trim();
841
+ expect(mainShaAfter).toBe(mainSha);
842
+ });
843
+ });
844
+ ```
845
+
846
+ - [ ] **Step 2: Run test to verify it fails**
847
+
848
+ Run: `npx vitest run src/lib/instance/__tests__/bootstrap.test.ts`
849
+ Expected: FAIL with "Cannot find module '../bootstrap'"
850
+
851
+ - [ ] **Step 3: Write the Phase A implementation**
852
+
853
+ ```typescript
854
+ // src/lib/instance/bootstrap.ts
855
+ import { randomUUID } from "crypto";
856
+ import type { EnsureStepResult, GitOps } from "./types";
857
+ import { getInstanceConfig, setInstanceConfig } from "./settings";
858
+ import { isPrivateInstance } from "./detect";
859
+
860
+ const DEFAULT_BRANCH_NAME = "local";
861
+
862
+ /**
863
+ * Phase A step 1: ensure the instance config row exists with a stable instanceId.
864
+ * Idempotent — returns early if config already exists.
865
+ */
866
+ export function ensureInstanceConfig(_cwd: string = process.cwd()): EnsureStepResult {
867
+ const existing = getInstanceConfig();
868
+ if (existing) {
869
+ return { step: "instance-config", status: "skipped", reason: "already_exists" };
870
+ }
871
+ setInstanceConfig({
872
+ instanceId: randomUUID(),
873
+ branchName: DEFAULT_BRANCH_NAME,
874
+ isPrivateInstance: isPrivateInstance(),
875
+ createdAt: Math.floor(Date.now() / 1000),
876
+ });
877
+ return { step: "instance-config", status: "ok" };
878
+ }
879
+
880
+ /**
881
+ * Phase A step 2: create the `local` branch at current HEAD if it doesn't exist.
882
+ * Non-destructive: `git checkout -b local` preserves whatever branch the user
883
+ * was on, including any local commits. Safe on drifted-main scenarios.
884
+ */
885
+ export function ensureLocalBranch(git: GitOps): EnsureStepResult {
886
+ if (git.branchExists(DEFAULT_BRANCH_NAME)) {
887
+ return { step: "local-branch", status: "skipped", reason: "branch_exists" };
888
+ }
889
+ try {
890
+ git.createAndCheckoutBranch(DEFAULT_BRANCH_NAME);
891
+ return { step: "local-branch", status: "ok" };
892
+ } catch (err) {
893
+ return {
894
+ step: "local-branch",
895
+ status: "failed",
896
+ reason: err instanceof Error ? err.message : String(err),
897
+ };
898
+ }
899
+ }
900
+ ```
901
+
902
+ - [ ] **Step 4: Run test to verify it passes**
903
+
904
+ Run: `npx vitest run src/lib/instance/__tests__/bootstrap.test.ts`
905
+ Expected: PASS — 5 tests passing (two in ensureInstanceConfig, three in ensureLocalBranch)
906
+
907
+ - [ ] **Step 5: Commit**
908
+
909
+ ```bash
910
+ git add src/lib/instance/bootstrap.ts src/lib/instance/__tests__/bootstrap.test.ts
911
+ git commit -m "feat(instance): implement bootstrap Phase A (config + local branch)"
912
+ ```
913
+
914
+ ---
915
+
916
+ ## Task 6: Implement Phase B (pre-push hook install + pushRemote config)
917
+
918
+ **Files:**
919
+ - Modify: `src/lib/instance/bootstrap.ts` (append Phase B functions + hook template)
920
+ - Modify: `src/lib/instance/__tests__/bootstrap.test.ts` (append Phase B tests)
921
+
922
+ - [ ] **Step 1: Write the failing test**
923
+
924
+ Append to `src/lib/instance/__tests__/bootstrap.test.ts`:
925
+
926
+ ```typescript
927
+ import { existsSync, readFileSync, statSync, chmodSync } from "fs";
928
+
929
+ describe("ensurePrePushHook (Phase B)", () => {
930
+ it("writes a pre-push hook with the STAGENT_HOOK_VERSION marker", async () => {
931
+ const { createGitOps } = await import("../git-ops");
932
+ const { ensurePrePushHook } = await import("../bootstrap");
933
+ const ops = createGitOps(tempDir);
934
+ const result = ensurePrePushHook(ops);
935
+ expect(result.status).toBe("ok");
936
+ const hookPath = join(tempDir, ".git", "hooks", "pre-push");
937
+ expect(existsSync(hookPath)).toBe(true);
938
+ const content = readFileSync(hookPath, "utf-8");
939
+ expect(content).toContain("STAGENT_HOOK_VERSION=");
940
+ expect(content).toContain("ALLOW_PRIVATE_PUSH");
941
+ // Executable bit set
942
+ const mode = statSync(hookPath).mode & 0o777;
943
+ expect(mode & 0o100).toBeTruthy();
944
+ });
945
+
946
+ it("is a no-op when a hook with matching version already exists", async () => {
947
+ const { createGitOps } = await import("../git-ops");
948
+ const { ensurePrePushHook } = await import("../bootstrap");
949
+ const ops = createGitOps(tempDir);
950
+ ensurePrePushHook(ops); // first install
951
+ const firstMtime = statSync(join(tempDir, ".git", "hooks", "pre-push")).mtimeMs;
952
+ // Wait briefly then call again
953
+ const result = ensurePrePushHook(ops);
954
+ expect(result.status).toBe("skipped");
955
+ expect(result.reason).toBe("already_installed");
956
+ const secondMtime = statSync(join(tempDir, ".git", "hooks", "pre-push")).mtimeMs;
957
+ expect(secondMtime).toBe(firstMtime);
958
+ });
959
+
960
+ it("backs up a pre-existing non-stagent hook before installing", async () => {
961
+ const customHook = "#!/bin/sh\necho custom hook\n";
962
+ writeFileSync(join(tempDir, ".git", "hooks", "pre-push"), customHook);
963
+ chmodSync(join(tempDir, ".git", "hooks", "pre-push"), 0o755);
964
+ const { createGitOps } = await import("../git-ops");
965
+ const { ensurePrePushHook } = await import("../bootstrap");
966
+ const ops = createGitOps(tempDir);
967
+ const result = ensurePrePushHook(ops);
968
+ expect(result.status).toBe("ok");
969
+ const backupPath = join(tempDir, ".git", "hooks", "pre-push.stagent-backup");
970
+ expect(existsSync(backupPath)).toBe(true);
971
+ expect(readFileSync(backupPath, "utf-8")).toBe(customHook);
972
+ // Ours is now installed
973
+ expect(readFileSync(join(tempDir, ".git", "hooks", "pre-push"), "utf-8"))
974
+ .toContain("STAGENT_HOOK_VERSION=");
975
+ });
976
+ });
977
+
978
+ describe("ensureBranchPushConfig (Phase B)", () => {
979
+ it("sets branch.local.pushRemote=no_push", async () => {
980
+ const { createGitOps } = await import("../git-ops");
981
+ const { ensureLocalBranch, ensureBranchPushConfig } = await import("../bootstrap");
982
+ const ops = createGitOps(tempDir);
983
+ ensureLocalBranch(ops);
984
+ const result = ensureBranchPushConfig(ops, ["local"]);
985
+ expect(result.status).toBe("ok");
986
+ const value = execFileSync("git", ["config", "--get", "branch.local.pushRemote"], { cwd: tempDir, encoding: "utf-8" }).trim();
987
+ expect(value).toBe("no_push");
988
+ });
989
+
990
+ it("handles multiple blocked branches", async () => {
991
+ const { createGitOps } = await import("../git-ops");
992
+ const { ensureBranchPushConfig } = await import("../bootstrap");
993
+ const ops = createGitOps(tempDir);
994
+ ops.createAndCheckoutBranch("wealth-mgr");
995
+ ops.createAndCheckoutBranch("investor-mgr");
996
+ const result = ensureBranchPushConfig(ops, ["wealth-mgr", "investor-mgr"]);
997
+ expect(result.status).toBe("ok");
998
+ expect(execFileSync("git", ["config", "--get", "branch.wealth-mgr.pushRemote"], { cwd: tempDir, encoding: "utf-8" }).trim()).toBe("no_push");
999
+ expect(execFileSync("git", ["config", "--get", "branch.investor-mgr.pushRemote"], { cwd: tempDir, encoding: "utf-8" }).trim()).toBe("no_push");
1000
+ });
1001
+ });
1002
+ ```
1003
+
1004
+ - [ ] **Step 2: Run test to verify it fails**
1005
+
1006
+ Run: `npx vitest run src/lib/instance/__tests__/bootstrap.test.ts`
1007
+ Expected: FAIL with "ensurePrePushHook is not a function" or similar import error
1008
+
1009
+ - [ ] **Step 3: Append the Phase B implementation**
1010
+
1011
+ Append to `src/lib/instance/bootstrap.ts`:
1012
+
1013
+ ```typescript
1014
+ import { existsSync, readFileSync, writeFileSync, chmodSync, renameSync } from "fs";
1015
+ import { join } from "path";
1016
+
1017
+ export const STAGENT_HOOK_VERSION = "1.0.0";
1018
+
1019
+ /**
1020
+ * Pre-push hook template. Installed verbatim at .git/hooks/pre-push.
1021
+ *
1022
+ * Reads the blocked branch list from the stagent SQLite settings table
1023
+ * via a bounded sqlite3 invocation. The query is hardcoded — no user
1024
+ * input reaches the shell.
1025
+ *
1026
+ * Escape hatch: set ALLOW_PRIVATE_PUSH=1 in env to bypass the guardrail
1027
+ * for legitimate cherry-pick pushes.
1028
+ */
1029
+ const PRE_PUSH_HOOK_TEMPLATE = `#!/bin/sh
1030
+ # STAGENT_HOOK_VERSION=${STAGENT_HOOK_VERSION}
1031
+ # Blocks pushes of private instance branches to origin.
1032
+ # Escape hatch: ALLOW_PRIVATE_PUSH=1 git push ...
1033
+ #
1034
+ # Generated by src/lib/instance/bootstrap.ts — do not edit manually.
1035
+
1036
+ if [ "$ALLOW_PRIVATE_PUSH" = "1" ]; then
1037
+ exit 0
1038
+ fi
1039
+
1040
+ current_branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
1041
+ if [ -z "$current_branch" ]; then
1042
+ exit 0
1043
+ fi
1044
+
1045
+ # Read blocked branches from stagent settings (JSON array).
1046
+ data_dir="\${STAGENT_DATA_DIR:-$HOME/.stagent}"
1047
+ db_path="$data_dir/stagent.db"
1048
+ if [ ! -f "$db_path" ] || ! command -v sqlite3 >/dev/null 2>&1; then
1049
+ exit 0
1050
+ fi
1051
+
1052
+ blocked_json=$(sqlite3 "$db_path" "SELECT value FROM settings WHERE key='instance.guardrails';" 2>/dev/null)
1053
+ if [ -z "$blocked_json" ]; then
1054
+ exit 0
1055
+ fi
1056
+
1057
+ # Extract pushRemoteBlocked array entries without jq dependency
1058
+ if echo "$blocked_json" | grep -q "\\"$current_branch\\""; then
1059
+ echo "stagent: refusing to push private instance branch '$current_branch' to origin." >&2
1060
+ echo "stagent: set ALLOW_PRIVATE_PUSH=1 to override (not recommended)." >&2
1061
+ exit 1
1062
+ fi
1063
+
1064
+ exit 0
1065
+ `;
1066
+
1067
+ /**
1068
+ * Phase B step 1: install the pre-push hook at .git/hooks/pre-push.
1069
+ * Idempotent: checks version marker in existing file; backs up foreign hooks.
1070
+ */
1071
+ export function ensurePrePushHook(git: GitOps): EnsureStepResult {
1072
+ const hookPath = join(git.getGitDir(), "hooks", "pre-push");
1073
+ const markerLine = `STAGENT_HOOK_VERSION=${STAGENT_HOOK_VERSION}`;
1074
+
1075
+ if (existsSync(hookPath)) {
1076
+ const existing = readFileSync(hookPath, "utf-8");
1077
+ if (existing.includes(markerLine)) {
1078
+ return { step: "pre-push-hook", status: "skipped", reason: "already_installed" };
1079
+ }
1080
+ if (existing.includes("STAGENT_HOOK_VERSION=")) {
1081
+ // Older stagent version — overwrite without backup
1082
+ try {
1083
+ writeFileSync(hookPath, PRE_PUSH_HOOK_TEMPLATE, { mode: 0o755 });
1084
+ return { step: "pre-push-hook", status: "ok", reason: "upgraded" };
1085
+ } catch (err) {
1086
+ return {
1087
+ step: "pre-push-hook",
1088
+ status: "failed",
1089
+ reason: err instanceof Error ? err.message : String(err),
1090
+ };
1091
+ }
1092
+ }
1093
+ // Foreign hook — back it up
1094
+ try {
1095
+ renameSync(hookPath, `${hookPath}.stagent-backup`);
1096
+ } catch (err) {
1097
+ return {
1098
+ step: "pre-push-hook",
1099
+ status: "failed",
1100
+ reason: `backup_failed: ${err instanceof Error ? err.message : String(err)}`,
1101
+ };
1102
+ }
1103
+ }
1104
+
1105
+ try {
1106
+ writeFileSync(hookPath, PRE_PUSH_HOOK_TEMPLATE, { mode: 0o755 });
1107
+ chmodSync(hookPath, 0o755);
1108
+ return { step: "pre-push-hook", status: "ok" };
1109
+ } catch (err) {
1110
+ return {
1111
+ step: "pre-push-hook",
1112
+ status: "failed",
1113
+ reason: err instanceof Error ? err.message : String(err),
1114
+ };
1115
+ }
1116
+ }
1117
+
1118
+ /**
1119
+ * Phase B step 2: set branch.<name>.pushRemote=no_push for each blocked branch.
1120
+ * Idempotent via git config semantics (setting the same value is a no-op).
1121
+ */
1122
+ export function ensureBranchPushConfig(git: GitOps, branches: string[]): EnsureStepResult {
1123
+ const failures: string[] = [];
1124
+ for (const branch of branches) {
1125
+ try {
1126
+ git.setConfig(`branch.${branch}.pushRemote`, "no_push");
1127
+ } catch (err) {
1128
+ failures.push(`${branch}: ${err instanceof Error ? err.message : String(err)}`);
1129
+ }
1130
+ }
1131
+ if (failures.length > 0) {
1132
+ return {
1133
+ step: "branch-push-config",
1134
+ status: "failed",
1135
+ reason: failures.join("; "),
1136
+ };
1137
+ }
1138
+ return { step: "branch-push-config", status: "ok" };
1139
+ }
1140
+ ```
1141
+
1142
+ - [ ] **Step 4: Run test to verify it passes**
1143
+
1144
+ Run: `npx vitest run src/lib/instance/__tests__/bootstrap.test.ts`
1145
+ Expected: PASS — 10 tests total (5 Phase A + 5 Phase B)
1146
+
1147
+ - [ ] **Step 5: Commit**
1148
+
1149
+ ```bash
1150
+ git add src/lib/instance/bootstrap.ts src/lib/instance/__tests__/bootstrap.test.ts
1151
+ git commit -m "feat(instance): implement bootstrap Phase B (hook + pushRemote)"
1152
+ ```
1153
+
1154
+ ---
1155
+
1156
+ ## Task 7: Add consent state management (settings-only, no schema changes)
1157
+
1158
+ **Rationale:** The `notifications.type` column (src/lib/db/schema.ts:93-103) is a strict enum with 8 values; adding `instance_guardrails_consent` would be a schema change, which the feature spec forbids. Instead, consent state lives entirely in `settings.instance.guardrails.consentStatus` — a data fact this feature owns. The UI surface for the consent prompt belongs to `upgrade-session` (Settings → Instance section), not `instance-bootstrap`. This strengthens the feature boundary: `instance-bootstrap` writes data, `upgrade-session` renders it.
1159
+
1160
+ **Effect on behavior:** Until `upgrade-session` ships, users who clone the repo get Phase A (local branch, instanceId) but NOT Phase B guardrails by default. That is the safer default — destructive operations require explicit opt-in. Users who already want the guardrails can set `consentStatus=enabled` manually via a SQL update or, post-`upgrade-session`, via the Settings UI.
1161
+
1162
+ **Files:**
1163
+ - Modify: `src/lib/instance/bootstrap.ts` (append `resolveConsentDecision()` helper)
1164
+ - Modify: `src/lib/instance/__tests__/bootstrap.test.ts` (append consent tests)
1165
+
1166
+ - [ ] **Step 1: Write the failing test**
1167
+
1168
+ Append to `src/lib/instance/__tests__/bootstrap.test.ts`:
1169
+
1170
+ ```typescript
1171
+ describe("resolveConsentDecision", () => {
1172
+ it("returns {shouldRunPhaseB: false, reason: 'not_yet'} when consent is not_yet (default)", async () => {
1173
+ const { resolveConsentDecision } = await import("../bootstrap");
1174
+ const decision = resolveConsentDecision();
1175
+ expect(decision.shouldRunPhaseB).toBe(false);
1176
+ expect(decision.reason).toBe("not_yet");
1177
+ });
1178
+
1179
+ it("returns {shouldRunPhaseB: true} when consent is enabled", async () => {
1180
+ const { setGuardrails } = await import("../settings");
1181
+ setGuardrails({
1182
+ prePushHookInstalled: false,
1183
+ prePushHookVersion: "",
1184
+ pushRemoteBlocked: [],
1185
+ consentStatus: "enabled",
1186
+ firstBootCompletedAt: null,
1187
+ });
1188
+ const { resolveConsentDecision } = await import("../bootstrap");
1189
+ const decision = resolveConsentDecision();
1190
+ expect(decision.shouldRunPhaseB).toBe(true);
1191
+ expect(decision.reason).toBe("enabled");
1192
+ });
1193
+
1194
+ it("returns {shouldRunPhaseB: false, reason: 'declined_permanently'}", async () => {
1195
+ const { setGuardrails } = await import("../settings");
1196
+ setGuardrails({
1197
+ prePushHookInstalled: false,
1198
+ prePushHookVersion: "",
1199
+ pushRemoteBlocked: [],
1200
+ consentStatus: "declined_permanently",
1201
+ firstBootCompletedAt: null,
1202
+ });
1203
+ const { resolveConsentDecision } = await import("../bootstrap");
1204
+ const decision = resolveConsentDecision();
1205
+ expect(decision.shouldRunPhaseB).toBe(false);
1206
+ expect(decision.reason).toBe("declined_permanently");
1207
+ });
1208
+
1209
+ it("initializes guardrails row with consentStatus='not_yet' on first call if settings has no row", async () => {
1210
+ const { getGuardrails, setGuardrails } = await import("../settings");
1211
+ // Verify nothing written yet — getGuardrails returns defaults
1212
+ expect(getGuardrails().consentStatus).toBe("not_yet");
1213
+ // First call should upsert the row so downstream reads are stable
1214
+ const { resolveConsentDecision } = await import("../bootstrap");
1215
+ resolveConsentDecision();
1216
+ // Still "not_yet" but now explicitly persisted
1217
+ const after = getGuardrails();
1218
+ expect(after.consentStatus).toBe("not_yet");
1219
+ expect(after.firstBootCompletedAt).not.toBeNull();
1220
+ });
1221
+ });
1222
+ ```
1223
+
1224
+ - [ ] **Step 2: Run test to verify it fails**
1225
+
1226
+ Run: `npx vitest run src/lib/instance/__tests__/bootstrap.test.ts`
1227
+ Expected: FAIL — "resolveConsentDecision is not a function"
1228
+
1229
+ - [ ] **Step 3: Implement the consent helper**
1230
+
1231
+ Append to `src/lib/instance/bootstrap.ts`:
1232
+
1233
+ ```typescript
1234
+ import { getGuardrails, setGuardrails } from "./settings";
1235
+ import type { ConsentStatus } from "./types";
1236
+
1237
+ export interface ConsentDecision {
1238
+ shouldRunPhaseB: boolean;
1239
+ reason: ConsentStatus;
1240
+ }
1241
+
1242
+ /**
1243
+ * Reads the current consent status from settings and returns a decision
1244
+ * about whether Phase B (destructive guardrail installation) should run.
1245
+ *
1246
+ * On first call, stamps firstBootCompletedAt so the system has a record
1247
+ * that bootstrap has run at least once. This enables the upgrade-session
1248
+ * feature to distinguish "never booted" from "booted but consent not yet
1249
+ * given" in its Settings → Instance UI.
1250
+ *
1251
+ * Does NOT create any UI artifact. The prompt surface is owned by
1252
+ * upgrade-session, which renders a "Enable guardrails" action in the
1253
+ * Settings → Instance section reading from settings.instance.guardrails.
1254
+ */
1255
+ export function resolveConsentDecision(): ConsentDecision {
1256
+ const current = getGuardrails();
1257
+
1258
+ // Stamp first-boot timestamp on first call
1259
+ if (current.firstBootCompletedAt === null) {
1260
+ setGuardrails({
1261
+ ...current,
1262
+ firstBootCompletedAt: Math.floor(Date.now() / 1000),
1263
+ });
1264
+ }
1265
+
1266
+ return {
1267
+ shouldRunPhaseB: current.consentStatus === "enabled",
1268
+ reason: current.consentStatus,
1269
+ };
1270
+ }
1271
+ ```
1272
+
1273
+ - [ ] **Step 4: Run test to verify it passes**
1274
+
1275
+ Run: `npx vitest run src/lib/instance/__tests__/bootstrap.test.ts`
1276
+ Expected: PASS — 14 tests total (10 previous + 4 consent)
1277
+
1278
+ - [ ] **Step 5: Commit**
1279
+
1280
+ ```bash
1281
+ git add src/lib/instance/bootstrap.ts src/lib/instance/__tests__/bootstrap.test.ts
1282
+ git commit -m "feat(instance): add settings-based consent resolution"
1283
+ ```
1284
+
1285
+ ---
1286
+
1287
+ ## Task 8: Implement ensureInstance() orchestrator
1288
+
1289
+ **Files:**
1290
+ - Modify: `src/lib/instance/bootstrap.ts` (append orchestrator)
1291
+ - Modify: `src/lib/instance/__tests__/bootstrap.test.ts` (append orchestrator tests)
1292
+
1293
+ - [ ] **Step 1: Write the failing test**
1294
+
1295
+ Append to `src/lib/instance/__tests__/bootstrap.test.ts`:
1296
+
1297
+ ```typescript
1298
+ describe("ensureInstance orchestrator", () => {
1299
+ it("returns skipped with dev_mode_env when STAGENT_DEV_MODE=true", async () => {
1300
+ vi.stubEnv("STAGENT_DEV_MODE", "true");
1301
+ const { ensureInstance } = await import("../bootstrap");
1302
+ const result = ensureInstance(tempDir);
1303
+ expect(result.skipped).toBe("dev_mode_env");
1304
+ expect(result.steps).toEqual([]);
1305
+ // Verify zero side effects
1306
+ expect(existsSync(join(tempDir, ".git", "hooks", "pre-push"))).toBe(false);
1307
+ const { createGitOps } = await import("../git-ops");
1308
+ expect(createGitOps(tempDir).branchExists("local")).toBe(false);
1309
+ });
1310
+
1311
+ it("returns skipped with dev_mode_sentinel when sentinel file exists", async () => {
1312
+ writeFileSync(join(tempDir, ".git", "stagent-dev-mode"), "");
1313
+ const { ensureInstance } = await import("../bootstrap");
1314
+ const result = ensureInstance(tempDir);
1315
+ expect(result.skipped).toBe("dev_mode_sentinel");
1316
+ expect(result.steps).toEqual([]);
1317
+ });
1318
+
1319
+ it("returns skipped with no_git when .git directory is absent", async () => {
1320
+ const noGitDir = mkdtempSync(join(tmpdir(), "stagent-nogit-"));
1321
+ try {
1322
+ const { ensureInstance } = await import("../bootstrap");
1323
+ const result = ensureInstance(noGitDir);
1324
+ expect(result.skipped).toBe("no_git");
1325
+ } finally {
1326
+ rmSync(noGitDir, { recursive: true, force: true });
1327
+ }
1328
+ });
1329
+
1330
+ it("runs Phase A and stamps consent state on fresh clone (consent not_yet)", async () => {
1331
+ const { ensureInstance } = await import("../bootstrap");
1332
+ const result = ensureInstance(tempDir);
1333
+ expect(result.skipped).toBeUndefined();
1334
+ const steps = result.steps.map((s) => s.step);
1335
+ expect(steps).toContain("instance-config");
1336
+ expect(steps).toContain("local-branch");
1337
+ // Phase B skipped because consent is not_yet (no "consent" step — consent is resolved inline, not a step)
1338
+ expect(steps).not.toContain("pre-push-hook");
1339
+ expect(steps).not.toContain("branch-push-config");
1340
+ // local branch exists
1341
+ const { createGitOps } = await import("../git-ops");
1342
+ expect(createGitOps(tempDir).branchExists("local")).toBe(true);
1343
+ // hook not installed
1344
+ expect(existsSync(join(tempDir, ".git", "hooks", "pre-push"))).toBe(false);
1345
+ // Guardrails row has been stamped with firstBootCompletedAt
1346
+ const { getGuardrails } = await import("../settings");
1347
+ expect(getGuardrails().firstBootCompletedAt).not.toBeNull();
1348
+ expect(getGuardrails().consentStatus).toBe("not_yet");
1349
+ });
1350
+
1351
+ it("runs Phase B when consent is enabled", async () => {
1352
+ const { setGuardrails } = await import("../settings");
1353
+ setGuardrails({
1354
+ prePushHookInstalled: false,
1355
+ prePushHookVersion: "",
1356
+ pushRemoteBlocked: [],
1357
+ consentStatus: "enabled",
1358
+ firstBootCompletedAt: null,
1359
+ });
1360
+ const { ensureInstance } = await import("../bootstrap");
1361
+ const result = ensureInstance(tempDir);
1362
+ const steps = result.steps.map((s) => s.step);
1363
+ expect(steps).toContain("pre-push-hook");
1364
+ expect(steps).toContain("branch-push-config");
1365
+ expect(existsSync(join(tempDir, ".git", "hooks", "pre-push"))).toBe(true);
1366
+ });
1367
+
1368
+ it("STAGENT_INSTANCE_MODE=true override beats STAGENT_DEV_MODE=true", async () => {
1369
+ vi.stubEnv("STAGENT_DEV_MODE", "true");
1370
+ vi.stubEnv("STAGENT_INSTANCE_MODE", "true");
1371
+ const { ensureInstance } = await import("../bootstrap");
1372
+ const result = ensureInstance(tempDir);
1373
+ expect(result.skipped).toBeUndefined();
1374
+ expect(result.steps.length).toBeGreaterThan(0);
1375
+ });
1376
+
1377
+ it("is a full no-op on the second call (idempotent)", async () => {
1378
+ const { ensureInstance } = await import("../bootstrap");
1379
+ ensureInstance(tempDir);
1380
+ const result = ensureInstance(tempDir);
1381
+ // All Phase A steps return skipped
1382
+ for (const step of result.steps) {
1383
+ if (step.step === "instance-config" || step.step === "local-branch") {
1384
+ expect(step.status).toBe("skipped");
1385
+ }
1386
+ }
1387
+ });
1388
+
1389
+ it("skips ensureLocalBranch with warning when rebase is in progress", async () => {
1390
+ mkdirSync(join(tempDir, ".git", "rebase-merge"));
1391
+ const { ensureInstance } = await import("../bootstrap");
1392
+ const result = ensureInstance(tempDir);
1393
+ const branchStep = result.steps.find((s) => s.step === "local-branch");
1394
+ expect(branchStep?.status).toBe("skipped");
1395
+ expect(branchStep?.reason).toBe("rebase_in_progress");
1396
+ });
1397
+ });
1398
+ ```
1399
+
1400
+ - [ ] **Step 2: Run test to verify it fails**
1401
+
1402
+ Run: `npx vitest run src/lib/instance/__tests__/bootstrap.test.ts`
1403
+ Expected: FAIL — "ensureInstance is not a function"
1404
+
1405
+ - [ ] **Step 3: Implement the orchestrator**
1406
+
1407
+ Append to `src/lib/instance/bootstrap.ts`:
1408
+
1409
+ ```typescript
1410
+ import { isDevMode, hasGitDir, detectRebaseInProgress } from "./detect";
1411
+ import { createGitOps } from "./git-ops";
1412
+ import type { EnsureResult } from "./types";
1413
+
1414
+ /**
1415
+ * Main entry point called from src/instrumentation.ts.
1416
+ * Idempotent — safe to run on every boot.
1417
+ *
1418
+ * Execution order:
1419
+ * 1. Dev-mode gates (env + sentinel) — skip entirely if active
1420
+ * 2. .git presence check — skip if absent (npx runtime)
1421
+ * 3. Phase A: instanceId, local branch (non-destructive, always runs)
1422
+ * 4. Consent: create first-boot notification if status=not_yet
1423
+ * 5. Phase B: pre-push hook, pushRemote config (only if consent=enabled)
1424
+ */
1425
+ export function ensureInstance(cwd: string = process.cwd()): EnsureResult {
1426
+ if (isDevMode(cwd)) {
1427
+ const reason = process.env.STAGENT_DEV_MODE === "true" ? "dev_mode_env" : "dev_mode_sentinel";
1428
+ return { skipped: reason, steps: [] };
1429
+ }
1430
+
1431
+ if (!hasGitDir(cwd)) {
1432
+ return { skipped: "no_git", steps: [] };
1433
+ }
1434
+
1435
+ const steps: EnsureStepResult[] = [];
1436
+ const git = createGitOps(cwd);
1437
+
1438
+ // Phase A step 1: instance config
1439
+ steps.push(ensureInstanceConfig(cwd));
1440
+
1441
+ // Phase A step 2: local branch — skip if rebase in progress
1442
+ if (detectRebaseInProgress(cwd)) {
1443
+ steps.push({ step: "local-branch", status: "skipped", reason: "rebase_in_progress" });
1444
+ } else {
1445
+ steps.push(ensureLocalBranch(git));
1446
+ }
1447
+
1448
+ // Resolve consent (stamps firstBootCompletedAt on first call, returns decision)
1449
+ const decision = resolveConsentDecision();
1450
+
1451
+ // Phase B — only if user has explicitly enabled guardrails
1452
+ if (decision.shouldRunPhaseB) {
1453
+ steps.push(ensurePrePushHook(git));
1454
+
1455
+ const config = getInstanceConfig();
1456
+ const blockedBranches = config ? [config.branchName] : [];
1457
+ if (blockedBranches.length > 0) {
1458
+ steps.push(ensureBranchPushConfig(git, blockedBranches));
1459
+ }
1460
+ }
1461
+
1462
+ return { steps };
1463
+ }
1464
+ ```
1465
+
1466
+ - [ ] **Step 4: Run test to verify it passes**
1467
+
1468
+ Run: `npx vitest run src/lib/instance/__tests__/bootstrap.test.ts`
1469
+ Expected: PASS — 22 tests total (14 previous + 8 orchestrator)
1470
+
1471
+ - [ ] **Step 5: Commit**
1472
+
1473
+ ```bash
1474
+ git add src/lib/instance/bootstrap.ts src/lib/instance/__tests__/bootstrap.test.ts
1475
+ git commit -m "feat(instance): implement ensureInstance orchestrator with layered gates"
1476
+ ```
1477
+
1478
+ ---
1479
+
1480
+ ## Task 9: Integrate ensureInstance into instrumentation.ts
1481
+
1482
+ **Files:**
1483
+ - Modify: `src/instrumentation.ts`
1484
+
1485
+ - [ ] **Step 1: Add a test that verifies instrumentation imports and calls ensureInstance**
1486
+
1487
+ This integration is hard to unit test because `instrumentation.ts` is a Next.js lifecycle hook. Instead, add a smoke test that verifies the import path is correct and the function returns without throwing in dev mode.
1488
+
1489
+ Create `src/__tests__/instrumentation-smoke.test.ts`:
1490
+
1491
+ ```typescript
1492
+ import { describe, expect, it, vi } from "vitest";
1493
+
1494
+ describe("instrumentation register()", () => {
1495
+ it("calls ensureInstance without throwing when NEXT_RUNTIME=nodejs and dev mode", async () => {
1496
+ vi.stubEnv("NEXT_RUNTIME", "nodejs");
1497
+ vi.stubEnv("STAGENT_DEV_MODE", "true");
1498
+ // All other startup calls mocked to no-ops would complicate the test.
1499
+ // We only need to verify the new ensureInstance import path resolves.
1500
+ const { ensureInstance } = await import("@/lib/instance/bootstrap");
1501
+ const result = ensureInstance();
1502
+ expect(result.skipped).toBe("dev_mode_env");
1503
+ vi.unstubAllEnvs();
1504
+ });
1505
+ });
1506
+ ```
1507
+
1508
+ - [ ] **Step 2: Run smoke test to verify import resolves**
1509
+
1510
+ Run: `npx vitest run src/__tests__/instrumentation-smoke.test.ts`
1511
+ Expected: PASS
1512
+
1513
+ - [ ] **Step 3: Modify `src/instrumentation.ts` to call ensureInstance before scheduler startup**
1514
+
1515
+ ```typescript
1516
+ // src/instrumentation.ts
1517
+ export async function register() {
1518
+ // Only start background services on the server (not during build or edge)
1519
+ if (process.env.NEXT_RUNTIME === "nodejs") {
1520
+ try {
1521
+ // Instance bootstrap — creates local branch, handles dev-mode gates, consent flow.
1522
+ // Runs BEFORE scheduler so instance config is available to scheduled polling.
1523
+ const { ensureInstance } = await import("@/lib/instance/bootstrap");
1524
+ const result = ensureInstance();
1525
+ if (result.skipped) {
1526
+ console.log(`[instance] bootstrap skipped: ${result.skipped}`);
1527
+ } else {
1528
+ for (const step of result.steps) {
1529
+ if (step.status === "failed") {
1530
+ console.error(`[instance] ${step.step} failed: ${step.reason}`);
1531
+ }
1532
+ }
1533
+ }
1534
+
1535
+ // License manager — initialize from DB (creates default row if needed)
1536
+ const { licenseManager } = await import("@/lib/license/manager");
1537
+ licenseManager.initialize();
1538
+ licenseManager.startValidationTimer();
1539
+
1540
+ const { startScheduler } = await import("@/lib/schedules/scheduler");
1541
+ startScheduler();
1542
+
1543
+ const { startChannelPoller } = await import("@/lib/channels/poller");
1544
+ startChannelPoller();
1545
+
1546
+ const { startAutoBackup } = await import("@/lib/snapshots/auto-backup");
1547
+ startAutoBackup();
1548
+
1549
+ // History retention cleanup — prunes old agent_logs and usage_ledger
1550
+ // based on tier retention limit (Community: 30 days)
1551
+ startHistoryCleanup(licenseManager);
1552
+
1553
+ // Telemetry batch flush (opt-in, every 5 minutes)
1554
+ const { startTelemetryFlush } = await import("@/lib/telemetry/queue");
1555
+ startTelemetryFlush();
1556
+ } catch (err) {
1557
+ console.error("Instrumentation startup failed:", err);
1558
+ }
1559
+ }
1560
+ }
1561
+
1562
+ async function startHistoryCleanup(licenseManager: { getLimit: (r: "historyRetentionDays") => number }) {
1563
+ const CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
1564
+
1565
+ async function cleanup() {
1566
+ const retentionDays = licenseManager.getLimit("historyRetentionDays");
1567
+ if (!Number.isFinite(retentionDays)) return; // Unlimited retention
1568
+
1569
+ const { db } = await import("@/lib/db");
1570
+ const { agentLogs, usageLedger } = await import("@/lib/db/schema");
1571
+ const { lt } = await import("drizzle-orm");
1572
+
1573
+ const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
1574
+ db.delete(agentLogs).where(lt(agentLogs.timestamp, cutoff)).run();
1575
+ db.delete(usageLedger).where(lt(usageLedger.startedAt, cutoff)).run();
1576
+ }
1577
+
1578
+ // Run once at startup, then daily
1579
+ cleanup().catch(() => {});
1580
+ setInterval(() => cleanup().catch(() => {}), CLEANUP_INTERVAL);
1581
+ }
1582
+ ```
1583
+
1584
+ - [ ] **Step 4: Run the smoke test and full instance test suite**
1585
+
1586
+ Run: `npx vitest run src/__tests__/instrumentation-smoke.test.ts src/lib/instance/`
1587
+ Expected: PASS — all tests still passing
1588
+
1589
+ - [ ] **Step 5: Commit**
1590
+
1591
+ ```bash
1592
+ git add src/instrumentation.ts src/__tests__/instrumentation-smoke.test.ts
1593
+ git commit -m "feat(instance): wire ensureInstance into instrumentation hook"
1594
+ ```
1595
+
1596
+ ---
1597
+
1598
+ ## Task 10: Manual verification in main dev repo
1599
+
1600
+ **Files:**
1601
+ - None (manual verification)
1602
+
1603
+ - [ ] **Step 1: Confirm dev-mode gates are active in this clone**
1604
+
1605
+ Run these commands and verify expected output:
1606
+
1607
+ ```bash
1608
+ cd /Users/navam/Developer/stagent
1609
+ grep "STAGENT_DEV_MODE=true" .env.local && echo "env gate: OK"
1610
+ ls .git/stagent-dev-mode && echo "sentinel gate: OK"
1611
+ ```
1612
+
1613
+ Expected: both `env gate: OK` and `sentinel gate: OK` printed.
1614
+
1615
+ - [ ] **Step 2: Record current git state**
1616
+
1617
+ ```bash
1618
+ git branch | grep -E "^\*" > /tmp/stagent-pre-branch.txt
1619
+ git config --get branch.main.pushRemote > /tmp/stagent-pre-pushremote.txt 2>/dev/null || echo "(not set)" > /tmp/stagent-pre-pushremote.txt
1620
+ ls .git/hooks/pre-push > /tmp/stagent-pre-hook.txt 2>/dev/null || echo "(missing)" > /tmp/stagent-pre-hook.txt
1621
+ ```
1622
+
1623
+ - [ ] **Step 3: Start dev server once, let it boot, then stop**
1624
+
1625
+ ```bash
1626
+ npm run dev &
1627
+ DEV_PID=$!
1628
+ sleep 10
1629
+ kill $DEV_PID 2>/dev/null
1630
+ wait $DEV_PID 2>/dev/null
1631
+ ```
1632
+
1633
+ Expected: dev server starts, console shows `[instance] bootstrap skipped: dev_mode_env` (or `dev_mode_sentinel`) in output.
1634
+
1635
+ - [ ] **Step 4: Confirm git state is unchanged**
1636
+
1637
+ ```bash
1638
+ git branch | grep -E "^\*" > /tmp/stagent-post-branch.txt
1639
+ git config --get branch.main.pushRemote > /tmp/stagent-post-pushremote.txt 2>/dev/null || echo "(not set)" > /tmp/stagent-post-pushremote.txt
1640
+ ls .git/hooks/pre-push > /tmp/stagent-post-hook.txt 2>/dev/null || echo "(missing)" > /tmp/stagent-post-hook.txt
1641
+
1642
+ diff /tmp/stagent-pre-branch.txt /tmp/stagent-post-branch.txt && echo "branch: UNCHANGED"
1643
+ diff /tmp/stagent-pre-pushremote.txt /tmp/stagent-post-pushremote.txt && echo "pushRemote: UNCHANGED"
1644
+ diff /tmp/stagent-pre-hook.txt /tmp/stagent-post-hook.txt && echo "pre-push hook: UNCHANGED"
1645
+ ```
1646
+
1647
+ Expected: all three `UNCHANGED` messages printed. No new branches, no pushRemote set, no hook installed.
1648
+
1649
+ - [ ] **Step 5: Clean up and commit final checkpoint**
1650
+
1651
+ ```bash
1652
+ rm /tmp/stagent-pre-* /tmp/stagent-post-*
1653
+ git add -A
1654
+ git status # should show nothing unexpected
1655
+ ```
1656
+
1657
+ No commit needed if all other tasks committed cleanly. This task is verification only.
1658
+
1659
+ ---
1660
+
1661
+ ## Self-Review Checklist
1662
+
1663
+ After implementing all 10 tasks, verify:
1664
+
1665
+ **1. Spec coverage:** Every acceptance criterion in `features/instance-bootstrap.md` maps to at least one task:
1666
+ - [ ] Core functionality ACs → Tasks 5, 6, 8
1667
+ - [ ] Dev-mode gate ACs → Tasks 2, 8
1668
+ - [ ] Consent flow ACs → Task 7
1669
+ - [ ] Guardrails ACs → Task 6
1670
+ - [ ] Single-clone generalization test → Task 5 (`ensureLocalBranch` third test covers this)
1671
+ - [ ] Main dev repo safety test → Task 10 (manual verification)
1672
+
1673
+ **2. Placeholder scan:** No `TODO`, `TBD`, "implement later", or vague error handling. Every code block is complete.
1674
+
1675
+ **3. Type consistency:** `EnsureStepResult`, `EnsureResult`, `GitOps`, `InstanceConfig`, `Guardrails`, `ConsentStatus` are all defined in Task 1 and used identically in Tasks 3-8. Method names match: `branchExists`, `createAndCheckoutBranch`, `setConfig`, `getCurrentBranch`, `isGitRepo`, `getGitDir`.
1676
+
1677
+ **4. Test count check:** Expected final count: detect.test.ts (13) + git-ops.test.ts (7) + settings.test.ts (5) + bootstrap.test.ts (22) + instrumentation-smoke.test.ts (1) = **48 tests** in the new test files. Bootstrap.test.ts breakdown: 5 Phase A + 5 Phase B + 4 consent + 8 orchestrator = 22. Run `npx vitest run src/lib/instance/ src/__tests__/instrumentation-smoke.test.ts` to verify.
1678
+
1679
+ **5. Main dev repo safety:** Task 10 manual verification MUST pass before merging. If any step shows a change, investigate before proceeding.
1680
+
1681
+ ---
1682
+
1683
+ ## Execution Handoff
1684
+
1685
+ Plan complete and saved to `docs/superpowers/plans/2026-04-07-instance-bootstrap.md`. Two execution options:
1686
+
1687
+ **1. Subagent-Driven (recommended)** — Dispatch a fresh subagent per task, review between tasks, fast iteration. Each of the 10 tasks is self-contained with exact code.
1688
+
1689
+ **2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints for review.
1690
+
1691
+ **Which approach?**