@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
@@ -30,13 +30,20 @@ const reviewLogSchema = z.object({
30
30
  });
31
31
  /**
32
32
  * MCP wrapper around the SAME reviews.md-ledger writer `logReviewEntry` that
33
- * (an unwired) `wp review log` uses, plus the matching frontmatter `approvals:`
34
- * entry that `collectTrackedReviewProvenance` (the promotion approval gate)
35
- * actually reads. This is the tracked/frontmatter provenance path — the same
36
- * trust model a hand-authored `approvals:` block already had (the reviewer id
37
- * is caller-asserted, verified only by the referenced artifact/reviews.md
38
- * entry being tracked in git) — this tool just makes that existing path
39
- * reachable from MCP instead of a raw Write/Edit or a Bash/python workaround.
33
+ * (an unwired) `wp review log` uses. For NON-approval verdicts it also appends
34
+ * the matching frontmatter `approvals:` entry, so rejection attribution stays
35
+ * visible where readers already look. This is the tracked/frontmatter provenance
36
+ * path — the same trust model a hand-authored `approvals:` block already had (the
37
+ * reviewer id is caller-asserted, verified only by the referenced
38
+ * artifact/reviews.md entry being tracked in git) — this tool just makes that
39
+ * existing path reachable from MCP instead of a raw Write/Edit or a Bash/python
40
+ * workaround.
41
+ *
42
+ * It never writes a POLICY-APPROVAL entry (approve / approve-with-nits) into
43
+ * frontmatter. `collectTrackedReviewProvenance` — the legacy promotion leg — only
44
+ * counts policy approvals and never reads or hashes the blueprint body, so such
45
+ * an entry would be gate authority that survives an arbitrary rewrite of the
46
+ * plan. See `LEGACY_APPROVAL_LEG_ANCHOR_COMMIT` in `#lifecycle/rollout-anchor.js`.
40
47
  *
41
48
  * A policy-approval verdict (approve / approve-with-nits) REQUIRES
42
49
  * reviewer==="human" — matching `logReviewEvent`'s existing v2-ledger
@@ -143,22 +150,39 @@ export async function handleReviewLog(projectResolver, cwd, raw) {
143
150
  ...(rev ? { rev } : {}),
144
151
  ...(note ? { note } : {}),
145
152
  });
146
- const overviewRaw = readFileSync(found.path, "utf8");
147
- const parsed = matter(overviewRaw);
148
- const existingApprovals = Array.isArray(parsed.data.approvals) ? parsed.data.approvals : [];
149
- const approvalEntry = {
150
- reviewer: entry.reviewer,
151
- verdict: entry.verdict,
152
- evidence: entry.evidence ?? "reviews.md",
153
- artifact: entry.artifact ?? normalizedArtifactPath,
154
- ...(entry.commit ? { commit: entry.commit } : {}),
155
- ...(entry.rev ? { rev: entry.rev } : {}),
156
- };
157
- const updatedData = { ...parsed.data, approvals: [...existingApprovals, approvalEntry] };
158
- const rendered = matter.stringify(parsed.content, updatedData);
159
- writeFileAtomic(found.path, rendered, { encoding: "utf8" });
153
+ // Policy-approval verdicts are NOT projected into frontmatter `approvals:`.
154
+ // That field feeds `collectTrackedReviewProvenance`, the legacy promotion leg
155
+ // that carries no content binding at all: it matches a reviews.md record and
156
+ // a tracked artifact but never reads or hashes the blueprint body, and the
157
+ // gate supplies no `targetHash`. Writing an approval there would mint
158
+ // authority that survives an arbitrary rewrite of the plan. Non-approval
159
+ // verdicts (reject / no-verdict) are still recorded, because the legacy leg
160
+ // only ever counts policy approvals -- they are attribution, never authority.
161
+ //
162
+ // Nothing is lost: the approval is fully recorded in reviews.md and in the
163
+ // tracked artifact. Satisfying the promotion gate still requires a converged
164
+ // `wp review gate` run (content-addressed v2 ledger) or `wp review override`.
165
+ const mintsGateAuthority = isPolicyApprovalVerdict(entry.verdict);
166
+ if (!mintsGateAuthority) {
167
+ const overviewRaw = readFileSync(found.path, "utf8");
168
+ const parsed = matter(overviewRaw);
169
+ const existingApprovals = Array.isArray(parsed.data.approvals) ? parsed.data.approvals : [];
170
+ const approvalEntry = {
171
+ reviewer: entry.reviewer,
172
+ verdict: entry.verdict,
173
+ evidence: entry.evidence ?? "reviews.md",
174
+ artifact: entry.artifact ?? normalizedArtifactPath,
175
+ ...(entry.commit ? { commit: entry.commit } : {}),
176
+ ...(entry.rev ? { rev: entry.rev } : {}),
177
+ };
178
+ const updatedData = { ...parsed.data, approvals: [...existingApprovals, approvalEntry] };
179
+ const rendered = matter.stringify(parsed.content, updatedData);
180
+ writeFileAtomic(found.path, rendered, { encoding: "utf8" });
181
+ }
160
182
  return finishPayload({
161
- summary: `Logged review ${entry.id} -> ${entry.blueprintSlug} and recorded approvals frontmatter`,
183
+ summary: mintsGateAuthority
184
+ ? `Logged review ${entry.id} -> ${entry.blueprintSlug} in reviews.md (no frontmatter approval recorded: an approval verdict must bind to reviewed content via wp review gate)`
185
+ : `Logged review ${entry.id} -> ${entry.blueprintSlug} and recorded approvals frontmatter`,
162
186
  event_id: entry.id,
163
187
  blueprint_slug: entry.blueprintSlug,
164
188
  reviewer: entry.reviewer,
@@ -289,7 +289,7 @@ export async function registerBlueprintTools(registrar, cwd, projectResolver = c
289
289
  next_action: nextActionOutputSchema,
290
290
  },
291
291
  }, (r) => handleTaskVerify(projectResolver, cwd, r), { title: "Blueprint Task Verify", destructiveHint: false, openWorldHint: false });
292
- registrar.registerTool("wp_blueprint_review_log", 'Record a tracked review-ledger entry for a blueprint: writes the given artifact content under blueprints/<state>/<slug>/review-artifacts/, appends the matching structured entry to reviews.md (via the same logReviewEntry writer, distinct from the CLI\'s own `wp review log` subcommand), and appends the corresponding `approvals:` frontmatter entry to _overview.md that the legacy promotion approval path (`collectTrackedReviewProvenance`) reads. A `verdict` of approve or approve-with-nits REQUIRES reviewer="human" this tool cannot mint a claude/codex/grok/OpenCode-Go frontmatter approval; satisfying the strong-reviewer promotion gate still requires either a converged `wp review gate` run or a maintainer directly authoring that frontmatter. Use this tool for reject/no-verdict entries from any reviewer, or a human-adjudicated approve/approve-with-nits — including the audit-trail note when `wp review gate` fails to converge. Returns { event_id, blueprint_slug, reviewer, verdict, status, artifact_path }.', {
292
+ registrar.registerTool("wp_blueprint_review_log", 'Record a tracked review-ledger entry for a blueprint: writes the given artifact content under blueprints/<state>/<slug>/review-artifacts/ and appends the matching structured entry to reviews.md (via the same logReviewEntry writer, distinct from the CLI\'s own `wp review log` subcommand). For reject/no-verdict it ALSO appends the corresponding `approvals:` frontmatter entry to _overview.md, so rejection attribution stays visible. It NEVER writes an approve/approve-with-nits entry into `approvals:`: that field feeds the legacy promotion path (`collectTrackedReviewProvenance`), which never reads or hashes the blueprint body, so an approval recorded there would keep satisfying the gate after the plan was rewritten. A `verdict` of approve or approve-with-nits additionally REQUIRES reviewer="human". Satisfying the promotion gate requires a converged `wp review gate` run (content-addressed v2 ledger) or `wp review override`. Use this tool for reject/no-verdict entries from any reviewer, or to record a human-adjudicated approve/approve-with-nits in reviews.md — including the audit-trail note when `wp review gate` fails to converge. Returns { event_id, blueprint_slug, reviewer, verdict, status, artifact_path }.', {
293
293
  type: "object",
294
294
  properties: {
295
295
  project_id: { type: "string" },
@@ -1 +1,23 @@
1
+ /**
2
+ * Redaction for the shared read-only MCP path (`wp_pr_status`, `wp_pr_wait`,
3
+ * `wp_run_wait` error tails, `_readonly-ops`, worker tails, hook logs — 11
4
+ * consumers). Output reaches agent context, session memory, and committed
5
+ * review artifacts in a public repository, so a credential surviving here is
6
+ * irreversible and externally exploitable.
7
+ *
8
+ * Three properties are load-bearing; changing any of them breaks a consumer:
9
+ *
10
+ * 1. {@link mask} is **partial disclosure by design** — first and last two
11
+ * characters survive above six, and the mask is deterministic.
12
+ * `run-wait.ts` redacts both sides of its SHA comparison and relies on
13
+ * identical input producing identical output.
14
+ * 2. The generic 40+-character rule masks full commit SHAs as a side effect,
15
+ * compensated downstream by that same mask-aware equality. Do not "fix" it
16
+ * here without updating those consumers.
17
+ * 3. **Every quantifier is bounded.** Callers pass unbounded log text —
18
+ * `run-wait.ts` redacts the whole tail *before* clipping it to 2000 bytes —
19
+ * so an unbounded `[A-Za-z0-9_]*` around a keyword alternation backtracks
20
+ * quadratically and a hostile CI log can stall the MCP path for tens of
21
+ * seconds. Keep the `{0,64}` bounds.
22
+ */
1
23
  export declare function redactText(value: string | undefined): string | undefined;
@@ -1,22 +1,128 @@
1
- const KEY_PATTERNS = [
2
- /\b(?:GH_PACKAGES_TOKEN|GITHUB_TOKEN|GITHUB_PAT|NEON_API_KEY(?:_PLATFORM)?|CHEF_CI_TOKEN)\b\s*=\s*([^\s]+)/giu,
1
+ /**
2
+ * Redaction for the shared read-only MCP path (`wp_pr_status`, `wp_pr_wait`,
3
+ * `wp_run_wait` error tails, `_readonly-ops`, worker tails, hook logs — 11
4
+ * consumers). Output reaches agent context, session memory, and committed
5
+ * review artifacts in a public repository, so a credential surviving here is
6
+ * irreversible and externally exploitable.
7
+ *
8
+ * Three properties are load-bearing; changing any of them breaks a consumer:
9
+ *
10
+ * 1. {@link mask} is **partial disclosure by design** — first and last two
11
+ * characters survive above six, and the mask is deterministic.
12
+ * `run-wait.ts` redacts both sides of its SHA comparison and relies on
13
+ * identical input producing identical output.
14
+ * 2. The generic 40+-character rule masks full commit SHAs as a side effect,
15
+ * compensated downstream by that same mask-aware equality. Do not "fix" it
16
+ * here without updating those consumers.
17
+ * 3. **Every quantifier is bounded.** Callers pass unbounded log text —
18
+ * `run-wait.ts` redacts the whole tail *before* clipping it to 2000 bytes —
19
+ * so an unbounded `[A-Za-z0-9_]*` around a keyword alternation backtracks
20
+ * quadratically and a hostile CI log can stall the MCP path for tens of
21
+ * seconds. Keep the `{0,64}` bounds.
22
+ */
23
+ /** Key-name fragments that advertise a secret. */
24
+ const SECRET_WORD = "PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?";
25
+ /**
26
+ * Optional opening quote OUTSIDE the capture, then the value up to the first
27
+ * closing delimiter.
28
+ *
29
+ * Both halves matter. Excluding quotes from the class stops a greedy capture
30
+ * pulling the closing quote into the mask (`sk***6"` instead of `sk***56`).
31
+ * Consuming an optional *opening* quote is what keeps `KEY="secret"` matching
32
+ * at all — omitting it makes the `+` match zero characters, the pattern fail,
33
+ * and the most common real-world shape (quoted shell/JSON/YAML values) sail
34
+ * through completely unredacted.
35
+ */
36
+ // `\x60` is the backtick: a literal one cannot appear inside a String.raw
37
+ // template, and `\`` is an invalid escape under the `u` flag.
38
+ const QUOTED_VALUE = String.raw `["'\x60]?([^\s"'\x60,;)\]}\[]+)`;
39
+ /** As above, with a length floor — see the `:` rule below. */
40
+ const QUOTED_VALUE_LONG = String.raw `["'\x60]?([^\s"'\x60,;)\]}\[]{12,})`;
41
+ /**
42
+ * Optional closing quote on the KEY, before the separator. JSON and quoted-key
43
+ * YAML put it there — `{"password":"…"}` — and without this the separator match
44
+ * fails on the key's own closing quote and the whole line sails through.
45
+ */
46
+ const KEY_CLOSE = String.raw `["']?[ \t]*`;
47
+ /**
48
+ * Credential value for the keyword rules (`Bearer`, `Basic`, `Cookie`), which
49
+ * carry no assignment signal at all — and whose keywords are ordinary English
50
+ * words. Without a floor, `Basic checks passed` becomes
51
+ * `Basic [REDACTED] passed`, and that reaches users: `pr-upsert.ts` pushes PR
52
+ * **titles** through this function, so "Add Bearer token support" would render
53
+ * corrupted.
54
+ *
55
+ * So the value must be ≥12 characters AND contain at least one uppercase
56
+ * letter, digit or base64 punctuation — enough to separate `dXNlcjpwYXNzd29yZA==`
57
+ * from `authentication`.
58
+ *
59
+ * The keyword rules therefore drop the `i` flag and spell each casing out.
60
+ * Under `i`, `[A-Z]` also matches lowercase, so this lookahead would be
61
+ * silently satisfied by any long lowercase word and the check would do nothing.
62
+ */
63
+ const KEYWORD_CRED = String.raw `["'\x60]?((?:(?=[^\s"'\x60,;)\]}\[]*[A-Z0-9+/=_-])[^\s"'\x60,;)\]}\[]{12,}|[^\s"'\x60,;)\]}\[]{24,}))`;
64
+ /**
65
+ * Patterns whose **first capture group** is the secret. Only that group is
66
+ * masked, so the header name, config key or URL host stays readable — a
67
+ * redactor that destroys the line it protects gets routed around.
68
+ */
69
+ const CAPTURING_PATTERNS = [
70
+ // Originally-named credential env assignments.
71
+ new RegExp(String.raw `\b(?:GH_PACKAGES_TOKEN|GITHUB_TOKEN|GITHUB_PAT|NEON_API_KEY(?:_PLATFORM)?|CHEF_CI_TOKEN)\b` +
72
+ KEY_CLOSE +
73
+ String.raw `=[ \t]*` +
74
+ QUOTED_VALUE, "giu"),
75
+ // Generic secret-named key with `=`. Assignment is an unambiguous signal, so
76
+ // no length floor: `PASSWORD=x` should mask even when short.
77
+ new RegExp(String.raw `\b[A-Za-z0-9_]{0,64}(?:${SECRET_WORD})[A-Za-z0-9_]{0,64}` +
78
+ KEY_CLOSE +
79
+ String.raw `=[ \t]*` +
80
+ QUOTED_VALUE, "giu"),
81
+ // Generic secret-named key with `:`. Deliberately stricter than `=`, because
82
+ // `:` is overloaded across YAML, tables, markdown and plain prose — without a
83
+ // floor this masks `tokens: 512`, `secret: true`, `credentials: verified for
84
+ // 3 users` and `**Token:** an opaque handle`, corrupting ordinary CI output.
85
+ // A 12-character floor admits real secrets while excluding those.
86
+ new RegExp(String.raw `\b[A-Za-z0-9_]{0,64}(?:${SECRET_WORD})[A-Za-z0-9_]{0,64}` +
87
+ KEY_CLOSE +
88
+ String.raw `:[ \t]*` +
89
+ QUOTED_VALUE_LONG, "giu"),
90
+ // `Authorization: Bearer <token>` / bare `Bearer <token>`. Casings spelled
91
+ // out because `KEYWORD_CRED`'s lookahead is case-sensitive by necessity.
92
+ new RegExp(String.raw `\b(?:Bearer|bearer|BEARER)\s+` + KEYWORD_CRED, "gu"),
93
+ // `Authorization: Basic <base64>` — same header family, and the base64 is
94
+ // usually under the generic 40-character floor.
95
+ new RegExp(String.raw `\b(?:Basic|basic|BASIC)\s+` + KEYWORD_CRED, "gu"),
96
+ // Session cookies: the cookie NAME (`session`, `sid`) never matches a
97
+ // secret-word rule, so the value needs its own case or session tokens ride
98
+ // through CI and proxy logs intact. Anchoring on `name=` both avoids matching
99
+ // prose after a bare `Cookie:` and keeps the cookie name readable in output.
100
+ new RegExp(String.raw `\b(?:Set-|set-|SET-)?(?:Cookie|cookie|COOKIE)\s*:\s*[^\s=;]{1,64}=` + KEYWORD_CRED, "gu"),
101
+ // Credential-bearing URL userinfo: `https://user:pass@host/…`. Masking the
102
+ // whole userinfo keeps the host, which is what makes the log useful.
103
+ /:\/\/([^\s/@]+)@/gu,
3
104
  ];
4
- const TOKEN_PATTERNS = [
105
+ /** Patterns where the entire match is the secret. */
106
+ const WHOLE_MATCH_PATTERNS = [
5
107
  /\bghp_[A-Za-z0-9]{20,}\b/gu,
6
108
  /\b(?:cf|sk|tok)_[A-Za-z0-9_-]{16,}\b/gu,
109
+ // JWTs, whose segments routinely fall under the generic 40-character floor.
110
+ /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/gu,
7
111
  /\b[A-Za-z0-9+/_-]{40,}\b/gu,
8
112
  ];
9
113
  export function redactText(value) {
10
114
  if (!value)
11
115
  return value;
12
116
  let output = value;
13
- for (const pattern of KEY_PATTERNS) {
14
- output = output.replace(pattern, (whole, token) => {
15
- const trimmed = String(token ?? "").trim();
16
- return whole.includes("=") ? whole.replace(trimmed, mask(trimmed)) : mask(trimmed);
117
+ for (const pattern of CAPTURING_PATTERNS) {
118
+ output = output.replace(pattern, (whole, secret) => {
119
+ const trimmed = String(secret ?? "").trim();
120
+ if (trimmed.length === 0)
121
+ return whole;
122
+ return whole.replace(trimmed, mask(trimmed));
17
123
  });
18
124
  }
19
- for (const pattern of TOKEN_PATTERNS) {
125
+ for (const pattern of WHOLE_MATCH_PATTERNS) {
20
126
  output = output.replace(pattern, (token) => mask(token));
21
127
  }
22
128
  return output;
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { resolveManagedRtkExecutable } from "#tool-runtime/managed-rtk.js";
2
3
  import { createSummaryOutputSchema, createSummaryResult } from "./_shared/result.js";
3
4
  import { readonlyOpsBaseSchema, resolveReadonlyCwd, runReadonlyCommand } from "./_readonly-ops.js";
4
5
  const inputSchema = readonlyOpsBaseSchema
@@ -19,8 +20,12 @@ const outputSchema = createSummaryOutputSchema({
19
20
  }),
20
21
  });
21
22
  function commandFor(input) {
22
- if (input.source === "rtk")
23
- return { command: "rtk", args: ["gain", "--format", "json"] };
23
+ if (input.source === "rtk") {
24
+ const rtkBin = resolveManagedRtkExecutable();
25
+ if (!rtkBin)
26
+ return null;
27
+ return { command: rtkBin, args: ["gain", "--format", "json"] };
28
+ }
24
29
  return { command: "./bin/wp", args: input.sessions ? ["gain", "--sessions"] : ["gain"] };
25
30
  }
26
31
  const tool = {
@@ -39,6 +44,27 @@ const tool = {
39
44
  const input = inputSchema.parse(raw ?? {});
40
45
  const cwd = resolveReadonlyCwd(input);
41
46
  const command = commandFor(input);
47
+ if (command === null) {
48
+ return createSummaryResult({
49
+ passed: false,
50
+ summary: "managed RTK not available (wp setup --with rtk or WP_RTK_BIN)",
51
+ exitCode: 1,
52
+ counts: { commandCount: 0, passedCount: 0, failedCount: 1 },
53
+ details: {
54
+ cwd,
55
+ source: input.source,
56
+ format: input.format,
57
+ sessions: input.sessions,
58
+ command: {},
59
+ result: { error: "managed_rtk_missing" },
60
+ },
61
+ rawOutput: "",
62
+ truncated: false,
63
+ timedOut: false,
64
+ aborted: false,
65
+ warnings: ["managed RTK binary missing"],
66
+ });
67
+ }
42
68
  const result = await runReadonlyCommand("gain", command.command, command.args, {
43
69
  cwd,
44
70
  timeoutMs: input.timeoutMs,
@@ -15,6 +15,7 @@ import { gitBranchExists, listEntries, resolveNewWorktreeTarget, resolveWorktree
15
15
  import { deriveRepoNamespace, resolveKnownManagedWorktreeRoots, resolveWorktreeRoot, } from "#worktrees/location.js";
16
16
  import { readRepoOriginUrl, repoManagedRoot } from "#worktrees/manager.js";
17
17
  import { buildWorktreeInventory, canonicalizeWorktreePath, isManagedWorktreePath, resolveRepoIdentity, } from "#worktrees/identity.js";
18
+ import { isStaleWorktreeRegistration, pruneStaleGitWorktreeMetadata, } from "#worktrees/git-metadata.js";
18
19
  import { findRegistryCandidatesByPath, pruneStaleWorktreeRegistryEntries, removeWorktreeRegistryEntries, repoScopedPathPredicate, upsertWorktreeRegistryEntry, } from "#worktrees/registry.js";
19
20
  const ACTIONS = ["list", "root", "new", "remove", "prune", "refresh"];
20
21
  const MUTATING_ACTIONS = new Set(["new", "remove", "prune", "refresh"]);
@@ -105,6 +106,53 @@ function isRegisteredManagedWorktree(resolvedPath, _context) {
105
106
  // a legacy-root worktree registered by an earlier binary authorizes removal.
106
107
  return findRegistryCandidatesByPath(canonicalizeWorktreePath(resolvedPath)).length > 0;
107
108
  }
109
+ /**
110
+ * A stale registration may already have lost its registry entry to an earlier
111
+ * prune, so registration alone cannot gate reconciling it. Fall back to the
112
+ * managed-root boundary, which still keeps unmanaged worktrees protected.
113
+ */
114
+ function isRemovableManagedTarget(resolved, context, stale) {
115
+ if (isRegisteredManagedWorktree(resolved, context))
116
+ return true;
117
+ if (!stale)
118
+ return false;
119
+ return isManagedWorktreePath(canonicalizeWorktreePath(resolved), resolveKnownManagedWorktreeRoots());
120
+ }
121
+ /** Reconcile git + registry for a worktree whose directory is already gone. */
122
+ function clearStaleRegistration(input, repoRoot, resolved, entries, entry) {
123
+ const gitPruned = pruneStaleGitWorktreeMetadata(repoRoot, entries);
124
+ if (gitPruned.error) {
125
+ return {
126
+ passed: false,
127
+ summary: `wp_worktree remove failed: git worktree prune failed: ${gitPruned.error}`,
128
+ action: input.action,
129
+ executed: false,
130
+ warnings: ["git_worktree_prune_failed"],
131
+ nextAction: "Inspect the repository's git worktree admin records and retry.",
132
+ };
133
+ }
134
+ const warnings = [];
135
+ try {
136
+ removeWorktreeRegistryEntries(repoScopedPathPredicate(resolved, { authoritativeRepoKey: resolveRepoIdentity(repoRoot) }));
137
+ }
138
+ catch {
139
+ warnings.push("registry_cleanup_failed");
140
+ }
141
+ return {
142
+ passed: true,
143
+ summary: `Cleared stale worktree registration ${resolved} (directory already gone)`,
144
+ action: input.action,
145
+ executed: true,
146
+ removed: {
147
+ path: resolved,
148
+ ...(entry?.branch ? { branch: entry.branch.replace("refs/heads/", "") } : {}),
149
+ },
150
+ warnings,
151
+ ...(warnings.includes("registry_cleanup_failed")
152
+ ? { nextAction: "Run wp_worktree prune with execute:true to repair stale registry metadata." }
153
+ : {}),
154
+ };
155
+ }
108
156
  function refreshManagedEntries(repoRoot) {
109
157
  const { originUrl, repoNamespace } = managedContext(repoRoot);
110
158
  const knownRoots = resolveKnownManagedWorktreeRoots();
@@ -234,6 +282,7 @@ function handleRemove(input, repoRoot) {
234
282
  }
235
283
  const entry = entries.find((candidate) => candidate.path === resolved);
236
284
  const context = managedContext(repoRoot);
285
+ const stale = isStaleWorktreeRegistration(entry, resolved, existsSync);
237
286
  if (resolved === repoRoot) {
238
287
  return {
239
288
  passed: false,
@@ -243,7 +292,7 @@ function handleRemove(input, repoRoot) {
243
292
  warnings: ["main_checkout_protected"],
244
293
  };
245
294
  }
246
- if (!isRegisteredManagedWorktree(resolved, context)) {
295
+ if (!isRemovableManagedTarget(resolved, context, stale)) {
247
296
  return {
248
297
  passed: false,
249
298
  summary: `wp_worktree remove refused: ${resolved} is not a registered managed worktree for this repository`,
@@ -263,6 +312,10 @@ function handleRemove(input, repoRoot) {
263
312
  nextAction: "Unlock the worktree explicitly with git after confirming it is safe, then retry.",
264
313
  };
265
314
  }
315
+ // Checked before the dirty gate: `git status` fails in a missing directory,
316
+ // which would otherwise be reported as "uncommitted changes".
317
+ if (stale)
318
+ return clearStaleRegistration(input, repoRoot, resolved, entries, entry);
266
319
  if (isDirty(resolved)) {
267
320
  return {
268
321
  passed: false,
@@ -368,6 +421,10 @@ const tool = {
368
421
  warnings: [],
369
422
  }, false);
370
423
  }
424
+ // Reconcile git's own admin records FIRST: a hand-deleted worktree
425
+ // directory leaves a record that still claims its branch, and it would
426
+ // otherwise read as live below and pin its registry entry in place too.
427
+ const gitPruned = pruneStaleGitWorktreeMetadata(repoRoot, listEntries(repoRoot));
371
428
  // Repository-scoped, three-state prune across every known root: only THIS
372
429
  // repo's entries proven stale against a COMPLETE git-live inventory drop;
373
430
  // unknown, other-repo, and live-legacy entries are retained. `existsSync`
@@ -377,12 +434,17 @@ const tool = {
377
434
  inventory,
378
435
  scope: { authoritativeRepoKey: resolveRepoIdentity(repoRoot) },
379
436
  });
437
+ const gitSummary = gitPruned.error
438
+ ? ""
439
+ : ` and ${gitPruned.prunedPaths.length} stale git worktree registration${gitPruned.prunedPaths.length === 1 ? "" : "s"}`;
380
440
  return result({
381
441
  passed: true,
382
- summary: `Pruned ${pruned.removed.length} stale managed registry entr${pruned.removed.length === 1 ? "y" : "ies"}`,
442
+ summary: `Pruned ${pruned.removed.length} stale managed registry entr${pruned.removed.length === 1 ? "y" : "ies"}${gitSummary}`,
383
443
  action: input.action,
384
444
  executed: true,
385
- warnings: pruned.warnings,
445
+ warnings: gitPruned.error
446
+ ? [...pruned.warnings, `git_worktree_prune_failed:${gitPruned.error}`]
447
+ : pruned.warnings,
386
448
  }, false);
387
449
  }
388
450
  catch (error) {
@@ -29,20 +29,52 @@ const tool = {
29
29
  openWorldHint: false,
30
30
  },
31
31
  handler: async (raw) => {
32
- const input = inputSchema.parse(raw ?? {});
32
+ const parsed = inputSchema.safeParse(raw ?? {});
33
+ if (!parsed.success) {
34
+ const summary = `Invalid wp_ui_compose input: ${parsed.error.issues.map((i) => i.message).join("; ")}`;
35
+ return {
36
+ content: [{ type: "text", text: summary }],
37
+ structuredContent: {
38
+ passed: false,
39
+ summary,
40
+ ok: false,
41
+ diagnostics: parsed.error.issues.map((i) => ({
42
+ path: i.path.length > 0 ? i.path.join(".") : "(root)",
43
+ message: i.message,
44
+ })),
45
+ },
46
+ isError: true,
47
+ };
48
+ }
49
+ const input = parsed.data;
50
+ if (input.profile === "admin-crud") {
51
+ if (input.spec === null || typeof input.spec !== "object" || Array.isArray(input.spec)) {
52
+ const summary = "Invalid wp_ui_compose admin-crud spec: expected a non-null object (not string/array/empty coercion)";
53
+ return {
54
+ content: [{ type: "text", text: summary }],
55
+ structuredContent: {
56
+ passed: false,
57
+ summary,
58
+ ok: false,
59
+ diagnostics: [{ path: "spec", message: summary }],
60
+ },
61
+ isError: true,
62
+ };
63
+ }
64
+ }
33
65
  const composeInput = input.profile === "admin-crud"
34
66
  ? {
35
67
  profile: "admin-crud",
36
68
  kind: input.kind,
37
- spec: input.spec && typeof input.spec === "object" && !Array.isArray(input.spec)
38
- ? input.spec
39
- : {},
69
+ spec: input.spec,
40
70
  }
41
71
  : { profile: "ops-report", spec: input.spec };
42
72
  const result = composeValidate(composeInput, { cwd: input.cwd });
43
73
  const summary = result.ok
44
74
  ? `compose ok (${result.profile})`
45
75
  : `compose failed (${result.profile}): ${result.diagnostics.map((d) => d.message).join("; ")}`;
76
+ // MCP tool execution errors (incl. domain validation) use isError:true so
77
+ // models self-correct; diagnostics stay in content text + structuredContent.
46
78
  return {
47
79
  content: [{ type: "text", text: summary }],
48
80
  structuredContent: {
@@ -55,7 +87,9 @@ const tool = {
55
87
  registrySource: result.registrySource,
56
88
  registryVersion: result.registryVersion,
57
89
  warnings: result.warnings,
90
+ blockTypesValidated: result.blockTypesValidated,
58
91
  },
92
+ ...(result.ok ? {} : { isError: true }),
59
93
  };
60
94
  },
61
95
  };
@@ -19,9 +19,20 @@ const tool = {
19
19
  openWorldHint: false,
20
20
  },
21
21
  handler: async (raw) => {
22
+ const parsed = inputSchema.safeParse(raw ?? {});
23
+ if (!parsed.success) {
24
+ const message = `Invalid wp_ui_preview input: ${parsed.error.issues.map((i) => i.message).join("; ")} (ops-report profile only; admin-crud is not supported)`;
25
+ return {
26
+ content: [{ type: "text", text: message }],
27
+ structuredContent: {
28
+ passed: false,
29
+ summary: message,
30
+ },
31
+ isError: true,
32
+ };
33
+ }
22
34
  try {
23
- const input = inputSchema.parse(raw ?? {});
24
- const { path } = previewOpsReport({ cwd: input.cwd, spec: input.spec });
35
+ const { path } = previewOpsReport({ cwd: parsed.data.cwd, spec: parsed.data.spec });
25
36
  const summary = `ops-report preview written: ${path}`;
26
37
  return {
27
38
  content: [{ type: "text", text: summary }],
@@ -33,11 +44,7 @@ const tool = {
33
44
  };
34
45
  }
35
46
  catch (error) {
36
- const message = error instanceof z.ZodError
37
- ? `Invalid wp_ui_preview input: ${error.issues.map((i) => i.message).join("; ")} (ops-report profile only; admin-crud is not supported)`
38
- : error instanceof Error
39
- ? error.message
40
- : String(error);
47
+ const message = error instanceof Error ? error.message : String(error);
41
48
  return {
42
49
  content: [{ type: "text", text: message }],
43
50
  structuredContent: {
@@ -9,6 +9,7 @@ import { createHash } from "node:crypto";
9
9
  import { existsSync, lstatSync, readFileSync } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { evaluateReviewEvents, parseReviewEventLog } from "./events.js";
12
+ import { isPolicyApprovalVerdict } from "./verdict.js";
12
13
  import { createDeliverySubjectAtRef, createLegacyPlanSubjectAtRef, createPlanSubjectAtRef, createPreTagsStripPlanSubjectAtRef, resolveReviewCommit, } from "./subject.js";
13
14
  const GIT_TIMEOUT_MS = 5_000;
14
15
  const GIT_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
@@ -114,6 +115,54 @@ function markCompromisedArtifactEvents(cwd, entries, blueprintDirectory, events)
114
115
  }
115
116
  return { compromisedIds, issues };
116
117
  }
118
+ /** Bounded `git branch -a --contains <commit>` probe, tolerant of a pruned/unknown object. */
119
+ function branchesContainingCommit(cwd, commit) {
120
+ try {
121
+ const output = gitBuffer(cwd, ["branch", "-a", "--contains", commit]).toString("utf8");
122
+ return output
123
+ .split("\n")
124
+ .map((line) => line.trim())
125
+ .filter((line) => line.length > 0);
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ // Diagnostic-only: names historical ledger approvals whose `reviewedCommit` is
132
+ // no longer reachable from any branch (typically because a rebase rewrote the
133
+ // history that commit lived on). Such an approval no longer matches the
134
+ // current subjectDigest, so it already contributes zero approvals under the
135
+ // existing policy in ./events.ts -- this only makes the reason visible instead
136
+ // of the operator seeing a bare "approvals does not satisfy the promotion
137
+ // reviewer mix" and mistaking a dead commit ref for a genuinely missing
138
+ // review. Never mutates `approvals`/`matching`; policy is unchanged.
139
+ function markOrphanedApprovalEvents(cwd, events, current) {
140
+ const issues = [];
141
+ const seen = new Set();
142
+ for (const event of events) {
143
+ if (event.purpose !== current.purpose ||
144
+ event.blueprintSlug !== current.blueprintSlug ||
145
+ event.subjectScheme !== current.subjectScheme ||
146
+ event.subjectDigest === current.subjectDigest ||
147
+ event.status !== "complete" ||
148
+ !isPolicyApprovalVerdict(event.verdict)) {
149
+ continue;
150
+ }
151
+ const key = `${event.reviewer}:${event.reviewedCommit}`;
152
+ if (seen.has(key))
153
+ continue;
154
+ seen.add(key);
155
+ const branches = branchesContainingCommit(cwd, event.reviewedCommit);
156
+ if (branches === null || branches.length === 0) {
157
+ issues.push(`Historical approval from ${event.reviewer} (event ${event.id}) reviewed commit ` +
158
+ `${event.reviewedCommit}, which is unreachable from any branch (git branch -a ` +
159
+ `--contains ${event.reviewedCommit} found nothing) -- likely orphaned by a rebase. ` +
160
+ "This approval does not count toward the current subject; rebase this worktree onto " +
161
+ "the latest base and re-run the gate.");
162
+ }
163
+ }
164
+ return issues;
165
+ }
117
166
  export function readReviewAuthorityAtRef(cwd, ref, stableSlug, purpose) {
118
167
  const commit = resolveReviewCommit(cwd, ref);
119
168
  const entries = parseRefTree(cwd, commit);
@@ -131,6 +180,12 @@ export function readReviewAuthorityAtRef(cwd, ref, stableSlug, purpose) {
131
180
  const subject = purpose === "plan"
132
181
  ? createPlanSubjectAtRef(cwd, commit, stableSlug, overview.path)
133
182
  : createDeliverySubjectAtRef(cwd, commit, stableSlug, overview.path);
183
+ const orphanIssues = markOrphanedApprovalEvents(cwd, events, {
184
+ purpose,
185
+ blueprintSlug: stableSlug,
186
+ subjectScheme: subject.scheme,
187
+ subjectDigest: subject.digest,
188
+ });
134
189
  let evaluation = evaluateReviewEvents(events, {
135
190
  purpose,
136
191
  blueprintSlug: stableSlug,
@@ -166,7 +221,7 @@ export function readReviewAuthorityAtRef(cwd, ref, stableSlug, purpose) {
166
221
  subject,
167
222
  events,
168
223
  ...evaluation,
169
- issues,
224
+ issues: [...issues, ...orphanIssues],
170
225
  };
171
226
  }
172
227
  export function readWorkingReviewEvents(blueprintPath, stableSlug) {
@@ -21,6 +21,10 @@ export interface ReviewGateVerification {
21
21
  readonly logPath?: string;
22
22
  readonly sandbox?: ReviewSandboxMode;
23
23
  readonly sandboxReason?: string;
24
+ readonly violations?: readonly {
25
+ readonly section: string;
26
+ readonly message: string;
27
+ }[];
24
28
  }
25
29
  /** Runs one gate command in the review checkout and returns its raw result. */
26
30
  export type RunSandboxedGate = (args: {
@@ -18,7 +18,15 @@ export async function verifyDeliveryPromotionGates(input) {
18
18
  parsed.violations[0]?.message === "missing Trust Dossier section") {
19
19
  return undefined;
20
20
  }
21
- return { status: "failed", commands: [], failureCode: "malformed-trust-dossier" };
21
+ return {
22
+ status: "failed",
23
+ commands: [],
24
+ failureCode: "malformed-trust-dossier",
25
+ violations: parsed.violations.map((violation) => ({
26
+ section: violation.section,
27
+ message: violation.message,
28
+ })),
29
+ };
22
30
  }
23
31
  const gates = parsed.dossier?.gates ?? [];
24
32
  if (gates.length === 0)