@lannguyensi/harness 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +9 -0
  3. package/dist/cli/adopt/derive.d.ts +15 -0
  4. package/dist/cli/adopt/derive.js +34 -7
  5. package/dist/cli/adopt/derive.js.map +1 -1
  6. package/dist/cli/adopt/index.d.ts +30 -0
  7. package/dist/cli/adopt/index.js +23 -3
  8. package/dist/cli/adopt/index.js.map +1 -1
  9. package/dist/cli/apply/generate-settings.d.ts +14 -1
  10. package/dist/cli/apply/generate-settings.js +7 -3
  11. package/dist/cli/apply/generate-settings.js.map +1 -1
  12. package/dist/cli/approve/stdin-report.js +2 -0
  13. package/dist/cli/approve/stdin-report.js.map +1 -1
  14. package/dist/cli/approve/understanding.js +28 -0
  15. package/dist/cli/approve/understanding.js.map +1 -1
  16. package/dist/cli/doctor/claude-mcp.d.ts +50 -0
  17. package/dist/cli/doctor/claude-mcp.js +170 -0
  18. package/dist/cli/doctor/claude-mcp.js.map +1 -0
  19. package/dist/cli/doctor/codex.d.ts +8 -0
  20. package/dist/cli/doctor/codex.js +8 -1
  21. package/dist/cli/doctor/codex.js.map +1 -1
  22. package/dist/cli/doctor/format.js +60 -11
  23. package/dist/cli/doctor/format.js.map +1 -1
  24. package/dist/cli/doctor/index.d.ts +46 -0
  25. package/dist/cli/doctor/index.js +155 -29
  26. package/dist/cli/doctor/index.js.map +1 -1
  27. package/dist/cli/doctor/npm-bin-path.js +42 -0
  28. package/dist/cli/doctor/npm-bin-path.js.map +1 -1
  29. package/dist/cli/doctor/types.d.ts +37 -0
  30. package/dist/cli/doctor/types.js.map +1 -1
  31. package/dist/cli/dry-run.js +5 -0
  32. package/dist/cli/dry-run.js.map +1 -1
  33. package/dist/cli/explain.js +16 -3
  34. package/dist/cli/explain.js.map +1 -1
  35. package/dist/cli/index.js +328 -11
  36. package/dist/cli/index.js.map +1 -1
  37. package/dist/cli/init/agent-tasks-auth.js +32 -0
  38. package/dist/cli/init/agent-tasks-auth.js.map +1 -1
  39. package/dist/cli/init/composer.js +13 -40
  40. package/dist/cli/init/composer.js.map +1 -1
  41. package/dist/cli/init/dependencies.js +21 -0
  42. package/dist/cli/init/dependencies.js.map +1 -1
  43. package/dist/cli/init/detect.d.ts +20 -3
  44. package/dist/cli/init/detect.js +34 -36
  45. package/dist/cli/init/detect.js.map +1 -1
  46. package/dist/cli/init/index.d.ts +18 -0
  47. package/dist/cli/init/index.js +28 -2
  48. package/dist/cli/init/index.js.map +1 -1
  49. package/dist/cli/init/interactive.d.ts +41 -0
  50. package/dist/cli/init/interactive.js +421 -6
  51. package/dist/cli/init/interactive.js.map +1 -1
  52. package/dist/cli/init/profiles.d.ts +2 -2
  53. package/dist/cli/init/profiles.js +10 -1
  54. package/dist/cli/init/profiles.js.map +1 -1
  55. package/dist/cli/init/templates.d.ts +1 -1
  56. package/dist/cli/init/templates.js +217 -8
  57. package/dist/cli/init/templates.js.map +1 -1
  58. package/dist/cli/list.js +3 -1
  59. package/dist/cli/list.js.map +1 -1
  60. package/dist/cli/main.js +10 -0
  61. package/dist/cli/main.js.map +1 -1
  62. package/dist/cli/pack/hook-bootstrap.d.ts +24 -0
  63. package/dist/cli/pack/hook-bootstrap.js +47 -1
  64. package/dist/cli/pack/hook-bootstrap.js.map +1 -1
  65. package/dist/cli/pack/hook-branch-protection.js +9 -1
  66. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  67. package/dist/cli/pack/hook-codex-post-tool-use.d.ts +25 -0
  68. package/dist/cli/pack/hook-codex-post-tool-use.js +160 -0
  69. package/dist/cli/pack/hook-codex-post-tool-use.js.map +1 -0
  70. package/dist/cli/pack/hook-codex-pre-tool-use.d.ts +1 -0
  71. package/dist/cli/pack/hook-codex-pre-tool-use.js +43 -10
  72. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  73. package/dist/cli/pack/hook-codex-stop.js +1 -8
  74. package/dist/cli/pack/hook-codex-stop.js.map +1 -1
  75. package/dist/cli/pack/hook-post-merge-gate-record.d.ts +30 -0
  76. package/dist/cli/pack/hook-post-merge-gate-record.js +153 -0
  77. package/dist/cli/pack/hook-post-merge-gate-record.js.map +1 -0
  78. package/dist/cli/pack/hook-post-merge-gate.d.ts +28 -0
  79. package/dist/cli/pack/hook-post-merge-gate.js +244 -0
  80. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -0
  81. package/dist/cli/pack/hook-post-tool-use.js +16 -131
  82. package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
  83. package/dist/cli/pack/hook-pre-tool-use.js +54 -2
  84. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  85. package/dist/cli/pack/hook-solution-acceptance-writeguard.js +148 -2
  86. package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
  87. package/dist/cli/pack/hook-stay-in-scope.js +63 -20
  88. package/dist/cli/pack/hook-stay-in-scope.js.map +1 -1
  89. package/dist/cli/pack/hook-track-active-claim.js +33 -9
  90. package/dist/cli/pack/hook-track-active-claim.js.map +1 -1
  91. package/dist/cli/pack/index.d.ts +2 -1
  92. package/dist/cli/pack/index.js +2 -1
  93. package/dist/cli/pack/index.js.map +1 -1
  94. package/dist/cli/pack/mutate.d.ts +19 -0
  95. package/dist/cli/pack/mutate.js +38 -0
  96. package/dist/cli/pack/mutate.js.map +1 -1
  97. package/dist/cli/pack/reseed.d.ts +19 -0
  98. package/dist/cli/pack/reseed.js +118 -0
  99. package/dist/cli/pack/reseed.js.map +1 -0
  100. package/dist/cli/pack/understanding-report-schema-hint.js +1 -1
  101. package/dist/cli/pack/understanding-report-schema-hint.js.map +1 -1
  102. package/dist/cli/pause/index.js +12 -3
  103. package/dist/cli/pause/index.js.map +1 -1
  104. package/dist/cli/record/index.d.ts +64 -0
  105. package/dist/cli/record/index.js +211 -0
  106. package/dist/cli/record/index.js.map +1 -0
  107. package/dist/cli/session-start/index.d.ts +21 -0
  108. package/dist/cli/session-start/index.js +160 -25
  109. package/dist/cli/session-start/index.js.map +1 -1
  110. package/dist/cli/session-start/toolchain-parity.d.ts +149 -0
  111. package/dist/cli/session-start/toolchain-parity.js +618 -0
  112. package/dist/cli/session-start/toolchain-parity.js.map +1 -0
  113. package/dist/cli/uninstall/index.d.ts +73 -1
  114. package/dist/cli/uninstall/index.js +134 -2
  115. package/dist/cli/uninstall/index.js.map +1 -1
  116. package/dist/cli/validate/checks.js +8 -0
  117. package/dist/cli/validate/checks.js.map +1 -1
  118. package/dist/io/claude-mcp.d.ts +258 -0
  119. package/dist/io/claude-mcp.js +544 -0
  120. package/dist/io/claude-mcp.js.map +1 -0
  121. package/dist/policies/ledger-client.d.ts +34 -1
  122. package/dist/policies/ledger-client.js +21 -16
  123. package/dist/policies/ledger-client.js.map +1 -1
  124. package/dist/policy-packs/builtin/branch-protection-runtime.d.ts +4 -2
  125. package/dist/policy-packs/builtin/branch-protection-runtime.js +4 -2
  126. package/dist/policy-packs/builtin/branch-protection-runtime.js.map +1 -1
  127. package/dist/policy-packs/builtin/branch-protection.d.ts +10 -1
  128. package/dist/policy-packs/builtin/branch-protection.js +17 -0
  129. package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
  130. package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +195 -0
  131. package/dist/policy-packs/builtin/post-merge-gate-runtime.js +290 -0
  132. package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -0
  133. package/dist/policy-packs/builtin/post-merge-gate.d.ts +56 -0
  134. package/dist/policy-packs/builtin/post-merge-gate.js +291 -0
  135. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -0
  136. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +28 -13
  137. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
  138. package/dist/policy-packs/builtin/understanding-before-execution-runtime.d.ts +197 -24
  139. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js +342 -40
  140. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js.map +1 -1
  141. package/dist/policy-packs/builtin/understanding-before-execution.d.ts +26 -1
  142. package/dist/policy-packs/builtin/understanding-before-execution.js +302 -17
  143. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  144. package/dist/policy-packs/registry.d.ts +23 -2
  145. package/dist/policy-packs/registry.js +31 -2
  146. package/dist/policy-packs/registry.js.map +1 -1
  147. package/dist/policy-packs/ux-compare.d.ts +14 -0
  148. package/dist/policy-packs/ux-compare.js +58 -0
  149. package/dist/policy-packs/ux-compare.js.map +1 -0
  150. package/dist/policy-packs/ux-drift-check.d.ts +27 -0
  151. package/dist/policy-packs/ux-drift-check.js +78 -0
  152. package/dist/policy-packs/ux-drift-check.js.map +1 -0
  153. package/dist/probes/mcp.d.ts +13 -0
  154. package/dist/probes/mcp.js +57 -0
  155. package/dist/probes/mcp.js.map +1 -1
  156. package/dist/runtime/approval-signing.d.ts +82 -0
  157. package/dist/runtime/approval-signing.js +275 -0
  158. package/dist/runtime/approval-signing.js.map +1 -0
  159. package/dist/runtime/git-context.d.ts +50 -0
  160. package/dist/runtime/git-context.js +108 -1
  161. package/dist/runtime/git-context.js.map +1 -1
  162. package/dist/runtime/hermetic-spawn-guard.d.ts +25 -0
  163. package/dist/runtime/hermetic-spawn-guard.js +136 -0
  164. package/dist/runtime/hermetic-spawn-guard.js.map +1 -0
  165. package/dist/runtime/intercept.js +62 -5
  166. package/dist/runtime/intercept.js.map +1 -1
  167. package/dist/runtime/ledger-writer.d.ts +27 -0
  168. package/dist/runtime/ledger-writer.js +46 -0
  169. package/dist/runtime/ledger-writer.js.map +1 -0
  170. package/dist/runtime/pause-sentinel.js +14 -3
  171. package/dist/runtime/pause-sentinel.js.map +1 -1
  172. package/dist/runtime/read-only-bash.js +118 -0
  173. package/dist/runtime/read-only-bash.js.map +1 -1
  174. package/dist/runtime/recovery-git-commit.d.ts +22 -0
  175. package/dist/runtime/recovery-git-commit.js +284 -0
  176. package/dist/runtime/recovery-git-commit.js.map +1 -0
  177. package/dist/runtime/tool-name-aliases.d.ts +1 -0
  178. package/dist/runtime/tool-name-aliases.js +7 -1
  179. package/dist/runtime/tool-name-aliases.js.map +1 -1
  180. package/dist/schema/index.d.ts +94 -42
  181. package/dist/schema/index.js +5 -0
  182. package/dist/schema/index.js.map +1 -1
  183. package/dist/schema/policies.d.ts +56 -44
  184. package/dist/schema/policies.js +79 -9
  185. package/dist/schema/policies.js.map +1 -1
  186. package/dist/schema/toolchain-parity.d.ts +18 -0
  187. package/dist/schema/toolchain-parity.js +35 -0
  188. package/dist/schema/toolchain-parity.js.map +1 -0
  189. package/package.json +4 -2
@@ -0,0 +1,290 @@
1
+ // Shared runtime constants + helpers for the `post-merge-gate` policy pack.
2
+ //
3
+ // The pack itself (`post-merge-gate.ts`) only emits hooks + the audit-copy
4
+ // instructions. The actual enforcement lives in two CLI verbs — `harness
5
+ // pack hook post-merge-gate-record` (producer, PostToolUse) and `harness
6
+ // pack hook post-merge-gate` (blocker, PreToolUse) — both under `src/cli/
7
+ // pack/`. This module is the small shared surface they pull from: the
8
+ // merged-tag format, the curated mutation matcher, the escape allowlist,
9
+ // and a couple of small payload/command helpers.
10
+ //
11
+ // Binding decisions this module encodes (see
12
+ // .ai/runs/2026-07-23-post-merge-gate/03-decisions.md, BINDING):
13
+ //
14
+ // - The merged fact is keyed on an EXACT branch-tip sha match, not
15
+ // ancestry. `gh pr merge` merges the PR on the remote side; it does
16
+ // NOT move the local branch's tip. So the local tip observed by the
17
+ // PostToolUse producer, right after a successful merge, IS the exact
18
+ // commit that got merged. The blocker denies only while the current
19
+ // tip still equals that recorded value — a new local commit moves the
20
+ // tip and the gate falls silent (deliberate: that is legitimate
21
+ // continued work, not stale post-merge state), and a recycled branch
22
+ // name with a different tip never matches either. No expiry /
23
+ // freshness window is needed as a result (squash-fest, offline).
24
+ //
25
+ // - Deny scope v1 is a CURATED command list (git commit / add / push /
26
+ // merge / rebase / cherry-pick / revert / reset / stash pop|apply, gh
27
+ // pr create / merge), not "every Bash call" — mirrors
28
+ // branch-protection.ts's documented v1 scope-cut (see its "Out of
29
+ // scope" section). Read-only git (status/log/diff/branch) and
30
+ // unrelated shell are never touched.
31
+ //
32
+ // - The escape allowlist is checked FIRST in the blocker, unconditionally,
33
+ // before manifest load or any ledger query — see hook-post-merge-gate.ts.
34
+ // This module only supplies the matcher; the ordering guarantee lives
35
+ // in the hook, not here.
36
+ //
37
+ // - Fails OPEN (allows) when the ledger is unreachable, unlike
38
+ // branch-protection's fail-closed posture: without the ledger,
39
+ // "merged" and "not merged" are indistinguishable, and fail-closed
40
+ // here would block every feature branch's git history forever. This
41
+ // pack is advisory-strength against a hostile agent, same honesty as
42
+ // the other `requires.ledger_tag`-style gates
43
+ // (docs/okf/evidence-ledger-trust-boundary.md).
44
+ import { findGitEntry, resolveCommonDir, resolveOriginHeadBase } from "../../runtime/git-context.js";
45
+ export const PACK_NAME = "post-merge-gate";
46
+ /**
47
+ * Content prefix the producer writes and the blocker substring-matches.
48
+ * The full recorded content is
49
+ * `${MERGED_TAG_PREFIX}:${repo}:${branch}:${sha}[ pr:<n>] at:<iso>`; the
50
+ * blocker only ever tests for the `${MERGED_TAG_PREFIX}:${repo}:${branch}:
51
+ * ${sha}` prefix (an exact 40-hex-char sha), so the trailing `pr:` / `at:`
52
+ * tokens are audit decoration, not part of the match key.
53
+ */
54
+ export const MERGED_TAG_PREFIX = "post-merge-gate:merged";
55
+ /** The exact substring the blocker tests ledger entries for. */
56
+ export function mergedTagMatchKey(repo, branch, sha) {
57
+ return `${MERGED_TAG_PREFIX}:${repo}:${branch}:${sha}`;
58
+ }
59
+ /** Build the full fact content the producer writes (match key + audit decoration). */
60
+ export function buildMergedTagContent(args) {
61
+ const prPart = args.pr ? ` pr:${args.pr}` : "";
62
+ return `${mergedTagMatchKey(args.repo, args.branch, args.sha)}${prPart} at:${args.whenIso}`;
63
+ }
64
+ /**
65
+ * Producer trigger: does this Bash command look like a `gh pr merge`
66
+ * invocation? Same anchoring convention as `DEFAULT_PUSH_BASH_RE`
67
+ * (solution-acceptance-runtime.ts): tolerates a leading `cd … &&`, inline
68
+ * `VAR=val` assignments, and chaining. Known gap, same class as that
69
+ * regex's own documented residual: a heredoc / `sh -c` / `eval`
70
+ * indirection defeats this match before the producer ever runs — not
71
+ * attempted to close here (the MCP merge path, `pull_requests_merge`, is
72
+ * the other documented gap; see the pack's instructions.md).
73
+ */
74
+ export const GH_PR_MERGE_BASH_RE = /(?:^|\n|;|\||&&|\()\s*(?:\w+=\S+\s+)*gh\s+pr\s+merge\b/;
75
+ /**
76
+ * Blocker deny-scope v1 (03-decisions.md): the curated mutation command
77
+ * list. Deliberately NOT "every Bash command" — read-only git
78
+ * (status/log/diff/branch) and unrelated shell stay unaffected. Same
79
+ * anchoring convention as `GH_PR_MERGE_BASH_RE`.
80
+ */
81
+ export const CURATED_MUTATION_BASH_RE = /(?:^|\n|;|\||&&|\()\s*(?:\w+=\S+\s+)*(?:git(?:\s+-C\s+\S+)?\s+(?:commit|add|push|merge|rebase|cherry-pick|revert|reset|stash\s+(?:pop|apply))\b|gh\s+pr\s+(?:create|merge)\b)/;
82
+ /**
83
+ * Escape allowlist, git verbs (03-decisions.md): the exact recovery path
84
+ * the deny message recommends, plus read-only stash inspection. Checked
85
+ * BEFORE the curated-mutation match and before any manifest/ledger access
86
+ * — see hook-post-merge-gate.ts for the ordering guarantee.
87
+ */
88
+ export const ESCAPE_GIT_BASH_RE = /(?:^|\n|;|\||&&|\()\s*(?:\w+=\S+\s+)*git(?:\s+-C\s+\S+)?\s+(?:switch\b|checkout\b|pull\b|fetch\b|branch\s+-(?:d|D)\b|stash\s+(?:list|show)\b)/;
89
+ /**
90
+ * Escape allowlist, harness verbs (03-decisions.md): any invocation of
91
+ * harness's own CLI (recovery commands like `harness session-start
92
+ * branch-check`, diagnostics, a future self-check) always passes,
93
+ * regardless of spelling — mirrors the `npx` / absolute-path /
94
+ * `./node_modules/.bin` robustness the `deny-kill-switch-bash` regex in
95
+ * `src/cli/init/templates.ts` already established for the same class of
96
+ * bypass concern.
97
+ */
98
+ export const ESCAPE_HARNESS_BASH_RE = /(?:^|\n|;|\||&&|\()\s*(?:\w+=\S+\s+)*(?:npx\s+|\S*\/)?harness\b/;
99
+ /** True when `command` matches any escape pattern. Checked first, unconditionally. */
100
+ export function isEscapeCommand(command) {
101
+ return ESCAPE_GIT_BASH_RE.test(command) || ESCAPE_HARNESS_BASH_RE.test(command);
102
+ }
103
+ /** True when `command` matches the curated v1 deny-scope. */
104
+ export function isCuratedMutationCommand(command) {
105
+ return CURATED_MUTATION_BASH_RE.test(command);
106
+ }
107
+ /**
108
+ * Defensive `tool_output.exit_code` reader for the PostToolUse Bash
109
+ * payload — Contract A. Returns `null` on ANY shape other than a plain
110
+ * finite number — missing field, a differently-named variant, a string,
111
+ * etc. — so the producer's "no fact on anything but a confirmed success"
112
+ * rule (03-decisions.md) degrades to "no fact" rather than guessing.
113
+ *
114
+ * PAYLOAD REALITY (2026-07, follow-up to 03-decisions.md): live
115
+ * verification against a real Claude Code 2.1.218 native install (a
116
+ * `claude -p --settings` dump-hook capture, 19/19 fired events) found NO
117
+ * `tool_output` field at all — the field is `tool_response`, shaped
118
+ * `{ stdout, stderr, interrupted, isImage, noOutputExpected }` with no
119
+ * exit-code equivalent (see `tests/fixtures/post-merge-gate/
120
+ * real-posttooluse-payload-2.1.218.json`, a verbatim capture). The
121
+ * `tool_output.stdout/stderr/exit_code` shape this function reads
122
+ * evidently describes a DIFFERENT — newer, or differently-shimmed —
123
+ * Claude Code contract than 2.1.218's, not a documentation error to
124
+ * "fix" here: Contract A is left exactly as it was (any install that
125
+ * DOES send `tool_output.exit_code` still works unchanged), and Contract
126
+ * B below is the fallback that makes the producer fire on 2.1.218 at
127
+ * all. See `resolveMergeConfirmation`.
128
+ */
129
+ export function extractExitCode(toolOutput) {
130
+ if (typeof toolOutput !== "object" || toolOutput === null)
131
+ return null;
132
+ const ec = toolOutput["exit_code"];
133
+ return typeof ec === "number" && Number.isFinite(ec) ? ec : null;
134
+ }
135
+ // Matches the first bare integer following `gh pr merge` — audit
136
+ // decoration only (the deny/allow decision never depends on this).
137
+ const PR_NUMBER_RE = /gh\s+pr\s+merge\b[^\n;|&]*?(\d+)\b/;
138
+ /** Best-effort PR number extraction from a `gh pr merge` command string. */
139
+ export function extractPrNumber(command) {
140
+ const m = PR_NUMBER_RE.exec(command);
141
+ return m ? m[1] : null;
142
+ }
143
+ /**
144
+ * Contract B success-text matcher (2.1.218 payload-reality follow-up).
145
+ * `gh pr merge`'s own past-tense confirmation sentence, verbatim across
146
+ * all three merge methods.
147
+ *
148
+ * SOURCE (verified against the installed `gh` binary, v2.94.0,
149
+ * `pkg/cmd/pr/merge/merge.go` lines 369-376): the success line is built
150
+ * as `infof("%s %s pull request %s#%d (%s)", icon, action,
151
+ * ghrepo.FullName(baseRepo), pr.Number, pr.Title)` — i.e. the repo
152
+ * FULLNAME sits directly between "pull request" and the bare `#<n>`,
153
+ * GLUED to the `#` with no space (`%s#%d`, not `%s #%d`):
154
+ *
155
+ * ✓ Squashed and merged pull request owner/repo#65 (Some title)
156
+ * ✓ Rebased and merged pull request owner/repo#65 (Some title)
157
+ * ✓ Merged pull request owner/repo#65 (Some title)
158
+ *
159
+ * `infof` writes to `gh`'s STDERR (the informational-message channel,
160
+ * distinct from `gh`'s own stdout, which is reserved for machine-
161
+ * consumable output) — another reason this matcher tests the CONCATENATED
162
+ * `stdout`+`stderr`, not stdout alone. `icon` is a bare `✓` (or `!` for
163
+ * warnings) with no ANSI color codes when `gh` detects a non-TTY output
164
+ * stream, which a hook-captured Bash hijacks it into.
165
+ *
166
+ * `[^\s#]*` between "pull request" and `#` accepts BOTH that real
167
+ * `owner/repo#<n>` shape AND a bare `#<n>` (no fullname) — the latter is
168
+ * not `gh`'s actual current wording but is accepted defensively as a
169
+ * documentation-shape / future-`gh`-version tolerance, at zero matching-
170
+ * surface cost (repo fullnames never contain whitespace or `#`, so the
171
+ * character class cannot itself widen what counts as a match beyond
172
+ * "some non-space run immediately before the PR number").
173
+ *
174
+ * Still deliberately narrow overall (conservative — false negatives are
175
+ * the safe direction here, false positives are not):
176
+ * - exact past-tense phrase, not a bare "merged" substring — excludes
177
+ * `gh pr merge --auto`'s pending text (real wording, same source
178
+ * file: "Pull request owner/repo#65 will be automatically merged via
179
+ * squash when all requirements are met" — capitalized standalone
180
+ * "Pull request", no "Squashed/Rebased and merged" or standalone
181
+ * "Merged" immediately precedes it), and excludes the already-merged
182
+ * warning (real wording: "Pull request owner/repo#65 was already
183
+ * merged" — word order is reversed: "Pull request ... was already
184
+ * merged", not "Merged pull request ...").
185
+ * - case-sensitive, matching gh's own capitalization exactly (no `/i`)
186
+ * — narrower matching surface, same conservative direction.
187
+ * - `\b` word boundaries on both ends so a partial-word coincidence
188
+ * (e.g. a hypothetical "ReMerged") cannot match.
189
+ *
190
+ * KNOWN GAP (documented, not attempted to close here): this is coupled
191
+ * to `gh`'s current wording. A future `gh` release that rephrases its
192
+ * success sentence makes this matcher — and therefore the whole
193
+ * Contract-B path — silently, fail-safely inert (no fact written, same
194
+ * as any other unmatched shape; never a false "merged" record). See the
195
+ * pack's instructions.md / docs/policy-packs/post-merge-gate.md "Known
196
+ * gaps" for the operator-facing verification path.
197
+ */
198
+ export const GH_MERGE_SUCCESS_RE = /\b(?:Squashed and merged|Rebased and merged|Merged)\s+pull request\s+[^\s#]*#(\d+)\b/;
199
+ /**
200
+ * Contract B confirmation: does `toolResponse` carry a real Claude Code
201
+ * 2.1.218-shaped Bash result (`{ stdout, stderr, interrupted, ... }`)
202
+ * whose `stdout`+`stderr` contain a `gh pr merge` success sentence?
203
+ *
204
+ * `interrupted` must be the LITERAL boolean `false` — missing, `true`,
205
+ * or any other value all fail closed (no match), the same fail-safe
206
+ * direction as Contract A's exit_code check. `stdout`/`stderr` default
207
+ * to `""` when absent or non-string (never throws), and are joined with
208
+ * a newline before matching so a phrase cannot accidentally form by
209
+ * concatenating the tail of one stream with the head of the other.
210
+ */
211
+ export function matchGhMergeSuccessText(toolResponse) {
212
+ if (typeof toolResponse !== "object" || toolResponse === null)
213
+ return { matched: false };
214
+ const tr = toolResponse;
215
+ if (tr["interrupted"] !== false)
216
+ return { matched: false };
217
+ const stdout = typeof tr["stdout"] === "string" ? tr["stdout"] : "";
218
+ const stderr = typeof tr["stderr"] === "string" ? tr["stderr"] : "";
219
+ const combined = `${stdout}\n${stderr}`;
220
+ const match = GH_MERGE_SUCCESS_RE.exec(combined);
221
+ if (!match)
222
+ return { matched: false };
223
+ return { matched: true, pr: match[1] ?? null };
224
+ }
225
+ /**
226
+ * Dual-contract merge confirmation (2.1.218 payload-reality follow-up).
227
+ * Tries Contract A (`tool_output.exit_code`, unchanged) first; falls
228
+ * back to Contract B (`tool_response` + a matching `gh` success
229
+ * sentence) only when Contract A yields no verdict at all.
230
+ *
231
+ * BINDING ordering decision — Contract A wins whenever it resolves to
232
+ * ANY definite verdict, success OR failure: a well-formed, non-zero
233
+ * `exit_code` is Contract A's own explicit failure signal and short-
234
+ * circuits WITHOUT consulting Contract B, so a coincidental gh success
235
+ * phrase sitting in a sibling `tool_response` field (the hypothetical
236
+ * "both present" shape) can never override an authoritative failure.
237
+ * Contract B is consulted only when `exit_code` is entirely unresolvable
238
+ * (`extractExitCode` returns `null` — missing field, wrong shape, or the
239
+ * field genuinely absent as on 2.1.218).
240
+ */
241
+ export function resolveMergeConfirmation(toolOutput, toolResponse, command) {
242
+ const exitCode = extractExitCode(toolOutput);
243
+ if (exitCode === 0) {
244
+ return {
245
+ confirmed: true,
246
+ contract: "exit_code",
247
+ pr: extractPrNumber(command),
248
+ reason: "tool_output.exit_code === 0 (Contract A)",
249
+ };
250
+ }
251
+ if (exitCode !== null) {
252
+ return {
253
+ confirmed: false,
254
+ contract: "none",
255
+ pr: null,
256
+ reason: `tool_output.exit_code is ${exitCode}, not 0 (Contract A reports failure; Contract B not consulted)`,
257
+ };
258
+ }
259
+ const ghSuccess = matchGhMergeSuccessText(toolResponse);
260
+ if (ghSuccess.matched) {
261
+ return {
262
+ confirmed: true,
263
+ contract: "gh_success_text",
264
+ pr: extractPrNumber(command) ?? ghSuccess.pr,
265
+ reason: "tool_response carries a confirmed gh merge success sentence (Contract B)",
266
+ };
267
+ }
268
+ return {
269
+ confirmed: false,
270
+ contract: "none",
271
+ pr: null,
272
+ reason: "no confirming tool_output.exit_code (Contract A) and no matching gh merge success text in tool_response (Contract B)",
273
+ };
274
+ }
275
+ /**
276
+ * Best-effort default-branch name for the deny message's `git switch
277
+ * <default>` line. Offline, reusing the same `origin/HEAD` resolution
278
+ * `harness record review --base` uses (now shared via
279
+ * `runtime/git-context.ts`, see that module's header). Returns `null`
280
+ * when `cwd` is not in a git work tree, or the repo has no resolvable
281
+ * `origin/HEAD` (e.g. no remote) — the caller falls back to a generic
282
+ * placeholder in that case; this is advisory text, never a gate input.
283
+ */
284
+ export function resolveDefaultBranchName(cwd) {
285
+ const gitDir = findGitEntry(cwd)?.gitDir;
286
+ if (!gitDir)
287
+ return null;
288
+ return resolveOriginHeadBase(resolveCommonDir(gitDir));
289
+ }
290
+ //# sourceMappingURL=post-merge-gate-runtime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"post-merge-gate-runtime.js","sourceRoot":"","sources":["../../../src/policy-packs/builtin/post-merge-gate-runtime.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,56 @@
1
+ import { z } from "zod";
2
+ import type { PolicyPack, PolicyUx } from "../../schema/index.js";
3
+ import { type Runtime } from "../runtime.js";
4
+ import type { PackContribution } from "../types.js";
5
+ import { PACK_NAME } from "./post-merge-gate-runtime.js";
6
+ export { PACK_NAME };
7
+ /**
8
+ * Zod schema for this pack's `config:` block. Strict by design so
9
+ * typo'd keys fail loud at lint time (mirrors sibling packs). `ux` is
10
+ * the only operator-tunable key today — the curated mutation list and
11
+ * escape allowlist are fixed in v1 (03-decisions.md); a config override
12
+ * for either can land as a follow-up if operators need it.
13
+ */
14
+ export declare const configSchema: z.ZodObject<{
15
+ ux: z.ZodOptional<z.ZodObject<{
16
+ cannot: z.ZodString;
17
+ required: z.ZodArray<z.ZodString, "many">;
18
+ run: z.ZodArray<z.ZodString, "many">;
19
+ }, "strict", z.ZodTypeAny, {
20
+ cannot: string;
21
+ required: string[];
22
+ run: string[];
23
+ }, {
24
+ cannot: string;
25
+ required: string[];
26
+ run: string[];
27
+ }>>;
28
+ }, "strict", z.ZodTypeAny, {
29
+ ux?: {
30
+ cannot: string;
31
+ required: string[];
32
+ run: string[];
33
+ } | undefined;
34
+ }, {
35
+ ux?: {
36
+ cannot: string;
37
+ required: string[];
38
+ run: string[];
39
+ } | undefined;
40
+ }>;
41
+ /**
42
+ * Shipped default `config.ux` for this pack. Canonical source for the
43
+ * `full` init template and `harness pack reseed` (mirrors
44
+ * branch-protection's `defaultUx`): a future wording fix lands here once
45
+ * and reaches both a fresh `harness init --template full` and an
46
+ * operator running `harness pack reseed post-merge-gate` against an
47
+ * already-installed manifest. `${DEFAULT_BRANCH}` is always populated by
48
+ * the blocker (falling back to the literal placeholder `<default-branch>`
49
+ * when it cannot resolve `origin/HEAD`), never left as an unresolved
50
+ * template var.
51
+ */
52
+ export declare function defaultUx(): PolicyUx;
53
+ export declare function resolve(pack: PolicyPack, runtime?: Runtime): {
54
+ contribution: PackContribution;
55
+ warnings: string[];
56
+ };
@@ -0,0 +1,291 @@
1
+ // Builtin Policy Pack: `post-merge-gate`.
2
+ //
3
+ // Catches the "kept working on a branch after it was already merged"
4
+ // pattern: an agent (or operator) merges a PR via `gh pr merge`, then
5
+ // keeps committing / pushing / rebasing on the SAME local checkout
6
+ // without switching back to the default branch first — silently
7
+ // building on top of a commit that is already integrated, which either
8
+ // produces a confusing duplicate-merge PR or loses the new work off the
9
+ // side of a branch nobody looks at again.
10
+ //
11
+ // Mechanics, mirroring `branch-protection` (see that pack's header for
12
+ // the identical two-hook shape):
13
+ //
14
+ // 1. PostToolUse producer (`harness pack hook post-merge-gate-record`)
15
+ // watches every Bash call and, ONLY when the command matched
16
+ // `gh pr merge` AND the merge is CONFIRMED, writes a
17
+ // `post-merge-gate:merged:<repo>:<branch>:<sha>` fact (plus PR
18
+ // number and timestamp, audit-only) to the evidence ledger — `sha`
19
+ // being the LOCAL branch tip observed right after the merge, which
20
+ // is the exact commit that got merged (`gh pr merge` merges on the
21
+ // remote side; it does not move the local tip). Confirmation is
22
+ // DUAL-CONTRACT (payload-reality follow-up, 2026-07): a `tool_output.
23
+ // exit_code === 0` (Contract A) is NOT reliably present — live
24
+ // verification against a real Claude Code 2.1.218 install found the
25
+ // PostToolUse Bash payload carries no `tool_output` / exit-code field
26
+ // at all, only `tool_response` (`{ stdout, stderr, interrupted,
27
+ // isImage, noOutputExpected }`) — so a `gh pr merge` success sentence
28
+ // match against `tool_response` (Contract B) is the fallback that
29
+ // makes the producer fire at all on that install. See
30
+ // `resolveMergeConfirmation` in post-merge-gate-runtime.ts for the
31
+ // exact dual-contract / "Contract A wins" ordering. Payload reads are
32
+ // defensive throughout: an unexpected shape on either contract writes
33
+ // nothing.
34
+ //
35
+ // 2. PreToolUse blocker (`harness pack hook post-merge-gate`) checks a
36
+ // CURATED list of history-mutating commands (see
37
+ // post-merge-gate-runtime.ts for the exact list) and denies them
38
+ // when the CURRENT branch tip still equals a recorded merged tip
39
+ // for this repo+branch — an exact sha match, not ancestry, so a new
40
+ // commit (moving the tip) or a recycled branch name (a different
41
+ // tip) both fall silently outside the gate. An ESCAPE ALLOWLIST
42
+ // (git switch/checkout/pull/fetch, git branch -d/-D, git stash
43
+ // list/show, any `harness ...` command) is checked FIRST, before
44
+ // any manifest load or ledger query, so the recovery path can never
45
+ // be starved by an unrelated failure.
46
+ //
47
+ // Fails OPEN when the ledger is unreachable (unlike branch-protection's
48
+ // fail-closed posture): without the ledger, "merged" and "not merged"
49
+ // are indistinguishable, and fail-closed here would block ordinary git
50
+ // history work on every branch whenever grounding-mcp hiccups. This pack
51
+ // is advisory-strength against a hostile agent, like the other
52
+ // `requires.ledger_tag`-style gates (docs/okf/evidence-ledger-trust-boundary.md);
53
+ // see post-merge-gate-runtime.ts's header for the full rationale.
54
+ //
55
+ // No auto-switch: the blocker only ever recommends the recovery commands
56
+ // in its deny message, never runs them (operator/agent decides).
57
+ //
58
+ // Enabled per-installation via `harness pack add post-merge-gate`. The
59
+ // `full` init template wires it with `enabled: false` (opt-in — a fresh,
60
+ // higher-risk gate; see templates.ts for the rationale), unlike
61
+ // branch-protection's default-enabled precedent.
62
+ //
63
+ // Claude Code only in v1 (no Codex adapter): both hooks match on the
64
+ // `Bash` tool name and classify by command text, a mechanism that has
65
+ // no established Codex-adapter parity yet (mirrors `solution-acceptance`,
66
+ // which ships with no Codex variant either). `resolve()` warns if wired
67
+ // under `runtime: "codex"`.
68
+ import { z } from "zod";
69
+ import { PolicyUxSchema } from "../../schema/policies.js";
70
+ import { DEFAULT_RUNTIME } from "../runtime.js";
71
+ import { CURATED_MUTATION_BASH_RE, ESCAPE_GIT_BASH_RE, ESCAPE_HARNESS_BASH_RE, GH_MERGE_SUCCESS_RE, GH_PR_MERGE_BASH_RE, MERGED_TAG_PREFIX, PACK_NAME, } from "./post-merge-gate-runtime.js";
72
+ export { PACK_NAME };
73
+ /**
74
+ * Zod schema for this pack's `config:` block. Strict by design so
75
+ * typo'd keys fail loud at lint time (mirrors sibling packs). `ux` is
76
+ * the only operator-tunable key today — the curated mutation list and
77
+ * escape allowlist are fixed in v1 (03-decisions.md); a config override
78
+ * for either can land as a follow-up if operators need it.
79
+ */
80
+ export const configSchema = z
81
+ .object({
82
+ ux: PolicyUxSchema.optional(),
83
+ })
84
+ .strict();
85
+ /**
86
+ * Shipped default `config.ux` for this pack. Canonical source for the
87
+ * `full` init template and `harness pack reseed` (mirrors
88
+ * branch-protection's `defaultUx`): a future wording fix lands here once
89
+ * and reaches both a fresh `harness init --template full` and an
90
+ * operator running `harness pack reseed post-merge-gate` against an
91
+ * already-installed manifest. `${DEFAULT_BRANCH}` is always populated by
92
+ * the blocker (falling back to the literal placeholder `<default-branch>`
93
+ * when it cannot resolve `origin/HEAD`), never left as an unresolved
94
+ * template var.
95
+ */
96
+ export function defaultUx() {
97
+ return {
98
+ cannot: "You cannot run ${TOOL_NAME} on branch ${BRANCH} yet — its current tip was already merged.",
99
+ required: [
100
+ "a branch tip that is not sitting at an already-merged commit (switch off `${BRANCH}`, or move its tip with a new commit)",
101
+ ],
102
+ run: [
103
+ "git switch ${DEFAULT_BRANCH}",
104
+ "git pull --ff-only",
105
+ "git branch -d ${BRANCH} # optional cleanup",
106
+ ],
107
+ };
108
+ }
109
+ const HOOK_NAME_PREFIX = `policy-pack:${PACK_NAME}`;
110
+ const PRODUCER_COMMAND = "harness pack hook post-merge-gate-record";
111
+ const BLOCKER_COMMAND = "harness pack hook post-merge-gate";
112
+ function buildHooks() {
113
+ return [
114
+ {
115
+ name: `${HOOK_NAME_PREFIX}:post-tool-use`,
116
+ event: "PostToolUse",
117
+ match: "Bash",
118
+ command: PRODUCER_COMMAND,
119
+ blocking: false,
120
+ budget_ms: 5000,
121
+ description: "Producer: on a `gh pr merge` Bash call that exited 0, write `post-merge-gate:merged:<repo>:<branch>:<sha>` to the evidence ledger. Non-blocking; any failure just leaves the gate silent for that merge.",
122
+ },
123
+ {
124
+ name: `${HOOK_NAME_PREFIX}:pre-tool-use`,
125
+ event: "PreToolUse",
126
+ match: "Bash",
127
+ command: BLOCKER_COMMAND,
128
+ blocking: "hard",
129
+ budget_ms: 5000,
130
+ description: "Blocker: deny curated history-mutating Bash commands (git commit/add/push/merge/rebase/cherry-pick/revert/reset/stash pop|apply, gh pr create/merge) when the current branch tip matches a recorded merged tip. An escape allowlist (git switch/checkout/pull/fetch, git branch -d/-D, git stash list/show, any `harness ...` command) is checked first, unconditionally. Fails open when the ledger is unreachable.",
131
+ },
132
+ ];
133
+ }
134
+ function buildInstructions(pack, runtime) {
135
+ const description = pack.description?.trim() ?? "";
136
+ return `# Policy Pack: ${PACK_NAME}
137
+
138
+ > Operator audit copy. This pack denies curated history-mutating Bash
139
+ > commands on a branch whose tip was already merged via \`gh pr merge\`,
140
+ > closing the loop on the "kept working on a merged branch" incident
141
+ > pattern.
142
+
143
+ ## Runtime
144
+
145
+ ${runtime}${runtime === "codex" ? " (UNSUPPORTED — see \"Known gaps\" below; both hooks assume the Claude Code Bash tool surface)" : ""}
146
+
147
+ ## Trigger + signals
148
+
149
+ 1. \`PostToolUse\` producer (\`${PRODUCER_COMMAND}\`, blocking: false) on
150
+ \`Bash\`: matches \`${GH_PR_MERGE_BASH_RE.source}\` against the command
151
+ text. Fires only on a CONFIRMED merge, via either of two contracts
152
+ (dual-contract, payload-reality follow-up — real Claude Code shims
153
+ have shipped both shapes in the wild):
154
+ - **Contract A** — \`tool_output.exit_code\` is the number \`0\`.
155
+ - **Contract B** — \`tool_response\` is present with
156
+ \`interrupted === false\` AND its \`stdout\`+\`stderr\` contain \`gh pr
157
+ merge\`'s own past-tense success sentence: \`${GH_MERGE_SUCCESS_RE.source}\`
158
+ (covers all three merge methods: Squashed / Rebased / plain Merged).
159
+ Verified against the installed \`gh\` binary (v2.94.0,
160
+ \`pkg/cmd/pr/merge/merge.go\` lines 369-376): the line is built as
161
+ \`infof("%s %s pull request %s#%d (%s)", icon, action,
162
+ ghrepo.FullName(baseRepo), pr.Number, pr.Title)\` — the repo
163
+ fullname sits between "pull request" and the PR number, GLUED to
164
+ \`#\` with no space (e.g. \`Squashed and merged pull request
165
+ owner/repo#65 (title)\`), and \`infof\` writes to \`gh\`'s STDERR,
166
+ which is why this contract checks the concatenated
167
+ \`stdout\`+\`stderr\`, not stdout alone.
168
+ Contract A wins whenever it resolves to ANY definite verdict, success
169
+ OR failure — a well-formed non-zero \`exit_code\` short-circuits
170
+ WITHOUT consulting Contract B. Contract B is tried only when Contract
171
+ A's \`exit_code\` is entirely unresolvable (missing field, wrong
172
+ shape, or genuinely absent — as verified live against Claude Code
173
+ 2.1.218, whose real PostToolUse Bash payload carries NO \`tool_output\`
174
+ / no exit-code field at all, only \`tool_response\`; the
175
+ \`tool_output.exit_code\` shape appears to describe a different,
176
+ possibly newer, Claude Code contract). Any other shape on either
177
+ contract writes NO fact; fail-safe against a false "merged" record.
178
+ On a confirmed match, records
179
+ \`${MERGED_TAG_PREFIX}:<repo>:<branch>:<sha>[ pr:<n>] at:<iso>\` to the
180
+ evidence ledger, where \`<sha>\` is the LOCAL branch tip observed right
181
+ after the merge (the exact commit that got merged — \`gh pr merge\`
182
+ merges remote-side and does not itself move the local tip). \`<n>\`
183
+ prefers extraction from the \`gh pr merge\` command itself, falling
184
+ back to the number captured out of the Contract-B success sentence.
185
+
186
+ 2. \`PreToolUse\` blocker (\`${BLOCKER_COMMAND}\`, blocking: hard) on
187
+ \`Bash\`: checked in this order —
188
+ a. **Escape allowlist first, unconditionally** (before any manifest
189
+ load or ledger query): \`${ESCAPE_GIT_BASH_RE.source}\` OR
190
+ \`${ESCAPE_HARNESS_BASH_RE.source}\` — always allowed.
191
+ b. **Curated mutation match**: \`${CURATED_MUTATION_BASH_RE.source}\`
192
+ — commands that don't match this pass through untouched.
193
+ c. **Ledger check**: denies only when the current branch tip exactly
194
+ equals a recorded \`${MERGED_TAG_PREFIX}:<repo>:<branch>:<sha>\` fact
195
+ for this repo+branch. A new commit moves the tip (gate falls
196
+ silent — that's legitimate continued work); a recycled branch name
197
+ has a different tip (no false positive). No freshness window is
198
+ needed as a result.
199
+
200
+ ## Escape hatches
201
+
202
+ - **Recovery path** (named in every deny message): \`git switch
203
+ <default-branch>\`, then \`git pull --ff-only\`, then optionally
204
+ \`git branch -d <branch>\` to clean up the merged local branch.
205
+ - **Read-only / inspection commands** (\`git switch\`/\`checkout\`,
206
+ \`pull\`/\`fetch\`, \`branch -d\`/\`-D\`, \`stash list\`/\`show\`, and any
207
+ \`harness ...\` invocation) always pass, independent of ledger
208
+ reachability — the gate can never itself become the reason the
209
+ recovery path is unavailable.
210
+
211
+ ## Known gaps (documented, not attempted in v1)
212
+
213
+ - **MCP merge path**: \`mcp__agent-tasks__pull_requests_merge\` is NOT a
214
+ producer trigger. Only the \`gh pr merge\` Bash surface is watched;
215
+ merges done through the agent-tasks MCP tool leave no
216
+ \`${MERGED_TAG_PREFIX}\` fact, so the blocker never fires for that
217
+ branch. Left as a documented gap (spec decision), not silently faked
218
+ as covered.
219
+ - **Regex-vs-shell-eval residual**: same class of gap as every other
220
+ \`bash_match\`-style matcher in this codebase (branch-protection,
221
+ solution-acceptance, the kill-switch deny policies) — a heredoc,
222
+ \`sh -c\`, \`eval\`, or similarly indirected command defeats the TRIGGER
223
+ match before either hook ever runs. Not attempted to close here.
224
+ - **Curated scope, not every Bash command**: destructive non-git
225
+ commands on a just-merged branch (e.g. hand-editing files outside
226
+ git's own history primitives) are out of v1 scope, mirroring
227
+ branch-protection's documented scope-cut.
228
+ - **Chained-escape bypass**: escape-first is checked against the WHOLE
229
+ command string, not per-clause, so a curated mutation chained with an
230
+ escape verb (e.g. \`git commit -am x && git switch main\`) is fully
231
+ ALLOWED — the escape match wins for the entire compound command. Same
232
+ accepted class as the regex-vs-shell-eval residual above (\`eval\`,
233
+ heredoc, \`sh -c\`); not attempted to close here, and deliberate: the
234
+ alternative (per-clause splitting) would need a real shell parser.
235
+ - **Inverted trust-boundary residual**: a merged fact written by any
236
+ means OTHER than the producer (e.g. directly via
237
+ \`mcp__agent-grounding__ledger_add\`) that happens to exactly match the
238
+ CURRENT \`repo:branch:sha\` triple denies curated mutations on that
239
+ branch — recoverable via any escape command or by making a new commit
240
+ (which moves the tip and the fact no longer matches). A fact for a
241
+ different \`repo:branch:sha\` triple is inert; this is availability-only
242
+ (a spurious deny, always escapable), never a privilege the agent gains,
243
+ consistent with docs/okf/evidence-ledger-trust-boundary.md.
244
+ - **Contract B is coupled to \`gh\`'s current wording**: \`${GH_MERGE_SUCCESS_RE.source}\`
245
+ matches \`gh\`'s OWN past-tense success sentence, verbatim, as verified
246
+ against the installed \`gh\` v2.94.0 source
247
+ (\`pkg/cmd/pr/merge/merge.go\` lines 369-376). A future \`gh\` release
248
+ that rephrases that sentence makes
249
+ Contract B silently, FAIL-SAFELY inert on installs that never send
250
+ \`tool_output.exit_code\` (Contract A stays available wherever it's
251
+ actually sent) — never a false "merged" record, but the blocker also
252
+ never fires for that merge. Not attempted to close here (would need a
253
+ \`gh\` wire-format contract, e.g. \`--json\`, out of this pack's Bash-text
254
+ matching design). Verification path after upgrading \`gh\`: merge a
255
+ throwaway PR and check for a fresh \`${MERGED_TAG_PREFIX}\` fact in the
256
+ evidence ledger.
257
+ - **No Codex adapter**: see "Runtime" above.
258
+
259
+ ## Fail posture
260
+
261
+ Fails OPEN (allows, with a stderr warning) when the evidence ledger is
262
+ unreachable — deliberately the OPPOSITE of branch-protection's
263
+ fail-closed posture, because without the ledger "merged" and "not
264
+ merged" are indistinguishable, and fail-closed here would block ordinary
265
+ git history work on every branch whenever grounding-mcp hiccups. This
266
+ pack is advisory-strength against a hostile agent, like the other
267
+ \`requires.ledger_tag\`-style gates
268
+ (docs/okf/evidence-ledger-trust-boundary.md).
269
+
270
+ ## Pack metadata
271
+ ${description ? `\n> ${description.replace(/\n/g, "\n> ")}\n` : ""}
272
+ - Source: \`builtin\`
273
+ - Pack: \`${PACK_NAME}\`
274
+ - Runtime: \`${runtime}\`
275
+ `;
276
+ }
277
+ export function resolve(pack, runtime = DEFAULT_RUNTIME) {
278
+ const hooks = buildHooks();
279
+ const files = [
280
+ {
281
+ relativePath: `policy-packs/${PACK_NAME}/instructions.md`,
282
+ content: buildInstructions(pack, runtime),
283
+ },
284
+ ];
285
+ const warnings = [];
286
+ if (runtime === "codex") {
287
+ warnings.push(`policy_packs[${pack.name}]: post-merge-gate has no Codex adapter in v1; hooks are wired assuming the Claude Code Bash tool surface and will not fire correctly under a Codex runtime.`);
288
+ }
289
+ return { contribution: { hooks, files }, warnings };
290
+ }
291
+ //# sourceMappingURL=post-merge-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"post-merge-gate.js","sourceRoot":"","sources":["../../../src/policy-packs/builtin/post-merge-gate.ts"],"names":[],"mappings":"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"}