@lannguyensi/harness 0.44.0 → 0.46.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 (234) hide show
  1. package/CHANGELOG.md +1245 -0
  2. package/dist/cli/adopt/derive.d.ts +16 -7
  3. package/dist/cli/adopt/derive.js +19 -2
  4. package/dist/cli/adopt/derive.js.map +1 -1
  5. package/dist/cli/adopt/index.js +18 -3
  6. package/dist/cli/adopt/index.js.map +1 -1
  7. package/dist/cli/apply/apply.d.ts +1 -0
  8. package/dist/cli/apply/apply.js +62 -6
  9. package/dist/cli/apply/apply.js.map +1 -1
  10. package/dist/cli/apply/generate-codex-config.js +5 -6
  11. package/dist/cli/apply/generate-codex-config.js.map +1 -1
  12. package/dist/cli/apply/generate-opencode-config.d.ts +58 -0
  13. package/dist/cli/apply/generate-opencode-config.js +210 -0
  14. package/dist/cli/apply/generate-opencode-config.js.map +1 -0
  15. package/dist/cli/apply/generate-settings.d.ts +90 -32
  16. package/dist/cli/apply/generate-settings.js +133 -10
  17. package/dist/cli/apply/generate-settings.js.map +1 -1
  18. package/dist/cli/apply/index.d.ts +2 -1
  19. package/dist/cli/apply/index.js +2 -1
  20. package/dist/cli/apply/index.js.map +1 -1
  21. package/dist/cli/apply/next-steps.d.ts +20 -0
  22. package/dist/cli/apply/next-steps.js +60 -0
  23. package/dist/cli/apply/next-steps.js.map +1 -1
  24. package/dist/cli/approve/stdin-report.d.ts +15 -0
  25. package/dist/cli/approve/stdin-report.js +1 -1
  26. package/dist/cli/approve/stdin-report.js.map +1 -1
  27. package/dist/cli/approve/understanding.d.ts +75 -0
  28. package/dist/cli/approve/understanding.js +150 -2
  29. package/dist/cli/approve/understanding.js.map +1 -1
  30. package/dist/cli/audit.d.ts +1 -1
  31. package/dist/cli/audit.js +3 -2
  32. package/dist/cli/audit.js.map +1 -1
  33. package/dist/cli/doctor/claude-mcp.d.ts +19 -1
  34. package/dist/cli/doctor/claude-mcp.js +36 -10
  35. package/dist/cli/doctor/claude-mcp.js.map +1 -1
  36. package/dist/cli/doctor/codex.d.ts +3 -1
  37. package/dist/cli/doctor/codex.js +6 -9
  38. package/dist/cli/doctor/codex.js.map +1 -1
  39. package/dist/cli/doctor/format.js +94 -8
  40. package/dist/cli/doctor/format.js.map +1 -1
  41. package/dist/cli/doctor/index.d.ts +23 -0
  42. package/dist/cli/doctor/index.js +106 -2
  43. package/dist/cli/doctor/index.js.map +1 -1
  44. package/dist/cli/doctor/opencode.d.ts +24 -0
  45. package/dist/cli/doctor/opencode.js +107 -0
  46. package/dist/cli/doctor/opencode.js.map +1 -0
  47. package/dist/cli/doctor/target-checks.d.ts +11 -0
  48. package/dist/cli/doctor/target-checks.js +25 -0
  49. package/dist/cli/doctor/target-checks.js.map +1 -0
  50. package/dist/cli/doctor/toolchain-parity.d.ts +63 -0
  51. package/dist/cli/doctor/toolchain-parity.js +154 -0
  52. package/dist/cli/doctor/toolchain-parity.js.map +1 -0
  53. package/dist/cli/doctor/types.d.ts +85 -1
  54. package/dist/cli/doctor/types.js +1 -1
  55. package/dist/cli/doctor/types.js.map +1 -1
  56. package/dist/cli/doctor/understanding-mode-env.d.ts +39 -0
  57. package/dist/cli/doctor/understanding-mode-env.js +83 -0
  58. package/dist/cli/doctor/understanding-mode-env.js.map +1 -0
  59. package/dist/cli/dry-run.js +32 -17
  60. package/dist/cli/dry-run.js.map +1 -1
  61. package/dist/cli/explain.d.ts +1 -1
  62. package/dist/cli/explain.js +1 -1
  63. package/dist/cli/explain.js.map +1 -1
  64. package/dist/cli/index.js +64 -5
  65. package/dist/cli/index.js.map +1 -1
  66. package/dist/cli/init/composer.js +36 -13
  67. package/dist/cli/init/composer.js.map +1 -1
  68. package/dist/cli/init/interactive.d.ts +7 -4
  69. package/dist/cli/init/interactive.js +79 -27
  70. package/dist/cli/init/interactive.js.map +1 -1
  71. package/dist/cli/init/profiles.d.ts +2 -2
  72. package/dist/cli/init/profiles.js +32 -1
  73. package/dist/cli/init/profiles.js.map +1 -1
  74. package/dist/cli/init/templates.d.ts +2 -1
  75. package/dist/cli/init/templates.js +130 -20
  76. package/dist/cli/init/templates.js.map +1 -1
  77. package/dist/cli/pack/approve-escape.js +69 -8
  78. package/dist/cli/pack/approve-escape.js.map +1 -1
  79. package/dist/cli/pack/hook-branch-protection.js +1 -1
  80. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  81. package/dist/cli/pack/hook-codex-pre-tool-use.js +27 -1
  82. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  83. package/dist/cli/pack/hook-post-merge-gate.js +1 -1
  84. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
  85. package/dist/cli/pack/hook-pre-tool-use.js +91 -4
  86. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  87. package/dist/cli/pack/hook-solution-acceptance.js +19 -2
  88. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  89. package/dist/cli/pack/understanding-report-schema-hint.d.ts +13 -0
  90. package/dist/cli/pack/understanding-report-schema-hint.js +52 -3
  91. package/dist/cli/pack/understanding-report-schema-hint.js.map +1 -1
  92. package/dist/cli/pause/index.js +1 -1
  93. package/dist/cli/pause/index.js.map +1 -1
  94. package/dist/cli/policy/intercept.d.ts +148 -0
  95. package/dist/cli/policy/intercept.js +379 -48
  96. package/dist/cli/policy/intercept.js.map +1 -1
  97. package/dist/cli/session-start/index.d.ts +12 -3
  98. package/dist/cli/session-start/index.js +67 -11
  99. package/dist/cli/session-start/index.js.map +1 -1
  100. package/dist/cli/session-start/stale-base-check.d.ts +71 -0
  101. package/dist/cli/session-start/stale-base-check.js +379 -0
  102. package/dist/cli/session-start/stale-base-check.js.map +1 -0
  103. package/dist/cli/session-start/toolchain-parity.d.ts +142 -8
  104. package/dist/cli/session-start/toolchain-parity.js +244 -36
  105. package/dist/cli/session-start/toolchain-parity.js.map +1 -1
  106. package/dist/cli/smoke/assertions.d.ts +9 -2
  107. package/dist/cli/smoke/assertions.js +9 -2
  108. package/dist/cli/smoke/assertions.js.map +1 -1
  109. package/dist/cli/uninstall/index.js +18 -2
  110. package/dist/cli/uninstall/index.js.map +1 -1
  111. package/dist/cli/validate/checks.d.ts +3 -1
  112. package/dist/cli/validate/checks.js +298 -3
  113. package/dist/cli/validate/checks.js.map +1 -1
  114. package/dist/io/claude-mcp.d.ts +122 -1
  115. package/dist/io/claude-mcp.js +214 -12
  116. package/dist/io/claude-mcp.js.map +1 -1
  117. package/dist/io/duration.js.map +1 -0
  118. package/dist/io/expand-home.js.map +1 -0
  119. package/dist/io/extract.js.map +1 -0
  120. package/dist/io/harness-lock.js +1 -1
  121. package/dist/io/harness-lock.js.map +1 -1
  122. package/dist/{runtime → io}/ledger-record.d.ts +1 -1
  123. package/dist/io/ledger-record.js.map +1 -0
  124. package/dist/policies/index.d.ts +2 -2
  125. package/dist/policies/index.js +2 -2
  126. package/dist/policies/index.js.map +1 -1
  127. package/dist/policies/ledger-client.js +10 -6
  128. package/dist/policies/ledger-client.js.map +1 -1
  129. package/dist/policies/producers.js +1 -1
  130. package/dist/policies/producers.js.map +1 -1
  131. package/dist/policies/requires.js +2 -2
  132. package/dist/policies/requires.js.map +1 -1
  133. package/dist/policy-packs/builtin/branch-protection.js +28 -3
  134. package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
  135. package/dist/policy-packs/builtin/post-merge-gate.js +45 -9
  136. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
  137. package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +139 -8
  138. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +246 -12
  139. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
  140. package/dist/policy-packs/builtin/solution-acceptance.js +17 -3
  141. package/dist/policy-packs/builtin/solution-acceptance.js.map +1 -1
  142. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.d.ts +25 -0
  143. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js +89 -0
  144. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js.map +1 -0
  145. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +13 -0
  146. package/dist/policy-packs/builtin/understanding-before-execution/index.js +39 -0
  147. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -0
  148. package/dist/policy-packs/builtin/understanding-before-execution/ledger.d.ts +25 -0
  149. package/dist/policy-packs/builtin/understanding-before-execution/ledger.js +56 -0
  150. package/dist/policy-packs/builtin/understanding-before-execution/ledger.js.map +1 -0
  151. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.d.ts +20 -0
  152. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js +79 -0
  153. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js.map +1 -0
  154. package/dist/policy-packs/builtin/understanding-before-execution/markers.d.ts +103 -0
  155. package/dist/policy-packs/builtin/understanding-before-execution/markers.js +203 -0
  156. package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -0
  157. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +177 -0
  158. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +309 -0
  159. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -0
  160. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.d.ts +106 -0
  161. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +209 -0
  162. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -0
  163. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +78 -0
  164. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +185 -0
  165. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +1 -0
  166. package/dist/policy-packs/builtin/understanding-before-execution-runtime.d.ts +1 -536
  167. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js +9 -1095
  168. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js.map +1 -1
  169. package/dist/policy-packs/builtin/understanding-before-execution.d.ts +109 -1
  170. package/dist/policy-packs/builtin/understanding-before-execution.js +183 -15
  171. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  172. package/dist/policy-packs/registry.d.ts +11 -0
  173. package/dist/policy-packs/registry.js +1 -1
  174. package/dist/policy-packs/registry.js.map +1 -1
  175. package/dist/policy-packs/runtime.d.ts +1 -1
  176. package/dist/policy-packs/runtime.js +10 -1
  177. package/dist/policy-packs/runtime.js.map +1 -1
  178. package/dist/runtime/agent-facing.js +1 -1
  179. package/dist/runtime/agent-facing.js.map +1 -1
  180. package/dist/runtime/bash-match-facts.d.ts +9 -7
  181. package/dist/runtime/bash-match-facts.js +9 -7
  182. package/dist/runtime/bash-match-facts.js.map +1 -1
  183. package/dist/runtime/bash-match-registry.d.ts +75 -38
  184. package/dist/runtime/bash-match-registry.js +279 -232
  185. package/dist/runtime/bash-match-registry.js.map +1 -1
  186. package/dist/runtime/bash-prefix-parse.d.ts +7 -0
  187. package/dist/runtime/bash-prefix-parse.js +205 -17
  188. package/dist/runtime/bash-prefix-parse.js.map +1 -1
  189. package/dist/runtime/command-normalize.d.ts +112 -0
  190. package/dist/runtime/command-normalize.js +411 -38
  191. package/dist/runtime/command-normalize.js.map +1 -1
  192. package/dist/runtime/hermetic-spawn-guard.js +6 -0
  193. package/dist/runtime/hermetic-spawn-guard.js.map +1 -1
  194. package/dist/runtime/index.d.ts +2 -2
  195. package/dist/runtime/index.js +2 -2
  196. package/dist/runtime/index.js.map +1 -1
  197. package/dist/runtime/intercept.d.ts +78 -3
  198. package/dist/runtime/intercept.js +151 -39
  199. package/dist/runtime/intercept.js.map +1 -1
  200. package/dist/runtime/ledger-add.js +1 -1
  201. package/dist/runtime/ledger-add.js.map +1 -1
  202. package/dist/runtime/read-only-bash.js +303 -24
  203. package/dist/runtime/read-only-bash.js.map +1 -1
  204. package/dist/schema/doctor.d.ts +25 -0
  205. package/dist/schema/doctor.js +23 -0
  206. package/dist/schema/doctor.js.map +1 -0
  207. package/dist/schema/extract.js +1 -1
  208. package/dist/schema/extract.js.map +1 -1
  209. package/dist/schema/index.d.ts +76 -0
  210. package/dist/schema/index.js +9 -0
  211. package/dist/schema/index.js.map +1 -1
  212. package/dist/schema/requires.js +2 -2
  213. package/dist/schema/requires.js.map +1 -1
  214. package/dist/schema/risk.d.ts +7 -0
  215. package/dist/schema/risk.js +23 -0
  216. package/dist/schema/risk.js.map +1 -1
  217. package/dist/schema/stale-base-check.d.ts +18 -0
  218. package/dist/schema/stale-base-check.js +37 -0
  219. package/dist/schema/stale-base-check.js.map +1 -0
  220. package/dist/schema/toolchain-parity.d.ts +3 -0
  221. package/dist/schema/toolchain-parity.js +13 -0
  222. package/dist/schema/toolchain-parity.js.map +1 -1
  223. package/package.json +3 -2
  224. package/dist/policies/duration.js.map +0 -1
  225. package/dist/policies/extract.js.map +0 -1
  226. package/dist/runtime/expand-home.js.map +0 -1
  227. package/dist/runtime/ledger-record.js.map +0 -1
  228. /package/dist/{policies → io}/duration.d.ts +0 -0
  229. /package/dist/{policies → io}/duration.js +0 -0
  230. /package/dist/{runtime → io}/expand-home.d.ts +0 -0
  231. /package/dist/{runtime → io}/expand-home.js +0 -0
  232. /package/dist/{policies → io}/extract.d.ts +0 -0
  233. /package/dist/{policies → io}/extract.js +0 -0
  234. /package/dist/{runtime → io}/ledger-record.js +0 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,1251 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.46.0] - 2026-08-20
11
+
12
+ ### Fixed
13
+
14
+ - **`harness apply`'s post-apply "Next steps" hint (`formatNextSteps`,
15
+ `src/cli/apply/next-steps.ts`) ignored `--runtime` entirely and always
16
+ printed the Claude Code settings.json-merge / `claude -p` block, even
17
+ under `--runtime codex` and `--runtime opencode`** (task `f9d49e97`;
18
+ reviewer finding, batch18-R1, on task `f34eb233`, verified PRE-EXISTING).
19
+ Both suggested commands were impossible under those two runtimes:
20
+ `--target` is already rejected outright for `codex`/`opencode`
21
+ (`apply.ts`), and neither runtime emits a `settings.json`. `runtime` is
22
+ now threaded from the CLI's parsed `--runtime` option through to
23
+ `formatNextSteps`; under `codex` the hint now points at the generated
24
+ `codex/config.toml` and `harness apply --runtime codex --install`; under
25
+ `opencode` it points at `$OPENCODE_CONFIG` / copying the generated
26
+ `mcp` block, mirroring the wiring text each generator already prints in
27
+ its own banner (`generate-codex-config.ts`, `generate-opencode-config.ts`)
28
+ instead of inventing new wording. The default (`runtime` omitted, or
29
+ explicit `claude-code`) is byte-for-byte unchanged, pinned by a golden
30
+ test in `tests/cli/apply/next-steps.test.ts`.
31
+ - `check-changelog-coverage`'s `linkTokens` no longer drops a purely
32
+ numeric 8-digit task id (no hex letter, e.g. `13919613`; agent-tasks
33
+ issues those too), and the prior hex-letter-only gate silently failed
34
+ the gate for such a commit with no obvious cause (real gap seen in
35
+ Batch 19, PR #437; task `41f7eca5`). A numeric-only id now counts as a
36
+ link token when a `task`/`commit` keyword sits directly before it in
37
+ the commit's own message (e.g. "task `13919613`"); a bare 8-digit
38
+ number with no such adjacency, or one whose only nearby keyword comes
39
+ after it, still does not count, so a version number, an issue count,
40
+ or a timestamp fragment cannot create false coverage.
41
+
42
+ ## [0.45.0] - 2026-08-19
43
+
44
+ ### Internal
45
+
46
+ - The 4 grandfathered reverse-import exemptions (duration, extract,
47
+ expand-home, ledger-record) recorded as known debt in 0.44.0 are closed:
48
+ the utils moved to `src/io/` and the dependency-cruiser exemptions are
49
+ deleted (slice 4 of the structural-concentration work, task 61a37b25).
50
+ `src/io` stays deliberately unconstrained; a follow-up considers an
51
+ io-no-upward-imports rule.
52
+ - `src/policy-packs/builtin/understanding-before-execution-runtime.ts`
53
+ (formerly a single monolith) is now a 9-line re-export shim over 7
54
+ concern-scoped siblings under `understanding-before-execution/`
55
+ (`ledger.ts`, `markers.ts`, `persisted-reports.ts`, `lifecycle.ts`,
56
+ `task-markers.ts`, `post-tool-use-boundary.ts`, `active-claim.ts`),
57
+ composed by `index.ts` (slice 2 of the structural-concentration work,
58
+ task `348a4d42`). Pure move: every existing import of the old path
59
+ (hook-pre-tool-use.ts, hook-codex-pre-tool-use.ts,
60
+ cli/approve/understanding.ts, tests, etc.) keeps working unchanged.
61
+ `safeJsonParse` (module-private in the monolith) is now exported from
62
+ `persisted-reports.ts` so `markers.ts` can import it cross-file, but is
63
+ deliberately NOT re-exported from the shim's public surface (pinned by
64
+ `tests/policy-packs/ube-export-surface.test.ts`); a follow-up considers
65
+ moving it into `src/io/`.
66
+ - `approval_lifecycle.expire_on_bash_match`'s shipped `SOLO_TEMPLATE` /
67
+ `TEAM_TEMPLATE` / `FULL_TEMPLATE` regexes stay `^`-anchored
68
+ (`^gh pr (merge|close)\b`, `^git push origin (master|main)\b`) — no
69
+ functional change from the last release (task `fb80b5bb`). A same-task
70
+ first attempt widened both patterns to `\b`-scoped/un-anchored to close
71
+ a measured fail-open gap (a boundary command behind `cd <dir> &&`, an
72
+ env-var prefix, a subshell, or an inserted flag never expired the
73
+ marker); a follow-up review measured that widening end-to-end against
74
+ the real PostToolUse hook and found it made things worse: the
75
+ `harness approve understanding <<'RPT' ... RPT` flow became
76
+ self-revoking (a report body that legitimately quotes a boundary
77
+ command as part of the plan expired its own freshly-written marker,
78
+ an operator-only recovery), plus at least 8 everyday false positives
79
+ observed during the round-2 review probe (corpus not retained;
80
+ `grep`/`echo`/commit-message text) and at least 20 residual fail-open
81
+ forms still observed uncaught in that same probe. A scoped
82
+ `isEscapeCommand` exemption for the
83
+ approve-heredoc was considered and rejected: it would import a
84
+ documented divergence class (4 prior halts) into a new consumer.
85
+ Reverted `src/cli/init/profiles.ts`, `src/cli/init/templates.ts`,
86
+ `docs/examples/full-manifest.yaml`, and regenerated
87
+ `docs/examples/full-manifest.expected.yaml` (verified byte-for-byte
88
+ against the real `describe()` output) back to the shipped patterns.
89
+ New docs: "expire_on_bash_match: start-anchored, with a documented
90
+ fail-open limitation" in
91
+ `docs/policy-packs/understanding-before-execution.md`, naming the full
92
+ set of known fail-open forms (including `git push --force/-u origin
93
+ main`, `git push origin HEAD:main`, `git -c x=y push`, `gh --repo o/r
94
+ pr merge`, whitespace variants), the safety-net role of `max_age`
95
+ (`1h` SOLO / `4h` TEAM+FULL), and a separate known gap: the interactive
96
+ custom profile (`harness init --interactive`, `composeCustom()` in
97
+ `src/cli/init/composer.ts`) sets `expire_on_tool_match` and `max_age`
98
+ but never sets `expire_on_bash_match` at all — tracked as a follow-up
99
+ (task `90eae119-cb77-4941-975c-7d2930e685d8`), not fixed here. Updated
100
+ `tests/cli/init-full-template-pins.test.ts`:
101
+ restored the "expire_on_bash_match is a separate anchored family,
102
+ untouched" guard's original `^`-anchored assertions and added a
103
+ trailing-`\b` structural check that the pre-fb80b5bb version of this
104
+ test never had (a round-2 mutation probe proved its absence would go
105
+ undetected); added an "anchored-pattern behavior" describe block
106
+ pinning positive matches, the documented known-miss forms, and
107
+ negative false-positive-avoidance forms (including the approve-heredoc
108
+ shape) against the actual shipped regexes; hoisted the `bashMatchers`
109
+ helper (duplicated across two describe blocks during this task's first
110
+ attempt on this branch — master carried only one definition) to module
111
+ scope. Mutation-verified: removing the `^` anchor from a pattern turns
112
+ both the structural guard and the false-positive-avoidance pins red;
113
+ removing only the trailing `\b` turns solely the new end-anchor check
114
+ red — both restored to green afterward. Round 2b: the "separate
115
+ anchored family, untouched" guard gained a whole-value
116
+ `toEqual([...])` pin over the two literals (the pre-existing
117
+ per-pattern shape checks stayed green under a widening such as
118
+ `(master|main)` -> `(master|main|develop)`); the known-miss `it.each`
119
+ array gained the 4 forms the docs already named but the array did not
120
+ yet pin (`git -c user.name=x push origin main`, `git push origin
121
+ HEAD:main`, `gh --repo owner/repo pr merge 42`, doubled whitespace);
122
+ and `tests/cli/init-composer.test.ts` gained a pin for the
123
+ interactive-composer gap named above (task
124
+ `90eae119-cb77-4941-975c-7d2930e685d8`).
125
+
126
+ ### Added
127
+
128
+ - `generateSettingsWithWarnings` (`src/cli/apply/generate-settings.ts`)
129
+ gains `projectSigningKeyEnv`, projecting the harness's own approval-
130
+ signing key path (`<generatedDir>/.approval-signing.key`,
131
+ `signingKeyPathFor`) as the `SOLUTION_VERDICT_SIGNING_KEY` env on the
132
+ `tools.mcp[grounding-mcp]` entry, exactly mirroring the existing
133
+ `EVIDENCE_LEDGER_DB` / `projectGroundingEnv` projection (task
134
+ `03a917fd`/H1, agent-grounding `9b6c4beb` comment 2, Option 2): only
135
+ the fully-resolved, absolute key PATH is projected, never the key
136
+ bytes; an operator-declared `env.SOLUTION_VERDICT_SIGNING_KEY` on the
137
+ entry wins; no projection fires without a grounding-mcp entry or
138
+ without the new `GenerateSettingsExtras.generatedDir` passed in
139
+ (there is no safe default to guess). Wiring the real, resolved
140
+ `generatedDir` through `harness apply`'s and `harness init`'s call
141
+ sites (task `03a917fd`/H1b) is now live: `generate-opencode-config.ts`'s
142
+ `generateOpencodeConfig` gained the matching `generatedDir` extra and
143
+ `apply.ts`'s `buildExpectedFiles` threads its own resolved
144
+ `generatedDir` into both that call and its `generateSettingsWithWarnings`
145
+ call; `init/interactive.ts`'s `loadDesiredMcpServers` — the function
146
+ that feeds the REAL `claude mcp` CLI registration (`ensureMcpServers`)
147
+ — now also projects `SOLUTION_VERDICT_SIGNING_KEY`, mirroring
148
+ `EVIDENCE_LEDGER_DB` there; `adopt/derive.ts`'s `manifestMcpProjection`
149
+ gained the matching manifest-side mirror so an apply→adopt cycle does
150
+ not report phantom drift on grounding-mcp for this key either. One
151
+ caveat carried over from `EVIDENCE_LEDGER_DB`/T-002: settings.json's
152
+ `mcpServers` block (and therefore `apply.ts`'s
153
+ `generateSettingsWithWarnings` call specifically) is dead at runtime —
154
+ Claude Code's real MCP registration goes exclusively through the
155
+ `claude mcp` CLI path in `interactive.ts`, so the settings.json branch's
156
+ `generatedDir` threading is wired for symmetry/future-proofing but has
157
+ no independently observable effect today.
158
+ - Review round H1 fix-round (task `03a917fd`): `generate-settings.ts`
159
+ gains `buildDesiredMcpServers(manifest, {homeDir, generatedDir})`, a
160
+ single choke-point wrapper around `buildMcpServers` ->
161
+ `projectGroundingEnv` -> `projectSigningKeyEnv`. Every producer of the
162
+ Claude-Code-shaped MCP server-spec map — `harness apply`'s
163
+ settings.json generation, `init --interactive`'s `claude mcp` CLI
164
+ wiring, `harness doctor`'s registration-health check, and the opencode
165
+ config generator — now calls this one function instead of hand-rolling
166
+ the same three-call sequence, closing the gap where doctor's "not
167
+ registered, run this `claude mcp add-json ...`" hint for grounding-mcp
168
+ silently omitted `SOLUTION_VERDICT_SIGNING_KEY` (3 of the 4 producers
169
+ called `projectSigningKeyEnv`, doctor did not). `projectSigningKeyEnv`
170
+ also normalizes a relative or literal-tilde `generatedDir`
171
+ (`expandHome` + `path.resolve`, mirroring `groundingLedgerEnvValue`'s
172
+ idiom for `EVIDENCE_LEDGER_DB`) into an absolute path instead of
173
+ silently under-projecting. `docs/ARCHITECTURE.md`'s `grounding:`
174
+ wiring-status note and `GenerateSettingsResult.mcpServers`'s doc
175
+ comment now name both projections.
176
+ - `harness doctor` gains an on-demand toolchain-parity section (task
177
+ `13919613`, commit `16d47941`), the doctor companion to `harness session-start
178
+ toolchain-parity`: reuses that command's Collector/Comparator core
179
+ (`collectLocalSnapshot`, `compareToPeer`, `parseSnapshotJson`,
180
+ `formatSnapshotAge`, the real collectors) verbatim — no comparison
181
+ logic is duplicated — to compare the current live toolchain against
182
+ whatever peer snapshots already exist in `machine_state_dir`, without
183
+ ever writing a snapshot file or a ledger fact. Present only when
184
+ `toolchain_parity.enabled: true` (omitted otherwise), following the
185
+ same optional-section convention as `npmGlobalBin`/the claude-code MCP
186
+ registration check. `--shallow` skips the live collection (no
187
+ `node`/`npm` spawn), mirroring those same checks. Drift always rolls
188
+ into `warningCount`, never `errorCount` — advisory only, not a gate,
189
+ same contract as the SessionStart producer. Documented in
190
+ `docs/CLI.md`.
191
+ - `harness session-start toolchain-parity` hardening round (task
192
+ `c1b5ade5`, follow-up to the initial 690fba7c drop): (1) a lossy
193
+ `sanitizeProfileName` advisory — a configured `profile` that is not
194
+ filename-safe now logs a warning, plus a stronger WARNING when the
195
+ sanitized filename collides with an existing snapshot for a DIFFERENT
196
+ profile (this run's write is about to overwrite it); (2) defensive
197
+ try/catch around the `resolveSession` and `writeLedger` call sites — a
198
+ throw/rejection from either now degrades to a note + exit 0 instead of
199
+ an unhandled throw/rejection, same as every other failure path in this
200
+ producer (deliberately NOT applied to `collectLocalSnapshot`, which the
201
+ hermetic-spawn-guard contract requires to propagate uncaught); (3) a
202
+ new optional `toolchain_parity.stale_after_days` manifest knob
203
+ (`src/schema/toolchain-parity.ts`, default off) — a peer snapshot older
204
+ than the threshold gets an extra advisory note without being counted
205
+ as drift. Advisory character is unchanged throughout: every PRODUCTION
206
+ path exits 0 (an injected collector that throws propagates by the
207
+ hermetic-spawn-guard contract, corrected wording — see Round 2b below),
208
+ and `ok`/drift comparator semantics are untouched. Also
209
+ corrects the `toolchain-parity` hook's `budget_ms: 10000` comment in
210
+ `src/cli/init/templates.ts`, which previously described only the
211
+ parallel node/npm collection and missed that the ledger write runs
212
+ SEQUENTIALLY after with its own ~5s default timeout floor (worst case
213
+ ~9s, not the "comfortable" sub-5s headroom the old comment claimed).
214
+ **Round 2 (review fix-round, same task):** (a) the collision-warning
215
+ note and the stale-peer note both interpolated an untrusted,
216
+ cross-machine-synced `profile` field into the stderr stream
217
+ unsanitized — a crafted profile string (embedded newline plus
218
+ note-prefix-shaped text) could forge a standalone parity line;
219
+ both sites now wrap the value in `sanitizeProfileName(...)`, same as
220
+ the pre-existing filename-label site (the two "ok against"/"drift:N
221
+ against" sibling notes stay unsanitized — a known, separate gap,
222
+ scoped out of this round); (b) the collision check was one-sided — it
223
+ only ran on the lossy machine's own run, so a filename-safe machine
224
+ silently overwrote a lossy peer's snapshot with no signal and that
225
+ peer silently dropping out of `peersCompared`; the existing-file read
226
+ now runs on every invocation, and only the "not filename-safe" note
227
+ stays scoped to the lossy case; (c) a session resolver that throws
228
+ while the stdin event DID carry a `session_id` used to still report
229
+ `sessionSource: "stdin"` even though `sessionId` had actually fallen
230
+ back to `"default"` — misrepresenting the ledger fact's provenance and
231
+ suppressing the AC5 "resolved to default" warning; a `resolverThrew`
232
+ flag now short-circuits `sessionSource` to `"default"` whenever the
233
+ catch fires; (d) `stale_after_days` gained `.finite()` — `.positive()`
234
+ alone accepted `Infinity` (reachable via YAML `.inf`), silently turning
235
+ the staleness check into a permanent no-op. The try/catch this round
236
+ removes was around `resolveManifestLedgerWriter`: unlike the two
237
+ real I/O/spawn-adjacent seams it sat next to (`resolveSession`,
238
+ `writeLedger`), that call has no throw path at all — it is a pure
239
+ function over an already-validated `manifest` (a plain array `.find`
240
+ plus a string split underneath) — so the catch was dead, untested code
241
+ (round-1 review finding) and is removed rather than built out into a
242
+ fake seam; the two genuine I/O sites stay caught.
243
+ **Round 2b (second review fix-round, same task, `c1b5ade5`):** R2's
244
+ review, re-run against the R2 fix, proved the log-injection class was
245
+ still open across roughly ten call sites in three vector families — R2
246
+ had sanitized only the collision-warning and stale-peer notes, leaving
247
+ every other note() call reachable by untrusted content: the "ok
248
+ against"/"drift:N against" notes (raw `comparison.peerProfile`, never
249
+ wrapped by R2 at all), every individual drift message from
250
+ `compareToPeer` (untrusted `peer.node`/`owKitVersion`/npm/mcp names,
251
+ reproduced against a real forged `parity:unparseable-peer:INJECTED` tag),
252
+ and two of the three peer-file-read notes (the corrupt-file and
253
+ unreadable-file lines used a raw `fileName`, while only the third,
254
+ `peerLabel`, was sanitized). Fix: moved the CR/LF strip to the shared
255
+ `note()` closure itself — `msg.replace(/[\r\n]/g, " ")`, once, before
256
+ every stderr write — closing the whole class at its one choke point
257
+ instead of requiring every present and future call site to remember its
258
+ own sanitization. With injection protection centralized in `note()`, the
259
+ three R2-added `sanitizeProfileName(...)` display wraps (collision note,
260
+ unparseable-peer `peerLabel`, stale-peer note) are reverted back to the
261
+ raw field: `sanitizeProfileName` is a FILENAME escaper (documented at
262
+ its definition) that also lossily mangles a legitimate non-ASCII profile
263
+ name, so reusing it for display was both redundant once `note()`
264
+ protects the line and a correctness regression — the stale-peer note's
265
+ sanitized spelling could differ from the same peer's raw spelling on the
266
+ "ok against" line two lines above it, within the SAME run. `note()`
267
+ itself is now the only site responsible for log-injection safety; the
268
+ filename path (`ownFileName`/`writeOwnSnapshot`) is unaffected and still
269
+ goes through `sanitizeProfileName`. Also, three unrelated LOW findings
270
+ from the same review pass: (a) the collision-warning note now appends a
271
+ one-line remediation hint ("...set a distinct `toolchain_parity.profile`
272
+ on one of the machines"); (b) the collision pre-read's previously-bare
273
+ `catch {}` is narrowed to re-note any error whose `code` is not `ENOENT`
274
+ (a genuinely missing file is still silent — EACCES/EISDIR/etc. is now a
275
+ one-line note that the collision check silently did not run, rather than
276
+ vanishing into the same branch as "no existing file"); (c) the ledger
277
+ write's `result.reason ?? "unknown error"` fallback is kept, with a
278
+ comment explaining why (`AddLedgerFactResult`'s `!ok` arm types `reason`
279
+ as non-optional, so the fallback is unreachable through any type-checked
280
+ caller — but unlike the pure `resolveManifestLedgerWriter` call the R2
281
+ catch-removal above targeted, `writeLedger` is an injectable *public*
282
+ seam a JS-level or otherwise untyped caller can reach with a malformed
283
+ return value at runtime, so this one stays). New/updated tests: a
284
+ note()-level pin (an arbitrary thrown message with an embedded newline
285
+ produces no second stderr line), dedicated regressions for the three
286
+ reviewer-reproduced vectors (hostile `peer.profile`, hostile `peer.node`
287
+ in a drift message, hostile peer filename with an embedded newline —
288
+ each asserted against the exact greppable prefix it tried to forge), a
289
+ negative control pinning that the collision check does NOT fire when a
290
+ machine's own profile matches its own existing snapshot (guards a
291
+ "warn-always" mutation), an EISDIR case for the narrowed catch, and a
292
+ label-coherence test showing one peer's name renders identically on the
293
+ "ok against" and stale-peer lines in the same run.
294
+
295
+ ### Security
296
+
297
+ - No functional change: the known fail-open limitation of
298
+ `approval_lifecycle.expire_on_bash_match` is now documented — see the
299
+ Internal entry above (task `fb80b5bb`) and
300
+ `docs/policy-packs/understanding-before-execution.md`.
301
+ - **SECURITY: `harness session-start toolchain-parity`'s stderr log-injection
302
+ class is closed, across every call site, not just the two R2 patched**
303
+ (task `c1b5ade5` R2b). The machine-state directory is populated
304
+ cross-machine by `agent-memory-sync`, so a peer's snapshot content
305
+ (`profile`, `node`, `owKitVersion`, npm/mcp package names) and even a
306
+ peer's raw filename are all attacker-influenced input; a crafted value
307
+ containing an embedded newline plus note-prefix-shaped text could forge
308
+ a standalone, fully fake `harness session-start toolchain-parity: ...`
309
+ line in stderr — including the exact greppable
310
+ `parity:unparseable-peer:<label>` / `toolchain-parity:ok` /
311
+ `toolchain-parity:drift:<n>` tags an operator or `harness audit` would
312
+ grep for. R2 (same task, prior round) sanitized only 2 of the roughly 10
313
+ reachable sites (the collision-warning and stale-peer notes); this round
314
+ closes the rest — the "ok against"/"drift:N against" notes, every
315
+ individual `compareToPeer` drift message, and the two previously-raw
316
+ peer-file-read notes — by stripping CR/LF once, in the shared `note()`
317
+ closure all of these funnel through, rather than continuing to require a
318
+ wrap at each call site. The pre-existing 876/878-area peer-unreadable and
319
+ peer-corrupt notes (raw `fileName`) are covered by this same fix; there
320
+ is no remaining open site in this class and no follow-up is needed.
321
+ - **The `solution-acceptance` policy pack's verdict marker is now HMAC-signed, reusing the SAME `signMarker` / `verifyMarkerSignature` primitive the understanding-gate approval marker (and its branch-protection twin) shipped in v0.41.0 (task `f9485cc7`) — this is the "solution-acceptance's verdict marker remains unsigned" residual that entry's own follow-up note tracked separately** (task `c7c3f606`, pattern + exemplar). `evaluateGate` (`src/policy-packs/builtin/solution-acceptance-runtime.ts`) now verifies the verdict's signature BEFORE trusting `ready`/`head` at all: the signed payload mirrors the approval marker's `(approvedAt, approvedBy, reportContentHash)` shape onto the verdict's own fields — `timestamp` plays `approvedAt`, `source` plays `approvedBy`, and a sha256 content hash of `head`/`ready`/`confidence`/`blockers` plays `reportContentHash` — so tampering ANY of those fields post-signing invalidates the signature, not just editing `signature` itself. `harness pack hook solution-acceptance` REJECTS a verdict with a missing or invalid signature: `allowed:false`, same as no verdict at all, but with a distinct `forged/unsigned solution-acceptance verdict rejected` reason (new `GateResult.forged` field) so an operator/auditor can tell an active forgery attempt (or a not-yet-signing producer) apart from the routine "no verdict yet" / "not ready yet" cases. **Back-compat is strict, no migration window**, the SAME trade-off f9485cc7 made: a verdict with no `signature` field is rejected exactly like a forgery.
322
+ - **Honest residual (read before assuming more than it delivers): this is CONSUMER-SIDE ONLY.** Unlike the understanding-gate marker, harness does not WRITE the solution-acceptance verdict — the producer is `@lannguyensi/grounding-mcp`, a separate package/repo. This task shipped the pattern + exemplar (`signVerdict` / `verifyVerdictSignature`, exported from `solution-acceptance-runtime.ts`, are the reusable pair a producer-side change mirrors) but **no currently-released grounding-mcp version signs its output**. Concretely, until a matching producer release ships (a tracked cross-repo follow-up): every verdict this consumer reads is "unsigned" and the completion-gate denies it UNIVERSALLY, even a perfectly legitimate `ready:true` verdict at the correct HEAD; re-running `solution_evaluate` does not recover from this (the new verdict is unsigned too); `harness pause`, or temporarily disabling the pack, is the operator's interim recourse. grounding-mcp's own `solution_gate` does not yet enforce this signature either — only this harness consumer does, until the producer-side change closes that asymmetry. `docs/policy-packs/solution-acceptance.md` "Marker signing" spells this out in full. The persisted-report approval path (task `7402301d`) and the write-guard's glob-every-segment / interpreter-runtime-path-construction residual are UNCHANGED by this task and remain separately tracked.
323
+ - New tests: `tests/policy-packs/solution-acceptance-runtime.test.ts` (sign/verify round-trip, forged/unsigned rejection distinct from no-marker, hand-written-marker-without-key forgery regression, one-tampered-signature-byte regression, tampering `ready` without touching `signature`, cross-id signature-replay rejection, signing-key-I/O-failure-not-classified-as-forged, and the golden-fixture tests updated to assert the real UNSIGNED 0.3.2/0.5.0 producer markers are now rejected while a locally-signed copy of the same content still gates correctly) and `tests/cli/pack-hook-solution-acceptance.test.ts` (the same forgery/tamper regressions exercised through the real hook entrypoint).
324
+ - **The `solution-acceptance` policy pack's verdict marker is now HMAC-signed, reusing the SAME `signMarker` / `verifyMarkerSignature` primitive the understanding-gate approval marker (and its branch-protection twin) shipped in v0.41.0 (task `f9485cc7`) — this is the "solution-acceptance's verdict marker remains unsigned" residual that entry's own follow-up note tracked separately** (task `c7c3f606`, pattern + exemplar). `evaluateGate` (`src/policy-packs/builtin/solution-acceptance-runtime.ts`) now verifies the verdict's signature BEFORE trusting `ready`/`head` at all: the signed payload mirrors the approval marker's `(approvedAt, approvedBy, reportContentHash)` shape onto the verdict's own fields — `timestamp` plays `approvedAt`, `source` plays `approvedBy`, and a sha256 content hash of `head`/`ready`/`confidence`/`blockers` plays `reportContentHash` — so tampering ANY of those fields post-signing invalidates the signature, not just editing `signature` itself. `harness pack hook solution-acceptance` REJECTS a verdict with a missing or invalid signature: `allowed:false`, same as no verdict at all, but with a distinct `forged/unsigned solution-acceptance verdict rejected` reason (new `GateResult.forged` field) so an operator/auditor can tell an active forgery attempt (or a not-yet-signing producer) apart from the routine "no verdict yet" / "not ready yet" cases. **Back-compat is strict, no migration window**, the SAME strict no-grace-period POLICY f9485cc7 made: a verdict with no `signature` field is rejected exactly like a forgery. (The RECOVERY shape differs, though — see the honest-residual bullet below.)
325
+ - **Fix-round-2 (review R1 HIGH): cross-id replay via a verbatim file copy is now closed.** `verifyVerdictSignature` previously derived the HMAC markerId from `verdict.id` — a field read back out of the marker BODY, which an attacker controls simply by copying a validly-signed verdict file onto a DIFFERENT id's marker path (the body's `id` travels with the copy, unchanged). `evaluateGate` never compared `verdict.id` against the id it was actually checking, so a verbatim copy of one task's ready verdict onto a second task's path passed verification and ALLOWED the second task to complete. `verifyVerdictSignature` now takes the CALLER's id explicitly and derives the markerId from it, matching the load-bearing invariant `approval-signing.ts` already documents ("verification always recomputes the HMAC using the id the caller is checking, NOT one read back out of the marker body"); `evaluateGate` additionally rejects outright when `verdict.id !== id` (belt-and-braces — the signed payload does not cover `id` itself, so this catches a post-signing edit to that one field that the markerId fix alone would not).
326
+ - **Fix-round-2 (review R1 LOW): the hook's STDERR diagnostic now consumes `GateResult.forged`.** `harness pack hook solution-acceptance` appends a short `[audit: forged/unsigned verdict marker rejected]` tag to its operator-facing diagnostic line specifically when `forged: true`, mirroring the `markerForgedNote` audit-tag pattern already used by `hook-pre-tool-use.ts` / `hook-branch-protection.ts` — previously `GateResult.forged` was computed but never read by any production code path.
327
+ - **Fix-round-2 (review R1 LOW): a verdict missing `timestamp` or `source` is no longer misclassified as forged.** Those two fields map onto `verifyMarkerSignature`'s `approvedAt` / `approvedBy`; a blank one is a legitimately malformed marker (e.g. a producer bug), not necessarily an active forgery attempt. `evaluateGate` now carves the `"missing approvedAt"` / `"missing approvedBy"` verification reasons out of the generic forged bucket — fail-closed (`allowed: false`) either way, but `forged: false`, the same shape as the existing signing-key-unavailable carve-out.
328
+ - **Fix-round-2b (review R2 MED, audit-fidelity): the missing-timestamp/missing-source carve-out above is now scoped to verdicts with NO signature at all.** As shipped, the carve-out fired on the verification REASON string alone, regardless of whether the verdict actually carried `alg`/`signature` fields — so a forger who mutated an otherwise VALIDLY-SIGNED verdict to blank its `timestamp` (or `source`) suppressed the `forged: true` audit tag entirely: `verifyMarkerSignature` checks `approvedAt`/`approvedBy` for blankness BEFORE ever comparing the signature, so the blanked-but-signed marker hit the same `"missing approvedAt"` reason as a genuinely never-signed one and inherited its `forged: false` classification. The block itself still held either way (`allowed: false` throughout — this was never a bypass), but the audit trail lied about what happened. `evaluateGate` now additionally requires `verdict.signature === undefined && verdict.alg === undefined` before granting the carve-out; a verdict that carries `alg`/`signature` but still hits the blank-field reason falls through to the generic `forged: true` bucket instead, since a signed field reading blank only happens via post-signing tampering. The genuinely-unsigned case (no `alg`/`signature` fields, blank `timestamp`/`source`) keeps its `forged: false` classification unchanged — that one remains a defensible "unsigned legacy marker" read, not an attack signal.
329
+ - **Honest residual (read before assuming more than it delivers): this is CONSUMER-SIDE ONLY.** Unlike the understanding-gate marker, harness does not WRITE the solution-acceptance verdict — the producer is `@lannguyensi/grounding-mcp`, a separate package/repo. This task shipped the pattern + exemplar (`signVerdict` / `verifyVerdictSignature`, exported from `solution-acceptance-runtime.ts`, are the reusable pair a producer-side change mirrors) but **no currently-released grounding-mcp version signs its output**. Concretely, until a matching producer release ships (a tracked cross-repo follow-up): every verdict this consumer reads is "unsigned" and the completion-gate denies it UNIVERSALLY, even a perfectly legitimate `ready:true` verdict at the correct HEAD; re-running `solution_evaluate` does not recover from this (the new verdict is unsigned too); `harness pause`, or temporarily disabling the pack, is the operator's interim recourse. grounding-mcp's own `solution_gate` does not yet enforce this signature either — only this harness consumer does, until the producer-side change closes that asymmetry. `docs/policy-packs/solution-acceptance.md` "Marker signing" spells this out in full. **Unlike f9485cc7's residual, there is no operator-side command that resolves this one**: f9485cc7's producer and consumer live in this one repo, so a denied operator has a local one-command fix (`harness approve understanding` / `harness approve branch-protection`); here the producer is a separate package on its own release cadence, so nothing an operator runs today makes a verdict pass until that producer release ships. The persisted-report approval path (task `7402301d`) and the write-guard's glob-every-segment / interpreter-runtime-path-construction residual are UNCHANGED by this task and remain separately tracked.
330
+ - New tests: `tests/policy-packs/solution-acceptance-runtime.test.ts` (sign/verify round-trip, forged/unsigned rejection distinct from no-marker, hand-written-marker-without-key forgery regression, one-tampered-signature-byte regression, tampering `ready` without touching `signature`, relabeled-body signature-replay rejection, a VERBATIM file-copy cross-id replay regression, an isolated `verdict.id !== id` pin independent of the markerId fix, a sanitize-collision regression (`"a:b"` vs `"a_b"` colliding on one file path), the two missing-timestamp/missing-source not-forged carve-outs (now exercised via a genuinely UNSIGNED marker), signing-key-I/O-failure-not-classified-as-forged, the golden-fixture tests updated to assert the real UNSIGNED 0.3.2/0.5.0 producer markers are now rejected while a locally-signed copy of the same content still gates correctly, and — fix-round-2b — a SIGNED-verdict-with-blanked-timestamp/source regression pinning `forged: true` (audit finding A8) plus a minimal-hand-written-marker negative control pinning `forged: false` (audit finding A7)) and `tests/cli/pack-hook-solution-acceptance.test.ts` (the same forgery/tamper/cross-id-replay regressions and the forged-audit-tag diagnostic, exercised through the real hook entrypoint, plus — fix-round-2b — the SIGNED-and-blanked-timestamp forged-tag regression, the `verdict.id !== id` cross-id-mismatch-with-a-still-valid-signature regression, and forged-tag negative controls on the not-ready and stale-head denial paths).
331
+ - **SECURITY (LOW): `isEscapeCommand`'s top-level `command.trim()`
332
+ (`src/cli/pack/approve-escape.ts`) stripped a TRAILING report-heredoc line
333
+ made only of a non-bash-blank whitespace codepoint (e.g. NBSP, U+2028)
334
+ before the 623640a5 `after.every(/^[ \t]*$/)` post-terminator check ever
335
+ saw it, making that check unreachable through the public API for exactly
336
+ the shape it exists to reject** (task `508a2d81`, a residual the
337
+ 623640a5 fold self-disclosed). PATH-stub verified against real bash: such
338
+ a line is not inert — bash attempts to execute it as a real command.
339
+ Fix: the top-level trim now strips only TAB, SPACE, LF, and CR from the
340
+ string's boundaries (never the full JS `\s`/Unicode-whitespace class), so
341
+ every other `\s` codepoint survives to the after-check.
342
+ - **SECURITY: the agent-facing deny envelope of the built-in `gate-prod-destructive`
343
+ Risk Gate policy named the session-wide kill switch (`harness pause --for
344
+ <duration>` / `harness resume`) as a way to unblock a denied critical
345
+ production mutation** (task `70781cf6`). `ux.run` is the exact "what to do
346
+ next" text the blocked agent reads (`renderAgentFacing`,
347
+ `src/runtime/intercept.ts`); handing that same agent a recipe for silencing
348
+ every gate in the session is a full bypass instruction addressed to the
349
+ party who was just blocked, not to the operator who alone can act on it.
350
+ Removed the kill-switch line from `gate-prod-destructive`'s `ux.run` in both
351
+ `src/cli/init/templates.ts` (`FULL_TEMPLATE`) and
352
+ `docs/examples/full-manifest.yaml`; the two remaining `run:` entries (choose
353
+ a non-destructive alternative, or ask the operator for a deliberate `harness
354
+ approve risk --force` override) stay unchanged and fully actionable. The
355
+ operator-facing recovery path is not lost: `harness pause`'s own refusal
356
+ message already explains the correct route (a terminal genuinely outside the
357
+ agent session). While auditing every other shipped `ux.run` line for the
358
+ same class of leak, the `deny-kill-switch-bypass` and
359
+ `deny-pause-sentinel-forgery` policies' `run:` text also named `pause`/
360
+ `resume` as something the operator could be asked to do; reworded both to
361
+ point back at the commands already named in their `cannot`/`required` text
362
+ instead of repeating the verbs in the actionable `run:` list.
363
+ `deny-session-env-strip`'s `run:` line did not name either verb and is
364
+ unchanged. Fix round 1 (Finding 3, reviewer-flagged): reworded
365
+ `deny-pause-sentinel-forgery`'s `run:` line again — its first rewording's
366
+ "Ask the OPERATOR to do that themselves" had an ambiguous antecedent (the
367
+ nearest noun phrase was the just-forbidden sentinel write, not the
368
+ operator's actual recovery action); it now points explicitly at "the
369
+ operator-only command named under Required" instead, without
370
+ reintroducing `pause`/`resume` into the `run:` text itself.
371
+
372
+ New/updated tests: `tests/cli/init-no-agent-facing-pause-hint.test.ts` is
373
+ a repo-wide guard over every agent-facing `ux.run` line the init/describe
374
+ surface can emit: `manifest.policies[].ux.run` AND
375
+ `manifest.policy_packs[].config.ux.run` (understanding-before-execution,
376
+ branch-protection, post-merge-gate) across `FULL_TEMPLATE`,
377
+ `SOLO_TEMPLATE`, `TEAM_TEMPLATE`, and `docs/examples/full-manifest.yaml`,
378
+ plus the Custom composer (`composeCustom()` in
379
+ `src/cli/init/composer.ts`, `harness init --interactive`'s Custom
380
+ profile) with every composable pack and policy selected. Fix round 1
381
+ (Finding 1, reviewer-flagged as a MEDIUM coverage gap) added the
382
+ `policy_packs`/composer coverage and the `harness gate disable`/`harness
383
+ gate enable` sibling-verb check — the original guard only walked
384
+ `manifest.policies[].ux.run`, so a kill-switch hint reworded into a
385
+ policy pack's `ux.run` (or into the Custom composer's own copy) would
386
+ have passed both this guard and the pre-existing parity tests
387
+ unnoticed. Each scanned source also now asserts it found at least one
388
+ `ux.run` line (Finding 2): before the round-1 fix, `SOLO_TEMPLATE` has
389
+ zero top-level `policies:` entries, so that arm silently scanned zero
390
+ lines under the original narrower guard and could not have caught
391
+ anything. A "see also" citation to
392
+ `docs/okf/pause-vs-gate-kill-switch.md` stays allow-listed as
393
+ non-actionable, and a dedicated assertion pins that
394
+ `gate-prod-destructive`'s `ux.run` has exactly its two legitimate
395
+ entries left. Regenerated `docs/examples/full-manifest.expected.yaml`
396
+ (the `harness describe` golden fixture) to match. Coverage note
397
+ (Finding 4, reviewer-flagged as inaccurate): the existing
398
+ `tests/cli/init-full-template-parity.test.ts` is the test that
399
+ deep-equality-checks `FULL_TEMPLATE` against
400
+ `docs/examples/full-manifest.yaml` (including each policy_pack's
401
+ `config`, so its `ux.run` too); `tests/cli/init-templates-ux-parity.test.ts`
402
+ is a separate drift guard that instead pins `FULL_TEMPLATE` /
403
+ `SOLO_TEMPLATE` / `TEAM_TEMPLATE` / the Custom composer's policy_pack
404
+ `ux` against the canonical `defaultUx()` functions in
405
+ `src/policy-packs/builtin/*.ts`, not against `full-manifest.yaml`.
406
+
407
+ - **SECURITY: `harness apply`'s generated Claude Code `settings.json` hook
408
+ `timeout` was emitted in the manifest's `budget_ms` unit unconverted, so
409
+ every Claude-hook kill-timer was 1000x too large** (task `7bf47554`).
410
+ Claude Code's settings.json hook `timeout` is documented in SECONDS
411
+ ("Seconds before canceling",
412
+ https://code.claude.com/docs/en/hooks); the manifest's `budget_ms` is
413
+ milliseconds. `toSettingsCommand` (`src/cli/apply/generate-settings.ts`)
414
+ emitted `timeout: h.budget_ms` with no unit conversion, so a template
415
+ budget of 1000-2000ms silently became a Claude Code timeout of
416
+ 1000-2000 SECONDS (16-33 minutes) instead of 1-2 seconds, meaning
417
+ hard-blocking enforcement hooks (e.g. `harness policy intercept`) could
418
+ hang for tens of minutes before Claude Code cancelled them, rather than
419
+ failing fast. The sibling Codex projection
420
+ (`src/cli/apply/generate-codex-config.ts`) already converted correctly
421
+ (`Math.ceil(budget_ms / 1000)` with a policy-intercept floor); the
422
+ Claude Code projection now shares that exact formula via a single
423
+ extracted helper, `hookTimeoutSeconds` (`generate-settings.ts`), so the
424
+ two runtime projections cannot diverge on this again. `harness adopt`'s
425
+ reverse projection (`src/cli/adopt/index.ts`'s `buildHookEntry`), which
426
+ read a live settings.json `timeout` back into the manifest's
427
+ `budget_ms`, is fixed symmetrically (`* 1000`) so the adopt<->apply
428
+ round-trip stays lossless; `timeout` remains deliberately excluded from
429
+ the adopt drift key, so the ms/seconds rounding asymmetry a non-1000-
430
+ multiple `budget_ms` introduces (e.g. 1500ms -> 2s -> 2000ms) does not
431
+ register as phantom drift or duplicate-adopt a hook. New/updated tests:
432
+ `tests/cli/apply/generate-settings.test.ts` pins the corrected
433
+ conversion (including the 2s/1s floors) and asserts both runtime
434
+ projections emit the identical seconds value for the same hook;
435
+ `tests/cli/adopt.test.ts` adds an explicit apply-then-adopt round-trip
436
+ test for the 1500ms rounding-asymmetry case, asserting zero drift.
437
+
438
+ **Fix round 2, same task: the corrected 1-2s outer timeout above was
439
+ ITSELF a fail-open regression, not purely a safety fix.** Review found
440
+ that `harness policy intercept`'s blocking, ledger-consulting hooks —
441
+ every `requires:`-based policy (the require-*-evidence / risk-gate
442
+ policies) AND, via the engine's unconditional per-decision audit write
443
+ (`intercept()`'s `options.ledger.record(...)` call, run for every
444
+ matched policy including the pure pattern-deny `operator_only` kill
445
+ switches), every other blocking `harness policy intercept` hook too —
446
+ can legitimately need up to the grounding-mcp server's own
447
+ `health.timeout_ms` (5000ms by default) for a single ledger query, plus
448
+ a further deny-degraded audit-write retry bounded by
449
+ `auditRetryTimeoutMs` (`src/cli/policy/intercept.ts`), for a measured
450
+ worst case of ~10.8-13.75s. The now-CORRECT 1-2s Claude Code outer
451
+ timeout sits well UNDER that: on a merely slow or hung ledger (not a
452
+ clean, fast failure), Claude Code kills the `harness policy intercept`
453
+ subprocess before its `deny`/`deny-degraded` JSON reaches stdout, and a
454
+ killed PreToolUse hook is treated as ALLOW — silently turning a
455
+ correctly-computed fail-closed verdict into an unintended fail-open one.
456
+ The same shape applies to the three builtin policy-pack blockers
457
+ (`branch-protection`, `understanding-before-execution`,
458
+ `post-merge-gate`), whose own ledger probes are bounded by the identical
459
+ `health.timeout_ms` with effectively zero margin against their own
460
+ budget_ms=5000 outer timeout. Fixed by raising every blocking (`blocking:
461
+ "hard"`), ledger-consulting hook's `budget_ms` to 15000 (a 15s outer
462
+ timeout) in `src/cli/init/templates.ts` (FULL_TEMPLATE's eleven
463
+ `harness policy intercept` hooks, uniformly — deliberately including the
464
+ three pattern-deny kill-switch hooks, both because their audit write
465
+ waits on the same ledger round-trip and because `generate-settings.ts`'s
466
+ `buildGroups` dedupes same-matcher hooks by `(command, timeout)`, so a
467
+ non-uniform budget would split one Claude Code invocation per Bash call
468
+ into several redundant ones), `docs/examples/full-manifest.yaml` /
469
+ `.expected.yaml` (the one hook there that actually routes through
470
+ `harness policy intercept`), and the three builtin policy packs
471
+ (`src/policy-packs/builtin/branch-protection.ts`,
472
+ `understanding-before-execution.ts` — both the Claude and Codex
473
+ variants — `post-merge-gate.ts`). 15000ms clears the measured
474
+ ~10.8-13.75s worst case with margin. Trade-off, stated plainly: a gated
475
+ action can now visibly freeze for up to ~15s when the evidence ledger is
476
+ down or hanging, instead of failing fast (and silently open) at 1-2s —
477
+ this IS the intended fail-closed posture the Risk Gate's
478
+ `degraded_fail_posture: preserve_enforcement` default (task `f1aea826`)
479
+ already commits to; the pre-existing 1-2s budget just never gave that
480
+ posture enough time to actually land. New test:
481
+ `tests/runtime/hook-budget-ledger-margin.test.ts` pins, against the real
482
+ shipped `FULL_TEMPLATE` and the real policy-pack `resolve()` output
483
+ (not a hardcoded hook-name list), that every blocking ledger-consulting
484
+ hook's outer timeout clears `health.timeout_ms` plus the deny-degraded
485
+ audit-retry margin — a lightweight stand-in for the fuller drift-guard
486
+ task `d20a7e0c` is tracking.
487
+
488
+ **Fix round 2 continued, same task: the raise above was itself
489
+ incomplete.** Review found two more `harness init` surfaces still
490
+ shipping the identical blocking `harness policy intercept` hooks at
491
+ their pre-fix 1000-2000ms budgets — the same fail-open gap, reachable
492
+ through a different init path than `--template full`: the
493
+ `harness init --interactive` Custom composer's `HOOK_FOR_POLICY` table
494
+ (`src/cli/init/composer.ts`, five hook rows backing its six reference
495
+ policies) and the `team` profile template's `require-review-evidence`
496
+ hook (`src/cli/init/profiles.ts`'s `TEAM_TEMPLATE`). Raised both to the
497
+ same uniform 15000ms. The `solo` profile template ships no blocking
498
+ `harness policy intercept` hook at all (no agent-tasks-coupled or
499
+ Bash-triggered evidence policy in that profile), so there is nothing to
500
+ raise there; a new negative-control test pins that this is by design,
501
+ not an oversight. With this, the "every blocking ledger-consulting
502
+ hook" framing above is now accurate across every manifest-emitting
503
+ `harness init` surface, not only `FULL_TEMPLATE`. Also strengthened
504
+ `tests/runtime/hook-budget-ledger-margin.test.ts`: it now asserts the
505
+ same margin invariant against the Custom composer's full policy
506
+ selection and `TEAM_TEMPLATE` too (mutation-probe confirmed: reverting
507
+ either surface's budget back to its pre-fix value turns exactly that
508
+ surface's new assertion red, while every other assertion — including
509
+ the other surface's — stays green), asserts the `SOLO_TEMPLATE`
510
+ negative control, and adds a hard 15000ms floor assertion alongside the
511
+ existing formula-derived margin check. The formula alone
512
+ (`health.timeout_ms` + 2x `auditRetryTimeoutMs`, ~8s for the shipped
513
+ 5000ms grounding-mcp timeout) understates the measured ~10.8-13.75s
514
+ subprocess wall-time worst case, so a partial regression into
515
+ [8000ms, 13000ms) would still pass a formula-only check; the new hard
516
+ floor (pinned to the same ~10.8-13.75s measurement this task's budget
517
+ note above and `src/cli/init/templates.ts` both cite) closes that gap,
518
+ with its own control test pinning the discrimination at a 10000ms
519
+ probe value.
520
+
521
+ - **SECURITY: `harness validate` / `harness doctor` now ENFORCE the
522
+ hook-budget-vs-ledger-timeout margin task `7bf47554` above only pinned
523
+ by hand against the shipped defaults** (task `d20a7e0c`). Everything
524
+ `7bf47554` fixed was a one-time, hardcoded raise of specific budgets on
525
+ specific manifest-emitting surfaces (`FULL_TEMPLATE`, the Custom
526
+ composer, `TEAM_TEMPLATE`, three builtin policy packs); nothing stopped
527
+ an aged manifest, a hand-authored `harness.yaml`, or a manifest that
528
+ simply raises `tools.mcp.grounding-mcp.health.timeout_ms` from silently
529
+ regressing back into the fail-open gap — a merely SLOW (not even
530
+ hard-down) evidence ledger getting a blocking `harness policy
531
+ intercept` / policy-pack hook killed by the runtime's outer hook
532
+ timeout before its fail-closed `deny`/`deny-degraded` JSON reaches
533
+ stdout, which both Claude Code and Codex then read as an unintended
534
+ ALLOW.
535
+
536
+ Two fixes:
537
+
538
+ 1. **Corrected margin formula.** `7bf47554`'s own hardcoded 15000ms
539
+ floor existed because its formula (`health.timeout_ms` +
540
+ 2x`auditRetryTimeoutMs`, ~7500ms at the shipped 5000ms default)
541
+ UNDERSTATED the measured ~10.8-13.75s real-world worst case. Re-
542
+ deriving from `realLedgerClient`'s actual two round-trip shapes
543
+ (`src/cli/policy/intercept.ts`) explains the gap and closes it
544
+ properly: `query()` can cost up to **2x** `health.timeout_ms` (a
545
+ slow-but-alive server can independently near-max BOTH the
546
+ `initialize` handshake and the following `ledger_summary` call
547
+ before the second one finally times out — not just 1x), and the
548
+ deny-degraded audit retry can cost up to **3x**
549
+ `auditRetryTimeoutMs` (the fresh retry session's own `initialize`,
550
+ the first `ledger_add` attempt, and — only when that attempt is
551
+ rejected outright by an older/incompatible grounding-mcp rather
552
+ than timing out — the legacy `type: "fact"` fallback `ledger_add`).
553
+ The corrected formula, `requiredHookBudgetMs(T) = 2T +
554
+ 3*auditRetryTimeoutMs(T)` (new export, `src/cli/policy/
555
+ intercept.ts`), evaluates to 13750ms at the shipped 5000ms default —
556
+ within `7bf47554`'s own cited ~10.8-13.75s measurement range, unlike
557
+ the ~7500ms the old formula gave. Both round-trip shapes are
558
+ measured independently against the REAL `realLedgerClient` /
559
+ `recordPolicyDecisionOnSession` (not just asserted) in
560
+ `tests/e2e/policy-intercept.test.ts`'s new "hook-budget-ledger-margin
561
+ derivation" suite, via a fake grounding-mcp subprocess that can
562
+ independently delay `initialize`, `ledger_summary`, and
563
+ `ledger_add`, and can force the legacy fact-type fallback.
564
+
565
+ 2. **Durable, generic validate/doctor guard.** New
566
+ `checkHookBudgetLedgerMargin` (`src/cli/validate/checks.ts`, wired
567
+ into both `harness validate` and `harness doctor`) checks the
568
+ corrected formula against an ARBITRARY manifest's OWN
569
+ `tools.mcp[grounding-mcp].health.timeout_ms` — not the shipped
570
+ 5000ms default — and against every ENABLED `policy_packs[]` entry's
571
+ resolved hooks via the shared `resolveBuiltin` registry lookup, for
572
+ every runtime `harness apply` can target, instead of
573
+ `7bf47554`'s test hand-importing three specific pack modules. A pack
574
+ whose blocker gates on a filesystem verdict marker rather than a
575
+ live ledger round-trip (`solution-acceptance` /
576
+ `solution-acceptance-writeguard`) is correctly excluded — flagging
577
+ it would be a false positive. An under-budgeted hook is reported as
578
+ an `error` naming both the hook's own `budget_ms` and the ledger's
579
+ `health.timeout_ms` (plus the derived requirement), so e.g. an
580
+ operator who raises `health.timeout_ms` without raising a
581
+ pack-shipped hook's fixed `budget_ms` in lockstep is now caught at
582
+ lint time instead of silently reopening the gap. Also fixes the
583
+ matcher this check (and, incidentally,
584
+ `tests/runtime/hook-budget-ledger-margin.test.ts`'s own
585
+ `blockingInterceptHooks`) uses to recognise a `harness policy
586
+ intercept` hook: the new `isPolicyInterceptCommand`
587
+ (`src/cli/policy/intercept.ts`) matches the subcommand tokens
588
+ regardless of a leading interpreter/env-var prefix (`node
589
+ /path/to/dist/cli/index.js policy intercept`, `npx harness policy
590
+ intercept`, `FOO=bar harness policy intercept`) or a trailing flag
591
+ (`harness policy intercept --hook <name>`), instead of the verbatim
592
+ `command.trim() === "harness policy intercept"` compare every other
593
+ call site in this codebase still (deliberately, out of this task's
594
+ scope) uses — an operator-authored `manifest.hooks[]` entry is under
595
+ no obligation to spell the command exactly the way harness's own
596
+ generators do, and under-recognition here is a false NEGATIVE on a
597
+ security check. Fix round 1 (review 2026-08-09) additionally widens
598
+ `isPolicyInterceptCommand`'s TRAILING boundary to also accept a
599
+ semicolon or quote character glued directly onto the subcommand word
600
+ with no whitespace (`harness policy intercept; echo done`, `sh -c
601
+ "harness policy intercept"`), which the whitespace-or-end-only
602
+ boundary shipped in this task's first commit missed; a trailing `)`,
603
+ `|`, `&`, or backtick immediately abutting the word remains a known,
604
+ deliberately-unclosed gap (documented in the function's own doc
605
+ comment) rather than a runtime bypass — a missed match here only
606
+ means the hook is not classified as ledger-consulting for THIS
607
+ check, never a change to what the hook actually does at runtime.
608
+
609
+ **Fix round 1 (review 2026-08-09): two corrections to this task's own
610
+ first commit, made in the name of not overclaiming what the guard
611
+ above actually guarantees.**
612
+
613
+ - **The `2T + 3R` formula's "worst case" claim is scoped to the
614
+ pure-timeout hang shape, not every degradation shape.** The formula
615
+ (and the diagnostic message above) previously read as an unqualified
616
+ worst-case bound. It is not one: `query()`'s `2T` half assumes the
617
+ pooled `LedgerSession`'s session-wide `timedOut` latch trips (making
618
+ the FIRST `record()` attempt instant, per `realLedgerClient`'s own
619
+ doc comment) — true only when `query()` degrades via an actual
620
+ timeout. A `query()` that instead degrades via a NON-timeout path
621
+ (`ledger_summary` returning a genuine JSON-RPC error, an unparseable
622
+ payload, or a payload missing the `entries` shape —
623
+ `LedgerSession.querySummary`'s non-timeout `degraded` branches,
624
+ `src/policies/ledger-client.ts`) never sets that latch, so the first
625
+ `record()` attempt afterwards is a REAL round-trip too — up to
626
+ another `2T` (the `type: "policy_decision"` attempt plus a possible
627
+ legacy `type: "fact"` fallback) BEFORE the `3R` retry sequence even
628
+ starts. Worst case on this "error-then-hang" shape is therefore up to
629
+ `4T + 3R` — 23750ms at the shipped `T=5000` default, against this
630
+ guard's `13750ms` floor. The formula is DELIBERATELY NOT raised to
631
+ `4T + 3R`: doing so would fail `checkHookBudgetLedgerMargin` against
632
+ the shipped, reviewer-confirmed-clean fresh-init budget of `15000ms`
633
+ (task `7bf47554`/#414, out of this task's scope) at the same
634
+ `T=5000` default, since `15000 < 23750`. Instead, `requiredHookBudgetMs`'s
635
+ doc comment (`src/cli/policy/intercept.ts`) and the
636
+ `checkHookBudgetLedgerMargin` diagnostic message now both name this
637
+ residual explicitly as a KNOWN, un-closed gap rather than implying a
638
+ guarantee neither actually gives — the guard backs delivery of the
639
+ fail-closed verdict on the pure-timeout hang shape only. This
640
+ residual is NOT confined to one runtime projection: Claude Code and
641
+ Codex both convert `budget_ms` into the outer hook-kill seconds via
642
+ the identical `hookTimeoutSeconds` helper
643
+ (`src/cli/apply/generate-settings.ts`, shared by
644
+ `generate-codex-config.ts`) since task `7bf47554`/#414, so a manifest
645
+ at the shipped defaults is equally exposed on both — checked against
646
+ both generators' current source for this note, not assumed.
647
+
648
+ - **The diagnostic message no longer attributes a `3× deny-degraded
649
+ audit-retry budget` to hooks that never run one.** The four
650
+ ledger-consulting pack blockers (`branch-protection`,
651
+ `understanding-before-execution`'s `pre-tool-use` /
652
+ `codex-pre-tool-use`, `post-merge-gate`) only ever call
653
+ `queryLedgerByTag` (open session, one `querySummary`, dispose) — they
654
+ never call `ledger_add`, so they have no deny-degraded audit-retry
655
+ step of their own; their real worst case is bounded at up to
656
+ `2×timeout_ms` for the query alone, not `2T + 3R`. The shared
657
+ `${required}ms` floor is kept identical across direct
658
+ `harness policy intercept` hooks and these pack blockers on purpose
659
+ (a deliberately conservative carryover, not a separately-derived and
660
+ separately-tested per-kind bound), but the message text explaining
661
+ WHY now differs per hook kind instead of asserting a retry sequence
662
+ that does not exist for the pack blockers. `post-merge-gate`
663
+ specifically already fails OPEN (allow) on a degraded or unreachable
664
+ ledger by its own design (`src/cli/pack/hook-post-merge-gate.ts`'s
665
+ "Fail posture: OPEN" note, and its own `budget_ms: 15000` comment in
666
+ `src/policy-packs/builtin/post-merge-gate.ts` already said as much)
667
+ — a hook killed by the outer timeout there reaches the same allow
668
+ outcome its own degraded-handling would already choose, so this
669
+ check is kept for it only to stop a merely-slow-but-alive ledger from
670
+ needlessly stalling the gate, not because a fail-closed verdict is at
671
+ risk. `src/cli/validate/checks.ts` now tags each collected hook with
672
+ its ledger-traffic shape (`isPolicyInterceptHook`,
673
+ `isFailOpenOnDegraded`) to pick the right explanatory text; the
674
+ numeric threshold, hook selection, and severity are all unchanged.
675
+
676
+ `tests/runtime/hook-budget-ledger-margin.test.ts`'s original
677
+ hand-imported per-surface assertions are updated to source their margin
678
+ bound from the corrected formula (previously understated) and gain a
679
+ new describe block running the generic `checkHookBudgetLedgerMargin`
680
+ guard against the very same shipped manifests, closing the loop the
681
+ file's own comment named as future work. New/updated tests:
682
+ `tests/cli/validate.test.ts` (fixture pairs for AC1/AC2 plus the
683
+ matcher-fix, pack-genericity, and negative-control cases, plus fix
684
+ round 1's semicolon-chained and quote-wrapped matcher cases),
685
+ `tests/cli/doctor.test.ts` (doctor-surface parity + `format()`
686
+ rendering), `tests/runtime/hook-budget-ledger-margin.test.ts`,
687
+ `tests/e2e/policy-intercept.test.ts`.
688
+
689
+ - **SECURITY: the operator-approval escape matcher (`isEscapeCommand`,
690
+ `src/cli/pack/approve-escape.ts`) used JS's generic `\s` whitespace
691
+ class where bash's actual lexical blank set was meant, letting a
692
+ non-bash-blank codepoint move a report heredoc's real delimiter word
693
+ (as bash reads it) away from the word the matcher extracts** (task
694
+ `623640a5`). Ground-truthed against GNU bash with a PATH-stub harness:
695
+ of the 25 codepoints JS's `\s` class matches, only TAB and SPACE are
696
+ bash lexical blanks; the other 23 (NBSP U+00A0, LF, CR, VT, FF, and 18
697
+ further Unicode space-separator/line-separator/BOM codepoints) glue
698
+ onto the adjacent token instead of being stripped as an insignificant
699
+ separator. Measured exploit shape (the 2026-08-04 halt that opened this
700
+ task): a non-breaking space right after a report heredoc's `<<'X'`
701
+ intro closing quote was accepted by the matcher's `\s*$` as
702
+ insignificant trailing whitespace, so the matcher extracted delimiter
703
+ `X`; bash instead glues the NBSP onto the quoted word, so its real
704
+ delimiter is `X`+NBSP, and the body line `X`+NBSP (matching the
705
+ matcher's delimiter, not bash's) closes the heredoc one line early:
706
+ the following body line then runs as a real top-level command
707
+ (`git push origin master` in the measured repro) instead of staying
708
+ inert report text. Fix: every regex in the module that means "a
709
+ separator bash will actually treat as blank here" (the heredoc-intro
710
+ trailing/leading whitespace, the `harness`/`approve` separator, the
711
+ heredoc command-part whitelist, the command-part right-trim, and the
712
+ post-terminator blank-line check) now uses `[ \t]`, never `\s`.
713
+ Behavior change operators should know about: a `harness approve`
714
+ heredoc carrying a non-bash-blank codepoint in one of those positions
715
+ used to be accepted as a clean escape and routed to the normal
716
+ interactive ASK permission prompt; it is now DENIED outright (fails
717
+ closed to the ordinary understanding-gate block), since ASK implied a
718
+ false equivalence to the safe heredoc shape. The PreToolUse
719
+ discoverability hint (`approveEscapeHint`,
720
+ `src/cli/pack/hook-pre-tool-use.ts`) now also names the offending
721
+ codepoint (e.g. "contains U+00A0 ... where an ordinary space or tab was
722
+ expected") when a blocked command carries one, so an agent that emitted
723
+ invisible whitespace gets a reason instead of silently retrying the
724
+ same bytes. New regression tests: `tests/cli/pack-approve-escape.test.ts`
725
+ (the full 25/23 codepoint enumeration measured against the live JS `\s`
726
+ class, the NBSP incident itself, and a mechanical guard, module source
727
+ with comments/strings stripped, asserting no future regex in the
728
+ module can reintroduce a bare `\s` token, mutation-verified to actually
729
+ redden); `tests/cli/pack-hook-pre-tool-use.test.ts` (the hint still
730
+ surfaces for the newly-blocked shapes, now naming the codepoint; a
731
+ negative control confirms an ordinary metacharacter block names no
732
+ codepoint).
733
+
734
+ - **SECURITY: `harness doctor` now detects template-policy drift, an
735
+ installed manifest that lacks a shipped `operator_only` kill-switch
736
+ security policy** (task `adf037c1`). Measured 2026-08-06: a 0.44.0
737
+ machine whose `~/.harness/harness.yaml` predated the kill-switch
738
+ defenses (`deny-kill-switch-bypass`, `deny-session-env-strip`,
739
+ `deny-pause-sentinel-forgery`) had the documented `harness pause`
740
+ bypass live as ALLOW, with nothing surfacing the gap: `harness apply`
741
+ never retroactively adds newly-shipped default policies to an
742
+ already-materialized manifest, so security policies introduced after
743
+ install reach only fresh installs. Doctor's new "Template drift
744
+ (shipped security policies)" section compares the installed manifest
745
+ against `shippedOperatorOnlyPolicyNames()` (the `operator_only` set
746
+ parsed from `FULL_TEMPLATE` itself, kept honest by the full-template
747
+ parity test), so it can never drift from what `harness init --template
748
+ full` writes and automatically extends when a new operator_only policy
749
+ is added. Each missing policy is an ERROR (doctor exits non-zero, the
750
+ standard CI/dogfood signal), naming the policy and the rehydration
751
+ path. A same-named-but-DOWNGRADED policy (present but no longer
752
+ `operator_only: true`, e.g. the historical `requires.ledger_tag` shape
753
+ a ledger write can satisfy, or `enforcement: warn`) is flagged as a
754
+ distinct error too: name-presence alone would pass a bypassable
755
+ kill-switch as no-drift, the exact aged-manifest class this check
756
+ targets. A stale/typo'd `doctor.ignore_template_drift` entry matching
757
+ no shipped name is surfaced as a warning so a policy rename cannot
758
+ silently strand an opt-out. Scope is the `operator_only` security floor regardless of profile
759
+ (a solo/team install missing kill-switch protection is flagged too;
760
+ that is the security core, per operator decision 2026-08-08), and it
761
+ does NOT nag about full-only convenience policies a smaller profile
762
+ never carried. Deliberate opt-out: `doctor.ignore_template_drift:
763
+ [<policy-name>, ...]` in the manifest, a NEW minimal `doctor:` schema
764
+ section that silences only the doctor report line and changes no
765
+ enforcement. This is deliberately NOT a `policies[].enabled: false`
766
+ flag: the runtime policy engine has no such concept, so an operator
767
+ setting it would believe a policy disabled while it still fired (a
768
+ footgun the CLI help + docs already implied but no code backed). The
769
+ original AC's two-severity split (operator_only error vs cosmetic
770
+ warning) was consciously narrowed to a single operator_only error tier
771
+ per the same operator scope decision, since cosmetic policies are no
772
+ longer compared. Shared `checkTemplatePolicyDrift` in
773
+ `src/cli/validate/checks.ts` (doctor-only surface today; validate is
774
+ about internal consistency, not external-reference comparison, so it is
775
+ intentionally not wired to fail `harness validate`/`apply`).
776
+ docs/okf/pause-vs-gate-kill-switch.md documents the drift check and the
777
+ opt-out; fixtures cover missing/present/opt-out/partial, and a parity
778
+ test pins the shipped set so a template rename reddens rather than
779
+ silently shrinking the check.
780
+
781
+ - **SECURITY: read-only Bash classifier no longer treats mutating
782
+ argument forms of allowlisted git subcommands as read-only** (task
783
+ `9d1fff1b`). `GIT_READ_ONLY_SUBS` (`src/runtime/read-only-bash.ts`)
784
+ was a bare-form allowlist of subcommand NAMES; measured against real
785
+ git 2.50.1, six argument forms of those names mutate while classifying
786
+ read-only: `git branch -D <b>` (deletes a branch), `git tag <name>`
787
+ (creates a tag), `git remote set-url` (rewrites config), `git fetch
788
+ <url> <refspec>` (writes an arbitrary local ref), `git diff
789
+ --output=<path>` (creates/overwrites a file; `log`/`show` accept the
790
+ same option), and `git reflog expire`/`delete` (destroys reflog
791
+ entries). All four consumers of the classifier — the risk-classifier
792
+ read-only severity floor, both PreToolUse understanding-gate hooks, and
793
+ the solution-acceptance write-guard — funnel through the same
794
+ `classifyTokens`, so all four are corrected by the single fix and none
795
+ needed a consumer-side change (measured: the full pack + risk-classifier
796
+ suites, 602 tests, unchanged). The `bin === "git"` branch now applies a
797
+ per-subcommand POSITIVE argument-form check (`isReadOnlyGitInvocation`):
798
+ branch/tag are read-only only with no non-flag operand (every mutation
799
+ needs the branch/tag name; branch also denies the glued no-operand
800
+ writes like `--set-upstream-to=`); remote only for the `show`/`get-url`
801
+ verbs or bare `-v`; fetch only for bare or a single remote/URL (a
802
+ second positional refspec blocks — the escalation to an arbitrary
803
+ local ref); reflog only for bare/`show`/flag forms (expire/delete/drop
804
+ are always the first arg); everything after a bare `--` is folded into
805
+ the positional view. Two write/exec vectors reach git's SHARED option
806
+ parser and transport layer and are therefore forfeited GLOBALLY, above
807
+ the per-subcommand switch, for every allowlisted git subcommand (review
808
+ round 1, measured against git 2.50.1): `--output=<path>` creates a file
809
+ at option-parse time — measured writing on `rev-list`/`shortlog`/`blame`
810
+ as well as diff/log/show — and `--upload-pack=`/`--exec=`/`--receive-pack=`
811
+ plus `ext::`/`fd::` positional transports run an operator-named binary
812
+ locally (measured `git fetch --upload-pack=<script> <repo>` executing
813
+ the script). The choice is positive-shape, not a write-flag
814
+ denylist, so a git argument this floor has not reasoned about
815
+ over-blocks (annoying) instead of laundering a write (unsafe) — the
816
+ same fail-closed stance as the existing `npm audit` guard, and
817
+ deliberately NOT the enumerate-every-exception shape whose repeated
818
+ failure halted the neighbouring task 5b5d1022. Literal verb/flag
819
+ comparisons use the repo's raw-or-decoded form (task fdee7d0f) so a
820
+ quoted spelling (`git remote se"t-url"`) fails the positive match and
821
+ blocks rather than slipping through. Explicitly out of scope (separate
822
+ tasks): global-option / path-qualified git (`git -C <d>`, 5b5d1022),
823
+ GNU long-option abbreviations (dd055c1d), and env-var exec equivalents.
824
+ negative tests pin the mutating forms (reproduced against git 2.50.1),
825
+ positive controls pin the bare forms plus the read corpus (`rev-parse
826
+ --git-dir`, `ls-files -c`, `log -c HEAD`, `show -c HEAD`, `diff --stat`,
827
+ `status --short`, and single-positional `fetch`/`ls-remote` from a URL);
828
+ a mutation probe confirms reverting to the bare-name allowlist reddens
829
+ the negative suite, and a per-arm probe confirms each raw-or-decoded
830
+ decode arm is load-bearing (deleting it reddens a quoted no-positional
831
+ write form). The over-blocked read forms (`git tag -l 'v*'`,
832
+ `git branch --contains HEAD`, separated fetch flag values, ...) are the
833
+ accepted conservative cost, documented in `docs/risk-gate.md`.
834
+
835
+ - **SECURITY: block/require_approval policies now fail CLOSED when their
836
+ evidence source is degraded, instead of silently mapping to the
837
+ never-blocking `warn-degraded`** (task `f1aea826`). Measured 2026-08-06 on
838
+ 0.44.0 (isolated probe home, `git push` event without its preflight tag):
839
+ a healthy ledger denied, every broken-ledger shape (ENOTDIR, corrupt file,
840
+ directory) denied, but `--ledger-timeout` at 1/25/50/100 ms flipped the
841
+ same event to **ALLOW**, because the timeout path mapped to
842
+ `warn-degraded`, which `isBlockingDecision` never treats as blocking.
843
+ `evaluateOnePolicy`'s degraded family (ledger timeout/spawn failure,
844
+ unresolved template variables, invalid `within`, thrown evaluator,
845
+ schema-invariant branch) now routes through a tier-aware
846
+ `degradedOutcome`: `warn` policies keep the non-blocking `warn-degraded`
847
+ (availability first, unchanged), `block`/`require_approval` policies
848
+ produce the NEW blocking `deny-degraded` outcome. The `deny-degraded`
849
+ envelope deliberately bypasses the policy's `ux:` surface and names the
850
+ degraded cause (sanitised: the transport reason embeds subprocess
851
+ output, so the envelope interpolation is bounded to 200 chars with
852
+ control characters stripped) plus an operator-facing recovery path, so
853
+ nobody debugs a phantom missing tag on an unreadable ledger. The
854
+ fail_open opt-out is deliberately ABSENT from that agent-facing text
855
+ (review finding: a deny that includes its own disable recipe is not a
856
+ gate, and nothing prevents the blocked agent from editing the
857
+ manifest); it is named on the operator surfaces instead: the verbose
858
+ stderr diagnostic, docs/risk-gate.md, and the OKF fail-posture matrix.
859
+ **ACCEPTED SEMANTIC CHANGE
860
+ an upgrading operator should know about:** a degraded ledger (including
861
+ "grounding-mcp not declared in manifest" under block-tier policies) now
862
+ DENIES where it silently allowed; the explicit manifest opt-out
863
+ `risk.degraded_fail_posture: fail_open` (schema default
864
+ `preserve_enforcement`, `src/schema/risk.ts`) restores the pre-0.45
865
+ availability-first mapping for every tier, and the operator-only
866
+ `harness pause` kill switch remains the deadlock escape (it is honoured
867
+ BEFORE manifest load); the shipped init template documents the knob as
868
+ a COMMENTED-OUT line on purpose (the schema is strict, so an emitted
869
+ key would make the manifest unreadable by a pre-0.45 binary, and a
870
+ manifest load failure is allow at the hook layer: on a mixed-version
871
+ fleet that would turn a downgrade into a silent full fail-open). Audit
872
+ completeness rides along, best-effort and precisely bounded: the pooled
873
+ ledger session's timeout latch previously guaranteed that exactly the
874
+ degraded deny lost its own audit row (every post-timeout call on that
875
+ session short-circuits), so `realLedgerClient.record` now retries once
876
+ per invocation over a fresh session, RESERVED for `deny-degraded` rows
877
+ (an allow/warn row failing first cannot consume it) and capped at
878
+ max(250ms, timeoutMs/4) per call — two calls, so the added stall is at
879
+ most half a timeout once the ledger timeout is >=1s, and bounded at
880
+ 500ms absolute below that, where the floor dominates (the formula is
881
+ pinned as the exported `auditRetryTimeoutMs`); a longer or unreserved
882
+ retry would stall the deny path toward the outer hook budget, where a
883
+ hook timeout is conventionally allow. With grounding-mcp absent from the manifest
884
+ altogether there is no transport at all: a BLOCKING decision on that
885
+ path now emits a dedicated "has NO audit row" stderr line instead of
886
+ the previous silent no-op. `isBlockingDecision` is now exported and
887
+ shared with the CLI wrapper's pending-approval staging, which had a
888
+ drifted hand-rolled copy. New outcome threaded through every consumer:
889
+ `harness audit` `VALID_OUTCOMES`, `explain`/`audit` `--outcome` filters,
890
+ the verbose stderr diagnostic (which names the outcome, the fail-closed
891
+ posture and the operator opt-out, cause-neutrally — the true cause
892
+ follows on its own reason line), a NEW
893
+ default-verbosity one-line stderr operator hint per degraded-denied
894
+ event (carrying the decision's OWN sanitised reason, since
895
+ deny-degraded has five causes and only one of them is the ledger — a
896
+ hardcoded ledger-fault wording sent operators of the other four to a
897
+ green `harness doctor`; pointing at doctor and the docs, deliberately
898
+ without the opt-out literal on the default channel, and suppressed
899
+ under verbose where the full diagnostic supersedes it), and the
900
+ `checkPolicyGroundingMcp` validate/apply warning, whose pre-0.45
901
+ "every policy will fire in degraded warn-mode" wording described the
902
+ inverse of the new block-tier behaviour and is now tier-aware and
903
+ test-pinned; `smoke` assertions classify the new outcome as deny via
904
+ the unchanged envelope path. A stale-contract drift guard now runs in
905
+ the suite: `tests/no-stale-fail-posture-claims.test.ts` fails if the
906
+ pre-0.45 phrasings OR the round-4-enumerated restatement shapes
907
+ ("ledger degradation → warn-degraded" untiered, "only ever blocks if
908
+ grounding-mcp", the untiered fail-open framing) reappear under `src/`
909
+ or `docs/` outside a per-phrase allowlist with freshness checks and
910
+ one positive control per needle. Honest bound, stated in the guard
911
+ itself: it is a literal/pattern grep pinning the mechanically
912
+ greppable phrasings the review rounds found, not a semantic checker —
913
+ wrong-modality claims were fixed by hand and are not pinned, and a
914
+ future paraphrase avoiding all needles still needs eyes (four review rounds
915
+ each found a fresh such surface; docs quoting message literals
916
+ verbatim was the recurring root cause, so the updated docs now
917
+ describe messages instead of quoting them); the same sweep updated
918
+ docs/writing-custom-policies.md,
919
+ docs/CLI.md, docs/examples/pandora-bootstrap.md, the OKF
920
+ producer-wiring bundle, the ledger-client module header, the init
921
+ composer's wizard advisory, and marked the superseded ROADMAP Phase 4
922
+ acceptance bullets. NOT closed here, named so no
923
+ doc overclaims: the OUTER hook-budget layer (a hook exceeding its
924
+ `budget_ms` is allow by harness contract) stays fail-open and is not
925
+ reachable from this repo's schema; keep hook budgets comfortably above
926
+ the ledger timeout (the shipped template's 1000-2000ms budgets vs the
927
+ 5000ms grounding-mcp health timeout mean the HANG shape can still blow
928
+ the hook budget before the deny lands; a validate/doctor consistency
929
+ check is filed as a follow-up task), and malformed event JSON or a
930
+ failed manifest load remain allow-with-stderr at the CLI wrapper.
931
+ docs/risk-gate.md "Degraded mode",
932
+ docs/okf/gate-fail-posture-matrix.md,
933
+ docs/okf/policy-engine-producer-wiring.md and
934
+ docs/okf/debug-verb-selection.md rewritten to the new contract;
935
+ the two pre-0.45 pins asserting warn-degraded-never-blocks for block
936
+ tier were rewritten deliberately alongside (unit + e2e), plus new
937
+ fixtures: require_approval+timeout blocks, warn+timeout allows,
938
+ fail_open opt-out restores the old mapping end-to-end through the real
939
+ manifest parse, genuine-timeout e2e (hanging fake grounding-mcp), an
940
+ AC4 e2e pinning exactly TWO subprocess spawns with the `deny-degraded`
941
+ row landed in the ledger despite the latch, retry allocation (a warn
942
+ row failing first does not consume the deny row's retry),
943
+ envelope-over-ux precedence, envelope sanitisation, the opt-out's
944
+ absence from the envelope and presence on stderr, the no-transport
945
+ "has NO audit row" line, and `--outcome deny-degraded` accepted by
946
+ audit/explain filters.
947
+
948
+ - nanoid bumped 3.3.16 → 3.3.18 via `npm audit fix` (lockfile-only, no source
949
+ change), closing GHSA-2v37-7h3g-55p8 / CVE-2026-67213 (High, CVSS 8.2:
950
+ `customAlphabet`/`customRandom` loop indefinitely when `size` is 0; affected
951
+ range < 3.3.17). Dev-only transitive (vitest → vite → postcss → nanoid);
952
+ postcss's declared `^3.3.16` already admits the fix, so no `package.json`
953
+ change and no override introduced (the same shape as the 0.42.0 entry that
954
+ carried nanoid 3.3.12 → 3.3.16 for the postcss advisory). harness source
955
+ never calls nanoid, so the advisory's mechanism is not reachable from the
956
+ shipped CLI; the practical effect is `npm audit` back to 0 findings and this
957
+ repo's own preflight npm-audit check unblocked. Note the structural residue
958
+ the 0.42.0 entry already accepted: postcss's range floor (3.3.16) is below
959
+ the patched 3.3.17, so only the lockfile pin holds the fix (conscious
960
+ no-override decision, unchanged). (task `b81e8e09`)
961
+
962
+ - **SECURITY: the Risk Gate's environment resolver ignored a `git
963
+ switch`/`checkout <branch>` at the head of a Bash command, so a
964
+ same-command branch hop onto a production branch ran under the
965
+ PRE-switch (often `unknown`) label** (task `341e024b`). Measured
966
+ 2026-08-06 (isolated probe home, harness 0.44.0, cwd on a non-production
967
+ branch): `git switch main && rm -rf <path>` and the `git checkout main`
968
+ equivalent both classified `critical` severity / `unknown` environment /
969
+ **ALLOW**, while the comparison `cd <repo-on-main> && rm -rf <path>` form
970
+ already denied — the branch resolver only ever read `.git/HEAD` at the
971
+ hook's cwd (or, since the `cd`-target fix below, a leading `cd <path>`'s
972
+ target), never a `git switch`/`checkout` argument. Alltagsrelevant: the
973
+ post-merge convention of switching back to the default branch before
974
+ continuing work is exactly this shape (`git switch main && rm -rf
975
+ node_modules && npm ci`), so it ran ungated while the identical work with
976
+ a leading `cd` did not.
977
+
978
+ `src/runtime/bash-prefix-parse.ts`'s `parseBashPrefix` now also extracts
979
+ a `branchTarget` from a leading `git [-C <path>] (switch|checkout)
980
+ <branch> [&&|;]`, mirroring the existing `cdTarget` extraction (a new
981
+ `consumeLeadingGitSwitch`, sharing the same command-head-only, two-pass,
982
+ no-shell-simulation discipline the module's `cdTarget`/`inlineEnv`
983
+ parsing already has — task `1a8a103d`). Deliberately conservative,
984
+ unlike `cdTarget`'s bidirectional risk (which can raise OR lower the
985
+ resolved environment depending on the target, a pre-existing, documented
986
+ asymmetry — G5, task `98ad072f`/2026-07-27 — untouched by this task):
987
+ `git checkout -- <path>` (a file restore, not a branch change) and an
988
+ unresolvable `$VAR`/`${VAR}` branch argument set NO branch signal — never
989
+ guessed. `src/cli/policy/intercept.ts`'s new `applyBranchSwitchUpgrade`
990
+ merges the parsed branch candidate against the existing `resolverGit`
991
+ (cwd- or cd-target-based) UPGRADE-ONLY: both are run through the same
992
+ `resolveEnvironment` call with identical env/kube inputs, and the
993
+ switch-target branch is only adopted when it resolves to a MORE
994
+ dangerous environment than the base; a switch AWAY from a production
995
+ branch never downgrades an already-production classification.
996
+
997
+ 5 new end-to-end fixtures in `tests/runtime/intercept-cli-bash-prefix.test.ts`
998
+ pin the acceptance criteria (`git switch main`/`git checkout main` from
999
+ a non-prod cwd now deny; `git switch <non-prod-branch>` from a repo
1000
+ already on `main` still denies — no downgrade; `git checkout --
1001
+ <path>` and `git switch $BRANCH` set no signal and stay allow), plus
1002
+ unit coverage for the parser itself in
1003
+ `tests/runtime/bash-prefix-parse.test.ts` (slashed branch names, the
1004
+ optional `-C <path>` skip, the flag/variable/missing-separator
1005
+ exclusions, and — falling out of the existing two-pass loop for free —
1006
+ a leading `cd <path> && git switch <branch>` chain resolving both
1007
+ candidates). Existing `cdTarget`/inline-env fixtures are unchanged and
1008
+ green. `docs/risk-gate.md`'s "Environment resolvers" section documents
1009
+ the new upgrade-only branch signal alongside the pre-existing
1010
+ bidirectional `cd` one.
1011
+
1012
+ Fix round 1 (reviewer-flagged, `accept_with_notes`): the upgrade-only
1013
+ merge itself was verified sound (no downgrade path), but two gaps
1014
+ remained. **MEDIUM (security):** a QUOTED branch name bypassed the
1015
+ gate entirely — `consumeLeadingGitSwitch` read the branch token
1016
+ INCLUDING the quote characters, so `git switch "main" && rm -rf <path>`
1017
+ extracted `branchTarget` as the literal 6-character string `"main"`,
1018
+ which never matches a `branch_patterns` entry like `main`, so no
1019
+ upgrade fired and the destructive command ran ungated — identical in
1020
+ effect to the pre-fix unquoted case above. The sibling `cd`-target
1021
+ parser already stripped quotes (`skipPathToken`/`consumeLeadingCd`);
1022
+ this was an inconsistency, and the module doc's given reason for
1023
+ skipping a quoted branch form ("real git branch names cannot contain
1024
+ whitespace, so this covers slashed names without needing a quoted
1025
+ form") was itself wrong — a whitespace-free branch name can still be
1026
+ quoted by the operator. `consumeLeadingGitSwitch` now reads a single-
1027
+ or double-quoted branch token the same way `skipPathToken` reads a
1028
+ quoted path: literal content, quotes stripped. A `$`-containing
1029
+ DOUBLE-quoted branch argument is still left unresolved (real bash
1030
+ interpolates inside double quotes, consistent with the existing
1031
+ unquoted-`$VAR` rule); a SINGLE-quoted one is always literal, since
1032
+ single quotes never interpolate in real bash. New e2e fixtures in
1033
+ `tests/runtime/intercept-cli-bash-prefix.test.ts` pin `git switch
1034
+ "main"` / `git switch 'main'` / `git checkout "main"` from a non-prod
1035
+ cwd now denying, a quoted switch away from `main` still not
1036
+ downgrading, and a double-quoted `"$BRANCH"` still not guessed; new
1037
+ parser-unit coverage in `tests/runtime/bash-prefix-parse.test.ts`
1038
+ covers the quote-stripping itself, the double-quote-`$` exclusion, the
1039
+ single-quote-`$` literal case, unterminated/empty quoted literals, and
1040
+ the missing-separator rule for a quoted branch. Mutation-probed:
1041
+ disabling the quote-stripping branch turns exactly the 12 new e2e/unit
1042
+ assertions red.
1043
+
1044
+ **LOW (maintainability):** `ENV_RANK` in `src/cli/policy/intercept.ts`
1045
+ is a hand-maintained copy of `ENV_PRECEDENCE` in
1046
+ `src/runtime/environment-resolver.ts`; the `Record<MatchableEnvironment,
1047
+ number>` type catches an added/removed environment but not a REORDER,
1048
+ which could silently invert the upgrade-only direction. A new drift
1049
+ guard, `tests/runtime/intercept-env-rank-drift-guard.test.ts`, measures
1050
+ `ENV_PRECEDENCE`'s real order live via `resolveEnvironment` (four
1051
+ resolvers, one per environment, each on a different signal kind so all
1052
+ four can fire on one probe envelope at once; the conflict winner reveals
1053
+ the true order by elimination) and, for every adjacent pair in that
1054
+ measured order, round-trips `runInterceptCli` to confirm
1055
+ `applyBranchSwitchUpgrade`'s `ENV_RANK`-driven behavior agrees.
1056
+ Mutation-probed: swapping two adjacent entries in `ENV_PRECEDENCE`
1057
+ (without touching `ENV_RANK`) turns exactly the affected pair's two
1058
+ assertions red.
1059
+
1060
+ **LOW, documentation only (not built):** a chained double switch (`git
1061
+ switch dev && git switch main && rm ...`) resolves `branchTarget` as
1062
+ `dev` (first-switch-wins), missing `main`. Multi-switch parsing was
1063
+ deliberately NOT added — it would widen the parser's surface toward the
1064
+ false-positive class task `dbc6d303` already measured. The module doc
1065
+ on `consumeLeadingGitSwitch` and `docs/risk-gate.md` now state this
1066
+ limit explicitly so it is not over-trusted.
1067
+
1068
+ ### Changed
1069
+
1070
+ - Audited `src/runtime/recovery-git-commit.ts`'s three JS `\s`-based
1071
+ tokenizers (the `git\s+commit` anchor and `tokenize`'s leading-skip and
1072
+ token-continuation checks) against the same JS-`\s`-vs-bash-blank
1073
+ divergence class `623640a5` closed elsewhere — reviewer residual from
1074
+ that task. Measured against real bash and git (PATH-stub): unlike
1075
+ `623640a5`, this module's over-split is safe by construction, not a
1076
+ fix. No production change; the safe direction is pinned in
1077
+ `tests/runtime/recovery-git-commit.test.ts` with live bash+git evidence
1078
+ for every call site plus a hermetic pin across all 23 non-bash-blank
1079
+ `\s` codepoints. (task `822a508d`)
1080
+
1081
+ ### Fixed
1082
+
1083
+ - **`init`'s MCP registration could mutate/read the WRONG `claude` CLI registry file under a non-default home, and treated an "already registered" outcome as a hard failure that blocked the settings.json migration** (task `fb3e4dce`, two reviewer findings on run `2026-07-18-init-mcp-wiring-claude-code`). Finding 2: `realClaudeMcpExec` (`src/io/claude-mcp.ts`) spawned the real `claude` CLI with no explicit `env`, so it inherited `process.env` verbatim — identical to `ensureMcpServers`'s own drift-read directory only when that directory happened to be the OS default (`~/.claude.json`). A non-default `homeDir`/`registryPath` override (e.g. `harness init --home`) made the live CLI spawn mutate/read a DIFFERENT registry file than the one `ensureMcpServers` had just compared `desired` against. Fixed by threading a new `configDir` through every verb wrapper to `exec`'s new third argument; `realClaudeMcpExec` now sets `CLAUDE_CONFIG_DIR` in the spawned child's env from it. **Correction (review round 2, task `fb3e4dce`): the original fix set `configDir = path.dirname(registryPath)` unconditionally, which on the DEFAULT resolution path is `~` itself (not the CLI's own actual default `CLAUDE_CONFIG_DIR` of `~/.claude`), so it silently redirected every OTHER config-dir-relative path the CLI resolves (e.g. registry backups landing in `<HOME>/backups` instead of `<HOME>/.claude/backups`) — `resolveMcpConfigDirOverride` (`src/io/claude-mcp.ts`) now emits `configDir` only when the resolution actually used a `CLAUDE_CONFIG_DIR`/`homeDir`/`registryPath` override, leaving the pure default path's real spawn with unmodified `process.env` inheritance; the same helper now also backs `uninstall`'s `claude mcp remove` call (`src/cli/uninstall/index.ts`), which previously threaded no `configDir` at all under a non-default `--home`.** A `claude mcp get` verification that merely timed out or hit a missing CLI (inconclusive, not a positive "absent") no longer overrides a File-verified already-exists match on its own (`addAndVerifyAlreadyExists`); the wizard's post-registration message now distinguishes freshly `add-json`-registered servers ("registered N ...") from verified-already-exists ones ("confirmed N ... already registered"), instead of reporting both as "registered". Finding 3: `ensureMcpServers` reported an `add-json` "already exists" outcome exactly like any other failure, even when the desired server genuinely IS registered (e.g. a registration this run's own registry-file read raced ahead of/missed) — the wizard's `allOk` gate then blocked the dead-`settings.json`-`mcpServers`-block migration even though the target state already held. `addAndVerifyAlreadyExists` now confirms an "already exists" outcome via `claude mcp get` + a fresh re-read of the same registry file (empirically verified: `claude mcp get`'s stdout is prose and does not expose the registered command/args, so the spec comparison uses the file, not `get`'s own output) — a verified, spec-matching registration is surfaced as `EnsureServerResult.verifiedAlreadyExists.matches: true`, which `wireClaudeMcp`'s `allOk` check now treats as success; an unverified or spec-mismatched one preserves the prior conservative behavior unchanged. New coverage in `tests/io/claude-mcp.test.ts` (configDir threaded to every verb across an add/replace/get sequence for a non-default home; matching-spec, mismatched-spec, and get-can't-find-it already-exists variants; a plain "added" outcome never calls `get`) and `tests/cli/init-interactive.test.ts` (end-to-end: a verified-matching already-exists still runs the settings.json migration and prints no failure warning; a verified-mismatched one skips migration and prints the same warning as before this fix). Review round 2 adds: a real `claude mcp add-json` spawn (a throwaway shim binary, never the operator's real CLI/registry) pinning that an explicit `configDir` wins over the parent process's own `CLAUDE_CONFIG_DIR` and that omitting it inherits `process.env` verbatim; pure-function coverage of `resolveMcpConfigDirOverride`'s default-vs-override decision; a `claude mcp remove` configDir-alignment pin for `uninstall` under both an explicit `--home` and the pure default; a timed-out-`get`-still-checks-the-file case; and verb-order plus message-wording pins on the two existing already-exists wizard tests.
1084
+ - **The session-start preflight not-ready fail-log's default directory (`~/.harness/logs`) is now repo-wide test-isolation-safe instead of suite-local** (task `80f49922`, reviewer finding on `a48b9729`). `defaultFailLogDir()` called `os.homedir()` directly, so the throw-on-real-home-dir guard in `resolvePaths()` (`loader.ts:45-64`, already pinned by `tests/integration/operator-state-isolation.test.ts`) never covered this seam — only a suite-local `vi.mock("node:os")` pin in `preflight.test.ts` did, protecting the one caller that suite happened to know about, not any future test or child process. `defaultFailLogDir` now resolves via `resolvePaths()` (same idiom as `defaultReportsDir`'s caller in `approve/understanding.ts`), so a caller that forgets to inject `homeDir`/`configPath` now fails at the guard instead of silently writing into the operator's real home. Side effect fix: the fail-log dir now also respects `$HARNESS_HOME` and the legacy `~/.claude/` fallback instead of hardcoding `~/.harness/` regardless of the rest of the harness state's actual location (a pre-existing divergence from task `31fbf856`) — EXCEPT when `configPath` is injected without `homeDir`: `configPath` overrides the manifest path directly, so the log dir becomes `dirname(configPath)/logs` and does not route through home-dir resolution (or the `$HARNESS_HOME`/legacy tiers) at all in that case. The not-ready branch now catches only the guard's throw (`persistFailLog`'s own write failure already never throws, so it stays outside that try/catch) and folds it into the same `{ ok: false, reason }` shape a fail-log write failure already produces, so the SessionStart producer still never throws. New coverage in `tests/integration/operator-state-isolation.test.ts` (no `HARNESS_INTEGRATION_TESTS` opt-in, no subprocess spawn), all exercising `runSessionStartPreflight` directly: a no-injection negative control (asserts the guard blocks the write and the real `~/.harness/logs/` is byte-for-byte unchanged); a hermetic `homeDir`-injection case and a hermetic `configPath`-injection case (neither mocks `os` nor needs `HARNESS_ALLOW_REAL_GENERATED_DIR`, since an injected option bypasses the guard outright); a `$HARNESS_HOME` case (needs the `ALLOW_REAL` escape hatch to reach that precedence tier since neither `homeDir` nor `configPath` is injected, secured with the same real-`~/.harness/logs/`-unchanged assertion as the negative control); and a legacy `~/.claude/` fallback case, the only one of the five that mocks `node:os` — scoped to itself via `vi.doMock` + a dynamic re-import — because that precedence tier reads `os.homedir()` directly and has no LoaderOptions seam to override it. `preflight.test.ts`'s suite-local negative-control test is updated to assert the guard blocks the write; its `vi.mock("node:os")` pin stays in place as a second line of defense for every OTHER `os.homedir()` consumer in that suite (it no longer protects this one seam, which now fails closed independently of it).
1085
+ - **Two not-ready preflight fail-logs written in the same millisecond practically never overwrite each other anymore** (task `a48b9729`). The fail-log filename's ISO timestamp has millisecond resolution, so a second persist within the same ms silently clobbered the first; filenames now carry a 4-hex random suffix (`preflight-<repo>-<timestamp>-<hex4>.json`), leaving a 1-in-65536 same-millisecond residual collision chance rather than a guarantee. Alongside (test-only): the preflight suite pins `os.homedir()` to a throwaway tmp home so a not-ready test that forgets to inject `logDir` can never write into the operator's real `~/.harness/logs` (with a negative-control test), plus rotation boundary tests (exactly 20 → no eviction; 21 → exactly the oldest evicted), hostile-repo-basename containment tests, and a multi-failing-checks cap/join pin.
1086
+ - **`harness doctor`'s dead-`settings.json`-`mcpServers`-block check now honors `CLAUDE_CONFIG_DIR`** (task `f13c6fbd`, #423). The check read a fixed `<home>/.claude/settings.json`, so with a config-dir override it inspected a file Claude Code does not read. Resolution now mirrors `resolveClaudeUserRegistryPath`'s precedence (non-empty `CLAUDE_CONFIG_DIR` wins), the warning names the resolved path, and doctor gained an `envOverride` test knob so the dead-block tests stay hermetic against the operator's real environment. (This entry was itself backfilled by the first run of the new changelog-coverage gate below.)
1087
+ - **`harness dry-run`'s `bash_match` prediction under-predicted the quoted-shell-boundary family (`VAR='a; b' git push origin master`) that `harness policy intercept` already blocks** (task `f561e44c`, follow-up to `cf3dff51`). Task `cf3dff51` wired `normalizeCommandQuoteAware` into `policyMatchesEvent` (`src/runtime/intercept.ts`) as a fourth matching arm but, scoped narrowly to that one wiring point, deliberately left `dry-run`'s own independently-copied matcher (`src/cli/dry-run.ts`) untouched — reintroducing the debug-verb/runtime parity gap that file's own comment (and `docs/okf/debug-verb-selection.md`) has warned about since task `ea8becf5`. `policyMatchesTool` now tries the same fourth, quote-aware OR-arm, literally duplicated to mirror the amp-aware arm's own precedent (no shared-helper seam exists for this OR-chain yet). Debug-verb-only fix, no gate touched; `dry-run` was under-predicting, the fail-safe direction. New parity fixture: `tests/cli/dry-run.test.ts` asserts `dry-run` and `policyMatchesEvent` agree, entry for entry, across the 12 `cf3dff51` target spellings (`;`, `|`, `&&`, `(`, and a literal newline in both spaced/unspaced forms, on `git push` and the operator-only kill switch).
1088
+ - **`understanding-before-execution`'s `config.mode` (harness.yaml) was config-drift: it drove prose only, never enforcement** (task `5d73d78d`; fix landed in two rounds, the second a review fix-round closing three HIGH findings against the first). Live repro in an isolated home: `config.mode: grill_me`, an agent report with no `mode:` metadata field, `harness approve understanding` (stdin heredoc) → output read `validation: ✓ fast_confirm report passed structural checks` and persisted `"mode": "fast_confirm"`, silently skipping the `derivedTodos` / `acceptanceCriteria` / `priorArt` sections `grill_me` requires. Root cause, two independent sites: (1) the Claude-runtime `UserPromptSubmit`/`Stop` hooks are bins shipped by `@lannguyensi/understanding-gate` (`understanding-gate-claude-hook` / `understanding-gate-claude-stop`), invoked bare — no `UNDERSTANDING_GATE_MODE` env var — so the package's own env-then-marker-then-`fast_confirm`-default resolver always fell through to its default; (2) `persistStdinReport`'s gap-fill for a report with no declared mode hardcoded the literal `"fast_confirm"`.
1089
+ - Fix: mode resolution is now TWO functions with different contracts (`src/policy-packs/builtin/understanding-before-execution.ts`), not one. `resolveModeFromConfig()` — `config.mode` > default (`grill_me`), **never the env var** — drives the GENERATION path: `resolve()`/`buildHooks` (what `harness apply` bakes into `settings.json` + `instructions.md`) and `registry.ts`'s `resolveBuiltinDefaultConfig` (`harness doctor`'s UX-drift comparison, `harness pack reseed`). `resolveMode()` — `UNDERSTANDING_GATE_MODE` env > `config.mode` > default — is reserved for LIVE runtime consumers that resolve at invocation time with the real process environment available: `approve/understanding.ts`'s stdin-report gap-fill, and the Codex `UserPromptSubmit` injector (`harness pack hook codex-user-prompt-submit`). The first round shipped a single env-aware resolver for BOTH roles, which meant an operator's ambient `UNDERSTANDING_GATE_MODE` (exported for an unrelated one-off override, or left over from a previous shell) would silently override `config.mode` in the GENERATED artefact itself — persisting, frozen, until the next `apply`, and making `doctor`/`reseed` report drift that had nothing to do with `harness.yaml`. `buildHooks` still bakes the (now config-only) resolved mode into the two npm-backed Claude bin commands via the same env var (`wrapMode`), but omits the prefix entirely when the resolved mode already coerces to the package's own default (`fast_confirm`) — baking it unconditionally (the first round's behavior) made the package's own in-prompt `/grill` / "grill me" per-prompt escalation marker permanently dead, since the package's own resolver checks its env var before ever looking at the marker.
1090
+ - Fix: `harness approve understanding`'s stdin-report path now genuinely REFUSES the approval marker when the submitted report is rejected under the resolved mode (e.g. a `fast_confirm`-shaped report submitted against a `grill_me`-configured host) and no report matching this session id exists — no marker, no ledger tag, no report flip, unless `--force` (mirroring the pre-existing `priorArt`-validation short-circuit). The first round resolved the correct mode but a rejected stdin submission with no fallback report still fell through to a `{ skipped: true }` validation outcome (not `ok: false`), so the marker was written anyway — a config-drift-shaped hole reopened one call deeper than the one this task closes. A second review round (verification round 2) closed a further gap in the same guard: a sessionId-null pending report ADOPTED via the tolerant fallback (not a strict sessionId match) does not count as "matching this session id" either, so a rejected submission can no longer silently ride on an unrelated leftover report's already-valid content and get stamped with the live session's id.
1091
+ - `strict` has no upstream equivalent (the package's own mode type is two-valued); `toPackageMode()` coerces it to `grill_me` — the closest available rigor — only at that interop boundary, not inside harness's own `strict` semantics.
1092
+ - **Behavior change, corrected blast-radius statement (the first round's was inaccurate — it implied only an EXPLICITLY-configured non-`fast_confirm` host was affected):** the mode actually enforced now matches `config.mode` in all three cases — `config.mode: fast_confirm` (was already effectively `fast_confirm`, unaffected save for the marker-liveness fix above), `config.mode: grill_me` / `strict` (now genuinely enforced, was silently `fast_confirm`), **and no `config:` block / no `mode:` key at all** (now genuinely enforced at `grill_me`, the package default — this last case is the WIDEST blast radius of the three and was NOT called out by the first round's changelog entry: every unconfigured host is affected, not only hosts with an explicit setting). Previously-accepted reports missing `derivedTodos` / `acceptanceCriteria` / `priorArt` are now rejected until the agent supplies them, for any host in the latter two buckets.
1093
+ - New/updated coverage: `tests/policy-packs/resolve-mode.test.ts` (both resolvers' contracts, LOW-8/LOW-9 warning + normalisation fixes), `tests/policy-packs/expand.test.ts` and `tests/cli/apply/apply.test.ts` (generation-path env-independence, the omitted-prefix case), `tests/cli/approve-understanding.test.ts` and `tests/cli/approve-stdin-report.test.ts` (mode gap-fill end to end, the stdin-rejection marker-refusal enforcement + its `--force` escape hatch, the disabled-pack LOW-10 case), and a new suite-wide `tests/_helpers/scrub-ambient-env.ts` setup file (MEDIUM-5: an ambient `UNDERSTANDING_GATE_MODE` in the operator/CI shell no longer leaks into tests that don't explicitly set it).
1094
+ - **`harness approve understanding` could persist a self-contradictory report: `approvalStatus: "approved"` alongside a stale `expiredAt` timestamp** (task `646b7b6a`, reviewer finding on `agent-grounding` `5120938c`, the harness-side twin of that repo's `withApprovalStatus` fix in PR #173). `rewriteReportApproved` (`src/cli/approve/understanding.ts`) rewrote `approvalStatus` to `"approved"` in place without clearing `expiredAt`; since `@lannguyensi/understanding-gate` 0.4.11 declared `expiredAt` in the report schema, the field survived the rewrite whenever an operator approved a report that `expirePersistedReport` (`understanding-before-execution-runtime.ts`) had previously expired. Not a gate bypass — enforcement only reads `approvalStatus` — but a data-integrity defect in the persisted audit record. Fix: `rewriteReportApproved` now `delete`s `expiredAt` from the parsed report before serializing, mirroring the package's own `withApprovalStatus` idiom. New test in `tests/cli/approve-understanding.test.ts` covers approving a report fixtured exactly as `expirePersistedReport` writes one (`approvalStatus: "expired"` + ISO `expiredAt`), asserting the key is absent after approval.
1095
+
1096
+ ### Added
1097
+
1098
+ - **Release-pipeline guards: CI now fails fast when a commit since the last
1099
+ tag has no `[Unreleased]` changelog entry, and release.yml aborts instead
1100
+ of shipping an empty release body** (task `3ed3d333`). New
1101
+ `scripts/check-changelog-coverage.mjs` (`npm run
1102
+ check:changelog-coverage`, wired before Build in ci.yml in the
1103
+ check-no-only idiom) compares `git log <last-tag>..HEAD` against the
1104
+ `[Unreleased]` section: a commit counts as covered when the section cites
1105
+ one of its mechanical link tokens (an 8-hex task id from the commit
1106
+ message, the squash-merge `#PR` number, a GHSA advisory id, or the
1107
+ commit's own SHA); commits typed `test`/`docs`/`ci`/`style`/`refactor`/
1108
+ `chore` are skipped by a convention documented — with its limits — in the
1109
+ script header. Verified against the historical failure: a checkout of
1110
+ `5b7a36f` (pre-0.42.0) goes red with 14 uncovered commits (including the
1111
+ postcss HIGH advisory close), while this branch is green; the gate's
1112
+ first live run also caught the missing `f13c6fbd` doctor entry backfilled
1113
+ under Fixed above. release.yml additionally hard-stops after the awk
1114
+ extraction when `release_notes.md` came out empty or whitespace-only
1115
+ (the "tag pushed before converting [Unreleased]" failure mode), naming
1116
+ the missing `## [<version>]` section in the error.
1117
+ - The `toolchain-parity` ledger fact now carries unparseable peer snapshots
1118
+ instead of silently absorbing them into a shrunken comparison (task
1119
+ `690fba7c`, consumer hardening motivated by the agent-memory `06d09cde`
1120
+ incident where a corrupted `machine-state/<profile>.json` — inline git
1121
+ conflict markers, not valid JSON — made the drift count quietly fall from
1122
+ `drift:22` to `drift:5` with no warning). A peer file that fails
1123
+ `parseSnapshotJson` now also logs a `parity:unparseable-peer:<file-derived
1124
+ label>` stderr line, and the run's ledger fact gains a
1125
+ `:unparseable-peer:<n>` suffix (e.g. `toolchain-parity:drift:7:unparseable-peer:2`)
1126
+ whenever one or more peer files were skipped this way, so an unparseable
1127
+ peer never makes `drift:N` look like a complete comparison. (Peers whose
1128
+ file cannot be READ at all are a separate branch scoped to task
1129
+ `c1b5ade5`; the absence of the suffix alone therefore does not prove
1130
+ completeness.) The file-derived label is sanitized before interpolation —
1131
+ peer filenames arrive cross-machine via sync and are untrusted, so a
1132
+ crafted name cannot forge a standalone parity line in stderr — and falls
1133
+ back to the raw filename when stripping `.json` would leave it empty. The
1134
+ existing "peer snapshot `<file>` is corrupt" warn line is unchanged;
1135
+ valid-only peer sets keep the exact pre-existing `toolchain-parity:ok` /
1136
+ `toolchain-parity:drift:<n>` format (no behaviour change). Four new tests
1137
+ (negative control, corrupt-peer-plus-drift, multi-peer aggregation
1138
+ pinning `:unparseable-peer:2`, hostile-filename sanitization) plus two
1139
+ existing cases extended.
1140
+ - Both PreToolUse hooks (Claude and Codex) now tell a blocked agent WHICH
1141
+ Understanding Report sections were malformed on their last attempt: a
1142
+ `(list)` heading that was present but held prose instead of markdown
1143
+ list items, with an agent-facing sentence naming each section as
1144
+ `<Display Name> (<key>)` (e.g. `Prior Art (priorArt)`), paired with
1145
+ `renderReportSchemaHint`'s existing display-name vocabulary rather than
1146
+ the bare camelCase key the parser itself uses (task `823837fd`, review
1147
+ Fix 3). The sentence is only appended when the session has NO persisted
1148
+ report at all, never when a fixed report is merely pending operator
1149
+ approval (review Fix 1), and is defined once and shared by both hooks
1150
+ via `renderMalformedSectionsNotice` (`src/cli/approve/understanding.ts`,
1151
+ review Fix 4) instead of two byte-identical copies.
1152
+ - `harness approve understanding`'s one-line parse-error summary
1153
+ (`findLatestParseError`) likewise names malformed sections when the
1154
+ operator-facing reason is not already sourced from the package's own
1155
+ `message` field, which annotates them inline since
1156
+ `@lannguyensi/understanding-gate` 0.4.10 (review Fix 5).
1157
+ - `@lannguyensi/understanding-gate` lockfile refreshed to 0.4.10 (the
1158
+ `malformedSections` `ParseError` field this surfacing depends on);
1159
+ the declared range stays `^0.4.9`.
1160
+ - **`harness doctor` now warns (advisory, never an error) when
1161
+ `UNDERSTANDING_GATE_MODE` is set in the operator's shell environment and
1162
+ diverges from `policy_packs[understanding-before-execution].config.mode`**
1163
+ (task `24abdecb`, reviewer finding on the `5d73d78d` Batch 11
1164
+ verification round). After that task's fix, only the LIVE runtime path
1165
+ (`resolveMode` in `approve/understanding.ts`'s stdin-report gap-fill, and
1166
+ the Codex `UserPromptSubmit` injector) reads the env var at all — the
1167
+ GENERATION path (`resolve()`/`buildHooks`, what `harness apply` bakes
1168
+ into settings.json) never has and still doesn't. An operator who carries
1169
+ `UNDERSTANDING_GATE_MODE` in their shell profile therefore silently
1170
+ downgrades live enforcement relative to what `harness.yaml` declares,
1171
+ with no signal anywhere that the two have diverged — live-verified:
1172
+ `harness approve understanding` accepted a `fast_confirm`-shaped report
1173
+ against a `grill_me`-configured manifest with no warning. New
1174
+ `checkUnderstandingModeEnvDivergence` (`src/cli/doctor/understanding-mode-env.ts`)
1175
+ is a pure manifest+env check, gated on the pack being declared and
1176
+ enabled, normalising the env value the same way `resolveMode` does
1177
+ (trim + lowercase) so an env value the live resolver would itself reject
1178
+ is correctly treated as non-divergent; the advisory names both the env
1179
+ value and the resolved `config.mode` and renders in doctor's
1180
+ `Environment` section (mirroring the existing npm-global-bin-off-PATH
1181
+ advisory there). `harness doctor`'s existing `envOverride` test-injection
1182
+ knob is reused rather than adding a second one. New coverage in
1183
+ `tests/cli/doctor-understanding-mode-env.test.ts`: the pure function's
1184
+ unset/empty/agreeing/invalid/undeclared/disabled no-advisory cases, the
1185
+ diverging-value case (naming both values), and a `doctor()`-level
1186
+ integration pass through `format()` asserting the rendered `Environment`
1187
+ section and `warningCount`.
1188
+ - **`harness apply --runtime opencode`: a third policy-pack adapter
1189
+ runtime alongside `claude-code` and `codex`** (task `f34eb233`).
1190
+ New `src/cli/apply/generate-opencode-config.ts` emits
1191
+ `harness.generated/opencode/opencode.json` in place of settings.json,
1192
+ carrying an ADR-style header documenting the adapter-mapping decisions
1193
+ verified against opencode's published config JSON Schema and docs:
1194
+ `tools.mcp[]` IS projected (opencode's native `mcp` block, reusing
1195
+ `generate-settings.ts`'s `buildMcpServers` / `projectGroundingEnv` so
1196
+ the grounding-mcp `EVIDENCE_LEDGER_DB` wiring and the literal-tilde
1197
+ warning stay identical across every runtime); manifest `hooks[]` and
1198
+ `memory.router` are NOT projected (opencode has no declarative
1199
+ hook/event field at all, only a JS/TS plugin API — out of this task's
1200
+ scope), and opencode's native `permission` block is deferred to a
1201
+ follow-up (v1 keeps parity with the codex adapter's own deferred
1202
+ sandbox-profile mapping), both cases surfacing a warning instead of a
1203
+ silent drop. Output is byte-stable across repeat applies and harness
1204
+ never merges into, or writes, any operator-owned opencode config file
1205
+ (`~/.config/opencode/`, a project `opencode.json`, or `$OPENCODE_CONFIG`)
1206
+ — the generated artefact's own banner explains how an operator wires it
1207
+ in. `--target <path>` is rejected with `--runtime opencode`, symmetric
1208
+ to the existing `--runtime codex` rejection (both wire Claude Code's
1209
+ settings.json). `harness doctor --target opencode`
1210
+ (`src/cli/doctor/opencode.ts`) checks the generated artefact's
1211
+ presence + banner and that every projected MCP server's command
1212
+ resolves on PATH; `countStatusDiagnostics` is factored out of
1213
+ `doctor/codex.ts`'s `countCodexDiagnostics` so both target modules
1214
+ share one tally loop instead of two copies. New coverage:
1215
+ `tests/cli/apply/generate-opencode-config.test.ts`,
1216
+ `tests/cli/apply/apply-opencode-runtime.test.ts`,
1217
+ `tests/cli/doctor-opencode.test.ts`.
1218
+ - **`harness apply --runtime opencode` review fix-round** (task
1219
+ `f34eb233`): addresses five findings from the batch18 review of the
1220
+ opencode adapter above. HIGH: `branch-protection` /
1221
+ `understanding-before-execution` / `solution-acceptance` /
1222
+ `post-merge-gate`'s `buildInstructions()` each branched only on
1223
+ `runtime === "codex"`, so opencode silently fell through to the
1224
+ claude-code `else` branch and its `instructions.md` falsely claimed
1225
+ hooks were "wired into the harness-managed settings.json" even though
1226
+ nothing is wired for opencode (no settings.json exists, and
1227
+ `packExpansion.hooks` is never projected into the opencode artefact).
1228
+ Each pack's instructions now mark opencode UNSUPPORTED/not-wired,
1229
+ mirroring `post-merge-gate`'s existing codex marker;
1230
+ `post-merge-gate`'s pack-level apply warning widened from
1231
+ `runtime === "codex"` to `runtime !== "claude-code"` so opencode gets
1232
+ it too; `apply.ts`'s stale "pack instructions.md are runtime-agnostic"
1233
+ comment corrected. MED: added a golden `toBe()` test over
1234
+ `generateOpencodeConfig`'s full rendered output (a manifest with mcp +
1235
+ hooks + a policy-pack-shaped hook + memory.router, deterministic
1236
+ `homeDir`) — the prior substring-only assertions missed a
1237
+ deleted/garbled line inside the generator's static HEADER banner.
1238
+ `src/cli/init/interactive.ts`'s three stale "disabled until f34eb233
1239
+ lands" comments/messages reworded: the adapter has shipped, wizard
1240
+ wiring is tracked separately as installer v1.1 task `c5287b80`;
1241
+ `docs/for-agents.md`'s CLI cheat sheet gains the missing
1242
+ `doctor --target opencode` row. LOW: a `tools.mcp[]` entry declared
1243
+ `enabled: false` is now projected into the opencode `mcp` block as an
1244
+ explicit `{"enabled": false}` marker instead of being omitted --
1245
+ opencode MERGES multiple config sources, so an omitted key does not
1246
+ override an active declaration for the same server name elsewhere,
1247
+ while opencode does accept a bare `{"enabled": false}` entry;
1248
+ `harness doctor --target opencode`'s MCP command-resolution check
1249
+ skips the PATH lookup for such a marker instead of reading its missing
1250
+ `.command`. `countStatusDiagnostics` and its status type moved out of
1251
+ `doctor/codex.ts` into new `doctor/target-checks.ts` (re-exported from
1252
+ `codex.ts` for compatibility) -- `codex.ts` was not a natural home for
1253
+ a target-agnostic tally helper other adapters also depend on.
1254
+
10
1255
  ## [0.44.0] - 2026-08-03
11
1256
 
12
1257
  ### Security