harnery 0.31.6 → 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 (218) 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 +7 -3
  28. package/dist/core/agents/coord-client.d.ts.map +1 -1
  29. package/dist/core/agents/coord-client.js +5 -4
  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/wiring.d.ts +23 -0
  72. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  73. package/dist/core/hooks/adapter/wiring.js +32 -0
  74. package/dist/core/hooks/cli.d.ts +1 -2
  75. package/dist/core/hooks/cli.d.ts.map +1 -1
  76. package/dist/core/hooks/cli.js +169 -27
  77. package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
  78. package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
  79. package/dist/core/hooks/codex-wsl-bridge.js +137 -0
  80. package/dist/core/hooks/effects/index.d.ts +1 -1
  81. package/dist/core/hooks/effects/index.js +3 -3
  82. package/dist/core/hooks/events/schema.d.ts +27 -0
  83. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  84. package/dist/core/hooks/resolve/owner.d.ts +7 -1
  85. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  86. package/dist/core/hooks/resolve/owner.js +15 -0
  87. package/dist/core/hooks/resolve/transcript.d.ts +44 -0
  88. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  89. package/dist/core/hooks/resolve/transcript.js +176 -1
  90. package/dist/core/hooks/session-name-presence.d.ts +30 -0
  91. package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
  92. package/dist/core/hooks/session-name-presence.js +43 -0
  93. package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
  94. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
  95. package/dist/core/hooks/unsafe-cross-shell.js +151 -0
  96. package/dist/core/work/state.d.ts +4 -5
  97. package/dist/core/work/state.d.ts.map +1 -1
  98. package/dist/core/work/state.js +5 -8
  99. package/dist/core/workflow/index.d.ts +1 -1
  100. package/dist/core/workflow/index.d.ts.map +1 -1
  101. package/dist/core/workflow/proof.d.ts +2 -2
  102. package/dist/core/workflow/proof.d.ts.map +1 -1
  103. package/dist/core/workflow/proof.js +2 -2
  104. package/dist/core/workflow/run-state.d.ts +2 -2
  105. package/dist/core/workflow/run-state.d.ts.map +1 -1
  106. package/dist/core/workflow/run-state.js +2 -2
  107. package/dist/core/workflow/types.d.ts +3 -3
  108. package/dist/core/workflow/types.d.ts.map +1 -1
  109. package/dist/core/workflow/workspaces/execution.d.ts +2 -2
  110. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
  111. package/dist/core/workflow/workspaces/execution.js +5 -0
  112. package/dist/core/workflow/workspaces/index.d.ts +1 -1
  113. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  114. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  115. package/dist/core/workflow/workspaces/local-git.js +1 -2
  116. package/dist/core/workflow/workspaces/paths.d.ts +2 -2
  117. package/dist/core/workflow/workspaces/types.d.ts +0 -6
  118. package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
  119. package/dist/core/workflow/workspaces/validate.d.ts +0 -2
  120. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
  121. package/dist/core/workflow/workspaces/validate.js +0 -2
  122. package/dist/lib/browser/client.d.ts +118 -1
  123. package/dist/lib/browser/client.d.ts.map +1 -1
  124. package/dist/lib/browser/client.js +435 -6
  125. package/dist/lib/browser/geometry.d.ts.map +1 -1
  126. package/dist/lib/browser/geometry.js +180 -37
  127. package/dist/lib/browser/index.d.ts +5 -1
  128. package/dist/lib/browser/index.d.ts.map +1 -1
  129. package/dist/lib/browser/index.js +5 -1
  130. package/dist/lib/browser/netscape-cookies.d.ts +6 -0
  131. package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
  132. package/dist/lib/browser/netscape-cookies.js +31 -0
  133. package/dist/lib/browser/proxy.d.ts +24 -0
  134. package/dist/lib/browser/proxy.d.ts.map +1 -0
  135. package/dist/lib/browser/proxy.js +84 -0
  136. package/dist/lib/browser/runts.d.ts +6 -0
  137. package/dist/lib/browser/runts.d.ts.map +1 -1
  138. package/dist/lib/browser/runts.js +21 -4
  139. package/dist/lib/browser/session-control.d.ts +112 -0
  140. package/dist/lib/browser/session-control.d.ts.map +1 -0
  141. package/dist/lib/browser/session-control.js +670 -0
  142. package/dist/lib/docs-links.d.ts +108 -0
  143. package/dist/lib/docs-links.d.ts.map +1 -0
  144. package/dist/lib/docs-links.js +555 -0
  145. package/dist/lib/exec.d.ts +1 -1
  146. package/dist/lib/exec.js +1 -1
  147. package/dist/lib/identities/assume.d.ts +4 -2
  148. package/dist/lib/identities/assume.d.ts.map +1 -1
  149. package/dist/lib/identities/assume.js +15 -2
  150. package/dist/lib/instructions/git-hooks.d.ts +75 -0
  151. package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
  152. package/dist/lib/instructions/git-hooks.js +238 -0
  153. package/dist/lib/instructions/splice.d.ts +10 -4
  154. package/dist/lib/instructions/splice.d.ts.map +1 -1
  155. package/dist/lib/instructions/splice.js +19 -11
  156. package/package.json +1 -1
  157. package/schemas/config.schema.json +32 -0
  158. package/src/commander.ts +2 -0
  159. package/src/commands/agents.ts +96 -115
  160. package/src/commands/browse-session.ts +245 -0
  161. package/src/commands/browse.ts +287 -44
  162. package/src/commands/checkpoint.ts +1 -1
  163. package/src/commands/deinit.ts +7 -1
  164. package/src/commands/docs.ts +56 -0
  165. package/src/commands/doctor.ts +100 -18
  166. package/src/commands/init.ts +17 -9
  167. package/src/commands/tunnel.ts +2 -5
  168. package/src/core/agents/canonical-emit.ts +1 -2
  169. package/src/core/agents/cli.ts +137 -88
  170. package/src/core/agents/coord-client.ts +10 -5
  171. package/src/core/agents/finalization.ts +595 -0
  172. package/src/core/agents/git-hook.ts +126 -0
  173. package/src/core/agents/render/prompt-context.ts +28 -13
  174. package/src/core/agents/render/session-context.ts +15 -2
  175. package/src/core/agents/rules/claim-conflict.ts +3 -3
  176. package/src/core/agents/rules/commit-conflict.ts +35 -8
  177. package/src/core/agents/rules/stop-hook.ts +71 -23
  178. package/src/core/agents/session-events.ts +14 -41
  179. package/src/core/agents/state/heartbeat-projector.ts +45 -1
  180. package/src/core/agents/state/heartbeat-writer.ts +78 -16
  181. package/src/core/agents/state/names.ts +54 -2
  182. package/src/core/config.ts +92 -12
  183. package/src/core/governor/planning.ts +17 -12
  184. package/src/core/hooks/adapter/detect.ts +4 -13
  185. package/src/core/hooks/adapter/output.ts +9 -4
  186. package/src/core/hooks/adapter/wiring.ts +43 -0
  187. package/src/core/hooks/cli.ts +201 -23
  188. package/src/core/hooks/codex-wsl-bridge.ts +188 -0
  189. package/src/core/hooks/effects/index.ts +5 -5
  190. package/src/core/hooks/events/schema.ts +27 -0
  191. package/src/core/hooks/resolve/owner.ts +22 -1
  192. package/src/core/hooks/resolve/transcript.ts +164 -1
  193. package/src/core/hooks/session-name-presence.ts +52 -0
  194. package/src/core/hooks/unsafe-cross-shell.ts +160 -0
  195. package/src/core/work/state.ts +8 -12
  196. package/src/core/workflow/engine.ts +2 -2
  197. package/src/core/workflow/index.ts +0 -1
  198. package/src/core/workflow/proof.ts +4 -4
  199. package/src/core/workflow/run-state.ts +5 -5
  200. package/src/core/workflow/types.ts +3 -3
  201. package/src/core/workflow/workspaces/execution.ts +11 -4
  202. package/src/core/workflow/workspaces/index.ts +0 -3
  203. package/src/core/workflow/workspaces/local-git.ts +1 -2
  204. package/src/core/workflow/workspaces/paths.ts +2 -2
  205. package/src/core/workflow/workspaces/types.ts +0 -9
  206. package/src/core/workflow/workspaces/validate.ts +1 -5
  207. package/src/lib/browser/client.ts +528 -6
  208. package/src/lib/browser/geometry.ts +197 -37
  209. package/src/lib/browser/index.ts +39 -0
  210. package/src/lib/browser/netscape-cookies.ts +39 -0
  211. package/src/lib/browser/proxy.ts +105 -0
  212. package/src/lib/browser/runts.ts +27 -3
  213. package/src/lib/browser/session-control.ts +892 -0
  214. package/src/lib/docs-links.ts +674 -0
  215. package/src/lib/exec.ts +1 -1
  216. package/src/lib/identities/assume.ts +22 -1
  217. package/src/lib/instructions/git-hooks.ts +259 -0
  218. package/src/lib/instructions/splice.ts +41 -11
@@ -28,7 +28,7 @@ import { join } from "node:path";
28
28
  import { emitAndProject } from "../../core/agents/cli-emit.ts";
29
29
  import { emit } from "../../core/agents/events/emit.ts";
30
30
  import { type Heartbeat, readHeartbeat } from "../../core/agents/state/heartbeat-writer.ts";
31
- import { recordNameAssumption } from "../../core/agents/state/names.ts";
31
+ import { recordNameAssumption, resolveForkAncestry } from "../../core/agents/state/names.ts";
32
32
  import { instanceHasLivePid, removePidmapRowsForInstance } from "../../core/agents/state/pidmap.ts";
33
33
  import { coordFreshnessSeconds } from "../../core/config.ts";
34
34
  import { readRemoteMachines } from "../../core/presence/index.ts";
@@ -41,6 +41,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
41
41
  export type IdentityAssumeErrorCode =
42
42
  | "identity_busy"
43
43
  | "identity_in_use"
44
+ | "identity_is_ancestor"
44
45
  | "identity_not_found"
45
46
  | "invalid_identity"
46
47
  | "no_heartbeat"
@@ -255,6 +256,7 @@ export function assumeIdentity(
255
256
  coordRoot: string,
256
257
  instanceId: string,
257
258
  target: string,
259
+ opts?: { forceAncestor?: boolean },
258
260
  ): IdentityAssumeResult {
259
261
  const release = acquireLock(coordRoot);
260
262
  try {
@@ -273,6 +275,25 @@ export function assumeIdentity(
273
275
  }
274
276
 
275
277
  const targetIdentity = resolveTarget(coordRoot, target);
278
+
279
+ // Recorded-fork-lineage guard: a branched session inherits a transcript
280
+ // full of its ancestor's name, so "assume <ancestor>" is far more likely
281
+ // a confused fork trusting its scrollback than a legitimate role handoff.
282
+ // The liveness check below cannot catch this once the ancestor exits;
283
+ // lineage can. --force-ancestor is the deliberate-successor escape hatch.
284
+ if (!opts?.forceAncestor) {
285
+ const ancestor = resolveForkAncestry(coordRoot, instanceId).find(
286
+ (a) => (a.name ?? "").toLowerCase() === targetIdentity.name.toLowerCase(),
287
+ );
288
+ if (ancestor) {
289
+ throw new IdentityAssumeError(
290
+ "identity_is_ancestor",
291
+ `agent-${targetIdentity.name} is this session's fork ancestor (${ancestor.instance_id}): ` +
292
+ `this conversation was branched from that session, so its name in your context is ` +
293
+ `inherited history, not your role. If you are deliberately succeeding it, rerun with --force-ancestor.`,
294
+ );
295
+ }
296
+ }
276
297
  let reclaimedInstanceId: string | null = null;
277
298
  let conflict = findIdentityConflict(coordRoot, instanceId, targetIdentity.name);
278
299
  if (conflict && reclaimAbandonedLocalConflict(coordRoot, conflict)) {
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Git-hook side of ADR 0008's lifecycle contract: the coordination content of
3
+ * a consumer's git hooks is machine-owned, exactly like the AGENTS.md block.
4
+ * `init` installs/refreshes it, `deinit` removes it, `init --check` flags
5
+ * drift.
6
+ *
7
+ * What lives in the hook file is deliberately tiny: a hash-versioned managed
8
+ * region (# harnery:begin/end markers) that locates `agent-coord` and invokes
9
+ * `git-hook <event>`. Every piece of actual behavior — staged collection,
10
+ * submodule canonicalization, gitlink discrimination, verdict, claim pruning —
11
+ * lives in harnery and upgrades with the package. The region only changes when
12
+ * the invocation contract does, and then `init` re-splices it and `--check`
13
+ * catches a stale copy. History's lesson (the first host carried ~200 lines of
14
+ * coordination bash that decayed for months): logic in a host hook file is
15
+ * logic outside the upgrade path.
16
+ *
17
+ * A hook file harnery created whole is deletable by `deinit` (nothing but the
18
+ * shebang remains after the region is removed); a host-authored hook keeps all
19
+ * host content and only loses the managed region.
20
+ */
21
+
22
+ import { spawnSync } from "node:child_process";
23
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
24
+ import { isAbsolute, join, relative, resolve } from "node:path";
25
+ import type { GitHookEvent } from "../../core/agents/git-hook.ts";
26
+ import {
27
+ checkRegion,
28
+ type ManagedStatus,
29
+ regionBlock,
30
+ removeRegion,
31
+ spliceRegion,
32
+ } from "./splice.ts";
33
+
34
+ export const GIT_HOOK_EVENTS = [
35
+ "pre-commit",
36
+ "post-commit",
37
+ "post-checkout",
38
+ ] as const satisfies readonly GitHookEvent[];
39
+ export type { GitHookEvent };
40
+
41
+ function regionName(event: GitHookEvent): string {
42
+ return `git-hook-${event}`;
43
+ }
44
+
45
+ /**
46
+ * The managed region body for one hook event. POSIX sh, `harnery_`-prefixed
47
+ * variables (the region runs inside arbitrary host hook scripts), and a bin
48
+ * lookup that covers both consumer layouts: git-submodule
49
+ * (`<root>/harnery/bin/`) and npm (`<root>/node_modules/harnery/bin/`).
50
+ *
51
+ * pre-commit propagates a blocking exit code; post-commit / post-checkout are
52
+ * best-effort and never fail the git operation.
53
+ */
54
+ export function renderGitHookBody(event: GitHookEvent): string {
55
+ const blocking = event === "pre-commit";
56
+ const invoke = blocking
57
+ ? ` "$harnery_hook_bin" git-hook ${event} "$@"\n` +
58
+ ` harnery_hook_rc=$?\n` +
59
+ ` [ "$harnery_hook_rc" -ne 0 ] && exit "$harnery_hook_rc"\n`
60
+ : ` "$harnery_hook_bin" git-hook ${event} "$@" >/dev/null 2>&1 || true\n`;
61
+ return (
62
+ `# Coordination for this hook lives in harnery (agent-coord git-hook ${event});\n` +
63
+ `# upgrading harnery upgrades the behavior. Managed region: do not edit by hand.\n` +
64
+ `harnery_hook_root=$(git rev-parse --show-superproject-working-tree 2>/dev/null)\n` +
65
+ `[ -n "$harnery_hook_root" ] || harnery_hook_root=$(git rev-parse --show-toplevel 2>/dev/null)\n` +
66
+ `for harnery_hook_bin in "$harnery_hook_root/harnery/bin/agent-coord" "$harnery_hook_root/node_modules/harnery/bin/agent-coord"; do\n` +
67
+ ` [ -x "$harnery_hook_bin" ] || continue\n` +
68
+ invoke +
69
+ ` break\n` +
70
+ `done`
71
+ );
72
+ }
73
+
74
+ /**
75
+ * The effective hooks dir for the repo at `projectRoot`: honors
76
+ * `core.hooksPath` (relative values resolve against the repo root) and
77
+ * worktree layouts, because that's what `git rev-parse --git-path hooks`
78
+ * reports. Null when `projectRoot` isn't a git repo.
79
+ */
80
+ export function resolveHooksDir(projectRoot: string): string | null {
81
+ const r = spawnSync("git", ["rev-parse", "--git-path", "hooks"], {
82
+ cwd: projectRoot,
83
+ encoding: "utf8",
84
+ timeout: 5000,
85
+ });
86
+ if (r.status !== 0) return null;
87
+ const p = (r.stdout ?? "").trim();
88
+ if (!p) return null;
89
+ return isAbsolute(p) ? p : resolve(projectRoot, p);
90
+ }
91
+
92
+ export interface GitHooksResult {
93
+ actions: string[];
94
+ warnings: string[];
95
+ }
96
+
97
+ /** Install or refresh the managed region in each of the three hook files. */
98
+ export function applyGitHooks(projectRoot: string, opts: { dryRun?: boolean }): GitHooksResult {
99
+ const dryRun = opts.dryRun === true;
100
+ const actions: string[] = [];
101
+ const warnings: string[] = [];
102
+ const hooksDir = resolveHooksDir(projectRoot);
103
+ if (!hooksDir) {
104
+ warnings.push("not a git repository; skipped git-hook regions");
105
+ return { actions, warnings };
106
+ }
107
+
108
+ if (!existsSync(hooksDir) && !dryRun) mkdirSync(hooksDir, { recursive: true });
109
+
110
+ for (const event of GIT_HOOK_EVENTS) {
111
+ const file = join(hooksDir, event);
112
+ const body = renderGitHookBody(event);
113
+ const region = regionName(event);
114
+ const display = relative(projectRoot, file);
115
+
116
+ if (!existsSync(file)) {
117
+ if (dryRun) {
118
+ actions.push(`+ would create ${display} (harnery-owned git hook)`);
119
+ continue;
120
+ }
121
+ writeFileSync(file, `#!/bin/sh\n${regionBlock(region, body, "hash")}\n`);
122
+ chmodSync(file, 0o755);
123
+ actions.push(`+ created ${display} (harnery-owned git hook)`);
124
+ continue;
125
+ }
126
+
127
+ const content = readFileSync(file, "utf8");
128
+ const status = checkRegion(content, region, body, "hash");
129
+ if (status === "fresh") {
130
+ actions.push(`· ${display} git-hook region current`);
131
+ continue;
132
+ }
133
+
134
+ if (status === "stale") {
135
+ const spliced = spliceRegion(content, region, body, "hash");
136
+ if (dryRun) actions.push(`+ would refresh stale git-hook region in ${display}`);
137
+ else {
138
+ writeFileSync(file, spliced.text);
139
+ actions.push(`+ refreshed stale git-hook region in ${display}`);
140
+ }
141
+ continue;
142
+ }
143
+
144
+ // Missing from an existing, host-authored hook: insert right after the
145
+ // shebang. Coordination runs first on purpose — an agent should hear
146
+ // "this commit is blocked by a peer's claim" before paying for the host's
147
+ // typecheck/lint checks, and post-checkout's claim release must beat any
148
+ // host short-circuit below it.
149
+ if (dryRun) {
150
+ actions.push(`+ would insert git-hook region into ${display} (after shebang)`);
151
+ continue;
152
+ }
153
+ const block = regionBlock(region, body, "hash");
154
+ let text: string;
155
+ if (content.startsWith("#!")) {
156
+ const nl = content.indexOf("\n");
157
+ text = `${content.slice(0, nl + 1)}\n${block}\n${content.slice(nl + 1)}`;
158
+ } else {
159
+ text = `${block}\n\n${content}`;
160
+ }
161
+ writeFileSync(file, text);
162
+ actions.push(`+ inserted git-hook region into ${display} (after shebang)`);
163
+ }
164
+
165
+ return { actions, warnings };
166
+ }
167
+
168
+ /**
169
+ * Has this project adopted harnery-managed git hooks at all? True when any of
170
+ * the three hook files carries a managed region. Adoption is the gate between
171
+ * "never installed" (a consumer that upgraded but hasn't opted in — not drift,
172
+ * `--check` stays green, `doctor` nudges) and "decayed" (a region existed and
173
+ * is now stale or partially deleted — drift, `--check` goes red).
174
+ */
175
+ export function gitHooksInstalled(projectRoot: string): boolean {
176
+ const hooksDir = resolveHooksDir(projectRoot);
177
+ if (!hooksDir) return false;
178
+ for (const event of GIT_HOOK_EVENTS) {
179
+ const file = join(hooksDir, event);
180
+ if (!existsSync(file)) continue;
181
+ try {
182
+ if (readFileSync(file, "utf8").includes(`harnery:begin ${regionName(event)}`)) return true;
183
+ } catch {
184
+ /* unreadable file counts as absent */
185
+ }
186
+ }
187
+ return false;
188
+ }
189
+
190
+ /**
191
+ * Drift report for `init --check`: per-hook managed-region freshness. A
192
+ * project that never adopted git hooks reports fresh — an upgrade must not
193
+ * turn a consumer's CI red for a feature they haven't installed.
194
+ */
195
+ export function checkGitHooks(projectRoot: string): { status: ManagedStatus; issues: string[] } {
196
+ const issues: string[] = [];
197
+ let worst: ManagedStatus = "fresh";
198
+ const hooksDir = resolveHooksDir(projectRoot);
199
+ if (!hooksDir) return { status: "fresh", issues };
200
+ if (!gitHooksInstalled(projectRoot)) return { status: "fresh", issues };
201
+
202
+ for (const event of GIT_HOOK_EVENTS) {
203
+ const file = join(hooksDir, event);
204
+ const display = relative(projectRoot, file);
205
+ if (!existsSync(file)) {
206
+ issues.push(`${display}: missing (run init to create the harnery git hook)`);
207
+ worst = "missing";
208
+ continue;
209
+ }
210
+ const status = checkRegion(
211
+ readFileSync(file, "utf8"),
212
+ regionName(event),
213
+ renderGitHookBody(event),
214
+ "hash",
215
+ );
216
+ if (status !== "fresh") {
217
+ issues.push(`${display}: git-hook region ${status} (re-run init to refresh)`);
218
+ if (worst === "fresh") worst = status;
219
+ }
220
+ }
221
+ return { status: worst, issues };
222
+ }
223
+
224
+ /**
225
+ * Remove the managed region from each hook. A file that was harnery-created
226
+ * whole (nothing but a shebang left) is deleted; host-authored hooks keep
227
+ * every host line.
228
+ */
229
+ export function removeGitHooks(projectRoot: string, opts: { dryRun?: boolean }): GitHooksResult {
230
+ const dryRun = opts.dryRun === true;
231
+ const actions: string[] = [];
232
+ const warnings: string[] = [];
233
+ const hooksDir = resolveHooksDir(projectRoot);
234
+ if (!hooksDir) return { actions, warnings };
235
+
236
+ for (const event of GIT_HOOK_EVENTS) {
237
+ const file = join(hooksDir, event);
238
+ if (!existsSync(file)) continue;
239
+ const display = relative(projectRoot, file);
240
+ const content = readFileSync(file, "utf8");
241
+ const { text, removed } = removeRegion(content, regionName(event), "hash");
242
+ if (!removed) continue;
243
+
244
+ const residue = text.replace(/^#![^\n]*\n?/, "").trim();
245
+ if (residue === "") {
246
+ if (dryRun) actions.push(`+ would remove ${display} (was harnery-owned)`);
247
+ else {
248
+ rmSync(file);
249
+ actions.push(`+ removed ${display} (was harnery-owned)`);
250
+ }
251
+ } else if (dryRun) {
252
+ actions.push(`+ would remove git-hook region from ${display} (host content kept)`);
253
+ } else {
254
+ writeFileSync(file, text);
255
+ actions.push(`+ removed git-hook region from ${display} (host content kept)`);
256
+ }
257
+ }
258
+ return { actions, warnings };
259
+ }
@@ -32,17 +32,33 @@ function escapeRe(s: string): string {
32
32
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
33
  }
34
34
 
35
+ /**
36
+ * Marker comment style. Markdown/HTML files wrap markers in HTML comments;
37
+ * shell files (git hooks) use `#` line comments. The style only changes the
38
+ * marker syntax — hashing, splice, remove, and check semantics are identical.
39
+ */
40
+ export type CommentStyle = "html" | "hash";
41
+
42
+ function markerPair(style: CommentStyle): { open: string; close: string } {
43
+ return style === "hash" ? { open: "#", close: "" } : { open: "<!--", close: "-->" };
44
+ }
45
+
35
46
  /** Capture regex for a named managed region: begin-marker, body, end-marker. */
36
- function regionRe(region: string): RegExp {
47
+ function regionRe(region: string, style: CommentStyle = "html"): RegExp {
37
48
  const r = escapeRe(region);
49
+ const { open, close } = markerPair(style);
50
+ const o = escapeRe(open);
51
+ const c = close ? `\\s*${escapeRe(close)}` : "";
38
52
  return new RegExp(
39
- `(<!--\\s*harnery:begin ${r}(?:\\s+v=([0-9a-f]*))?\\s*-->)([\\s\\S]*?)(<!--\\s*harnery:end ${r}\\s*-->)`,
53
+ `(${o}\\s*harnery:begin ${r}(?:\\s+v=([0-9a-f]*))?${c ? c : "[ \\t]*"})([\\s\\S]*?)(${o}\\s*harnery:end ${r}${c ? c : "[ \\t]*"})`,
40
54
  );
41
55
  }
42
56
 
43
57
  /** Canonical region block: begin-marker, body flanked by newlines, end-marker. */
44
- export function regionBlock(region: string, body: string): string {
45
- return `<!-- harnery:begin ${region} v=${shortHash(body)} -->\n${body}\n<!-- harnery:end ${region} -->`;
58
+ export function regionBlock(region: string, body: string, style: CommentStyle = "html"): string {
59
+ const { open, close } = markerPair(style);
60
+ const tail = close ? ` ${close}` : "";
61
+ return `${open} harnery:begin ${region} v=${shortHash(body)}${tail}\n${body}\n${open} harnery:end ${region}${tail}`;
46
62
  }
47
63
 
48
64
  export type ManagedStatus = "fresh" | "stale" | "missing";
@@ -63,10 +79,15 @@ export interface SpliceResult {
63
79
  * absent, the block is appended after existing content (blank-line separated);
64
80
  * an empty/whitespace-only `content` becomes just the block.
65
81
  */
66
- export function spliceRegion(content: string, region: string, body: string): SpliceResult {
67
- const re = regionRe(region);
82
+ export function spliceRegion(
83
+ content: string,
84
+ region: string,
85
+ body: string,
86
+ style: CommentStyle = "html",
87
+ ): SpliceResult {
88
+ const re = regionRe(region, style);
68
89
  const m = content.match(re);
69
- const fresh = regionBlock(region, body);
90
+ const fresh = regionBlock(region, body, style);
70
91
  if (m) {
71
92
  const stale = m[2] !== shortHash(body) || m[3] !== `\n${body}\n`;
72
93
  // Replacer fn avoids `$`-in-body being read as a capture reference.
@@ -84,8 +105,12 @@ export function spliceRegion(content: string, region: string, body: string): Spl
84
105
  * was the file's only content, the result is the empty string — the caller
85
106
  * decides whether to delete the file.
86
107
  */
87
- export function removeRegion(content: string, region: string): { text: string; removed: boolean } {
88
- const re = regionRe(region);
108
+ export function removeRegion(
109
+ content: string,
110
+ region: string,
111
+ style: CommentStyle = "html",
112
+ ): { text: string; removed: boolean } {
113
+ const re = regionRe(region, style);
89
114
  if (!re.test(content)) return { text: content, removed: false };
90
115
  const stripped = content
91
116
  .replace(re, "")
@@ -96,8 +121,13 @@ export function removeRegion(content: string, region: string): { text: string; r
96
121
  }
97
122
 
98
123
  /** Region freshness: missing, stale (hash or body drifted), or fresh. */
99
- export function checkRegion(content: string, region: string, body: string): ManagedStatus {
100
- const m = content.match(regionRe(region));
124
+ export function checkRegion(
125
+ content: string,
126
+ region: string,
127
+ body: string,
128
+ style: CommentStyle = "html",
129
+ ): ManagedStatus {
130
+ const m = content.match(regionRe(region, style));
101
131
  if (!m) return "missing";
102
132
  return m[2] === shortHash(body) && m[3] === `\n${body}\n` ? "fresh" : "stale";
103
133
  }