@bugmole/cli 0.4.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 (173) hide show
  1. package/LICENSE +18 -4
  2. package/package.json +7 -4
  3. package/scripts/bugmole-admin.mjs +194 -0
  4. package/scripts/bugmole-admin.test.mjs +62 -0
  5. package/scripts/bugmole.test.ts +94 -2
  6. package/scripts/bugmole.ts +290 -43
  7. package/scripts/ensure-playwright.cjs +87 -0
  8. package/scripts/ensure-playwright.test.ts +36 -0
  9. package/scripts/sync-byok.d.mts +2 -0
  10. package/scripts/sync-byok.mjs +18 -0
  11. package/scripts/sync-launcher-version.cjs +24 -0
  12. package/scripts/sync-launcher-version.test.ts +28 -0
  13. package/scripts/sync-plan-catalog.d.mts +3 -0
  14. package/scripts/sync-plan-catalog.mjs +16 -6
  15. package/spec/domain_rules.yaml +139 -0
  16. package/spec/roles.yaml +20 -0
  17. package/spec/test-case-results.schema.json +20 -4
  18. package/spec/test-cases.schema.json +131 -16
  19. package/src/billing/plan-catalog.test.ts +82 -2
  20. package/src/billing/plan-catalog.ts +212 -9
  21. package/src/integrations/jira.ts +264 -0
  22. package/src/mcp/roles-and-review.test.ts +111 -0
  23. package/src/mcp/server.ts +358 -22
  24. package/src/mcp/write-test-cases.test.ts +68 -0
  25. package/src/registry/control-plane-client.ts +121 -6
  26. package/src/registry/migrations/0034_signup_attribution.sql +19 -0
  27. package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
  28. package/src/registry/migrations/0036_task_approval.sql +11 -0
  29. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  30. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  31. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  32. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  33. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  34. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  35. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  36. package/src/registry/migrations/0044_project_identity.sql +31 -0
  37. package/src/registry/project-identity.test.ts +99 -0
  38. package/src/registry/project-identity.ts +206 -0
  39. package/src/registry/roles.test.ts +66 -0
  40. package/src/registry/roles.ts +201 -0
  41. package/src/registry/task-scheduling.test.ts +94 -0
  42. package/src/registry/task-scheduling.ts +199 -2
  43. package/src/registry/test-case-revisions.test.ts +57 -0
  44. package/src/registry/test-case-revisions.ts +132 -0
  45. package/src/registry-worker/ai/routes.ts +3 -3
  46. package/src/registry-worker/artifacts.ts +23 -4
  47. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  48. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  49. package/src/registry-worker/billing/enforcement.ts +67 -12
  50. package/src/registry-worker/billing/paypal/api.ts +14 -0
  51. package/src/registry-worker/billing/paypal/client.ts +9 -0
  52. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  53. package/src/registry-worker/billing/paypal.test.ts +108 -1
  54. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  55. package/src/registry-worker/billing/provider.ts +8 -0
  56. package/src/registry-worker/billing/routes.ts +2 -1
  57. package/src/registry-worker/billing/subscriptions.ts +114 -4
  58. package/src/registry-worker/core.ts +24 -0
  59. package/src/registry-worker/devices/policy.ts +4 -5
  60. package/src/registry-worker/devices/routes.ts +8 -8
  61. package/src/registry-worker/domains/domains.test.ts +64 -1
  62. package/src/registry-worker/domains/routes.ts +32 -15
  63. package/src/registry-worker/domains.ts +40 -2
  64. package/src/registry-worker/feature-access.ts +165 -0
  65. package/src/registry-worker/feature-flags/admin.ts +300 -0
  66. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  67. package/src/registry-worker/feature-flags/routes.ts +102 -0
  68. package/src/registry-worker/features.ts +11 -0
  69. package/src/registry-worker/feedback/feedback.test.ts +47 -0
  70. package/src/registry-worker/feedback/routes.ts +2 -2
  71. package/src/registry-worker/feedback/website.ts +77 -0
  72. package/src/registry-worker/flags.ts +80 -18
  73. package/src/registry-worker/github/checks.ts +4 -4
  74. package/src/registry-worker/hooks.ts +9 -0
  75. package/src/registry-worker/identity/oidc.test.ts +103 -0
  76. package/src/registry-worker/identity/oidc.ts +176 -0
  77. package/src/registry-worker/index.ts +494 -173
  78. package/src/registry-worker/jira/connection.ts +111 -0
  79. package/src/registry-worker/jira/jira.test.ts +404 -0
  80. package/src/registry-worker/jira/routes.ts +432 -0
  81. package/src/registry-worker/jira/workflow.ts +577 -0
  82. package/src/registry-worker/jobs/retention.ts +21 -5
  83. package/src/registry-worker/mcp/tools.ts +2 -1
  84. package/src/registry-worker/notifications/alerts.ts +4 -4
  85. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  86. package/src/registry-worker/notifications/routes.ts +9 -10
  87. package/src/registry-worker/notifications/teams.ts +6 -2
  88. package/src/registry-worker/org/routes.test.ts +24 -1
  89. package/src/registry-worker/org/routes.ts +9 -8
  90. package/src/registry-worker/projects/identity.ts +194 -0
  91. package/src/registry-worker/projects/inactivity.ts +162 -0
  92. package/src/registry-worker/projects/projects.test.ts +283 -0
  93. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  94. package/src/registry-worker/proposals/routes.ts +183 -0
  95. package/src/registry-worker/roles/roles.test.ts +84 -0
  96. package/src/registry-worker/roles/routes.ts +141 -0
  97. package/src/registry-worker/runner/dispatch.ts +1 -0
  98. package/src/registry-worker/runner/routes.ts +22 -5
  99. package/src/registry-worker/runner/runner.test.ts +59 -0
  100. package/src/registry-worker/runner/tokens.ts +12 -1
  101. package/src/registry-worker/secrets/crypto.ts +135 -0
  102. package/src/registry-worker/secrets/routes.ts +296 -0
  103. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  104. package/src/registry-worker/signup/attribution.test.ts +147 -0
  105. package/src/registry-worker/signup/attribution.ts +194 -0
  106. package/src/registry-worker/signup/policy.ts +2 -2
  107. package/src/registry-worker/signup/routes.ts +12 -3
  108. package/src/registry-worker/sso/membership.ts +4 -2
  109. package/src/registry-worker/sso/routes.ts +24 -8
  110. package/src/registry-worker/sso/sso.test.ts +3 -2
  111. package/src/registry-worker/task-approval.test.ts +67 -0
  112. package/src/registry-worker/task-resume.test.ts +196 -0
  113. package/src/registry-worker/task-review.test.ts +121 -0
  114. package/src/runtime/ai-exploration.test.ts +34 -1
  115. package/src/runtime/ai-exploration.ts +97 -3
  116. package/src/runtime/appium-driver.ts +7 -1
  117. package/src/runtime/apply-proposals.test.ts +74 -0
  118. package/src/runtime/apply-proposals.ts +41 -0
  119. package/src/runtime/cloud-secrets.test.ts +201 -0
  120. package/src/runtime/cloud-secrets.ts +210 -0
  121. package/src/runtime/config-validate.ts +12 -9
  122. package/src/runtime/cursor-driver-run.ts +14 -3
  123. package/src/runtime/discovery-task.test.ts +26 -1
  124. package/src/runtime/discovery-task.ts +87 -7
  125. package/src/runtime/executor.ts +18 -10
  126. package/src/runtime/explorer.test.ts +32 -0
  127. package/src/runtime/explorer.ts +48 -0
  128. package/src/runtime/flow-language.ts +43 -8
  129. package/src/runtime/init-wizard.ts +112 -6
  130. package/src/runtime/journey-editor.ts +78 -2
  131. package/src/runtime/journey-graph.test.ts +60 -0
  132. package/src/runtime/local-registry-stub.test.ts +64 -0
  133. package/src/runtime/local-registry-stub.ts +255 -7
  134. package/src/runtime/local-vault.ts +65 -0
  135. package/src/runtime/pipeline.test.ts +23 -0
  136. package/src/runtime/pipeline.ts +61 -20
  137. package/src/runtime/planner.test.ts +24 -1
  138. package/src/runtime/planner.ts +88 -23
  139. package/src/runtime/playwright-driver.test.ts +164 -0
  140. package/src/runtime/playwright-driver.ts +286 -15
  141. package/src/runtime/project-identity.test.ts +161 -0
  142. package/src/runtime/project-identity.ts +232 -0
  143. package/src/runtime/project-roles.test.ts +107 -0
  144. package/src/runtime/project-roles.ts +158 -0
  145. package/src/runtime/propose-cli.ts +66 -0
  146. package/src/runtime/record-run-verdicts.test.ts +58 -0
  147. package/src/runtime/record-run-verdicts.ts +38 -4
  148. package/src/runtime/reporter.ts +1 -1
  149. package/src/runtime/reset.ts +2 -0
  150. package/src/runtime/roles-cli.test.ts +47 -0
  151. package/src/runtime/roles-cli.ts +60 -0
  152. package/src/runtime/run-job.test.ts +21 -0
  153. package/src/runtime/run-job.ts +8 -0
  154. package/src/runtime/run-once.ts +68 -5
  155. package/src/runtime/scenario-matrix.test.ts +41 -0
  156. package/src/runtime/scenario-matrix.ts +98 -0
  157. package/src/runtime/secret-driver.ts +77 -0
  158. package/src/runtime/secret-redaction.ts +69 -0
  159. package/src/runtime/secret-sources.test.ts +442 -0
  160. package/src/runtime/secret-sources.ts +176 -0
  161. package/src/runtime/secrets-cli.ts +146 -0
  162. package/src/runtime/serve-worker.ts +64 -6
  163. package/src/runtime/site-discovery.test.ts +181 -4
  164. package/src/runtime/site-discovery.ts +248 -29
  165. package/src/runtime/vault-federation.test.ts +37 -0
  166. package/src/runtime/vault-federation.ts +128 -0
  167. package/src/runtime/web-suite.test.ts +65 -0
  168. package/src/runtime/web-suite.ts +8 -2
  169. package/src/storage/create-object-store.ts +5 -1
  170. package/src/storage/object-store.ts +12 -1
  171. package/src/storage/test-case-results.test.ts +64 -0
  172. package/src/storage/test-case-results.ts +49 -0
  173. package/src/vendor/byok.ts +371 -0
@@ -29,7 +29,8 @@ import { randomBytes } from "node:crypto";
29
29
  import { createObjectStore, type ObjectStoreConfig } from "../storage/create-object-store.js";
30
30
  import type { ObjectStore } from "../storage/object-store.js";
31
31
  import { normalizeTargets } from "./browser-matrix.js";
32
- import { MAX_TASK_ATTEMPTS, backoffUntil, isResumableTask, resumedTaskFields } from "../registry/task-scheduling.js";
32
+ import { IN_REVIEW, MAX_TASK_ATTEMPTS, REVIEW_NOTE_MAX, approvalModeOf, backoffUntil, completionStatus, inReviewMessage, isResumableTask, resumedTaskFields, sentBackTaskFields, type ApprovalMode } from "../registry/task-scheduling.js";
33
+ import { ROLE_DESCRIPTION_MAX, ROLE_EVIDENCE_MAX, normalizeRoleName, roleSourceOf, roleStatusOf, type RoleSource, type RoleStatus } from "../registry/roles.js";
33
34
 
34
35
  export type LocalRegistryStub = {
35
36
  origin: string;
@@ -39,6 +40,13 @@ export type LocalRegistryStub = {
39
40
  export type LocalRegistryStubOptions = {
40
41
  directory: string;
41
42
  port?: number;
43
+ /**
44
+ * The approval mode this stand-in plays for completion review. It approves
45
+ * every task on creation, as "extreme" does, and by default reviews none;
46
+ * pass (or set BUGMOLE_APPROVAL to) manual or auto to have finished tasks
47
+ * wait in review the way the real registry does.
48
+ */
49
+ reviewMode?: ApprovalMode;
42
50
  };
43
51
 
44
52
  type PlanDraft = {
@@ -112,6 +120,26 @@ type AgentTask = {
112
120
  requestedAt: string;
113
121
  startedAt?: string | null;
114
122
  completedAt?: string | null;
123
+ approvedAt?: string | null;
124
+ approvedBy?: string | null;
125
+ reviewedAt?: string | null;
126
+ reviewedBy?: string | null;
127
+ reviewNote?: string | null;
128
+ };
129
+
130
+ type Role = {
131
+ id: string;
132
+ projectId: string;
133
+ name: string;
134
+ description: string;
135
+ status: RoleStatus;
136
+ source: RoleSource;
137
+ evidence: string | null;
138
+ suggestedBy: string;
139
+ decidedBy: string | null;
140
+ decidedAt: string | null;
141
+ createdAt: string;
142
+ updatedAt: string;
115
143
  };
116
144
 
117
145
  type JourneyRevision = {
@@ -196,6 +224,7 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
196
224
  localDirectory: options.directory,
197
225
  } satisfies ObjectStoreConfig);
198
226
 
227
+ const reviewMode: ApprovalMode = options.reviewMode ?? approvalModeOf(process.env.BUGMOLE_APPROVAL?.trim()) ?? "extreme";
199
228
  const server = http.createServer(async (request, response) => {
200
229
  try {
201
230
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
@@ -210,6 +239,58 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
210
239
  return;
211
240
  }
212
241
 
242
+ // Proposals: the stand-in has no people to review them, so each one is
243
+ // accepted on arrival (like "extreme" mode) and left for the proposing
244
+ // CLI or worker to apply to this project's own storage.
245
+ const proposalsMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/proposals$/);
246
+ if (proposalsMatch) {
247
+ const projectId = decodeURIComponent(proposalsMatch[1]);
248
+ const proposals = await loadCollection<Record<string, unknown>>(store, projectId, "proposals");
249
+ if (method === "GET") {
250
+ const unapplied = url.searchParams.get("unapplied") === "1";
251
+ writeJson(response, 200, { proposals: proposals.filter((item) => !unapplied || (item.status === "accepted" && !item.appliedAt)) });
252
+ return;
253
+ }
254
+ if (method === "POST") {
255
+ const body = await readJsonBody(request) ?? {};
256
+ if (!["add", "change", "retire"].includes(String(body.action)) || typeof body.caseSetId !== "string" || typeof body.caseId !== "string" || typeof body.summary !== "string" || !body.summary.trim()) {
257
+ writeJson(response, 422, { error: "caseSetId, caseId, action and summary are required" });
258
+ return;
259
+ }
260
+ const proposal = {
261
+ id: genId("prop"), projectId, kind: "test_case", caseSetId: body.caseSetId, caseId: body.caseId, action: body.action,
262
+ baseRevision: Number.isInteger(body.baseRevision) ? body.baseRevision : null, proposed: body.case ?? null,
263
+ summary: body.summary, source: body.source ?? "cli", proposedBy: "local-dev-bypass", status: "accepted",
264
+ decidedBy: "auto:local", decisionNote: null, appliedAt: null, appliedRevision: null, createdAt: new Date().toISOString(),
265
+ };
266
+ proposals.push(proposal);
267
+ await saveCollection(store, projectId, "proposals", proposals);
268
+ writeJson(response, 201, { proposal });
269
+ return;
270
+ }
271
+ }
272
+ const proposalAppliedMatch = url.pathname.match(/^\/api\/proposals\/([^/]+)\/applied$/);
273
+ if (proposalAppliedMatch && method === "POST") {
274
+ const proposalId = decodeURIComponent(proposalAppliedMatch[1]);
275
+ const projectIds = [...new Set((await store.list("projects/"))
276
+ .map((object) => object.key.match(/^projects\/([^/]+)\/local-registry\/proposals\.json$/)?.[1])
277
+ .filter((value): value is string => Boolean(value)))].map(decodeURIComponent);
278
+ for (const projectId of projectIds) {
279
+ const proposals = await loadCollection<Record<string, unknown>>(store, projectId, "proposals");
280
+ const index = proposals.findIndex((item) => item.id === proposalId);
281
+ if (index < 0) continue;
282
+ const body = await readJsonBody(request) ?? {};
283
+ proposals[index] = body.outcome === "outdated"
284
+ ? { ...proposals[index], status: "outdated", decisionNote: body.message ?? null }
285
+ : { ...proposals[index], appliedAt: new Date().toISOString(), appliedRevision: body.revision ?? null };
286
+ await saveCollection(store, projectId, "proposals", proposals);
287
+ writeJson(response, 200, { proposal: proposals[index] });
288
+ return;
289
+ }
290
+ writeJson(response, 404, { error: "Proposal not found" });
291
+ return;
292
+ }
293
+
213
294
  const plansMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/plans$/);
214
295
  if (plansMatch) {
215
296
  const projectId = decodeURIComponent(plansMatch[1]);
@@ -248,6 +329,111 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
248
329
  }
249
330
  }
250
331
 
332
+ // Roles: same contract as src/registry-worker/roles/routes.ts. The
333
+ // stand-in has one caller, the local developer, so a `source` of
334
+ // "person" (the dashboard's add form) confirms and anything else is a
335
+ // suggestion that waits for a decision.
336
+ const rolesMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/roles$/);
337
+ if (rolesMatch) {
338
+ const projectId = decodeURIComponent(rolesMatch[1]);
339
+ const roles = await loadCollection<Role>(store, projectId, "roles");
340
+ if (method === "GET") {
341
+ const status = roleStatusOf(url.searchParams.get("status"));
342
+ const order = { suggested: 0, confirmed: 1, rejected: 2 };
343
+ writeJson(response, 200, {
344
+ roles: roles.filter((role) => !status || role.status === status)
345
+ .sort((a, b) => order[a.status] - order[b.status] || a.name.localeCompare(b.name)),
346
+ });
347
+ return;
348
+ }
349
+ if (method === "POST") {
350
+ const body = await readJsonBody(request);
351
+ const name = normalizeRoleName(body?.name);
352
+ if (!name) {
353
+ writeJson(response, 422, { error: "name is required: letters, numbers and underscores, up to 60 characters" });
354
+ return;
355
+ }
356
+ const source = roleSourceOf(body?.source) ?? "agent";
357
+ const status: RoleStatus = source === "person" ? "confirmed" : "suggested";
358
+ const description = typeof body?.description === "string" ? body.description.trim().slice(0, ROLE_DESCRIPTION_MAX) : "";
359
+ const evidence = typeof body?.evidence === "string" && body.evidence.trim() ? body.evidence.trim().slice(0, ROLE_EVIDENCE_MAX) : null;
360
+ if (status === "suggested" && !evidence) {
361
+ writeJson(response, 422, { error: "Say what points at this role in evidence (a sign-in wall, a gated route or menu, a spec entry)" });
362
+ return;
363
+ }
364
+ const now = new Date().toISOString();
365
+ const existing = roles.find((role) => role.name === name);
366
+ if (existing) {
367
+ if (existing.status === "suggested" && status === "suggested") {
368
+ existing.evidence = evidence;
369
+ existing.updatedAt = now;
370
+ } else if (existing.status !== "confirmed" && status === "confirmed") {
371
+ Object.assign(existing, { status: "confirmed", decidedBy: "local-dev-bypass", decidedAt: now, updatedAt: now });
372
+ }
373
+ await saveCollection(store, projectId, "roles", roles);
374
+ writeJson(response, 200, { role: existing, alreadyDecided: existing.status !== "suggested" && status === "suggested" });
375
+ return;
376
+ }
377
+ if (!description) {
378
+ writeJson(response, 422, { error: `description is required, up to ${ROLE_DESCRIPTION_MAX} characters` });
379
+ return;
380
+ }
381
+ const role: Role = {
382
+ id: genId("role"), projectId, name, description, status, source, evidence, suggestedBy: "local-dev-bypass",
383
+ decidedBy: status === "confirmed" ? "local-dev-bypass" : null, decidedAt: status === "confirmed" ? now : null,
384
+ createdAt: now, updatedAt: now,
385
+ };
386
+ roles.push(role);
387
+ await saveCollection(store, projectId, "roles", roles);
388
+ writeJson(response, 201, { role });
389
+ return;
390
+ }
391
+ }
392
+ const roleMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/roles\/([^/]+)$/);
393
+ if (roleMatch && (method === "PATCH" || method === "DELETE")) {
394
+ const projectId = decodeURIComponent(roleMatch[1]);
395
+ const roleId = decodeURIComponent(roleMatch[2]);
396
+ const roles = await loadCollection<Role>(store, projectId, "roles");
397
+ const index = roles.findIndex((role) => role.id === roleId);
398
+ if (index < 0) {
399
+ writeJson(response, 404, { error: "Not found" });
400
+ return;
401
+ }
402
+ if (method === "DELETE") {
403
+ roles.splice(index, 1);
404
+ await saveCollection(store, projectId, "roles", roles);
405
+ writeJson(response, 200, { deleted: true });
406
+ return;
407
+ }
408
+ const body = await readJsonBody(request);
409
+ const action = body?.action === "confirm" || body?.action === "reject" ? body.action : body?.action === undefined || body?.action === "edit" ? "edit" : null;
410
+ if (!action) {
411
+ writeJson(response, 422, { error: "action must be confirm, reject or edit" });
412
+ return;
413
+ }
414
+ const name = body?.name !== undefined ? normalizeRoleName(body.name) : roles[index].name;
415
+ if (!name) {
416
+ writeJson(response, 422, { error: "name must be letters, numbers and underscores, up to 60 characters" });
417
+ return;
418
+ }
419
+ if (roles.some((role, other) => other !== index && role.name === name)) {
420
+ writeJson(response, 409, { error: "A role with that name already exists" });
421
+ return;
422
+ }
423
+ const now = new Date().toISOString();
424
+ roles[index] = {
425
+ ...roles[index],
426
+ name,
427
+ description: typeof body?.description === "string" && body.description.trim() ? body.description.trim().slice(0, ROLE_DESCRIPTION_MAX) : roles[index].description,
428
+ status: action === "confirm" ? "confirmed" : action === "reject" ? "rejected" : roles[index].status,
429
+ ...(action !== "edit" ? { decidedBy: "local-dev-bypass", decidedAt: now } : {}),
430
+ updatedAt: now,
431
+ };
432
+ await saveCollection(store, projectId, "roles", roles);
433
+ writeJson(response, 200, { role: roles[index] });
434
+ return;
435
+ }
436
+
251
437
  const personasMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/personas$/);
252
438
  if (personasMatch) {
253
439
  const projectId = decodeURIComponent(personasMatch[1]);
@@ -522,6 +708,10 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
522
708
  requestedAt: new Date().toISOString(),
523
709
  startedAt: null,
524
710
  completedAt: null,
711
+ // The stand-in has no people to approve anything: every task
712
+ // it holds is approved on creation, as in "extreme" mode.
713
+ approvedAt: new Date().toISOString(),
714
+ approvedBy: "auto:local",
525
715
  };
526
716
  await saveCollection(store, projectId, "tasks", tasks);
527
717
  writeJson(response, 200, { task: tasks[existingIndex] });
@@ -543,6 +733,8 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
543
733
  attempts: 0,
544
734
  nextAttemptAt: null,
545
735
  requestedAt: new Date().toISOString(),
736
+ approvedAt: new Date().toISOString(),
737
+ approvedBy: "auto:local",
546
738
  };
547
739
  tasks.push(task);
548
740
  await saveCollection(store, projectId, "tasks", tasks);
@@ -641,7 +833,9 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
641
833
  writeJson(response, 409, {
642
834
  error: tasks[index].status === "completed"
643
835
  ? "Task is already finished"
644
- : "Task is still held by a live worker",
836
+ : tasks[index].status === IN_REVIEW
837
+ ? "Task is waiting for review; accept it or send it back"
838
+ : "Task is still held by a live worker",
645
839
  });
646
840
  return;
647
841
  }
@@ -659,6 +853,45 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
659
853
  return;
660
854
  }
661
855
 
856
+ // Completion review, as in the registry's acceptAgentTask/sendBackAgentTask.
857
+ const taskReviewMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/(accept|send-back)$/);
858
+ if (taskReviewMatch && method === "POST") {
859
+ const found = await findTaskAnyProject(store, decodeURIComponent(taskReviewMatch[1]));
860
+ if (!found) {
861
+ writeJson(response, 404, { error: "Task not found" });
862
+ return;
863
+ }
864
+ const { projectId, tasks, index } = found;
865
+ if (tasks[index].status !== IN_REVIEW) {
866
+ writeJson(response, 409, { error: "Only a task in review can be accepted or sent back" });
867
+ return;
868
+ }
869
+ const body = await readJsonBody(request);
870
+ const note = typeof body?.note === "string" ? body.note.trim().slice(0, REVIEW_NOTE_MAX) : "";
871
+ const now = new Date().toISOString();
872
+ if (taskReviewMatch[2] === "accept") {
873
+ tasks[index] = { ...tasks[index], status: "completed", completedAt: now, reviewedAt: now, reviewedBy: "local-dev-bypass", reviewNote: note || null };
874
+ await saveCollection(store, projectId, "tasks", tasks);
875
+ writeJson(response, 200, { accepted: true, task: tasks[index] });
876
+ return;
877
+ }
878
+ if (!note) {
879
+ writeJson(response, 422, { error: "Say what needs to change in note" });
880
+ return;
881
+ }
882
+ tasks[index] = {
883
+ ...tasks[index],
884
+ ...sentBackTaskFields(note),
885
+ errorMessage: null,
886
+ completedAt: null,
887
+ reviewedAt: now,
888
+ reviewedBy: "local-dev-bypass",
889
+ };
890
+ await saveCollection(store, projectId, "tasks", tasks);
891
+ writeJson(response, 200, { sentBack: true, task: tasks[index] });
892
+ return;
893
+ }
894
+
662
895
  const taskUpdateMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)$/);
663
896
  if (taskUpdateMatch && method === "POST") {
664
897
  const taskId = decodeURIComponent(taskUpdateMatch[1]);
@@ -669,15 +902,26 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
669
902
  }
670
903
  const { projectId, tasks, index } = found;
671
904
  const body = await readJsonBody(request);
672
- const status = typeof body?.status === "string" ? body.status : "running";
673
- if (!["running", "blocked", "completed", "error"].includes(status)) {
905
+ const reported = typeof body?.status === "string" ? body.status : "running";
906
+ if (!["running", "blocked", "completed", "error"].includes(reported)) {
674
907
  writeJson(response, 422, { error: "Invalid task status" });
675
908
  return;
676
909
  }
677
- if (status === "completed" && tasks[index].taskType === "ui" && (!body?.parityAuditId || typeof body?.parityMatchScore !== "number")) {
910
+ if (reported === "completed" && tasks[index].taskType === "ui" && (!body?.parityAuditId || typeof body?.parityMatchScore !== "number")) {
678
911
  writeJson(response, 422, { error: "UI tasks require parityAuditId and parityMatchScore before completion" });
679
912
  return;
680
913
  }
914
+ // See registry-worker's updateAgentTask: a task in review only
915
+ // answers a repeated "completed", and the mode decides review.
916
+ if (tasks[index].status === IN_REVIEW) {
917
+ if (reported !== "completed") {
918
+ writeJson(response, 409, { error: "Task is waiting for review" });
919
+ return;
920
+ }
921
+ writeJson(response, 200, { updated: false, inReview: true, message: "Task is already waiting for review.", task: tasks[index] });
922
+ return;
923
+ }
924
+ const status = reported === "completed" ? completionStatus(reviewMode, tasks[index].taskType) : reported;
681
925
  const terminal = ["completed", "error"].includes(status);
682
926
  tasks[index] = {
683
927
  ...tasks[index],
@@ -694,11 +938,15 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
694
938
  nextAttemptAt: status === "blocked"
695
939
  ? backoffUntil(tasks[index].attempts ?? 0)
696
940
  : tasks[index].nextAttemptAt ?? null,
697
- leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(),
941
+ leaseExpiresAt: status === IN_REVIEW ? null : new Date(Date.now() + 60_000).toISOString(),
698
942
  completedAt: terminal ? new Date().toISOString() : tasks[index].completedAt,
699
943
  };
700
944
  await saveCollection(store, projectId, "tasks", tasks);
701
- writeJson(response, 200, { updated: true, task: tasks[index] });
945
+ writeJson(response, 200, {
946
+ updated: true,
947
+ ...(status === IN_REVIEW ? { inReview: true, message: inReviewMessage(reviewMode) } : {}),
948
+ task: tasks[index],
949
+ });
702
950
  return;
703
951
  }
704
952
 
@@ -0,0 +1,65 @@
1
+ // This machine's secure store for flow secrets: an encrypted vault file whose
2
+ // one key is kept in the OS credential store (macOS Keychain, Windows
3
+ // Credential Manager, Linux Secret Service). It is the open-source
4
+ // @theaiinc/arcana-vault package, in Bugmole's own namespace, so nothing else
5
+ // on the machine shares it. Where that package isn't installed (an
6
+ // unsupported platform), secret-sources.ts falls back to the OS keychain.
7
+
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+
11
+ export type LocalVaultLike = {
12
+ get(name: string): string | null;
13
+ set(name: string, value: string): void;
14
+ delete(name: string): boolean;
15
+ has(name: string): boolean;
16
+ list(): string[];
17
+ };
18
+
19
+ /** The credential-store service holding Bugmole's vault key. */
20
+ export const VAULT_SERVICE = "com.bugmole.cli";
21
+
22
+ /** Where Bugmole keeps its data on this machine (BUGMOLE_DATA_DIR overrides it). */
23
+ export function bugmoleDataDir(env: Record<string, string | undefined> = process.env, platform: string = process.platform): string {
24
+ if (env.BUGMOLE_DATA_DIR) return env.BUGMOLE_DATA_DIR;
25
+ const home = env.HOME ?? env.USERPROFILE ?? os.homedir();
26
+ if (platform === "darwin") return path.join(home, "Library", "Application Support", "Bugmole");
27
+ if (platform === "win32") return path.join(env.APPDATA ?? path.join(home, "AppData", "Roaming"), "Bugmole");
28
+ return path.join(env.XDG_DATA_HOME ?? path.join(home, ".local", "share"), "bugmole");
29
+ }
30
+
31
+ let opened: Promise<LocalVaultLike | null> | undefined;
32
+
33
+ /**
34
+ * Opens Bugmole's vault, or null when the vault package isn't installed here.
35
+ * BUGMOLE_VAULT_MODULE points at another build of the package (development).
36
+ */
37
+ export function openLocalVault(env: Record<string, string | undefined> = process.env): Promise<LocalVaultLike | null> {
38
+ opened ??= (async () => {
39
+ // A variable specifier: the package is optional, so it isn't resolved at build time.
40
+ const specifier = env.BUGMOLE_VAULT_MODULE || "@theaiinc/arcana-vault";
41
+ let module: { LocalVault?: { open(namespace: { service: string; dir: string }): LocalVaultLike } };
42
+ try {
43
+ module = await import(specifier);
44
+ } catch (error) {
45
+ const code = (error as NodeJS.ErrnoException).code;
46
+ if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || /Cannot find (module|native binding)/i.test(String(error))) return null;
47
+ throw error;
48
+ }
49
+ const LocalVault = module.LocalVault ?? (module as { default?: { LocalVault?: typeof module.LocalVault } }).default?.LocalVault;
50
+ if (!LocalVault) return null;
51
+ try {
52
+ return LocalVault.open({ service: VAULT_SERVICE, dir: path.join(bugmoleDataDir(env), "vault") });
53
+ } catch {
54
+ // No credential store to keep the vault key in (a Linux machine with
55
+ // no Secret Service session, say): fall back as if it weren't installed.
56
+ return null;
57
+ }
58
+ })();
59
+ return opened;
60
+ }
61
+
62
+ /** For tests: forget the opened vault. */
63
+ export function resetLocalVault(): void {
64
+ opened = undefined;
65
+ }
@@ -64,6 +64,29 @@ test("an actor missing from roles.yaml is reconciled instead of dead-ending in t
64
64
  assert.match(state.next!.description, /user/);
65
65
  });
66
66
 
67
+ test("a flow whose role is only suggested waits for a person instead of re-queueing the roles stage", () => {
68
+ const state = derivePipelineState(inputs({ knownActors: ["owner"], suggestedActors: ["user"] }));
69
+ assert.equal(state.status, "paused");
70
+ assert.equal(state.next, undefined);
71
+ assert.match(state.reason, /confirm or reject the suggested role user/);
72
+ });
73
+
74
+ test("the roles stage asks an agent to suggest, never to confirm", () => {
75
+ const state = derivePipelineState(inputs({ knownActors: ["owner"] }));
76
+ assert.match(state.next!.description, /bugmole_role_propose/);
77
+ assert.match(state.next!.description, /cannot confirm a role yourself/);
78
+ });
79
+
80
+ test("a task waiting for review pauses the pipeline until someone accepts or sends it back", () => {
81
+ const state = derivePipelineState(inputs({
82
+ tasks: [{ status: "in_review", title: "Fix the checkout button", idempotencyKey: "pipeline:bugmole:audit:flow:login" }],
83
+ }));
84
+ assert.equal(state.status, "paused");
85
+ assert.equal(state.next, undefined);
86
+ assert.match(state.reason, /waiting for your review/);
87
+ assert.match(state.reason, /Fix the checkout button/);
88
+ });
89
+
67
90
  test("once cases exist the flow gets planned", () => {
68
91
  const state = derivePipelineState(inputs({
69
92
  auditedJourneyIds: ["flow:login"],
@@ -22,7 +22,10 @@ import path from "node:path";
22
22
  import YAML from "yaml";
23
23
  import { createProjectObjectStore } from "../storage/create-object-store.js";
24
24
  import { projectPrefix } from "../storage/keys.js";
25
- import { MAX_TASK_ATTEMPTS } from "../registry/task-scheduling.js";
25
+ import { testCaseResultsKey } from "../storage/test-case-results.js";
26
+ import { IN_REVIEW, MAX_TASK_ATTEMPTS } from "../registry/task-scheduling.js";
27
+ import { suggestedRoleNames, usableRoleNames, type RoleRecord } from "../registry/roles.js";
28
+ import { readSpecRoles } from "./project-roles.js";
26
29
  import { driverForPlatform } from "./planner.js";
27
30
 
28
31
  export type PipelineStage = "explore" | "roles" | "audit" | "testcases" | "plan" | "run";
@@ -50,8 +53,10 @@ export type PipelineTask = {
50
53
  export type PipelineInputs = {
51
54
  projectId: string;
52
55
  journeys: PipelineJourney[];
53
- /** Actor names defined in roles.yaml. */
56
+ /** Confirmed roles: the only actors a flow may be planned for. */
54
57
  knownActors: string[];
58
+ /** Roles Bugmole suggested that still wait for a person to confirm or reject. */
59
+ suggestedActors?: string[];
55
60
  /** Journey ids that already have a written test-case set. */
56
61
  caseSetJourneyIds: string[];
57
62
  /** Journey ids that already have an App Audit artifact. */
@@ -168,7 +173,7 @@ function activeTask(tasks: PipelineTask[]): PipelineTask | undefined {
168
173
  */
169
174
  export function derivePipelineState(inputs: PipelineInputs): PipelineState {
170
175
  const {
171
- projectId, journeys, knownActors, caseSetJourneyIds,
176
+ projectId, journeys, knownActors, suggestedActors = [], caseSetJourneyIds,
172
177
  plannedJourneyIds, tasks, hardStopCategories, recordedBlockerCategories,
173
178
  } = inputs;
174
179
 
@@ -237,8 +242,8 @@ export function derivePipelineState(inputs: PipelineInputs): PipelineState {
237
242
  stage: "roles",
238
243
  satisfied: missingActors.length === 0,
239
244
  detail: missingActors.length
240
- ? `undefined actor${missingActors.length === 1 ? "" : "s"}: ${missingActors.join(", ")}`
241
- : "every flow actor is defined",
245
+ ? `unconfirmed role${missingActors.length === 1 ? "" : "s"}: ${missingActors.join(", ")}`
246
+ : "every flow actor is a confirmed role",
242
247
  },
243
248
  {
244
249
  // The audit inventories each screen's controls and runs the component
@@ -331,6 +336,33 @@ export function derivePipelineState(inputs: PipelineInputs): PipelineState {
331
336
  };
332
337
  }
333
338
 
339
+ // Finished work waiting for a person is not in flight, but the next stage
340
+ // would build on it before anyone looked. Wait for the review instead.
341
+ const inReview = tasks.filter((task) => task.status === IN_REVIEW);
342
+ if (inReview.length) {
343
+ const first = inReview[0];
344
+ return {
345
+ status: "paused",
346
+ reason: `${inReview.length} task${inReview.length === 1 ? " is" : "s are"} waiting for your review — `
347
+ + `accept or send back "${first.title ?? first.idempotencyKey ?? "the task"}" on the Agent Tasks board.`,
348
+ stages,
349
+ orphanedArtifacts,
350
+ };
351
+ }
352
+
353
+ // A flow's actor that Bugmole suggested but nobody has confirmed yet is a
354
+ // question for a person, not work for an agent: queueing the roles stage
355
+ // again would only re-suggest it.
356
+ const awaitingDecision = missingActors.filter((actor) => suggestedActors.includes(actor));
357
+ if (awaitingDecision.length && awaitingDecision.length === missingActors.length) {
358
+ return {
359
+ status: "paused",
360
+ reason: `confirm or reject the suggested role${awaitingDecision.length === 1 ? "" : "s"} ${awaitingDecision.join(", ")} on the Roles page (Manage → Roles). Only confirmed roles are planned for.`,
361
+ stages,
362
+ orphanedArtifacts,
363
+ };
364
+ }
365
+
334
366
  // Only one stage runs at a time; the worker already serialises task
335
367
  // execution, and queueing ahead would just create work that races.
336
368
  const inFlight = activeTask(tasks);
@@ -438,13 +470,14 @@ function buildNextTask(
438
470
  return {
439
471
  stage,
440
472
  taskType: "code",
441
- title: "Define the actors the discovered flows use",
473
+ title: "Suggest the roles the discovered flows use",
442
474
  description: [
443
- `The discovered flows use actors that roles.yaml does not define: ${context.missingActors.join(", ")}.`,
444
- "Planning refuses an undefined actor, so the pipeline cannot proceed until this is reconciled.",
445
- "Inspect the application's real roles and either add the missing actor to spec/roles.yaml with",
446
- "accurate permissions, or update the flow to use an existing role that genuinely matches.",
447
- "Do not invent permissions to make the check pass.",
475
+ `The discovered flows use actors that are not confirmed roles: ${context.missingActors.join(", ")}.`,
476
+ "Planning only uses roles a person confirmed, so the pipeline cannot proceed until this is reconciled.",
477
+ "Inspect the application's real roles (sign-in walls, role-gated routes and menus) and either suggest",
478
+ "the missing role with bugmole_role_propose (with the evidence that points at it) so a person can",
479
+ "confirm, rename or reject it, or update the flow to use a confirmed role that genuinely matches.",
480
+ "You cannot confirm a role yourself. Do not invent permissions to make the check pass.",
448
481
  ].join(" "),
449
482
  idempotencyKey: pipelineIdempotencyKey(projectId, stage),
450
483
  };
@@ -521,12 +554,14 @@ function readYaml(filePath: string): any {
521
554
  }
522
555
  }
523
556
 
524
- /** Actor names defined in roles.yaml, which the planner validates against. */
525
- export function knownActors(specDir: string): string[] {
526
- const roles = readYaml(path.join(specDir, "roles.yaml"));
527
- return Array.isArray(roles?.roles)
528
- ? roles.roles.map((role: any) => role?.name).filter((name: unknown): name is string => typeof name === "string")
529
- : [];
557
+ /** Confirmed roles, which the planner validates against: roles.yaml, overridden by the registry's decisions. */
558
+ export function knownActors(specDir: string, registryRoles: RoleRecord[] = []): string[] {
559
+ return usableRoleNames(readSpecRoles(specDir), registryRoles);
560
+ }
561
+
562
+ /** Roles still waiting for a person's decision. */
563
+ export function suggestedActors(specDir: string, registryRoles: RoleRecord[] = []): string[] {
564
+ return suggestedRoleNames(readSpecRoles(specDir), registryRoles);
530
565
  }
531
566
 
532
567
  /**
@@ -569,7 +604,7 @@ async function listJourneyIdsWithVerdicts(cfg: any, projectId: string): Promise<
569
604
  ? caseSet.cases.map((item: any) => item?.caseId).filter((id: unknown) => typeof id === "string")
570
605
  : [];
571
606
  if (!journeyId || caseIds.length === 0) return null;
572
- const resultKey = `${projectPrefix(projectId)}/test-case-results/${caseSet.caseSetId}.json`;
607
+ const resultKey = testCaseResultsKey(projectId, caseSet.caseSetId);
573
608
  const results = JSON.parse(await store(cfg).getText(resultKey));
574
609
  // A verdict recorded against a superseded revision is not evidence
575
610
  // about the cases as they stand now, matching how the dashboard
@@ -706,6 +741,8 @@ export async function loadPipelineInputs(
706
741
  registry?: {
707
742
  runs?: Array<{ journeyId?: string; status?: string }>;
708
743
  plans?: Array<{ planId: string; journeyId?: string }>;
744
+ /** The registry's role decisions, which override roles.yaml. */
745
+ roles?: RoleRecord[];
709
746
  },
710
747
  ): Promise<PipelineInputs> {
711
748
  const projectId = cfg.project?.id ?? "default";
@@ -727,7 +764,8 @@ export async function loadPipelineInputs(
727
764
  return {
728
765
  projectId,
729
766
  journeys,
730
- knownActors: knownActors(specDir),
767
+ knownActors: knownActors(specDir, registry?.roles ?? []),
768
+ suggestedActors: suggestedActors(specDir, registry?.roles ?? []),
731
769
  caseSetJourneyIds,
732
770
  auditedJourneyIds,
733
771
  plannedJourneyIds: plannedJourneyIds(specDir, journeys.map((journey) => journey.id)),
@@ -758,6 +796,7 @@ type ReconcileClient = {
758
796
  }): Promise<unknown>;
759
797
  listRuns?(): Promise<{ runs: any[] }>;
760
798
  listPlans?(): Promise<{ plans: any[] }>;
799
+ listRoles?(): Promise<{ roles: Array<{ name: string; status: RoleRecord["status"] }> }>;
761
800
  createRun?(input: {
762
801
  planId: string;
763
802
  idempotencyKey: string;
@@ -799,14 +838,16 @@ export async function publishPipelineState(cfg: any, state: PipelineState): Prom
799
838
  }
800
839
 
801
840
  export async function reconcilePipeline(cfg: any, client: ReconcileClient): Promise<ReconcileResult> {
802
- const [listed, runs, plans] = await Promise.all([
841
+ const [listed, runs, plans, roles] = await Promise.all([
803
842
  client.listTasks(),
804
843
  client.listRuns?.().catch(() => ({ runs: [] })) ?? Promise.resolve({ runs: [] }),
805
844
  client.listPlans?.().catch(() => ({ plans: [] })) ?? Promise.resolve({ plans: [] }),
845
+ client.listRoles?.().catch(() => ({ roles: [] })) ?? Promise.resolve({ roles: [] }),
806
846
  ]);
807
847
  const inputs = await loadPipelineInputs(cfg, listed.tasks ?? [], {
808
848
  runs: runs.runs ?? [],
809
849
  plans: plans.plans ?? [],
850
+ roles: (roles.roles ?? []).map((role) => ({ name: role.name, status: role.status })),
810
851
  });
811
852
  const state = derivePipelineState(inputs);
812
853
  // Published on every pass so the dashboard reflects the live position,
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { driverForPlatform } from "./planner.js";
3
+ import { driverForPlatform, journeyFlow } from "./planner.js";
4
+ import { parseMaestroStyleFlow } from "./flow-language.js";
4
5
 
5
6
  test("a web plan is never pinned to the mobile driver", () => {
6
7
  // bugmole.config defaults execution.driver to "maestro" while the plan
@@ -35,3 +36,25 @@ test("an unknown platform is treated as web rather than defaulting to mobile", (
35
36
  // first place: the driver that cannot run without a device file.
36
37
  assert.equal(driverForPlatform(undefined, "desktop"), "playwright");
37
38
  });
39
+
40
+ test("a journey becomes a flow that opens its first screen and follows each transition", () => {
41
+ const flow = journeyFlow(
42
+ { id: "sign-up", name: "Sign up", startNodeId: "screen:/", nodeIds: ["screen:/", "screen:/signup", "screen:/projects/:id"], edgeIds: ["a", "b"] },
43
+ [
44
+ { id: "screen:/", route: "/" },
45
+ { id: "screen:/signup", route: "/signup" },
46
+ { id: "screen:/projects/:id", route: "/projects/:id" },
47
+ ],
48
+ [
49
+ { id: "a", from: "screen:/", to: "screen:/signup" },
50
+ { id: "b", from: "screen:/signup", to: "screen:/projects/:id", label: "Create account" },
51
+ ],
52
+ );
53
+ assert.ok(flow);
54
+ const parsed = parseMaestroStyleFlow(flow, "http://localhost:3000");
55
+ assert.deepEqual(parsed.steps.map((step) => step.type), ["launchApp", "assertLayout", "openLink", "assertLayout", "tapOn", "assertLayout"]);
56
+ });
57
+
58
+ test("a journey with no openable screen has no flow", () => {
59
+ assert.equal(journeyFlow({ id: "x", nodeIds: ["n"] }, [{ id: "n", route: "/projects/:id" }], []), null);
60
+ });