@muggleai/works 5.27.0-staging.245 → 5.27.0-staging.247

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 (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/{chunk-FI6VHHZN.js → chunk-6BAB7IVG.js} +25 -2
  3. package/dist/{chunk-SJBYMBC2.js → chunk-B5BWBZZ5.js} +142 -28
  4. package/dist/cli.js +2 -2
  5. package/dist/index.js +2 -2
  6. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  7. package/dist/plugin/.codex-mcp.json +1 -1
  8. package/dist/plugin/.codex-plugin/plugin.json +1 -1
  9. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  10. package/dist/plugin/.mcp.json +1 -1
  11. package/dist/plugin/hooks/README.md +2 -2
  12. package/dist/plugin/scripts/pr-watch-arm.sh +27 -10
  13. package/dist/plugin/scripts/pr-watch-floors.sh +62 -0
  14. package/dist/plugin/scripts/pr-watch-guards.sh +11 -11
  15. package/dist/plugin/scripts/pr-watch-loop.sh +63 -50
  16. package/dist/plugin/scripts/reconcile-stale-watchers.sh +7 -30
  17. package/dist/plugin/skills/_shared/host-tool-equivalents.md +1 -1
  18. package/dist/plugin/skills/do/e2e-repair.md +2 -0
  19. package/dist/plugin/skills/do/next-step.md +1 -1
  20. package/dist/plugin/skills/do/open-prs/forward.md +1 -1
  21. package/dist/plugin/skills/muggle-pr-followup/SKILL.md +5 -5
  22. package/dist/plugin/skills/muggle-pr-followup/adopt.md +2 -2
  23. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +21 -16
  24. package/dist/plugin/skills/muggle-pr-followup/blocked-tick.md +3 -3
  25. package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +1 -1
  26. package/dist/plugin/skills/muggle-pr-followup/contract.md +9 -7
  27. package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +16 -16
  28. package/dist/plugin/skills/muggle-pr-followup/reconcile.md +4 -4
  29. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +17 -6
  30. package/dist/plugin/skills/muggle-pr-followup/stop.md +3 -3
  31. package/dist/plugin/skills/muggle-pr-visual-walkthrough/e2e-report-assembly.md +1 -0
  32. package/dist/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +2 -2
  33. package/dist/release-manifest.json +3 -3
  34. package/dist/{src-33XQK7NM.js → src-MCJFH7AB.js} +1 -1
  35. package/package.json +1 -1
  36. package/plugin/.claude-plugin/plugin.json +1 -1
  37. package/plugin/.codex-mcp.json +1 -1
  38. package/plugin/.codex-plugin/plugin.json +1 -1
  39. package/plugin/.cursor-plugin/plugin.json +1 -1
  40. package/plugin/.mcp.json +1 -1
  41. package/plugin/hooks/README.md +2 -2
  42. package/plugin/scripts/pr-watch-arm.sh +27 -10
  43. package/plugin/scripts/pr-watch-floors.sh +62 -0
  44. package/plugin/scripts/pr-watch-guards.sh +11 -11
  45. package/plugin/scripts/pr-watch-loop.sh +63 -50
  46. package/plugin/scripts/reconcile-stale-watchers.sh +7 -30
  47. package/plugin/skills/_shared/host-tool-equivalents.md +1 -1
  48. package/plugin/skills/do/e2e-repair.md +2 -0
  49. package/plugin/skills/do/next-step.md +1 -1
  50. package/plugin/skills/do/open-prs/forward.md +1 -1
  51. package/plugin/skills/muggle-pr-followup/SKILL.md +5 -5
  52. package/plugin/skills/muggle-pr-followup/adopt.md +2 -2
  53. package/plugin/skills/muggle-pr-followup/arm-watcher.md +21 -16
  54. package/plugin/skills/muggle-pr-followup/blocked-tick.md +3 -3
  55. package/plugin/skills/muggle-pr-followup/bootstrap.md +1 -1
  56. package/plugin/skills/muggle-pr-followup/contract.md +9 -7
  57. package/plugin/skills/muggle-pr-followup/evals/evals.json +16 -16
  58. package/plugin/skills/muggle-pr-followup/reconcile.md +4 -4
  59. package/plugin/skills/muggle-pr-followup/state-schemas.md +17 -6
  60. package/plugin/skills/muggle-pr-followup/stop.md +3 -3
  61. package/plugin/skills/muggle-pr-visual-walkthrough/e2e-report-assembly.md +1 -0
  62. package/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +2 -2
@@ -9,7 +9,7 @@
9
9
  "name": "muggleai",
10
10
  "source": "./plugin",
11
11
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
12
- "version": "5.27.0-staging.245"
12
+ "version": "5.27.0-staging.247"
13
13
  }
14
14
  ]
15
15
  }
@@ -5705,7 +5705,7 @@ async function checkServiceStack(params) {
5705
5705
  const snapshot = await readGenerationStore({ directory: ledgerDir(), emptyRecord: EMPTY_STACK_RECORD });
5706
5706
  const swept = sweepStackRecord({ record: snapshot.record, now: Date.now(), isAlive: isProcessAlive });
5707
5707
  const ownLease = swept.leases.find((lease) => lease.cwd === cwd) ?? null;
5708
- const base = { cwd, generation: snapshot.generation, mismatches: [], peerLease: null };
5708
+ const base = { cwd, generation: snapshot.generation, mismatches: [], peerLease: null, servedNeeds: [] };
5709
5709
  const isClaimedByCaller = ownLease?.needs.some((need) => need.port === params.port) ?? false;
5710
5710
  const peerLease = params.port === void 0 || isClaimedByCaller ? void 0 : swept.leases.find((lease) => lease.cwd !== cwd && lease.needs.some((need) => need.port === params.port));
5711
5711
  if (peerLease !== void 0) {
@@ -5734,7 +5734,12 @@ async function checkServiceStack(params) {
5734
5734
  mismatches
5735
5735
  };
5736
5736
  }
5737
- return { ...base, verdict: "VALID" /* Valid */, reason: "every claimed port serves the claimed code" };
5737
+ return {
5738
+ ...base,
5739
+ verdict: "VALID" /* Valid */,
5740
+ reason: "every claimed port serves the claimed code",
5741
+ servedNeeds: ownLease.needs
5742
+ };
5738
5743
  }
5739
5744
  async function startStackRun(params) {
5740
5745
  const cwd = await resolveCheckoutRoot({ cwd: params.cwd, runGit: params.runGit ?? runGitCommand });
@@ -9596,6 +9601,20 @@ async function findRunStackRefusal(params) {
9596
9601
  });
9597
9602
  return REFUSING_VERDICTS.has(report.verdict) ? report : null;
9598
9603
  }
9604
+ async function readServedStack(params) {
9605
+ if (!isLoopbackUrl(params.localUrl)) return [];
9606
+ const report = await checkServiceStack({ cwd: params.cwd, runGit: params.runGit, readListener: params.readListener });
9607
+ return report.servedNeeds;
9608
+ }
9609
+ function recordServedStack(params) {
9610
+ if (params.servedStack.length === 0 || params.stackChange !== null) return;
9611
+ const storage = getRunResultStorageService();
9612
+ const runResult = storage.getRunResult(params.runId);
9613
+ if (!runResult) return;
9614
+ storage.updateRunResult(params.runId, {
9615
+ localExecutionContext: { ...runResult.localExecutionContext, servedStack: params.servedStack }
9616
+ });
9617
+ }
9599
9618
  async function runUnderStackLease(params) {
9600
9619
  if (!isLoopbackUrl(params.localUrl)) return await params.execute();
9601
9620
  const runId = randomUUID();
@@ -9985,6 +10004,7 @@ var executeTestGenerationTool = {
9985
10004
  logger7.warn("Test generation refused: service stack", { verdict: stackRefusal.verdict, cwd: input.cwd });
9986
10005
  return { content: formatRunStackRefusal(stackRefusal, input.cwd), isError: true, data: stackRefusal };
9987
10006
  }
10007
+ const servedStack = await readServedStack({ cwd: input.cwd, localUrl: input.localUrl });
9988
10008
  try {
9989
10009
  const generationRun = await runUnderStackLease({
9990
10010
  cwd: input.cwd,
@@ -10012,6 +10032,7 @@ var executeTestGenerationTool = {
10012
10032
  generationRun.errorMessage ? `**Error:** ${generationRun.errorMessage}` : ""
10013
10033
  ].filter(Boolean).join("\n");
10014
10034
  const stackChange = await findStackChangedDuringRun({ cwd: input.cwd, localUrl: input.localUrl });
10035
+ recordServedStack({ runId: generationRun.id, servedStack, stackChange });
10015
10036
  const reportedContent = stackChange ? content + formatStackChangedDuringRun(stackChange) : content;
10016
10037
  return {
10017
10038
  content: reportedContent,
@@ -10051,6 +10072,7 @@ var executeReplayTool = {
10051
10072
  logger7.warn("Test replay refused: service stack", { verdict: stackRefusal.verdict, cwd: input.cwd });
10052
10073
  return { content: formatRunStackRefusal(stackRefusal, input.cwd), isError: true, data: stackRefusal };
10053
10074
  }
10075
+ const servedStack = await readServedStack({ cwd: input.cwd, localUrl: input.localUrl });
10054
10076
  try {
10055
10077
  const replayRun = await runUnderStackLease({
10056
10078
  cwd: input.cwd,
@@ -10079,6 +10101,7 @@ var executeReplayTool = {
10079
10101
  replayRun.errorMessage ? `**Error:** ${replayRun.errorMessage}` : ""
10080
10102
  ].filter(Boolean).join("\n");
10081
10103
  const stackChange = await findStackChangedDuringRun({ cwd: input.cwd, localUrl: input.localUrl });
10104
+ recordServedStack({ runId: replayRun.id, servedStack, stackChange });
10082
10105
  const reportedContent = stackChange ? content + formatStackChangedDuringRun(stackChange) : content;
10083
10106
  return {
10084
10107
  content: reportedContent,
@@ -1,4 +1,4 @@
1
- import { __export, getLogger, getConfig, createChildLogger, buildElectronAppReleaseAssetUrl, getActiveRuntimeTarget, getAuthService, hasApiKey, getElectronAppVersion, getElectronAppDir, getPlatformKey, reconcileProjectPreferences, getDataDir2, PREFERENCES_FILE_NAME, isFirstRun, writePreferences, DEFAULT_PREFERENCES, isElectronAppInstalled, getElectronAppChecksums, getChecksumForPlatform, getElectronAppSignedFromVersion, getReleaseSignerIdentityUri, verifyFileChecksum, resolveElectronAppExecutablePath, calculateFileChecksum, initTelemetry, Surface, ServiceName, track, EventName, getQaTools, getLocalQaTools, performLogout, assertDeviceCodeClientProvisioned, performLogin, getActiveElectronAppReleaseStream, buildToolInputJsonSchema, toolRequiresAuth, getCallerCredentials, parseElectronAppDirName, getElectronAppReleaseTagPrefix, hasShownDisclosure, markDisclosureShown, getBundledElectronAppVersion, getElectronAppVersionSource, getCredentialsFilePath, ElectronAppReleaseStream, buildElectronAppChecksumsUrl, __require, getDisclosureCopy, buildOnboardingPlan, applyOnboardingAnswers, needsOnboarding, buildLlmSourcePlan, describeLlmSourceStatus, resolveLlmSourceStatus, readRunView, RunViewError, renderRunViewText, writeRunViewHtml, openBrowserUrl, evaluateCheckoutFreshness, runGitCommand, BLOCKING_FRESHNESS_VERDICTS, claimServiceStack, BUSY_EXIT_CODE, checkServiceStack, releaseServiceStack, readServiceStackStatus, resolvePreferences, OnboardingBlanketChoice, PreferenceKey, LlmSource, applyLlmSourceSelection, describeStackMismatch, getDataDir, DEFAULT_LOCAL_LLM_BASE_URL, isValidLocalBaseUrl } from './chunk-FI6VHHZN.js';
1
+ import { __export, getLogger, getConfig, createChildLogger, buildElectronAppReleaseAssetUrl, getActiveRuntimeTarget, getAuthService, hasApiKey, getElectronAppVersion, getElectronAppDir, getPlatformKey, reconcileProjectPreferences, getDataDir2, PREFERENCES_FILE_NAME, isFirstRun, writePreferences, DEFAULT_PREFERENCES, isElectronAppInstalled, getElectronAppChecksums, getChecksumForPlatform, getElectronAppSignedFromVersion, getReleaseSignerIdentityUri, verifyFileChecksum, resolveElectronAppExecutablePath, calculateFileChecksum, initTelemetry, Surface, ServiceName, track, EventName, getQaTools, getLocalQaTools, performLogout, assertDeviceCodeClientProvisioned, performLogin, getActiveElectronAppReleaseStream, buildToolInputJsonSchema, toolRequiresAuth, getCallerCredentials, parseElectronAppDirName, getElectronAppReleaseTagPrefix, hasShownDisclosure, markDisclosureShown, getBundledElectronAppVersion, getElectronAppVersionSource, getCredentialsFilePath, ElectronAppReleaseStream, buildElectronAppChecksumsUrl, __require, getDisclosureCopy, buildOnboardingPlan, applyOnboardingAnswers, needsOnboarding, buildLlmSourcePlan, describeLlmSourceStatus, resolveLlmSourceStatus, readRunView, RunViewError, renderRunViewText, writeRunViewHtml, openBrowserUrl, evaluateCheckoutFreshness, runGitCommand, BLOCKING_FRESHNESS_VERDICTS, claimServiceStack, BUSY_EXIT_CODE, checkServiceStack, releaseServiceStack, readServiceStackStatus, resolvePreferences, OnboardingBlanketChoice, PreferenceKey, LlmSource, applyLlmSourceSelection, describeStackMismatch, getDataDir, DEFAULT_LOCAL_LLM_BASE_URL, isValidLocalBaseUrl, local_exports } from './chunk-6BAB7IVG.js';
2
2
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
3
  import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { v4 } from 'uuid';
@@ -356,6 +356,16 @@ function parentIsAlive(pid) {
356
356
  }
357
357
  }
358
358
 
359
+ // src/pr-walkthrough/constants.ts
360
+ var REPORT_SENTINEL = "muggle-pr-section";
361
+ var WALKTHROUGH_SLOT_MARKER = "<!-- muggle-pr-walkthrough:v1 -->";
362
+ var WALKTHROUGH_SKIPPED_MARKER = "<!-- muggle-pr-walkthrough-status:skipped -->";
363
+ var WALKTHROUGH_COMMENT_HEADING = "### Muggle AI \u2014 PR visual walkthrough";
364
+ var RERUN_NOTICE_OPENING = "<!-- muggle-pr-walkthrough-rerun -->";
365
+ var SHORT_SHA_LENGTH = 7;
366
+ var GH_COMMENT_TIMEOUT_MS = 1e4;
367
+ var DEPENDENCY_BOT_LOGINS = ["dependabot[bot]", "renovate[bot]"];
368
+
359
369
  // src/cli/pr-section/constants.ts
360
370
  var DETAIL_IMAGE_WIDTH = 720;
361
371
  var STEP_IMAGE_WIDTH = 640;
@@ -369,6 +379,84 @@ var SHORT_ID_LABEL_SEPARATOR = " \xB7 ";
369
379
  var TEST_DETAILS_ANCHOR_PREFIX = "muggle-test-";
370
380
  var STEP_PAIR_IMAGE_WIDTH = 420;
371
381
  var MAX_COMMENT_BYTES = 64e3;
382
+ var FULL_COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/;
383
+ var BASELINE_FAILED_STATUS = "failed";
384
+
385
+ // src/cli/pr-section/baseline.ts
386
+ var refused = (refusalReason) => ({ isCredited: false, refusalReason });
387
+ async function resolveBaseRange(deps) {
388
+ const baseRef = deps.baseBranch ? `refs/remotes/origin/${deps.baseBranch}` : await deps.runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], deps.cwd);
389
+ if (!baseRef) return null;
390
+ const baseTipSha = await deps.runGit(["rev-parse", "--verify", `${baseRef}^{commit}`], deps.cwd);
391
+ if (!baseTipSha) return null;
392
+ const mergeBaseSha = await deps.runGit(["merge-base", "HEAD", baseTipSha], deps.cwd);
393
+ return mergeBaseSha ? { mergeBaseSha, baseTipSha } : null;
394
+ }
395
+ async function isOnBaseSinceFork(sha, range, deps) {
396
+ const isAncestor = async (ancestor, descendant) => await deps.runGit(["merge-base", "--is-ancestor", ancestor, descendant], deps.cwd) !== null;
397
+ return await isAncestor(range.mergeBaseSha, sha) && await isAncestor(sha, range.baseTipSha);
398
+ }
399
+ function servedCommitsOf(baselineRun) {
400
+ const servedStack = baselineRun.localExecutionContext?.servedStack;
401
+ if (!Array.isArray(servedStack)) return [];
402
+ return servedStack.map((servedNeed) => servedNeed?.headSha).filter((sha) => typeof sha === "string" && FULL_COMMIT_SHA_PATTERN.test(sha));
403
+ }
404
+ async function judgeBaseline(test, baselineRunId, range, deps) {
405
+ if (range === null) return refused("the base branch could not be resolved in this checkout");
406
+ const baselineRun = deps.readRunResult(baselineRunId);
407
+ if (!baselineRun) return refused("no such run in the local run store");
408
+ if (baselineRun.id === test.runId) return refused("it is the failing run itself");
409
+ if (baselineRun.cloudTestCaseId !== test.testCaseId) return refused(`it ran test case ${baselineRun.cloudTestCaseId}`);
410
+ if (baselineRun.status !== BASELINE_FAILED_STATUS) return refused(`its status is ${baselineRun.status}`);
411
+ if (test.runSettingsId && baselineRun.cloudRunSettingsId && baselineRun.cloudRunSettingsId !== test.runSettingsId) {
412
+ return refused("it ran under a different runSettings record");
413
+ }
414
+ const servedCommits = servedCommitsOf(baselineRun);
415
+ if (servedCommits.length === 0) return refused("it recorded no stack it served");
416
+ for (const servedSha of servedCommits) {
417
+ if (!await isOnBaseSinceFork(servedSha, range, deps)) continue;
418
+ return {
419
+ isCredited: true,
420
+ failsOnBase: {
421
+ baselineRunId: baselineRun.id,
422
+ baselineSha: servedSha,
423
+ ...baselineRun.viewUrl && { viewUrl: baselineRun.viewUrl }
424
+ }
425
+ };
426
+ }
427
+ const span = `${range.mergeBaseSha.slice(0, SHORT_SHA_LENGTH)}..${range.baseTipSha.slice(0, SHORT_SHA_LENGTH)}`;
428
+ return refused(`none of the commits it served is on the base between ${span}`);
429
+ }
430
+ function withoutFailsOnBase(test) {
431
+ const uncredited = { ...test };
432
+ delete uncredited.failsOnBase;
433
+ return uncredited;
434
+ }
435
+ async function verifyBaselineFailures(report, deps) {
436
+ let range;
437
+ const tests = [];
438
+ for (const test of report.tests) {
439
+ if (test.status !== "failed") {
440
+ tests.push(test);
441
+ continue;
442
+ }
443
+ const uncredited = withoutFailsOnBase(test);
444
+ if (!test.baselineRunId) {
445
+ tests.push(uncredited);
446
+ continue;
447
+ }
448
+ if (range === void 0) range = await resolveBaseRange(deps);
449
+ const judgment = await judgeBaseline(uncredited, test.baselineRunId, range, deps);
450
+ if (judgment.isCredited) {
451
+ tests.push({ ...uncredited, failsOnBase: judgment.failsOnBase });
452
+ continue;
453
+ }
454
+ deps.stderrWrite(`build-pr-section: baseline ${test.baselineRunId} for "${test.name}" not credited: ${judgment.refusalReason}
455
+ `);
456
+ tests.push(uncredited);
457
+ }
458
+ return { ...report, tests };
459
+ }
372
460
 
373
461
  // src/e2e-result/types.ts
374
462
  var UnpassedCaseStatus = /* @__PURE__ */ ((UnpassedCaseStatus2) => {
@@ -400,8 +488,12 @@ function describeUnpassedCase(unpassed) {
400
488
  const outcome = unpassed.status === "failed" /* Failed */ && unpassed.failedStep !== void 0 ? `failed at step ${unpassed.failedStep}` : unpassed.status;
401
489
  return `${outcome}: ${escapeHtml(unpassed.detail)}`;
402
490
  }
491
+ function describeFailsOnBase(failsOnBase) {
492
+ const baselineLink = failsOnBase.viewUrl ? ` ([baseline run](${failsOnBase.viewUrl}))` : "";
493
+ return ` \xB7 also fails on base \`${failsOnBase.baselineSha.slice(0, SHORT_SHA_LENGTH)}\`${baselineLink}`;
494
+ }
403
495
  function renderUnpassedCaseBullet(unpassed, linkText, linkTarget) {
404
- return `- ${UNPASSED_STATUS_EMOJI[unpassed.status]} **${escapeHtml(withResolutionLabel(unpassed.name, unpassed.resolutionLabel))}** \u2014 ${describeUnpassedCase(unpassed)} \xB7 [${linkText}](${linkTarget})`;
496
+ return `- ${UNPASSED_STATUS_EMOJI[unpassed.status]} **${escapeHtml(withResolutionLabel(unpassed.name, unpassed.resolutionLabel))}** \u2014 ${describeUnpassedCase(unpassed)} \xB7 [${linkText}](${linkTarget})` + (unpassed.failsOnBase ? describeFailsOnBase(unpassed.failsOnBase) : "");
405
497
  }
406
498
  var UnpassedCaseSchema = z.object({
407
499
  name: z.string(),
@@ -411,7 +503,8 @@ var UnpassedCaseSchema = z.object({
411
503
  status: z.enum(UnpassedCaseStatus),
412
504
  failedStep: z.number().int().positive().optional(),
413
505
  detail: z.string(),
414
- viewUrl: z.string()
506
+ viewUrl: z.string(),
507
+ failsOnBase: z.object({ baselineRunId: z.string(), baselineSha: z.string(), viewUrl: z.string().optional() }).optional()
415
508
  });
416
509
  var E2eResultRecordSchema = z.object({
417
510
  total: z.number().int().nonnegative(),
@@ -467,7 +560,8 @@ function toUnpassedCase(test) {
467
560
  status: "failed" /* Failed */,
468
561
  failedStep: failureOrdinal(test, test.failureStepIndex),
469
562
  detail: oneLineDetail(test.error),
470
- viewUrl: test.viewUrl
563
+ viewUrl: test.viewUrl,
564
+ ...test.failsOnBase && { failsOnBase: test.failsOnBase }
471
565
  };
472
566
  }
473
567
  if (test.status === "inconclusive") {
@@ -532,6 +626,9 @@ var FailedTestSchema = z.object({
532
626
  steps: z.array(StepSchema),
533
627
  failureStepIndex: z.number().int().nonnegative(),
534
628
  error: z.string().min(1),
629
+ // A run of the same test case on the PR's base the author says failed too; build-pr-section verifies it before
630
+ // the walkthrough credits it.
631
+ baselineRunId: z.string().min(1).optional(),
535
632
  artifactsDir: z.string().min(1).optional(),
536
633
  useCaseName: z.string().min(1).optional(),
537
634
  description: z.string().min(1).optional(),
@@ -1054,7 +1151,7 @@ async function resolveGsScreenshotUrls(report, opts) {
1054
1151
  if (gsUrls.length === 0) {
1055
1152
  return report;
1056
1153
  }
1057
- const mcps = await import('./src-33XQK7NM.js');
1154
+ const mcps = await import('./src-MCJFH7AB.js');
1058
1155
  const credentials = await mcps.getCallerCredentialsAsync();
1059
1156
  if (!credentials.bearerToken && !credentials.apiKey) {
1060
1157
  stderrWrite(
@@ -1148,7 +1245,15 @@ ${err.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n")}
1148
1245
  }
1149
1246
  return 1;
1150
1247
  }
1151
- const resolvedReport = await resolveGsScreenshotUrls(report, { stderrWrite: opts.stderrWrite });
1248
+ const verifyBaselines = opts.verifyBaselines ?? ((parsedReport) => verifyBaselineFailures(parsedReport, {
1249
+ cwd: process.cwd(),
1250
+ baseBranch: opts.baseBranch,
1251
+ runGit: runGitCommand,
1252
+ readRunResult: (runId) => local_exports.getRunResultStorageService().getRunResult(runId),
1253
+ stderrWrite: opts.stderrWrite
1254
+ }));
1255
+ const verifiedReport = await verifyBaselines(report);
1256
+ const resolvedReport = await resolveGsScreenshotUrls(verifiedReport, { stderrWrite: opts.stderrWrite });
1152
1257
  const sentinelCost = Buffer.byteLength(`${REPORT_SECTION_SENTINEL}
1153
1258
  `, "utf-8");
1154
1259
  const renderedSection = buildPrSection(resolvedReport, {
@@ -1174,7 +1279,8 @@ async function buildPrSectionCommand(options) {
1174
1279
  stdin: process.stdin,
1175
1280
  stdoutWrite: (s) => process.stdout.write(s),
1176
1281
  stderrWrite: (s) => process.stderr.write(s),
1177
- maxBodyBytes
1282
+ maxBodyBytes,
1283
+ baseBranch: options.base
1178
1284
  });
1179
1285
  if (code !== 0) {
1180
1286
  process.exitCode = code;
@@ -2278,7 +2384,6 @@ var WATCH_STATUS_FILE = "status.json";
2278
2384
  var WATCH_STATE_FILE = "state.json";
2279
2385
  var WATCH_EVENTS_FILE = "events.jsonl";
2280
2386
  var WATCH_EVENTS_ROTATE_BYTES = 5 * 1024 * 1024;
2281
- var MONITOR_STDOUT_CAP_MS = 30 * 6e4;
2282
2387
  var WATCHER_REARM_GRACE_MS = 5 * 6e4;
2283
2388
  var CODEX_ACTIVE_WINDOW_MS = 10 * 6e4;
2284
2389
  var CODEX_ROLLOUT_DAY_DIRS_SCANNED = 7;
@@ -3192,16 +3297,6 @@ var NON_APP_PATH_PATTERNS = [
3192
3297
  /^\.gitlab-ci\.yml$/
3193
3298
  ];
3194
3299
 
3195
- // src/pr-walkthrough/constants.ts
3196
- var REPORT_SENTINEL = "muggle-pr-section";
3197
- var WALKTHROUGH_SLOT_MARKER = "<!-- muggle-pr-walkthrough:v1 -->";
3198
- var WALKTHROUGH_SKIPPED_MARKER = "<!-- muggle-pr-walkthrough-status:skipped -->";
3199
- var WALKTHROUGH_COMMENT_HEADING = "### Muggle AI \u2014 PR visual walkthrough";
3200
- var RERUN_NOTICE_OPENING = "<!-- muggle-pr-walkthrough-rerun -->";
3201
- var SHORT_SHA_LENGTH = 7;
3202
- var GH_COMMENT_TIMEOUT_MS = 1e4;
3203
- var DEPENDENCY_BOT_LOGINS = ["dependabot[bot]", "renovate[bot]"];
3204
-
3205
3300
  // src/pr-walkthrough/comment.ts
3206
3301
  function renderReservedComment() {
3207
3302
  return `${WALKTHROUGH_SLOT_MARKER}
@@ -3411,6 +3506,7 @@ var CHECK_SUMMARY_MAX_CASES = 50;
3411
3506
  var OUTCOME_CONCLUSIONS = {
3412
3507
  ["settled" /* Settled */]: "success" /* Success */,
3413
3508
  ["e2e-not-passing" /* E2eNotPassing */]: "failure" /* Failure */,
3509
+ ["e2e-fails-on-base" /* E2eFailsOnBase */]: "neutral" /* Neutral */,
3414
3510
  ["rerun-in-progress" /* RerunInProgress */]: null,
3415
3511
  ["unsettled" /* Unsettled */]: "failure" /* Failure */,
3416
3512
  ["unreadable" /* Unreadable */]: "neutral" /* Neutral */
@@ -3427,15 +3523,32 @@ function countLabel(count, status) {
3427
3523
  return count > 0 ? `${count} ${status}` : null;
3428
3524
  }
3429
3525
  function renderNotPassingSummary(record) {
3526
+ return renderCaseSummary(
3527
+ record,
3528
+ `E2E acceptance did not pass \u2014 ${unpassedTally(record)} of ${testCaseCount(record)}. Fix them, re-run the whole suite, and update the walkthrough comment; editing it re-runs this check.`
3529
+ );
3530
+ }
3531
+ function renderFailsOnBaseSummary(record) {
3532
+ return renderCaseSummary(
3533
+ record,
3534
+ `E2E acceptance: ${unpassedTally(record)} of ${testCaseCount(record)}, each verified to fail on the base too. This PR did not cause them; fix them on the base branch.`
3535
+ );
3536
+ }
3537
+ function testCaseCount(record) {
3538
+ return `${record.total} test ${record.total === 1 ? "case" : "cases"}`;
3539
+ }
3540
+ function unpassedTally(record) {
3430
3541
  const failed2 = record.unpassed.filter((c) => c.status === "failed" /* Failed */).length;
3431
- const tally = [
3542
+ return [
3432
3543
  countLabel(failed2, "failed" /* Failed */),
3433
3544
  countLabel(record.unpassed.length - failed2, "inconclusive" /* Inconclusive */)
3434
3545
  ].filter((part) => part !== null).join(", ");
3546
+ }
3547
+ function renderCaseSummary(record, headline) {
3435
3548
  const listed = record.unpassed.slice(0, CHECK_SUMMARY_MAX_CASES).map((unpassed) => renderUnpassedCaseBullet(unpassed, "open run", unpassed.viewUrl));
3436
3549
  const hidden = record.unpassed.length - listed.length;
3437
3550
  return [
3438
- `E2E acceptance did not pass \u2014 ${tally} of ${record.total} test ${record.total === 1 ? "case" : "cases"}. Fix them, re-run the whole suite, and update the walkthrough comment; editing it re-runs this check.`,
3551
+ headline,
3439
3552
  "",
3440
3553
  ...listed,
3441
3554
  ...hidden > 0 ? [`- \u2026and ${hidden} more \u2014 see the walkthrough comment.`] : []
@@ -3469,7 +3582,10 @@ function judgeComments(pr, comments, run) {
3469
3582
  }
3470
3583
  const record = latestE2eResult(bodies);
3471
3584
  if (record && record.unpassed.length > 0) {
3472
- return { outcome: "e2e-not-passing" /* E2eNotPassing */, summary: renderNotPassingSummary(record) };
3585
+ const isEveryUnpassedOnBase = record.unpassed.every(
3586
+ (unpassed) => unpassed.status === "failed" /* Failed */ && unpassed.failsOnBase !== void 0
3587
+ );
3588
+ return isEveryUnpassedOnBase ? { outcome: "e2e-fails-on-base" /* E2eFailsOnBase */, summary: renderFailsOnBaseSummary(record) } : { outcome: "e2e-not-passing" /* E2eNotPassing */, summary: renderNotPassingSummary(record) };
3473
3589
  }
3474
3590
  const outcome = verdictNow === "satisfied" /* Satisfied */ ? "settled" /* Settled */ : "unsettled" /* Unsettled */;
3475
3591
  return { outcome, summary: FIXED_SUMMARIES[outcome] };
@@ -4873,8 +4989,7 @@ function resolveWatcherLiveness(loop, heartbeatAgeMs, isOwnerSessionActive, isPr
4873
4989
  return isHeartbeatWithin(WATCH_HEARTBEAT_STALE_MS) ? "polling" /* Polling */ : "dead" /* Dead */;
4874
4990
  }
4875
4991
  if (loop) {
4876
- const hasOutlivedMonitor = (loop.ageMs ?? 0) > MONITOR_STDOUT_CAP_MS;
4877
- return hasOutlivedMonitor || isOwnerSessionActive === false ? "deaf" /* Deaf */ : "polling" /* Polling */;
4992
+ return isOwnerSessionActive === false ? "deaf" /* Deaf */ : "polling" /* Polling */;
4878
4993
  }
4879
4994
  return isHeartbeatWithin(WATCHER_REARM_GRACE_MS) ? "rearming" /* Rearming */ : "dead" /* Dead */;
4880
4995
  }
@@ -4941,12 +5056,11 @@ function watcherFinding(assessment, subject) {
4941
5056
  );
4942
5057
  }
4943
5058
  if (assessment.watcher === "deaf" /* Deaf */) {
4944
- const reason = assessment.isOwnerSessionActive === false ? `its owner session ${shortSessionId(assessment.ownerSessionId ?? "")} has ended` : `it has run ${formatDuration(assessment.loopAgeMs ?? 0)}, past the 30-minute Monitor cap`;
4945
5059
  return finding(
4946
5060
  "WATCHER_DEAF" /* WatcherDeaf */,
4947
5061
  [assessment.pr.url, assessment.loopPid],
4948
5062
  subject,
4949
- `watch loop pid ${assessment.loopPid} reports to no session: ${reason}`
5063
+ `watch loop pid ${assessment.loopPid} reports to no session: its owner session ${shortSessionId(assessment.ownerSessionId ?? "")} has ended`
4950
5064
  );
4951
5065
  }
4952
5066
  return void 0;
@@ -6058,8 +6172,8 @@ function renderReloadReport(report, isDryRun) {
6058
6172
  const lines = report.decisions.map((decision) => {
6059
6173
  const label = decision.name ?? decision.shortId ?? decision.sessionId;
6060
6174
  const failed2 = report.failedSessionIds.includes(decision.sessionId) ? " (restart failed)" : "";
6061
- const refused = report.untrustedSessionIds.includes(decision.sessionId) ? ` (left running: ${decision.cwd ?? "its workspace"} refuses a background restart \u2014 run /reload-plugins in it)` : "";
6062
- return ` ${decision.action.padEnd(16)} ${label} ${decision.reason}${failed2}${refused}`;
6175
+ const refused2 = report.untrustedSessionIds.includes(decision.sessionId) ? ` (left running: ${decision.cwd ?? "its workspace"} refuses a background restart \u2014 run /reload-plugins in it)` : "";
6176
+ return ` ${decision.action.padEnd(16)} ${label} ${decision.reason}${failed2}${refused2}`;
6063
6177
  });
6064
6178
  const isRestart = (action) => action === "respawn" /* Respawn */ || action === "resume-reconcile" /* ResumeReconcile */;
6065
6179
  const respawnVerb = isDryRun ? "would restart" : "restarted";
@@ -6137,7 +6251,7 @@ function createProgram() {
6137
6251
  program.command("login").description("Authenticate with Muggle AI (uses device code flow)").option("--key-name <name>", "Name for the API key").option("--key-expiry <expiry>", "API key expiry: 30d, 90d, 1y, never", "90d").action(loginCommand);
6138
6252
  program.command("logout").description("Clear stored credentials").action(logoutCommand);
6139
6253
  program.command("status").description("Show authentication status").action(statusCommand);
6140
- program.command("build-pr-section").description("Render a muggle-do PR body evidence block from an e2e report on stdin").option("--max-body-bytes <n>", "Max UTF-8 byte budget for the PR body (default 60000)").action(buildPrSectionCommand);
6254
+ program.command("build-pr-section").description("Render a muggle-do PR body evidence block from an e2e report on stdin").option("--max-body-bytes <n>", "Max UTF-8 byte budget for the PR body (default 60000)").option("--base <branch>", "Branch the PR merges into, for verifying cited baseline runs (default: the remote's default branch)").action(buildPrSectionCommand);
6141
6255
  program.command("run-view").argument("[runId]", "Run id, or a unique prefix of one (default: the newest run)").description("Show a local run's steps beside the frames they produced").option("--html", "Render a self-contained HTML page instead of terminal text").option("--open", "Open the rendered page in the default browser (implies --html)").option("--out-dir <dir>", "Directory to write the page into (default: current directory)").action(runViewCommand);
6142
6256
  program.command("pr-walkthrough-check").description("Check that a PR's Muggle AI walkthrough comment carries a result or a stated skip").option("--repo <owner/repo>", "Repository (default: $GITHUB_REPOSITORY)").option("--pr <number>", "Pull request number (default: the Actions event payload)").option("--head-sha <sha>", "Commit the check run attaches to (default: the PR head)").option("--check-run", "Publish the verdict as a check run instead of failing the process").action(prWalkthroughCheckCommand);
6143
6257
  program.command("checkout-freshness").description("Fetch the base branch and check that a checkout is built on its latest commit").option("--cwd <dir>", "Directory inside the checkout (default: the current directory)").option("--base <branch>", "Branch the change merges into (default: origin's default branch)").option("--no-fetch", "Check against the last fetched base instead of fetching").option("--json", "Print the full report as JSON").action(checkoutFreshnessCommand);
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { runCli } from './chunk-SJBYMBC2.js';
3
- import './chunk-FI6VHHZN.js';
2
+ import { runCli } from './chunk-B5BWBZZ5.js';
3
+ import './chunk-6BAB7IVG.js';
4
4
 
5
5
  // src/cli/main.ts
6
6
  runCli().catch((error) => {
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-SJBYMBC2.js';
2
- export { createChildLogger, e2e_exports as e2e, getConfig, getLocalQaTools, getLogger, getQaTools, local_exports as localQa, mcp_exports as mcp, e2e_exports as qa, src_exports as shared } from './chunk-FI6VHHZN.js';
1
+ export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-B5BWBZZ5.js';
2
+ export { createChildLogger, e2e_exports as e2e, getConfig, getLocalQaTools, getLogger, getQaTools, local_exports as localQa, mcp_exports as mcp, e2e_exports as qa, src_exports as shared } from './chunk-6BAB7IVG.js';
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muggle",
3
3
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
4
- "version": "5.27.0-staging.245",
4
+ "version": "5.27.0-staging.247",
5
5
  "author": {
6
6
  "name": "Muggle AI",
7
7
  "email": "support@muggle-ai.com"
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.27.0-staging.245",
7
+ "@muggleai/works@5.27.0-staging.247",
8
8
  "serve"
9
9
  ],
10
10
  "env": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muggle",
3
- "version": "5.27.0-staging.245",
3
+ "version": "5.27.0-staging.247",
4
4
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
5
5
  "author": {
6
6
  "name": "Muggle AI",
@@ -2,7 +2,7 @@
2
2
  "name": "muggle",
3
3
  "displayName": "Muggle AI",
4
4
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
5
- "version": "5.27.0-staging.245",
5
+ "version": "5.27.0-staging.247",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.27.0-staging.245",
7
+ "@muggleai/works@5.27.0-staging.247",
8
8
  "serve"
9
9
  ]
10
10
  }
@@ -77,9 +77,9 @@ Codex runs a plugin's hooks only once the user trusts them: it asks at startup (
77
77
 
78
78
  `SessionStart` (`scripts/reconcile-stale-watchers.sh`) — a standalone advisory, not part of the `guardrails.mjs` decision tree above.
79
79
 
80
- `muggle-pr-followup` watchers are session-only (a monitor or `/loop` cron); they die with their session, leaving open PRs with no live poller. This is by design — a review is addressed only inside a session that carries the context to address it. The skill's [`reconcile`](../skills/muggle-pr-followup/reconcile.md) procedure recovers them at the next session start — finalizes slots whose PR went terminal, sweeps orphan crons, re-arms silently-stopped open watchers — but re-arming needs Claude tools a shell hook can't call. So this hook nudges rather than acts.
80
+ `muggle-pr-followup` watchers are session-only (a background watch loop or `/loop` cron); they die with their session, leaving open PRs with no live poller. This is by design — a review is addressed only inside a session that carries the context to address it. The skill's [`reconcile`](../skills/muggle-pr-followup/reconcile.md) procedure recovers them at the next session start — finalizes slots whose PR went terminal, sweeps orphan crons, re-arms silently-stopped open watchers — but re-arming needs Claude tools a shell hook can't call. So this hook nudges rather than acts.
81
81
 
82
- The nudge counts only slots **this session owns**, and reports the rest as orphans it will not act on — ownership and the recovery rules it gates are defined in [`reconcile`](../skills/muggle-pr-followup/reconcile.md). Nothing owned and nothing orphaned → it emits nothing. A pure directory scan (no `gh`, no writes), so it's cheap enough for every session start.
82
+ The nudge counts only slots **this session owns** — ownership and the recovery rules it gates are defined in [`reconcile`](../skills/muggle-pr-followup/reconcile.md). A session that owns none hears nothing: the hook speaks into every session start, and slots owned elsewhere are no work of this one's — `muggle watch status` reports them. A pure directory scan (no `gh`, no writes), so it's cheap enough for every session start.
83
83
 
84
84
  ## Session-start upkeep
85
85
 
@@ -8,9 +8,10 @@
8
8
  # and never reaching its terminal check. Every health signal said fine. Doing
9
9
  # the three steps here, in one command, is what makes that unskippable.
10
10
  #
11
- # The floors are taken from the drain's own read rather than from a fresh fetch
12
- # afterwards: anything that lands between the two would otherwise be marked seen
13
- # without ever being reported.
11
+ # A first arm takes its floors from the drain's own read rather than from a fresh
12
+ # fetch afterwards: anything that lands between the two would otherwise be marked
13
+ # seen without ever being reported. A re-arm keeps the review, comment and thread
14
+ # floors the session already holds, for the same reason.
14
15
  #
15
16
  # A stale branch or a red head is floored only when the slot proves a cycle ran
16
17
  # for it. Flooring whatever was outstanding at arm time let a session that never
@@ -38,7 +39,7 @@ if [ -z "$slot" ] || [ -z "$repo" ] || [ -z "$pr_number" ] || [ -z "$base_branch
38
39
  exit 2
39
40
  fi
40
41
 
41
- for lib in pr-watch-guards.sh pr-watch-events.sh pr-watch-fetch.sh pr-watch-due.sh; do
42
+ for lib in pr-watch-guards.sh pr-watch-events.sh pr-watch-fetch.sh pr-watch-due.sh pr-watch-floors.sh; do
42
43
  if [ ! -f "${script_dir}/${lib}" ]; then
43
44
  echo "pr-watch-arm: missing ${lib} beside this script" >&2
44
45
  exit 2
@@ -135,9 +136,10 @@ recorded_rebase_due_key="$rebase_due_key"
135
136
  thread_count=0
136
137
  [ -n "$unresolved_threads" ] && thread_count=$(printf '%s' "$unresolved_threads" | tr ';' '\n' | grep -c .)
137
138
 
138
- # Printed, not swallowed: these are the things the arming session owes action on
139
- # before the monitor takes over. Unattempted work is left unfloored, so the
140
- # loop's first pass reports it again as an event.
139
+ # A record of the PR as armed, read from the watch's output when its loop ends.
140
+ # Printing wakes nobody: the session hears the watch only when the loop exits.
141
+ # Unattempted work is left unfloored, so the loop's first poll reports it and
142
+ # exits — that report is the wake.
141
143
  echo "DRAIN pr=$pr_number state=$pr_state head=${head_sha:0:8}"
142
144
  echo "DRAIN unresolved-threads=$thread_count latest-review=${latest_review:-0} latest-comment=${latest_comment:-0}"
143
145
  echo "DRAIN checks pending=${pending_checks:-0} failed=${failed_checks:-0} behind-base=${behind_count:-unknown} mergeable=${mergeable:-UNKNOWN}"
@@ -158,10 +160,25 @@ if [ -n "$rebase_due_key" ]; then
158
160
  fi
159
161
  fi
160
162
 
163
+ # A re-arm carries the review, comment and thread floors forward. The session
164
+ # advanced them past what it handled, so anything newer — a review landing
165
+ # between its last tick's read and this one — is above them, and the loop's
166
+ # first poll reports it. Seeding them from this read would floor that review
167
+ # unseen. Only a first arm, with nothing handled yet, takes them from its read.
168
+ review_floor="${latest_review:-0}"
169
+ comment_floor="${latest_comment:-0}"
170
+ threads_floor="$unresolved_threads"
171
+ if [ -f "${slot}/watch-watermark.env" ]; then
172
+ review_floor="$(watch_read_slot_value "$slot" watch-watermark.env REV)"
173
+ comment_floor="$(watch_read_slot_value "$slot" watch-watermark.env COM)"
174
+ threads_floor="$(watch_threads_still_unresolved \
175
+ "$(watch_read_slot_value "$slot" watch-watermark.env THREADS)" "$unresolved_threads")"
176
+ fi
177
+
161
178
  {
162
- printf 'REV=%s\n' "${latest_review:-0}"
163
- printf 'COM=%s\n' "${latest_comment:-0}"
164
- printf 'THREADS="%s"\n' "$unresolved_threads"
179
+ printf 'REV=%s\n' "${review_floor:-0}"
180
+ printf 'COM=%s\n' "${comment_floor:-0}"
181
+ printf 'THREADS="%s"\n' "$threads_floor"
165
182
  printf 'CIRED="%s"\n' "$ci_red_floor"
166
183
  printf 'REBASED="%s"\n' "$rebase_floor"
167
184
  printf 'BLOCKED_CIDIGEST="%s"\n' "$blocked_digest_floor"
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # The floors a watch compares each poll against, as they persist between loops.
4
+ # Sourced by pr-watch-arm.sh and pr-watch-loop.sh.
5
+ #
6
+ # A loop exits on every report, because the session that owns it hears it only
7
+ # when it exits. So no floor can live in a loop's memory: whatever stops one
8
+ # occurrence firing twice has to be on disk for the next loop to read. Two files
9
+ # hold them — watch-watermark.env, which the session advances past what it
10
+ # handled, and watch-reported.env, which the loop advances past what it told
11
+ # the session — and a poll fires only on what is above both.
12
+
13
+ MUGGLE_PR_WATCH_REPORTED_FILE="watch-reported.env"
14
+
15
+ # Prints the value of `key` in a slot file of `KEY="value"` lines, quotes
16
+ # stripped, the last match winning; nothing when the file or key is absent.
17
+ # Read with builtins, never sourced: THREADS holds bare semicolons, which a
18
+ # sourced unquoted line would split, and a CI digest carries check names an
19
+ # author controls.
20
+ watch_read_slot_value() {
21
+ local slot="$1" file_name="$2" key="$3" line value=""
22
+ [ -f "${slot}/${file_name}" ] || return 0
23
+ while IFS= read -r line || [ -n "$line" ]; do
24
+ line="${line%$'\r'}"
25
+ case "$line" in
26
+ "${key}="*)
27
+ value="${line#"${key}"=}"
28
+ value="${value%\"}"
29
+ value="${value#\"}"
30
+ ;;
31
+ esac
32
+ done < "${slot}/${file_name}"
33
+ printf '%s' "$value"
34
+ }
35
+
36
+ # Writes <slot>/watch-reported.env: the newest review and comment ids and the
37
+ # unresolved threads a loop's last poll saw — every one of them either reported
38
+ # or already floored — and the red head, rebase key and blocked digest it
39
+ # reported, with the block (`blocked_for`, that block's watermark digest) the
40
+ # digest was reported under.
41
+ watch_record_reported() {
42
+ local slot="$1" review="$2" comment="$3" threads="$4" ci_red="$5" rebase="$6" blocked_digest="$7" blocked_for="$8"
43
+ printf 'REV=%s\nCOM=%s\nTHREADS="%s"\nCIRED="%s"\nREBASED="%s"\nBLOCKED_CIDIGEST="%s"\nBLOCKED_FOR="%s"\n' \
44
+ "$review" "$comment" "$threads" "$ci_red" "$rebase" "$blocked_digest" "$blocked_for" \
45
+ > "${slot}/${MUGGLE_PR_WATCH_REPORTED_FILE}"
46
+ }
47
+
48
+ # Prints the threads of `known` (semicolon-joined) that are still in
49
+ # `unresolved`, semicolon-joined. A thread resolved since it was floored drops
50
+ # out, so its unresolving again is news.
51
+ watch_threads_still_unresolved() {
52
+ local known="$1" unresolved="$2" thread_id kept=""
53
+ local -a thread_ids
54
+ IFS=';' read -ra thread_ids <<< "$known"
55
+ for thread_id in "${thread_ids[@]}"; do
56
+ [ -n "$thread_id" ] || continue
57
+ case ";${unresolved};" in
58
+ *";${thread_id};"*) kept="${kept:+${kept};}${thread_id}" ;;
59
+ esac
60
+ done
61
+ printf '%s' "$kept"
62
+ }