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