@bugmole/cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/package.json +5 -2
  2. package/scripts/bugmole-admin.mjs +194 -0
  3. package/scripts/bugmole-admin.test.mjs +62 -0
  4. package/scripts/bugmole.test.ts +94 -2
  5. package/scripts/bugmole.ts +233 -46
  6. package/scripts/sync-byok.d.mts +2 -0
  7. package/scripts/sync-byok.mjs +18 -0
  8. package/scripts/sync-plan-catalog.d.mts +3 -0
  9. package/scripts/sync-plan-catalog.mjs +16 -6
  10. package/spec/domain_rules.yaml +139 -0
  11. package/spec/roles.yaml +20 -0
  12. package/spec/test-case-results.schema.json +20 -4
  13. package/spec/test-cases.schema.json +131 -16
  14. package/src/billing/plan-catalog.test.ts +82 -2
  15. package/src/billing/plan-catalog.ts +212 -9
  16. package/src/integrations/jira.ts +264 -0
  17. package/src/mcp/roles-and-review.test.ts +111 -0
  18. package/src/mcp/server.ts +358 -22
  19. package/src/mcp/write-test-cases.test.ts +68 -0
  20. package/src/registry/control-plane-client.ts +121 -6
  21. package/src/registry/migrations/0036_task_approval.sql +11 -0
  22. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  23. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  24. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  25. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  26. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  27. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  28. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  29. package/src/registry/migrations/0044_project_identity.sql +31 -0
  30. package/src/registry/project-identity.test.ts +99 -0
  31. package/src/registry/project-identity.ts +206 -0
  32. package/src/registry/roles.test.ts +66 -0
  33. package/src/registry/roles.ts +201 -0
  34. package/src/registry/task-scheduling.test.ts +94 -0
  35. package/src/registry/task-scheduling.ts +199 -2
  36. package/src/registry/test-case-revisions.test.ts +57 -0
  37. package/src/registry/test-case-revisions.ts +132 -0
  38. package/src/registry-worker/ai/routes.ts +3 -3
  39. package/src/registry-worker/artifacts.ts +23 -4
  40. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  41. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  42. package/src/registry-worker/billing/enforcement.ts +67 -12
  43. package/src/registry-worker/billing/paypal/api.ts +14 -0
  44. package/src/registry-worker/billing/paypal/client.ts +9 -0
  45. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  46. package/src/registry-worker/billing/paypal.test.ts +108 -1
  47. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  48. package/src/registry-worker/billing/provider.ts +8 -0
  49. package/src/registry-worker/billing/routes.ts +2 -1
  50. package/src/registry-worker/billing/subscriptions.ts +114 -4
  51. package/src/registry-worker/core.ts +22 -0
  52. package/src/registry-worker/devices/policy.ts +4 -5
  53. package/src/registry-worker/devices/routes.ts +8 -8
  54. package/src/registry-worker/feature-access.ts +165 -0
  55. package/src/registry-worker/feature-flags/admin.ts +300 -0
  56. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  57. package/src/registry-worker/feature-flags/routes.ts +102 -0
  58. package/src/registry-worker/features.ts +9 -0
  59. package/src/registry-worker/feedback/routes.ts +2 -2
  60. package/src/registry-worker/flags.ts +80 -18
  61. package/src/registry-worker/github/checks.ts +4 -4
  62. package/src/registry-worker/hooks.ts +9 -0
  63. package/src/registry-worker/identity/oidc.test.ts +103 -0
  64. package/src/registry-worker/identity/oidc.ts +176 -0
  65. package/src/registry-worker/index.ts +477 -174
  66. package/src/registry-worker/jira/connection.ts +111 -0
  67. package/src/registry-worker/jira/jira.test.ts +404 -0
  68. package/src/registry-worker/jira/routes.ts +432 -0
  69. package/src/registry-worker/jira/workflow.ts +577 -0
  70. package/src/registry-worker/jobs/retention.ts +21 -5
  71. package/src/registry-worker/mcp/tools.ts +2 -1
  72. package/src/registry-worker/notifications/alerts.ts +4 -4
  73. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  74. package/src/registry-worker/notifications/routes.ts +9 -10
  75. package/src/registry-worker/notifications/teams.ts +6 -2
  76. package/src/registry-worker/org/routes.ts +4 -4
  77. package/src/registry-worker/projects/identity.ts +194 -0
  78. package/src/registry-worker/projects/inactivity.ts +162 -0
  79. package/src/registry-worker/projects/projects.test.ts +283 -0
  80. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  81. package/src/registry-worker/proposals/routes.ts +183 -0
  82. package/src/registry-worker/roles/roles.test.ts +84 -0
  83. package/src/registry-worker/roles/routes.ts +141 -0
  84. package/src/registry-worker/runner/dispatch.ts +1 -0
  85. package/src/registry-worker/runner/routes.ts +22 -5
  86. package/src/registry-worker/runner/runner.test.ts +21 -0
  87. package/src/registry-worker/runner/tokens.ts +8 -0
  88. package/src/registry-worker/secrets/crypto.ts +135 -0
  89. package/src/registry-worker/secrets/routes.ts +296 -0
  90. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  91. package/src/registry-worker/signup/policy.ts +2 -2
  92. package/src/registry-worker/signup/routes.ts +12 -3
  93. package/src/registry-worker/sso/membership.ts +4 -2
  94. package/src/registry-worker/sso/routes.ts +24 -8
  95. package/src/registry-worker/sso/sso.test.ts +3 -2
  96. package/src/registry-worker/task-approval.test.ts +67 -0
  97. package/src/registry-worker/task-resume.test.ts +196 -0
  98. package/src/registry-worker/task-review.test.ts +121 -0
  99. package/src/runtime/appium-driver.ts +1 -1
  100. package/src/runtime/apply-proposals.test.ts +74 -0
  101. package/src/runtime/apply-proposals.ts +41 -0
  102. package/src/runtime/cloud-secrets.test.ts +201 -0
  103. package/src/runtime/cloud-secrets.ts +210 -0
  104. package/src/runtime/config-validate.ts +12 -9
  105. package/src/runtime/cursor-driver-run.ts +14 -3
  106. package/src/runtime/discovery-task.test.ts +26 -1
  107. package/src/runtime/discovery-task.ts +67 -6
  108. package/src/runtime/executor.ts +18 -10
  109. package/src/runtime/explorer.test.ts +32 -0
  110. package/src/runtime/explorer.ts +48 -0
  111. package/src/runtime/flow-language.ts +22 -8
  112. package/src/runtime/init-wizard.ts +112 -6
  113. package/src/runtime/journey-editor.ts +39 -2
  114. package/src/runtime/journey-graph.test.ts +18 -0
  115. package/src/runtime/local-registry-stub.test.ts +64 -0
  116. package/src/runtime/local-registry-stub.ts +255 -7
  117. package/src/runtime/local-vault.ts +65 -0
  118. package/src/runtime/pipeline.test.ts +23 -0
  119. package/src/runtime/pipeline.ts +61 -20
  120. package/src/runtime/planner.test.ts +24 -1
  121. package/src/runtime/planner.ts +88 -23
  122. package/src/runtime/playwright-driver.test.ts +21 -2
  123. package/src/runtime/playwright-driver.ts +49 -7
  124. package/src/runtime/project-identity.test.ts +161 -0
  125. package/src/runtime/project-identity.ts +232 -0
  126. package/src/runtime/project-roles.test.ts +107 -0
  127. package/src/runtime/project-roles.ts +158 -0
  128. package/src/runtime/propose-cli.ts +66 -0
  129. package/src/runtime/record-run-verdicts.test.ts +58 -0
  130. package/src/runtime/record-run-verdicts.ts +38 -4
  131. package/src/runtime/reporter.ts +1 -1
  132. package/src/runtime/reset.ts +2 -0
  133. package/src/runtime/roles-cli.test.ts +47 -0
  134. package/src/runtime/roles-cli.ts +60 -0
  135. package/src/runtime/run-once.ts +68 -5
  136. package/src/runtime/scenario-matrix.test.ts +41 -0
  137. package/src/runtime/scenario-matrix.ts +98 -0
  138. package/src/runtime/secret-driver.ts +77 -0
  139. package/src/runtime/secret-redaction.ts +69 -0
  140. package/src/runtime/secret-sources.test.ts +442 -0
  141. package/src/runtime/secret-sources.ts +176 -0
  142. package/src/runtime/secrets-cli.ts +146 -0
  143. package/src/runtime/serve-worker.ts +64 -6
  144. package/src/runtime/site-discovery.test.ts +47 -4
  145. package/src/runtime/site-discovery.ts +79 -9
  146. package/src/runtime/vault-federation.test.ts +37 -0
  147. package/src/runtime/vault-federation.ts +128 -0
  148. package/src/runtime/web-suite.ts +5 -2
  149. package/src/storage/create-object-store.ts +5 -1
  150. package/src/storage/object-store.ts +12 -1
  151. package/src/storage/test-case-results.test.ts +64 -0
  152. package/src/storage/test-case-results.ts +49 -0
  153. package/src/vendor/byok.ts +371 -0
@@ -22,3 +22,142 @@ domain_rules:
22
22
  - name: screenshot_evidence_manifest
23
23
  description: Browser evidence is manifest-backed.
24
24
  rule: "Screenshots live under qa/screenshots and the current manifest lives at qa/browser-evidence.json."
25
+
26
+ - name: agent_task_approval_mode
27
+ description: Each project has an approval mode for agent tasks, default auto.
28
+ rule: "manual holds every task until a project manager approves it; auto starts discovery and investigation tasks by themselves and holds code and ui tasks; extreme holds nothing. A task records approvedAt and approvedBy (a person, or auto:<mode>)."
29
+
30
+ - name: workers_take_only_approved_tasks
31
+ description: Nothing runs before it is approved.
32
+ rule: "Claiming a task and dispatching it to Bugmole Cloud both require approvedAt; claiming an unapproved task answers 409 'Task is waiting for approval'. Approving a cloud task hands it to a runner."
33
+
34
+ - name: worker_approval_is_stricter_never_looser
35
+ description: A worker can demand more approval than its project, never less.
36
+ rule: "With bugmole serve --approval <mode> (BUGMOLE_APPROVAL), a worker skips tasks its own mode would hold unless a person approved them; it never takes a task the project holds."
37
+
38
+ - name: test_case_revisions
39
+ description: A test case is never edited in place.
40
+ rule: "Changing a test case adds its next revision and marks the replaced one obsolete (status obsolete, supersededBy, obsoleteReason revised); retiring marks the current one obsolete (obsoleteReason retired). At most one revision of a caseId is active. Obsolete revisions don't run or count toward coverage and keep their results; a result records caseRevision, so a new revision is untested until a run judges it."
41
+
42
+ - name: test_case_proposals
43
+ description: Changes to test cases arrive as proposals.
44
+ rule: "Agents (bugmole_propose_test_case), developers (bugmole propose) and dashboard users propose add, change or retire with a summary and the revision they change. The approval mode decides acceptance: a project manager in manual and auto, on arrival in extreme. A proposal whose base revision is no longer current becomes outdated instead of overwriting. Bugmole storage is updated by the registry on accept; a project's own storage by bugmole serve or the proposing CLI."
45
+
46
+ - name: test_case_results_history
47
+ description: Recording test case results keeps the earlier ones.
48
+ rule: "Every result write (bugmole_write_test_case_results or a run's verdicts) replaces projects/<id>/test-case-results/<caseSetId>.json and also writes projects/<id>/test-case-history/<caseSetId>/<recordedAt>-<runId>.json. The dashboard reads the newest ten history files per case set; a case's history counts only verdicts for its own revision and the current journey revision. Blocked and needs_setup are neither passes nor failures; a case is flaky when it both passed and failed in that window."
49
+
50
+ - name: resume_never_strands_a_task
51
+ description: Resuming a task puts it where something will run it, or says why it can't.
52
+ rule: "POST /api/tasks/<id>/resume hands a cloud task (executionMode cloud) to the dispatcher. A task with executionMode self on a project that runs in the cloud (storage_mode managed or default_execution_mode cloud) is refused with 409 code needs_local_worker unless a machine's API key listed the project's tasks within LOCAL_WORKER_FRESH_MS (projects.local_worker_seen_at); canRunInCloud says a discovery can be resumed with executionMode cloud, which passes the same checks as starting on Bugmole Cloud."
53
+
54
+ - name: resume_keeps_approval_semantics
55
+ description: Resuming never skips the approval step the project requires.
56
+ rule: "A person's approval (approvedBy not auto:) survives a resume; an automatic or missing one is decided again by the project's current approval mode, and a task that waits for approval again is not dispatched until approved."
57
+
58
+ - name: completion_review_follows_approval_mode
59
+ description: A finished AI task waits for a person when the project's approval mode asks for it.
60
+ rule: "When a worker reports completed, the registry sets status in_review instead when the project's approval mode would hold that task type: manual reviews every task, auto reviews code and ui tasks, extreme none (completionStatus in src/registry/task-scheduling.ts). The response carries inReview: true and a message. A task in review is not claimable or resumable, a repeated completed is a no-op, and any other worker update answers 409. Tasks that were completed before review existed stay completed."
61
+
62
+ - name: only_a_person_reviews
63
+ description: Accepting or sending back a task is a person's call.
64
+ rule: "POST /api/tasks/<id>/accept (status completed, completed_at, reviewed_by, reviewed_at, optional review_note) and /send-back (a note is required; status queued, attempts 0, lease and worker cleared, review_note kept, approval kept) need project.manage and refuse API keys and runners with 403, so an agent cannot accept its own work. Both are audited (task.review_accepted, task.review_sent_back). The pipeline pauses while any task is in review."
65
+
66
+ - name: roles_are_suggested_then_confirmed
67
+ description: Bugmole suggests roles; a person decides which are used.
68
+ rule: "project_roles rows are suggested, confirmed or rejected, with source (sign_in_wall, role_gated_route, role_gated_menu, spec, persona, agent, person) and evidence. Anyone with project.write may suggest (evidence required); a person with project.manage adding a role confirms it. Only a person with project.manage confirms, renames, rejects or deletes; API keys and runners get 403. Suggesting a role a person already decided on changes nothing. Every change is audited (role.suggested, role.created, role.confirmed, role.rejected, role.updated, role.deleted)."
69
+
70
+ - name: only_confirmed_roles_are_planned
71
+ description: Plans and test case sets are written for confirmed roles only.
72
+ rule: "bugmole_plan and bugmole_write_test_cases refuse an actor that is not confirmed, naming the confirmed roles. A roles.yaml entry without status is confirmed; the registry's decision overrides roles.yaml. bugmole_write_spec writes any role an agent adds or promotes in roles.yaml as status suggested (a rejected one stays rejected). The pipeline pauses while a flow's only missing actors are suggested roles."
73
+
74
+ - name: project_secrets_are_write_only
75
+ description: Sign-in secrets for cloud runs are stored per project and never read back.
76
+ rule: "Project managers (project.manage) set, replace and delete project secrets in the dashboard; API keys and runners cannot write them. No route returns a value to a browser or an API key: the list carries name, environmentId, createdBy, updatedBy, createdAt, updatedAt and lastUsedAt only. Set, replace, delete and release are audited with names, never values."
77
+
78
+ - name: project_secrets_encryption
79
+ description: Values are encrypted per project and bound to where they belong.
80
+ rule: "Values are AES-256-GCM ciphertext under a per-project key derived with HKDF-SHA256 from the registry secret PROJECT_SECRETS_KEY (info bugmole:project-secret:<projectId>), with additional data <projectId>|<environmentId>|<name> and a key_version for rotation. Without PROJECT_SECRETS_KEY the secrets routes answer 503 and nothing is stored."
81
+
82
+ - name: project_secrets_released_only_to_cloud_runner
83
+ description: Only a Bugmole Cloud runner reads values, for the job it holds.
84
+ rule: "GET /api/runner/runs/:id/secrets and /api/runner/tasks/:id/secrets answer only the run token of that job, while the job is running on cloud:<id>. Project-wide values are replaced by the run environment's own values; explorations get project-wide values. Self-hosted workers never receive stored values and keep reading BUGMOLE_FLOW_SECRET_<NAME> from their own environment."
85
+
86
+ - name: runner_secrets_are_redacted
87
+ description: A secret value never leaves a worker in a log, result or report.
88
+ rule: "Every secret a worker types or is released is replaced with [secret] in logs, step results, failure context, manifests, text uploads and registry updates. A flow that types a secret records no Playwright trace. A cloud runner holds released values in its environment as BUGMOLE_FLOW_SECRET_<NAME> only while the job runs."
89
+
90
+ - name: cloud_exploration_signs_in_first
91
+ description: A project's sign-in flow runs before cloud exploration.
92
+ rule: "When a project has a sign-in flow, cloud exploration runs it first and explores in its session; the flow references secrets by name and saving a flow that contains a stored secret's value is refused. A flow that uses a secret the project doesn't have fails the exploration with a message naming the missing secret."
93
+
94
+ - name: coverage_counts_only_checked_work
95
+ description: Covered means a run checked it and it passed; everything else is listed as a gap.
96
+ rule: "A test case is an assumption until a run records a result for its current revision, and an assumption never counts as covered. Overview lists what isn't covered from data the dashboard already loads (apps/qa/src/lib/coverage-gaps.ts): pages or journeys with no test or case, journeys whose cases are all obsolete, tests never run, tests or cases whose latest result failed or was blocked, and what the last exploration couldn't reach (a sign-in wall, pages it couldn't open, links it skipped, its page limit). Each gap names its reason and a next action; a gap is listed only when a record shows it."
97
+
98
+ - name: estimates_before_starting
99
+ description: Starting a run or exploration says how long it usually takes, or that there is no estimate yet.
100
+ rule: "Before a test run starts, the estimate is the median of that test's last ten finished runs; without one it is estimated from the test's step count, then from other tests in the project; with none of these it says there is no estimate yet. Explorations use earlier ones of the same kind, or a first-time default. Estimates come from apps/qa/src/lib/eta.ts and are never shown as a promise."
101
+
102
+ - name: jira_credentials_are_write_only
103
+ description: A project's Jira API token and webhook secret are stored sealed and never read back.
104
+ rule: "Only project managers (project.manage) signed in to the dashboard connect, update, pause and disconnect Jira (PUT/DELETE /api/projects/:id/jira); API keys and runners cannot. The token is verified against Jira (myself and the project) before anything is stored, then sealed with AES-256-GCM under a key derived by HKDF-SHA256 from PROJECT_SECRETS_KEY with info bugmole:jira:<projectId> and additional data <projectId>|jira|<connectionId>|<field>. No response, audit entry or log line contains the token or webhook secret; the webhook secret is returned once, at connect or rotation. The site must be https://<name>.atlassian.net."
105
+
106
+ - name: jira_plan_and_tickets
107
+ description: Bugmole proposes its test plan as one Jira issue and keeps one ticket per approved test case.
108
+ rule: "POST /api/projects/:id/jira/plan creates one Task labelled bugmole-plan from the project's pending test case proposals (the same pending set is not posted twice); when every proposal on it is decided in Bugmole, the issue gets a comment and moves to a Done-category status. Accepted proposals (and, on request for Bugmole storage, existing active cases) get one Task each labelled bugmole-test plus a per-case label bugmole-case-<sha256(projectId|caseSetId|caseId)[0:16]>; jira_issue_links maps (project_id, case_set_id, case_id) to the issue key and revision. Creation is claimed per case and searches Jira by the case label first, so a lost write never creates a second ticket."
109
+
110
+ - name: jira_results_move_tickets
111
+ description: Run results move tickets to Done or Blocked and mention the people on failed ones.
112
+ rule: "When a run of a project with linked tickets ends, the outbox reports it once (jira-report:<runId>). Case verdicts come from the run's test-case-results record (latest or history) on Bugmole storage; a case-scoped retest without readable verdicts is judged by the run's own status. Passed moves the ticket to a Done-category transition and removes bugmole-blocked; failed or blocked moves it to a status named Blocked, or adds bugmole-blocked when the workflow has none, and comments with ADF mentions of the assignee and reporter (never Bugmole's own account) and the run's evidence link; needs_setup only comments."
113
+
114
+ - name: jira_mention_retests_one_case
115
+ description: Mentioning Bugmole on a ticket retests only that ticket's case.
116
+ rule: "POST /api/jira/webhook?connection=<id> accepts a delivery only with the connection's secret, compared in constant time, as X-Hub-Signature sha256 HMAC of the body, an X-Bugmole-Webhook-Secret header, or a token query parameter; accepted and rejected deliveries are audited with actor webhook jira:<connectionId>. A comment_created event on a linked ticket in the connected project, not written by Bugmole's own account, that contains the mention keyword or mentions Bugmole's account queues a run of the case's journey plan with idempotency key jira:<connectionId>:<issueKey>:<commentId>, trigger_source jira and runs.case_scope_json {caseSetId, caseIds:[caseId], jiraIssueKey}. Workers judge only scoped cases and merge them into the latest results, so other cases and tickets keep their state."
117
+
118
+ - name: plan_limits_on_every_way_in
119
+ description: Project and Free-workspace limits apply however a project is created, including CLI device pairing.
120
+ rule: "POST /api/projects/register (dashboard or API key) and CLI device pairing (POST /api/device-authorizations/claim) refuse a new project past the plan's project limit with 402 plan_limit, carrying suggestedPlan (the smallest plan with room), upgradeUrl (/billing?workspace=<id>&plan=<plan>) and billingUrl (absolute, from DASHBOARD_URL). Pairing never creates a second Free workspace: when the person already owns one, the project goes there, under that workspace's limit. A refused pairing is stored on the attempt (status refused, refusal_json) and returned by the CLI's poll, so `bugmole init` prints the message and the billing link instead of failing. Re-pairing an existing project is not a new project; pairing to a project the person does not manage is refused with 409 project_exists."
121
+
122
+ - name: keep_plan_before_period_end
123
+ description: A cancellation or scheduled plan change can be undone until the paid period ends.
124
+ rule: "POST /api/workspaces/:id/billing/resume (billing.manage, audited) clears cancel_at_period_end and any scheduled change while the provider's subscription is still live, activating it first when PayPal only suspended it. Cancelling at PayPal is final (a CANCELLED subscription cannot be activated), so otherwise it answers checkout_required and starts a checkout for the same plan and interval beginning at the period end, so nothing is charged twice. After the period ends it is refused with period_ended."
125
+
126
+ - name: choose_projects_within_limit
127
+ description: Projects past the plan's limit are read-only, and an owner chooses which stay active.
128
+ rule: "applyProjectLimit keeps projects with kept_at first, then the oldest, and locks the rest (locked_at); nothing is deleted. PUT /api/workspaces/:id/projects/active (workspace.manage, audited as billing.projects_chosen) sets kept_at on at most the plan's limit of the workspace's own projects and re-applies the limit, so later plan changes keep the choice."
129
+
130
+ - name: history_cutoff_everywhere
131
+ description: Run history follows the plan wherever the dashboard reads it, and retention follows the entitlements rollout.
132
+ rule: "Once limits are enforced, finished runs older than the plan's historyDays are hidden from the runs list, from GET /api/runs/:id (404 history_expired) and from run evidence under projects/<id>/runs/<runId>/ in the artifacts list and object routes (evidence of an unknown run is judged by its upload time). The daily retention job deletes the same runs and evidence only when FEATURE_ENTITLEMENTS enforces; in shadow or off it only logs how many it would delete."
133
+
134
+ - name: one_upsell_for_locked_features
135
+ description: Plan-gated features and limits show one upsell with the plan that includes them.
136
+ rule: "Dashboard pages render <LockedFeature feature enabled requiredPlan> (\"Available on Team and Business\", See plans -> /billing?workspace=&plan=) instead of their own copy, and a 402 plan_limit shows <PlanLimitNotice> with the suggested plan. Plan features reach it through one adapter (featureAccessFromUsage over GET /usage limits.features)."
137
+ - name: feature_access_single_evaluator
138
+ description: One function decides whether a workspace may use a feature, and the dashboard reads the same answer.
139
+ rule: "src/registry-worker/feature-access.ts effectiveFeatures evaluates every FEATURES entry in a fixed order and the first layer that decides wins: the global FEATURE_<NAME> kill switch (off or shadow → flag_off), beta rollout (FEATURE_<NAME>=beta needs a workspace_flags opt-in → beta_not_enabled), an unexpired workspace_feature_overrides row (→ workspace_override), then the plan-catalog features with the contract overrides on workspace_plans (→ plan or enterprise_override). Limits are entitlementsFor(plan, overrides), the same values enforcement reads. GET /api/workspaces/:id/features returns the evaluated features (enabled, reason, requiredPlan), limits (limit, used, unlimited) and plan to any workspace member; the dashboard hides features blocked by rollout or an operator and locks, with the required plan, those an upgrade unlocks."
140
+
141
+ - name: feature_flags_match_the_catalog
142
+ description: Every priced feature has exactly one flag.
143
+ rule: "FEATURE_DEFS in src/registry-worker/flags.ts maps each flag to at most one plan-catalog features key; every catalog features key has exactly one flag, and flags without one are listed in OPERATIONAL_FEATURES (entitlements, cloud_runners, billing, feedback, mcp). A test fails when either side drifts."
144
+
145
+ - name: platform_admin_routes
146
+ description: Only platform operators change overrides and contract plans, and every change is audited.
147
+ rule: "/api/platform/… routes require Authorization Bearer PLATFORM_ADMIN_TOKEN compared in constant time (SHA-256 of both sides); they answer 503 when the secret is unset and 401 for a missing or wrong token, and never accept dashboard sessions or API keys. The dashboard proxy never forwards them. Setting or clearing a feature override or beta opt-in, and setting a contract plan, writes an audit_log row with actor system:platform_admin, the x-bugmole-operator name, the new and previous values and the note. A contract plan is enterprise or free only, is refused with 409 while a provider subscription is active, re-applies the project limit and runs the plan-change hooks."
148
+
149
+ - name: one_app_one_project
150
+ description: A project is one app, identified by its domain or its repository and app folder; the same app in a workspace is the same project.
151
+ rule: "src/registry/project-identity.ts normalizes an identity: domain:<host> (lowercase, no port, no leading www., never a private or local host) for an app on the internet, repo:<host>/<path>#<appPath> for a git remote (scheme, user, password, token, port and .git dropped; lowercase host; path lowercased on github.com, gitlab.com, bitbucket.org, dev.azure.com and codeberg.org) plus the app's folder ('.' for the root). The CLI computes it (bugmole init, pairing, bugmole serve) and sends only the normalized, credential-free form; the registry normalizes again and stores projects.identity_kind, identity_key, identity_hash (SHA-256 of the key) and identity_origin, unique per workspace among undeleted projects. POST /api/projects/register with an identity (or a public appUrl) already in the workspace answers 200 {existing: true, project} instead of creating one; CLI pairing joins a project with the same identity in any workspace where the person is an owner, admin or member (poll returns its projectId, the key is scoped to it, the project and the person's role are unchanged). A project without an identity takes its first public environment base URL's domain. POST /api/projects/:id/identity sets it once; a different app is 409 identity_mismatch, one already in the workspace 409 identity_exists."
152
+
153
+ - name: monorepo_apps_are_separate_projects
154
+ description: Every app in a repository is its own project and counts toward the project limit.
155
+ rule: "The CLI finds web apps (framework dependencies, Django, Rails, Laravel, a static index.html) and mobile apps (Expo, React Native, Capacitor, Ionic, Flutter, Android com.android.application, iOS .xcodeproj/.xcworkspace), treating an npm/yarn/pnpm workspace, Turborepo, Nx or Lerna root as a container rather than an app. The app is the deepest one containing the working directory, the one named by --app-path, or the repository's only app; with several and no choice it sends no identity and asks for --app-path rather than collapsing apps into one project. With no web or mobile app it prints that data and AI projects are out of scope."
156
+
157
+ - name: free_identity_in_use
158
+ description: On Free, an app that already belongs to someone else's Free workspace can't be added to another Free workspace.
159
+ rule: "Creating a project (register, sign-up onboarding, CLI pairing) or giving one a domain through an environment in a Free workspace (no plan row, plan free, or a canceled plan) is refused with 409 identity_in_use when an undeleted, active (inactive_at IS NULL) project with the same identity_hash is in another Free workspace the person doesn't own. The message names the app, not the other workspace, and says to ask its owner for an invitation or upgrade (billingUrl for Team). Team, Business and Enterprise workspaces are never refused. It follows FEATURE_ENTITLEMENTS: enforced refuses, shadow logs, off allows. The one-Free-workspace-per-person rule is unchanged."
160
+
161
+ - name: inactive_projects_pause
162
+ description: Projects idle past the plan's inactiveProjectDays are paused until a member activates them.
163
+ rule: "inactiveProjectDays is a plan-catalog limit (Free 30, Team/Business/Enterprise null = never), on the pricing page as 'Projects paused after inactivity' and in GET /api/workspaces/:id/features limits. The daily job (jobs at 17 3 * * *, projects/inactivity.ts) takes a project's last activity as the latest of created_at, activated_at, local_worker_seen_at (a machine or MCP client with an API key listing tasks), runs requested/completed, agent tasks requested/heartbeat/completed, plan_drafts.updated_at, journey_revisions.created_at and non-system audit_log rows; past the limit it sets projects.inactive_at and audits project.paused. Enforced only when FEATURE_ENTITLEMENTS enforces; shadow logs what it would pause; off does nothing. A paused project is readable, refuses new runs, run claims, new tasks and task claims with 409 project_inactive {activateUrl}, and doesn't count toward the project limit, usage or Billing. POST /api/projects/:id/activate (project member with project.write, audited project.activated) clears inactive_at and sets activated_at, and answers 402 plan_limit when the workspace's active projects already fill the plan. The dashboard shows a Paused banner with Activate; bugmole serve prints the activation link."
package/spec/roles.yaml CHANGED
@@ -28,3 +28,23 @@ roles:
28
28
  permissions:
29
29
  - "projects:read"
30
30
  - "keys:write"
31
+ - "proposals:create"
32
+
33
+ - name: project_manager
34
+ description: Workspace owner or admin (project.manage), always a person. Everything a user can do, plus approving agent tasks the project's approval mode holds back, accepting or sending back finished tasks it holds for review, confirming, renaming or rejecting suggested roles, changing the project's approval mode and where runs go, and setting the project's sign-in flow and write-only secrets for cloud runs (never reading a value back).
35
+ permissions:
36
+ - "projects:read"
37
+ - "keys:write"
38
+ - "tasks:approve"
39
+ - "tasks:review"
40
+ - "roles:decide"
41
+ - "proposals:decide"
42
+ - "project_settings:write"
43
+ - "project_secrets:write"
44
+
45
+ - name: worker
46
+ description: A bugmole serve process, coding-agent driver or Bugmole Cloud runner acting with a project API key or run token. Claims queued work and reports results; takes only approved agent tasks, and with --approval / BUGMOLE_APPROVAL only those its own mode would allow. Its completed may become in_review for a person, and it may suggest roles but never confirm one or review a task. Only a Bugmole Cloud runner, with the run token of the job it holds, reads the project's secret values (project_secrets:release); API-key workers never do.
47
+ permissions:
48
+ - "tasks:claim"
49
+ - "runs:claim"
50
+ - "roles:suggest"
@@ -11,7 +11,9 @@
11
11
  "results"
12
12
  ],
13
13
  "properties": {
14
- "schemaVersion": { "const": 1 },
14
+ "schemaVersion": {
15
+ "const": 1
16
+ },
15
17
  "caseSetId": {
16
18
  "type": "string",
17
19
  "minLength": 1,
@@ -36,24 +38,38 @@
36
38
  "items": {
37
39
  "type": "object",
38
40
  "additionalProperties": false,
39
- "required": ["caseId", "status", "detail"],
41
+ "required": [
42
+ "caseId",
43
+ "status",
44
+ "detail"
45
+ ],
40
46
  "properties": {
41
47
  "caseId": {
42
48
  "type": "string",
43
49
  "description": "Must be a caseId present in the referenced case set."
44
50
  },
45
51
  "status": {
46
- "enum": ["passed", "failed", "blocked", "needs_setup"],
52
+ "enum": [
53
+ "passed",
54
+ "failed",
55
+ "blocked",
56
+ "needs_setup"
57
+ ],
47
58
  "description": "\"failed\" means the check ran and the expectation did not hold. \"blocked\" means it could not be run at all. Do not use one for the other: a regression hidden behind a setup problem is how a broken app looks healthy."
48
59
  },
49
60
  "detail": {
50
61
  "type": "string",
51
62
  "minLength": 1,
52
- "description": "What was actually observed — the status code, the element that was or was not there, the assertion that failed. Required, including for a pass: a verdict nobody can review is not evidence."
63
+ "description": "What was actually observed \u2014 the status code, the element that was or was not there, the assertion that failed. Required, including for a pass: a verdict nobody can review is not evidence."
53
64
  },
54
65
  "evidence": {
55
66
  "type": "string",
56
67
  "description": "Artifact key for the screenshot or capture backing this verdict."
68
+ },
69
+ "caseRevision": {
70
+ "type": "integer",
71
+ "minimum": 1,
72
+ "description": "The revision of the case this verdict judged. A newer revision is untested until a run records a verdict for it."
57
73
  }
58
74
  }
59
75
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://app.bugmole.com/schemas/test-cases.schema.json",
4
4
  "title": "Bugmole Test Case Assumptions",
5
- "description": "Candidate test cases derived from a discovered journey. These are assumptions: what the agent believes each step should do, grounded in nodes and edges that actually exist in the journey graph. They are not yet verified — a run is what turns an assumption into evidence.",
5
+ "description": "Candidate test cases derived from a discovered journey. These are assumptions: what the agent believes each step should do, grounded in nodes and edges that actually exist in the journey graph. They are not yet verified \u2014 a run is what turns an assumption into evidence.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": [
@@ -13,30 +13,88 @@
13
13
  "cases"
14
14
  ],
15
15
  "properties": {
16
- "schemaVersion": { "const": 1 },
16
+ "schemaVersion": {
17
+ "const": 1
18
+ },
17
19
  "caseSetId": {
18
20
  "type": "string",
19
21
  "minLength": 1,
20
22
  "pattern": "^[a-zA-Z0-9._-]+$"
21
23
  },
22
- "projectId": { "type": "string" },
23
- "journeyId": { "type": "string", "minLength": 1 },
24
- "journeyRevision": { "type": "integer", "minimum": 1 },
25
- "actor": { "type": "string" },
26
- "generatedAt": { "type": "string", "format": "date-time" },
24
+ "projectId": {
25
+ "type": "string"
26
+ },
27
+ "journeyId": {
28
+ "type": "string",
29
+ "minLength": 1
30
+ },
31
+ "journeyRevision": {
32
+ "type": "integer",
33
+ "minimum": 1
34
+ },
35
+ "actor": {
36
+ "type": "string"
37
+ },
38
+ "generatedAt": {
39
+ "type": "string",
40
+ "format": "date-time"
41
+ },
42
+ "scenarioExclusions": {
43
+ "type": "array",
44
+ "description": "Scenario categories that don't apply, with the reason, instead of a silent gap (a step with no input has nothing to validate; an app with one role has no permission scenarios). Without nodeId an exclusion covers the whole journey.",
45
+ "items": {
46
+ "type": "object",
47
+ "additionalProperties": false,
48
+ "required": [
49
+ "category",
50
+ "reason"
51
+ ],
52
+ "properties": {
53
+ "category": {
54
+ "enum": [
55
+ "happy_path",
56
+ "validation",
57
+ "error_state",
58
+ "permission",
59
+ "edge_case"
60
+ ]
61
+ },
62
+ "nodeId": {
63
+ "type": "string",
64
+ "minLength": 1,
65
+ "description": "Must be a node in the referenced journey."
66
+ },
67
+ "reason": {
68
+ "type": "string",
69
+ "minLength": 1
70
+ }
71
+ }
72
+ }
73
+ },
27
74
  "cases": {
28
75
  "type": "array",
29
76
  "items": {
30
77
  "type": "object",
31
78
  "additionalProperties": false,
32
- "required": ["caseId", "title", "nodeId", "intent", "steps", "expected", "confidence"],
79
+ "required": [
80
+ "caseId",
81
+ "title",
82
+ "nodeId",
83
+ "intent",
84
+ "steps",
85
+ "expected",
86
+ "confidence"
87
+ ],
33
88
  "properties": {
34
89
  "caseId": {
35
90
  "type": "string",
36
91
  "minLength": 1,
37
92
  "pattern": "^[a-zA-Z0-9._-]+$"
38
93
  },
39
- "title": { "type": "string", "minLength": 1 },
94
+ "title": {
95
+ "type": "string",
96
+ "minLength": 1
97
+ },
40
98
  "nodeId": {
41
99
  "type": "string",
42
100
  "minLength": 1,
@@ -47,39 +105,96 @@
47
105
  "minLength": 1,
48
106
  "description": "Optional; when present must be an edge in the referenced journey."
49
107
  },
50
- "route": { "type": "string" },
108
+ "route": {
109
+ "type": "string"
110
+ },
51
111
  "intent": {
52
112
  "type": "string",
53
113
  "minLength": 1,
54
114
  "description": "What this case is trying to prove, in one sentence."
55
115
  },
116
+ "category": {
117
+ "enum": [
118
+ "happy_path",
119
+ "validation",
120
+ "error_state",
121
+ "permission",
122
+ "edge_case"
123
+ ],
124
+ "description": "What kind of scenario this case is: happy_path (works for the intended user), validation (bad or missing input is rejected), error_state (backend failure, network error or no data is handled), permission (a user without access is kept out), edge_case (limits and unusual but valid input). Every step needs a happy_path case; each other category needs a case somewhere in the journey or a scenarioExclusions entry saying why it doesn't apply."
125
+ },
56
126
  "steps": {
57
127
  "type": "array",
58
128
  "minItems": 1,
59
- "items": { "type": "string", "minLength": 1 }
129
+ "items": {
130
+ "type": "string",
131
+ "minLength": 1
132
+ }
60
133
  },
61
134
  "expected": {
62
135
  "type": "array",
63
136
  "minItems": 1,
64
- "items": { "type": "string", "minLength": 1 },
137
+ "items": {
138
+ "type": "string",
139
+ "minLength": 1
140
+ },
65
141
  "description": "Observable outcomes. Each should be checkable, not a restatement of the step."
66
142
  },
67
143
  "confidence": {
68
- "enum": ["high", "medium", "low"],
144
+ "enum": [
145
+ "high",
146
+ "medium",
147
+ "low"
148
+ ],
69
149
  "description": "How sure the agent is that this expectation is correct rather than guessed."
70
150
  },
71
151
  "basis": {
72
152
  "type": "array",
73
- "items": { "type": "string", "minLength": 1 },
74
- "description": "What this assumption was derived from — source paths, observed controls, domain rules. An assumption with no basis should be low confidence."
153
+ "items": {
154
+ "type": "string",
155
+ "minLength": 1
156
+ },
157
+ "description": "What this assumption was derived from \u2014 source paths, observed controls, domain rules. An assumption with no basis should be low confidence."
75
158
  },
76
159
  "blockedReason": {
77
160
  "type": "string",
78
161
  "minLength": 1,
79
162
  "description": "Set when the case cannot be asserted yet; recorded rather than silently dropped."
163
+ },
164
+ "revision": {
165
+ "type": "integer",
166
+ "minimum": 1,
167
+ "description": "Which revision of this case this is; 1 when absent. A change never edits a case in place: it adds the next revision."
168
+ },
169
+ "status": {
170
+ "enum": [
171
+ "active",
172
+ "obsolete"
173
+ ],
174
+ "description": "Only the active revision runs and counts toward coverage; obsolete revisions are kept with their past results. Absent means active."
175
+ },
176
+ "supersededBy": {
177
+ "type": "integer",
178
+ "minimum": 2,
179
+ "description": "The revision that replaced this one (absent when the case was retired)."
180
+ },
181
+ "obsoletedAt": {
182
+ "type": "string",
183
+ "format": "date-time"
184
+ },
185
+ "obsoleteReason": {
186
+ "enum": [
187
+ "revised",
188
+ "retired"
189
+ ]
190
+ },
191
+ "proposalId": {
192
+ "type": "string",
193
+ "description": "The accepted proposal that created this revision."
80
194
  }
81
195
  }
82
- }
196
+ },
197
+ "description": "A case may appear once per revision; at most one revision of a caseId is active."
83
198
  }
84
199
  }
85
200
  }
@@ -2,7 +2,20 @@ import assert from "node:assert/strict";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import test from "node:test";
4
4
  import { COPY_HEADER, catalogCopy, catalogSource } from "../../scripts/sync-plan-catalog.mjs";
5
- import { AI_CREDIT_COST, PLANS, entitlementsFor, formatUsd, includedQuantity, overageCents } from "./plan-catalog.js";
5
+ import {
6
+ AI_CREDIT_COST,
7
+ PLANS,
8
+ PLAN_IDS,
9
+ PRICING_ROWS,
10
+ PRICING_UNLISTED,
11
+ catalogPricingKeys,
12
+ entitlementsFor,
13
+ formatUsd,
14
+ includedQuantity,
15
+ overageCents,
16
+ pricingCell,
17
+ pricingValue,
18
+ } from "./plan-catalog.js";
6
19
 
7
20
  test("the dashboard's copy of the plan catalog matches the source", async () => {
8
21
  const [source, copy] = await Promise.all([readFile(catalogSource, "utf8"), readFile(catalogCopy, "utf8")]);
@@ -26,8 +39,11 @@ test("free usage stops at the allowance while paid plans can buy more", () => {
26
39
  assert.equal(overageCents(free, "device_minute"), null);
27
40
  const team = entitlementsFor("team");
28
41
  assert.equal(overageCents(team, "browser_minute"), 4);
29
- assert.equal(overageCents(team, "device_minute"), 25);
42
+ assert.equal(overageCents(team, "device_minute"), null, "Team has no device clouds, so no real-device minutes");
30
43
  assert.equal(includedQuantity(team, "device_minute"), 0);
44
+ assert.equal(overageCents(entitlementsFor("business"), "device_minute"), 25);
45
+ assert.equal(overageCents(entitlementsFor("business"), "browser_minute"), 3);
46
+ assert.equal(overageCents(entitlementsFor("team", { features: { deviceClouds: true } }), "device_minute"), 25, "a contract that adds device clouds adds device minutes");
31
47
  assert.equal(includedQuantity(team, "ai_credit"), 1500);
32
48
  });
33
49
 
@@ -44,3 +60,67 @@ test("AI credit costs match the pricing page", () => {
44
60
  assert.equal(formatUsd(24_900), "$249");
45
61
  assert.equal(formatUsd(4), "$0.04");
46
62
  });
63
+
64
+ test("real-device minutes are priced only where device clouds are included", () => {
65
+ for (const plan of Object.values(PLANS)) {
66
+ const priced = overageCents(entitlementsFor(plan.id), "device_minute") !== null;
67
+ assert.equal(priced, plan.features.deviceClouds, plan.id);
68
+ }
69
+ });
70
+
71
+ test("only Enterprise lists contract extras, and they are descriptive only", () => {
72
+ for (const id of ["free", "team", "business"] as const) {
73
+ assert.ok(Object.values(PLANS[id].extras).every((value) => value === false), id);
74
+ }
75
+ assert.ok(Object.values(PLANS.enterprise.extras).every((value) => value === true));
76
+ assert.equal("extras" in entitlementsFor("enterprise"), false, "entitlements never carry contract extras");
77
+ });
78
+
79
+ test("every plan includes the everyone-gets features", () => {
80
+ for (const plan of Object.values(PLANS)) {
81
+ for (const key of ["unlimitedUsers", "unlimitedLocalRuns", "desktopBrowsers", "emulatedMobile", "aiFailureAnalysis"] as const) {
82
+ assert.equal(plan.features[key], true, `${plan.id}.${key}`);
83
+ }
84
+ }
85
+ });
86
+
87
+ test("every pricing row names a real catalog key", () => {
88
+ for (const row of PRICING_ROWS) {
89
+ for (const plan of PLAN_IDS) assert.notEqual(pricingValue(plan, row.key), undefined, `${row.key} on ${plan}`);
90
+ }
91
+ assert.equal(pricingValue("team", "features.notAThing"), undefined);
92
+ assert.equal(pricingValue("team", "limits.seats"), undefined);
93
+ assert.equal(new Set(PRICING_ROWS.map((row) => row.key)).size, PRICING_ROWS.length, "no duplicate rows");
94
+ });
95
+
96
+ test("every catalog feature, extra and limit is on the pricing page or deliberately unlisted", () => {
97
+ const shown = new Set<string>(PRICING_ROWS.map((row) => row.key));
98
+ for (const key of catalogPricingKeys()) {
99
+ assert.ok(shown.has(key) || PRICING_UNLISTED[key], `${key} is in the catalog but not on the pricing page`);
100
+ assert.ok(!(shown.has(key) && PRICING_UNLISTED[key]), `${key} is both shown and unlisted`);
101
+ }
102
+ for (const key of Object.keys(PRICING_UNLISTED)) assert.ok((catalogPricingKeys() as string[]).includes(key), `unlisted ${key} is not a catalog key`);
103
+ });
104
+
105
+ test("pricing cells follow the catalog", () => {
106
+ const row = (key: string) => PRICING_ROWS.find((item) => item.key === key)!;
107
+ assert.equal(pricingCell("team", row("features.teams")), "\u2713");
108
+ assert.equal(pricingCell("free", row("features.teams")), "\u2014");
109
+ assert.equal(pricingCell("team", row("overage.browser_minute")), "$0.04 / min");
110
+ assert.equal(pricingCell("business", row("overage.browser_minute")), "$0.03 / min");
111
+ assert.equal(pricingCell("team", row("overage.device_minute")), "\u2014");
112
+ assert.equal(pricingCell("business", row("overage.device_minute")), "$0.25 / min");
113
+ assert.equal(pricingCell("enterprise", row("overage.browser_minute")), "Volume pricing", "contract usage isn't shown at list rates");
114
+ assert.equal(pricingCell("team", row("limits.cloudMinutes")), "3,000");
115
+ assert.equal(pricingCell("enterprise", row("limits.projects")), "Custom");
116
+ assert.equal(pricingCell("business", row("limits.historyDays")), "90 days");
117
+ assert.equal(pricingCell("free", row("limits.inactiveProjectDays")), "30 days");
118
+ assert.equal(pricingCell("team", row("limits.inactiveProjectDays")), "Never", "no limit reads Never, not Custom");
119
+ assert.equal(pricingCell("enterprise", row("limits.inactiveProjectDays")), "Never");
120
+ });
121
+
122
+ test("only Free pauses inactive projects, and contracts can change it", () => {
123
+ assert.equal(entitlementsFor("free").inactiveProjectDays, 30);
124
+ for (const id of ["team", "business", "enterprise"] as const) assert.equal(entitlementsFor(id).inactiveProjectDays, null, id);
125
+ assert.equal(entitlementsFor("enterprise", { inactiveProjectDays: 90 }).inactiveProjectDays, 90);
126
+ });