harnery 0.3.2 → 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 (36) hide show
  1. package/README.md +20 -7
  2. package/dist/commander.js +2 -2
  3. package/dist/commands/deinit.d.ts +51 -0
  4. package/dist/commands/deinit.d.ts.map +1 -0
  5. package/dist/commands/{uninstall.js → deinit.js} +83 -14
  6. package/dist/commands/doctor.d.ts.map +1 -1
  7. package/dist/commands/doctor.js +47 -9
  8. package/dist/commands/init.d.ts +2 -21
  9. package/dist/commands/init.d.ts.map +1 -1
  10. package/dist/commands/init.js +3 -15
  11. package/dist/core/agents/render/session-context.d.ts +12 -2
  12. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  13. package/dist/core/agents/render/session-context.js +75 -36
  14. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  15. package/dist/core/agents/rules/claim-conflict.js +29 -11
  16. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  17. package/dist/core/agents/state/heartbeat-writer.js +7 -1
  18. package/dist/core/config.d.ts +7 -0
  19. package/dist/core/config.d.ts.map +1 -1
  20. package/dist/core/config.js +13 -0
  21. package/dist/core/hooks/harness/wiring.d.ts +85 -0
  22. package/dist/core/hooks/harness/wiring.d.ts.map +1 -0
  23. package/dist/core/hooks/harness/wiring.js +137 -0
  24. package/package.json +1 -1
  25. package/schemas/config.schema.json +4 -0
  26. package/src/commander.ts +2 -2
  27. package/src/commands/{uninstall.ts → deinit.ts} +107 -15
  28. package/src/commands/doctor.ts +47 -9
  29. package/src/commands/init.ts +12 -39
  30. package/src/core/agents/render/session-context.ts +74 -34
  31. package/src/core/agents/rules/claim-conflict.ts +28 -11
  32. package/src/core/agents/state/heartbeat-writer.ts +8 -1
  33. package/src/core/config.ts +21 -0
  34. package/src/core/hooks/harness/wiring.ts +185 -0
  35. package/dist/commands/uninstall.d.ts +0 -22
  36. 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,CAiFjF"}
@@ -63,14 +63,29 @@ export function evaluateClaim(coordRoot, req) {
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
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
- };
66
+ // Only ACTIVE (uncommitted) edits should constrain lock ordering. A claim on
67
+ // a committed-clean file is a finished edit, not a held lock, so it must not
68
+ // wall off a lower-sorted acquisition. Without this, a long session
69
+ // accumulates committed claims that block every earlier-sorted path — pure
70
+ // friction, no deadlock risk (the file isn't being touched). Mirrors the
71
+ // peer stale-claim self-heal above. The git probes run only on the
72
+ // would-block path (claims sorting after req.path), staying off the hot path.
73
+ const blockers = myPeer.files_touched.filter((p) => req.path < p);
74
+ if (blockers.length > 0) {
75
+ const activeBlockers = blockers.filter((p) => !isFileCommittedClean(coordRoot, p));
76
+ if (activeBlockers.length > 0) {
77
+ const highest = [...activeBlockers].sort().at(-1);
78
+ return {
79
+ allow: false,
80
+ exit_code: 2,
81
+ rule: "claim.ordering_violation",
82
+ reason: `Cannot acquire ${req.path} while holding ${highest} (claim ordering rule: paths must be acquired in sorted order). Release the higher claim first.`,
83
+ };
84
+ }
85
+ // Every blocker is a finished (committed-clean) edit: prune them so they
86
+ // stop constraining future acquisitions, then fall through to allow.
87
+ for (const p of blockers)
88
+ pruneClaimFromPeer(coordRoot, req.instance_id, p);
74
89
  }
75
90
  }
76
91
  // Acquire the claim: atomic check-and-set. Adds req.path to my
@@ -197,18 +212,21 @@ function isFresh(lastHeartbeat) {
197
212
  * - diff shows non-empty output (genuinely dirty)
198
213
  */
199
214
  function isFileCommittedClean(coordRoot, relPath) {
200
- const abs = join(coordRoot, relPath);
215
+ // Tolerate either path form: files_touched can hold absolute-under-coordRoot
216
+ // entries (legacy file-tracking) or canonical monorepo-relative ones.
217
+ const rel = relPath.startsWith(`${coordRoot}/`) ? relPath.slice(coordRoot.length + 1) : relPath;
218
+ const abs = join(coordRoot, rel);
201
219
  if (!existsSync(abs))
202
220
  return false;
203
221
  try {
204
- const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--", relPath], {
222
+ const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--", rel], {
205
223
  cwd: coordRoot,
206
224
  encoding: "utf8",
207
225
  timeout: 2000,
208
226
  });
209
227
  if (tracked.status !== 0)
210
228
  return false;
211
- const result = spawnSync("git", ["diff", "--quiet", "HEAD", "--", relPath], {
229
+ const result = spawnSync("git", ["diff", "--quiet", "HEAD", "--", rel], {
212
230
  cwd: coordRoot,
213
231
  encoding: "utf8",
214
232
  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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.3.2",
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);