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.
- package/README.md +20 -7
- package/dist/commander.js +2 -2
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +14 -1
- package/dist/commands/deinit.d.ts +51 -0
- package/dist/commands/deinit.d.ts.map +1 -0
- package/dist/commands/{uninstall.js → deinit.js} +83 -14
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +47 -9
- package/dist/commands/init.d.ts +2 -21
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +3 -15
- package/dist/core/agents/coord-client.js +20 -5
- package/dist/core/agents/render/session-context.d.ts +12 -2
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +75 -36
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +29 -11
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +7 -1
- package/dist/core/config.d.ts +7 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +13 -0
- package/dist/core/hooks/cli.js +25 -20
- package/dist/core/hooks/harness/wiring.d.ts +85 -0
- package/dist/core/hooks/harness/wiring.d.ts.map +1 -0
- package/dist/core/hooks/harness/wiring.js +137 -0
- package/dist/core/hooks/resolve/anchor.d.ts +15 -0
- package/dist/core/hooks/resolve/anchor.d.ts.map +1 -1
- package/dist/core/hooks/resolve/anchor.js +22 -0
- package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.js +20 -8
- package/package.json +1 -1
- package/schemas/config.schema.json +4 -0
- package/src/commander.ts +2 -2
- package/src/commands/agents.ts +14 -1
- package/src/commands/{uninstall.ts → deinit.ts} +107 -15
- package/src/commands/doctor.ts +47 -9
- package/src/commands/init.ts +12 -39
- package/src/core/agents/coord-client.ts +17 -4
- package/src/core/agents/render/session-context.ts +74 -34
- package/src/core/agents/rules/claim-conflict.ts +28 -11
- package/src/core/agents/state/heartbeat-writer.ts +8 -1
- package/src/core/config.ts +21 -0
- package/src/core/hooks/cli.ts +24 -20
- package/src/core/hooks/harness/wiring.ts +185 -0
- package/src/core/hooks/resolve/anchor.ts +21 -0
- package/src/core/hooks/resolve/owner.ts +17 -8
- package/dist/commands/uninstall.d.ts +0 -22
- 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.
|
|
42
|
+
// 4. Commit-guard wiring check
|
|
42
43
|
const wiringIssues = checkWiring(coordRoot);
|
|
43
44
|
if (wiringIssues.length > 0) {
|
|
44
|
-
const
|
|
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
|
|
156
|
-
*
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (!
|
|
193
|
-
return
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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,
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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", "--",
|
|
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", "--",
|
|
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,
|
|
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 !==
|
|
125
|
+
files_touched: (hb.files_touched ?? []).filter((p) => norm(p) !== target),
|
|
120
126
|
}));
|
|
121
127
|
}
|
|
122
128
|
/**
|
package/dist/core/config.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/hooks/cli.js
CHANGED
|
@@ -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
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
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)
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
852
|
-
const m = status.match(/^PPid:\s+(\d+)/m);
|
|
853
|
-
if (!m)
|
|
858
|
+
if (!hop)
|
|
854
859
|
break;
|
|
855
|
-
|
|
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
|
+
}
|