harnery 0.31.5 → 0.32.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 (222) hide show
  1. package/dist/commander.d.ts.map +1 -1
  2. package/dist/commander.js +2 -0
  3. package/dist/commands/agents.d.ts +0 -9
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +70 -95
  6. package/dist/commands/browse-session.d.ts +21 -0
  7. package/dist/commands/browse-session.d.ts.map +1 -0
  8. package/dist/commands/browse-session.js +157 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +205 -37
  11. package/dist/commands/checkpoint.js +1 -1
  12. package/dist/commands/deinit.d.ts.map +1 -1
  13. package/dist/commands/deinit.js +4 -0
  14. package/dist/commands/docs.d.ts.map +1 -1
  15. package/dist/commands/docs.js +40 -0
  16. package/dist/commands/doctor.d.ts.map +1 -1
  17. package/dist/commands/doctor.js +100 -17
  18. package/dist/commands/init.d.ts +1 -2
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +16 -5
  21. package/dist/commands/tunnel.d.ts.map +1 -1
  22. package/dist/commands/tunnel.js +2 -3
  23. package/dist/core/agents/canonical-emit.d.ts +1 -2
  24. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  25. package/dist/core/agents/canonical-emit.js +1 -2
  26. package/dist/core/agents/cli.js +123 -88
  27. package/dist/core/agents/coord-client.d.ts +11 -4
  28. package/dist/core/agents/coord-client.d.ts.map +1 -1
  29. package/dist/core/agents/coord-client.js +27 -9
  30. package/dist/core/agents/finalization.d.ts +68 -0
  31. package/dist/core/agents/finalization.d.ts.map +1 -0
  32. package/dist/core/agents/finalization.js +443 -0
  33. package/dist/core/agents/git-hook.d.ts +51 -0
  34. package/dist/core/agents/git-hook.d.ts.map +1 -0
  35. package/dist/core/agents/git-hook.js +118 -0
  36. package/dist/core/agents/render/prompt-context.d.ts +6 -5
  37. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/prompt-context.js +26 -13
  39. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  40. package/dist/core/agents/render/session-context.js +15 -2
  41. package/dist/core/agents/rules/claim-conflict.js +3 -3
  42. package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
  43. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  44. package/dist/core/agents/rules/commit-conflict.js +21 -5
  45. package/dist/core/agents/rules/stop-hook.d.ts +3 -0
  46. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  47. package/dist/core/agents/rules/stop-hook.js +59 -23
  48. package/dist/core/agents/session-events.d.ts +8 -16
  49. package/dist/core/agents/session-events.d.ts.map +1 -1
  50. package/dist/core/agents/session-events.js +12 -26
  51. package/dist/core/agents/state/heartbeat-projector.d.ts +3 -0
  52. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  53. package/dist/core/agents/state/heartbeat-projector.js +44 -1
  54. package/dist/core/agents/state/heartbeat-writer.d.ts +32 -5
  55. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  56. package/dist/core/agents/state/heartbeat-writer.js +54 -17
  57. package/dist/core/agents/state/names.d.ts +22 -1
  58. package/dist/core/agents/state/names.d.ts.map +1 -1
  59. package/dist/core/agents/state/names.js +40 -2
  60. package/dist/core/config.d.ts +30 -2
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +74 -11
  63. package/dist/core/governor/planning.d.ts.map +1 -1
  64. package/dist/core/governor/planning.js +12 -13
  65. package/dist/core/hooks/adapter/detect.d.ts +2 -4
  66. package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
  67. package/dist/core/hooks/adapter/detect.js +4 -14
  68. package/dist/core/hooks/adapter/output.d.ts +1 -1
  69. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  70. package/dist/core/hooks/adapter/output.js +5 -4
  71. package/dist/core/hooks/adapter/parse.d.ts +1 -1
  72. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  73. package/dist/core/hooks/adapter/parse.js +22 -5
  74. package/dist/core/hooks/adapter/wiring.d.ts +23 -0
  75. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  76. package/dist/core/hooks/adapter/wiring.js +32 -0
  77. package/dist/core/hooks/cli.d.ts +1 -2
  78. package/dist/core/hooks/cli.d.ts.map +1 -1
  79. package/dist/core/hooks/cli.js +169 -27
  80. package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
  81. package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
  82. package/dist/core/hooks/codex-wsl-bridge.js +137 -0
  83. package/dist/core/hooks/effects/index.d.ts +1 -1
  84. package/dist/core/hooks/effects/index.js +3 -3
  85. package/dist/core/hooks/events/schema.d.ts +27 -0
  86. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  87. package/dist/core/hooks/resolve/owner.d.ts +7 -1
  88. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  89. package/dist/core/hooks/resolve/owner.js +15 -0
  90. package/dist/core/hooks/resolve/transcript.d.ts +44 -0
  91. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  92. package/dist/core/hooks/resolve/transcript.js +176 -1
  93. package/dist/core/hooks/session-name-presence.d.ts +30 -0
  94. package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
  95. package/dist/core/hooks/session-name-presence.js +43 -0
  96. package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
  97. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
  98. package/dist/core/hooks/unsafe-cross-shell.js +151 -0
  99. package/dist/core/work/state.d.ts +4 -5
  100. package/dist/core/work/state.d.ts.map +1 -1
  101. package/dist/core/work/state.js +5 -8
  102. package/dist/core/workflow/index.d.ts +1 -1
  103. package/dist/core/workflow/index.d.ts.map +1 -1
  104. package/dist/core/workflow/proof.d.ts +2 -2
  105. package/dist/core/workflow/proof.d.ts.map +1 -1
  106. package/dist/core/workflow/proof.js +2 -2
  107. package/dist/core/workflow/run-state.d.ts +2 -2
  108. package/dist/core/workflow/run-state.d.ts.map +1 -1
  109. package/dist/core/workflow/run-state.js +2 -2
  110. package/dist/core/workflow/types.d.ts +3 -3
  111. package/dist/core/workflow/types.d.ts.map +1 -1
  112. package/dist/core/workflow/workspaces/execution.d.ts +2 -2
  113. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
  114. package/dist/core/workflow/workspaces/execution.js +5 -0
  115. package/dist/core/workflow/workspaces/index.d.ts +1 -1
  116. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  117. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  118. package/dist/core/workflow/workspaces/local-git.js +1 -2
  119. package/dist/core/workflow/workspaces/paths.d.ts +2 -2
  120. package/dist/core/workflow/workspaces/types.d.ts +0 -6
  121. package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
  122. package/dist/core/workflow/workspaces/validate.d.ts +0 -2
  123. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
  124. package/dist/core/workflow/workspaces/validate.js +0 -2
  125. package/dist/lib/browser/client.d.ts +118 -1
  126. package/dist/lib/browser/client.d.ts.map +1 -1
  127. package/dist/lib/browser/client.js +435 -6
  128. package/dist/lib/browser/geometry.d.ts.map +1 -1
  129. package/dist/lib/browser/geometry.js +180 -37
  130. package/dist/lib/browser/index.d.ts +5 -1
  131. package/dist/lib/browser/index.d.ts.map +1 -1
  132. package/dist/lib/browser/index.js +5 -1
  133. package/dist/lib/browser/netscape-cookies.d.ts +6 -0
  134. package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
  135. package/dist/lib/browser/netscape-cookies.js +31 -0
  136. package/dist/lib/browser/proxy.d.ts +24 -0
  137. package/dist/lib/browser/proxy.d.ts.map +1 -0
  138. package/dist/lib/browser/proxy.js +84 -0
  139. package/dist/lib/browser/runts.d.ts +6 -0
  140. package/dist/lib/browser/runts.d.ts.map +1 -1
  141. package/dist/lib/browser/runts.js +21 -4
  142. package/dist/lib/browser/session-control.d.ts +112 -0
  143. package/dist/lib/browser/session-control.d.ts.map +1 -0
  144. package/dist/lib/browser/session-control.js +670 -0
  145. package/dist/lib/docs-links.d.ts +108 -0
  146. package/dist/lib/docs-links.d.ts.map +1 -0
  147. package/dist/lib/docs-links.js +555 -0
  148. package/dist/lib/exec.d.ts +1 -1
  149. package/dist/lib/exec.js +1 -1
  150. package/dist/lib/identities/assume.d.ts +4 -2
  151. package/dist/lib/identities/assume.d.ts.map +1 -1
  152. package/dist/lib/identities/assume.js +15 -2
  153. package/dist/lib/instructions/git-hooks.d.ts +75 -0
  154. package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
  155. package/dist/lib/instructions/git-hooks.js +238 -0
  156. package/dist/lib/instructions/splice.d.ts +10 -4
  157. package/dist/lib/instructions/splice.d.ts.map +1 -1
  158. package/dist/lib/instructions/splice.js +19 -11
  159. package/package.json +1 -1
  160. package/schemas/config.schema.json +32 -0
  161. package/src/commander.ts +2 -0
  162. package/src/commands/agents.ts +96 -115
  163. package/src/commands/browse-session.ts +245 -0
  164. package/src/commands/browse.ts +287 -44
  165. package/src/commands/checkpoint.ts +1 -1
  166. package/src/commands/deinit.ts +7 -1
  167. package/src/commands/docs.ts +56 -0
  168. package/src/commands/doctor.ts +100 -18
  169. package/src/commands/init.ts +17 -9
  170. package/src/commands/tunnel.ts +2 -5
  171. package/src/core/agents/canonical-emit.ts +1 -2
  172. package/src/core/agents/cli.ts +137 -88
  173. package/src/core/agents/coord-client.ts +38 -9
  174. package/src/core/agents/finalization.ts +595 -0
  175. package/src/core/agents/git-hook.ts +126 -0
  176. package/src/core/agents/render/prompt-context.ts +28 -13
  177. package/src/core/agents/render/session-context.ts +15 -2
  178. package/src/core/agents/rules/claim-conflict.ts +3 -3
  179. package/src/core/agents/rules/commit-conflict.ts +35 -8
  180. package/src/core/agents/rules/stop-hook.ts +71 -23
  181. package/src/core/agents/session-events.ts +14 -41
  182. package/src/core/agents/state/heartbeat-projector.ts +45 -1
  183. package/src/core/agents/state/heartbeat-writer.ts +78 -16
  184. package/src/core/agents/state/names.ts +54 -2
  185. package/src/core/config.ts +92 -12
  186. package/src/core/governor/planning.ts +17 -12
  187. package/src/core/hooks/adapter/detect.ts +4 -13
  188. package/src/core/hooks/adapter/output.ts +9 -4
  189. package/src/core/hooks/adapter/parse.ts +24 -5
  190. package/src/core/hooks/adapter/wiring.ts +43 -0
  191. package/src/core/hooks/cli.ts +201 -23
  192. package/src/core/hooks/codex-wsl-bridge.ts +188 -0
  193. package/src/core/hooks/effects/index.ts +5 -5
  194. package/src/core/hooks/events/schema.ts +27 -0
  195. package/src/core/hooks/resolve/owner.ts +22 -1
  196. package/src/core/hooks/resolve/transcript.ts +164 -1
  197. package/src/core/hooks/session-name-presence.ts +52 -0
  198. package/src/core/hooks/unsafe-cross-shell.ts +160 -0
  199. package/src/core/work/state.ts +8 -12
  200. package/src/core/workflow/engine.ts +2 -2
  201. package/src/core/workflow/index.ts +0 -1
  202. package/src/core/workflow/proof.ts +4 -4
  203. package/src/core/workflow/run-state.ts +5 -5
  204. package/src/core/workflow/types.ts +3 -3
  205. package/src/core/workflow/workspaces/execution.ts +11 -4
  206. package/src/core/workflow/workspaces/index.ts +0 -3
  207. package/src/core/workflow/workspaces/local-git.ts +1 -2
  208. package/src/core/workflow/workspaces/paths.ts +2 -2
  209. package/src/core/workflow/workspaces/types.ts +0 -9
  210. package/src/core/workflow/workspaces/validate.ts +1 -5
  211. package/src/lib/browser/client.ts +528 -6
  212. package/src/lib/browser/geometry.ts +197 -37
  213. package/src/lib/browser/index.ts +39 -0
  214. package/src/lib/browser/netscape-cookies.ts +39 -0
  215. package/src/lib/browser/proxy.ts +105 -0
  216. package/src/lib/browser/runts.ts +27 -3
  217. package/src/lib/browser/session-control.ts +892 -0
  218. package/src/lib/docs-links.ts +674 -0
  219. package/src/lib/exec.ts +1 -1
  220. package/src/lib/identities/assume.ts +22 -1
  221. package/src/lib/instructions/git-hooks.ts +259 -0
  222. package/src/lib/instructions/splice.ts +41 -11
@@ -1,8 +1,9 @@
1
- import { mkdirSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, resolve } from "node:path";
4
4
  import { resolveBinName } from "../core/config.js";
5
- import { Browser, captureDevOverlay, DEFAULT_CRITIQUE_RUBRIC, parseAssertSpec, runCritique, tilesFromFullPage, wslHeadedLaunchArgs, } from "../lib/browser/index.js";
5
+ import { Browser, browserProxyFromEnv, browserProxyGateFromEnv, captureDevOverlay, DEFAULT_CRITIQUE_RUBRIC, extractObservedIp, parseAssertSpec, runCritique, tilesFromFullPage, WEBRTC_PROXY_ONLY_ARG, writeNetscapeCookieFile, wslHeadedLaunchArgs, } from "../lib/browser/index.js";
6
+ import { startBrowserSessionServer, } from "../lib/browser/session-control.js";
6
7
  import { diffAgainstBaseline, saveBaseline, } from "../lib/browser/visual-diff.js";
7
8
  import { CookieJar } from "../lib/cookies/index.js";
8
9
  /**
@@ -44,7 +45,6 @@ export function registerBrowseCommand(program, emitParam, context) {
44
45
  .description("Headless Chromium with persistent profile + cookie jar. Default writes a trio of files (last.png, last.html, last.json) for the LLM iteration loop; --snapshot/--html/--json switch to stdout-print mode.")
45
46
  .option("--out <prefix>", "Output prefix for the trio (writes <prefix>.png, .html, .json). Defaults to ~/.cache/harnery/browse/last.")
46
47
  .option("--no-screenshot", "Skip the .png in the trio (DOM + JSON only)")
47
- .option("--dom-only", "Alias for --no-screenshot")
48
48
  .option("--no-full-page", "Capture only the viewport, not the full scrollable page")
49
49
  .option("--snapshot", "Print body innerText to stdout (skips file writes)")
50
50
  .option("--html", `Print raw outer HTML to stdout (skips file writes; pair with \`${resolveBinName()} read -\`)`)
@@ -62,15 +62,22 @@ export function registerBrowseCommand(program, emitParam, context) {
62
62
  .option("--network-har <path>", "Record network traffic to a HAR file (finalized on close)")
63
63
  .option("--viewport <preset|WxH>", "Viewport: mobile (390x844), tablet (820x1180), desktop (1280x800), hd (1920x1080), or explicit '1920x1080'", "desktop")
64
64
  .option("--login", "Headed mode for one-time auth flow (cookies persist in profile)")
65
+ .option("--login-close-file <path>", "With --login, wait for this file instead of terminal Enter, then remove it and close cleanly")
66
+ .option("--control-file <path>", "With --login, publish an owner-only descriptor for repeated browse-session control")
65
67
  .option("--headed", "Headed mode for one-off (no auth-flow framing)")
66
68
  .option("--browser-arg <flag>", "Extra Chromium launch flag, passed straight to the browser (repeatable). " +
67
69
  "e.g. --browser-arg --disable-gpu. Also settable machine-wide via the " +
68
70
  "HARNERY_BROWSER_ARGS env var (whitespace-separated). Under WSL, headed " +
69
71
  "launches auto-add --disable-gpu to fix blank-window paint; opt out with " +
70
72
  "HARNERY_BROWSER_NO_WSL_DEFAULTS=1.", (value, prev = []) => [...prev, value], [])
73
+ .option("--proxy-from-env", "Pass HTTP(S)_PROXY to Playwright as an authenticated browser proxy. " +
74
+ "Credentials stay in the child environment, never command arguments. " +
75
+ "When the host also injects the HARNERY_BROWSER_PROXY_* gate variables, " +
76
+ "the expected IP is verified before the requested URL opens.")
71
77
  .option("--no-cookies", "Skip cookie-jar attach and persist")
72
78
  .option("--store <path>", `Cookie store path (default ${DEFAULT_STORE})`)
73
79
  .option("--profile <dir>", `Persistent Chromium profile dir (default ${DEFAULT_PROFILE})`)
80
+ .option("--export-cookies <path>", "Write live profile cookies as an owner-only Netscape cookies.txt file on close")
74
81
  .option("--wait-until <strategy>", "Navigation wait strategy: load | domcontentloaded | networkidle | commit", "load")
75
82
  .option("--timeout <ms>", "Navigation timeout in milliseconds", "30000")
76
83
  .option("--check-visible <selector>", "Run an occlusion check on this selector after navigation + batch. " +
@@ -96,7 +103,7 @@ export function registerBrowseCommand(program, emitParam, context) {
96
103
  .option("--check-overflow", "Assert the document has no horizontal overflow (document.scrollWidth <= window.innerWidth). " +
97
104
  "Surfaces protruding elements in the JSON envelope and annotates them on the screenshot. " +
98
105
  "Catches the class of mobile-layout bug where a nav/table overflows the viewport edge.")
99
- .option("--check-overflow-fail", "Exit non-zero if --check-overflow detects horizontal overflow.")
106
+ .option("--check-overflow-fail", "Run --check-overflow and exit 2 if horizontal overflow is detected.")
100
107
  .option("--no-check-overflow-annotate", "Skip drawing overflow annotations on the screenshot (JSON still emitted).")
101
108
  .option("--check-runts [selector]", "Scan text blocks for runts (a single word alone on a block's last visual line) " +
102
109
  "by counting words on the last line via per-word Range rects — width thresholds " +
@@ -104,26 +111,27 @@ export function registerBrowseCommand(program, emitParam, context) {
104
111
  "Atomic tokens (URLs, emails, phone numbers) are excluded. Reports hits in the JSON " +
105
112
  "envelope under `runts` and annotates them on the screenshot.")
106
113
  .option("--check-runts-min-chars <n>", "Minimum block text length to scan (default 40; smaller labels can't meaningfully wrap).", "40")
107
- .option("--check-runts-fail", "Exit non-zero if any runt is detected.")
114
+ .option("--check-runts-fail", "Run a document-wide runt check when needed; exit 2 on a runt, missing scope, or incomplete sweep.")
108
115
  .option("--no-check-runts-annotate", "Skip drawing runt boxes on the screenshot (JSON still emitted).")
109
116
  .option("--check-align <selector>", "Check rendered child-content alignment inside this container (repeatable).", (value, previous = []) => [...previous, value], [])
110
117
  .option("--check-align-axis <axis>", "Alignment axis: auto | x | y (default auto).", "auto")
111
118
  .option("--check-align-threshold <px>", "Maximum rendered-center drift in CSS pixels (default 2).", "2")
112
- .option("--check-align-fail", "Exit 2 when an alignment target fails or is missing.")
119
+ .option("--check-align-fail", "Exit 2 when an alignment target fails, is unknown, or is missing.")
113
120
  .option("--no-check-align-annotate", "Skip alignment screenshot annotations.")
114
121
  .option("--check-gap <selector>", "Check adjacent rendered gaps inside this container (repeatable).", (value, previous = []) => [...previous, value], [])
115
122
  .option("--check-gap-axis <axis>", "Gap axis: auto | x | y (default auto).", "auto")
116
123
  .option("--check-gap-expected <px>", "Expected gap in CSS pixels. Without it, groups of 3+ infer the median gap.")
117
124
  .option("--check-gap-threshold <px>", "Maximum gap deviation in CSS pixels (default 2).", "2")
118
- .option("--check-gap-fail", "Exit 2 when a gap target fails or is missing.")
125
+ .option("--check-gap-fail", "Exit 2 when a gap target fails, is unknown, or is missing.")
119
126
  .option("--no-check-gap-annotate", "Skip gap screenshot annotations.")
120
- .option("--check-clip <selector>", "Check descendants against this container's rectangular clipping chain (repeatable).", (value, previous = []) => [...previous, value], [])
127
+ .option("--check-clip <selector>", "Check every matching container for descendant boxes or text paint leaving its " +
128
+ "rectangular clipping chain or nearest block parent (repeatable).", (value, previous = []) => [...previous, value], [])
121
129
  .option("--check-clip-threshold <px>", "Allowed rectangular overrun in CSS pixels (default 0).", "0")
122
- .option("--check-clip-fail", "Exit 2 when a clip target fails or is missing.")
130
+ .option("--check-clip-fail", "Exit 2 when a clip target fails, is unknown, or is missing.")
123
131
  .option("--no-check-clip-annotate", "Skip clip screenshot annotations.")
124
132
  .option("--check-overlap <selector>", "Check measurable in-flow siblings for unintended overlap (repeatable).", (value, previous = []) => [...previous, value], [])
125
133
  .option("--check-overlap-threshold <px>", "Allowed overlap depth on either axis in CSS pixels (default 0).", "0")
126
- .option("--check-overlap-fail", "Exit 2 when an overlap target fails or is missing.")
134
+ .option("--check-overlap-fail", "Exit 2 when an overlap target fails, is unknown, or is missing.")
127
135
  .option("--no-check-overlap-annotate", "Skip overlap screenshot annotations.")
128
136
  .option("--check-crowd <selector>", "Flag adjacent card panels (full border, modest corner radius, or box-shadow) " +
129
137
  "that touch or nearly touch. Also treats wrappers that contain panels as " +
@@ -134,7 +142,7 @@ export function registerBrowseCommand(program, emitParam, context) {
134
142
  "uneven spacing, so a uniformly-flush stack passes). Repeatable.", (value, previous = []) => [...previous, value], [])
135
143
  .option("--check-crowd-min <px>", "Minimum acceptable edge gap between adjacent panel faces in CSS px (default 6). " +
136
144
  "Pairs closer than this are flagged; negative separation (overlap) always flags.", "6")
137
- .option("--check-crowd-fail", "Exit 2 when a crowd target fails or is missing.")
145
+ .option("--check-crowd-fail", "Exit 2 when a crowd target fails, is unknown, or is missing.")
138
146
  .option("--no-check-crowd-annotate", "Skip crowd screenshot annotations.")
139
147
  .option("--check-placeholder [selector]", "Scan rendered text for unrendered template tokens and bound-value tells: " +
140
148
  "JS template literals, `{{x}}` handlebars, `[object Object]`, `Invalid Date`, " +
@@ -142,26 +150,26 @@ export function registerBrowseCommand(program, emitParam, context) {
142
150
  "scopes the sweep (default: whole body). Catches the most common generation " +
143
151
  "bug on funnels and reports. Do not point it at a page that documents template " +
144
152
  "syntax in prose.")
145
- .option("--check-placeholder-fail", "Exit 2 if any placeholder tell is found.")
153
+ .option("--check-placeholder-fail", "Run a document-wide placeholder check when needed; exit 2 if the result does not pass.")
146
154
  .option("--no-check-placeholder-annotate", "Skip placeholder screenshot annotations.")
147
155
  .option("--check-images [selector]", "Audit <img> elements for load + aspect health: a failed load (naturalWidth 0), " +
148
156
  "a still-loading image, or a stretched image (rendered aspect ratio far from the " +
149
157
  "intrinsic one, with an object-fit that does not correct it). Optional selector " +
150
158
  "scopes the sweep (default: whole body).")
151
159
  .option("--check-images-tolerance <ratio>", "Aspect-ratio deviation above which an image counts as stretched (0–1, default 0.1 = 10%).", "0.1")
152
- .option("--check-images-fail", "Exit 2 if any image failed to load or is stretched.")
160
+ .option("--check-images-fail", "Run a document-wide image check when needed; exit 2 if the result does not pass.")
153
161
  .option("--no-check-images-annotate", "Skip image screenshot annotations.")
154
162
  .option("--check-truncation [selector]", "Flag text actively cut off by an ellipsis or -webkit-line-clamp — the author asked " +
155
163
  "to truncate AND the content overflows. High precision: it does not flag plain " +
156
164
  "overflow:hidden clips. Optional selector scopes the sweep (default: whole body).")
157
165
  .option("--check-truncation-tolerance <px>", "Overflow past the clip box, in CSS px, above which truncation counts (default 2).", "2")
158
- .option("--check-truncation-fail", "Exit 2 if any text is actively truncated.")
166
+ .option("--check-truncation-fail", "Run a document-wide truncation check when needed; exit 2 if the result does not pass.")
159
167
  .option("--no-check-truncation-annotate", "Skip truncation screenshot annotations.")
160
168
  .option("--check-contrast [selector]", "Flag rendered text below the WCAG AA contrast ratio (4.5:1 normal, 3:1 large) against " +
161
169
  "its effective background. Runs at whatever theme the page is in, so toggle the theme " +
162
170
  "with --batch to cover light and dark. Text over an image or gradient is reported as " +
163
171
  "`unknown`, not failed. Optional selector scopes the sweep (default: whole body).")
164
- .option("--check-contrast-fail", "Exit 2 if any text fails the contrast ratio.")
172
+ .option("--check-contrast-fail", "Run a document-wide contrast check when needed; exit 2 on fail, unknown, or a missing scope.")
165
173
  .option("--no-check-contrast-annotate", "Skip contrast screenshot annotations.")
166
174
  .option("--check-critique [selector]", "Hand the rendered page to a vision model for a visual-defect review, tile by " +
167
175
  "tile. Catches the long tail heuristic checks can't enumerate. Default tiling " +
@@ -173,18 +181,18 @@ export function registerBrowseCommand(program, emitParam, context) {
173
181
  .option("--check-critique-overlap <px>", "Vertical overlap between bands so a finding at a seam stays visible (default 120).", "120")
174
182
  .option("--check-critique-max-tiles <n>", "Cap on tiles sent to the model, to bound cost (default 24).", "24")
175
183
  .option("--check-critique-rubric <text>", "Override the default critique rubric.")
176
- .option("--check-critique-fail", "Exit 2 if the critique returns any high-severity finding.")
184
+ .option("--check-critique-fail", "Run a whole-page critique when needed; exit 2 unless the critique conclusively passes.")
177
185
  .option("--assert <expr>", "Assert a page value (repeatable). Grammar: '<op> <selector> => <expected>', where op is " +
178
186
  "text (trimmed text equals), contains (text includes), matches (text matches a regex), " +
179
187
  "count (match count vs a number or >=/<=/>/< comparator), exists / absent (no => needed). " +
180
188
  "e.g. --assert 'text h1 => Welcome' --assert 'count .card => >=3' --assert 'absent .error'.", (value, previous = []) => [...previous, value], [])
181
- .option("--assert-fail", "Exit 2 if any --assert fails.")
189
+ .option("--assert-fail", "Exit 2 if any --assert fails; requires at least one --assert.")
182
190
  .option("--check-hit [selector]", "Check pointer-target size and spacing in the document or optional scope (repeatable).", (value, previous = []) => [
183
191
  ...previous,
184
192
  typeof value === "string" ? value : null,
185
193
  ], [])
186
194
  .option("--check-hit-profile <profile>", "Target profile: wcag-aa (24px) | comfortable (44px).", "wcag-aa")
187
- .option("--check-hit-fail", "Exit 2 when target sizing fails, is incomplete, or a scope is missing.")
195
+ .option("--check-hit-fail", "Run a document-wide target-size check when needed; exit 2 when it fails or is incomplete.")
188
196
  .option("--no-check-hit-annotate", "Skip target-size screenshot annotations.")
189
197
  .option("--baseline <name>", "Save the captured screenshot as a named baseline at " +
190
198
  "~/.cache/harnery/visual-baselines/<name>.png. Use --diff <name> later to " +
@@ -194,7 +202,7 @@ export function registerBrowseCommand(program, emitParam, context) {
194
202
  "similarity in the JSON envelope.")
195
203
  .option("--diff-threshold <ratio>", "mismatchRatio (mismatchedPixels / totalPixels) below which the diff is " +
196
204
  "considered a match (0–1, default 0.01 = 1%).", "0.01")
197
- .option("--diff-fail", "Exit non-zero if --diff mismatchRatio exceeds --diff-threshold.")
205
+ .option("--diff-fail", "Exit non-zero if --diff mismatchRatio exceeds --diff-threshold; requires --diff.")
198
206
  .option("--no-dev-overlay", "Skip auto-capture of Next.js dev-overlay issues. Default: capture every queued error (kind/code/message/stack) when a <nextjs-portal> shadow root is present. Necessary because Next.js 16 + React 19 route hydration errors + most React warnings through onCaughtError → next-devtools' errorQueue, NOT through console.error, so Playwright's standard listener doesn't see them. Surfaces them in the JSON envelope under `devOverlay`.")
199
207
  .action(async (url, opts) => {
200
208
  try {
@@ -208,6 +216,7 @@ export function registerBrowseCommand(program, emitParam, context) {
208
216
  });
209
217
  }
210
218
  async function runBrowse(url, opts, context) {
219
+ normalizeCheckFailOptions(opts);
211
220
  const alignAxis = parseLayoutAxis(opts.checkAlignAxis ?? "auto", "--check-align-axis");
212
221
  const gapAxis = parseLayoutAxis(opts.checkGapAxis ?? "auto", "--check-gap-axis");
213
222
  const alignThreshold = parseNonNegativeNumber(opts.checkAlignThreshold ?? "2", "--check-align-threshold");
@@ -225,6 +234,18 @@ async function runBrowse(url, opts, context) {
225
234
  : new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "harn-browse" });
226
235
  const headed = opts.login || opts.headed;
227
236
  const viewport = parseViewport(opts.viewport ?? "desktop");
237
+ const proxy = opts.proxyFromEnv ? browserProxyFromEnv() : undefined;
238
+ const proxyGate = opts.proxyFromEnv ? browserProxyGateFromEnv() : null;
239
+ if (opts.loginCloseFile && !opts.login) {
240
+ throw new Error("--login-close-file requires --login.");
241
+ }
242
+ if (opts.controlFile && !opts.login) {
243
+ throw new Error("--control-file requires --login.");
244
+ }
245
+ const loginCloseFile = opts.loginCloseFile ? resolve(opts.loginCloseFile) : null;
246
+ if (loginCloseFile && existsSync(loginCloseFile)) {
247
+ throw new Error(`Login close signal already exists at ${loginCloseFile}. Remove the stale file before launching.`);
248
+ }
228
249
  const browser = new Browser({
229
250
  profileDir: opts.profile ?? DEFAULT_PROFILE,
230
251
  headed,
@@ -234,12 +255,27 @@ async function runBrowse(url, opts, context) {
234
255
  waitUntil: opts.waitUntil,
235
256
  recordHarPath: opts.networkHar ? resolve(opts.networkHar) : undefined,
236
257
  extraHeaders: context?.extraHeaders,
237
- launchArgs: resolveLaunchArgs(opts, Boolean(headed)),
258
+ launchArgs: resolveLaunchArgs(opts, Boolean(headed), Boolean(proxy)),
259
+ proxy,
238
260
  });
239
261
  // Print mode: --snapshot / --html / --json all suppress file writes.
240
262
  const printMode = opts.snapshot || opts.html || opts.json;
263
+ let controlServer = null;
264
+ let receivedSignal = null;
265
+ let resolveSignal = null;
266
+ const signalPromise = new Promise((resolveSignalPromise) => {
267
+ resolveSignal = resolveSignalPromise;
268
+ });
269
+ const handleSignal = (signal) => {
270
+ receivedSignal = signal;
271
+ resolveSignal?.();
272
+ };
273
+ const handleSigint = () => handleSignal("SIGINT");
274
+ const handleSigterm = () => handleSignal("SIGTERM");
241
275
  try {
242
276
  await browser.open();
277
+ if (proxyGate)
278
+ await verifyBrowserProxyGate(browser, proxyGate);
243
279
  const navResult = await browser.navigate(url);
244
280
  if (opts.fill) {
245
281
  const sep = opts.fill.indexOf("=>");
@@ -408,11 +444,35 @@ async function runBrowse(url, opts, context) {
408
444
  if (contentBoxes.length > 0) {
409
445
  await browser.annotateContent(contentBoxes);
410
446
  }
447
+ if (opts.controlFile) {
448
+ controlServer = await startBrowserSessionServer(opts.controlFile, browser);
449
+ process.once("SIGINT", handleSigint);
450
+ process.once("SIGTERM", handleSigterm);
451
+ emit.log(`browser session ready: ${controlServer.descriptorPath}`, "info");
452
+ }
411
453
  if (opts.login) {
412
- await new Promise((res) => {
454
+ const lifecycleAbort = new AbortController();
455
+ const closeWaits = [];
456
+ if (loginCloseFile) {
457
+ emit.log(`[--login] Headed Chromium is open. Drive the visible window now. Create ${loginCloseFile} to close cleanly.`, "info");
458
+ closeWaits.push(waitForLoginCloseFile(loginCloseFile, lifecycleAbort.signal));
459
+ }
460
+ else {
413
461
  emit.log("[--login] Headed Chromium is open. Walk through your auth flow now. Press Enter here to close + persist cookies into the profile.", "info");
414
- process.stdin.once("data", () => res());
415
- });
462
+ closeWaits.push(waitForTerminalEnter(lifecycleAbort.signal));
463
+ }
464
+ if (controlServer) {
465
+ closeWaits.push(controlServer.closeRequested, signalPromise);
466
+ }
467
+ await Promise.race(closeWaits);
468
+ lifecycleAbort.abort();
469
+ if (controlServer)
470
+ await controlServer.stopAccepting();
471
+ }
472
+ if (opts.exportCookies) {
473
+ const exportPath = resolve(opts.exportCookies);
474
+ writeNetscapeCookieFile(exportPath, await browser.cookies());
475
+ emit.log(`exported live profile cookies to ${exportPath} (mode 600)`, "info");
416
476
  }
417
477
  // Auto-capture Next.js dev-overlay issues unless --no-dev-overlay was passed.
418
478
  // Cheap no-op when no <nextjs-portal> shadow host is present (non-Next.js page).
@@ -485,9 +545,17 @@ async function runBrowse(url, opts, context) {
485
545
  process.exitCode = 2;
486
546
  }
487
547
  }
488
- if (opts.checkRuntsFail && runts && runts.runts.length > 0) {
489
- for (const hit of runts.runts) {
490
- emit.log(`check-runts FAIL ${hit.block}: last line is a lone "${hit.word}" ("…${hit.snippet.slice(-40)}")`, "warn");
548
+ if (opts.checkRuntsFail && runts && runts.outcome !== "pass") {
549
+ if (!runts.found) {
550
+ emit.log(`check-runts FAIL ${runts.scope}: scope not found`, "warn");
551
+ }
552
+ else if (runts.outcome === "unknown") {
553
+ emit.log(`check-runts FAIL ${runts.scope ?? "document"}: sweep incomplete after ${runts.scannedBlocks} blocks`, "warn");
554
+ }
555
+ else {
556
+ for (const hit of runts.runts) {
557
+ emit.log(`check-runts FAIL ${hit.block}: last line is a lone "${hit.word}" ("…${hit.snippet.slice(-40)}")`, "warn");
558
+ }
491
559
  }
492
560
  process.exitCode = 2;
493
561
  }
@@ -501,9 +569,11 @@ async function runBrowse(url, opts, context) {
501
569
  }
502
570
  applyLayoutLintFailGates(opts, layoutLint, hit);
503
571
  applyContentFailGates(opts, content);
504
- if (opts.checkCritiqueFail && critique && critique.outcome === "fail") {
505
- const high = critique.findings.filter((f) => f.severity === "high").length;
506
- emit.log(`check-critique FAIL: ${high} high-severity finding(s)`, "warn");
572
+ if (opts.checkCritiqueFail && critique && critique.outcome !== "pass") {
573
+ const detail = critique.outcome === "skipped"
574
+ ? (critique.error ?? "critique was skipped")
575
+ : `${critique.findings.filter((f) => f.severity === "high").length} high-severity finding(s)`;
576
+ emit.log(`check-critique FAIL: ${detail}`, "warn");
507
577
  process.exitCode = 2;
508
578
  }
509
579
  if (opts.assertFail && asserts) {
@@ -516,7 +586,12 @@ async function runBrowse(url, opts, context) {
516
586
  }
517
587
  }
518
588
  finally {
589
+ process.removeListener("SIGINT", handleSigint);
590
+ process.removeListener("SIGTERM", handleSigterm);
591
+ await controlServer?.cleanup();
519
592
  await browser.close();
593
+ if (receivedSignal)
594
+ process.exitCode = receivedSignal === "SIGINT" ? 130 : 143;
520
595
  }
521
596
  }
522
597
  async function runBatch(browser, batch, defaultTimeoutMs) {
@@ -684,7 +759,7 @@ async function runTrioMode(browser, navResult, opts, evalResult, visibility, wid
684
759
  const prefix = resolveOutPrefix(opts.out);
685
760
  mkdirSync(dirname(prefix), { recursive: true });
686
761
  const written = [];
687
- const skipScreenshot = opts.screenshot === false || opts.domOnly === true;
762
+ const skipScreenshot = opts.screenshot === false;
688
763
  let pngPath;
689
764
  let pngBytes;
690
765
  let captureEvalResult;
@@ -707,7 +782,7 @@ async function runTrioMode(browser, navResult, opts, evalResult, visibility, wid
707
782
  written.push(pngPath);
708
783
  }
709
784
  else if (opts.captureEvaluate) {
710
- throw new Error("--capture-evaluate requires a screenshot; remove --no-screenshot / --dom-only.");
785
+ throw new Error("--capture-evaluate requires a screenshot; remove --no-screenshot.");
711
786
  }
712
787
  const htmlPath = `${prefix}.html`;
713
788
  const html = await browser.htmlContent(opts.selector);
@@ -770,7 +845,7 @@ async function runTrioMode(browser, navResult, opts, evalResult, visibility, wid
770
845
  let savedBaseline;
771
846
  let diff;
772
847
  if ((opts.baseline || opts.diff) && !pngPath) {
773
- throw new Error("--baseline / --diff require a screenshot; --no-screenshot / --dom-only disables it.");
848
+ throw new Error("--baseline / --diff require a screenshot; --no-screenshot disables it.");
774
849
  }
775
850
  if (opts.baseline && pngPath) {
776
851
  savedBaseline = saveBaseline(pngPath, opts.baseline);
@@ -847,6 +922,9 @@ async function runTrioMode(browser, navResult, opts, evalResult, visibility, wid
847
922
  : ""}`;
848
923
  emit.log(`check-overflow: [${ok ? "OK" : "FAIL"}] ${detail}`, ok ? "info" : "warn");
849
924
  }
925
+ if (runts) {
926
+ emit.log(`check-runts: [${runts.outcome.toUpperCase()}] ${runts.scope ?? "document"}: ${runts.runts.length} runt${runts.runts.length === 1 ? "" : "s"} in ${runts.scannedBlocks} scanned block${runts.scannedBlocks === 1 ? "" : "s"}${runts.truncated ? " (incomplete)" : ""}`, runts.outcome === "pass" ? "info" : "warn");
927
+ }
850
928
  if (layoutLint) {
851
929
  logLayoutLintSummary(layoutLint);
852
930
  }
@@ -902,6 +980,46 @@ function parseNonNegativeNumber(value, flag) {
902
980
  }
903
981
  return parsed;
904
982
  }
983
+ function normalizeCheckFailOptions(opts) {
984
+ // Whole-document checks can be enabled by their fail flag. Selector-based
985
+ // checks cannot invent a scope, so reject the invocation before Chromium
986
+ // opens instead of returning a misleading zero.
987
+ if (opts.checkOverflowFail)
988
+ opts.checkOverflow = true;
989
+ if (opts.checkRuntsFail && opts.checkRunts === undefined)
990
+ opts.checkRunts = true;
991
+ if (opts.checkPlaceholderFail && opts.checkPlaceholder === undefined) {
992
+ opts.checkPlaceholder = true;
993
+ }
994
+ if (opts.checkImagesFail && opts.checkImages === undefined)
995
+ opts.checkImages = true;
996
+ if (opts.checkTruncationFail && opts.checkTruncation === undefined) {
997
+ opts.checkTruncation = true;
998
+ }
999
+ if (opts.checkContrastFail && opts.checkContrast === undefined)
1000
+ opts.checkContrast = true;
1001
+ if (opts.checkCritiqueFail && opts.checkCritique === undefined)
1002
+ opts.checkCritique = true;
1003
+ if (opts.checkHitFail && (!opts.checkHit || opts.checkHit.length === 0)) {
1004
+ opts.checkHit = [null];
1005
+ }
1006
+ const requireTargets = (failEnabled, targets, failFlag, checkFlag) => {
1007
+ if (failEnabled && (!targets || targets.length === 0)) {
1008
+ throw new Error(`${failFlag} requires ${checkFlag}.`);
1009
+ }
1010
+ };
1011
+ requireTargets(opts.checkVisibleFail, opts.checkVisible, "--check-visible-fail", "--check-visible");
1012
+ requireTargets(opts.checkWidthFail, opts.checkWidth, "--check-width-fail", "--check-width");
1013
+ requireTargets(opts.checkAlignFail, opts.checkAlign, "--check-align-fail", "--check-align");
1014
+ requireTargets(opts.checkGapFail, opts.checkGap, "--check-gap-fail", "--check-gap");
1015
+ requireTargets(opts.checkClipFail, opts.checkClip, "--check-clip-fail", "--check-clip");
1016
+ requireTargets(opts.checkOverlapFail, opts.checkOverlap, "--check-overlap-fail", "--check-overlap");
1017
+ requireTargets(opts.checkCrowdFail, opts.checkCrowd, "--check-crowd-fail", "--check-crowd");
1018
+ requireTargets(opts.assertFail, opts.assert, "--assert-fail", "--assert");
1019
+ if (opts.diffFail && !opts.diff) {
1020
+ throw new Error("--diff-fail requires --diff.");
1021
+ }
1022
+ }
905
1023
  function parseLayoutAxis(value, flag) {
906
1024
  if (value === "auto" || value === "x" || value === "y")
907
1025
  return value;
@@ -943,9 +1061,14 @@ function applyLayoutLintFailGates(opts, layoutLint, hit) {
943
1061
  const gate = (enabled, rule, results) => {
944
1062
  if (!enabled || !results)
945
1063
  return;
946
- const failed = results.filter((result) => !result.found || result.outcome === "fail");
1064
+ const failed = results.filter((result) => !result.found || result.outcome === "fail" || result.outcome === "unknown");
947
1065
  for (const result of failed) {
948
- emit.log(`check-${rule} FAIL ${result.selector}: ${result.found ? "rendered relation failed" : "element not found"}`, "warn");
1066
+ const reason = !result.found
1067
+ ? "element not found"
1068
+ : result.outcome === "unknown"
1069
+ ? "result unknown"
1070
+ : "rendered relation failed";
1071
+ emit.log(`check-${rule} FAIL ${result.selector}: ${reason}`, "warn");
949
1072
  }
950
1073
  if (failed.length > 0)
951
1074
  process.exitCode = 2;
@@ -1057,8 +1180,13 @@ function applyContentFailGates(opts, content) {
1057
1180
  const gate = (enabled, rule, result) => {
1058
1181
  if (!enabled || !result)
1059
1182
  return;
1060
- if (!result.found || result.outcome === "fail") {
1061
- emit.log(`check-${rule} FAIL: ${result.found ? "issues found" : "scope not found"}`, "warn");
1183
+ if (!result.found || result.outcome !== "pass") {
1184
+ const reason = !result.found
1185
+ ? "scope not found"
1186
+ : result.outcome === "unknown"
1187
+ ? "result unknown"
1188
+ : "issues found";
1189
+ emit.log(`check-${rule} FAIL: ${reason}`, "warn");
1062
1190
  process.exitCode = 2;
1063
1191
  }
1064
1192
  };
@@ -1122,7 +1250,7 @@ function summarizeDiagnostics(diag) {
1122
1250
  * 2. HARNERY_BROWSER_ARGS env (whitespace-separated) — a machine-wide default.
1123
1251
  * 3. --browser-arg flags on this invocation (repeatable).
1124
1252
  */
1125
- function resolveLaunchArgs(opts, headed) {
1253
+ function resolveLaunchArgs(opts, headed, proxyEnabled) {
1126
1254
  const args = [];
1127
1255
  if (headed && !process.env.HARNERY_BROWSER_NO_WSL_DEFAULTS) {
1128
1256
  args.push(...wslHeadedLaunchArgs());
@@ -1132,8 +1260,48 @@ function resolveLaunchArgs(opts, headed) {
1132
1260
  args.push(...envArgs.split(/\s+/));
1133
1261
  if (opts.browserArg && opts.browserArg.length > 0)
1134
1262
  args.push(...opts.browserArg);
1263
+ if (proxyEnabled)
1264
+ args.push(WEBRTC_PROXY_ONLY_ARG);
1135
1265
  return [...new Set(args)];
1136
1266
  }
1267
+ async function verifyBrowserProxyGate(browser, gate) {
1268
+ const result = await browser.navigate(gate.checkUrl);
1269
+ if (result.status === null || result.status < 200 || result.status >= 300) {
1270
+ throw new Error(`Browser proxy gate failed before target navigation (HTTP ${result.status ?? "unknown"}).`);
1271
+ }
1272
+ const body = await browser.evaluate("document.body?.innerText ?? ''");
1273
+ const observedIp = extractObservedIp(body);
1274
+ if (observedIp !== gate.expectedIp) {
1275
+ throw new Error(`Browser proxy gate returned ${observedIp ?? "no IP"}; expected ${gate.expectedIp}. Target navigation was blocked.`);
1276
+ }
1277
+ emit.log(`browser proxy gate: ${observedIp} · expected IP confirmed`, "info");
1278
+ }
1279
+ async function waitForLoginCloseFile(path, signal) {
1280
+ while (!existsSync(path) && !signal?.aborted) {
1281
+ await new Promise((resolve) => setTimeout(resolve, 250));
1282
+ }
1283
+ if (signal?.aborted)
1284
+ return;
1285
+ unlinkSync(path);
1286
+ }
1287
+ async function waitForTerminalEnter(signal) {
1288
+ await new Promise((resolveWait) => {
1289
+ const onData = () => {
1290
+ cleanup();
1291
+ resolveWait();
1292
+ };
1293
+ const onAbort = () => {
1294
+ cleanup();
1295
+ resolveWait();
1296
+ };
1297
+ const cleanup = () => {
1298
+ process.stdin.removeListener("data", onData);
1299
+ signal.removeEventListener("abort", onAbort);
1300
+ };
1301
+ process.stdin.once("data", onData);
1302
+ signal.addEventListener("abort", onAbort, { once: true });
1303
+ });
1304
+ }
1137
1305
  function parseViewport(spec) {
1138
1306
  const preset = VIEWPORT_PRESETS[spec.toLowerCase()];
1139
1307
  if (preset)
@@ -134,7 +134,7 @@ function resolveContinuityIdentity(context, opts) {
134
134
  return { coordRoot, instanceId, sessionId, adapter };
135
135
  }
136
136
  function adapterFromPlatform(platform) {
137
- if (platform === "claude_code" || platform === "claude-code")
137
+ if (platform === "claude-code")
138
138
  return "claude-code";
139
139
  if (platform === "cursor")
140
140
  return "cursor";
@@ -1 +1 @@
1
- {"version":3,"file":"deinit.d.ts","sourceRoot":"","sources":["../../src/commands/deinit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAanD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CA+GjG;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EAAE;IACtC,UAAU,EAAE,OAAO,CAAC;IACpB,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,OAAO,CAAC;IACpB,WAAW,EAAE,OAAO,CAAC;CACtB,GAAG,OAAO,CAEV;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAM1C"}
1
+ {"version":3,"file":"deinit.d.ts","sourceRoot":"","sources":["../../src/commands/deinit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAenD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAmHjG;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EAAE;IACtC,UAAU,EAAE,OAAO,CAAC;IACpB,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,OAAO,CAAC;IACpB,WAAW,EAAE,OAAO,CAAC;CACtB,GAAG,OAAO,CAEV;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAM1C"}
@@ -32,6 +32,7 @@ import { createInterface } from "node:readline";
32
32
  import { DEFAULT_BIN_NAME } from "../core/config.js";
33
33
  import { ADAPTER_SPECS } from "../core/hooks/adapter/events.js";
34
34
  import { removeInstructions } from "../lib/instructions/apply.js";
35
+ import { removeGitHooks } from "../lib/instructions/git-hooks.js";
35
36
  import { unwireHooks } from "./init.js";
36
37
  export function registerDeinitCommand(program, emit, binName) {
37
38
  // The interactive prompt + engine-removal hint are standalone-harn niceties.
@@ -117,6 +118,9 @@ export function registerDeinitCommand(program, emit, binName) {
117
118
  // ── 1b. agent-facing instructions block + skills ───────────────────────
118
119
  const removed = removeInstructions(projectRoot, { adapter, dryRun });
119
120
  actions.push(...removed.actions, ...removed.warnings.map((w) => `! ${w}`));
121
+ // ── 1c. git-hook managed regions ───────────────────────────────────────
122
+ const gitHooks = removeGitHooks(projectRoot, { dryRun });
123
+ actions.push(...gitHooks.actions, ...gitHooks.warnings.map((w) => `! ${w}`));
120
124
  // ── 2. coord root (opt-in; destructive) ────────────────────────────────
121
125
  if (purgeState) {
122
126
  if (!coordExists) {
@@ -1 +1 @@
1
- {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/commands/docs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAiC1E,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,OAAO,EAChB,SAAS,EAAE,WAAW,EACtB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,IAAI,CA8GN"}
1
+ {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/commands/docs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAmC1E,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,OAAO,EAChB,SAAS,EAAE,WAAW,EACtB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,IAAI,CA0IN"}
@@ -1,6 +1,7 @@
1
1
  import { initDocsContext as initDocs, scanDocs } from "../lib/docs.js";
2
2
  import { initDocsMigrationContext, runFrontmatterMigration, } from "../lib/docs-frontmatter-migrate.js";
3
3
  import { initDocsContext as initDocsIndex, runIndex } from "../lib/docs-index.js";
4
+ import { initDocsContext as initDocsLinks, runLinks } from "../lib/docs-links.js";
4
5
  import { initDocsContext as initDocsLint, runLint } from "../lib/docs-lint.js";
5
6
  import { readDocsMetadata, readDocsMetadataKey } from "../lib/docs-meta.js";
6
7
  import { countColdHandoffs, initDocsContext as initDocsSweep, runSweep, } from "../lib/docs-sweep.js";
@@ -17,6 +18,7 @@ function ensureContext(context) {
17
18
  extraExcludedPrefixes: context.extraDocsExcludedPrefixes,
18
19
  docsRootAllowlist: context.docsRootAllowlist,
19
20
  });
21
+ initDocsLinks({ ...opts, extraExcludedPrefixes: context.extraDocsExcludedPrefixes });
20
22
  initDocsSweep(opts);
21
23
  }
22
24
  let emit;
@@ -89,6 +91,25 @@ export function registerDocsCommand(program, emitParam, context) {
89
91
  emit.error({ code: "docs_error", message: msg });
90
92
  }
91
93
  });
94
+ docs
95
+ .command("links")
96
+ .description("Check internal Markdown link targets and heading fragments")
97
+ .option("--repo <name>", "Limit to one submodule or '.' for parent")
98
+ .option("--no-fragments", "Check target existence only; skip heading-fragment validation")
99
+ .option("--strict", "Report findings in archive/audit/changelog docs as errors, not warnings")
100
+ .option("--check-escapes", "Also flag links that resolve outside their own repo root")
101
+ .option("--fail", "Exit non-zero when errors are found (advisory by default)")
102
+ .option("--format <type>", "Output format: human, json", "human")
103
+ .action(async (opts) => {
104
+ try {
105
+ ensureContext(context);
106
+ await handleLinks(opts);
107
+ }
108
+ catch (err) {
109
+ const msg = err instanceof Error ? err.message : String(err);
110
+ emit.error({ code: "docs_error", message: msg });
111
+ }
112
+ });
92
113
  docs
93
114
  .command("sweep")
94
115
  .description("Surface stalled lifecycle states: stalled plans, cold issues, unverified runbooks")
@@ -185,6 +206,25 @@ async function handleLint(opts) {
185
206
  if (errors.length > 0)
186
207
  emit.setExitCode(1);
187
208
  }
209
+ // --- `harn docs links` ---
210
+ /**
211
+ * Advisory by default: a clean exit code even with findings, because turning
212
+ * link health into a gate is an enforcement policy decision for the host, not
213
+ * something this command should assume. `--fail` is the opt-in for CI or a hook.
214
+ */
215
+ async function handleLinks(opts) {
216
+ if (opts.format === "json")
217
+ emit.config({ format: "json" });
218
+ const report = await runLinks({
219
+ repo: opts.repo,
220
+ noFragments: opts.fragments === false,
221
+ strict: opts.strict,
222
+ checkEscapes: opts.checkEscapes,
223
+ });
224
+ emit.data({ ...report, advisory: !opts.fail });
225
+ if (opts.fail && report.error_count > 0)
226
+ emit.setExitCode(1);
227
+ }
188
228
  // --- `harn docs sweep` ---
189
229
  async function handleSweep(opts) {
190
230
  if (opts.format === "json")
@@ -1 +1 @@
1
- {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/commands/doctor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAanD,KAAK,QAAQ,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;AAEvC,UAAU,KAAK;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,QAAQ,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAOD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAmD/E;AAED,wBAAgB,SAAS,IAAI,KAAK,EAAE,CAcnC"}
1
+ {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/commands/doctor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAgBnD,KAAK,QAAQ,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;AAEvC,UAAU,KAAK;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,QAAQ,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAOD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAmD/E;AAED,wBAAgB,SAAS,IAAI,KAAK,EAAE,CAsBnC"}