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
@@ -6,6 +6,7 @@ import {
6
6
  runFrontmatterMigration,
7
7
  } from "../lib/docs-frontmatter-migrate.ts";
8
8
  import { initDocsContext as initDocsIndex, runIndex } from "../lib/docs-index.ts";
9
+ import { initDocsContext as initDocsLinks, runLinks } from "../lib/docs-links.ts";
9
10
  import { initDocsContext as initDocsLint, runLint } from "../lib/docs-lint.ts";
10
11
  import { readDocsMetadata, readDocsMetadataKey } from "../lib/docs-meta.ts";
11
12
  import {
@@ -27,6 +28,7 @@ function ensureContext(context: HarneryProgramContext | undefined): void {
27
28
  extraExcludedPrefixes: context.extraDocsExcludedPrefixes,
28
29
  docsRootAllowlist: context.docsRootAllowlist,
29
30
  });
31
+ initDocsLinks({ ...opts, extraExcludedPrefixes: context.extraDocsExcludedPrefixes });
30
32
  initDocsSweep(opts);
31
33
  }
32
34
 
@@ -115,6 +117,34 @@ export function registerDocsCommand(
115
117
  }
116
118
  });
117
119
 
120
+ docs
121
+ .command("links")
122
+ .description("Check internal Markdown link targets and heading fragments")
123
+ .option("--repo <name>", "Limit to one submodule or '.' for parent")
124
+ .option("--no-fragments", "Check target existence only; skip heading-fragment validation")
125
+ .option("--strict", "Report findings in archive/audit/changelog docs as errors, not warnings")
126
+ .option("--check-escapes", "Also flag links that resolve outside their own repo root")
127
+ .option("--fail", "Exit non-zero when errors are found (advisory by default)")
128
+ .option("--format <type>", "Output format: human, json", "human")
129
+ .action(
130
+ async (opts: {
131
+ repo?: string;
132
+ fragments?: boolean;
133
+ strict?: boolean;
134
+ checkEscapes?: boolean;
135
+ fail?: boolean;
136
+ format: string;
137
+ }) => {
138
+ try {
139
+ ensureContext(context);
140
+ await handleLinks(opts);
141
+ } catch (err: unknown) {
142
+ const msg = err instanceof Error ? err.message : String(err);
143
+ emit.error({ code: "docs_error", message: msg });
144
+ }
145
+ },
146
+ );
147
+
118
148
  docs
119
149
  .command("sweep")
120
150
  .description(
@@ -232,6 +262,32 @@ async function handleLint(opts: { fast?: boolean; repo?: string; format: string
232
262
  if (errors.length > 0) emit.setExitCode(1);
233
263
  }
234
264
 
265
+ // --- `harn docs links` ---
266
+
267
+ /**
268
+ * Advisory by default: a clean exit code even with findings, because turning
269
+ * link health into a gate is an enforcement policy decision for the host, not
270
+ * something this command should assume. `--fail` is the opt-in for CI or a hook.
271
+ */
272
+ async function handleLinks(opts: {
273
+ repo?: string;
274
+ fragments?: boolean;
275
+ strict?: boolean;
276
+ checkEscapes?: boolean;
277
+ fail?: boolean;
278
+ format: string;
279
+ }): Promise<void> {
280
+ if (opts.format === "json") emit.config({ format: "json" });
281
+ const report = await runLinks({
282
+ repo: opts.repo,
283
+ noFragments: opts.fragments === false,
284
+ strict: opts.strict,
285
+ checkEscapes: opts.checkEscapes,
286
+ });
287
+ emit.data({ ...report, advisory: !opts.fail });
288
+ if (opts.fail && report.error_count > 0) emit.setExitCode(1);
289
+ }
290
+
235
291
  // --- `harn docs sweep` ---
236
292
 
237
293
  async function handleSweep(opts: { repo?: string; format: string }): Promise<void> {
@@ -19,7 +19,9 @@ import type { Command } from "commander";
19
19
  import type { EmitContext } from "../commander.ts";
20
20
  import { BUILTIN_ADAPTER_IDS } from "../core/adapters/index.ts";
21
21
  import { resolveBinName, ripgrepAutoInstall } from "../core/config.ts";
22
- import { loadAdapterWiring } from "../core/hooks/adapter/wiring.ts";
22
+ import { ADAPTER_SPECS, type AdapterId } from "../core/hooks/adapter/events.ts";
23
+ import { loadAdapterWiring, summarizeAdapterWiring } from "../core/hooks/adapter/wiring.ts";
24
+ import { inspectCodexWslBridge } from "../core/hooks/codex-wsl-bridge.ts";
23
25
  import {
24
26
  ADAPTER_BINARIES,
25
27
  ADAPTER_INSTALL_HINTS,
@@ -27,6 +29,7 @@ import {
27
29
  } from "../core/workflow/adapters.ts";
28
30
  import { probeBilling } from "../core/workflow/billing.ts";
29
31
  import type { AdapterName } from "../core/workflow/types.ts";
32
+ import { checkGitHooks, gitHooksInstalled } from "../lib/instructions/git-hooks.ts";
30
33
  import { findRg, installRg, managedRgPath, rgInstallSupported } from "../lib/tools/ripgrep.ts";
31
34
 
32
35
  type Severity = "ok" | "warn" | "fail";
@@ -97,14 +100,22 @@ export function registerDoctorCommand(program: Command, emit: EmitContext): void
97
100
  }
98
101
 
99
102
  export function runChecks(): Check[] {
103
+ const codexWslBridge = checkCodexWslBridge();
104
+ // Probe the adapter CLIs before the hook check runs, not in output order:
105
+ // an unwired adapter only matters if its CLI is actually installed, and
106
+ // probing once here keeps that join to one spawn per CLI.
107
+ const workflow = BUILTIN_ADAPTER_IDS.map((id) => ({ id, ...checkWorkflowAdapter(id) }));
108
+ const installed = workflow.filter((w) => w.installed).map((w) => w.id as AdapterId);
100
109
  return [
101
110
  checkNode(),
102
111
  checkGit(),
103
112
  checkBun(),
104
113
  checkRipgrep(),
105
114
  checkHarneryDir(),
106
- checkAdapterHooks(),
107
- ...BUILTIN_ADAPTER_IDS.map(checkWorkflowAdapter),
115
+ checkAdapterHooks(installed),
116
+ checkGitHookRegions(),
117
+ ...(codexWslBridge ? [codexWslBridge] : []),
118
+ ...workflow.map((w) => w.check),
108
119
  checkRestic(),
109
120
  checkRclone(),
110
121
  checkPlaywright(),
@@ -112,6 +123,49 @@ export function runChecks(): Check[] {
112
123
  ];
113
124
  }
114
125
 
126
+ function checkGitHookRegions(): Check {
127
+ const root = findCoordProjectRoot();
128
+ if (!root) {
129
+ return { name: "git hooks", severity: "ok", detail: "n/a (no .harnery/ above cwd)" };
130
+ }
131
+ try {
132
+ if (!gitHooksInstalled(root)) {
133
+ const bin = resolveBinName(root);
134
+ return {
135
+ name: "git hooks",
136
+ severity: "warn",
137
+ detail:
138
+ `not installed - commits are not guarded against peer-claim conflicts and ` +
139
+ `claims never auto-release on commit/checkout; run \`${bin} init\` to install`,
140
+ };
141
+ }
142
+ const { status, issues } = checkGitHooks(root);
143
+ if (status === "fresh") {
144
+ return { name: "git hooks", severity: "ok", detail: "managed regions current" };
145
+ }
146
+ return { name: "git hooks", severity: "warn", detail: issues.join("; ") };
147
+ } catch (err) {
148
+ return {
149
+ name: "git hooks",
150
+ severity: "warn",
151
+ detail: `check failed: ${(err as Error).message}`,
152
+ };
153
+ }
154
+ }
155
+
156
+ function checkCodexWslBridge(): Check | null {
157
+ const status = inspectCodexWslBridge();
158
+ if (!status) return null;
159
+ return {
160
+ name: "codex:WSL bridge",
161
+ severity: status.ok ? "ok" : "warn",
162
+ detail: status.detail,
163
+ hint: status.ok
164
+ ? undefined
165
+ : "forward CODEX_THREAD_ID through WSLENV in the Codex shell environment policy, then start a fresh task",
166
+ };
167
+ }
168
+
115
169
  function checkRipgrep(): Check {
116
170
  const found = findRg();
117
171
  if (found) {
@@ -157,26 +211,35 @@ function whichVersion(bin: string, args: string[] = ["--version"]): { ok: boolea
157
211
  * One workflow spawn target: is the adapter CLI installed, and how will its
158
212
  * headless children bill (subscription login vs API key — see billing.ts)?
159
213
  * Missing is a warn, not a fail: workflows degrade to the adapters you have.
214
+ *
215
+ * Reports `installed` alongside the check so `checkAdapterHooks` can reuse the
216
+ * probe instead of spawning every adapter CLI a second time.
160
217
  */
161
- function checkWorkflowAdapter(adapter: AdapterName): Check {
218
+ function checkWorkflowAdapter(adapter: AdapterName): { check: Check; installed: boolean } {
162
219
  const bin = ADAPTER_BINARIES[adapter];
163
220
  const name = `workflow:${adapter}`;
164
221
  const r = whichVersion(bin);
165
222
  if (!r.ok) {
166
223
  return {
167
- name,
168
- severity: "warn",
169
- detail: `${bin} missing (workflow --adapter ${adapter} unavailable)`,
170
- hint: `${ADAPTER_INSTALL_HINTS[adapter]} then: ${ADAPTER_LOGIN_HINTS[adapter]}`,
224
+ check: {
225
+ name,
226
+ severity: "warn",
227
+ detail: `${bin} missing (workflow --adapter ${adapter} unavailable)`,
228
+ hint: `${ADAPTER_INSTALL_HINTS[adapter]} then: ${ADAPTER_LOGIN_HINTS[adapter]}`,
229
+ },
230
+ installed: false,
171
231
  };
172
232
  }
173
233
  const probe = probeBilling(adapter);
174
234
  if (probe.login === "absent" && !probe.apiKeyPresent) {
175
235
  return {
176
- name,
177
- severity: "warn",
178
- detail: `${r.out} — installed, but no stored login or API key detected`,
179
- hint: ADAPTER_LOGIN_HINTS[adapter],
236
+ check: {
237
+ name,
238
+ severity: "warn",
239
+ detail: `${r.out} — installed, but no stored login or API key detected`,
240
+ hint: ADAPTER_LOGIN_HINTS[adapter],
241
+ },
242
+ installed: true,
180
243
  };
181
244
  }
182
245
  const billing =
@@ -185,7 +248,7 @@ function checkWorkflowAdapter(adapter: AdapterName): Check {
185
248
  ? "billing: subscription"
186
249
  : "billing: subscription (login unverifiable, CLI is the authority)"
187
250
  : `billing: ${probe.mode} (${probe.apiKeySource})`;
188
- return { name, severity: "ok", detail: `${r.out} — ${billing}` };
251
+ return { check: { name, severity: "ok", detail: `${r.out} — ${billing}` }, installed: true };
189
252
  }
190
253
 
191
254
  function checkNode(): Check {
@@ -331,13 +394,22 @@ function checkHarneryDir(): Check {
331
394
  * loadAdapterWiring — so a bare settings file never false-warns. The remedy is
332
395
  * always the same: re-run `<bin> init` (idempotent, additive).
333
396
  */
334
- function checkAdapterHooks(): Check {
397
+ function checkAdapterHooks(installedAdapters: AdapterId[] = []): Check {
335
398
  const root = findCoordProjectRoot();
336
399
  if (!root) {
337
400
  return { name: "adapter hooks", severity: "ok", detail: "n/a (no .harnery/ above cwd)" };
338
401
  }
339
402
  const drift = loadAdapterWiring(root);
340
- if (drift.length === 0) {
403
+ // A adapter with zero harnery hooks is only worth flagging when the project
404
+ // plainly uses harnery hooks (something else is wired) AND that adapter's CLI
405
+ // is installed. Both conditions matter: the first keeps a project that has
406
+ // never run init quiet, the second keeps a project that simply doesn't have
407
+ // Codex quiet. What's left is the case that reads as healthy but isn't — an
408
+ // agent can start a session through that adapter and register nothing.
409
+ const summary = summarizeAdapterWiring(root);
410
+ const unwired =
411
+ summary.wired.length > 0 ? summary.unwired.filter((id) => installedAdapters.includes(id)) : [];
412
+ if (drift.length === 0 && unwired.length === 0) {
341
413
  return { name: "adapter hooks", severity: "ok", detail: "wired + current" };
342
414
  }
343
415
  const bin = resolveBinName(root);
@@ -356,12 +428,22 @@ function checkAdapterHooks(): Check {
356
428
  }
357
429
  return `${d.settingsFile}: ${bits.join("; ")}`;
358
430
  });
431
+ for (const id of unwired) {
432
+ parts.push(`${ADAPTER_SPECS[id].settingsFile}: ${id} CLI installed, no harnery hooks wired`);
433
+ }
359
434
  const needsManualRepair = drift.some(
360
435
  (d) => d.parseError || d.invalidTopLevelKeys.length > 0 || d.invalidEventKeys.length > 0,
361
436
  );
362
- const hint = needsManualRepair
363
- ? `repair the invalid adapter settings, then run \`${bin} init\` to migrate harnery hooks`
364
- : `run \`${bin} init\` to migrate the hook set (idempotent)`;
437
+ const wireHints = unwired.map((id) => `\`${bin} init --adapter ${id}\``);
438
+ let hint: string;
439
+ if (needsManualRepair) {
440
+ hint = `repair the invalid adapter settings, then run \`${bin} init\` to migrate harnery hooks`;
441
+ } else if (drift.length > 0) {
442
+ hint = `run \`${bin} init\` to migrate the hook set (idempotent)`;
443
+ if (wireHints.length > 0) hint += `; ${wireHints.join(" and ")} to wire the rest`;
444
+ } else {
445
+ hint = `run ${wireHints.join(" and ")} (idempotent, additive)`;
446
+ }
365
447
  return { name: "adapter hooks", severity: "warn", detail: parts.join(" | "), hint };
366
448
  }
367
449
 
@@ -36,12 +36,9 @@ import {
36
36
  applyInstructions,
37
37
  checkInstructions,
38
38
  } from "../lib/instructions/apply.ts";
39
+ import { applyGitHooks, checkGitHooks } from "../lib/instructions/git-hooks.ts";
39
40
  import { HostAddendumError } from "../lib/instructions/host-addendum.ts";
40
41
 
41
- // Re-exported for back-compat: callers (deinit, tests) import these names
42
- // from init.ts. The definitions now live in core/hooks/adapter/wiring.ts.
43
- export type { HookGroup, SettingsFile };
44
-
45
42
  // This file is src/commands/init.ts → harnery package root is two levels up.
46
43
  const HARNERY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
47
44
 
@@ -85,15 +82,19 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
85
82
  // ── --check: read-only drift report on the block + skills ──────────────
86
83
  if (opts.check === true) {
87
84
  const { status, issues } = checkInstructions(projectRoot, { binName: bin, adapter });
85
+ const gitHooks = checkGitHooks(projectRoot);
86
+ if (gitHooks.status !== "fresh") issues.push(...gitHooks.issues);
87
+ const merged =
88
+ status === "error" ? "error" : gitHooks.status !== "fresh" ? "drift" : status;
88
89
  const head =
89
- status === "fresh"
90
- ? "harn init --check: instructions + skills are current"
91
- : status === "drift"
90
+ merged === "fresh"
91
+ ? "harn init --check: instructions + skills + git hooks are current"
92
+ : merged === "drift"
92
93
  ? "harn init --check: drift found (re-run `init` to refresh)"
93
94
  : "harn init --check: error";
94
95
  const lines = issues.length ? `\n${issues.map((i) => ` ✗ ${i}`).join("\n")}` : "";
95
96
  emit.text(`${head}${lines}`);
96
- emit.setExitCode(status === "fresh" ? 0 : status === "drift" ? 2 : 1);
97
+ emit.setExitCode(merged === "fresh" ? 0 : merged === "drift" ? 2 : 1);
97
98
  return;
98
99
  }
99
100
 
@@ -190,7 +191,14 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
190
191
  }
191
192
  actions.push(...applied.actions);
192
193
 
193
- emit.text(render(projectRoot, dryRun, actions, applied.warnings));
194
+ // ── 4. git-hook managed regions ────────────────────────────────────────
195
+ // Same lifecycle contract as the instructions block: the coordination
196
+ // content of the consumer's git hooks is machine-owned and versioned;
197
+ // everything else in those files belongs to the host.
198
+ const gitHooks = applyGitHooks(projectRoot, { dryRun });
199
+ actions.push(...gitHooks.actions);
200
+
201
+ emit.text(render(projectRoot, dryRun, actions, [...applied.warnings, ...gitHooks.warnings]));
194
202
  });
195
203
  }
196
204
 
@@ -76,7 +76,6 @@ interface LogsOpts {
76
76
  name?: string;
77
77
  follow?: boolean;
78
78
  gate?: boolean;
79
- cloudflared?: boolean;
80
79
  provider?: boolean;
81
80
  }
82
81
 
@@ -916,7 +915,7 @@ function status(opts: StatusOpts): void {
916
915
  return;
917
916
  }
918
917
  if (states.length === 1) {
919
- // Single tunnel: show the full detail block (backward-compatible).
918
+ // A single tunnel is clearer as a detail block than as a one-row table.
920
919
  statusDetail(states[0]);
921
920
  return;
922
921
  }
@@ -953,8 +952,7 @@ function logs(opts: LogsOpts): void {
953
952
  const name = resolveName(opts.name);
954
953
  const state = readState(name);
955
954
  const provider = state?.provider ?? "cloudflare";
956
- const which =
957
- opts.cloudflared || opts.provider ? providerLogFile(name, provider) : gateLogFile(name);
955
+ const which = opts.provider ? providerLogFile(name, provider) : gateLogFile(name);
958
956
  const path = cachePath("tunnel", which);
959
957
  if (!existsSync(path)) {
960
958
  emit.error({ code: "tunnel_no_log", message: `No log file at ${path}` });
@@ -1089,7 +1087,6 @@ export function registerTunnelCommand(
1089
1087
  .option("-f, --follow", "follow the log")
1090
1088
  .option("--gate", "tail the gate log (default)")
1091
1089
  .option("--provider", "tail the provider log instead")
1092
- .option("--cloudflared", "tail the Cloudflare provider log instead (legacy alias)")
1093
1090
  .action(logs);
1094
1091
 
1095
1092
  const allow = cmd
@@ -78,8 +78,7 @@ export function emitCanonical(input: CanonicalEmitInput): void {
78
78
  }
79
79
 
80
80
  /**
81
- * Normalize the heartbeat's `platform` field (which uses snake_case
82
- * `claude_code`) to the canonical kebab-case Adapter type.
81
+ * Normalize the heartbeat's `platform` field to the canonical Adapter type.
83
82
  */
84
83
  export function normalizeAdapter(platform: string | undefined): "claude-code" | "cursor" | "codex" {
85
84
  if (platform === "cursor") return "cursor";
@@ -96,23 +96,11 @@ async function handleVerdict(root: string): Promise<number> {
96
96
  } else if (parsed.rule === "claim") {
97
97
  const { evaluateClaim } = await import("./rules/claim-conflict.ts");
98
98
  verdict = evaluateClaim(root, parsed as unknown as Parameters<typeof evaluateClaim>[1]);
99
- } else if (parsed.rule === "commit") {
100
- const { evaluateCommit } = await import("./rules/commit-conflict.ts");
101
- const result = evaluateCommit(root, parsed as unknown as Parameters<typeof evaluateCommit>[1]);
102
- // Map CommitVerdictResult → the standard verdict envelope, stash details
103
- // on extra fields so the bash caller can pull conflicts + log_lines.
104
- verdict = {
105
- allow: result.allow,
106
- exit_code: result.exit_code,
107
- rule: result.rule,
108
- reason: result.message,
109
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
110
- } as typeof verdict;
111
- (verdict as Record<string, unknown>).conflicts = result.conflicts;
112
- (verdict as Record<string, unknown>).log_lines = result.log_lines;
113
- (verdict as Record<string, unknown>).suppressed_self_attribution =
114
- result.suppressed_self_attribution ?? false;
115
99
  } else {
100
+ // NB: rule === "commit" was served here until 2026-08-09; the commit
101
+ // guard now enters through `git-hook pre-commit`, which runs the same
102
+ // evaluateCommit in-process. A straggler host lands in this fail-open
103
+ // branch, which is the pre-migration behavior for a malformed request.
116
104
  verdict = {
117
105
  allow: true,
118
106
  exit_code: 0,
@@ -261,14 +249,6 @@ async function handleStateAction(root: string, action: string, rest: string[]):
261
249
  );
262
250
  return 0;
263
251
  }
264
- case "stamp-status-call": {
265
- const hb = writer.stampStatusCheck(root, owner);
266
- if (!hb) return 1;
267
- process.stdout.write(
268
- `${JSON.stringify({ instance_id: owner, last_status_at: hb.last_status_at })}\n`,
269
- );
270
- return 0;
271
- }
272
252
  case "set-turn-summary": {
273
253
  const summary = args.join(" ");
274
254
  const hb = writer.setTurnSummary(root, owner, summary);
@@ -344,10 +324,20 @@ async function handleStateAction(root: string, action: string, rest: string[]):
344
324
  // fragility. Positionals (sessionId, model) stay as-is once flags are
345
325
  // filtered out.
346
326
  const adapter = args.find((a) => a.startsWith("--adapter="))?.slice("--adapter=".length);
327
+ const forkedFrom = args
328
+ .find((a) => a.startsWith("--forked-from="))
329
+ ?.slice("--forked-from=".length);
347
330
  const positional = args.filter((a) => !a.startsWith("--"));
348
331
  const sessionId = positional[0];
349
332
  const model = positional[1];
350
- const hb = writer.healHeartbeat(root, owner, sessionId, model, adapter);
333
+ const hb = writer.healHeartbeat(
334
+ root,
335
+ owner,
336
+ sessionId,
337
+ model,
338
+ adapter,
339
+ forkedFrom ? { forkedFrom } : undefined,
340
+ );
351
341
  process.stdout.write(`${JSON.stringify({ instance_id: owner, recreated: !!hb })}\n`);
352
342
  return hb ? 0 : 1;
353
343
  }
@@ -497,69 +487,39 @@ async function handleCouncilAction(root: string, action: string, rest: string[])
497
487
 
498
488
  async function handleAssignName(root: string, rest: string[]): Promise<number> {
499
489
  const { assignName } = await import("./state/names.ts");
500
- const [owner, kindArg] = rest;
490
+ // Optional recorded-fork-lineage flag: --forked-from <parent_instance_id>.
491
+ // Supplied by an adapter layer that knows (or detected) that this session
492
+ // was branched from another conversation. Inert on resume: assignName only
493
+ // stamps lineage on the row that first assigns the instance.
494
+ let forkedFrom: string | undefined;
495
+ const positional: string[] = [];
496
+ for (let i = 0; i < rest.length; i++) {
497
+ if (rest[i] === "--forked-from") {
498
+ forkedFrom = rest[++i];
499
+ continue;
500
+ }
501
+ positional.push(rest[i]!);
502
+ }
503
+ const [owner, kindArg] = positional;
501
504
  if (!owner || !kindArg) {
502
- process.stderr.write("agent-coord assign-name <instance_id> <session|subagent|transient>\n");
505
+ process.stderr.write(
506
+ "agent-coord assign-name <instance_id> <session|subagent|transient> [--forked-from <instance_id>]\n",
507
+ );
503
508
  return 2;
504
509
  }
505
510
  if (kindArg !== "session" && kindArg !== "subagent" && kindArg !== "transient") {
506
511
  process.stderr.write(`agent-coord assign-name: invalid kind ${kindArg}\n`);
507
512
  return 2;
508
513
  }
509
- const name = assignName(root, owner, kindArg);
510
- process.stdout.write(`${JSON.stringify({ instance_id: owner, name, kind: kindArg })}\n`);
511
- return 0;
512
- }
513
-
514
- async function handlePostCommit(root: string): Promise<number> {
515
- const raw = await readStdin();
516
- let req: { owner?: string; prune?: string[] } = {};
517
- try {
518
- req = JSON.parse(raw);
519
- } catch {
520
- return 0;
521
- }
522
- const { groupUnclaim } = await import("./state/heartbeat-writer.ts");
523
-
524
- // Session-group-wide unclaim. `owner` is the parent's session_id which is
525
- // also the group key (parent + subagents share session_id). Each actual
526
- // removal also emits a durable claim.release event — the projector rebuilds
527
- // files_touched from the permanent Edit/Write events, so a file-only prune
528
- // would resurrect on the next replay.
529
- if (req.owner && Array.isArray(req.prune)) {
530
- for (const path of req.prune) {
531
- try {
532
- for (const hit of groupUnclaim(root, req.owner, path)) {
533
- await emitClaimRelease(root, hit.instance_id, hit, path, "commit");
534
- }
535
- } catch {
536
- /* best-effort */
537
- }
538
- }
539
- }
540
- return 0;
541
- }
542
-
543
- async function handlePostCheckout(root: string, _rest: string[]): Promise<number> {
544
- const raw = await readStdin();
545
- let req: { owner?: string; removed?: string[] } = {};
546
- try {
547
- req = JSON.parse(raw);
548
- } catch {
549
- return 0;
550
- }
551
- const { groupUnclaim } = await import("./state/heartbeat-writer.ts");
552
- if (req.owner && Array.isArray(req.removed)) {
553
- for (const path of req.removed) {
554
- try {
555
- for (const hit of groupUnclaim(root, req.owner, path)) {
556
- await emitClaimRelease(root, hit.instance_id, hit, path, "checkout");
557
- }
558
- } catch {
559
- /* best-effort */
560
- }
561
- }
562
- }
514
+ const name = assignName(root, owner, kindArg, forkedFrom ? { forkedFrom } : undefined);
515
+ process.stdout.write(
516
+ `${JSON.stringify({
517
+ instance_id: owner,
518
+ name,
519
+ kind: kindArg,
520
+ ...(forkedFrom ? { forked_from: forkedFrom } : {}),
521
+ })}\n`,
522
+ );
563
523
  return 0;
564
524
  }
565
525
 
@@ -841,6 +801,100 @@ async function handleEmitEvent(root: string, rest: string[]): Promise<number> {
841
801
  return 0;
842
802
  }
843
803
 
804
+ /**
805
+ * `agent-coord git-hook <event> [args]` — the single entry a host git hook
806
+ * invokes. Owns ALL of the coordination git plumbing that hosts used to carry
807
+ * as bash (staged/committed/checkout-removed collection, submodule
808
+ * canonicalization, gitlink probe, verdict, claim pruning), so a harnery
809
+ * upgrade upgrades hook behavior with no change to the host's hook file.
810
+ *
811
+ * Events: pre-commit (E-guard verdict; exit 1 blocks the commit),
812
+ * post-commit (prune committed paths' claims), post-checkout <old> <new>
813
+ * (release claims on paths the ref move rewrote).
814
+ *
815
+ * Fail-open by design: an internal error never blocks a commit (parity with
816
+ * the bash era, where a crashed verdict read as empty and the hook moved on).
817
+ * A clean conflict verdict still blocks.
818
+ */
819
+ async function handleGitHook(fallbackRoot: string, rest: string[]): Promise<number> {
820
+ const event = rest[0];
821
+ if (process.env.HARNERY_AGENT_COORD_OFF === "1") return 0;
822
+ const cwd = process.cwd();
823
+
824
+ try {
825
+ const gh = await import("./git-hook.ts");
826
+ // Pin the coord root to the superproject: a submodule cwd may carry its
827
+ // own config-only .harnery that the generic root walk would land on.
828
+ const root = gh.discoverCoordRoot(cwd) ?? fallbackRoot;
829
+
830
+ if (event === "pre-commit") {
831
+ const { staged, gitlinks } = gh.collectStaged(cwd);
832
+ if (staged.length === 0) return 0;
833
+ const { evaluateCommit } = await import("./rules/commit-conflict.ts");
834
+ const result = evaluateCommit(root, {
835
+ staged_paths: staged,
836
+ staged_gitlinks: gitlinks,
837
+ bypass: process.env.HARNERY_AGENT_COORD_BYPASS === "1",
838
+ });
839
+ // Forensics channel shared with the stdin `verdict` path.
840
+ try {
841
+ const logPath = join(root, ".harnery", "debug", "agent-coord-verdict.ndjson");
842
+ await mkdir(dirname(logPath), { recursive: true });
843
+ await appendFile(
844
+ logPath,
845
+ `${JSON.stringify({ ts: new Date().toISOString(), via: "git-hook", staged, verdict: result })}\n`,
846
+ );
847
+ } catch {
848
+ /* diagnostics never break a verdict */
849
+ }
850
+ if (result.rule !== "commit.pass") {
851
+ if (result.message) process.stdout.write(`\n${result.message}\n\n`);
852
+ for (const c of result.conflicts) {
853
+ process.stdout.write(` ${c.staged_path} (held by ${c.short_name})\n`);
854
+ }
855
+ process.stdout.write("\n");
856
+ }
857
+ if (!result.allow) {
858
+ process.stdout.write(
859
+ " Wait for them to finish, split the commit, or set\n" +
860
+ " HARNERY_AGENT_COORD_BYPASS=1 to force this commit through.\n\n",
861
+ );
862
+ return result.exit_code || 1;
863
+ }
864
+ return 0;
865
+ }
866
+
867
+ if (event === "post-commit" || event === "post-checkout") {
868
+ const paths =
869
+ event === "post-commit"
870
+ ? gh.collectCommitted(cwd)
871
+ : gh.collectCheckoutRemoved(cwd, rest[1] ?? "", rest[2] ?? "");
872
+ if (paths.length === 0) return 0;
873
+ const { resolveOwner } = await import("../hooks/resolve/owner.ts");
874
+ const owner = resolveOwner({ payload: null, coordRoot: root })?.instance_id;
875
+ if (!owner) return 0;
876
+ const { groupUnclaim } = await import("./state/heartbeat-writer.ts");
877
+ const reason = event === "post-commit" ? ("commit" as const) : ("checkout" as const);
878
+ for (const path of paths) {
879
+ try {
880
+ for (const hit of groupUnclaim(root, owner, path)) {
881
+ await emitClaimRelease(root, hit.instance_id, hit, path, reason);
882
+ }
883
+ } catch {
884
+ /* best-effort */
885
+ }
886
+ }
887
+ return 0;
888
+ }
889
+
890
+ process.stderr.write(`agent-coord git-hook: unknown event ${event ?? "(none)"}\n`);
891
+ return 0;
892
+ } catch {
893
+ // Fail open: coordination must never brick git.
894
+ return 0;
895
+ }
896
+ }
897
+
844
898
  async function main(): Promise<number> {
845
899
  const [subcommand, ...rest] = process.argv.slice(2);
846
900
  const root = findCoordRoot(process.cwd());
@@ -860,7 +914,6 @@ async function main(): Promise<number> {
860
914
 
861
915
  if (
862
916
  subcommand === "set-task" ||
863
- subcommand === "stamp-status-call" ||
864
917
  subcommand === "set-turn-summary" ||
865
918
  subcommand === "release-claim" ||
866
919
  subcommand === "kill-heartbeat" ||
@@ -918,12 +971,8 @@ async function main(): Promise<number> {
918
971
  return handleShellMutationClaimLog(root, rest);
919
972
  }
920
973
 
921
- if (subcommand === "post-commit") {
922
- return handlePostCommit(root);
923
- }
924
-
925
- if (subcommand === "post-checkout") {
926
- return handlePostCheckout(root, rest);
974
+ if (subcommand === "git-hook") {
975
+ return handleGitHook(root, rest);
927
976
  }
928
977
 
929
978
  await logNoop(root, subcommand ?? "(none)", rest);