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
@@ -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
+ }
@@ -30,7 +30,7 @@ import { captureImages, detectPresence, imageJanitor, playSound, resetSoundCount
30
30
  import { emit } from "./events/emit.js";
31
31
  import { detectHarness } from "./harness/detect.js";
32
32
  import { extractBashCommand, extractToolDescription, normalizeEventName, parsePayload, } from "./harness/parse.js";
33
- import { selectAnchorPid } from "./resolve/anchor.js";
33
+ import { parsePsChainLine, selectAnchorPid } from "./resolve/anchor.js";
34
34
  import { findCoordRoot } from "./resolve/coord-root.js";
35
35
  import { extractIntentComment, resolveIntent } from "./resolve/intent.js";
36
36
  import { resolveOwner } from "./resolve/owner.js";
@@ -820,10 +820,10 @@ function healHeartbeatViaCli(coordRoot, instanceId, sessionId, harness) {
820
820
  * the next tool call rather than going invisible until SessionStart fires
821
821
  * again, which it may never do.
822
822
  *
823
- * Returns undefined on macOS (no /proc) or when no anchor is found; callers
824
- * fall back to `process.ppid` (the bash wrapper's parent, which is usually
825
- * the harness binary itself). `HARNERY_AGENT_COORD_TEST_ANCHOR_PID` overrides
826
- * everything so the test sandbox can pin a deterministic PID.
823
+ * Returns undefined only when no anchor is found; callers fall back to
824
+ * `process.ppid` (the bash wrapper's parent, which is usually the harness binary
825
+ * itself). `HARNERY_AGENT_COORD_TEST_ANCHOR_PID` overrides everything so the
826
+ * test sandbox can pin a deterministic PID.
827
827
  */
828
828
  function findHarnessAnchorPid(harness) {
829
829
  const override = coordEnv("AGENT_COORD_TEST_ANCHOR_PID");
@@ -832,30 +832,35 @@ function findHarnessAnchorPid(harness) {
832
832
  if (Number.isFinite(n) && n > 0)
833
833
  return n;
834
834
  }
835
- // Build the ppid chain (nearest → root, up to 20 hops) from /proc, then hand
836
- // it to the pure selector. Splitting the /proc walk (untestable off a live
837
- // box) from the comm-matching (unit-tested against the real Phase 0 chains in
838
- // resolve/anchor.ts) keeps the cursor `node`-fallback logic verifiable.
835
+ // Build the ppid chain (nearest → root, up to 20 hops), then hand it to the
836
+ // pure selector. Linux/WSL reads /proc; macOS/BSD (no /proc) falls back to
837
+ // `ps -o ppid=,comm=` parsed by the unit-tested `parsePsChainLine`. Splitting
838
+ // the walk (untestable off a live box) from the comm-matching keeps the
839
+ // cursor `node`-fallback logic verifiable.
839
840
  const chain = [];
840
841
  let pid = process.pid;
841
842
  for (let hops = 0; hops < 20; hops++) {
842
- let comm;
843
- let status;
843
+ let hop = null;
844
844
  try {
845
- comm = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
846
- status = readFileSync(`/proc/${pid}/status`, "utf8");
845
+ const comm = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
846
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
847
+ const m = status.match(/^PPid:\s+(\d+)/m);
848
+ hop = { comm, ppid: m ? Number(m[1]) : 0 };
847
849
  }
848
850
  catch {
849
- break;
851
+ // no /proc (macOS/BSD) — fall through to ps
852
+ }
853
+ if (!hop) {
854
+ const out = spawnSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { encoding: "utf8" });
855
+ if (out.status === 0)
856
+ hop = parsePsChainLine(out.stdout);
850
857
  }
851
- chain.push({ pid, comm });
852
- const m = status.match(/^PPid:\s+(\d+)/m);
853
- if (!m)
858
+ if (!hop)
854
859
  break;
855
- const ppid = Number(m[1]);
856
- if (!Number.isFinite(ppid) || ppid === 0 || ppid === 1)
860
+ chain.push({ pid, comm: hop.comm });
861
+ if (!Number.isFinite(hop.ppid) || hop.ppid === 0 || hop.ppid === 1)
857
862
  break;
858
- pid = ppid;
863
+ pid = hop.ppid;
859
864
  }
860
865
  return selectAnchorPid(chain, harness);
861
866
  }
@@ -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
+ }
@@ -34,4 +34,19 @@ export declare function selectAnchorPid(chain: ReadonlyArray<{
34
34
  pid: number;
35
35
  comm: string;
36
36
  }>, harness?: string): number | undefined;
37
+ /**
38
+ * Parse one `ps -o ppid=,comm= -p <pid>` line into the same `{ ppid, comm }`
39
+ * shape the `/proc` fast path produces, for the macOS/BSD branch of the anchor
40
+ * walk. `ps` prints `comm` as a full executable path (and some Apple helper
41
+ * names contain spaces, e.g. `Code Helper (Plugin)`), so the comm is reduced to
42
+ * its basename to match the harness comm tokens the way Linux's `/proc/<pid>/comm`
43
+ * basename does. Returns null when the line has no leading numeric ppid.
44
+ *
45
+ * Pure (no I/O) so the parsing — the error-prone part — is unit-testable
46
+ * without a live process tree; the caller owns the `ps` spawn.
47
+ */
48
+ export declare function parsePsChainLine(line: string): {
49
+ ppid: number;
50
+ comm: string;
51
+ } | null;
37
52
  //# sourceMappingURL=anchor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"anchor.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/anchor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAMH;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,aAAa,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EACnD,OAAO,CAAC,EAAE,MAAM,GACf,MAAM,GAAG,SAAS,CAUpB"}
1
+ {"version":3,"file":"anchor.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/anchor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAMH;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,aAAa,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EACnD,OAAO,CAAC,EAAE,MAAM,GACf,MAAM,GAAG,SAAS,CAUpB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAQpF"}
@@ -46,3 +46,25 @@ export function selectAnchorPid(chain, harness) {
46
46
  }
47
47
  return undefined;
48
48
  }
49
+ /**
50
+ * Parse one `ps -o ppid=,comm= -p <pid>` line into the same `{ ppid, comm }`
51
+ * shape the `/proc` fast path produces, for the macOS/BSD branch of the anchor
52
+ * walk. `ps` prints `comm` as a full executable path (and some Apple helper
53
+ * names contain spaces, e.g. `Code Helper (Plugin)`), so the comm is reduced to
54
+ * its basename to match the harness comm tokens the way Linux's `/proc/<pid>/comm`
55
+ * basename does. Returns null when the line has no leading numeric ppid.
56
+ *
57
+ * Pure (no I/O) so the parsing — the error-prone part — is unit-testable
58
+ * without a live process tree; the caller owns the `ps` spawn.
59
+ */
60
+ export function parsePsChainLine(line) {
61
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
62
+ if (!m)
63
+ return null;
64
+ const ppid = Number.parseInt(m[1], 10);
65
+ if (!Number.isFinite(ppid))
66
+ return null;
67
+ const commPath = m[2].trim();
68
+ const comm = commPath.split("/").pop() || commPath;
69
+ return { ppid, comm };
70
+ }