@webpresso/agent-kit 3.3.3 → 3.3.5

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 (156) hide show
  1. package/THIRD-PARTY-NOTICES.md +12 -10
  2. package/catalog/agent/rules/changeset-release.md +15 -4
  3. package/catalog/agent/rules/managed-tool-binaries.md +67 -0
  4. package/catalog/agent/rules/parallel-orchestration-quota.md +99 -1
  5. package/catalog/agent/rules/pre-implementation.md +36 -20
  6. package/catalog/agent/rules/rtk-routing.md +22 -16
  7. package/catalog/agent/skills/autopilot/SKILL.md +4 -3
  8. package/catalog/agent/skills/ultragoal/SKILL.md +11 -9
  9. package/dist/esm/audit/agents.js +126 -26
  10. package/dist/esm/audit/hook-surface.js +5 -1
  11. package/dist/esm/audit/repo-guardrails.d.ts +8 -0
  12. package/dist/esm/audit/repo-guardrails.js +71 -1
  13. package/dist/esm/blueprint/core/parser.js +38 -12
  14. package/dist/esm/blueprint/execution/artifacts.js +8 -4
  15. package/dist/esm/blueprint/execution/frontmatter-keys.d.ts +17 -0
  16. package/dist/esm/blueprint/execution/frontmatter-keys.js +24 -0
  17. package/dist/esm/blueprint/execution/metadata.js +8 -4
  18. package/dist/esm/blueprint/lifecycle/audit.d.ts +21 -0
  19. package/dist/esm/blueprint/lifecycle/audit.js +89 -3
  20. package/dist/esm/blueprint/lifecycle/review-provenance.js +63 -10
  21. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +22 -0
  22. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +37 -1
  23. package/dist/esm/blueprint/markdown/blocked-line.d.ts +49 -0
  24. package/dist/esm/blueprint/markdown/blocked-line.js +63 -0
  25. package/dist/esm/blueprint/markdown/helpers.js +10 -4
  26. package/dist/esm/blueprint/tracked-document/parser.js +3 -3
  27. package/dist/esm/blueprint/trust/dossier.js +41 -1
  28. package/dist/esm/blueprint/trust/gate-row.d.ts +55 -0
  29. package/dist/esm/blueprint/trust/gate-row.js +113 -0
  30. package/dist/esm/blueprint/trust/promotion.d.ts +17 -0
  31. package/dist/esm/blueprint/trust/promotion.js +19 -19
  32. package/dist/esm/build/atomic-file-copy.d.ts +32 -0
  33. package/dist/esm/build/atomic-file-copy.js +44 -0
  34. package/dist/esm/build/vendor-oxlint-plugins.d.ts +11 -0
  35. package/dist/esm/build/vendor-oxlint-plugins.js +48 -0
  36. package/dist/esm/cli/commands/blueprint/mutations.d.ts +15 -0
  37. package/dist/esm/cli/commands/blueprint/mutations.js +8 -2
  38. package/dist/esm/cli/commands/compile.d.ts +13 -0
  39. package/dist/esm/cli/commands/compile.js +182 -62
  40. package/dist/esm/cli/commands/format.js +17 -1
  41. package/dist/esm/cli/commands/gain/index.js +10 -3
  42. package/dist/esm/cli/commands/init/gitignore-patcher.js +6 -0
  43. package/dist/esm/cli/commands/init/index.js +32 -52
  44. package/dist/esm/cli/commands/init/mcp-spec.d.ts +48 -13
  45. package/dist/esm/cli/commands/init/mcp-spec.js +86 -30
  46. package/dist/esm/cli/commands/init/merge.js +30 -10
  47. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.d.ts +56 -31
  48. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +180 -43
  49. package/dist/esm/cli/commands/init/scaffolders/rtk/index.d.ts +17 -5
  50. package/dist/esm/cli/commands/init/scaffolders/rtk/index.js +56 -75
  51. package/dist/esm/cli/commands/init/scaffolders/runtime-check/index.js +7 -6
  52. package/dist/esm/cli/commands/init/untracked-collision.d.ts +12 -1
  53. package/dist/esm/cli/commands/init/untracked-collision.js +23 -1
  54. package/dist/esm/cli/commands/pr-help.js +1 -1
  55. package/dist/esm/cli/commands/pr.js +18 -4
  56. package/dist/esm/cli/commands/review.d.ts +1 -0
  57. package/dist/esm/cli/commands/review.js +137 -7
  58. package/dist/esm/cli/commands/sync.d.ts +8 -6
  59. package/dist/esm/cli/commands/sync.js +11 -9
  60. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +24 -2
  61. package/dist/esm/cli/commands/worktree/router-dispatch.js +80 -48
  62. package/dist/esm/cli/commands/worktree/router.js +5 -2
  63. package/dist/esm/cli/direct-provider-launch.js +12 -4
  64. package/dist/esm/compiler/flatten.d.ts +10 -1
  65. package/dist/esm/compiler/flatten.js +14 -4
  66. package/dist/esm/compose/admin-blocks-registry-integrity.d.ts +22 -0
  67. package/dist/esm/compose/admin-blocks-registry-integrity.js +90 -0
  68. package/dist/esm/compose/compose.d.ts +8 -0
  69. package/dist/esm/compose/compose.js +115 -8
  70. package/dist/esm/compose/ops-report-schema.js +17 -3
  71. package/dist/esm/compose/registry-resolve.d.ts +17 -0
  72. package/dist/esm/compose/registry-resolve.js +78 -11
  73. package/dist/esm/config/oxlint/graphql-conventions.js +2 -5
  74. package/dist/esm/config/oxlint/import-hygiene.js +21 -8
  75. package/dist/esm/config/oxlint/oxlint-config.fixture.d.ts +1 -1
  76. package/dist/esm/config/oxlint/oxlint-config.fixture.js +2 -3
  77. package/dist/esm/config/oxlint/oxlintrc.d.ts +5 -7
  78. package/dist/esm/config/oxlint/oxlintrc.js +19 -31
  79. package/dist/esm/config/oxlint/path-roles.js +53 -0
  80. package/dist/esm/config/oxlint/query-patterns.js +2 -5
  81. package/dist/esm/content/dispatch.js +4 -0
  82. package/dist/esm/content/loader.d.ts +1 -1
  83. package/dist/esm/content/loader.js +10 -6
  84. package/dist/esm/content/skill-policy.d.ts +11 -0
  85. package/dist/esm/content/skill-policy.js +33 -0
  86. package/dist/esm/daemon/domains/blueprint-exec-ledger.d.ts +86 -0
  87. package/dist/esm/daemon/domains/blueprint-exec-ledger.js +134 -0
  88. package/dist/esm/daemon/domains/blueprint-exec.d.ts +106 -0
  89. package/dist/esm/daemon/domains/blueprint-exec.js +161 -0
  90. package/dist/esm/daemon/domains/launch.d.ts +25 -0
  91. package/dist/esm/daemon/domains/launch.js +30 -1
  92. package/dist/esm/daemon/handlers/blueprints-handlers.d.ts +12 -1
  93. package/dist/esm/daemon/handlers/blueprints-handlers.js +148 -6
  94. package/dist/esm/daemon/handlers/product.d.ts +10 -0
  95. package/dist/esm/daemon/handlers/product.js +10 -1
  96. package/dist/esm/daemon/methods.d.ts +21 -1
  97. package/dist/esm/daemon/methods.js +20 -0
  98. package/dist/esm/daemon/protocol/contract.d.ts +58 -0
  99. package/dist/esm/daemon/protocol/contract.js +38 -0
  100. package/dist/esm/daemon/server.js +7 -0
  101. package/dist/esm/docs-linter/blueprint-plan.js +2 -2
  102. package/dist/esm/errors/wp-error.js +21 -2
  103. package/dist/esm/hooks/doctor.d.ts +11 -0
  104. package/dist/esm/hooks/doctor.js +114 -45
  105. package/dist/esm/hooks/pretool-guard/dev-routing.js +68 -5
  106. package/dist/esm/hooks/pretool-guard/rtk-rewrite.d.ts +24 -0
  107. package/dist/esm/hooks/pretool-guard/rtk-rewrite.js +50 -0
  108. package/dist/esm/hooks/pretool-guard/runner.js +6 -0
  109. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +62 -3
  110. package/dist/esm/hooks/status/index.js +1 -1
  111. package/dist/esm/mcp/blueprint/_shared/lifecycle.js +39 -11
  112. package/dist/esm/mcp/blueprint/handlers/review-log.d.ts +14 -7
  113. package/dist/esm/mcp/blueprint/handlers/review-log.js +46 -22
  114. package/dist/esm/mcp/blueprint/registration.js +1 -1
  115. package/dist/esm/mcp/tools/_shared/redact.d.ts +22 -0
  116. package/dist/esm/mcp/tools/_shared/redact.js +114 -8
  117. package/dist/esm/mcp/tools/gain.js +28 -2
  118. package/dist/esm/mcp/tools/worktree.js +65 -3
  119. package/dist/esm/mcp/tools/wp-ui-compose.js +38 -4
  120. package/dist/esm/mcp/tools/wp-ui-preview.js +14 -7
  121. package/dist/esm/review/authority.js +56 -1
  122. package/dist/esm/review/delivery-verifier.d.ts +4 -0
  123. package/dist/esm/review/delivery-verifier.js +9 -1
  124. package/dist/esm/review/lifecycle-writers.d.ts +76 -0
  125. package/dist/esm/review/lifecycle-writers.js +330 -0
  126. package/dist/esm/review/subject.js +66 -32
  127. package/dist/esm/status/snapshot.d.ts +5 -0
  128. package/dist/esm/status/snapshot.js +27 -2
  129. package/dist/esm/symlinker/consumers.d.ts +3 -1
  130. package/dist/esm/symlinker/consumers.js +7 -1
  131. package/dist/esm/symlinker/unified-sync.d.ts +3 -3
  132. package/dist/esm/symlinker/unified-sync.js +88 -11
  133. package/dist/esm/test/shard-durations.json +0 -2
  134. package/dist/esm/test-helpers/global-setup.js +38 -3
  135. package/dist/esm/tool-runtime/managed-rtk.d.ts +44 -0
  136. package/dist/esm/tool-runtime/managed-rtk.js +275 -0
  137. package/dist/esm/tool-runtime/resolve-runner.js +18 -194
  138. package/dist/esm/utils/package-root.d.ts +32 -0
  139. package/dist/esm/utils/package-root.js +82 -0
  140. package/dist/esm/worktrees/git-metadata.d.ts +41 -0
  141. package/dist/esm/worktrees/git-metadata.js +48 -0
  142. package/package.json +16 -21
  143. package/dist/esm/cli/commands/init/scaffolders/subagents/index.d.ts +0 -6
  144. package/dist/esm/cli/commands/init/scaffolders/subagents/index.js +0 -77
  145. package/dist/esm/config/oxlint/code-safety.d.ts +0 -18
  146. package/dist/esm/config/oxlint/foundation-purity.d.ts +0 -21
  147. package/dist/esm/config/oxlint/graphql-conventions.d.ts +0 -23
  148. package/dist/esm/config/oxlint/import-hygiene.d.ts +0 -34
  149. package/dist/esm/config/oxlint/index.d.ts +0 -22
  150. package/dist/esm/config/oxlint/index.js +0 -28
  151. package/dist/esm/config/oxlint/monorepo-paths.d.ts +0 -22
  152. package/dist/esm/config/oxlint/query-patterns.d.ts +0 -24
  153. package/dist/esm/config/oxlint/testing-quality.d.ts +0 -35
  154. package/dist/esm/config/oxlint/tier-boundaries.d.ts +0 -35
  155. package/dist/esm/output-transforms/rulesync.d.ts +0 -2
  156. package/dist/esm/output-transforms/rulesync.js +0 -79
@@ -27,6 +27,8 @@ import { isAgentKitSourceRepo } from "#cli/commands/init/source-repo-hook-policy
27
27
  import { findAgentKitPackageRoot, resolveAgentKitPackageRoot, } from "#cli/commands/init/package-root";
28
28
  import { readInstalledHooksMap } from "#hooks/shared/installed-hooks.js";
29
29
  import { auditCodexConfigPaths, checkCodexConfigPathIntegrity } from "#hooks/codex-config-paths.js";
30
+ import { findInstalledCodexPluginMcpManifests, pruneOwnedMcpServerBlock, } from "#cli/commands/init/scaffolders/codex-mcp/index.js";
31
+ import { isAgentKitOwnedWpCommand, WEBPRESSO_MCP_SERVER_NAME, } from "#cli/commands/init/mcp-spec.js";
30
32
  import { parseProjectMcpServers, scanCodexConfigDrift } from "#hooks/host-config-contract.js";
31
33
  import { defaultCodexConfigFilePath } from "#cli/commands/init/scaffolders/agent-hooks/codex-trust-sync.js";
32
34
  import { expectedRootWpBinRelativePath, formatRootLauncherContractFailure, rootContractMode, validateRootLauncherContract, } from "#launcher/root-contract.js";
@@ -55,7 +57,10 @@ function createOwnedProbeSettler(child, timeoutMs, timeoutResult, resolve) {
55
57
  return settle;
56
58
  }
57
59
  const RTK_REQUESTED_MARKER = join(".agent", ".rtk-requested");
58
- const RTK_INSTALL_HINT = "rtk requested via --with rtk but not on PATH; brew install rtk";
60
+ const RTK_INSTALL_HINT = "rtk requested but managed binary missing; re-run wp setup (or set WP_RTK_BIN)";
61
+ const RTK_FREESTANDING_HOOK_REL = join(".claude", "hooks", "rtk-rewrite.sh");
62
+ const RTK_FREESTANDING_SETTINGS_REL = join(".claude", "settings.json");
63
+ const RTK_FREESTANDING_REPAIR = "stale freestanding rtk-rewrite surface; run wp setup (ensureRtk strips rtk-rewrite.sh — pretool-guard owns rewrite)";
59
64
  const HOST_SMOKE_ENV = "WP_RUN_HOST_SMOKE";
60
65
  const HOOK_PROBE_TIMEOUT_ENV = "WP_DOCTOR_HOOK_TIMEOUT_MS";
61
66
  const WRAPPER_PROBE_ENV = "WP_DOCTOR_WRAPPER_PROBE";
@@ -347,6 +352,62 @@ function checkConsumerCodexHookPaths(cwd = process.cwd()) {
347
352
  function wasRtkRequested(cwd = process.cwd()) {
348
353
  return tryAccess(join(cwd, RTK_REQUESTED_MARKER));
349
354
  }
355
+ export function checkFreestandingRtkSurface(cwd = process.cwd()) {
356
+ const hookPath = join(cwd, RTK_FREESTANDING_HOOK_REL);
357
+ const settingsPath = join(cwd, RTK_FREESTANDING_SETTINGS_REL);
358
+ const hookPresent = tryAccess(hookPath);
359
+ const markers = [];
360
+ if (hookPresent)
361
+ markers.push(RTK_FREESTANDING_HOOK_REL);
362
+ if (tryAccess(settingsPath)) {
363
+ try {
364
+ const settings = readFileSync(settingsPath, "utf8");
365
+ if (settings.includes("rtk-rewrite.sh")) {
366
+ markers.push(`${RTK_FREESTANDING_SETTINGS_REL} rtk-rewrite.sh`);
367
+ }
368
+ if (settings.includes("rtk hook claude")) {
369
+ markers.push(`${RTK_FREESTANDING_SETTINGS_REL} rtk hook claude`);
370
+ }
371
+ }
372
+ catch {
373
+ // ignore unreadable settings
374
+ }
375
+ }
376
+ if (markers.length === 0)
377
+ return null;
378
+ return {
379
+ name: "freestanding rtk PreToolUse retired",
380
+ ok: false,
381
+ detail: `${RTK_FREESTANDING_REPAIR} (found: ${markers.join(", ")})`,
382
+ };
383
+ }
384
+ /** Non-mutating warn for user-global freestanding RTK rewriters (rtk init -g). */
385
+ export function checkGlobalFreestandingRtkSurface() {
386
+ const globalSettings = join(homedir(), ".claude", "settings.json");
387
+ const globalHook = join(homedir(), ".claude", "hooks", "rtk-rewrite.sh");
388
+ const markers = [];
389
+ if (tryAccess(globalHook))
390
+ markers.push("~/.claude/hooks/rtk-rewrite.sh");
391
+ if (tryAccess(globalSettings)) {
392
+ try {
393
+ const settings = readFileSync(globalSettings, "utf8");
394
+ if (settings.includes("rtk-rewrite.sh"))
395
+ markers.push("~/.claude/settings.json rtk-rewrite.sh");
396
+ if (settings.includes("rtk hook claude"))
397
+ markers.push("~/.claude/settings.json rtk hook claude");
398
+ }
399
+ catch {
400
+ // ignore
401
+ }
402
+ }
403
+ if (markers.length === 0)
404
+ return null;
405
+ return {
406
+ name: "global freestanding rtk (warn)",
407
+ ok: true,
408
+ detail: `WARNING: global freestanding RTK PreToolUse still present (${markers.join(", ")}); remove manually or re-run rtk uninstall — project setup does not mutate ~/.claude. Dual rewriters may race updatedInput with wp-pretool-guard.`,
409
+ };
410
+ }
350
411
  function shouldRunHostChecks(mode) {
351
412
  if (mode === "skip")
352
413
  return false;
@@ -393,36 +454,13 @@ function resolveRequestedHosts(mode, hostNames) {
393
454
  export function checkRtkOnPath(cwd) {
394
455
  if (!wasRtkRequested(cwd))
395
456
  return Promise.resolve(null);
396
- return new Promise((resolve) => {
397
- const timeoutMs = hookProbeTimeoutMs();
398
- const child = spawn("rtk", ["--version"], {
399
- stdio: ["ignore", "pipe", "pipe"],
400
- detached: process.platform !== "win32",
401
- });
402
- let stdout = "";
403
- let stderr = "";
404
- const settle = createOwnedProbeSettler(child, timeoutMs, () => ({
405
- name: "rtk on PATH",
406
- ok: false,
407
- detail: `rtk probe timed out after ${timeoutMs}ms`,
408
- }), resolve);
409
- child.stdout.on("data", (chunk) => {
410
- stdout += String(chunk);
411
- });
412
- child.stderr?.on("data", (chunk) => {
413
- stderr += String(chunk);
414
- });
415
- child.on("error", () => {
416
- settle({ name: "rtk on PATH", ok: false, detail: RTK_INSTALL_HINT });
417
- });
418
- child.on("close", (code) => {
419
- if (code === 0) {
420
- settle({ name: "rtk on PATH", ok: true, detail: stdout.trim() || "rtk present" });
421
- return;
422
- }
423
- const suffix = stderr.trim().length > 0 ? ` (${stderr.trim()})` : "";
424
- settle({ name: "rtk on PATH", ok: false, detail: `${RTK_INSTALL_HINT}${suffix}` });
425
- });
457
+ // Managed binary (Webpresso cache / WP_RTK_BIN), not bare PATH brew install.
458
+ return import("#tool-runtime/managed-rtk.js").then(({ isManagedRtkAvailable, resolveManagedRtkExecutable }) => {
459
+ const path = resolveManagedRtkExecutable();
460
+ if (path && isManagedRtkAvailable()) {
461
+ return { name: "rtk managed", ok: true, detail: path };
462
+ }
463
+ return { name: "rtk managed", ok: false, detail: RTK_INSTALL_HINT };
426
464
  });
427
465
  }
428
466
  async function probeHookBin(wpCli, hookName, checkStdin) {
@@ -1184,19 +1222,44 @@ async function checkCodexHost() {
1184
1222
  detail: `missing MCP entry (webpresso=${hasAgentKit})`,
1185
1223
  };
1186
1224
  }
1187
- const mcpPathFindings = auditCodexConfigPaths().filter((finding) => finding.location.startsWith("mcp_servers.webpresso."));
1188
- if (mcpPathFindings.length > 0) {
1189
- const detail = mcpPathFindings
1190
- .map((finding) => `${finding.location}: ${finding.path} (${finding.reason === "missing" ? "path does not exist" : "volatile versioned path"})`)
1191
- .join("; ");
1192
- return {
1193
- name: "Codex host integration",
1194
- ok: false,
1195
- detail: `webpresso MCP listed but launch paths are broken or rot-prone — ${detail}`,
1196
- };
1197
- }
1225
+ const legacy = classifyLegacyCodexWebpressoChannel();
1226
+ if (legacy)
1227
+ return { name: "Codex host integration", ok: false, detail: legacy };
1198
1228
  return { name: "Codex host integration", ok: true, detail: "webpresso MCP visible" };
1199
1229
  }
1230
+ /**
1231
+ * Codex must receive `webpresso` over exactly one channel — the bundled Codex
1232
+ * plugin. Report the retired user-global `[mcp_servers.webpresso]` block that
1233
+ * `wp setup` removes, and call out a double registration distinctly. A
1234
+ * user-authored block (a live `wp` outside every agent-kit root) is never
1235
+ * removed by setup, so it gets its own manual-action finding.
1236
+ */
1237
+ export function classifyLegacyCodexWebpressoChannel(configPath = defaultCodexConfigFilePath(), homeDir = process.env.HOME || homedir()) {
1238
+ const outcome = classifyCodexConfigWebpressoBlock(configPath);
1239
+ if (outcome === null || outcome.kind === "absent")
1240
+ return null;
1241
+ if (outcome.kind === "ambiguous") {
1242
+ return `${configPath} declares webpresso in a TOML spelling wp setup cannot rewrite; remove the [mcp_servers.webpresso] definition by hand — the bundled Codex plugin is the single delivery channel`;
1243
+ }
1244
+ if (outcome.kind === "preserved") {
1245
+ return `${configPath} declares a user-authored webpresso MCP server (${outcome.command ?? "no command"}); wp setup preserves it. Remove it by hand to use the bundled Codex plugin as the single delivery channel`;
1246
+ }
1247
+ const dualChannel = findInstalledCodexPluginMcpManifests(homeDir).length > 0;
1248
+ const prefix = dualChannel
1249
+ ? "webpresso is registered over BOTH delivery channels — the bundled Codex plugin AND a legacy"
1250
+ : "webpresso is still registered over the retired legacy";
1251
+ return `${prefix} [mcp_servers.webpresso] block in ${configPath} (command = ${outcome.command}); run \`wp setup\` — it removes the legacy block`;
1252
+ }
1253
+ function classifyCodexConfigWebpressoBlock(configPath) {
1254
+ try {
1255
+ if (!existsSync(configPath))
1256
+ return null;
1257
+ return pruneOwnedMcpServerBlock(readFileSync(configPath, "utf8"), WEBPRESSO_MCP_SERVER_NAME, isAgentKitOwnedWpCommand);
1258
+ }
1259
+ catch {
1260
+ return null;
1261
+ }
1262
+ }
1200
1263
  async function checkOpenCodeHost(cwd = process.cwd()) {
1201
1264
  const available = await runCommand("opencode", ["--version"]);
1202
1265
  if (!available.ok) {
@@ -1430,9 +1493,8 @@ export function checkPackagedHostArtifacts(cwd = process.cwd()) {
1430
1493
  const serverRecord = server && typeof server === "object" && !Array.isArray(server)
1431
1494
  ? server
1432
1495
  : null;
1433
- if (serverRecord?.command !== "${PLUGIN_ROOT}/bin/wp" ||
1434
- !stringArrayEquals(serverRecord.args, ["mcp"])) {
1435
- failures.push("Codex adapter MCP surface must expose direct webpresso server map launching ${PLUGIN_ROOT}/bin/wp mcp");
1496
+ if (serverRecord?.command !== "wp" || !stringArrayEquals(serverRecord.args, ["mcp"])) {
1497
+ failures.push("Codex adapter MCP surface must expose direct webpresso server map launching wp mcp");
1436
1498
  }
1437
1499
  }
1438
1500
  if (codexHooks) {
@@ -2138,6 +2200,13 @@ export async function runHooksDoctor(opts = {}) {
2138
2200
  const rtkCheck = await checkRtkOnPath(opts.cwd);
2139
2201
  if (rtkCheck)
2140
2202
  checks.push(rtkCheck);
2203
+ const freestandingRtk = checkFreestandingRtkSurface(opts.cwd);
2204
+ if (freestandingRtk)
2205
+ checks.push(freestandingRtk);
2206
+ // Non-mutating WARN only — never mutates ~/.claude; surfaces dual-rewriter risk.
2207
+ const globalFreestandingRtk = checkGlobalFreestandingRtkSurface();
2208
+ if (globalFreestandingRtk)
2209
+ checks.push(globalFreestandingRtk);
2141
2210
  if (shouldRunHostChecks(hostMode)) {
2142
2211
  for (const host of requestedHosts) {
2143
2212
  if (host === "codex") {
@@ -23,7 +23,7 @@ function routeRawMutatingGitWorktree(command) {
23
23
  return {
24
24
  action: "deny",
25
25
  tool: "wp_worktree",
26
- guidance: "Use the `wp_worktree` MCP tool (or `wp worktree` CLI when MCP is unavailable) instead — managed repositories may mutate linked worktrees only through agent-kit so owner bindings and the global registry stay consistent.",
26
+ guidance: "Use the `wp_worktree` MCP tool (or `wp worktree prune --all` / `wp worktree remove <target>` when MCP is unavailable) instead — managed repositories may mutate linked worktrees only through agent-kit so owner bindings and the global registry stay consistent. Both prune paths also run `git worktree prune`, so a hand-deleted worktree directory still releases the branch it claimed.",
27
27
  code: "GUARD_RAW_GIT_MUTATION",
28
28
  };
29
29
  }
@@ -296,11 +296,11 @@ const SAFE_PASSTHROUGH_PREFIXES = [
296
296
  const SANDBOX_PREFIXES = [
297
297
  {
298
298
  prefix: "grep",
299
- guidance: "Use wp_session_batch_execute for bounded shell gathering/search commands so output is capped, indexed, and recallable instead of flooding the transcript. If MCP is unavailable, use a bounded `rg -n ... | sed -n '1,120p'` excerpt instead of recursive raw grep output",
299
+ guidance: "Recursive/directory-wide grep is unbounded; narrow to explicit file target(s) (`grep -n pattern file`) to pass through directly. Otherwise use wp_session_batch_execute for bounded search so output is capped, indexed, and recallable instead of flooding the transcript — note wp_session_batch_execute runs argv directly (no shell) and cannot express `|`/`>`. If MCP is unavailable, use a bounded `rg -n ... | sed -n '1,120p'` excerpt instead of recursive raw grep output",
300
300
  },
301
301
  {
302
302
  prefix: "find",
303
- guidance: "Use wp_session_batch_execute for bounded shell gathering/search commands so output is capped, indexed, and recallable instead of flooding the transcript. If MCP is unavailable, use `rg --files ... | sed -n '1,120p'` or another explicit bounded listing",
303
+ guidance: "Use wp_session_batch_execute for bounded shell gathering/search commands so output is capped, indexed, and recallable instead of flooding the transcript — note wp_session_batch_execute runs argv directly (no shell) and cannot express `|`/`>`. If MCP is unavailable, use `rg --files ... | sed -n '1,120p'` or another explicit bounded listing",
304
304
  },
305
305
  {
306
306
  prefix: "cat",
@@ -324,7 +324,7 @@ const SANDBOX_PREFIXES = [
324
324
  },
325
325
  {
326
326
  prefix: "git log",
327
- guidance: "Use wp_session_batch_execute for bounded git log shell gathering so output is capped, indexed, and recallable. If MCP is unavailable, use explicit bounds such as `git log --oneline -n 50`",
327
+ guidance: "Add an explicit count bound (`-N`, `-n N`, or `--max-count=N`) to pass through directly — e.g. `git log --oneline -50`. Otherwise use wp_session_batch_execute for bounded git log shell gathering so output is capped, indexed, and recallable. If MCP is unavailable, use an explicit bound directly, e.g. `git log --oneline -n 50`",
328
328
  },
329
329
  {
330
330
  prefix: "git diff",
@@ -332,7 +332,7 @@ const SANDBOX_PREFIXES = [
332
332
  },
333
333
  {
334
334
  prefix: "git show",
335
- guidance: "Use wp_session_batch_execute or wp_session_execute for bounded git show gathering instead of raw large output. If MCP is unavailable, use `git show --stat` or a targeted excerpt",
335
+ guidance: "Add `--stat`/`--name-only`/`--name-status`/`--shortstat` to pass through directly — e.g. `git show --stat HEAD` or use wp_session_batch_execute or wp_session_execute for bounded gathering of the full patch",
336
336
  },
337
337
  {
338
338
  prefix: "vp run build",
@@ -782,6 +782,63 @@ const BOUNDED_GIT_DIFF = new RegExp(String.raw `^git\s+${SIMPLE_GIT_GLOBAL_OPTIO
782
782
  function isBoundedGitDiffRead(command) {
783
783
  return BOUNDED_GIT_DIFF.test(command);
784
784
  }
785
+ // `git log`/`git show` reads are bounded (small, predictable output) exactly when
786
+ // they carry an explicit count/summary bound the same way BOUNDED_GIT_DIFF already
787
+ // recognizes `--stat`/`--name-only`/etc. as bounded. Unbounded forms (bare `git log`,
788
+ // full-patch `git show <ref>`) still fall through to the SANDBOX_PREFIXES rules below.
789
+ const SIMPLE_GIT_LOG_PREFIX = new RegExp(String.raw `^git\s+${SIMPLE_GIT_GLOBAL_OPTION}log\b`, "u");
790
+ const GIT_LOG_COUNT_BOUND = /(?:^|\s)(?:-\d+|-n[=\s]?\d+|--max-count(?:=|\s+)\d+)(?:\s|$)/u;
791
+ function isBoundedGitLogRead(command) {
792
+ return SIMPLE_GIT_LOG_PREFIX.test(command) && GIT_LOG_COUNT_BOUND.test(command);
793
+ }
794
+ const BOUNDED_GIT_SHOW = new RegExp(String.raw `^git\s+${SIMPLE_GIT_GLOBAL_OPTION}show\b(?=[\s\S]*(?:--stat\b|--shortstat\b|--name-only\b|--name-status\b))(?![\s\S]*(?:--patch\b|-p\b))`, "u");
795
+ function isBoundedGitShowRead(command) {
796
+ return BOUNDED_GIT_SHOW.test(command);
797
+ }
798
+ // A grep read is bounded when it targets explicit file argument(s) (not a whole
799
+ // directory tree via -r/-R/--recursive/--include/--exclude*) and supplies at least
800
+ // one non-flag file target beyond the pattern itself. Recursive/directory-wide grep
801
+ // stays sandboxed since its output size is unpredictable.
802
+ const GREP_RECURSIVE_LONG_FLAGS = new Set([
803
+ "--recursive",
804
+ "--include",
805
+ "--exclude",
806
+ "--exclude-dir",
807
+ "--exclude-from",
808
+ ]);
809
+ function isBoundedGrepRead(command) {
810
+ const tokens = tokenizeCommand(command);
811
+ if (tokens[0] !== "grep")
812
+ return false;
813
+ const positionals = [];
814
+ let optionsEnded = false;
815
+ for (let index = 1; index < tokens.length; index += 1) {
816
+ const token = tokens[index];
817
+ if (!token)
818
+ continue;
819
+ if (!optionsEnded && token === "--") {
820
+ optionsEnded = true;
821
+ continue;
822
+ }
823
+ if (!optionsEnded && token !== "-" && token.startsWith("-")) {
824
+ const longFlag = token.split("=")[0] ?? token;
825
+ if (GREP_RECURSIVE_LONG_FLAGS.has(longFlag))
826
+ return false;
827
+ // Strip a trailing bundled numeric argument (e.g. the `2` in `-rA2` for
828
+ // `-r -A 2`) before checking for a bundled `r`/`R` — otherwise a
829
+ // recursive short flag bundled with a value-taking flag like `-A`/`-B`/
830
+ // `-C`/`-m` slips past undetected.
831
+ const shortCluster = token.slice(1).replace(/\d+$/u, "");
832
+ if (/^[A-Za-z]+$/u.test(shortCluster) && /[rR]/u.test(shortCluster))
833
+ return false;
834
+ continue;
835
+ }
836
+ positionals.push(token);
837
+ }
838
+ // First positional is the search pattern; at least one more explicit file
839
+ // target is required to count as a bounded (non-stdin, non-directory) read.
840
+ return positionals.length >= 2;
841
+ }
785
842
  function escapedRegexToken(value) {
786
843
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
787
844
  }
@@ -982,6 +1039,12 @@ export function routeCommand(command, _sessionId) {
982
1039
  }
983
1040
  if (isBoundedGitDiffRead(trimmed))
984
1041
  return { action: { action: "passthrough" } };
1042
+ if (isBoundedGitLogRead(trimmed))
1043
+ return { action: { action: "passthrough" } };
1044
+ if (isBoundedGitShowRead(trimmed))
1045
+ return { action: { action: "passthrough" } };
1046
+ if (isBoundedGrepRead(trimmed))
1047
+ return { action: { action: "passthrough" } };
985
1048
  // Explicit passthroughs (audits, safe git/nav commands)
986
1049
  for (const prefix of PASSTHROUGH_PREFIXES) {
987
1050
  if (matchesPrefix(trimmed, prefix))
@@ -0,0 +1,24 @@
1
+ /**
2
+ * RTK rewrite inside path-stable pretool-guard (no freestanding consumer hook).
3
+ *
4
+ * v1: exact `git status` → `<managed-rtk> git status` when managed rtk is available.
5
+ */
6
+ export type RtkRewriteDecision = {
7
+ readonly action: "rewrite";
8
+ readonly command: string;
9
+ } | {
10
+ readonly action: "noop";
11
+ readonly reason: string;
12
+ };
13
+ export type RtkPresenceProbe = () => boolean;
14
+ export type RtkExecutableResolver = () => string | null;
15
+ /**
16
+ * Decide whether to rewrite a Bash command through managed rtk.
17
+ */
18
+ export declare function decideRtkRewrite(command: string, isRtkPresent?: RtkPresenceProbe, resolveExecutable?: RtkExecutableResolver): RtkRewriteDecision;
19
+ /**
20
+ * POSIX single-quote wrap for absolute managed paths.
21
+ * Refuse NUL/newline/single-quote — those cannot be safely embedded in `'…'`.
22
+ * Double-quote wrapping is intentionally avoided ($ / ` / " injection).
23
+ */
24
+ export declare function shellQuoteExecutable(path: string): string | null;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * RTK rewrite inside path-stable pretool-guard (no freestanding consumer hook).
3
+ *
4
+ * v1: exact `git status` → `<managed-rtk> git status` when managed rtk is available.
5
+ */
6
+ import { isManagedRtkAvailable, resolveManagedRtkExecutable } from "#tool-runtime/managed-rtk.js";
7
+ const UNCERTAIN_SHELL_RE = /[\n\r|;&<>`$(){}[\]\\'"#*?]/u;
8
+ /**
9
+ * Decide whether to rewrite a Bash command through managed rtk.
10
+ */
11
+ export function decideRtkRewrite(command, isRtkPresent = isManagedRtkAvailable, resolveExecutable = resolveManagedRtkExecutable) {
12
+ if (command !== command.trim() || command.length === 0) {
13
+ return { action: "noop", reason: "uncertain-parse" };
14
+ }
15
+ if (UNCERTAIN_SHELL_RE.test(command)) {
16
+ return { action: "noop", reason: "uncertain-parse" };
17
+ }
18
+ // already managed absolute path or bare rtk prefix
19
+ if (command.startsWith("rtk ") || command.includes("/rtk ")) {
20
+ return { action: "noop", reason: "already-rtk" };
21
+ }
22
+ if (command !== "git status") {
23
+ return { action: "noop", reason: "not-eligible" };
24
+ }
25
+ if (!isRtkPresent()) {
26
+ return { action: "noop", reason: "rtk-missing" };
27
+ }
28
+ const executable = resolveExecutable();
29
+ if (!executable) {
30
+ return { action: "noop", reason: "rtk-missing" };
31
+ }
32
+ const bin = shellQuoteExecutable(executable);
33
+ if (!bin) {
34
+ return { action: "noop", reason: "rtk-missing" };
35
+ }
36
+ return { action: "rewrite", command: `${bin} git status` };
37
+ }
38
+ /**
39
+ * POSIX single-quote wrap for absolute managed paths.
40
+ * Refuse NUL/newline/single-quote — those cannot be safely embedded in `'…'`.
41
+ * Double-quote wrapping is intentionally avoided ($ / ` / " injection).
42
+ */
43
+ export function shellQuoteExecutable(path) {
44
+ if (path.length === 0 || /[\0\n\r']/.test(path))
45
+ return null;
46
+ // Unquoted only for the common managed-cache shape (no shell metacharacters).
47
+ if (/^[A-Za-z0-9_./:@+=,-]+$/.test(path))
48
+ return path;
49
+ return `'${path}'`;
50
+ }
@@ -11,6 +11,7 @@ import { VALIDATORS } from "./validators/index.js";
11
11
  import { isDirectEntrypoint } from "#hooks/shared/direct-entrypoint";
12
12
  import { optimizeToolInput, } from "./optimization.js";
13
13
  import { recordOptimizationEvent } from "./optimization-events.js";
14
+ import { decideRtkRewrite } from "./rtk-rewrite.js";
14
15
  const RED = "\x1b[31m";
15
16
  const YELLOW = "\x1b[33m";
16
17
  const DIM = "\x1b[2m";
@@ -256,6 +257,11 @@ export function processValidation(inputJson, options = {}) {
256
257
  process.exit(2);
257
258
  }
258
259
  if (command) {
260
+ const rtkRewrite = decideRtkRewrite(command);
261
+ if (rtkRewrite.action === "rewrite") {
262
+ writeUpdatedInput(input, rtkRewrite.command);
263
+ process.exit(0);
264
+ }
259
265
  const originalAction = routeCommand(command)?.action ?? { action: "passthrough" };
260
266
  const optimization = optimizeToolInput(input, originalAction, options.optimizationRules);
261
267
  if (optimization.action === "rewrite") {
@@ -850,7 +850,9 @@ const GIT_GLOBAL_RUN = `(?:-C\\s+${QUOTED_ARG}\\s+|-c\\s+${QUOTED_ARG}\\s+|--git
850
850
  // branch flags are allowed.
851
851
  const FORBIDDEN_SUBCOMMAND = "(commit\\b|switch\\b(?!\\s+(?:-h|--help)\\b)|checkout\\s+(?!--(?:\\s|$))(?:(?:\\S+\\s+)*)\\S|branch\\s+(?:(?!-|--)\\S|--track\\b|--no-track\\b|--create-reflog\\b|--force\\b|-f\\b|-c\\b|-C\\b|-m\\b|-M\\b))";
852
852
  const FORBIDDEN_OP = new RegExp(`\\bgit\\s+(${GIT_GLOBAL_RUN})${FORBIDDEN_SUBCOMMAND}`, "gu");
853
- const MAIN_OWNERSHIP_OP = new RegExp(`\\bgit\\s+(${GIT_GLOBAL_RUN})(switch\\b[^;&|(){}]*|checkout\\b[^;&|(){}]*|branch\\b[^;&|(){}]*|worktree\\s+add\\b[^;&|(){}]*)`, "gu");
853
+ // Verb-only: deliberately does NOT slurp trailing arguments itself (see
854
+ // mainOwnershipGitOps below for why a naive `[^;&|(){}]*` capture is unsafe).
855
+ const MAIN_OWNERSHIP_VERB = new RegExp(`\\bgit\\s+(${GIT_GLOBAL_RUN})(switch\\b|checkout\\b|branch\\b|worktree\\s+add\\b)`, "gu");
854
856
  function labelFor(subcommand) {
855
857
  if (subcommand.startsWith("commit"))
856
858
  return "git commit";
@@ -958,12 +960,69 @@ function mainOwnershipLabel(words) {
958
960
  return "git worktree add main";
959
961
  return null;
960
962
  }
963
+ /**
964
+ * Quote-aware segment bounds for `command`, split on the same unquoted
965
+ * `;&|(){}` control characters as {@link splitShellSegments} (kept in sync
966
+ * intentionally), but returning OFFSETS instead of trimmed substrings so a
967
+ * caller can slice the original command text for a match that falls inside a
968
+ * given segment.
969
+ */
970
+ function segmentBounds(command) {
971
+ const bounds = [];
972
+ let start = 0;
973
+ let quote = null;
974
+ let escaped = false;
975
+ for (let i = 0; i < command.length; i += 1) {
976
+ const ch = command[i];
977
+ if (ch === undefined)
978
+ continue;
979
+ if (escaped) {
980
+ escaped = false;
981
+ continue;
982
+ }
983
+ if (quote !== "'" && ch === "\\") {
984
+ escaped = true;
985
+ continue;
986
+ }
987
+ if ((ch === '"' || ch === "'") && (quote === null || quote === ch)) {
988
+ quote = quote === ch ? null : ch;
989
+ continue;
990
+ }
991
+ if (!quote && /[;&|(){}]/u.test(ch)) {
992
+ bounds.push({ start, end: i });
993
+ start = i + 1;
994
+ }
995
+ }
996
+ bounds.push({ start, end: command.length });
997
+ return bounds;
998
+ }
999
+ /**
1000
+ * Main-ownership candidate ops (`git switch|checkout|branch|worktree add`).
1001
+ *
1002
+ * MAIN_OWNERSHIP_VERB intentionally does NOT slurp trailing arguments itself
1003
+ * (unlike the pre-fix implementation) — a naive `[^;&|(){}]*` character class
1004
+ * is not quote-aware, so a read-only `git branch --format='%(refname:short)'`
1005
+ * has its captured subcommand text truncated mid-quote at the `(` inside the
1006
+ * quoted format string, producing an unterminated quote that shellWords()
1007
+ * reports as "ambiguous" and gets fail-closed blocked — purely because of a
1008
+ * paren inside its OWN quoted argument, not because it mutates anything.
1009
+ * Bounding the subcommand text to the enclosing quote-aware shell segment
1010
+ * (segmentBounds, sharing splitShellSegments' control-char set) fixes that
1011
+ * without weakening real-mutation detection: genuinely ambiguous syntax
1012
+ * (nested shells, unresolved substitutions, unterminated quotes) still
1013
+ * produces "ambiguous" and fails closed exactly as before.
1014
+ */
961
1015
  function mainOwnershipGitOps(command) {
962
1016
  if (!/\bgit\b/u.test(command))
963
1017
  return [];
964
1018
  const ops = [];
965
- for (const m of command.matchAll(MAIN_OWNERSHIP_OP)) {
966
- const subcommand = m[2] ?? "";
1019
+ const segments = segmentBounds(command);
1020
+ for (const m of command.matchAll(MAIN_OWNERSHIP_VERB)) {
1021
+ const verb = m[2] ?? "";
1022
+ const verbStart = (m.index ?? 0) + m[0].length - verb.length;
1023
+ const segment = segments.find((s) => verbStart >= s.start && verbStart < s.end) ??
1024
+ { start: verbStart, end: command.length };
1025
+ const subcommand = command.slice(verbStart, segment.end);
967
1026
  const words = shellWords(subcommand);
968
1027
  if (words === "ambiguous") {
969
1028
  ops.push({ label: "git main", globals: m[1] ?? "", index: m.index ?? 0 });
@@ -109,7 +109,7 @@ function hasValidCodexPluginArtifacts(repoRoot) {
109
109
  const hookMap = hooks?.hooks;
110
110
  return (plugin?.mcpServers === "./.mcp.json" &&
111
111
  plugin.hooks === "./hooks/hooks.json" &&
112
- serverRecord?.command === "${PLUGIN_ROOT}/bin/wp" &&
112
+ serverRecord?.command === "wp" &&
113
113
  stringArrayEquals(serverRecord.args, ["mcp"]) &&
114
114
  hookMap !== null &&
115
115
  typeof hookMap === "object" &&
@@ -94,21 +94,49 @@ export async function applyLocalBlueprintTransition(input) {
94
94
  mkdirSync(path.dirname(found.shape === "flat" ? destination.flat : destination.directory), {
95
95
  recursive: true,
96
96
  });
97
+ const sourcePath = found.shape === "flat" ? overviewPath : found.dir;
98
+ const destinationPath = found.shape === "flat" ? destination.flat : destination.directory;
97
99
  let finalOverviewPath = overviewPath;
100
+ let renamed = false;
98
101
  if (found.state !== to_state) {
99
102
  const { renameSync } = await import("node:fs");
100
- renameSync(found.shape === "flat" ? overviewPath : found.dir, found.shape === "flat" ? destination.flat : destination.directory);
103
+ renameSync(sourcePath, destinationPath);
104
+ renamed = true;
101
105
  finalOverviewPath = found.shape === "flat" ? destination.flat : destination.folder;
102
106
  }
103
- writeFileAtomic(finalOverviewPath, updated, "utf8");
104
- await reIngest(projectCwd);
105
- const refreshed = getCurrentProjectBlueprint(projectCwd, slug);
106
- if (!refreshed.blueprint) {
107
- throw new Error(`Blueprint "${slug}" did not appear in the projection after transition`);
107
+ // Atomicity: everything from here on can still fail (re-ingest, projection
108
+ // lookup). The rename above may already have happened, and the frontmatter
109
+ // write below always happens before those checks run. Roll BOTH back
110
+ // before rethrowing so a failed transition never leaves the blueprint
111
+ // stranded in the destination lifecycle directory, nor leaves stale
112
+ // frontmatter (bumped last_updated/progress/status) on an otherwise
113
+ // unmoved file — either the transition fully lands (moved + content
114
+ // updated + re-ingested + visible in the projection) or the working tree
115
+ // is restored to exactly what it was before this call.
116
+ try {
117
+ writeFileAtomic(finalOverviewPath, updated, "utf8");
118
+ await reIngest(projectCwd);
119
+ const refreshed = getCurrentProjectBlueprint(projectCwd, slug);
120
+ if (!refreshed.blueprint) {
121
+ throw new Error(`Blueprint "${slug}" did not appear in the projection after transition`);
122
+ }
123
+ return {
124
+ blueprint: refreshed.blueprint,
125
+ overviewPath: finalOverviewPath,
126
+ fromState: found.state,
127
+ };
128
+ }
129
+ catch (error) {
130
+ try {
131
+ if (renamed) {
132
+ const { renameSync } = await import("node:fs");
133
+ renameSync(destinationPath, sourcePath);
134
+ }
135
+ writeFileAtomic(overviewPath, markdown, "utf8");
136
+ }
137
+ catch (rollbackError) {
138
+ throw new Error(`Transition to "${to_state}" failed and automatic rollback also failed; the blueprint may be left ${renamed ? `moved at ${destinationPath}` : "with partially-updated frontmatter"}. Original error: ${error instanceof Error ? error.message : String(error)}. Rollback error: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
139
+ }
140
+ throw error;
108
141
  }
109
- return {
110
- blueprint: refreshed.blueprint,
111
- overviewPath: finalOverviewPath,
112
- fromState: found.state,
113
- };
114
142
  }
@@ -2,13 +2,20 @@ import type { ProjectResolver } from "#project-resolver.js";
2
2
  import type { ToolHandlerResult } from "#mcp/auto-discover.js";
3
3
  /**
4
4
  * MCP wrapper around the SAME reviews.md-ledger writer `logReviewEntry` that
5
- * (an unwired) `wp review log` uses, plus the matching frontmatter `approvals:`
6
- * entry that `collectTrackedReviewProvenance` (the promotion approval gate)
7
- * actually reads. This is the tracked/frontmatter provenance path — the same
8
- * trust model a hand-authored `approvals:` block already had (the reviewer id
9
- * is caller-asserted, verified only by the referenced artifact/reviews.md
10
- * entry being tracked in git) — this tool just makes that existing path
11
- * reachable from MCP instead of a raw Write/Edit or a Bash/python workaround.
5
+ * (an unwired) `wp review log` uses. For NON-approval verdicts it also appends
6
+ * the matching frontmatter `approvals:` entry, so rejection attribution stays
7
+ * visible where readers already look. This is the tracked/frontmatter provenance
8
+ * path — the same trust model a hand-authored `approvals:` block already had (the
9
+ * reviewer id is caller-asserted, verified only by the referenced
10
+ * artifact/reviews.md entry being tracked in git) — this tool just makes that
11
+ * existing path reachable from MCP instead of a raw Write/Edit or a Bash/python
12
+ * workaround.
13
+ *
14
+ * It never writes a POLICY-APPROVAL entry (approve / approve-with-nits) into
15
+ * frontmatter. `collectTrackedReviewProvenance` — the legacy promotion leg — only
16
+ * counts policy approvals and never reads or hashes the blueprint body, so such
17
+ * an entry would be gate authority that survives an arbitrary rewrite of the
18
+ * plan. See `LEGACY_APPROVAL_LEG_ANCHOR_COMMIT` in `#lifecycle/rollout-anchor.js`.
12
19
  *
13
20
  * A policy-approval verdict (approve / approve-with-nits) REQUIRES
14
21
  * reviewer==="human" — matching `logReviewEvent`'s existing v2-ledger