@yemi33/minions 0.1.2370 → 0.1.2371

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 (71) hide show
  1. package/bin/minions.js +38 -11
  2. package/dashboard/js/render-other.js +2 -30
  3. package/dashboard/js/render-work-items.js +0 -34
  4. package/dashboard/js/settings.js +12 -27
  5. package/dashboard/pages/tools.html +1 -1
  6. package/dashboard.js +80 -257
  7. package/docs/README.md +2 -3
  8. package/docs/architecture.excalidraw +2 -2
  9. package/docs/auto-discovery.md +3 -3
  10. package/docs/completion-reports.md +0 -121
  11. package/docs/copilot-cli-schema.md +9 -17
  12. package/docs/deprecated.json +0 -51
  13. package/docs/design-state-storage.md +4 -4
  14. package/docs/harness-propagation.md +33 -263
  15. package/docs/human-vs-automated.md +1 -1
  16. package/docs/named-agents.md +0 -2
  17. package/docs/plan-lifecycle.md +5 -6
  18. package/docs/qa-runbook-lifecycle.md +10 -25
  19. package/docs/shared-lifecycle-module-map.md +2 -10
  20. package/engine/ado-comment.js +5 -11
  21. package/engine/ado.js +10 -5
  22. package/engine/cleanup.js +16 -36
  23. package/engine/cli.js +13 -175
  24. package/engine/comment-format.js +8 -182
  25. package/engine/db/index.js +60 -10
  26. package/engine/db/migrations/018-sql-only-cutover.js +56 -0
  27. package/engine/dispatch-store.js +0 -114
  28. package/engine/dispatch.js +1 -6
  29. package/engine/gh-comment.js +2 -10
  30. package/engine/github.js +8 -6
  31. package/engine/lifecycle.js +58 -173
  32. package/engine/llm.js +9 -11
  33. package/engine/managed-spawn.js +1 -6
  34. package/engine/memory-store.js +8 -6
  35. package/engine/metrics-store.js +4 -128
  36. package/engine/pipeline.js +4 -7
  37. package/engine/playbook.js +8 -196
  38. package/engine/prd-store.js +115 -141
  39. package/engine/preflight.js +8 -55
  40. package/engine/projects.js +34 -41
  41. package/engine/pull-requests-store.js +9 -234
  42. package/engine/qa-runs.js +4 -15
  43. package/engine/qa-sessions.js +5 -17
  44. package/engine/queries.js +23 -103
  45. package/engine/routing.js +1 -1
  46. package/engine/runtimes/claude.js +1 -2
  47. package/engine/runtimes/codex.js +0 -2
  48. package/engine/runtimes/copilot.js +1 -4
  49. package/engine/shared-branch-pr-reconcile.js +2 -5
  50. package/engine/shared.js +174 -533
  51. package/engine/small-state-store.js +12 -647
  52. package/engine/spawn-agent.js +5 -96
  53. package/engine/state-operations.js +166 -0
  54. package/engine/supervisor.js +0 -1
  55. package/engine/watch-actions.js +2 -3
  56. package/engine/watches-store.js +2 -128
  57. package/engine/work-items-store.js +3 -254
  58. package/engine.js +102 -334
  59. package/package.json +2 -2
  60. package/playbooks/build-fix-complex.md +0 -4
  61. package/playbooks/fix.md +0 -4
  62. package/playbooks/implement-shared.md +0 -4
  63. package/playbooks/implement.md +0 -4
  64. package/playbooks/plan-to-prd.md +16 -11
  65. package/playbooks/plan.md +0 -4
  66. package/playbooks/review.md +0 -6
  67. package/playbooks/shared-rules.md +0 -65
  68. package/docs/harness-transparency.md +0 -199
  69. package/docs/project-skills.md +0 -193
  70. package/engine/discover-project-skills.js +0 -673
  71. package/engine/playbook-intents.js +0 -76
package/engine.js CHANGED
@@ -35,10 +35,11 @@ const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
35
35
  WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, AGENT_STATUS,
36
36
  FAILURE_CLASS, resolvePollFlag } = shared;
37
37
  const { resolveRuntime } = require('./engine/runtimes');
38
- const { assertStaleHeadOk, preApproveWorkspaceMcps, computeAddDirs } = require('./engine/spawn-agent');
38
+ const { assertStaleHeadOk, preApproveWorkspaceMcps } = require('./engine/spawn-agent');
39
39
  const adoGitAuth = require('./engine/ado-git-auth');
40
40
  const queries = require('./engine/queries');
41
41
  const dispatchEvents = require('./engine/dispatch-events');
42
+ const prdStore = require('./engine/prd-store');
42
43
 
43
44
  // ─── Paths ──────────────────────────────────────────────────────────────────
44
45
 
@@ -441,7 +442,6 @@ function _buildAgentSpawnFlags(runtime, opts = {}) {
441
442
  if (caps.fallbackModel && opts.fallbackModel) flags.push('--fallback-model', String(opts.fallbackModel));
442
443
  if (opts.stream != null && opts.stream !== '') flags.push('--stream', String(opts.stream));
443
444
  if (opts.disableBuiltinMcps === true) flags.push('--disable-builtin-mcps');
444
- if (opts.suppressAgentsMd === true) flags.push('--no-custom-instructions');
445
445
  if (opts.reasoningSummaries === true) flags.push('--enable-reasoning-summaries');
446
446
 
447
447
  return flags;
@@ -1033,36 +1033,50 @@ async function runWorktreeAdd(rootDir, worktreePath, addArgs, gitOpts, worktreeC
1033
1033
  // probe failure against a branch that genuinely exists on origin (e.g. a
1034
1034
  // PR-author's real branch) silently took the fresh-branch fallback and
1035
1035
  // forked a divergent local branch under the same name instead of retrying.
1036
- // We retry once with a short backoff (mirroring `_fetchWithTransientRetry`);
1037
- // if the retry still doesn't cleanly resolve to "not found", we throw so the
1038
- // caller does NOT treat this as a confirmed absence.
1036
+ // We retry with EXPONENTIAL backoff (mirroring `_fetchWithTransientRetry`'s
1037
+ // transient-recovery intent): 3 total attempts with ~1.5s then ~4s backoff.
1038
+ // A single flat 1.5s retry (the pre-W-mrdwcpzn shape) landed inside the same
1039
+ // brief ADO ls-remote flaky window often enough to hard-fail worktree creation
1040
+ // twice within an hour against office/iss/fluid-client-framework — live repro
1041
+ // confirmed the command succeeds reliably when retried a few seconds later, so
1042
+ // the deeper/backed-off retry closes that gap. If NONE of the attempts cleanly
1043
+ // resolve to "not found", we throw so the caller does NOT treat this as a
1044
+ // confirmed absence.
1045
+ const _PROBE_BRANCH_BACKOFFS_MS = [1500, 4000]; // delays BEFORE attempts 2 and 3 (3 total attempts)
1039
1046
  async function probeBranchOnRemote(rootDir, branchName, gitOpts) {
1040
1047
  if (!branchName || !rootDir) return false;
1041
1048
  const _attemptProbe = () => shared.shellSafeGit(
1042
1049
  ['ls-remote', '--exit-code', '--heads', 'origin', branchName],
1043
1050
  { ...gitOpts, cwd: rootDir, timeout: 10000 },
1044
1051
  );
1045
- try {
1046
- await _attemptProbe();
1047
- return true;
1048
- } catch (e) {
1049
- if (e && e.code === 2) return false; // clean, authoritative "not found"
1050
- const firstErrMsg = adoGitAuth.redactBearer(String(e?.message || e)).split('\n')[0].slice(0, 200);
1051
- log('warn', `probeBranchOnRemote: ls-remote --heads origin ${branchName} failed (${firstErrMsg}) — retrying once after 1.5s before giving up`);
1052
- await new Promise(r => setTimeout(r, 1500));
1052
+ const totalAttempts = _PROBE_BRANCH_BACKOFFS_MS.length + 1;
1053
+ let lastErr = null;
1054
+ for (let attempt = 1; attempt <= totalAttempts; attempt++) {
1053
1055
  try {
1054
1056
  await _attemptProbe();
1057
+ if (attempt > 1) log('info', `probeBranchOnRemote: ls-remote --heads origin ${branchName} succeeded on attempt ${attempt}/${totalAttempts}`);
1055
1058
  return true;
1056
- } catch (e2) {
1057
- if (e2 && e2.code === 2) return false; // clean, authoritative "not found" on retry
1058
- const retryErrMsg = adoGitAuth.redactBearer(String(e2?.message || e2)).split('\n')[0].slice(0, 200);
1059
- log('warn', `probeBranchOnRemote: ls-remote --heads origin ${branchName} retry also failed (${retryErrMsg}) cannot confirm branch absence, surfacing error`);
1060
- const probeErr = new Error(`probeBranchOnRemote: could not determine whether origin has ${branchName} after retry: ${retryErrMsg}`);
1061
- probeErr._probeFailed = true;
1062
- probeErr._cause = e2;
1063
- throw probeErr;
1059
+ } catch (e) {
1060
+ // exit code 2 = clean, authoritative "not found". This short-circuits
1061
+ // immediately on ANY attempt with no further retry/backoff — a confirmed
1062
+ // absence is authoritative and must stay fast (no #767 regression).
1063
+ if (e && e.code === 2) return false;
1064
+ lastErr = e;
1065
+ const errMsg = adoGitAuth.redactBearer(String(e?.message || e)).split('\n')[0].slice(0, 200);
1066
+ const backoffMs = _PROBE_BRANCH_BACKOFFS_MS[attempt - 1];
1067
+ if (backoffMs != null) {
1068
+ log('warn', `probeBranchOnRemote: ls-remote --heads origin ${branchName} failed on attempt ${attempt}/${totalAttempts} (${errMsg}) — retrying after ${backoffMs}ms`);
1069
+ await new Promise(r => setTimeout(r, backoffMs));
1070
+ } else {
1071
+ log('warn', `probeBranchOnRemote: ls-remote --heads origin ${branchName} failed on final attempt ${attempt}/${totalAttempts} (${errMsg}) — cannot confirm branch absence, surfacing error`);
1072
+ }
1064
1073
  }
1065
1074
  }
1075
+ const finalErrMsg = adoGitAuth.redactBearer(String(lastErr?.message || lastErr)).split('\n')[0].slice(0, 200);
1076
+ const probeErr = new Error(`probeBranchOnRemote: could not determine whether origin has ${branchName} after ${totalAttempts} attempts: ${finalErrMsg}`);
1077
+ probeErr._probeFailed = true;
1078
+ probeErr._cause = lastErr;
1079
+ throw probeErr;
1066
1080
  }
1067
1081
 
1068
1082
  // Detect and remove worktree registrations whose backing directory is missing
@@ -2080,38 +2094,6 @@ async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts
2080
2094
  }
2081
2095
  }
2082
2096
 
2083
- // P-f52d81ba — persist the dispatch's detected monorepo sub-project (+ any per-surface
2084
- // skill-discovery truncations the render surfaced) onto the dispatch record so
2085
- // later grounding/observability can see which sub-project scoped discovery + harness
2086
- // propagation. Best-effort: mutation failures must never block the spawn. Only
2087
- // writes fields that carry a value so unset sub-projects leave the record untouched.
2088
- function _recordDispatchDiscoveryDiagnostics(id, dispatchItem, subproject, diagnostics) {
2089
- try {
2090
- const dominantSubproject = (typeof subproject === 'string' && subproject) ? subproject : undefined;
2091
- const truncations = diagnostics && Array.isArray(diagnostics.truncations) && diagnostics.truncations.length
2092
- ? diagnostics.truncations
2093
- : undefined;
2094
- if (dominantSubproject === undefined && truncations === undefined) return;
2095
- mutateDispatch((dispatch) => {
2096
- for (const queue of ['pending', 'active', 'completed']) {
2097
- const arr = Array.isArray(dispatch?.[queue]) ? dispatch[queue] : null;
2098
- if (!arr) continue;
2099
- const found = arr.find(d => d && d.id === id);
2100
- if (!found) continue;
2101
- if (dominantSubproject !== undefined) found._dominantSubproject = dominantSubproject;
2102
- if (truncations !== undefined) found._skillDiscoveryTruncations = truncations;
2103
- }
2104
- return dispatch;
2105
- });
2106
- if (dispatchItem) {
2107
- if (dominantSubproject !== undefined) dispatchItem._dominantSubproject = dominantSubproject;
2108
- if (truncations !== undefined) dispatchItem._skillDiscoveryTruncations = truncations;
2109
- }
2110
- } catch (e) {
2111
- log('warn', `record discovery diagnostics failed for ${id} (non-fatal): ${e.message}`);
2112
- }
2113
- }
2114
-
2115
2097
  // Seed a spawned agent's COPILOT_HOME mcp-config as a copy of the operator's
2116
2098
  // `~/.copilot/mcp-config.json` MINUS the servers in
2117
2099
  // `engine.copilotAgentDisabledMcpServers`, then point COPILOT_HOME at it.
@@ -2119,11 +2101,8 @@ function _recordDispatchDiscoveryDiagnostics(id, dispatchItem, subproject, diagn
2119
2101
  // Why config-filtering and not `--disable-mcp-server`: copilot SPAWNS every
2120
2102
  // server in its config on startup and authenticates it; `--disable-mcp-server
2121
2103
  // <name>` only hides that server's TOOLS from the model, it does NOT stop the
2122
- // server process. So any auth-requiring server (azure-kusto/azmcp, DevBox,
2123
- // workiq, loop, teams…) pops a Microsoft sign-in window on EVERY agent spawn —
2124
- // an unbounded popup storm at minions' spawn cadence. A server simply ABSENT
2125
- // from the config can't be spawned, so filtering the config is the only real
2126
- // disable lever.
2104
+ // server process (PR #321 verified live: agents spawned azmcp.exe/workiq.exe
2105
+ // despite those servers being in `copilotAgentDisabledMcpServers`).
2127
2106
  //
2128
2107
  // Default disabled list `[]` => agents inherit ALL of the operator's MCP servers
2129
2108
  // (non-breaking; matches interactive copilot). Add a name to disable it for
@@ -3300,7 +3279,7 @@ async function spawnAgent(dispatchItem, config) {
3300
3279
  // the dispatch-end auto-restore (P-d9e6b2c4) can return the tree to where the
3301
3280
  // operator started — even after an engine restart + re-attach, where the
3302
3281
  // spawnAgent closure is gone and only the persisted dispatch record survives
3303
- // (same pattern as resolveHarnessPropagated at engine/lifecycle.js:3576).
3282
+ // even after an engine restart + re-attach.
3304
3283
  if (_liveResult.originalRef) {
3305
3284
  dispatchItem.originalRef = _liveResult.originalRef;
3306
3285
  dispatchItem.originalRefType = _liveResult.originalRefType || 'branch';
@@ -4365,45 +4344,6 @@ async function spawnAgent(dispatchItem, config) {
4365
4344
 
4366
4345
  updateAgentStatus(id, AGENT_STATUS.READY, 'Worktree ready, preparing to spawn process');
4367
4346
 
4368
- // ── P-f52d81ba — derive the effective monorepo sub-project for this dispatch ──────
4369
- // Feeds BOTH project-skill discovery (scopeSubproject → sub-project's skills lead the
4370
- // rendered project_skills_block) and project-local harness propagation
4371
- // (workdir clip → only the sub-project's --project-harness-dir roots are surfaced).
4372
- // Precedence:
4373
- // 1. Explicit meta.workdir wins — effective sub-project = its first segment. No
4374
- // diff work (the operator already declared the target sub-package).
4375
- // 2. Else best-effort from the CURRENT dispatch's diff: `git diff
4376
- // --name-only <main>...HEAD` in this worktree → detectDominantSubproject.
4377
- // 3. Else '' (flat discovery / unclipped harness — today's behavior).
4378
- // Contract: never blocks dispatch, never changes agent cwd (only an explicit
4379
- // meta.workdir moves cwd, applied separately below), and degrades to '' on
4380
- // any failure. Derived from THIS worktree's HEAD so a follow-up/parent's
4381
- // stale diff can't leak in (AC: current-dispatch diff only).
4382
- let _effectiveSubproject = '';
4383
- try {
4384
- if (validatedWorkdir) {
4385
- _effectiveSubproject = String(validatedWorkdir).replace(/\\/g, '/').replace(/^\/+/, '').split('/')[0] || '';
4386
- } else if (worktreePath && fs.existsSync(worktreePath) && project?.localPath) {
4387
- let _subprojectChangedPaths = [];
4388
- try {
4389
- const _subprojectBase = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
4390
- const _subprojectDiff = await execAsync(`git diff --name-only ${_subprojectBase}...HEAD`, { ..._gitOpts, cwd: worktreePath, timeout: 10000 });
4391
- _subprojectChangedPaths = (_subprojectDiff.stdout || '').split('\n').map(s => s.trim()).filter(Boolean);
4392
- } catch (e) {
4393
- log('debug', `subproject-detect: git diff failed for ${id} (flat discovery): ${e.message}`);
4394
- }
4395
- if (_subprojectChangedPaths.length) {
4396
- const { detectDominantSubproject } = require('./engine/discover-project-skills')._internal;
4397
- const _det = detectDominantSubproject({ projectPath: project.localPath, changedPaths: _subprojectChangedPaths });
4398
- if (_det && _det.confident && _det.subproject) _effectiveSubproject = _det.subproject;
4399
- }
4400
- }
4401
- } catch (e) {
4402
- log('warn', `subproject-detect failed for ${id} (non-fatal, flat discovery): ${e.message}`);
4403
- _effectiveSubproject = '';
4404
- }
4405
-
4406
- let _refreshedDiscoveryDiagnostics = null;
4407
4347
  if (worktreePath && meta?.source === 'work-item' && meta?.item?.branchStrategy === 'shared-branch') {
4408
4348
  const refreshed = renderProjectWorkItemPromptForAgent(
4409
4349
  meta.item,
@@ -4413,21 +4353,15 @@ async function spawnAgent(dispatchItem, config) {
4413
4353
  project,
4414
4354
  rootDir,
4415
4355
  branchName,
4416
- { worktreePath, dominantSubproject: _effectiveSubproject }
4356
+ { worktreePath }
4417
4357
  );
4418
4358
  if (refreshed.prompt) {
4419
4359
  taskPrompt = refreshed.prompt;
4420
4360
  fullTaskPrompt = buildFullTaskPrompt(taskPrompt);
4421
4361
  safeWrite(promptPath, fullTaskPrompt);
4422
- log('info', `Refreshed shared-branch prompt for ${id} with worktree ${worktreePath}${_effectiveSubproject ? ` (subproject=${_effectiveSubproject})` : ''}`);
4362
+ log('info', `Refreshed shared-branch prompt for ${id} with worktree ${worktreePath}`);
4423
4363
  }
4424
- _refreshedDiscoveryDiagnostics = refreshed.discoveryDiagnostics || null;
4425
4364
  }
4426
- // Record the detected sub-project (+ any per-surface skill-discovery truncations the
4427
- // shared-branch re-render surfaced) onto the dispatch record for later
4428
- // grounding/observability. Sub-project-only for non-re-render dispatches (their
4429
- // prompt was rendered at dispatch-build time; truncations are logged there).
4430
- _recordDispatchDiscoveryDiagnostics(id, dispatchItem, _effectiveSubproject, _refreshedDiscoveryDiagnostics);
4431
4365
 
4432
4366
  // Inject dirty file list when worktree has uncommitted changes (e.g., max_turns retry)
4433
4367
  // This signals to the respawned agent that prior work exists in the worktree (#960)
@@ -4524,15 +4458,6 @@ async function spawnAgent(dispatchItem, config) {
4524
4458
  const resolvedModel = runtime.resolveModel(shared.resolveAgentModel(agentConfig, engineConfig));
4525
4459
  const resolvedMaxBudget = shared.resolveAgentMaxBudget(agentConfig, engineConfig);
4526
4460
  const resolvedBare = shared.resolveAgentBareMode(agentConfig, engineConfig);
4527
- // P-49e1c8b7 — hermetic harness opt-out (per-agent override allowed).
4528
- // When true, this dispatch:
4529
- // - skips Claude workspace .mcp.json pre-approval (preApproveWorkspaceMcps),
4530
- // - skips project-local-on-main `--project-harness-dir` propagation,
4531
- // - forwards `--hermetic-harness` to spawn-agent.js so computeAddDirs
4532
- // returns [minionsDir] only (user-asset dirs stripped from --add-dir).
4533
- // Independent of copilotDisableBuiltinMcps and copilotSuppressAgentsMd,
4534
- // which retain their existing semantics.
4535
- const resolvedHermetic = shared.resolveAgentHermeticHarness(agentConfig, engineConfig);
4536
4461
 
4537
4462
  // W-mpg6isvy000xca4d — On retry after FAILURE_CLASS.MODEL_UNAVAILABLE, swap
4538
4463
  // to the runtime-appropriate fallback model. Two paths gated on
@@ -4585,7 +4510,6 @@ async function spawnAgent(dispatchItem, config) {
4585
4510
  fallbackModel: engineConfig.claudeFallbackModel,
4586
4511
  stream: engineConfig.copilotStreamMode,
4587
4512
  disableBuiltinMcps: engineConfig.copilotDisableBuiltinMcps,
4588
- suppressAgentsMd: engineConfig.copilotSuppressAgentsMd,
4589
4513
  reasoningSummaries: engineConfig.copilotReasoningSummaries,
4590
4514
  });
4591
4515
 
@@ -4600,28 +4524,21 @@ async function spawnAgent(dispatchItem, config) {
4600
4524
  // and when engine.claudePreApproveWorkspaceMcps is false (no runtime.name
4601
4525
  // check at this call site, per CLAUDE.md rule). Best-effort: NEVER block dispatch.
4602
4526
  //
4603
- // P-49e1c8b7 — Skipped entirely when resolvedHermetic is true: a hermetic
4604
- // dispatch wants a known-empty harness surface, so the workspace .mcp.json
4605
- // approval must not be written.
4606
- if (resolvedHermetic) {
4607
- log('debug', `Hermetic harness: skipping workspace MCP pre-approval for ${id}`);
4608
- } else {
4609
- try {
4610
- const result = preApproveWorkspaceMcps({
4611
- runtimeName,
4612
- worktreePath,
4613
- homeDir: os.homedir(),
4614
- engineConfig,
4615
- mutateJsonFileLocked: shared.mutateJsonFileLocked,
4616
- });
4617
- if (result.wrote) {
4618
- log('info', `Pre-approved ${result.servers.length} workspace MCP server(s) in ~/.claude.json for ${worktreePath}: ${result.servers.join(', ')}`);
4619
- } else if (result.reason !== 'no-workspace-mcp' && result.reason !== 'not-claude' && result.reason !== 'no-worktree') {
4620
- log('debug', `Skipped workspace MCP pre-approval for ${id} (reason=${result.reason})`);
4621
- }
4622
- } catch (err) {
4623
- log('warn', `Workspace MCP pre-approval failed for ${id} (non-fatal): ${err.message}`);
4527
+ try {
4528
+ const result = preApproveWorkspaceMcps({
4529
+ runtimeName,
4530
+ worktreePath,
4531
+ homeDir: os.homedir(),
4532
+ engineConfig,
4533
+ mutateJsonFileLocked: shared.mutateJsonFileLocked,
4534
+ });
4535
+ if (result.wrote) {
4536
+ log('info', `Pre-approved ${result.servers.length} workspace MCP server(s) in ~/.claude.json for ${worktreePath}: ${result.servers.join(', ')}`);
4537
+ } else if (result.reason !== 'no-workspace-mcp' && result.reason !== 'not-claude' && result.reason !== 'no-worktree') {
4538
+ log('debug', `Skipped workspace MCP pre-approval for ${id} (reason=${result.reason})`);
4624
4539
  }
4540
+ } catch (err) {
4541
+ log('warn', `Workspace MCP pre-approval failed for ${id} (non-fatal): ${err.message}`);
4625
4542
  }
4626
4543
 
4627
4544
  _phaseT.afterRuntime = Date.now();
@@ -4696,123 +4613,7 @@ async function spawnAgent(dispatchItem, config) {
4696
4613
  // spawn-agent.js handles CLAUDECODE env cleanup and claude binary resolution
4697
4614
  const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
4698
4615
 
4699
- // ── P-08b62d49 project-local-on-main harness propagation ──────────────
4700
- // The worktree-uncommitted footgun (docs/harness-propagation.md): when a
4701
- // mutating dispatch runs in a fresh `git worktree add` (worktreePath !==
4702
- // project.localPath), uncommitted `<repo>/.claude/skills/foo/SKILL.md` etc.
4703
- // on the operator's main checkout are invisible to the runtime CLI because
4704
- // its native discovery is rooted at cwd. Union the project-scope harness
4705
- // dirs (skills + commands) that exist under `project.localPath` but NOT
4706
- // under `worktreePath`, and forward them to spawn-agent as
4707
- // `--project-harness-dir <dir>` so `computeAddDirs` surfaces them via
4708
- // `--add-dir`. Gated by engine.harnessPropagateProjectLocal (default true).
4709
- // No-op for live-checkout mode (worktreePath === null) and read-only types
4710
- // (also worktreePath === null per resolveSpawnPaths).
4711
- //
4712
- // P-49e1c8b7 — Also skipped entirely when resolvedHermetic is true: hermetic
4713
- // dispatches want a known-empty harness surface so neither user-asset dirs
4714
- // (filtered inside computeAddDirs) nor project-local-on-main dirs (filtered
4715
- // here) reach the agent. We skip the queries.getProjectHarnesses(...) call
4716
- // too — saves the fs scan when the result will be discarded.
4717
- //
4718
- // P-714ef144 — when meta.workdir is set, the filter helper clips both the
4719
- // project-side and worktree-side anchors to <base>/<workdir>, so a
4720
- // `packages/foo` dispatch never sees harness dirs from sibling packages.
4721
- // The clipping is purely additive on top of the existing project-vs-worktree
4722
- // disjoint filter; when workdir is null the helper produces the same result
4723
- // as the legacy inline loop.
4724
- const projectHarnessArgs = [];
4725
- const propagatedProjectHarnessDirs = [];
4726
- if (!resolvedHermetic
4727
- && engineConfig.harnessPropagateProjectLocal !== false
4728
- && worktreePath
4729
- && project?.localPath
4730
- && path.resolve(worktreePath) !== path.resolve(project.localPath)) {
4731
- try {
4732
- const harnesses = queries.getProjectHarnesses(project);
4733
- const candidateDirs = [
4734
- ...(harnesses.skills || []),
4735
- ...(harnesses.commands || []),
4736
- ]
4737
- .map((entry) => entry?.dir)
4738
- .filter((d) => typeof d === 'string' && d);
4739
- const filteredDirs = shared.filterProjectHarnessDirsForWorkdir(candidateDirs, {
4740
- projectLocalPath: project.localPath,
4741
- worktreePath,
4742
- // P-f52d81ba — clip to the effective sub-project. Explicit meta.workdir wins
4743
- // (validatedWorkdir, possibly multi-segment); otherwise use the
4744
- // auto-derived dominant sub-project (single first-level segment) so an
4745
- // ocm/-dominant diff surfaces only ocm/'s --project-harness-dir roots.
4746
- // Empty ⇒ no clip (today's whole-project harness surface).
4747
- workdir: validatedWorkdir || _effectiveSubproject || null,
4748
- });
4749
- for (const abs of filteredDirs) {
4750
- if (!fs.existsSync(abs)) continue;
4751
- projectHarnessArgs.push('--project-harness-dir', abs);
4752
- propagatedProjectHarnessDirs.push(abs);
4753
- }
4754
- if (projectHarnessArgs.length) {
4755
- log('debug', `Project-local harness propagation: ${projectHarnessArgs.length / 2} dir(s) for ${id} (${worktreePath}${validatedWorkdir ? ', workdir=' + validatedWorkdir : (_effectiveSubproject ? ', subproject=' + _effectiveSubproject : '')})`);
4756
- }
4757
- } catch (err) {
4758
- log('warn', `Project-local harness propagation failed for ${id} (non-fatal): ${err.message}`);
4759
- }
4760
- }
4761
-
4762
- // ── P-a8f3c2d1 — harness grounding manifest (pure capture) ──────────────
4763
- // Snapshot the skills / MCP servers / commands the engine exposed to this
4764
- // agent — BOTH user-scope (queries.getUserHarnesses) and project-scope
4765
- // (queries.getProjectHarnesses) — plus the resolved `--add-dir` set
4766
- // (engine/spawn-agent.js#computeAddDirs, called with the exact same inputs
4767
- // spawn-agent uses). Persisted onto the dispatch record as `_harnessPropagated`
4768
- // so the completion-report grounding cross-check (P-c6f5e8b3) can mark each
4769
- // self-reported entry grounded:true|false. CRITICAL: include user-scope
4770
- // harnesses or legitimate ~/.agents / ~/.claude skill usage is wrongly
4771
- // flagged grounded:false downstream. This is observability only — it does NOT
4772
- // change which dirs are propagated at spawn time. Best-effort: any failure
4773
- // here must never block the spawn.
4774
- try {
4775
- const homeDir = os.homedir();
4776
- const userHarnesses = queries.getUserHarnesses(homeDir);
4777
- const projectHarnesses = project?.localPath
4778
- ? queries.getProjectHarnesses(project)
4779
- : { skills: [], commands: [], mcps: [] };
4780
- let addDirs = [];
4781
- try {
4782
- addDirs = computeAddDirs({
4783
- runtime,
4784
- minionsDir: MINIONS_DIR,
4785
- homeDir,
4786
- projectHarnessDirs: propagatedProjectHarnessDirs,
4787
- hermetic: resolvedHermetic,
4788
- });
4789
- } catch { addDirs = []; }
4790
- const harnessManifest = queries.buildHarnessPropagatedManifest({
4791
- userHarnesses,
4792
- projectHarnesses,
4793
- addDirs,
4794
- });
4795
- mutateDispatch((dispatch) => {
4796
- for (const queue of ['pending', 'active', 'completed']) {
4797
- const arr = Array.isArray(dispatch?.[queue]) ? dispatch[queue] : null;
4798
- if (!arr) continue;
4799
- const found = arr.find(d => d && d.id === id);
4800
- if (found) found._harnessPropagated = harnessManifest;
4801
- }
4802
- return dispatch;
4803
- });
4804
- dispatchItem._harnessPropagated = harnessManifest;
4805
- } catch (err) {
4806
- log('warn', `Harness grounding manifest capture failed for ${id} (non-fatal): ${err.message}`);
4807
- }
4808
-
4809
- // P-49e1c8b7 — hermetic harness opt-out forwarded to spawn-agent.js so
4810
- // computeAddDirs returns [minionsDir] only (user-asset dirs stripped).
4811
- // Append BEFORE projectHarnessArgs (which will be empty when hermetic, but
4812
- // ordering keeps the intent explicit in process listings).
4813
- const hermeticArgs = resolvedHermetic ? ['--hermetic-harness'] : [];
4814
-
4815
- const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args, ...hermeticArgs, ...projectHarnessArgs];
4616
+ const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args];
4816
4617
 
4817
4618
  // Live output file — stamped BEFORE child process is spawned (#W-mo248lkjwgsu).
4818
4619
  // Writing the stub pre-spawn lets the orphan detector distinguish three failure modes
@@ -5158,7 +4959,6 @@ async function spawnAgent(dispatchItem, config) {
5158
4959
  fallbackModel: engineConfig?.claudeFallbackModel,
5159
4960
  stream: engineConfig?.copilotStreamMode,
5160
4961
  disableBuiltinMcps: engineConfig?.copilotDisableBuiltinMcps,
5161
- suppressAgentsMd: engineConfig?.copilotSuppressAgentsMd,
5162
4962
  reasoningSummaries: engineConfig?.copilotReasoningSummaries,
5163
4963
  });
5164
4964
  if (!resumeArgs.includes('--resume')) {
@@ -5223,12 +5023,7 @@ async function spawnAgent(dispatchItem, config) {
5223
5023
  let resumeProc;
5224
5024
  try {
5225
5025
  // detached so the resumed steering session also survives engine death (matches initial spawn)
5226
- // P-08b62d49 also include projectHarnessArgs so the resumed runtime
5227
- // can still reach uncommitted project-local harness dirs (the runtime
5228
- // CLI re-indexes asset dirs on every spawn, including this resume).
5229
- // P-49e1c8b7 — same for --hermetic-harness so the resumed dispatch
5230
- // keeps the known-empty harness contract.
5231
- resumeProc = runFile(process.execPath, [spawnScript, steerPromptPath, sysPromptPath, ...resumeArgs, ...hermeticArgs, ...projectHarnessArgs], {
5026
+ resumeProc = runFile(process.execPath, [spawnScript, steerPromptPath, sysPromptPath, ...resumeArgs], {
5232
5027
  cwd,
5233
5028
  stdio: ['pipe', 'pipe', 'pipe'],
5234
5029
  env: childEnv,
@@ -6708,8 +6503,9 @@ function materializePlansAsWorkItems(config) {
6708
6503
  }
6709
6504
  }
6710
6505
 
6711
- let planFiles;
6712
- try { planFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json')); } catch { return; }
6506
+ const planFiles = prdStore.listPrdRows()
6507
+ .filter(row => !row.archived)
6508
+ .map(row => row.filename);
6713
6509
 
6714
6510
  // Regex for detecting sequential PRD item IDs (P-001, M001, M-001, WI-001, …) — hoisted outside loop.
6715
6511
  // Covers all patterns where an alphabetic prefix is followed by an optional dash and pure digits,
@@ -6937,11 +6733,11 @@ function materializePlansAsWorkItems(config) {
6937
6733
 
6938
6734
  let totalCreated = 0;
6939
6735
  for (const [projName, { project, items: projItems }] of itemsByProject) {
6940
- // Re-read PRD status from disk to guard against mid-tick status changes (P-f1a3b5c7).
6736
+ // Re-read authoritative PRD status to guard against mid-tick status changes (P-f1a3b5c7).
6941
6737
  // A pause/reject/awaiting-approval signal may arrive between the outer-loop initial
6942
6738
  // read and this point (e.g. operator pauses via dashboard while the tick is running).
6943
6739
  // Re-reading here prevents phantom WI creation on a paused, rejected, or unapproved plan.
6944
- const freshPrd = safeJsonNoRestore(path.join(PRD_DIR, file));
6740
+ const freshPrd = prdStore.readPrd(path.join(PRD_DIR, file));
6945
6741
  const freshStatus = (freshPrd || {}).status || ((freshPrd || {}).requires_approval ? 'awaiting-approval' : null);
6946
6742
  if (freshStatus === PLAN_STATUS.PAUSED || freshStatus === PLAN_STATUS.REJECTED || freshStatus === 'awaiting-approval') {
6947
6743
  log('warn', `PRD ${file}: status changed to '${freshStatus}' mid-tick — skipping WI batch for ${projName} (P-f1a3b5c7)`);
@@ -8364,30 +8160,6 @@ function _buildRunnerBriefVars(item, project) {
8364
8160
  }
8365
8161
  }
8366
8162
 
8367
- // P-f52d81ba — sync effective-sub-project derivation for the build-time prompt render
8368
- // (renderProjectWorkItemPromptForAgent runs synchronously, so no git diff).
8369
- // Explicit meta.workdir wins (first segment). Otherwise best-effort from the
8370
- // work item's references[*].path via detectDominantSubproject. Returns '' (flat
8371
- // discovery) on anything unresolved. spawnAgent's async re-render overrides
8372
- // this with a git-diff-derived sub-project via options.dominantSubproject.
8373
- function _deriveDominantSubprojectSync(item, project) {
8374
- try {
8375
- const wd = item && item.meta && item.meta.workdir;
8376
- if (typeof wd === 'string' && wd.trim()) {
8377
- return wd.replace(/\\/g, '/').replace(/^\/+/, '').split('/')[0] || '';
8378
- }
8379
- const projectPath = project && project.localPath;
8380
- if (!projectPath) return '';
8381
- const paths = (item && Array.isArray(item.references))
8382
- ? item.references.map(r => (r && typeof r.path === 'string') ? r.path : '').filter(Boolean)
8383
- : [];
8384
- if (paths.length === 0) return '';
8385
- const { detectDominantSubproject } = require('./engine/discover-project-skills')._internal;
8386
- const det = detectDominantSubproject({ projectPath, changedPaths: paths });
8387
- return (det && det.confident && det.subproject) ? det.subproject : '';
8388
- } catch { return ''; }
8389
- }
8390
-
8391
8163
  /**
8392
8164
  * Scan work-items.json for manually queued tasks
8393
8165
  */
@@ -8469,12 +8241,6 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
8469
8241
  qa_artifacts_dir: item.meta && item.meta.qaRunId
8470
8242
  ? path.posix.join('engine', 'qa-artifacts', String(item.meta.qaRunId))
8471
8243
  : '',
8472
- // W-mq16xtdx001a347e — escape hatch for review dispatches that should NOT
8473
- // be steered toward project-local review skills (e.g. when the diff
8474
- // under review IS that skill, so a meta-review needs first-principles).
8475
- // Default OFF — the new "Project review skills" block is the whole point
8476
- // of W-mq16xtdx and should surface on every review by default.
8477
- skip_project_review_skills: !!(item.meta && item.meta.skipProjectReviewSkills),
8478
8244
  // P-e6b3c2d8 — QA Session template vars. The qa-sessions chain helpers
8479
8245
  // (engine/qa-sessions.js#_baseWorkItem) stamp meta.sessionId,
8480
8246
  // meta.sessionPhase, and meta.qaSession.{target,flowsRaw,mode,capture,runner}
@@ -8550,26 +8316,6 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
8550
8316
  // failure via the qa-session-draft-failed / qa-session-execute-failed
8551
8317
  // path. (See playbooks/qa-session-draft.md → "Failure path" section.)
8552
8318
  ..._buildRunnerBriefVars(item, project),
8553
- // W-mq16xtdx001a347e + W-mq1cczi90006b21f — escape hatches for dispatches
8554
- // that should NOT be steered toward project-local skills (e.g. when the
8555
- // diff under review IS that skill, so a meta-review needs first-principles).
8556
- // Default OFF — the project skills block is the whole point of these WIs
8557
- // and should surface on every applicable dispatch by default.
8558
- //
8559
- // Both meta names are honored:
8560
- // - meta.skipProjectReviewSkills (PR-82 alias, review-only suppression)
8561
- // - meta.skipProjectSkills (W-mq1cczi90006b21f, generic suppression)
8562
- // Either flag suppresses both blocks on the dispatch.
8563
- skip_project_review_skills: !!(item.meta && item.meta.skipProjectReviewSkills),
8564
- skip_project_skills: !!(item.meta && (item.meta.skipProjectSkills || item.meta.skipProjectReviewSkills)),
8565
- // P-f52d81ba — effective monorepo sub-project for scoped skill discovery. When the
8566
- // async caller (spawnAgent) supplies a git-diff-derived sub-project it wins (even
8567
- // ''); otherwise derive best-effort from explicit meta.workdir /
8568
- // references[*].path. Empty ⇒ flat discovery. playbook.js reads this as the
8569
- // discovery scopeSubproject.
8570
- dominant_subproject: (typeof options.dominantSubproject === 'string')
8571
- ? options.dominantSubproject
8572
- : _deriveDominantSubprojectSync(item, project),
8573
8319
  // M006 — typed handoff envelope. Set on follow-up dispatch metas by
8574
8320
  // lifecycle.js when queuing an item after a completing agent. The
8575
8321
  // renderPlaybook path injects a "Handoff Context" section when truthy
@@ -8594,11 +8340,6 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
8594
8340
  needsReview: false,
8595
8341
  checkpointCount: cpResult.checkpointCount,
8596
8342
  prompt,
8597
- // P-f52d81ba — surface the discovery diagnostics renderPlaybook stashed on
8598
- // the vars object (detected sub-project + per-surface truncations) so spawnAgent
8599
- // can record them onto the dispatch record. null when no playbook render
8600
- // ran (item.prompt short-circuit) or discovery was skipped.
8601
- discoveryDiagnostics: vars._discoveryDiagnostics || null,
8602
8343
  };
8603
8344
  }
8604
8345
 
@@ -8690,19 +8431,34 @@ function refreshDeferredReviewPrompt(item, config) {
8690
8431
  if (!pr) return;
8691
8432
  const project = projectFromDispatchMeta(item.meta?.project, config);
8692
8433
  if (!project) return;
8693
- const prBranch = item.meta.branch || pr.branch || '';
8694
- const prNumber = shared.getPrNumber(pr);
8434
+ // W-mrewhflh000k1acd: meta.reviewHeadSha (and meta.pr) were snapshotted at
8435
+ // DEFER time — when no non-author reviewer was available. If the PR head
8436
+ // advances while the review sits pending reassignment, reusing that stale
8437
+ // snapshot means lifecycle.js#updatePrAfterReview stamps reviewedHeadSha
8438
+ // against the OLD head once this review actually completes, so
8439
+ // isPrReviewCurrent() wrongly judges freshness against the true current
8440
+ // head — producing spurious re-review dispatch loops (or false
8441
+ // "already reviewed" skips). Re-fetch the live PR record and recompute the
8442
+ // head SHA before rebuilding the prompt.
8443
+ let livePr = pr;
8444
+ try {
8445
+ const prs = safeJsonArr(projectPrPath(project));
8446
+ const found = shared.findPrRecord(prs, pr, project);
8447
+ if (found) livePr = found;
8448
+ } catch (e) { log('warn', `refreshDeferredReviewPrompt: live PR lookup for ${pr.id} failed: ${e.message}`); }
8449
+ const prBranch = item.meta.branch || livePr.branch || '';
8450
+ const prNumber = shared.getPrNumber(livePr);
8695
8451
  const extraVars = {
8696
- pr_id: pr.id,
8452
+ pr_id: livePr.id,
8697
8453
  pr_number: prNumber,
8698
- pr_title: pr.title || '',
8454
+ pr_title: livePr.title || '',
8699
8455
  pr_branch: prBranch,
8700
- pr_author: pr.agent || '',
8701
- pr_url: pr.url || '',
8456
+ pr_author: livePr.agent || '',
8457
+ pr_url: livePr.url || '',
8702
8458
  };
8703
8459
  const rebuilt = buildPrDispatch(
8704
- item.agent, config, project, pr, WORK_TYPE.REVIEW,
8705
- extraVars, `Review ${pr.id}: ${pr.title || ''}`, item.meta
8460
+ item.agent, config, project, livePr, WORK_TYPE.REVIEW,
8461
+ extraVars, `Review ${livePr.id}: ${livePr.title || ''}`, item.meta
8706
8462
  );
8707
8463
  if (rebuilt && rebuilt.prompt) {
8708
8464
  item.prompt = rebuilt.prompt;
@@ -8710,6 +8466,8 @@ function refreshDeferredReviewPrompt(item, config) {
8710
8466
  if (rebuilt.agentName) item.agentName = rebuilt.agentName;
8711
8467
  if (rebuilt.agentRole) item.agentRole = rebuilt.agentRole;
8712
8468
  }
8469
+ item.meta.pr = livePr;
8470
+ item.meta.reviewHeadSha = getPrCauseHead(livePr);
8713
8471
  item.meta.deferReviewerResolution = false;
8714
8472
  }
8715
8473
 
@@ -9837,12 +9595,13 @@ function discoverCentralWorkItems(config) {
9837
9595
  // PRD's source_plan can't fork a duplicate PRD (#415 class).
9838
9596
  let prdFilename = null;
9839
9597
  const planKey = path.basename(String(item.planFile || ''));
9840
- const prdFiles = safeReadDir(PRD_DIR).filter(f => f.endsWith('.json'));
9598
+ const prdRows = prdStore.listPrdRows();
9599
+ const prdFiles = prdRows.filter(row => !row.archived).map(row => row.filename);
9841
9600
  for (const pf of prdFiles) {
9842
- const prd = safeJson(path.join(PRD_DIR, pf));
9601
+ const prd = prdStore.readPrd(path.join(PRD_DIR, pf));
9843
9602
  if (planKey && prd && !shared.isPrdArchived(prd) && shared.prdMatchesSourcePlan(prd.source_plan, planKey)) {
9844
9603
  prdFilename = pf;
9845
- try { vars.existing_prd_json = fs.readFileSync(path.join(PRD_DIR, pf), 'utf8'); } catch (_) { /* ignore */ }
9604
+ vars.existing_prd_json = JSON.stringify(prd, null, 2);
9846
9605
  log('info', `plan-to-prd: reusing existing PRD "${pf}" for plan "${item.planFile}" (#884)`);
9847
9606
  break;
9848
9607
  }
@@ -9882,7 +9641,7 @@ function discoverCentralWorkItems(config) {
9882
9641
  shared.sanitizePath(prdFilename, PRD_DIR);
9883
9642
  const prdExisting = new Set([
9884
9643
  ...prdFiles,
9885
- ...safeReadDir(path.join(PRD_DIR, 'archive')).filter(f => f.endsWith('.json')),
9644
+ ...prdRows.filter(row => row.archived).map(row => row.filename),
9886
9645
  ]);
9887
9646
  for (const otherItem of items) {
9888
9647
  if (!otherItem || otherItem.id === item.id) continue;
@@ -10245,7 +10004,7 @@ async function discoverWork(config) {
10245
10004
  // basename. The plan-completion scan does the same purge safely (identity-
10246
10005
  // checked + neutralize); here the isDefunctPrd guard below already skips
10247
10006
  // archived/defunct PRDs, so this loop never re-runs completion on a ghost.)
10248
- for (const f of fs.readdirSync(prdDir).filter(f => f.endsWith('.json'))) {
10007
+ for (const f of prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename)) {
10249
10008
  if (completedPlanCache.has(f)) continue;
10250
10009
  // safeJsonNoRestore — defense in depth: if the file vanished between
10251
10010
  // readdir and read (e.g. concurrent archive), do not resurrect it
@@ -10819,7 +10578,7 @@ async function tickInner() {
10819
10578
  try { persistVerifyPrsToPrd(config); } catch (err) { log('warn', `PRD verify-PR persist error: ${err?.message || err}`); }
10820
10579
  // Check if any plans can be marked completed (all features done/in-pr)
10821
10580
  try {
10822
- const prdFiles = safeReadDir(PRD_DIR).filter(f => f.endsWith('.json'));
10581
+ const prdFiles = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
10823
10582
  for (const file of prdFiles) {
10824
10583
  if (completedPlanCache.has(file)) continue;
10825
10584
  if (fs.existsSync(path.join(PRD_DIR, 'archive', file))) {
@@ -10978,6 +10737,14 @@ async function tickInner() {
10978
10737
  // Only retry if something depends on this item
10979
10738
  const isBlocking = items.some(w => w.status === WI_STATUS.PENDING && (w.depends_on || []).includes(item.id));
10980
10739
  if (!isBlocking) continue;
10740
+ // W-mrdykpv60005d50d: don't resurrect items whose failure class is explicitly
10741
+ // non-retryable (AUTH, INJECTION_FLAGGED, PRE_DISPATCH_EVAL_STUCK, etc.) — mirrors
10742
+ // the same isRetryableFailureReason() gate used by the dispatch-completion retry path
10743
+ // (engine/dispatch.js:764) and the quarantine auto-recovery block above (engine.js:8804-8811).
10744
+ if (!isRetryableFailureReason(item.failReason, item._failureClass)) {
10745
+ log('info', `Stall recovery: skipping ${item.id} — failure class '${item._failureClass || 'unknown'}' is non-retryable`);
10746
+ continue;
10747
+ }
10981
10748
 
10982
10749
  log('info', `Stall recovery: auto-retrying ${item.id} (blocking ${pendingWithBlockedDeps.filter(w => (w.depends_on || []).includes(item.id)).length} items)`);
10983
10750
  item.status = WI_STATUS.PENDING;
@@ -11753,6 +11520,7 @@ module.exports = {
11753
11520
  normalizePrBranch, resolvePrBranch, prCausePart, getPrCauseHead, getPrCauseBase, getPrAutomationCauseKey, getPrAutomationDispatchKey, // exported for testing
11754
11521
  isPrReviewCurrent, isPrFixedAfterReview, // exported for testing (W-mrej8p740006b0bf — SHA-based review freshness)
11755
11522
  ensurePrBranchForDispatch, isWithinLinkGraceWindow, PR_LINK_GRACE_WINDOW_MS, // exported for testing (W-mphm0kt0000cebc3)
11523
+ refreshDeferredReviewPrompt, // exported for testing (W-mrewhflh000k1acd — stale reviewHeadSha on reassignment)
11756
11524
 
11757
11525
  // Playbooks
11758
11526
  renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS, buildWorkItemDispatchVars,