harnery 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/dist/commander.js +2 -2
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +48 -10
- 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/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 +38 -12
- 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 +4 -10
- package/dist/core/hooks/guard-path.d.ts +29 -0
- package/dist/core/hooks/guard-path.d.ts.map +1 -0
- package/dist/core/hooks/guard-path.js +38 -0
- 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/lib/completion/bash.d.ts +15 -0
- package/dist/lib/completion/bash.d.ts.map +1 -1
- package/dist/lib/completion/bash.js +35 -0
- package/dist/lib/completion/fish.d.ts +10 -0
- package/dist/lib/completion/fish.d.ts.map +1 -1
- package/dist/lib/completion/fish.js +21 -0
- package/dist/lib/completion/index.d.ts +4 -3
- package/dist/lib/completion/index.d.ts.map +1 -1
- package/dist/lib/completion/index.js +4 -3
- package/dist/lib/completion/resolve.d.ts +52 -0
- package/dist/lib/completion/resolve.d.ts.map +1 -0
- package/dist/lib/completion/resolve.js +171 -0
- package/dist/lib/completion/zsh.d.ts +8 -0
- package/dist/lib/completion/zsh.d.ts.map +1 -1
- package/dist/lib/completion/zsh.js +33 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +6 -0
- package/package.json +1 -1
- package/schemas/config.schema.json +4 -0
- package/src/commander.ts +2 -2
- package/src/commands/completion.ts +62 -9
- 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/render/session-context.ts +74 -34
- package/src/core/agents/rules/claim-conflict.ts +37 -12
- package/src/core/agents/state/heartbeat-writer.ts +8 -1
- package/src/core/config.ts +21 -0
- package/src/core/hooks/cli.ts +4 -8
- package/src/core/hooks/guard-path.ts +34 -0
- package/src/core/hooks/harness/wiring.ts +185 -0
- package/src/lib/completion/bash.ts +36 -0
- package/src/lib/completion/fish.ts +22 -0
- package/src/lib/completion/index.ts +12 -3
- package/src/lib/completion/resolve.ts +210 -0
- package/src/lib/completion/zsh.ts +34 -0
- package/src/lib/docs-lint.ts +5 -0
- 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,CAyFjF"}
|
|
@@ -62,15 +62,38 @@ export function evaluateClaim(coordRoot, req) {
|
|
|
62
62
|
// single-agent flow can't deadlock with itself, and the rule otherwise
|
|
63
63
|
// forces release-and-reacquire cycles on every reverse-order edit pair.
|
|
64
64
|
const hasFreshPeers = otherPeers.some((p) => isFresh(p.last_heartbeat) && p.files_touched.length > 0);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
// Re-editing a path already in our own files_touched acquires no new lock
|
|
66
|
+
// edge, so it can't create a circular wait — the ordering rule must not block
|
|
67
|
+
// it. Without this exemption, an agent that edits a higher-sorting file and
|
|
68
|
+
// then makes a second pass over an already-held lower-sorting file gets a
|
|
69
|
+
// spurious ordering_violation (the dominant friction source under concurrency:
|
|
70
|
+
// both agent-Gibson holding README.md and agent-Ophelia holding AGENTS.md were
|
|
71
|
+
// blocked re-editing those held files after touching a higher path, 2026-07-03).
|
|
72
|
+
const alreadyHeld = myPeer?.files_touched.includes(req.path) ?? false;
|
|
73
|
+
if (hasFreshPeers && myPeer && myPeer.files_touched.length > 0 && !alreadyHeld) {
|
|
74
|
+
// Only ACTIVE (uncommitted) edits should constrain lock ordering. A claim on
|
|
75
|
+
// a committed-clean file is a finished edit, not a held lock, so it must not
|
|
76
|
+
// wall off a lower-sorted acquisition. Without this, a long session
|
|
77
|
+
// accumulates committed claims that block every earlier-sorted path — pure
|
|
78
|
+
// friction, no deadlock risk (the file isn't being touched). Mirrors the
|
|
79
|
+
// peer stale-claim self-heal above. The git probes run only on the
|
|
80
|
+
// would-block path (claims sorting after req.path), staying off the hot path.
|
|
81
|
+
const blockers = myPeer.files_touched.filter((p) => req.path < p);
|
|
82
|
+
if (blockers.length > 0) {
|
|
83
|
+
const activeBlockers = blockers.filter((p) => !isFileCommittedClean(coordRoot, p));
|
|
84
|
+
if (activeBlockers.length > 0) {
|
|
85
|
+
const highest = [...activeBlockers].sort().at(-1);
|
|
86
|
+
return {
|
|
87
|
+
allow: false,
|
|
88
|
+
exit_code: 2,
|
|
89
|
+
rule: "claim.ordering_violation",
|
|
90
|
+
reason: `Cannot acquire ${req.path}: you already hold ${highest}, which sorts after it (claim ordering rule: acquire paths in sorted order to prevent deadlock between concurrent agents). Fix by editing in sorted order, or by committing ${highest} first, since a committed-clean file no longer blocks and is auto-pruned.`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// Every blocker is a finished (committed-clean) edit: prune them so they
|
|
94
|
+
// stop constraining future acquisitions, then fall through to allow.
|
|
95
|
+
for (const p of blockers)
|
|
96
|
+
pruneClaimFromPeer(coordRoot, req.instance_id, p);
|
|
74
97
|
}
|
|
75
98
|
}
|
|
76
99
|
// Acquire the claim: atomic check-and-set. Adds req.path to my
|
|
@@ -197,18 +220,21 @@ function isFresh(lastHeartbeat) {
|
|
|
197
220
|
* - diff shows non-empty output (genuinely dirty)
|
|
198
221
|
*/
|
|
199
222
|
function isFileCommittedClean(coordRoot, relPath) {
|
|
200
|
-
|
|
223
|
+
// Tolerate either path form: files_touched can hold absolute-under-coordRoot
|
|
224
|
+
// entries (legacy file-tracking) or canonical monorepo-relative ones.
|
|
225
|
+
const rel = relPath.startsWith(`${coordRoot}/`) ? relPath.slice(coordRoot.length + 1) : relPath;
|
|
226
|
+
const abs = join(coordRoot, rel);
|
|
201
227
|
if (!existsSync(abs))
|
|
202
228
|
return false;
|
|
203
229
|
try {
|
|
204
|
-
const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--",
|
|
230
|
+
const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--", rel], {
|
|
205
231
|
cwd: coordRoot,
|
|
206
232
|
encoding: "utf8",
|
|
207
233
|
timeout: 2000,
|
|
208
234
|
});
|
|
209
235
|
if (tracked.status !== 0)
|
|
210
236
|
return false;
|
|
211
|
-
const result = spawnSync("git", ["diff", "--quiet", "HEAD", "--",
|
|
237
|
+
const result = spawnSync("git", ["diff", "--quiet", "HEAD", "--", rel], {
|
|
212
238
|
cwd: coordRoot,
|
|
213
239
|
encoding: "utf8",
|
|
214
240
|
timeout: 2000,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"heartbeat-writer.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/heartbeat-writer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA6DH,MAAM,WAAW,SAAS;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAiBD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAQrF;AAeD,wBAAgB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAS7F;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAKxF;AAED,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GACd,SAAS,GAAG,IAAI,CAMlB;AAED,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,GACX,SAAS,GAAG,IAAI,
|
|
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
|
@@ -28,6 +28,7 @@ import { projectHeartbeats } from "../agents/state/heartbeat-projector.js";
|
|
|
28
28
|
import { shellMutationPaths } from "../agents/state/shell-mutation.js";
|
|
29
29
|
import { captureImages, detectPresence, imageJanitor, playSound, resetSoundCounters, runTurnSummary, scratchArchive, scratchJanitor, scratchRecoveryCue, soundForEvent, syncClaudeSessions, } from "./effects/index.js";
|
|
30
30
|
import { emit } from "./events/emit.js";
|
|
31
|
+
import { canonicalize } from "./guard-path.js";
|
|
31
32
|
import { detectHarness } from "./harness/detect.js";
|
|
32
33
|
import { extractBashCommand, extractToolDescription, normalizeEventName, parsePayload, } from "./harness/parse.js";
|
|
33
34
|
import { parsePsChainLine, selectAnchorPid } from "./resolve/anchor.js";
|
|
@@ -688,7 +689,9 @@ async function main() {
|
|
|
688
689
|
}
|
|
689
690
|
async function runPreToolUseGuard(coordRoot, instanceId, sessionId, data, harness) {
|
|
690
691
|
const toolName = data.tool_name ?? "";
|
|
691
|
-
const targets = collectGuardTargets(toolName, data)
|
|
692
|
+
const targets = collectGuardTargets(toolName, data)
|
|
693
|
+
.map((p) => canonicalize(coordRoot, p))
|
|
694
|
+
.filter((p) => p !== null);
|
|
692
695
|
if (targets.length === 0)
|
|
693
696
|
return;
|
|
694
697
|
const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
|
|
@@ -737,15 +740,6 @@ async function runPreToolUseGuard(coordRoot, instanceId, sessionId, data, harnes
|
|
|
737
740
|
/** Canonicalize a path to monorepo-relative form. Absolute paths under
|
|
738
741
|
* coordRoot get the prefix stripped; relative paths pass through (assumed
|
|
739
742
|
* already canonical). */
|
|
740
|
-
function canonicalize(coordRoot, p) {
|
|
741
|
-
if (!p)
|
|
742
|
-
return p;
|
|
743
|
-
if (p.startsWith(`${coordRoot}/`))
|
|
744
|
-
return p.slice(coordRoot.length + 1);
|
|
745
|
-
if (p === coordRoot)
|
|
746
|
-
return ".";
|
|
747
|
-
return p;
|
|
748
|
-
}
|
|
749
743
|
/** Pull the candidate path(s) out of a write-tool payload. Empty array when
|
|
750
744
|
* the tool isn't a write or no path could be derived. */
|
|
751
745
|
function collectGuardTargets(toolName, data) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonicalize a write-tool target path for the claim guard.
|
|
3
|
+
*
|
|
4
|
+
* Returns the monorepo-relative path, or `null` when the target lies OUTSIDE the
|
|
5
|
+
* repo (an absolute path not under coordRoot, e.g. a `/tmp` scratchpad or other
|
|
6
|
+
* session-temp file).
|
|
7
|
+
*
|
|
8
|
+
* The claim system is intentionally repo-scoped: it coordinates monorepo files,
|
|
9
|
+
* not arbitrary absolute paths, so the guard skips out-of-repo targets. Skipping
|
|
10
|
+
* them is right on two counts. First, it keeps non-repo paths out of a
|
|
11
|
+
* heartbeat's `files_touched`. Second, the ordering rule compares raw path
|
|
12
|
+
* strings, and an absolute `/tmp/…` sorts before every repo-relative path
|
|
13
|
+
* (`/` = 0x2F < any letter), so without this a scratchpad write would spuriously
|
|
14
|
+
* "block" a legitimately-held repo file. Returning null keeps such paths out of
|
|
15
|
+
* the claim system entirely.
|
|
16
|
+
*
|
|
17
|
+
* Accepted tradeoff: this also means shared out-of-repo files (a user-level
|
|
18
|
+
* memory or plans directory) are not coordinated across agents. The alternative,
|
|
19
|
+
* normalizing every path to one consistent key so those stay coordinated, was
|
|
20
|
+
* rejected as gold-plating a rare, merge-disciplined race in a deadlock-critical
|
|
21
|
+
* path. Coordinate shared state by keeping it in the repo, not out of it.
|
|
22
|
+
*
|
|
23
|
+
* Relative inputs are assumed already-repo-relative (Codex `apply_patch` emits
|
|
24
|
+
* cwd-relative paths). The in-repo check requires the `<root>/` separator, so a
|
|
25
|
+
* sibling dir that merely shares a prefix (`/repo-other` vs `/repo`) is treated
|
|
26
|
+
* as out-of-repo, not stripped.
|
|
27
|
+
*/
|
|
28
|
+
export declare function canonicalize(coordRoot: string, p: string): string | null;
|
|
29
|
+
//# sourceMappingURL=guard-path.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guard-path.d.ts","sourceRoot":"","sources":["../../../src/core/hooks/guard-path.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAMxE"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonicalize a write-tool target path for the claim guard.
|
|
3
|
+
*
|
|
4
|
+
* Returns the monorepo-relative path, or `null` when the target lies OUTSIDE the
|
|
5
|
+
* repo (an absolute path not under coordRoot, e.g. a `/tmp` scratchpad or other
|
|
6
|
+
* session-temp file).
|
|
7
|
+
*
|
|
8
|
+
* The claim system is intentionally repo-scoped: it coordinates monorepo files,
|
|
9
|
+
* not arbitrary absolute paths, so the guard skips out-of-repo targets. Skipping
|
|
10
|
+
* them is right on two counts. First, it keeps non-repo paths out of a
|
|
11
|
+
* heartbeat's `files_touched`. Second, the ordering rule compares raw path
|
|
12
|
+
* strings, and an absolute `/tmp/…` sorts before every repo-relative path
|
|
13
|
+
* (`/` = 0x2F < any letter), so without this a scratchpad write would spuriously
|
|
14
|
+
* "block" a legitimately-held repo file. Returning null keeps such paths out of
|
|
15
|
+
* the claim system entirely.
|
|
16
|
+
*
|
|
17
|
+
* Accepted tradeoff: this also means shared out-of-repo files (a user-level
|
|
18
|
+
* memory or plans directory) are not coordinated across agents. The alternative,
|
|
19
|
+
* normalizing every path to one consistent key so those stay coordinated, was
|
|
20
|
+
* rejected as gold-plating a rare, merge-disciplined race in a deadlock-critical
|
|
21
|
+
* path. Coordinate shared state by keeping it in the repo, not out of it.
|
|
22
|
+
*
|
|
23
|
+
* Relative inputs are assumed already-repo-relative (Codex `apply_patch` emits
|
|
24
|
+
* cwd-relative paths). The in-repo check requires the `<root>/` separator, so a
|
|
25
|
+
* sibling dir that merely shares a prefix (`/repo-other` vs `/repo`) is treated
|
|
26
|
+
* as out-of-repo, not stripped.
|
|
27
|
+
*/
|
|
28
|
+
export function canonicalize(coordRoot, p) {
|
|
29
|
+
if (!p)
|
|
30
|
+
return null;
|
|
31
|
+
if (p === coordRoot)
|
|
32
|
+
return ".";
|
|
33
|
+
if (p.startsWith(`${coordRoot}/`))
|
|
34
|
+
return p.slice(coordRoot.length + 1);
|
|
35
|
+
if (p.startsWith("/"))
|
|
36
|
+
return null; // absolute + not under coordRoot → out-of-repo
|
|
37
|
+
return p; // relative → treat as repo-relative
|
|
38
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only harness-hook wiring inspection — the inverse of `harn init`'s
|
|
3
|
+
* writer (commands/init.ts `wireHooks`). Compares what `init` would wire
|
|
4
|
+
* (HARNESS_SPECS) against what's actually present in a project's harness
|
|
5
|
+
* settings file, so `harn doctor` and the SessionStart nudge can tell an agent
|
|
6
|
+
* when a harnery upgrade changed the hook set but the project hasn't been
|
|
7
|
+
* re-wired yet.
|
|
8
|
+
*
|
|
9
|
+
* The shared types + matcher live here (not in init.ts) so the writer, the
|
|
10
|
+
* doctor check, and the session-start renderer all agree on what "wired" means
|
|
11
|
+
* — there's exactly one definition of the `agent-hook <subcommand>` match.
|
|
12
|
+
*/
|
|
13
|
+
import { type HarnessId, type HarnessSpec, type HookEntryShape, type HookEvent } from "./events.js";
|
|
14
|
+
/** Claude Code + Codex entry: `{ hooks: [{ type, command }] }`. */
|
|
15
|
+
export interface ClaudeHookGroup {
|
|
16
|
+
matcher?: string;
|
|
17
|
+
hooks: {
|
|
18
|
+
type: string;
|
|
19
|
+
command: string;
|
|
20
|
+
}[];
|
|
21
|
+
}
|
|
22
|
+
/** Cursor entry: a flat `{ command }`. */
|
|
23
|
+
export interface CursorHookGroup {
|
|
24
|
+
command: string;
|
|
25
|
+
type?: string;
|
|
26
|
+
matcher?: string;
|
|
27
|
+
}
|
|
28
|
+
export type HookGroup = ClaudeHookGroup | CursorHookGroup;
|
|
29
|
+
export interface SettingsFile {
|
|
30
|
+
version?: number;
|
|
31
|
+
hooks?: Record<string, HookGroup[]>;
|
|
32
|
+
[k: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
/** Build a hook entry in the harness's shape. */
|
|
35
|
+
export declare function makeEntry(shape: HookEntryShape, command: string): HookGroup;
|
|
36
|
+
/** Pull every command string out of a hook entry, regardless of shape. */
|
|
37
|
+
export declare function groupCommands(group: HookGroup): string[];
|
|
38
|
+
/**
|
|
39
|
+
* Whether a hook command string wires the given agent-hook subcommand. The
|
|
40
|
+
* trailing space is load-bearing: it keeps `stop` from matching `stop-failure`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function commandWiresSubcommand(command: string, subcommand: string): boolean;
|
|
43
|
+
export interface WiringDiff {
|
|
44
|
+
/** Spec events not wired in the settings file. */
|
|
45
|
+
missing: HookEvent[];
|
|
46
|
+
/** Spec events already wired. */
|
|
47
|
+
present: HookEvent[];
|
|
48
|
+
/**
|
|
49
|
+
* agent-hook subcommands wired in the file that are NOT in the current spec
|
|
50
|
+
* (e.g. an event renamed/removed by an upgrade). Additive re-init won't clean
|
|
51
|
+
* these — they need explicit removal — so they're surfaced separately.
|
|
52
|
+
*/
|
|
53
|
+
orphans: string[];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Pure diff of one settings object against one harness spec. Read-only inverse
|
|
57
|
+
* of `wireHooks`; no fs, so it's unit-testable.
|
|
58
|
+
*/
|
|
59
|
+
export declare function diffWiring(settings: SettingsFile, spec: HarnessSpec): WiringDiff;
|
|
60
|
+
export interface HarnessWiringStatus {
|
|
61
|
+
harness: HarnessId;
|
|
62
|
+
/** Settings file path, relative to the project root. */
|
|
63
|
+
settingsFile: string;
|
|
64
|
+
missing: HookEvent[];
|
|
65
|
+
orphans: string[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Inspect every harness whose settings file exists under `projectRoot` and
|
|
69
|
+
* return only those with *drift*. Read-only; never writes.
|
|
70
|
+
*
|
|
71
|
+
* Drift is reported only for a harness the project has **already opted into** —
|
|
72
|
+
* i.e. at least one harnery hook is already wired. A settings file with zero
|
|
73
|
+
* harnery hooks just means this harness isn't harnery-wired here (a bare
|
|
74
|
+
* `.claude/settings.json` is a generic Claude Code file); that's `harn init`'s
|
|
75
|
+
* job to surface on first run, not drift to nag about every session. A harness
|
|
76
|
+
* with no settings file at all, or an unparseable one, is skipped.
|
|
77
|
+
*/
|
|
78
|
+
export declare function loadHarnessWiring(projectRoot: string): HarnessWiringStatus[];
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the harnery package version for context in nudges/checks. Walks up
|
|
81
|
+
* from this module to the package root (works under Bun from `src/` and Node
|
|
82
|
+
* from `dist/`). Returns "" if unresolved — callers omit it from the message.
|
|
83
|
+
*/
|
|
84
|
+
export declare function harneryVersion(): string;
|
|
85
|
+
//# sourceMappingURL=wiring.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wiring.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/harness/wiring.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AAErB,mEAAmE;AACnE,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC5C;AACD,0CAA0C;AAC1C,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AACD,MAAM,MAAM,SAAS,GAAG,eAAe,GAAG,eAAe,CAAC;AAE1D,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;IACpC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AAED,iDAAiD;AACjD,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,CAE3E;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,EAAE,CAMxD;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAEnF;AAQD,MAAM,WAAW,UAAU;IACzB,kDAAkD;IAClD,OAAO,EAAE,SAAS,EAAE,CAAC;IACrB,iCAAiC;IACjC,OAAO,EAAE,SAAS,EAAE,CAAC;IACrB;;;;OAIG;IACH,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,QAAQ,EAAE,YAAY,EAAE,IAAI,EAAE,WAAW,GAAG,UAAU,CA0BhF;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,SAAS,CAAC;IACnB,wDAAwD;IACxD,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,SAAS,EAAE,CAAC;IACrB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,mBAAmB,EAAE,CAwB5E;AAED;;;;GAIG;AACH,wBAAgB,cAAc,IAAI,MAAM,CAkBvC"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only harness-hook wiring inspection — the inverse of `harn init`'s
|
|
3
|
+
* writer (commands/init.ts `wireHooks`). Compares what `init` would wire
|
|
4
|
+
* (HARNESS_SPECS) against what's actually present in a project's harness
|
|
5
|
+
* settings file, so `harn doctor` and the SessionStart nudge can tell an agent
|
|
6
|
+
* when a harnery upgrade changed the hook set but the project hasn't been
|
|
7
|
+
* re-wired yet.
|
|
8
|
+
*
|
|
9
|
+
* The shared types + matcher live here (not in init.ts) so the writer, the
|
|
10
|
+
* doctor check, and the session-start renderer all agree on what "wired" means
|
|
11
|
+
* — there's exactly one definition of the `agent-hook <subcommand>` match.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { HARNESS_SPECS, } from "./events.js";
|
|
17
|
+
/** Build a hook entry in the harness's shape. */
|
|
18
|
+
export function makeEntry(shape, command) {
|
|
19
|
+
return shape === "cursor" ? { command } : { hooks: [{ type: "command", command }] };
|
|
20
|
+
}
|
|
21
|
+
/** Pull every command string out of a hook entry, regardless of shape. */
|
|
22
|
+
export function groupCommands(group) {
|
|
23
|
+
if ("command" in group && typeof group.command === "string")
|
|
24
|
+
return [group.command];
|
|
25
|
+
if ("hooks" in group && Array.isArray(group.hooks)) {
|
|
26
|
+
return group.hooks.map((h) => h.command).filter((c) => typeof c === "string");
|
|
27
|
+
}
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether a hook command string wires the given agent-hook subcommand. The
|
|
32
|
+
* trailing space is load-bearing: it keeps `stop` from matching `stop-failure`.
|
|
33
|
+
*/
|
|
34
|
+
export function commandWiresSubcommand(command, subcommand) {
|
|
35
|
+
return command.includes(`agent-hook ${subcommand} `);
|
|
36
|
+
}
|
|
37
|
+
/** Pull the agent-hook subcommand out of a command string, or null if none. */
|
|
38
|
+
function commandSubcommand(command) {
|
|
39
|
+
const m = command.match(/agent-hook\s+([a-z][a-z-]*)\s/);
|
|
40
|
+
return m ? m[1] : null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Pure diff of one settings object against one harness spec. Read-only inverse
|
|
44
|
+
* of `wireHooks`; no fs, so it's unit-testable.
|
|
45
|
+
*/
|
|
46
|
+
export function diffWiring(settings, spec) {
|
|
47
|
+
const missing = [];
|
|
48
|
+
const present = [];
|
|
49
|
+
const hooks = settings.hooks ?? {};
|
|
50
|
+
for (const event of spec.events) {
|
|
51
|
+
const groups = hooks[event.settingsKey] ?? [];
|
|
52
|
+
const wired = groups.some((g) => groupCommands(g).some((c) => commandWiresSubcommand(c, event.subcommand)));
|
|
53
|
+
(wired ? present : missing).push(event);
|
|
54
|
+
}
|
|
55
|
+
const specSubcommands = new Set(spec.events.map((e) => e.subcommand));
|
|
56
|
+
const orphans = new Set();
|
|
57
|
+
for (const groups of Object.values(hooks)) {
|
|
58
|
+
if (!Array.isArray(groups))
|
|
59
|
+
continue;
|
|
60
|
+
for (const g of groups) {
|
|
61
|
+
for (const c of groupCommands(g)) {
|
|
62
|
+
const sub = commandSubcommand(c);
|
|
63
|
+
if (sub && !specSubcommands.has(sub))
|
|
64
|
+
orphans.add(sub);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { missing, present, orphans: [...orphans].sort() };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Inspect every harness whose settings file exists under `projectRoot` and
|
|
72
|
+
* return only those with *drift*. Read-only; never writes.
|
|
73
|
+
*
|
|
74
|
+
* Drift is reported only for a harness the project has **already opted into** —
|
|
75
|
+
* i.e. at least one harnery hook is already wired. A settings file with zero
|
|
76
|
+
* harnery hooks just means this harness isn't harnery-wired here (a bare
|
|
77
|
+
* `.claude/settings.json` is a generic Claude Code file); that's `harn init`'s
|
|
78
|
+
* job to surface on first run, not drift to nag about every session. A harness
|
|
79
|
+
* with no settings file at all, or an unparseable one, is skipped.
|
|
80
|
+
*/
|
|
81
|
+
export function loadHarnessWiring(projectRoot) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const [id, spec] of Object.entries(HARNESS_SPECS)) {
|
|
84
|
+
const settingsPath = resolve(projectRoot, spec.settingsFile);
|
|
85
|
+
if (!existsSync(settingsPath))
|
|
86
|
+
continue;
|
|
87
|
+
let settings;
|
|
88
|
+
try {
|
|
89
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Unparseable settings file: can't tell opt-in from noise, and the
|
|
93
|
+
// harness itself will complain about its own malformed config. Skip.
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const diff = diffWiring(settings, spec);
|
|
97
|
+
if (diff.present.length === 0)
|
|
98
|
+
continue; // not opted in → not drift
|
|
99
|
+
if (diff.missing.length === 0 && diff.orphans.length === 0)
|
|
100
|
+
continue; // current
|
|
101
|
+
out.push({
|
|
102
|
+
harness: id,
|
|
103
|
+
settingsFile: spec.settingsFile,
|
|
104
|
+
missing: diff.missing,
|
|
105
|
+
orphans: diff.orphans,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the harnery package version for context in nudges/checks. Walks up
|
|
112
|
+
* from this module to the package root (works under Bun from `src/` and Node
|
|
113
|
+
* from `dist/`). Returns "" if unresolved — callers omit it from the message.
|
|
114
|
+
*/
|
|
115
|
+
export function harneryVersion() {
|
|
116
|
+
try {
|
|
117
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
118
|
+
for (let i = 0; i < 8; i++) {
|
|
119
|
+
try {
|
|
120
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
121
|
+
if (pkg.name === "harnery" && typeof pkg.version === "string")
|
|
122
|
+
return pkg.version;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
/* no package.json here, or not ours; keep walking up */
|
|
126
|
+
}
|
|
127
|
+
const parent = dirname(dir);
|
|
128
|
+
if (parent === dir)
|
|
129
|
+
break;
|
|
130
|
+
dir = parent;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* import.meta.url unavailable or fs error */
|
|
135
|
+
}
|
|
136
|
+
return "";
|
|
137
|
+
}
|