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.
- package/README.md +5 -42
- package/dist/cli.js +42 -18
- package/docs/.coverage-gaps.json +13 -55
- package/docs/.last-generated +1 -1
- package/docs/features/provider-runtimes.md +4 -0
- package/docs/features/schedules.md +32 -4
- package/docs/features/settings.md +28 -5
- package/docs/features/tables.md +9 -2
- package/docs/features/workflows.md +10 -4
- package/docs/journeys/developer.md +15 -1
- package/docs/journeys/personal-use.md +21 -4
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
- package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
- package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
- package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
- package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
- package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
- package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
- package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
- package/package.json +3 -2
- package/src/__tests__/instrumentation-smoke.test.ts +15 -0
- package/src/app/analytics/page.tsx +1 -21
- package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
- package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
- package/src/app/api/instance/config/route.ts +41 -0
- package/src/app/api/instance/init/route.ts +34 -0
- package/src/app/api/instance/upgrade/check/route.ts +26 -0
- package/src/app/api/instance/upgrade/route.ts +96 -0
- package/src/app/api/instance/upgrade/status/route.ts +35 -0
- package/src/app/api/memory/route.ts +0 -11
- package/src/app/api/notifications/route.ts +4 -2
- package/src/app/api/projects/[id]/route.ts +5 -155
- package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
- package/src/app/api/schedules/[id]/execute/route.ts +111 -0
- package/src/app/api/schedules/[id]/route.ts +9 -1
- package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
- package/src/app/api/schedules/route.ts +3 -12
- package/src/app/api/settings/openai/login/route.ts +22 -0
- package/src/app/api/settings/openai/logout/route.ts +7 -0
- package/src/app/api/settings/openai/route.ts +21 -1
- package/src/app/api/settings/providers/route.ts +35 -8
- package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
- package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
- package/src/app/api/tables/[id]/enrich/route.ts +147 -0
- package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
- package/src/app/api/tasks/[id]/execute/route.ts +0 -21
- package/src/app/api/workflows/[id]/resume/route.ts +59 -0
- package/src/app/api/workflows/[id]/status/route.ts +22 -8
- package/src/app/api/workspace/context/route.ts +2 -0
- package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
- package/src/app/chat/page.tsx +11 -0
- package/src/app/inbox/page.tsx +12 -5
- package/src/app/layout.tsx +42 -21
- package/src/app/page.tsx +0 -2
- package/src/app/settings/page.tsx +6 -9
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +408 -0
- package/src/components/chat/chat-command-popover.tsx +2 -2
- package/src/components/chat/chat-input.tsx +2 -3
- package/src/components/chat/chat-session-provider.tsx +720 -0
- package/src/components/chat/chat-shell.tsx +92 -401
- package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
- package/src/components/instance/instance-section.tsx +382 -0
- package/src/components/instance/upgrade-badge.tsx +219 -0
- package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
- package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
- package/src/components/notifications/batch-proposal-review.tsx +20 -5
- package/src/components/notifications/inbox-list.tsx +11 -2
- package/src/components/notifications/notification-item.tsx +56 -2
- package/src/components/notifications/pending-approval-host.tsx +56 -37
- package/src/components/schedules/schedule-create-sheet.tsx +19 -1
- package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
- package/src/components/schedules/schedule-form.tsx +31 -0
- package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
- package/src/components/settings/auth-method-selector.tsx +19 -4
- package/src/components/settings/auth-status-badge.tsx +28 -3
- package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
- package/src/components/settings/openai-runtime-section.tsx +7 -1
- package/src/components/settings/providers-runtimes-section.tsx +138 -19
- package/src/components/shared/app-sidebar.tsx +4 -3
- package/src/components/shared/command-palette.tsx +4 -5
- package/src/components/shared/theme-toggle.tsx +5 -24
- package/src/components/shared/workspace-indicator.tsx +61 -2
- package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
- package/src/components/tables/table-create-sheet.tsx +4 -0
- package/src/components/tables/table-enrichment-runs.tsx +103 -0
- package/src/components/tables/table-enrichment-sheet.tsx +538 -0
- package/src/components/tables/table-spreadsheet.tsx +29 -5
- package/src/components/tables/table-toolbar.tsx +10 -1
- package/src/components/tasks/kanban-board.tsx +1 -0
- package/src/components/tasks/kanban-column.tsx +53 -14
- package/src/components/tasks/task-bento-grid.tsx +19 -0
- package/src/components/tasks/task-card.tsx +26 -3
- package/src/components/tasks/task-chip-bar.tsx +24 -0
- package/src/components/tasks/task-result-renderer.tsx +1 -1
- package/src/components/workflows/delay-step-body.tsx +109 -0
- package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
- package/src/components/workflows/loop-status-view.tsx +1 -1
- package/src/components/workflows/shared/step-result.tsx +78 -0
- package/src/components/workflows/shared/workflow-header.tsx +141 -0
- package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
- package/src/components/workflows/swarm-dashboard.tsx +2 -15
- package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
- package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
- package/src/components/workflows/workflow-form-view.tsx +133 -16
- package/src/components/workflows/workflow-status-view.tsx +30 -740
- package/src/instrumentation-node.ts +94 -0
- package/src/instrumentation.ts +4 -48
- package/src/lib/agents/__tests__/claude-agent.test.ts +199 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
- package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
- package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
- package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
- package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
- package/src/lib/agents/claude-agent.ts +155 -18
- package/src/lib/agents/execution-manager.ts +0 -35
- package/src/lib/agents/learned-context.ts +0 -12
- package/src/lib/agents/learning-session.ts +18 -5
- package/src/lib/agents/profiles/__tests__/registry.test.ts +6 -4
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +70 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +32 -0
- package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
- package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
- package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
- package/src/lib/agents/runtime/openai-codex.ts +29 -60
- package/src/lib/agents/runtime/types.ts +8 -0
- package/src/lib/book/chapter-mapping.ts +11 -0
- package/src/lib/book/content.ts +10 -0
- package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
- package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
- package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
- package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
- package/src/lib/chat/active-streams.ts +27 -0
- package/src/lib/chat/codex-engine.ts +16 -17
- package/src/lib/chat/context-builder.ts +5 -3
- package/src/lib/chat/engine.ts +50 -3
- package/src/lib/chat/reconcile.ts +117 -0
- package/src/lib/chat/stagent-tools.ts +1 -0
- package/src/lib/chat/stream-telemetry.ts +132 -0
- package/src/lib/chat/suggested-prompts.ts +28 -1
- package/src/lib/chat/system-prompt.ts +26 -1
- package/src/lib/chat/tool-catalog.ts +2 -1
- package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
- package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +352 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +217 -0
- package/src/lib/chat/tools/document-tools.ts +29 -13
- package/src/lib/chat/tools/helpers.ts +39 -0
- package/src/lib/chat/tools/notification-tools.ts +9 -5
- package/src/lib/chat/tools/project-tools.ts +33 -0
- package/src/lib/chat/tools/schedule-tools.ts +44 -11
- package/src/lib/chat/tools/table-tools.ts +71 -0
- package/src/lib/chat/tools/task-tools.ts +84 -20
- package/src/lib/chat/tools/workflow-tools.ts +234 -32
- package/src/lib/constants/settings.ts +8 -18
- package/src/lib/data/__tests__/clear.test.ts +56 -2
- package/src/lib/data/clear.ts +20 -15
- package/src/lib/data/delete-project.ts +171 -0
- package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
- package/src/lib/db/bootstrap.ts +45 -16
- package/src/lib/db/index.ts +5 -0
- package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
- package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
- package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
- package/src/lib/db/migrations/0026_drop_license.sql +3 -0
- package/src/lib/db/migrations/meta/_journal.json +21 -0
- package/src/lib/db/schema.ts +68 -23
- package/src/lib/environment/workspace-context.ts +13 -1
- package/src/lib/import/dedup.ts +4 -54
- package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
- package/src/lib/instance/__tests__/detect.test.ts +115 -0
- package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
- package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
- package/src/lib/instance/__tests__/settings.test.ts +83 -0
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +131 -0
- package/src/lib/instance/bootstrap.ts +270 -0
- package/src/lib/instance/detect.ts +49 -0
- package/src/lib/instance/fingerprint.ts +78 -0
- package/src/lib/instance/git-ops.ts +95 -0
- package/src/lib/instance/settings.ts +61 -0
- package/src/lib/instance/types.ts +77 -0
- package/src/lib/instance/upgrade-poller.ts +153 -0
- package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
- package/src/lib/notifications/visibility.ts +33 -0
- package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
- package/src/lib/schedules/__tests__/config.test.ts +62 -0
- package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
- package/src/lib/schedules/__tests__/integration.test.ts +82 -0
- package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
- package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
- package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
- package/src/lib/schedules/collision-check.ts +105 -0
- package/src/lib/schedules/config.ts +53 -0
- package/src/lib/schedules/scheduler.ts +232 -13
- package/src/lib/schedules/slot-claim.ts +105 -0
- package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
- package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
- package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
- package/src/lib/settings/openai-auth.ts +105 -10
- package/src/lib/settings/openai-login-manager.ts +260 -0
- package/src/lib/settings/runtime-setup.ts +14 -4
- package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
- package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
- package/src/lib/tables/enrichment-planner.ts +454 -0
- package/src/lib/tables/enrichment.ts +328 -0
- package/src/lib/tables/query-builder.ts +5 -2
- package/src/lib/tables/trigger-evaluator.ts +3 -2
- package/src/lib/theme.ts +71 -0
- package/src/lib/usage/ledger.ts +2 -18
- package/src/lib/util/__tests__/similarity.test.ts +106 -0
- package/src/lib/util/similarity.ts +77 -0
- package/src/lib/utils/format-timestamp.ts +24 -0
- package/src/lib/utils/stagent-paths.ts +12 -0
- package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
- package/src/lib/validators/__tests__/settings.test.ts +10 -0
- package/src/lib/validators/blueprint.ts +70 -9
- package/src/lib/validators/profile.ts +2 -2
- package/src/lib/validators/settings.ts +3 -1
- package/src/lib/workflows/__tests__/delay.test.ts +196 -0
- package/src/lib/workflows/__tests__/engine.test.ts +8 -0
- package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
- package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
- package/src/lib/workflows/blueprints/instantiator.ts +22 -1
- package/src/lib/workflows/blueprints/types.ts +10 -2
- package/src/lib/workflows/delay.ts +106 -0
- package/src/lib/workflows/engine.ts +207 -4
- package/src/lib/workflows/loop-executor.ts +349 -24
- package/src/lib/workflows/post-action.ts +91 -0
- package/src/lib/workflows/types.ts +166 -1
- package/src/app/api/license/checkout/route.ts +0 -28
- package/src/app/api/license/portal/route.ts +0 -26
- package/src/app/api/license/route.ts +0 -89
- package/src/app/api/license/usage/route.ts +0 -63
- package/src/app/api/marketplace/browse/route.ts +0 -15
- package/src/app/api/marketplace/import/route.ts +0 -28
- package/src/app/api/marketplace/publish/route.ts +0 -40
- package/src/app/api/onboarding/email/route.ts +0 -53
- package/src/app/api/settings/telemetry/route.ts +0 -14
- package/src/app/api/sync/export/route.ts +0 -54
- package/src/app/api/sync/restore/route.ts +0 -37
- package/src/app/api/sync/sessions/route.ts +0 -24
- package/src/app/auth/callback/route.ts +0 -73
- package/src/app/marketplace/page.tsx +0 -19
- package/src/components/analytics/analytics-gate-card.tsx +0 -101
- package/src/components/marketplace/blueprint-card.tsx +0 -61
- package/src/components/marketplace/marketplace-browser.tsx +0 -131
- package/src/components/onboarding/email-capture-card.tsx +0 -104
- package/src/components/settings/activation-form.tsx +0 -95
- package/src/components/settings/cloud-account-section.tsx +0 -147
- package/src/components/settings/cloud-sync-section.tsx +0 -155
- package/src/components/settings/subscription-section.tsx +0 -410
- package/src/components/settings/telemetry-section.tsx +0 -80
- package/src/components/shared/premium-gate-overlay.tsx +0 -50
- package/src/components/shared/schedule-gate-dialog.tsx +0 -64
- package/src/components/shared/upgrade-banner.tsx +0 -112
- package/src/hooks/use-supabase-auth.ts +0 -79
- package/src/lib/billing/email.ts +0 -54
- package/src/lib/billing/products.ts +0 -80
- package/src/lib/billing/stripe.ts +0 -101
- package/src/lib/cloud/supabase-browser.ts +0 -32
- package/src/lib/cloud/supabase-client.ts +0 -56
- package/src/lib/license/__tests__/features.test.ts +0 -56
- package/src/lib/license/__tests__/key-format.test.ts +0 -88
- package/src/lib/license/__tests__/manager.test.ts +0 -64
- package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
- package/src/lib/license/cloud-validation.ts +0 -60
- package/src/lib/license/features.ts +0 -44
- package/src/lib/license/key-format.ts +0 -101
- package/src/lib/license/limit-check.ts +0 -111
- package/src/lib/license/limit-queries.ts +0 -51
- package/src/lib/license/manager.ts +0 -345
- package/src/lib/license/notifications.ts +0 -59
- package/src/lib/license/tier-limits.ts +0 -71
- package/src/lib/marketplace/marketplace-client.ts +0 -107
- package/src/lib/sync/cloud-sync.ts +0 -235
- package/src/lib/telemetry/conversion-events.ts +0 -71
- package/src/lib/telemetry/queue.ts +0 -122
- 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?**
|