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
@@ -10,7 +10,8 @@
10
10
  import { spawnSync } from "node:child_process";
11
11
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
12
12
  import { join } from "node:path";
13
- import { resolveBinName } from "../../config.ts";
13
+ import { resolveBinName, resolveHooksSetupHint } from "../../config.ts";
14
+ import { harneryVersion, loadHarnessWiring } from "../../hooks/harness/wiring.ts";
14
15
 
15
16
  interface HeartbeatRow {
16
17
  instance_id?: string;
@@ -69,13 +70,40 @@ export function renderSessionContext(opts: RenderOpts): string {
69
70
  if (councilMsg) messages.push(councilMsg);
70
71
  }
71
72
 
72
- // 4. Wiring check
73
+ // 4. Commit-guard wiring check
73
74
  const wiringIssues = checkWiring(coordRoot);
74
75
  if (wiringIssues.length > 0) {
75
- const wiringSummary = `Coordination hooks are NOT wired: the E-guard will not block conflicting commits, and post-commit claim pruning will not run. Run \`scripts/setup-hooks.sh\` to fix. Detected:\n${wiringIssues.map((i) => ` - ${i}`).join("\n")}`;
76
+ const hint = resolveHooksSetupHint(coordRoot);
77
+ const fix = hint
78
+ ? `Run \`${hint}\` to install them.`
79
+ : "Wire each repo's pre-commit hook to invoke `agent-coord verdict --rule=commit` (harnery's commit guard).";
80
+ const wiringSummary = `Coordination hooks are NOT wired: the E-guard will not block conflicting commits, and post-commit claim pruning will not run. ${fix} Detected:\n${wiringIssues.map((i) => ` - ${i}`).join("\n")}`;
76
81
  messages.push(wiringSummary);
77
82
  }
78
83
 
84
+ // 5. Harness-hook drift: a harnery upgrade changed the hook set, but this
85
+ // project's settings file hasn't been re-wired. Only fires for a harness the
86
+ // project already opted into (≥1 hook wired), so it never nags a project that
87
+ // simply has a settings file. Remedy is always `<bin> init` (idempotent).
88
+ const drift = loadHarnessWiring(coordRoot);
89
+ if (drift.length > 0) {
90
+ const bin = resolveBinName(coordRoot);
91
+ const ver = harneryVersion();
92
+ const verPart = ver ? ` (harnery ${ver})` : "";
93
+ const lines = drift.map((d) => {
94
+ const bits: string[] = [];
95
+ if (d.missing.length > 0)
96
+ bits.push(`missing: ${d.missing.map((m) => m.subcommand).join(", ")}`);
97
+ if (d.orphans.length > 0) bits.push(`orphaned: ${d.orphans.join(", ")}`);
98
+ return ` - ${d.settingsFile} — ${bits.join("; ")}`;
99
+ });
100
+ messages.push(
101
+ `Harnery hook wiring is out of date${verPart}: an upgrade changed the hook set but the harness ` +
102
+ `settings file hasn't been re-wired, so the new hook(s) won't fire. Run \`${bin} init\` to wire them ` +
103
+ `(idempotent, additive).\n${lines.join("\n")}`,
104
+ );
105
+ }
106
+
79
107
  return messages.join("\n\n");
80
108
  }
81
109
 
@@ -198,19 +226,25 @@ function fmtAge(secs: number): string {
198
226
  }
199
227
 
200
228
  /**
201
- * Returns a list of wiring issues (empty when everything's wired). Checks
202
- * parent core.hooksPath + one representative submodule.
229
+ * Returns a list of commit-guard wiring issues (empty when wired). Portable
230
+ * across host projects: it asserts the FUNCTIONAL property ("does this repo's
231
+ * pre-commit invoke harnery's guard?") rather than any path convention. For
232
+ * each repo it resolves the EFFECTIVE git-hooks dir via
233
+ * `git rev-parse --git-path hooks` (which already honors `core.hooksPath`,
234
+ * linked worktrees, and submodule gitdirs) and checks whether the `pre-commit`
235
+ * there calls `agent-coord` / `agent-hook`. Checks the parent repo + one
236
+ * representative submodule (others almost always share the same setup).
237
+ *
238
+ * harnery does not install git hooks itself — each host wires its own
239
+ * pre-commit to invoke the guard — so the remediation command is host-specific
240
+ * and supplied via `hooksSetupHint` in `.harnery/config.jsonc` (see the caller).
203
241
  */
204
242
  export function checkWiring(coordRoot: string): string[] {
205
- const expected = join(coordRoot, "scripts", "hooks");
206
243
  const issues: string[] = [];
207
244
 
208
- // Parent repo
209
- const parentHp = gitConfig(coordRoot, "core.hooksPath");
210
- const parentResolved = resolveHooksPath(coordRoot, parentHp);
211
- if (parentResolved !== expected) {
245
+ if (!preCommitInvokesGuard(coordRoot)) {
212
246
  issues.push(
213
- `parent core.hooksPath=${parentHp || "<unset>"} (resolves to ${parentResolved}, expected ${expected})`,
247
+ "parent repo: pre-commit hook is missing or doesn't invoke the harnery commit guard",
214
248
  );
215
249
  }
216
250
 
@@ -220,15 +254,10 @@ export function checkWiring(coordRoot: string): string[] {
220
254
  const sampleSub = extractFirstSubmodule(gitmodules);
221
255
  if (sampleSub) {
222
256
  const subPath = join(coordRoot, sampleSub);
223
- const subGitDir = join(subPath, ".git");
224
- if (existsSync(subGitDir)) {
225
- const subHp = gitConfig(subPath, "core.hooksPath");
226
- const subResolved = resolveSubmoduleHooksPath(coordRoot, sampleSub, subHp);
227
- if (subResolved !== expected) {
228
- issues.push(
229
- `submodule ${sampleSub} core.hooksPath=${subHp || "<unset>"} (resolves to ${subResolved}; other submodules likely affected too)`,
230
- );
231
- }
257
+ if (existsSync(join(subPath, ".git")) && !preCommitInvokesGuard(subPath)) {
258
+ issues.push(
259
+ `submodule ${sampleSub}: pre-commit hook doesn't invoke the harnery commit guard (other submodules likely affected too)`,
260
+ );
232
261
  }
233
262
  }
234
263
  }
@@ -236,22 +265,33 @@ export function checkWiring(coordRoot: string): string[] {
236
265
  return issues;
237
266
  }
238
267
 
239
- function gitConfig(cwd: string, key: string): string {
240
- const result = spawnSync("git", ["-C", cwd, "config", "--get", key], { encoding: "utf8" });
241
- if (result.status !== 0) return "";
242
- return result.stdout.trim();
243
- }
244
-
245
- function resolveHooksPath(root: string, hp: string): string {
246
- if (!hp) return join(root, ".git", "hooks");
247
- if (hp.startsWith("/")) return hp.replace(/\/$/, "");
248
- return join(root, hp).replace(/\/$/, "");
268
+ /**
269
+ * Whether the repo at `repoDir` has a pre-commit hook — at its effective,
270
+ * `core.hooksPath`-aware location — that invokes harnery's commit guard.
271
+ * Fully portable: no assumption about WHERE the host keeps its hooks.
272
+ */
273
+ function preCommitInvokesGuard(repoDir: string): boolean {
274
+ const hooksDir = gitHooksDir(repoDir);
275
+ if (!hooksDir) return false;
276
+ const preCommit = join(hooksDir, "pre-commit");
277
+ if (!existsSync(preCommit)) return false;
278
+ try {
279
+ return /agent-(coord|hook)\b/.test(readFileSync(preCommit, "utf8"));
280
+ } catch {
281
+ return false;
282
+ }
249
283
  }
250
284
 
251
- function resolveSubmoduleHooksPath(root: string, sub: string, hp: string): string {
252
- if (!hp) return join(root, sub, ".git", "hooks");
253
- if (hp.startsWith("/")) return hp.replace(/\/$/, "");
254
- return join(root, sub, hp).replace(/\/$/, "");
285
+ /** Resolve a repo's effective git-hooks directory (absolute), or null. */
286
+ function gitHooksDir(repoDir: string): string | null {
287
+ const r = spawnSync("git", ["-C", repoDir, "rev-parse", "--git-path", "hooks"], {
288
+ encoding: "utf8",
289
+ });
290
+ if (r.status !== 0) return null;
291
+ const p = r.stdout.trim();
292
+ if (!p) return null;
293
+ // `--git-path` prints relative to repoDir (we passed -C); absolutize.
294
+ return p.startsWith("/") ? p : join(repoDir, p);
255
295
  }
256
296
 
257
297
  function extractFirstSubmodule(gitmodulesPath: string): string | null {
@@ -102,14 +102,28 @@ export function evaluateClaim(coordRoot: string, req: ClaimRequest): VerdictResu
102
102
  (p) => isFresh(p.last_heartbeat) && p.files_touched.length > 0,
103
103
  );
104
104
  if (hasFreshPeers && myPeer && myPeer.files_touched.length > 0) {
105
- const highest = [...myPeer.files_touched].sort().at(-1)!;
106
- if (req.path < highest) {
107
- return {
108
- allow: false,
109
- exit_code: 2,
110
- rule: "claim.ordering_violation",
111
- reason: `Cannot acquire ${req.path} while holding ${highest} (claim ordering rule: paths must be acquired in sorted order). Release the higher claim first.`,
112
- };
105
+ // Only ACTIVE (uncommitted) edits should constrain lock ordering. A claim on
106
+ // a committed-clean file is a finished edit, not a held lock, so it must not
107
+ // wall off a lower-sorted acquisition. Without this, a long session
108
+ // accumulates committed claims that block every earlier-sorted path — pure
109
+ // friction, no deadlock risk (the file isn't being touched). Mirrors the
110
+ // peer stale-claim self-heal above. The git probes run only on the
111
+ // would-block path (claims sorting after req.path), staying off the hot path.
112
+ const blockers = myPeer.files_touched.filter((p) => req.path < p);
113
+ if (blockers.length > 0) {
114
+ const activeBlockers = blockers.filter((p) => !isFileCommittedClean(coordRoot, p));
115
+ if (activeBlockers.length > 0) {
116
+ const highest = [...activeBlockers].sort().at(-1)!;
117
+ return {
118
+ allow: false,
119
+ exit_code: 2,
120
+ rule: "claim.ordering_violation",
121
+ reason: `Cannot acquire ${req.path} while holding ${highest} (claim ordering rule: paths must be acquired in sorted order). Release the higher claim first.`,
122
+ };
123
+ }
124
+ // Every blocker is a finished (committed-clean) edit: prune them so they
125
+ // stop constraining future acquisitions, then fall through to allow.
126
+ for (const p of blockers) pruneClaimFromPeer(coordRoot, req.instance_id, p);
113
127
  }
114
128
  }
115
129
 
@@ -240,16 +254,19 @@ function isFresh(lastHeartbeat: string): boolean {
240
254
  * - diff shows non-empty output (genuinely dirty)
241
255
  */
242
256
  function isFileCommittedClean(coordRoot: string, relPath: string): boolean {
243
- const abs = join(coordRoot, relPath);
257
+ // Tolerate either path form: files_touched can hold absolute-under-coordRoot
258
+ // entries (legacy file-tracking) or canonical monorepo-relative ones.
259
+ const rel = relPath.startsWith(`${coordRoot}/`) ? relPath.slice(coordRoot.length + 1) : relPath;
260
+ const abs = join(coordRoot, rel);
244
261
  if (!existsSync(abs)) return false;
245
262
  try {
246
- const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--", relPath], {
263
+ const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--", rel], {
247
264
  cwd: coordRoot,
248
265
  encoding: "utf8",
249
266
  timeout: 2000,
250
267
  });
251
268
  if (tracked.status !== 0) return false;
252
- const result = spawnSync("git", ["diff", "--quiet", "HEAD", "--", relPath], {
269
+ const result = spawnSync("git", ["diff", "--quiet", "HEAD", "--", rel], {
253
270
  cwd: coordRoot,
254
271
  encoding: "utf8",
255
272
  timeout: 2000,
@@ -171,9 +171,16 @@ export function releaseClaim(
171
171
  instanceId: string,
172
172
  path: string,
173
173
  ): Heartbeat | null {
174
+ // files_touched can hold either absolute-under-coordRoot or canonical
175
+ // monorepo-relative entries; normalize both sides so release matches
176
+ // regardless of the form the caller passes (the old exact-string filter
177
+ // silently no-op'd on a form mismatch).
178
+ const norm = (p: string): string =>
179
+ p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
180
+ const target = norm(path);
174
181
  return mutate(coordRoot, instanceId, (hb) => ({
175
182
  ...hb,
176
- files_touched: (hb.files_touched ?? []).filter((p) => p !== path),
183
+ files_touched: (hb.files_touched ?? []).filter((p) => norm(p) !== target),
177
184
  }));
178
185
  }
179
186
 
@@ -25,6 +25,14 @@ export const DEFAULT_BIN_NAME = "harn";
25
25
  interface HarneryConfig {
26
26
  /** Host CLI bin name, stamped by `harn init` for a consumer (e.g. "bp"). */
27
27
  binName?: string;
28
+ /**
29
+ * Host-specific command that (re)installs the project's git hooks, surfaced
30
+ * verbatim in the "commit guard not wired" nudge. Optional: harnery doesn't
31
+ * own git-hook installation (each host wires its own pre-commit to invoke
32
+ * `agent-coord verdict`), and the path/command is host-specific, so the host
33
+ * declares it here (e.g. "scripts/setup-hooks.sh"). Unset → a generic hint.
34
+ */
35
+ hooksSetupHint?: string;
28
36
  [k: string]: unknown;
29
37
  }
30
38
 
@@ -109,3 +117,16 @@ export function resolveBinName(coordRoot?: string | null): string {
109
117
  }
110
118
  return DEFAULT_BIN_NAME;
111
119
  }
120
+
121
+ /**
122
+ * The host's git-hook (re)install command, for the "commit guard not wired"
123
+ * nudge. Returns the configured `hooksSetupHint` (e.g. "scripts/setup-hooks.sh")
124
+ * or null when unset — callers fall back to a generic, host-agnostic message.
125
+ * `coordRoot` is resolved via `findCoordRoot()` when not passed.
126
+ */
127
+ export function resolveHooksSetupHint(coordRoot?: string | null): string | null {
128
+ const root = coordRoot ?? findCoordRoot();
129
+ if (!root) return null;
130
+ const hint = readConfig(root).hooksSetupHint;
131
+ return typeof hint === "string" && hint.trim() ? hint.trim() : null;
132
+ }
@@ -51,7 +51,7 @@ import {
51
51
  type ParsedPayload,
52
52
  parsePayload,
53
53
  } from "./harness/parse.ts";
54
- import { selectAnchorPid } from "./resolve/anchor.ts";
54
+ import { parsePsChainLine, selectAnchorPid } from "./resolve/anchor.ts";
55
55
  import { findCoordRoot } from "./resolve/coord-root.ts";
56
56
  import { extractIntentComment, resolveIntent } from "./resolve/intent.ts";
57
57
  import { resolveOwner } from "./resolve/owner.ts";
@@ -908,10 +908,10 @@ function healHeartbeatViaCli(
908
908
  * the next tool call rather than going invisible until SessionStart fires
909
909
  * again, which it may never do.
910
910
  *
911
- * Returns undefined on macOS (no /proc) or when no anchor is found; callers
912
- * fall back to `process.ppid` (the bash wrapper's parent, which is usually
913
- * the harness binary itself). `HARNERY_AGENT_COORD_TEST_ANCHOR_PID` overrides
914
- * everything so the test sandbox can pin a deterministic PID.
911
+ * Returns undefined only when no anchor is found; callers fall back to
912
+ * `process.ppid` (the bash wrapper's parent, which is usually the harness binary
913
+ * itself). `HARNERY_AGENT_COORD_TEST_ANCHOR_PID` overrides everything so the
914
+ * test sandbox can pin a deterministic PID.
915
915
  */
916
916
  function findHarnessAnchorPid(harness?: Harness): number | undefined {
917
917
  const override = coordEnv("AGENT_COORD_TEST_ANCHOR_PID");
@@ -919,27 +919,31 @@ function findHarnessAnchorPid(harness?: Harness): number | undefined {
919
919
  const n = Number(override);
920
920
  if (Number.isFinite(n) && n > 0) return n;
921
921
  }
922
- // Build the ppid chain (nearest → root, up to 20 hops) from /proc, then hand
923
- // it to the pure selector. Splitting the /proc walk (untestable off a live
924
- // box) from the comm-matching (unit-tested against the real Phase 0 chains in
925
- // resolve/anchor.ts) keeps the cursor `node`-fallback logic verifiable.
922
+ // Build the ppid chain (nearest → root, up to 20 hops), then hand it to the
923
+ // pure selector. Linux/WSL reads /proc; macOS/BSD (no /proc) falls back to
924
+ // `ps -o ppid=,comm=` parsed by the unit-tested `parsePsChainLine`. Splitting
925
+ // the walk (untestable off a live box) from the comm-matching keeps the
926
+ // cursor `node`-fallback logic verifiable.
926
927
  const chain: Array<{ pid: number; comm: string }> = [];
927
928
  let pid = process.pid;
928
929
  for (let hops = 0; hops < 20; hops++) {
929
- let comm: string;
930
- let status: string;
930
+ let hop: { comm: string; ppid: number } | null = null;
931
931
  try {
932
- comm = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
933
- status = readFileSync(`/proc/${pid}/status`, "utf8");
932
+ const comm = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
933
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
934
+ const m = status.match(/^PPid:\s+(\d+)/m);
935
+ hop = { comm, ppid: m ? Number(m[1]) : 0 };
934
936
  } catch {
935
- break;
937
+ // no /proc (macOS/BSD) — fall through to ps
936
938
  }
937
- chain.push({ pid, comm });
938
- const m = status.match(/^PPid:\s+(\d+)/m);
939
- if (!m) break;
940
- const ppid = Number(m[1]);
941
- if (!Number.isFinite(ppid) || ppid === 0 || ppid === 1) break;
942
- pid = ppid;
939
+ if (!hop) {
940
+ const out = spawnSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { encoding: "utf8" });
941
+ if (out.status === 0) hop = parsePsChainLine(out.stdout);
942
+ }
943
+ if (!hop) break;
944
+ chain.push({ pid, comm: hop.comm });
945
+ if (!Number.isFinite(hop.ppid) || hop.ppid === 0 || hop.ppid === 1) break;
946
+ pid = hop.ppid;
943
947
  }
944
948
  return selectAnchorPid(chain, harness);
945
949
  }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Read-only harness-hook wiring inspection — the inverse of `harn init`'s
3
+ * writer (commands/init.ts `wireHooks`). Compares what `init` would wire
4
+ * (HARNESS_SPECS) against what's actually present in a project's harness
5
+ * settings file, so `harn doctor` and the SessionStart nudge can tell an agent
6
+ * when a harnery upgrade changed the hook set but the project hasn't been
7
+ * re-wired yet.
8
+ *
9
+ * The shared types + matcher live here (not in init.ts) so the writer, the
10
+ * doctor check, and the session-start renderer all agree on what "wired" means
11
+ * — there's exactly one definition of the `agent-hook <subcommand>` match.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { dirname, join, resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import {
18
+ HARNESS_SPECS,
19
+ type HarnessId,
20
+ type HarnessSpec,
21
+ type HookEntryShape,
22
+ type HookEvent,
23
+ } from "./events.ts";
24
+
25
+ /** Claude Code + Codex entry: `{ hooks: [{ type, command }] }`. */
26
+ export interface ClaudeHookGroup {
27
+ matcher?: string;
28
+ hooks: { type: string; command: string }[];
29
+ }
30
+ /** Cursor entry: a flat `{ command }`. */
31
+ export interface CursorHookGroup {
32
+ command: string;
33
+ type?: string;
34
+ matcher?: string;
35
+ }
36
+ export type HookGroup = ClaudeHookGroup | CursorHookGroup;
37
+
38
+ export interface SettingsFile {
39
+ version?: number;
40
+ hooks?: Record<string, HookGroup[]>;
41
+ [k: string]: unknown;
42
+ }
43
+
44
+ /** Build a hook entry in the harness's shape. */
45
+ export function makeEntry(shape: HookEntryShape, command: string): HookGroup {
46
+ return shape === "cursor" ? { command } : { hooks: [{ type: "command", command }] };
47
+ }
48
+
49
+ /** Pull every command string out of a hook entry, regardless of shape. */
50
+ export function groupCommands(group: HookGroup): string[] {
51
+ if ("command" in group && typeof group.command === "string") return [group.command];
52
+ if ("hooks" in group && Array.isArray(group.hooks)) {
53
+ return group.hooks.map((h) => h.command).filter((c): c is string => typeof c === "string");
54
+ }
55
+ return [];
56
+ }
57
+
58
+ /**
59
+ * Whether a hook command string wires the given agent-hook subcommand. The
60
+ * trailing space is load-bearing: it keeps `stop` from matching `stop-failure`.
61
+ */
62
+ export function commandWiresSubcommand(command: string, subcommand: string): boolean {
63
+ return command.includes(`agent-hook ${subcommand} `);
64
+ }
65
+
66
+ /** Pull the agent-hook subcommand out of a command string, or null if none. */
67
+ function commandSubcommand(command: string): string | null {
68
+ const m = command.match(/agent-hook\s+([a-z][a-z-]*)\s/);
69
+ return m ? m[1]! : null;
70
+ }
71
+
72
+ export interface WiringDiff {
73
+ /** Spec events not wired in the settings file. */
74
+ missing: HookEvent[];
75
+ /** Spec events already wired. */
76
+ present: HookEvent[];
77
+ /**
78
+ * agent-hook subcommands wired in the file that are NOT in the current spec
79
+ * (e.g. an event renamed/removed by an upgrade). Additive re-init won't clean
80
+ * these — they need explicit removal — so they're surfaced separately.
81
+ */
82
+ orphans: string[];
83
+ }
84
+
85
+ /**
86
+ * Pure diff of one settings object against one harness spec. Read-only inverse
87
+ * of `wireHooks`; no fs, so it's unit-testable.
88
+ */
89
+ export function diffWiring(settings: SettingsFile, spec: HarnessSpec): WiringDiff {
90
+ const missing: HookEvent[] = [];
91
+ const present: HookEvent[] = [];
92
+ const hooks = settings.hooks ?? {};
93
+
94
+ for (const event of spec.events) {
95
+ const groups = hooks[event.settingsKey] ?? [];
96
+ const wired = groups.some((g) =>
97
+ groupCommands(g).some((c) => commandWiresSubcommand(c, event.subcommand)),
98
+ );
99
+ (wired ? present : missing).push(event);
100
+ }
101
+
102
+ const specSubcommands = new Set(spec.events.map((e) => e.subcommand));
103
+ const orphans = new Set<string>();
104
+ for (const groups of Object.values(hooks)) {
105
+ if (!Array.isArray(groups)) continue;
106
+ for (const g of groups) {
107
+ for (const c of groupCommands(g)) {
108
+ const sub = commandSubcommand(c);
109
+ if (sub && !specSubcommands.has(sub)) orphans.add(sub);
110
+ }
111
+ }
112
+ }
113
+
114
+ return { missing, present, orphans: [...orphans].sort() };
115
+ }
116
+
117
+ export interface HarnessWiringStatus {
118
+ harness: HarnessId;
119
+ /** Settings file path, relative to the project root. */
120
+ settingsFile: string;
121
+ missing: HookEvent[];
122
+ orphans: string[];
123
+ }
124
+
125
+ /**
126
+ * Inspect every harness whose settings file exists under `projectRoot` and
127
+ * return only those with *drift*. Read-only; never writes.
128
+ *
129
+ * Drift is reported only for a harness the project has **already opted into** —
130
+ * i.e. at least one harnery hook is already wired. A settings file with zero
131
+ * harnery hooks just means this harness isn't harnery-wired here (a bare
132
+ * `.claude/settings.json` is a generic Claude Code file); that's `harn init`'s
133
+ * job to surface on first run, not drift to nag about every session. A harness
134
+ * with no settings file at all, or an unparseable one, is skipped.
135
+ */
136
+ export function loadHarnessWiring(projectRoot: string): HarnessWiringStatus[] {
137
+ const out: HarnessWiringStatus[] = [];
138
+ for (const [id, spec] of Object.entries(HARNESS_SPECS) as [HarnessId, HarnessSpec][]) {
139
+ const settingsPath = resolve(projectRoot, spec.settingsFile);
140
+ if (!existsSync(settingsPath)) continue;
141
+ let settings: SettingsFile;
142
+ try {
143
+ settings = JSON.parse(readFileSync(settingsPath, "utf8")) as SettingsFile;
144
+ } catch {
145
+ // Unparseable settings file: can't tell opt-in from noise, and the
146
+ // harness itself will complain about its own malformed config. Skip.
147
+ continue;
148
+ }
149
+ const diff = diffWiring(settings, spec);
150
+ if (diff.present.length === 0) continue; // not opted in → not drift
151
+ if (diff.missing.length === 0 && diff.orphans.length === 0) continue; // current
152
+ out.push({
153
+ harness: id,
154
+ settingsFile: spec.settingsFile,
155
+ missing: diff.missing,
156
+ orphans: diff.orphans,
157
+ });
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /**
163
+ * Resolve the harnery package version for context in nudges/checks. Walks up
164
+ * from this module to the package root (works under Bun from `src/` and Node
165
+ * from `dist/`). Returns "" if unresolved — callers omit it from the message.
166
+ */
167
+ export function harneryVersion(): string {
168
+ try {
169
+ let dir = dirname(fileURLToPath(import.meta.url));
170
+ for (let i = 0; i < 8; i++) {
171
+ try {
172
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
173
+ if (pkg.name === "harnery" && typeof pkg.version === "string") return pkg.version;
174
+ } catch {
175
+ /* no package.json here, or not ours; keep walking up */
176
+ }
177
+ const parent = dirname(dir);
178
+ if (parent === dir) break;
179
+ dir = parent;
180
+ }
181
+ } catch {
182
+ /* import.meta.url unavailable or fs error */
183
+ }
184
+ return "";
185
+ }
@@ -49,3 +49,24 @@ export function selectAnchorPid(
49
49
  }
50
50
  return undefined;
51
51
  }
52
+
53
+ /**
54
+ * Parse one `ps -o ppid=,comm= -p <pid>` line into the same `{ ppid, comm }`
55
+ * shape the `/proc` fast path produces, for the macOS/BSD branch of the anchor
56
+ * walk. `ps` prints `comm` as a full executable path (and some Apple helper
57
+ * names contain spaces, e.g. `Code Helper (Plugin)`), so the comm is reduced to
58
+ * its basename to match the harness comm tokens the way Linux's `/proc/<pid>/comm`
59
+ * basename does. Returns null when the line has no leading numeric ppid.
60
+ *
61
+ * Pure (no I/O) so the parsing — the error-prone part — is unit-testable
62
+ * without a live process tree; the caller owns the `ps` spawn.
63
+ */
64
+ export function parsePsChainLine(line: string): { ppid: number; comm: string } | null {
65
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
66
+ if (!m) return null;
67
+ const ppid = Number.parseInt(m[1]!, 10);
68
+ if (!Number.isFinite(ppid)) return null;
69
+ const commPath = m[2]!.trim();
70
+ const comm = commPath.split("/").pop() || commPath;
71
+ return { ppid, comm };
72
+ }
@@ -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.ts";
@@ -73,18 +74,26 @@ export function resolveOwner(opts: {
73
74
  }
74
75
 
75
76
  function readPpid(pid: number): number | null {
76
- // Linux/WSL: /proc/<pid>/status carries `PPid:`. Falls back to null on
77
- // macOS or any read failure; ancestor walk just terminates.
77
+ // Linux/WSL fast path: /proc/<pid>/status carries `PPid:`.
78
78
  try {
79
79
  const status = readFileSync(`/proc/${pid}/status`, "utf8");
80
- for (const line of status.split("\n")) {
81
- if (line.startsWith("PPid:")) {
82
- const n = Number(line.split(/\s+/)[1]);
83
- return Number.isFinite(n) ? n : null;
84
- }
80
+ const m = status.match(/^PPid:\s+(\d+)/m);
81
+ if (m) {
82
+ const n = Number.parseInt(m[1]!, 10);
83
+ if (Number.isFinite(n) && n > 0) return n;
84
+ }
85
+ } catch {
86
+ /* no /proc (macOS/BSD) — fall through to ps */
87
+ }
88
+ // Portable fallback: `ps -o ppid= -p <pid>` works on macOS/BSD/Linux.
89
+ try {
90
+ const out = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
91
+ if (out.status === 0) {
92
+ const n = Number.parseInt(out.stdout.trim(), 10);
93
+ if (Number.isFinite(n) && n > 0) return n;
85
94
  }
86
95
  } catch {
87
- /* fallthrough */
96
+ /* ps unavailable — give up */
88
97
  }
89
98
  return null;
90
99
  }
@@ -1,22 +0,0 @@
1
- /**
2
- * `harn uninstall`: reverse what `harn init` wired into a project.
3
- *
4
- * `init` makes two kinds of change outside the harnery package:
5
- * 1. Merges `agent-hook` entries into the harness settings file
6
- * (Claude Code `.claude/settings.json`, Cursor `.cursor/hooks.json`, or
7
- * Codex `.codex/hooks.json`).
8
- * 2. Creates the `.harnery/` coord root (runtime state: events, councils,
9
- * identities, scratch) and stamps the host bin name into
10
- * `.harnery/config.jsonc`.
11
- *
12
- * `uninstall` undoes (1) by default: it removes only harnery's hook entries from
13
- * the settings file, preserving any other hooks the consumer added, and deletes
14
- * the settings file outright when it's left harnery-only. It does NOT touch the
15
- * `.harnery/` coord root unless `--purge-state` is passed, because that directory
16
- * holds session history a consumer may want to keep. Idempotent + `--dry-run`,
17
- * mirroring `init`.
18
- */
19
- import type { Command } from "commander";
20
- import type { EmitContext } from "../commander.js";
21
- export declare function registerUninstallCommand(program: Command, emit: EmitContext): void;
22
- //# sourceMappingURL=uninstall.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"uninstall.d.ts","sourceRoot":"","sources":["../../src/commands/uninstall.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAWnD,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAoFlF"}