harnery 0.3.1 → 0.4.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 (50) hide show
  1. package/README.md +20 -7
  2. package/dist/commander.js +2 -2
  3. package/dist/commands/agents.d.ts.map +1 -1
  4. package/dist/commands/agents.js +14 -1
  5. package/dist/commands/deinit.d.ts +51 -0
  6. package/dist/commands/deinit.d.ts.map +1 -0
  7. package/dist/commands/{uninstall.js → deinit.js} +83 -14
  8. package/dist/commands/doctor.d.ts.map +1 -1
  9. package/dist/commands/doctor.js +47 -9
  10. package/dist/commands/init.d.ts +2 -21
  11. package/dist/commands/init.d.ts.map +1 -1
  12. package/dist/commands/init.js +3 -15
  13. package/dist/core/agents/coord-client.js +20 -5
  14. package/dist/core/agents/render/session-context.d.ts +12 -2
  15. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  16. package/dist/core/agents/render/session-context.js +75 -36
  17. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  18. package/dist/core/agents/rules/claim-conflict.js +29 -11
  19. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  20. package/dist/core/agents/state/heartbeat-writer.js +7 -1
  21. package/dist/core/config.d.ts +7 -0
  22. package/dist/core/config.d.ts.map +1 -1
  23. package/dist/core/config.js +13 -0
  24. package/dist/core/hooks/cli.js +25 -20
  25. package/dist/core/hooks/harness/wiring.d.ts +85 -0
  26. package/dist/core/hooks/harness/wiring.d.ts.map +1 -0
  27. package/dist/core/hooks/harness/wiring.js +137 -0
  28. package/dist/core/hooks/resolve/anchor.d.ts +15 -0
  29. package/dist/core/hooks/resolve/anchor.d.ts.map +1 -1
  30. package/dist/core/hooks/resolve/anchor.js +22 -0
  31. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  32. package/dist/core/hooks/resolve/owner.js +20 -8
  33. package/package.json +1 -1
  34. package/schemas/config.schema.json +4 -0
  35. package/src/commander.ts +2 -2
  36. package/src/commands/agents.ts +14 -1
  37. package/src/commands/{uninstall.ts → deinit.ts} +107 -15
  38. package/src/commands/doctor.ts +47 -9
  39. package/src/commands/init.ts +12 -39
  40. package/src/core/agents/coord-client.ts +17 -4
  41. package/src/core/agents/render/session-context.ts +74 -34
  42. package/src/core/agents/rules/claim-conflict.ts +28 -11
  43. package/src/core/agents/state/heartbeat-writer.ts +8 -1
  44. package/src/core/config.ts +21 -0
  45. package/src/core/hooks/cli.ts +24 -20
  46. package/src/core/hooks/harness/wiring.ts +185 -0
  47. package/src/core/hooks/resolve/anchor.ts +21 -0
  48. package/src/core/hooks/resolve/owner.ts +17 -8
  49. package/dist/commands/uninstall.d.ts +0 -22
  50. package/dist/commands/uninstall.d.ts.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"owner.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/owner.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE;IACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,KAAK,GAAG,SAAS,GAAG,aAAa,GAAG,iBAAiB,CAAA;CAAE,GAAG,IAAI,CA+ChG;AAmBD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ7E;AAED,wEAAwE;AACxE,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUxF;AAED,2DAA2D;AAC3D,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAgBnF"}
1
+ {"version":3,"file":"owner.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/owner.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE;IACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,KAAK,GAAG,SAAS,GAAG,aAAa,GAAG,iBAAiB,CAAA;CAAE,GAAG,IAAI,CA+ChG;AA2BD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ7E;AAED,wEAAwE;AACxE,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUxF;AAED,2DAA2D;AAC3D,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAgBnF"}
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { coordEnv } from "../../../lib/env.js";
@@ -67,19 +68,30 @@ export function resolveOwner(opts) {
67
68
  return null;
68
69
  }
69
70
  function readPpid(pid) {
70
- // Linux/WSL: /proc/<pid>/status carries `PPid:`. Falls back to null on
71
- // macOS or any read failure; ancestor walk just terminates.
71
+ // Linux/WSL fast path: /proc/<pid>/status carries `PPid:`.
72
72
  try {
73
73
  const status = readFileSync(`/proc/${pid}/status`, "utf8");
74
- for (const line of status.split("\n")) {
75
- if (line.startsWith("PPid:")) {
76
- const n = Number(line.split(/\s+/)[1]);
77
- return Number.isFinite(n) ? n : null;
78
- }
74
+ const m = status.match(/^PPid:\s+(\d+)/m);
75
+ if (m) {
76
+ const n = Number.parseInt(m[1], 10);
77
+ if (Number.isFinite(n) && n > 0)
78
+ return n;
79
+ }
80
+ }
81
+ catch {
82
+ /* no /proc (macOS/BSD) — fall through to ps */
83
+ }
84
+ // Portable fallback: `ps -o ppid= -p <pid>` works on macOS/BSD/Linux.
85
+ try {
86
+ const out = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
87
+ if (out.status === 0) {
88
+ const n = Number.parseInt(out.stdout.trim(), 10);
89
+ if (Number.isFinite(n) && n > 0)
90
+ return n;
79
91
  }
80
92
  }
81
93
  catch {
82
- /* fallthrough */
94
+ /* ps unavailable — give up */
83
95
  }
84
96
  return null;
85
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Multi-agent coordination + harness adapters + portable CLI utilities for Claude Code / Cursor / Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -19,6 +19,10 @@
19
19
  "default": "harn",
20
20
  "description": "Host CLI bin name used in agent-facing strings (council prompts, end-of-turn nudges, command help). The coord binaries + web UI run as harnery and read this back since they can't see a consumer CLI's name. Stamped by `harn init` for a consumer. Resolution: HARNERY_BIN env -> this field -> 'harn'."
21
21
  },
22
+ "hooksSetupHint": {
23
+ "type": "string",
24
+ "description": "Host-specific command that (re)installs the project's git hooks (e.g. 'scripts/setup-hooks.sh'). Surfaced verbatim in the SessionStart 'commit guard not wired' nudge. harnery doesn't own git-hook installation (each host wires its own pre-commit to invoke `agent-coord verdict`) and the path/command is host-specific, so the host declares it here. Unset -> a generic, host-agnostic hint."
25
+ },
22
26
  "coord": {
23
27
  "type": "object",
24
28
  "additionalProperties": false,
package/src/commander.ts CHANGED
@@ -27,6 +27,7 @@ import { registerCompletionCommand } from "./commands/completion.ts";
27
27
  import { registerConfigGetCommand } from "./commands/config-get.ts";
28
28
  import { registerContextCommand } from "./commands/context.ts";
29
29
  import { registerCookiesCommand } from "./commands/cookies.ts";
30
+ import { registerDeinitCommand } from "./commands/deinit.ts";
30
31
  import { registerDocsCommand } from "./commands/docs.ts";
31
32
  import { registerDoctorCommand } from "./commands/doctor.ts";
32
33
  import { registerEditBatchCommand } from "./commands/edit-batch.ts";
@@ -45,7 +46,6 @@ import { registerSyncCommand } from "./commands/sync.ts";
45
46
  import { registerSectionCommand, registerTocCommand } from "./commands/toc.ts";
46
47
  import { registerTokensCommand } from "./commands/tokens.ts";
47
48
  import { registerTunnelCommand } from "./commands/tunnel.ts";
48
- import { registerUninstallCommand } from "./commands/uninstall.ts";
49
49
  import { registerWebCommand } from "./commands/web.ts";
50
50
 
51
51
  export interface HarneryContextOpts {
@@ -230,7 +230,7 @@ export function createHarneryProgram(opts: HarneryContextOpts = {}): Command {
230
230
  registerAgentsCommand(program, emit);
231
231
  registerDoctorCommand(program, emit);
232
232
  registerInitCommand(program, emit, opts.binName);
233
- registerUninstallCommand(program, emit);
233
+ registerDeinitCommand(program, emit, opts.binName);
234
234
  registerBackupCommand(program, emit);
235
235
  registerSyncCommand(program, emit);
236
236
  if (include("web")) registerWebCommand(program, emit);
@@ -38,6 +38,7 @@ import {
38
38
  resolveOwnerWithSource,
39
39
  } from "../core/agents/index.ts";
40
40
  import { resolveBinName } from "../core/config.ts";
41
+ import { parsePsChainLine } from "../core/hooks/resolve/anchor.ts";
41
42
  import {
42
43
  buildCouncilId,
43
44
  buildInviteMarkdown,
@@ -2692,13 +2693,25 @@ function runHarnessProbe(
2692
2693
  for (let hops = 0; hops < 20; hops++) {
2693
2694
  let comm = "?";
2694
2695
  let ppid = 0;
2696
+ let got = false;
2695
2697
  try {
2696
2698
  comm = readFileSync(`/proc/${walkPid}/comm`, "utf8").trim() || "?";
2697
2699
  const status = readFileSync(`/proc/${walkPid}/status`, "utf8");
2698
2700
  const m = status.match(/^PPid:\s+(\d+)/m);
2699
2701
  ppid = m ? Number(m[1]) : 0;
2702
+ got = true;
2700
2703
  } catch {
2701
- // non-Linux (no /proc) or the pid is gone: stop walking.
2704
+ // non-Linux (no /proc): fall through to the portable ps walk below.
2705
+ }
2706
+ if (!got) {
2707
+ const out = spawnSync("ps", ["-o", "ppid=,comm=", "-p", String(walkPid)], {
2708
+ encoding: "utf8",
2709
+ });
2710
+ const parsed = out.status === 0 ? parsePsChainLine(out.stdout) : null;
2711
+ if (parsed) {
2712
+ comm = parsed.comm || "?";
2713
+ ppid = parsed.ppid;
2714
+ }
2702
2715
  }
2703
2716
  chainParts.push(`${walkPid}:${comm}`);
2704
2717
  if (!anchorPid && anchorTokens.has(comm)) anchorPid = String(walkPid);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `harn uninstall`: reverse what `harn init` wired into a project.
2
+ * `harn deinit`: reverse what `harn init` wired into a project.
3
3
  *
4
4
  * `init` makes two kinds of change outside the harnery package:
5
5
  * 1. Merges `agent-hook` entries into the harness settings file
@@ -9,42 +9,60 @@
9
9
  * identities, scratch) and stamps the host bin name into
10
10
  * `.harnery/config.jsonc`.
11
11
  *
12
- * `uninstall` undoes (1) by default: it removes only harnery's hook entries from
12
+ * `deinit` undoes (1) by default: it removes only harnery's hook entries from
13
13
  * the settings file, preserving any other hooks the consumer added, and deletes
14
14
  * the settings file outright when it's left harnery-only. It does NOT touch the
15
15
  * `.harnery/` coord root unless `--purge-state` is passed, because that directory
16
16
  * holds session history a consumer may want to keep. Idempotent + `--dry-run`,
17
17
  * mirroring `init`.
18
+ *
19
+ * For standalone `harn` on a terminal it also handles the destructive extra
20
+ * conversationally: when `--purge-state` wasn't passed and `.harnery/` exists, it
21
+ * asks before deleting it, and afterward it prints how to remove the harnery CLI
22
+ * itself (which a running process can't do to its own package). Both are gated to
23
+ * standalone harn — an embedding host routes output through its own emit and owns
24
+ * its install lifecycle — and the prompt never fires off a TTY, so scripted / CI
25
+ * runs keep the flag-driven behavior untouched. The shell wrapper `scripts/teardown.sh`
26
+ * is the fuller mirror (it also unlinks the PATH bins and can delete the clone).
18
27
  */
19
28
 
20
29
  import { spawnSync } from "node:child_process";
21
30
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
31
  import { relative, resolve } from "node:path";
32
+ import { createInterface } from "node:readline";
23
33
  import type { Command } from "commander";
24
34
  import type { EmitContext } from "../commander.ts";
35
+ import { DEFAULT_BIN_NAME } from "../core/config.ts";
25
36
  import { HARNESS_SPECS, type HarnessId } from "../core/hooks/harness/events.ts";
26
37
  import { type SettingsFile, unwireHooks } from "./init.ts";
27
38
 
28
- interface UninstallOpts {
39
+ interface DeinitOpts {
29
40
  harness: string;
30
41
  dryRun?: boolean;
31
42
  projectRoot?: string;
32
43
  purgeState?: boolean;
33
44
  }
34
45
 
35
- export function registerUninstallCommand(program: Command, emit: EmitContext): void {
46
+ export function registerDeinitCommand(program: Command, emit: EmitContext, binName?: string): void {
47
+ // The interactive prompt + engine-removal hint are standalone-harn niceties.
48
+ // An embedding host routes output through its own emit and owns its install
49
+ // lifecycle, so for a host (binName set + non-default) deinit stays strictly
50
+ // flag-driven and says nothing about removing "the package".
51
+ const standalone = !binName || binName === DEFAULT_BIN_NAME;
52
+
36
53
  program
37
- .command("uninstall")
54
+ .command("deinit")
38
55
  .description(
39
56
  "Reverse `harn init`: remove harnery's hook entries from the harness " +
40
57
  "settings file (keeps any others). Pass --purge-state to also delete the " +
41
- ".harnery/ coord root. Idempotent; use --dry-run to preview.",
58
+ ".harnery/ coord root (on a terminal it asks first). Idempotent; use " +
59
+ "--dry-run to preview.",
42
60
  )
43
61
  .option("--harness <id>", "claude-code | cursor | codex", "claude-code")
44
62
  .option("--dry-run", "Show what would change without writing")
45
63
  .option("--project-root <path>", "Project root (default: git toplevel, else cwd)")
46
64
  .option("--purge-state", "Also delete the .harnery/ coord root (runtime state, destructive)")
47
- .action((opts: UninstallOpts) => {
65
+ .action(async (opts: DeinitOpts) => {
48
66
  const harness = opts.harness as HarnessId;
49
67
  const spec = HARNESS_SPECS[harness];
50
68
  if (!spec) {
@@ -55,8 +73,24 @@ export function registerUninstallCommand(program: Command, emit: EmitContext): v
55
73
 
56
74
  const projectRoot = resolve(opts.projectRoot ?? gitTopLevel() ?? process.cwd());
57
75
  const dryRun = opts.dryRun === true;
76
+ const coordDir = resolve(projectRoot, ".harnery");
77
+ const coordExists = existsSync(coordDir);
78
+ let purgeState = opts.purgeState === true;
58
79
  const actions: string[] = [];
59
80
 
81
+ // ── 0. interactive: offer to delete .harnery/ (standalone harn, TTY) ───
82
+ if (
83
+ shouldPromptForState({
84
+ standalone,
85
+ interactive: process.stdin.isTTY === true,
86
+ dryRun,
87
+ purgeState,
88
+ coordExists,
89
+ })
90
+ ) {
91
+ purgeState = await confirmDeleteState(coordDir);
92
+ }
93
+
60
94
  // ── 1. unwire harness hooks ────────────────────────────────────────────
61
95
  const settingsPath = resolve(projectRoot, spec.settingsFile);
62
96
  if (!existsSync(settingsPath)) {
@@ -100,9 +134,8 @@ export function registerUninstallCommand(program: Command, emit: EmitContext): v
100
134
  }
101
135
 
102
136
  // ── 2. coord root (opt-in; destructive) ────────────────────────────────
103
- const coordDir = resolve(projectRoot, ".harnery");
104
- if (opts.purgeState) {
105
- if (!existsSync(coordDir)) {
137
+ if (purgeState) {
138
+ if (!coordExists) {
106
139
  actions.push("· .harnery/ doesn't exist; nothing to purge");
107
140
  } else if (dryRun) {
108
141
  actions.push("+ would delete .harnery/ and all coord state (events, councils, scratch)");
@@ -110,12 +143,65 @@ export function registerUninstallCommand(program: Command, emit: EmitContext): v
110
143
  rmSync(coordDir, { recursive: true, force: true });
111
144
  actions.push("+ deleted .harnery/ and all coord state");
112
145
  }
113
- } else if (existsSync(coordDir)) {
146
+ } else if (coordExists) {
114
147
  actions.push("· left .harnery/ coord root in place (pass --purge-state to delete it)");
115
148
  }
116
149
 
117
- emit.text(render(projectRoot, dryRun, actions));
150
+ const hint = standalone && !dryRun ? engineRemovalHint() : null;
151
+ emit.text(render(projectRoot, dryRun, actions, hint));
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Whether to interactively ask before deleting `.harnery/`. True only for
157
+ * standalone harn on a TTY, when the user didn't already answer with
158
+ * --purge-state, it isn't a dry run, and there's actually a coord root to
159
+ * delete. Pure so the gating is unit-testable without a terminal.
160
+ */
161
+ export function shouldPromptForState(o: {
162
+ standalone: boolean;
163
+ interactive: boolean;
164
+ dryRun: boolean;
165
+ purgeState: boolean;
166
+ coordExists: boolean;
167
+ }): boolean {
168
+ return o.standalone && o.interactive && !o.dryRun && !o.purgeState && o.coordExists;
169
+ }
170
+
171
+ /**
172
+ * The "harnery itself is still installed" line shown to standalone-harn users
173
+ * after a real deinit. `harn deinit` can't remove the package it's running
174
+ * from, so it points at the two ways to finish the job. Pure + exported for the
175
+ * test.
176
+ */
177
+ export function engineRemovalHint(): string {
178
+ return (
179
+ "harnery itself is still installed. To remove the CLI too: `npm rm -g harnery` " +
180
+ "(if you installed it with npm/bun); from a git clone, " +
181
+ "`scripts/teardown.sh --remove-clone` removes the checkout."
182
+ );
183
+ }
184
+
185
+ /** Mirror scripts/teardown.sh's wording: explain what .harnery/ holds, then ask. */
186
+ function confirmDeleteState(coordDir: string): Promise<boolean> {
187
+ process.stdout.write(
188
+ "\nharnery saved this project's coordination history in .harnery/\n" +
189
+ "(its event log, councils, agent identities, and scratchpads):\n" +
190
+ ` ${coordDir}\n` +
191
+ "Unwiring leaves that in place. Deleting it can't be undone.\n",
192
+ );
193
+ return confirm("Delete this project's harnery history too? [y/N]");
194
+ }
195
+
196
+ /** Read one y/N answer from the TTY. Yes only on an explicit y / yes. */
197
+ function confirm(question: string): Promise<boolean> {
198
+ return new Promise((resolveAnswer) => {
199
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
200
+ rl.question(`${question} `, (answer) => {
201
+ rl.close();
202
+ resolveAnswer(/^y(es)?$/i.test(answer.trim()));
118
203
  });
204
+ });
119
205
  }
120
206
 
121
207
  /** True when an unwired settings object holds nothing but (optionally) `version`. */
@@ -124,12 +210,18 @@ function harnessOnly(settings: SettingsFile): boolean {
124
210
  return keys.length === 0 || (keys.length === 1 && keys[0] === "version");
125
211
  }
126
212
 
127
- function render(projectRoot: string, dryRun: boolean, actions: string[]): string {
128
- const head = dryRun ? "harn uninstall (dry run): no changes written" : "harn uninstall";
213
+ function render(
214
+ projectRoot: string,
215
+ dryRun: boolean,
216
+ actions: string[],
217
+ hint: string | null,
218
+ ): string {
219
+ const head = dryRun ? "harn deinit (dry run): no changes written" : "harn deinit";
129
220
  const tail = dryRun
130
221
  ? "\nRe-run without --dry-run to apply."
131
222
  : "\nDone. harnery hooks are unwired; restart your harness session to drop them.";
132
- return `${head}\n root: ${projectRoot}\n${actions.map((a) => ` ${a}`).join("\n")}${tail}`;
223
+ const hintBlock = hint ? `\n\n${hint}` : "";
224
+ return `${head}\n root: ${projectRoot}\n${actions.map((a) => ` ${a}`).join("\n")}${tail}${hintBlock}`;
133
225
  }
134
226
 
135
227
  function gitTopLevel(): string | null {
@@ -17,6 +17,8 @@ import os from "node:os";
17
17
  import path from "node:path";
18
18
  import type { Command } from "commander";
19
19
  import type { EmitContext } from "../commander.ts";
20
+ import { resolveBinName } from "../core/config.ts";
21
+ import { loadHarnessWiring } from "../core/hooks/harness/wiring.ts";
20
22
 
21
23
  type Severity = "ok" | "warn" | "fail";
22
24
 
@@ -79,6 +81,7 @@ export function runChecks(): Check[] {
79
81
  checkGit(),
80
82
  checkBun(),
81
83
  checkHarneryDir(),
84
+ checkHarnessHooks(),
82
85
  checkRestic(),
83
86
  checkRclone(),
84
87
  checkPlaywright(),
@@ -203,21 +206,23 @@ function checkPython(): Check {
203
206
  return { name: "python3", severity: "ok", detail: r.out };
204
207
  }
205
208
 
206
- function checkHarneryDir(): Check {
207
- // Walk up from cwd looking for .harnery/.
209
+ /** Walk up from cwd to the nearest dir containing `.harnery/`; null if none. */
210
+ function findCoordProjectRoot(): string | null {
208
211
  let dir = process.cwd();
209
212
  for (let i = 0; i < 8; i++) {
210
- if (existsSync(path.join(dir, ".harnery"))) {
211
- return {
212
- name: ".harnery/",
213
- severity: "ok",
214
- detail: path.join(dir, ".harnery"),
215
- };
216
- }
213
+ if (existsSync(path.join(dir, ".harnery"))) return dir;
217
214
  const parent = path.dirname(dir);
218
215
  if (parent === dir) break;
219
216
  dir = parent;
220
217
  }
218
+ return null;
219
+ }
220
+
221
+ function checkHarneryDir(): Check {
222
+ const root = findCoordProjectRoot();
223
+ if (root) {
224
+ return { name: ".harnery/", severity: "ok", detail: path.join(root, ".harnery") };
225
+ }
221
226
  return {
222
227
  name: ".harnery/",
223
228
  severity: "warn",
@@ -226,6 +231,39 @@ function checkHarneryDir(): Check {
226
231
  };
227
232
  }
228
233
 
234
+ /**
235
+ * Compare the project's wired harness hooks against HARNESS_SPECS. Catches the
236
+ * post-upgrade case where a harnery release added (or renamed) a hook event but
237
+ * the consumer's settings file hasn't been re-wired. Only fires for a harness
238
+ * the project has opted into (≥1 harnery hook already wired) — see
239
+ * loadHarnessWiring — so a bare settings file never false-warns. The remedy is
240
+ * always the same: re-run `<bin> init` (idempotent, additive).
241
+ */
242
+ function checkHarnessHooks(): Check {
243
+ const root = findCoordProjectRoot();
244
+ if (!root) {
245
+ return { name: "harness hooks", severity: "ok", detail: "n/a (no .harnery/ above cwd)" };
246
+ }
247
+ const drift = loadHarnessWiring(root);
248
+ if (drift.length === 0) {
249
+ return { name: "harness hooks", severity: "ok", detail: "wired + current" };
250
+ }
251
+ const bin = resolveBinName(root);
252
+ const parts = drift.map((d) => {
253
+ const bits: string[] = [];
254
+ if (d.missing.length > 0) {
255
+ bits.push(`${d.missing.length} missing (${d.missing.map((m) => m.subcommand).join(", ")})`);
256
+ }
257
+ if (d.orphans.length > 0) bits.push(`${d.orphans.length} orphaned (${d.orphans.join(", ")})`);
258
+ return `${d.settingsFile}: ${bits.join("; ")}`;
259
+ });
260
+ const hasOrphans = drift.some((d) => d.orphans.length > 0);
261
+ const hint = hasOrphans
262
+ ? `run \`${bin} init\` to wire missing hooks; remove orphaned entries (renamed/dropped events) with \`${bin} deinit\` then \`${bin} init\``
263
+ : `run \`${bin} init\` to wire the new hook(s) (idempotent)`;
264
+ return { name: "harness hooks", severity: "warn", detail: parts.join(" | "), hint };
265
+ }
266
+
229
267
  function macOrLinux(mac: string, linux: string): string {
230
268
  return os.platform() === "darwin" ? mac : linux;
231
269
  }
@@ -23,12 +23,18 @@ import { fileURLToPath } from "node:url";
23
23
  import type { Command } from "commander";
24
24
  import type { EmitContext } from "../commander.ts";
25
25
  import { DEFAULT_BIN_NAME, stripJsonComments } from "../core/config.ts";
26
+ import { HARNESS_SPECS, type HarnessId, type HarnessSpec } from "../core/hooks/harness/events.ts";
26
27
  import {
27
- HARNESS_SPECS,
28
- type HarnessId,
29
- type HarnessSpec,
30
- type HookEntryShape,
31
- } from "../core/hooks/harness/events.ts";
28
+ commandWiresSubcommand,
29
+ groupCommands,
30
+ type HookGroup,
31
+ makeEntry,
32
+ type SettingsFile,
33
+ } from "../core/hooks/harness/wiring.ts";
34
+
35
+ // Re-exported for back-compat: callers (deinit, tests) import these names
36
+ // from init.ts. The definitions now live in core/hooks/harness/wiring.ts.
37
+ export type { HookGroup, SettingsFile };
32
38
 
33
39
  // This file is src/commands/init.ts → harnery package root is two levels up.
34
40
  const HARNERY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -39,25 +45,6 @@ interface InitOpts {
39
45
  projectRoot?: string;
40
46
  }
41
47
 
42
- /** Claude Code + Codex entry: `{ hooks: [{ type, command }] }`. */
43
- interface ClaudeHookGroup {
44
- matcher?: string;
45
- hooks: { type: string; command: string }[];
46
- }
47
- /** Cursor entry: a flat `{ command }`. */
48
- interface CursorHookGroup {
49
- command: string;
50
- type?: string;
51
- matcher?: string;
52
- }
53
- type HookGroup = ClaudeHookGroup | CursorHookGroup;
54
-
55
- export interface SettingsFile {
56
- version?: number;
57
- hooks?: Record<string, HookGroup[]>;
58
- [k: string]: unknown;
59
- }
60
-
61
48
  export function registerInitCommand(program: Command, emit: EmitContext, binName?: string): void {
62
49
  program
63
50
  .command("init")
@@ -173,7 +160,7 @@ export function wireHooks(
173
160
  const command = `bash ${agentHookPath} ${subcommand} --harness ${harness}`;
174
161
  const groups = settings.hooks[settingsKey] ?? [];
175
162
  const present = groups.some((g) =>
176
- groupCommands(g).some((c) => c.includes(`agent-hook ${subcommand} `)),
163
+ groupCommands(g).some((c) => commandWiresSubcommand(c, subcommand)),
177
164
  );
178
165
  if (present) {
179
166
  already++;
@@ -217,20 +204,6 @@ export function unwireHooks(settings: SettingsFile): { removed: number; remainin
217
204
  return { removed, remaining };
218
205
  }
219
206
 
220
- /** Build a hook entry in the harness's shape. */
221
- function makeEntry(shape: HookEntryShape, command: string): HookGroup {
222
- return shape === "cursor" ? { command } : { hooks: [{ type: "command", command }] };
223
- }
224
-
225
- /** Pull every command string out of a hook entry, regardless of shape. */
226
- function groupCommands(group: HookGroup): string[] {
227
- if ("command" in group && typeof group.command === "string") return [group.command];
228
- if ("hooks" in group && Array.isArray(group.hooks)) {
229
- return group.hooks.map((h) => h.command).filter((c): c is string => typeof c === "string");
230
- }
231
- return [];
232
- }
233
-
234
207
  /**
235
208
  * Idempotently record `binName` in `.harnery/config.jsonc`, preserving any
236
209
  * existing JSONC comments and the `files` section. Returns an action line, or
@@ -361,13 +361,26 @@ export function selfDisplayName(): string {
361
361
  }
362
362
 
363
363
  function readPpid(pid: number): number | null {
364
+ // Linux/WSL fast path: /proc/<pid>/status carries `PPid:`.
364
365
  try {
365
366
  const status = readFileSync(`/proc/${pid}/status`, "utf8");
366
367
  const m = status.match(/^PPid:\s+(\d+)/m);
367
- if (!m) return null;
368
- const parsed = Number.parseInt(m[1]!, 10);
369
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
368
+ if (m) {
369
+ const parsed = Number.parseInt(m[1]!, 10);
370
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
371
+ }
370
372
  } catch {
371
- return null;
373
+ // no /proc (macOS/BSD) — fall through to ps
372
374
  }
375
+ // Portable fallback: `ps -o ppid= -p <pid>` works on macOS/BSD/Linux.
376
+ try {
377
+ const out = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
378
+ if (out.status === 0) {
379
+ const parsed = Number.parseInt(out.stdout.trim(), 10);
380
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
381
+ }
382
+ } catch {
383
+ // ps unavailable — give up
384
+ }
385
+ return null;
373
386
  }