harnery 0.3.2 → 0.5.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 (68) hide show
  1. package/README.md +20 -7
  2. package/dist/commander.js +2 -2
  3. package/dist/commands/completion.d.ts.map +1 -1
  4. package/dist/commands/completion.js +48 -10
  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/render/session-context.d.ts +12 -2
  14. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  15. package/dist/core/agents/render/session-context.js +75 -36
  16. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  17. package/dist/core/agents/rules/claim-conflict.js +38 -12
  18. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  19. package/dist/core/agents/state/heartbeat-writer.js +7 -1
  20. package/dist/core/config.d.ts +7 -0
  21. package/dist/core/config.d.ts.map +1 -1
  22. package/dist/core/config.js +13 -0
  23. package/dist/core/hooks/cli.js +4 -10
  24. package/dist/core/hooks/guard-path.d.ts +29 -0
  25. package/dist/core/hooks/guard-path.d.ts.map +1 -0
  26. package/dist/core/hooks/guard-path.js +38 -0
  27. package/dist/core/hooks/harness/wiring.d.ts +85 -0
  28. package/dist/core/hooks/harness/wiring.d.ts.map +1 -0
  29. package/dist/core/hooks/harness/wiring.js +137 -0
  30. package/dist/lib/completion/bash.d.ts +15 -0
  31. package/dist/lib/completion/bash.d.ts.map +1 -1
  32. package/dist/lib/completion/bash.js +35 -0
  33. package/dist/lib/completion/fish.d.ts +10 -0
  34. package/dist/lib/completion/fish.d.ts.map +1 -1
  35. package/dist/lib/completion/fish.js +21 -0
  36. package/dist/lib/completion/index.d.ts +4 -3
  37. package/dist/lib/completion/index.d.ts.map +1 -1
  38. package/dist/lib/completion/index.js +4 -3
  39. package/dist/lib/completion/resolve.d.ts +52 -0
  40. package/dist/lib/completion/resolve.d.ts.map +1 -0
  41. package/dist/lib/completion/resolve.js +171 -0
  42. package/dist/lib/completion/zsh.d.ts +8 -0
  43. package/dist/lib/completion/zsh.d.ts.map +1 -1
  44. package/dist/lib/completion/zsh.js +33 -0
  45. package/dist/lib/docs-lint.d.ts.map +1 -1
  46. package/dist/lib/docs-lint.js +6 -0
  47. package/package.json +1 -1
  48. package/schemas/config.schema.json +4 -0
  49. package/src/commander.ts +2 -2
  50. package/src/commands/completion.ts +62 -9
  51. package/src/commands/{uninstall.ts → deinit.ts} +107 -15
  52. package/src/commands/doctor.ts +47 -9
  53. package/src/commands/init.ts +12 -39
  54. package/src/core/agents/render/session-context.ts +74 -34
  55. package/src/core/agents/rules/claim-conflict.ts +37 -12
  56. package/src/core/agents/state/heartbeat-writer.ts +8 -1
  57. package/src/core/config.ts +21 -0
  58. package/src/core/hooks/cli.ts +4 -8
  59. package/src/core/hooks/guard-path.ts +34 -0
  60. package/src/core/hooks/harness/wiring.ts +185 -0
  61. package/src/lib/completion/bash.ts +36 -0
  62. package/src/lib/completion/fish.ts +22 -0
  63. package/src/lib/completion/index.ts +12 -3
  64. package/src/lib/completion/resolve.ts +210 -0
  65. package/src/lib/completion/zsh.ts +34 -0
  66. package/src/lib/docs-lint.ts +5 -0
  67. package/dist/commands/uninstall.d.ts +0 -22
  68. package/dist/commands/uninstall.d.ts.map +0 -1
@@ -9,7 +9,8 @@
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
11
11
  import { join } from "node:path";
12
- import { resolveBinName } from "../../config.js";
12
+ import { resolveBinName, resolveHooksSetupHint } from "../../config.js";
13
+ import { harneryVersion, loadHarnessWiring } from "../../hooks/harness/wiring.js";
13
14
  /**
14
15
  * Build the combined SessionStart systemMessage. Returns the additionalContext
15
16
  * string (or "" if there's nothing to say).
@@ -38,12 +39,37 @@ export function renderSessionContext(opts) {
38
39
  if (councilMsg)
39
40
  messages.push(councilMsg);
40
41
  }
41
- // 4. Wiring check
42
+ // 4. Commit-guard wiring check
42
43
  const wiringIssues = checkWiring(coordRoot);
43
44
  if (wiringIssues.length > 0) {
44
- 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")}`;
45
+ const hint = resolveHooksSetupHint(coordRoot);
46
+ const fix = hint
47
+ ? `Run \`${hint}\` to install them.`
48
+ : "Wire each repo's pre-commit hook to invoke `agent-coord verdict --rule=commit` (harnery's commit guard).";
49
+ 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")}`;
45
50
  messages.push(wiringSummary);
46
51
  }
52
+ // 5. Harness-hook drift: a harnery upgrade changed the hook set, but this
53
+ // project's settings file hasn't been re-wired. Only fires for a harness the
54
+ // project already opted into (≥1 hook wired), so it never nags a project that
55
+ // simply has a settings file. Remedy is always `<bin> init` (idempotent).
56
+ const drift = loadHarnessWiring(coordRoot);
57
+ if (drift.length > 0) {
58
+ const bin = resolveBinName(coordRoot);
59
+ const ver = harneryVersion();
60
+ const verPart = ver ? ` (harnery ${ver})` : "";
61
+ const lines = drift.map((d) => {
62
+ const bits = [];
63
+ if (d.missing.length > 0)
64
+ bits.push(`missing: ${d.missing.map((m) => m.subcommand).join(", ")}`);
65
+ if (d.orphans.length > 0)
66
+ bits.push(`orphaned: ${d.orphans.join(", ")}`);
67
+ return ` - ${d.settingsFile} — ${bits.join("; ")}`;
68
+ });
69
+ messages.push(`Harnery hook wiring is out of date${verPart}: an upgrade changed the hook set but the harness ` +
70
+ `settings file hasn't been re-wired, so the new hook(s) won't fire. Run \`${bin} init\` to wire them ` +
71
+ `(idempotent, additive).\n${lines.join("\n")}`);
72
+ }
47
73
  return messages.join("\n\n");
48
74
  }
49
75
  /** Read all peer heartbeats from .harnery/active/, excluding self. */
@@ -152,17 +178,23 @@ function fmtAge(secs) {
152
178
  return `${Math.floor(secs / 86400)}d ago`;
153
179
  }
154
180
  /**
155
- * Returns a list of wiring issues (empty when everything's wired). Checks
156
- * parent core.hooksPath + one representative submodule.
181
+ * Returns a list of commit-guard wiring issues (empty when wired). Portable
182
+ * across host projects: it asserts the FUNCTIONAL property ("does this repo's
183
+ * pre-commit invoke harnery's guard?") rather than any path convention. For
184
+ * each repo it resolves the EFFECTIVE git-hooks dir via
185
+ * `git rev-parse --git-path hooks` (which already honors `core.hooksPath`,
186
+ * linked worktrees, and submodule gitdirs) and checks whether the `pre-commit`
187
+ * there calls `agent-coord` / `agent-hook`. Checks the parent repo + one
188
+ * representative submodule (others almost always share the same setup).
189
+ *
190
+ * harnery does not install git hooks itself — each host wires its own
191
+ * pre-commit to invoke the guard — so the remediation command is host-specific
192
+ * and supplied via `hooksSetupHint` in `.harnery/config.jsonc` (see the caller).
157
193
  */
158
194
  export function checkWiring(coordRoot) {
159
- const expected = join(coordRoot, "scripts", "hooks");
160
195
  const issues = [];
161
- // Parent repo
162
- const parentHp = gitConfig(coordRoot, "core.hooksPath");
163
- const parentResolved = resolveHooksPath(coordRoot, parentHp);
164
- if (parentResolved !== expected) {
165
- issues.push(`parent core.hooksPath=${parentHp || "<unset>"} (resolves to ${parentResolved}, expected ${expected})`);
196
+ if (!preCommitInvokesGuard(coordRoot)) {
197
+ issues.push("parent repo: pre-commit hook is missing or doesn't invoke the harnery commit guard");
166
198
  }
167
199
  // One representative submodule
168
200
  const gitmodules = join(coordRoot, ".gitmodules");
@@ -170,37 +202,44 @@ export function checkWiring(coordRoot) {
170
202
  const sampleSub = extractFirstSubmodule(gitmodules);
171
203
  if (sampleSub) {
172
204
  const subPath = join(coordRoot, sampleSub);
173
- const subGitDir = join(subPath, ".git");
174
- if (existsSync(subGitDir)) {
175
- const subHp = gitConfig(subPath, "core.hooksPath");
176
- const subResolved = resolveSubmoduleHooksPath(coordRoot, sampleSub, subHp);
177
- if (subResolved !== expected) {
178
- issues.push(`submodule ${sampleSub} core.hooksPath=${subHp || "<unset>"} (resolves to ${subResolved}; other submodules likely affected too)`);
179
- }
205
+ if (existsSync(join(subPath, ".git")) && !preCommitInvokesGuard(subPath)) {
206
+ issues.push(`submodule ${sampleSub}: pre-commit hook doesn't invoke the harnery commit guard (other submodules likely affected too)`);
180
207
  }
181
208
  }
182
209
  }
183
210
  return issues;
184
211
  }
185
- function gitConfig(cwd, key) {
186
- const result = spawnSync("git", ["-C", cwd, "config", "--get", key], { encoding: "utf8" });
187
- if (result.status !== 0)
188
- return "";
189
- return result.stdout.trim();
190
- }
191
- function resolveHooksPath(root, hp) {
192
- if (!hp)
193
- return join(root, ".git", "hooks");
194
- if (hp.startsWith("/"))
195
- return hp.replace(/\/$/, "");
196
- return join(root, hp).replace(/\/$/, "");
212
+ /**
213
+ * Whether the repo at `repoDir` has a pre-commit hook — at its effective,
214
+ * `core.hooksPath`-aware location — that invokes harnery's commit guard.
215
+ * Fully portable: no assumption about WHERE the host keeps its hooks.
216
+ */
217
+ function preCommitInvokesGuard(repoDir) {
218
+ const hooksDir = gitHooksDir(repoDir);
219
+ if (!hooksDir)
220
+ return false;
221
+ const preCommit = join(hooksDir, "pre-commit");
222
+ if (!existsSync(preCommit))
223
+ return false;
224
+ try {
225
+ return /agent-(coord|hook)\b/.test(readFileSync(preCommit, "utf8"));
226
+ }
227
+ catch {
228
+ return false;
229
+ }
197
230
  }
198
- function resolveSubmoduleHooksPath(root, sub, hp) {
199
- if (!hp)
200
- return join(root, sub, ".git", "hooks");
201
- if (hp.startsWith("/"))
202
- return hp.replace(/\/$/, "");
203
- return join(root, sub, hp).replace(/\/$/, "");
231
+ /** Resolve a repo's effective git-hooks directory (absolute), or null. */
232
+ function gitHooksDir(repoDir) {
233
+ const r = spawnSync("git", ["-C", repoDir, "rev-parse", "--git-path", "hooks"], {
234
+ encoding: "utf8",
235
+ });
236
+ if (r.status !== 0)
237
+ return null;
238
+ const p = r.stdout.trim();
239
+ if (!p)
240
+ return null;
241
+ // `--git-path` prints relative to repoDir (we passed -C); absolutize.
242
+ return p.startsWith("/") ? p : join(repoDir, p);
204
243
  }
205
244
  function extractFirstSubmodule(gitmodulesPath) {
206
245
  try {
@@ -1 +1 @@
1
- {"version":3,"file":"claim-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/claim-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAQH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAeF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,GAAG,aAAa,CAmEjF"}
1
+ {"version":3,"file":"claim-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/claim-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAQH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAeF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,GAAG,aAAa,CAyFjF"}
@@ -62,15 +62,38 @@ export function evaluateClaim(coordRoot, req) {
62
62
  // single-agent flow can't deadlock with itself, and the rule otherwise
63
63
  // forces release-and-reacquire cycles on every reverse-order edit pair.
64
64
  const hasFreshPeers = otherPeers.some((p) => isFresh(p.last_heartbeat) && p.files_touched.length > 0);
65
- if (hasFreshPeers && myPeer && myPeer.files_touched.length > 0) {
66
- const highest = [...myPeer.files_touched].sort().at(-1);
67
- if (req.path < highest) {
68
- return {
69
- allow: false,
70
- exit_code: 2,
71
- rule: "claim.ordering_violation",
72
- reason: `Cannot acquire ${req.path} while holding ${highest} (claim ordering rule: paths must be acquired in sorted order). Release the higher claim first.`,
73
- };
65
+ // Re-editing a path already in our own files_touched acquires no new lock
66
+ // edge, so it can't create a circular wait — the ordering rule must not block
67
+ // it. Without this exemption, an agent that edits a higher-sorting file and
68
+ // then makes a second pass over an already-held lower-sorting file gets a
69
+ // spurious ordering_violation (the dominant friction source under concurrency:
70
+ // both agent-Gibson holding README.md and agent-Ophelia holding AGENTS.md were
71
+ // blocked re-editing those held files after touching a higher path, 2026-07-03).
72
+ const alreadyHeld = myPeer?.files_touched.includes(req.path) ?? false;
73
+ if (hasFreshPeers && myPeer && myPeer.files_touched.length > 0 && !alreadyHeld) {
74
+ // Only ACTIVE (uncommitted) edits should constrain lock ordering. A claim on
75
+ // a committed-clean file is a finished edit, not a held lock, so it must not
76
+ // wall off a lower-sorted acquisition. Without this, a long session
77
+ // accumulates committed claims that block every earlier-sorted path — pure
78
+ // friction, no deadlock risk (the file isn't being touched). Mirrors the
79
+ // peer stale-claim self-heal above. The git probes run only on the
80
+ // would-block path (claims sorting after req.path), staying off the hot path.
81
+ const blockers = myPeer.files_touched.filter((p) => req.path < p);
82
+ if (blockers.length > 0) {
83
+ const activeBlockers = blockers.filter((p) => !isFileCommittedClean(coordRoot, p));
84
+ if (activeBlockers.length > 0) {
85
+ const highest = [...activeBlockers].sort().at(-1);
86
+ return {
87
+ allow: false,
88
+ exit_code: 2,
89
+ rule: "claim.ordering_violation",
90
+ reason: `Cannot acquire ${req.path}: you already hold ${highest}, which sorts after it (claim ordering rule: acquire paths in sorted order to prevent deadlock between concurrent agents). Fix by editing in sorted order, or by committing ${highest} first, since a committed-clean file no longer blocks and is auto-pruned.`,
91
+ };
92
+ }
93
+ // Every blocker is a finished (committed-clean) edit: prune them so they
94
+ // stop constraining future acquisitions, then fall through to allow.
95
+ for (const p of blockers)
96
+ pruneClaimFromPeer(coordRoot, req.instance_id, p);
74
97
  }
75
98
  }
76
99
  // Acquire the claim: atomic check-and-set. Adds req.path to my
@@ -197,18 +220,21 @@ function isFresh(lastHeartbeat) {
197
220
  * - diff shows non-empty output (genuinely dirty)
198
221
  */
199
222
  function isFileCommittedClean(coordRoot, relPath) {
200
- const abs = join(coordRoot, relPath);
223
+ // Tolerate either path form: files_touched can hold absolute-under-coordRoot
224
+ // entries (legacy file-tracking) or canonical monorepo-relative ones.
225
+ const rel = relPath.startsWith(`${coordRoot}/`) ? relPath.slice(coordRoot.length + 1) : relPath;
226
+ const abs = join(coordRoot, rel);
201
227
  if (!existsSync(abs))
202
228
  return false;
203
229
  try {
204
- const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--", relPath], {
230
+ const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--", rel], {
205
231
  cwd: coordRoot,
206
232
  encoding: "utf8",
207
233
  timeout: 2000,
208
234
  });
209
235
  if (tracked.status !== 0)
210
236
  return false;
211
- const result = spawnSync("git", ["diff", "--quiet", "HEAD", "--", relPath], {
237
+ const result = spawnSync("git", ["diff", "--quiet", "HEAD", "--", rel], {
212
238
  cwd: coordRoot,
213
239
  encoding: "utf8",
214
240
  timeout: 2000,
@@ -1 +1 @@
1
- {"version":3,"file":"heartbeat-writer.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/heartbeat-writer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA6DH,MAAM,WAAW,SAAS;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAiBD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAQrF;AAeD,wBAAgB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAS7F;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAKxF;AAED,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GACd,SAAS,GAAG,IAAI,CAMlB;AAED,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,GACX,SAAS,GAAG,IAAI,CAKlB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CA4BnF;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAS5E;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAuBnF;AAED,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,EAClB,KAAK,CAAC,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,MAAM,GACf,SAAS,GAAG,IAAI,CAqDlB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACb,SAAS,GAAG,IAAI,CAOlB"}
1
+ {"version":3,"file":"heartbeat-writer.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/heartbeat-writer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA6DH,MAAM,WAAW,SAAS;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAiBD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAQrF;AAeD,wBAAgB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAS7F;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAKxF;AAED,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GACd,SAAS,GAAG,IAAI,CAMlB;AAED,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,GACX,SAAS,GAAG,IAAI,CAYlB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CA4BnF;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAS5E;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAuBnF;AAED,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,EAClB,KAAK,CAAC,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,MAAM,GACf,SAAS,GAAG,IAAI,CAqDlB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACb,SAAS,GAAG,IAAI,CAOlB"}
@@ -114,9 +114,15 @@ export function setTurnSummary(coordRoot, instanceId, summary) {
114
114
  }));
115
115
  }
116
116
  export function releaseClaim(coordRoot, instanceId, path) {
117
+ // files_touched can hold either absolute-under-coordRoot or canonical
118
+ // monorepo-relative entries; normalize both sides so release matches
119
+ // regardless of the form the caller passes (the old exact-string filter
120
+ // silently no-op'd on a form mismatch).
121
+ const norm = (p) => p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
122
+ const target = norm(path);
117
123
  return mutate(coordRoot, instanceId, (hb) => ({
118
124
  ...hb,
119
- files_touched: (hb.files_touched ?? []).filter((p) => p !== path),
125
+ files_touched: (hb.files_touched ?? []).filter((p) => norm(p) !== target),
120
126
  }));
121
127
  }
122
128
  /**
@@ -26,4 +26,11 @@ export declare function stripJsonComments(input: string): string;
26
26
  * `coordRoot` is resolved via `findCoordRoot()` when not passed.
27
27
  */
28
28
  export declare function resolveBinName(coordRoot?: string | null): string;
29
+ /**
30
+ * The host's git-hook (re)install command, for the "commit guard not wired"
31
+ * nudge. Returns the configured `hooksSetupHint` (e.g. "scripts/setup-hooks.sh")
32
+ * or null when unset — callers fall back to a generic, host-agnostic message.
33
+ * `coordRoot` is resolved via `findCoordRoot()` when not passed.
34
+ */
35
+ export declare function resolveHooksSetupHint(coordRoot?: string | null): string | null;
29
36
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/core/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAOH,oFAAoF;AACpF,eAAO,MAAM,gBAAgB,SAAS,CAAC;AAQvC,8FAA8F;AAC9F,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAqCvD;AAyBD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAShE"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/core/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAOH,oFAAoF;AACpF,eAAO,MAAM,gBAAgB,SAAS,CAAC;AAgBvC,8FAA8F;AAC9F,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAqCvD;AAyBD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAShE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAK9E"}
@@ -106,3 +106,16 @@ export function resolveBinName(coordRoot) {
106
106
  }
107
107
  return DEFAULT_BIN_NAME;
108
108
  }
109
+ /**
110
+ * The host's git-hook (re)install command, for the "commit guard not wired"
111
+ * nudge. Returns the configured `hooksSetupHint` (e.g. "scripts/setup-hooks.sh")
112
+ * or null when unset — callers fall back to a generic, host-agnostic message.
113
+ * `coordRoot` is resolved via `findCoordRoot()` when not passed.
114
+ */
115
+ export function resolveHooksSetupHint(coordRoot) {
116
+ const root = coordRoot ?? findCoordRoot();
117
+ if (!root)
118
+ return null;
119
+ const hint = readConfig(root).hooksSetupHint;
120
+ return typeof hint === "string" && hint.trim() ? hint.trim() : null;
121
+ }
@@ -28,6 +28,7 @@ import { projectHeartbeats } from "../agents/state/heartbeat-projector.js";
28
28
  import { shellMutationPaths } from "../agents/state/shell-mutation.js";
29
29
  import { captureImages, detectPresence, imageJanitor, playSound, resetSoundCounters, runTurnSummary, scratchArchive, scratchJanitor, scratchRecoveryCue, soundForEvent, syncClaudeSessions, } from "./effects/index.js";
30
30
  import { emit } from "./events/emit.js";
31
+ import { canonicalize } from "./guard-path.js";
31
32
  import { detectHarness } from "./harness/detect.js";
32
33
  import { extractBashCommand, extractToolDescription, normalizeEventName, parsePayload, } from "./harness/parse.js";
33
34
  import { parsePsChainLine, selectAnchorPid } from "./resolve/anchor.js";
@@ -688,7 +689,9 @@ async function main() {
688
689
  }
689
690
  async function runPreToolUseGuard(coordRoot, instanceId, sessionId, data, harness) {
690
691
  const toolName = data.tool_name ?? "";
691
- const targets = collectGuardTargets(toolName, data).map((p) => canonicalize(coordRoot, p));
692
+ const targets = collectGuardTargets(toolName, data)
693
+ .map((p) => canonicalize(coordRoot, p))
694
+ .filter((p) => p !== null);
692
695
  if (targets.length === 0)
693
696
  return;
694
697
  const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
@@ -737,15 +740,6 @@ async function runPreToolUseGuard(coordRoot, instanceId, sessionId, data, harnes
737
740
  /** Canonicalize a path to monorepo-relative form. Absolute paths under
738
741
  * coordRoot get the prefix stripped; relative paths pass through (assumed
739
742
  * already canonical). */
740
- function canonicalize(coordRoot, p) {
741
- if (!p)
742
- return p;
743
- if (p.startsWith(`${coordRoot}/`))
744
- return p.slice(coordRoot.length + 1);
745
- if (p === coordRoot)
746
- return ".";
747
- return p;
748
- }
749
743
  /** Pull the candidate path(s) out of a write-tool payload. Empty array when
750
744
  * the tool isn't a write or no path could be derived. */
751
745
  function collectGuardTargets(toolName, data) {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Canonicalize a write-tool target path for the claim guard.
3
+ *
4
+ * Returns the monorepo-relative path, or `null` when the target lies OUTSIDE the
5
+ * repo (an absolute path not under coordRoot, e.g. a `/tmp` scratchpad or other
6
+ * session-temp file).
7
+ *
8
+ * The claim system is intentionally repo-scoped: it coordinates monorepo files,
9
+ * not arbitrary absolute paths, so the guard skips out-of-repo targets. Skipping
10
+ * them is right on two counts. First, it keeps non-repo paths out of a
11
+ * heartbeat's `files_touched`. Second, the ordering rule compares raw path
12
+ * strings, and an absolute `/tmp/…` sorts before every repo-relative path
13
+ * (`/` = 0x2F < any letter), so without this a scratchpad write would spuriously
14
+ * "block" a legitimately-held repo file. Returning null keeps such paths out of
15
+ * the claim system entirely.
16
+ *
17
+ * Accepted tradeoff: this also means shared out-of-repo files (a user-level
18
+ * memory or plans directory) are not coordinated across agents. The alternative,
19
+ * normalizing every path to one consistent key so those stay coordinated, was
20
+ * rejected as gold-plating a rare, merge-disciplined race in a deadlock-critical
21
+ * path. Coordinate shared state by keeping it in the repo, not out of it.
22
+ *
23
+ * Relative inputs are assumed already-repo-relative (Codex `apply_patch` emits
24
+ * cwd-relative paths). The in-repo check requires the `<root>/` separator, so a
25
+ * sibling dir that merely shares a prefix (`/repo-other` vs `/repo`) is treated
26
+ * as out-of-repo, not stripped.
27
+ */
28
+ export declare function canonicalize(coordRoot: string, p: string): string | null;
29
+ //# sourceMappingURL=guard-path.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard-path.d.ts","sourceRoot":"","sources":["../../../src/core/hooks/guard-path.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAMxE"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Canonicalize a write-tool target path for the claim guard.
3
+ *
4
+ * Returns the monorepo-relative path, or `null` when the target lies OUTSIDE the
5
+ * repo (an absolute path not under coordRoot, e.g. a `/tmp` scratchpad or other
6
+ * session-temp file).
7
+ *
8
+ * The claim system is intentionally repo-scoped: it coordinates monorepo files,
9
+ * not arbitrary absolute paths, so the guard skips out-of-repo targets. Skipping
10
+ * them is right on two counts. First, it keeps non-repo paths out of a
11
+ * heartbeat's `files_touched`. Second, the ordering rule compares raw path
12
+ * strings, and an absolute `/tmp/…` sorts before every repo-relative path
13
+ * (`/` = 0x2F < any letter), so without this a scratchpad write would spuriously
14
+ * "block" a legitimately-held repo file. Returning null keeps such paths out of
15
+ * the claim system entirely.
16
+ *
17
+ * Accepted tradeoff: this also means shared out-of-repo files (a user-level
18
+ * memory or plans directory) are not coordinated across agents. The alternative,
19
+ * normalizing every path to one consistent key so those stay coordinated, was
20
+ * rejected as gold-plating a rare, merge-disciplined race in a deadlock-critical
21
+ * path. Coordinate shared state by keeping it in the repo, not out of it.
22
+ *
23
+ * Relative inputs are assumed already-repo-relative (Codex `apply_patch` emits
24
+ * cwd-relative paths). The in-repo check requires the `<root>/` separator, so a
25
+ * sibling dir that merely shares a prefix (`/repo-other` vs `/repo`) is treated
26
+ * as out-of-repo, not stripped.
27
+ */
28
+ export function canonicalize(coordRoot, p) {
29
+ if (!p)
30
+ return null;
31
+ if (p === coordRoot)
32
+ return ".";
33
+ if (p.startsWith(`${coordRoot}/`))
34
+ return p.slice(coordRoot.length + 1);
35
+ if (p.startsWith("/"))
36
+ return null; // absolute + not under coordRoot → out-of-repo
37
+ return p; // relative → treat as repo-relative
38
+ }
@@ -0,0 +1,85 @@
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
+ import { type HarnessId, type HarnessSpec, type HookEntryShape, type HookEvent } from "./events.js";
14
+ /** Claude Code + Codex entry: `{ hooks: [{ type, command }] }`. */
15
+ export interface ClaudeHookGroup {
16
+ matcher?: string;
17
+ hooks: {
18
+ type: string;
19
+ command: string;
20
+ }[];
21
+ }
22
+ /** Cursor entry: a flat `{ command }`. */
23
+ export interface CursorHookGroup {
24
+ command: string;
25
+ type?: string;
26
+ matcher?: string;
27
+ }
28
+ export type HookGroup = ClaudeHookGroup | CursorHookGroup;
29
+ export interface SettingsFile {
30
+ version?: number;
31
+ hooks?: Record<string, HookGroup[]>;
32
+ [k: string]: unknown;
33
+ }
34
+ /** Build a hook entry in the harness's shape. */
35
+ export declare function makeEntry(shape: HookEntryShape, command: string): HookGroup;
36
+ /** Pull every command string out of a hook entry, regardless of shape. */
37
+ export declare function groupCommands(group: HookGroup): string[];
38
+ /**
39
+ * Whether a hook command string wires the given agent-hook subcommand. The
40
+ * trailing space is load-bearing: it keeps `stop` from matching `stop-failure`.
41
+ */
42
+ export declare function commandWiresSubcommand(command: string, subcommand: string): boolean;
43
+ export interface WiringDiff {
44
+ /** Spec events not wired in the settings file. */
45
+ missing: HookEvent[];
46
+ /** Spec events already wired. */
47
+ present: HookEvent[];
48
+ /**
49
+ * agent-hook subcommands wired in the file that are NOT in the current spec
50
+ * (e.g. an event renamed/removed by an upgrade). Additive re-init won't clean
51
+ * these — they need explicit removal — so they're surfaced separately.
52
+ */
53
+ orphans: string[];
54
+ }
55
+ /**
56
+ * Pure diff of one settings object against one harness spec. Read-only inverse
57
+ * of `wireHooks`; no fs, so it's unit-testable.
58
+ */
59
+ export declare function diffWiring(settings: SettingsFile, spec: HarnessSpec): WiringDiff;
60
+ export interface HarnessWiringStatus {
61
+ harness: HarnessId;
62
+ /** Settings file path, relative to the project root. */
63
+ settingsFile: string;
64
+ missing: HookEvent[];
65
+ orphans: string[];
66
+ }
67
+ /**
68
+ * Inspect every harness whose settings file exists under `projectRoot` and
69
+ * return only those with *drift*. Read-only; never writes.
70
+ *
71
+ * Drift is reported only for a harness the project has **already opted into** —
72
+ * i.e. at least one harnery hook is already wired. A settings file with zero
73
+ * harnery hooks just means this harness isn't harnery-wired here (a bare
74
+ * `.claude/settings.json` is a generic Claude Code file); that's `harn init`'s
75
+ * job to surface on first run, not drift to nag about every session. A harness
76
+ * with no settings file at all, or an unparseable one, is skipped.
77
+ */
78
+ export declare function loadHarnessWiring(projectRoot: string): HarnessWiringStatus[];
79
+ /**
80
+ * Resolve the harnery package version for context in nudges/checks. Walks up
81
+ * from this module to the package root (works under Bun from `src/` and Node
82
+ * from `dist/`). Returns "" if unresolved — callers omit it from the message.
83
+ */
84
+ export declare function harneryVersion(): string;
85
+ //# sourceMappingURL=wiring.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wiring.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/harness/wiring.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AAErB,mEAAmE;AACnE,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC5C;AACD,0CAA0C;AAC1C,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AACD,MAAM,MAAM,SAAS,GAAG,eAAe,GAAG,eAAe,CAAC;AAE1D,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;IACpC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AAED,iDAAiD;AACjD,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,CAE3E;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,EAAE,CAMxD;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAEnF;AAQD,MAAM,WAAW,UAAU;IACzB,kDAAkD;IAClD,OAAO,EAAE,SAAS,EAAE,CAAC;IACrB,iCAAiC;IACjC,OAAO,EAAE,SAAS,EAAE,CAAC;IACrB;;;;OAIG;IACH,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,QAAQ,EAAE,YAAY,EAAE,IAAI,EAAE,WAAW,GAAG,UAAU,CA0BhF;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,SAAS,CAAC;IACnB,wDAAwD;IACxD,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,SAAS,EAAE,CAAC;IACrB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,mBAAmB,EAAE,CAwB5E;AAED;;;;GAIG;AACH,wBAAgB,cAAc,IAAI,MAAM,CAkBvC"}
@@ -0,0 +1,137 @@
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
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { HARNESS_SPECS, } from "./events.js";
17
+ /** Build a hook entry in the harness's shape. */
18
+ export function makeEntry(shape, command) {
19
+ return shape === "cursor" ? { command } : { hooks: [{ type: "command", command }] };
20
+ }
21
+ /** Pull every command string out of a hook entry, regardless of shape. */
22
+ export function groupCommands(group) {
23
+ if ("command" in group && typeof group.command === "string")
24
+ return [group.command];
25
+ if ("hooks" in group && Array.isArray(group.hooks)) {
26
+ return group.hooks.map((h) => h.command).filter((c) => typeof c === "string");
27
+ }
28
+ return [];
29
+ }
30
+ /**
31
+ * Whether a hook command string wires the given agent-hook subcommand. The
32
+ * trailing space is load-bearing: it keeps `stop` from matching `stop-failure`.
33
+ */
34
+ export function commandWiresSubcommand(command, subcommand) {
35
+ return command.includes(`agent-hook ${subcommand} `);
36
+ }
37
+ /** Pull the agent-hook subcommand out of a command string, or null if none. */
38
+ function commandSubcommand(command) {
39
+ const m = command.match(/agent-hook\s+([a-z][a-z-]*)\s/);
40
+ return m ? m[1] : null;
41
+ }
42
+ /**
43
+ * Pure diff of one settings object against one harness spec. Read-only inverse
44
+ * of `wireHooks`; no fs, so it's unit-testable.
45
+ */
46
+ export function diffWiring(settings, spec) {
47
+ const missing = [];
48
+ const present = [];
49
+ const hooks = settings.hooks ?? {};
50
+ for (const event of spec.events) {
51
+ const groups = hooks[event.settingsKey] ?? [];
52
+ const wired = groups.some((g) => groupCommands(g).some((c) => commandWiresSubcommand(c, event.subcommand)));
53
+ (wired ? present : missing).push(event);
54
+ }
55
+ const specSubcommands = new Set(spec.events.map((e) => e.subcommand));
56
+ const orphans = new Set();
57
+ for (const groups of Object.values(hooks)) {
58
+ if (!Array.isArray(groups))
59
+ continue;
60
+ for (const g of groups) {
61
+ for (const c of groupCommands(g)) {
62
+ const sub = commandSubcommand(c);
63
+ if (sub && !specSubcommands.has(sub))
64
+ orphans.add(sub);
65
+ }
66
+ }
67
+ }
68
+ return { missing, present, orphans: [...orphans].sort() };
69
+ }
70
+ /**
71
+ * Inspect every harness whose settings file exists under `projectRoot` and
72
+ * return only those with *drift*. Read-only; never writes.
73
+ *
74
+ * Drift is reported only for a harness the project has **already opted into** —
75
+ * i.e. at least one harnery hook is already wired. A settings file with zero
76
+ * harnery hooks just means this harness isn't harnery-wired here (a bare
77
+ * `.claude/settings.json` is a generic Claude Code file); that's `harn init`'s
78
+ * job to surface on first run, not drift to nag about every session. A harness
79
+ * with no settings file at all, or an unparseable one, is skipped.
80
+ */
81
+ export function loadHarnessWiring(projectRoot) {
82
+ const out = [];
83
+ for (const [id, spec] of Object.entries(HARNESS_SPECS)) {
84
+ const settingsPath = resolve(projectRoot, spec.settingsFile);
85
+ if (!existsSync(settingsPath))
86
+ continue;
87
+ let settings;
88
+ try {
89
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
90
+ }
91
+ catch {
92
+ // Unparseable settings file: can't tell opt-in from noise, and the
93
+ // harness itself will complain about its own malformed config. Skip.
94
+ continue;
95
+ }
96
+ const diff = diffWiring(settings, spec);
97
+ if (diff.present.length === 0)
98
+ continue; // not opted in → not drift
99
+ if (diff.missing.length === 0 && diff.orphans.length === 0)
100
+ continue; // current
101
+ out.push({
102
+ harness: id,
103
+ settingsFile: spec.settingsFile,
104
+ missing: diff.missing,
105
+ orphans: diff.orphans,
106
+ });
107
+ }
108
+ return out;
109
+ }
110
+ /**
111
+ * Resolve the harnery package version for context in nudges/checks. Walks up
112
+ * from this module to the package root (works under Bun from `src/` and Node
113
+ * from `dist/`). Returns "" if unresolved — callers omit it from the message.
114
+ */
115
+ export function harneryVersion() {
116
+ try {
117
+ let dir = dirname(fileURLToPath(import.meta.url));
118
+ for (let i = 0; i < 8; i++) {
119
+ try {
120
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
121
+ if (pkg.name === "harnery" && typeof pkg.version === "string")
122
+ return pkg.version;
123
+ }
124
+ catch {
125
+ /* no package.json here, or not ours; keep walking up */
126
+ }
127
+ const parent = dirname(dir);
128
+ if (parent === dir)
129
+ break;
130
+ dir = parent;
131
+ }
132
+ }
133
+ catch {
134
+ /* import.meta.url unavailable or fs error */
135
+ }
136
+ return "";
137
+ }