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
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
-
import { resolveBinName } from "../../config.ts";
|
|
13
|
+
import { resolveBinName, resolveHooksSetupHint } from "../../config.ts";
|
|
14
|
+
import { harneryVersion, loadHarnessWiring } from "../../hooks/harness/wiring.ts";
|
|
14
15
|
|
|
15
16
|
interface HeartbeatRow {
|
|
16
17
|
instance_id?: string;
|
|
@@ -69,13 +70,40 @@ export function renderSessionContext(opts: RenderOpts): string {
|
|
|
69
70
|
if (councilMsg) messages.push(councilMsg);
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
// 4.
|
|
73
|
+
// 4. Commit-guard wiring check
|
|
73
74
|
const wiringIssues = checkWiring(coordRoot);
|
|
74
75
|
if (wiringIssues.length > 0) {
|
|
75
|
-
const
|
|
76
|
+
const hint = resolveHooksSetupHint(coordRoot);
|
|
77
|
+
const fix = hint
|
|
78
|
+
? `Run \`${hint}\` to install them.`
|
|
79
|
+
: "Wire each repo's pre-commit hook to invoke `agent-coord verdict --rule=commit` (harnery's commit guard).";
|
|
80
|
+
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")}`;
|
|
76
81
|
messages.push(wiringSummary);
|
|
77
82
|
}
|
|
78
83
|
|
|
84
|
+
// 5. Harness-hook drift: a harnery upgrade changed the hook set, but this
|
|
85
|
+
// project's settings file hasn't been re-wired. Only fires for a harness the
|
|
86
|
+
// project already opted into (≥1 hook wired), so it never nags a project that
|
|
87
|
+
// simply has a settings file. Remedy is always `<bin> init` (idempotent).
|
|
88
|
+
const drift = loadHarnessWiring(coordRoot);
|
|
89
|
+
if (drift.length > 0) {
|
|
90
|
+
const bin = resolveBinName(coordRoot);
|
|
91
|
+
const ver = harneryVersion();
|
|
92
|
+
const verPart = ver ? ` (harnery ${ver})` : "";
|
|
93
|
+
const lines = drift.map((d) => {
|
|
94
|
+
const bits: string[] = [];
|
|
95
|
+
if (d.missing.length > 0)
|
|
96
|
+
bits.push(`missing: ${d.missing.map((m) => m.subcommand).join(", ")}`);
|
|
97
|
+
if (d.orphans.length > 0) bits.push(`orphaned: ${d.orphans.join(", ")}`);
|
|
98
|
+
return ` - ${d.settingsFile} — ${bits.join("; ")}`;
|
|
99
|
+
});
|
|
100
|
+
messages.push(
|
|
101
|
+
`Harnery hook wiring is out of date${verPart}: an upgrade changed the hook set but the harness ` +
|
|
102
|
+
`settings file hasn't been re-wired, so the new hook(s) won't fire. Run \`${bin} init\` to wire them ` +
|
|
103
|
+
`(idempotent, additive).\n${lines.join("\n")}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
79
107
|
return messages.join("\n\n");
|
|
80
108
|
}
|
|
81
109
|
|
|
@@ -198,19 +226,25 @@ function fmtAge(secs: number): string {
|
|
|
198
226
|
}
|
|
199
227
|
|
|
200
228
|
/**
|
|
201
|
-
* Returns a list of wiring issues (empty when
|
|
202
|
-
*
|
|
229
|
+
* Returns a list of commit-guard wiring issues (empty when wired). Portable
|
|
230
|
+
* across host projects: it asserts the FUNCTIONAL property ("does this repo's
|
|
231
|
+
* pre-commit invoke harnery's guard?") rather than any path convention. For
|
|
232
|
+
* each repo it resolves the EFFECTIVE git-hooks dir via
|
|
233
|
+
* `git rev-parse --git-path hooks` (which already honors `core.hooksPath`,
|
|
234
|
+
* linked worktrees, and submodule gitdirs) and checks whether the `pre-commit`
|
|
235
|
+
* there calls `agent-coord` / `agent-hook`. Checks the parent repo + one
|
|
236
|
+
* representative submodule (others almost always share the same setup).
|
|
237
|
+
*
|
|
238
|
+
* harnery does not install git hooks itself — each host wires its own
|
|
239
|
+
* pre-commit to invoke the guard — so the remediation command is host-specific
|
|
240
|
+
* and supplied via `hooksSetupHint` in `.harnery/config.jsonc` (see the caller).
|
|
203
241
|
*/
|
|
204
242
|
export function checkWiring(coordRoot: string): string[] {
|
|
205
|
-
const expected = join(coordRoot, "scripts", "hooks");
|
|
206
243
|
const issues: string[] = [];
|
|
207
244
|
|
|
208
|
-
|
|
209
|
-
const parentHp = gitConfig(coordRoot, "core.hooksPath");
|
|
210
|
-
const parentResolved = resolveHooksPath(coordRoot, parentHp);
|
|
211
|
-
if (parentResolved !== expected) {
|
|
245
|
+
if (!preCommitInvokesGuard(coordRoot)) {
|
|
212
246
|
issues.push(
|
|
213
|
-
|
|
247
|
+
"parent repo: pre-commit hook is missing or doesn't invoke the harnery commit guard",
|
|
214
248
|
);
|
|
215
249
|
}
|
|
216
250
|
|
|
@@ -220,15 +254,10 @@ export function checkWiring(coordRoot: string): string[] {
|
|
|
220
254
|
const sampleSub = extractFirstSubmodule(gitmodules);
|
|
221
255
|
if (sampleSub) {
|
|
222
256
|
const subPath = join(coordRoot, sampleSub);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (subResolved !== expected) {
|
|
228
|
-
issues.push(
|
|
229
|
-
`submodule ${sampleSub} core.hooksPath=${subHp || "<unset>"} (resolves to ${subResolved}; other submodules likely affected too)`,
|
|
230
|
-
);
|
|
231
|
-
}
|
|
257
|
+
if (existsSync(join(subPath, ".git")) && !preCommitInvokesGuard(subPath)) {
|
|
258
|
+
issues.push(
|
|
259
|
+
`submodule ${sampleSub}: pre-commit hook doesn't invoke the harnery commit guard (other submodules likely affected too)`,
|
|
260
|
+
);
|
|
232
261
|
}
|
|
233
262
|
}
|
|
234
263
|
}
|
|
@@ -236,22 +265,33 @@ export function checkWiring(coordRoot: string): string[] {
|
|
|
236
265
|
return issues;
|
|
237
266
|
}
|
|
238
267
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (!
|
|
247
|
-
|
|
248
|
-
|
|
268
|
+
/**
|
|
269
|
+
* Whether the repo at `repoDir` has a pre-commit hook — at its effective,
|
|
270
|
+
* `core.hooksPath`-aware location — that invokes harnery's commit guard.
|
|
271
|
+
* Fully portable: no assumption about WHERE the host keeps its hooks.
|
|
272
|
+
*/
|
|
273
|
+
function preCommitInvokesGuard(repoDir: string): boolean {
|
|
274
|
+
const hooksDir = gitHooksDir(repoDir);
|
|
275
|
+
if (!hooksDir) return false;
|
|
276
|
+
const preCommit = join(hooksDir, "pre-commit");
|
|
277
|
+
if (!existsSync(preCommit)) return false;
|
|
278
|
+
try {
|
|
279
|
+
return /agent-(coord|hook)\b/.test(readFileSync(preCommit, "utf8"));
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
249
283
|
}
|
|
250
284
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
285
|
+
/** Resolve a repo's effective git-hooks directory (absolute), or null. */
|
|
286
|
+
function gitHooksDir(repoDir: string): string | null {
|
|
287
|
+
const r = spawnSync("git", ["-C", repoDir, "rev-parse", "--git-path", "hooks"], {
|
|
288
|
+
encoding: "utf8",
|
|
289
|
+
});
|
|
290
|
+
if (r.status !== 0) return null;
|
|
291
|
+
const p = r.stdout.trim();
|
|
292
|
+
if (!p) return null;
|
|
293
|
+
// `--git-path` prints relative to repoDir (we passed -C); absolutize.
|
|
294
|
+
return p.startsWith("/") ? p : join(repoDir, p);
|
|
255
295
|
}
|
|
256
296
|
|
|
257
297
|
function extractFirstSubmodule(gitmodulesPath: string): string | null {
|
|
@@ -102,14 +102,28 @@ export function evaluateClaim(coordRoot: string, req: ClaimRequest): VerdictResu
|
|
|
102
102
|
(p) => isFresh(p.last_heartbeat) && p.files_touched.length > 0,
|
|
103
103
|
);
|
|
104
104
|
if (hasFreshPeers && myPeer && myPeer.files_touched.length > 0) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
105
|
+
// Only ACTIVE (uncommitted) edits should constrain lock ordering. A claim on
|
|
106
|
+
// a committed-clean file is a finished edit, not a held lock, so it must not
|
|
107
|
+
// wall off a lower-sorted acquisition. Without this, a long session
|
|
108
|
+
// accumulates committed claims that block every earlier-sorted path — pure
|
|
109
|
+
// friction, no deadlock risk (the file isn't being touched). Mirrors the
|
|
110
|
+
// peer stale-claim self-heal above. The git probes run only on the
|
|
111
|
+
// would-block path (claims sorting after req.path), staying off the hot path.
|
|
112
|
+
const blockers = myPeer.files_touched.filter((p) => req.path < p);
|
|
113
|
+
if (blockers.length > 0) {
|
|
114
|
+
const activeBlockers = blockers.filter((p) => !isFileCommittedClean(coordRoot, p));
|
|
115
|
+
if (activeBlockers.length > 0) {
|
|
116
|
+
const highest = [...activeBlockers].sort().at(-1)!;
|
|
117
|
+
return {
|
|
118
|
+
allow: false,
|
|
119
|
+
exit_code: 2,
|
|
120
|
+
rule: "claim.ordering_violation",
|
|
121
|
+
reason: `Cannot acquire ${req.path} while holding ${highest} (claim ordering rule: paths must be acquired in sorted order). Release the higher claim first.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// Every blocker is a finished (committed-clean) edit: prune them so they
|
|
125
|
+
// stop constraining future acquisitions, then fall through to allow.
|
|
126
|
+
for (const p of blockers) pruneClaimFromPeer(coordRoot, req.instance_id, p);
|
|
113
127
|
}
|
|
114
128
|
}
|
|
115
129
|
|
|
@@ -240,16 +254,19 @@ function isFresh(lastHeartbeat: string): boolean {
|
|
|
240
254
|
* - diff shows non-empty output (genuinely dirty)
|
|
241
255
|
*/
|
|
242
256
|
function isFileCommittedClean(coordRoot: string, relPath: string): boolean {
|
|
243
|
-
|
|
257
|
+
// Tolerate either path form: files_touched can hold absolute-under-coordRoot
|
|
258
|
+
// entries (legacy file-tracking) or canonical monorepo-relative ones.
|
|
259
|
+
const rel = relPath.startsWith(`${coordRoot}/`) ? relPath.slice(coordRoot.length + 1) : relPath;
|
|
260
|
+
const abs = join(coordRoot, rel);
|
|
244
261
|
if (!existsSync(abs)) return false;
|
|
245
262
|
try {
|
|
246
|
-
const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--",
|
|
263
|
+
const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--", rel], {
|
|
247
264
|
cwd: coordRoot,
|
|
248
265
|
encoding: "utf8",
|
|
249
266
|
timeout: 2000,
|
|
250
267
|
});
|
|
251
268
|
if (tracked.status !== 0) return false;
|
|
252
|
-
const result = spawnSync("git", ["diff", "--quiet", "HEAD", "--",
|
|
269
|
+
const result = spawnSync("git", ["diff", "--quiet", "HEAD", "--", rel], {
|
|
253
270
|
cwd: coordRoot,
|
|
254
271
|
encoding: "utf8",
|
|
255
272
|
timeout: 2000,
|
|
@@ -171,9 +171,16 @@ export function releaseClaim(
|
|
|
171
171
|
instanceId: string,
|
|
172
172
|
path: string,
|
|
173
173
|
): Heartbeat | null {
|
|
174
|
+
// files_touched can hold either absolute-under-coordRoot or canonical
|
|
175
|
+
// monorepo-relative entries; normalize both sides so release matches
|
|
176
|
+
// regardless of the form the caller passes (the old exact-string filter
|
|
177
|
+
// silently no-op'd on a form mismatch).
|
|
178
|
+
const norm = (p: string): string =>
|
|
179
|
+
p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
|
|
180
|
+
const target = norm(path);
|
|
174
181
|
return mutate(coordRoot, instanceId, (hb) => ({
|
|
175
182
|
...hb,
|
|
176
|
-
files_touched: (hb.files_touched ?? []).filter((p) => p !==
|
|
183
|
+
files_touched: (hb.files_touched ?? []).filter((p) => norm(p) !== target),
|
|
177
184
|
}));
|
|
178
185
|
}
|
|
179
186
|
|
package/src/core/config.ts
CHANGED
|
@@ -25,6 +25,14 @@ export const DEFAULT_BIN_NAME = "harn";
|
|
|
25
25
|
interface HarneryConfig {
|
|
26
26
|
/** Host CLI bin name, stamped by `harn init` for a consumer (e.g. "bp"). */
|
|
27
27
|
binName?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Host-specific command that (re)installs the project's git hooks, surfaced
|
|
30
|
+
* verbatim in the "commit guard not wired" nudge. Optional: harnery doesn't
|
|
31
|
+
* own git-hook installation (each host wires its own pre-commit to invoke
|
|
32
|
+
* `agent-coord verdict`), and the path/command is host-specific, so the host
|
|
33
|
+
* declares it here (e.g. "scripts/setup-hooks.sh"). Unset → a generic hint.
|
|
34
|
+
*/
|
|
35
|
+
hooksSetupHint?: string;
|
|
28
36
|
[k: string]: unknown;
|
|
29
37
|
}
|
|
30
38
|
|
|
@@ -109,3 +117,16 @@ export function resolveBinName(coordRoot?: string | null): string {
|
|
|
109
117
|
}
|
|
110
118
|
return DEFAULT_BIN_NAME;
|
|
111
119
|
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The host's git-hook (re)install command, for the "commit guard not wired"
|
|
123
|
+
* nudge. Returns the configured `hooksSetupHint` (e.g. "scripts/setup-hooks.sh")
|
|
124
|
+
* or null when unset — callers fall back to a generic, host-agnostic message.
|
|
125
|
+
* `coordRoot` is resolved via `findCoordRoot()` when not passed.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveHooksSetupHint(coordRoot?: string | null): string | null {
|
|
128
|
+
const root = coordRoot ?? findCoordRoot();
|
|
129
|
+
if (!root) return null;
|
|
130
|
+
const hint = readConfig(root).hooksSetupHint;
|
|
131
|
+
return typeof hint === "string" && hint.trim() ? hint.trim() : null;
|
|
132
|
+
}
|
package/src/core/hooks/cli.ts
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
type ParsedPayload,
|
|
52
52
|
parsePayload,
|
|
53
53
|
} from "./harness/parse.ts";
|
|
54
|
-
import { selectAnchorPid } from "./resolve/anchor.ts";
|
|
54
|
+
import { parsePsChainLine, selectAnchorPid } from "./resolve/anchor.ts";
|
|
55
55
|
import { findCoordRoot } from "./resolve/coord-root.ts";
|
|
56
56
|
import { extractIntentComment, resolveIntent } from "./resolve/intent.ts";
|
|
57
57
|
import { resolveOwner } from "./resolve/owner.ts";
|
|
@@ -908,10 +908,10 @@ function healHeartbeatViaCli(
|
|
|
908
908
|
* the next tool call rather than going invisible until SessionStart fires
|
|
909
909
|
* again, which it may never do.
|
|
910
910
|
*
|
|
911
|
-
* Returns undefined
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
911
|
+
* Returns undefined only when no anchor is found; callers fall back to
|
|
912
|
+
* `process.ppid` (the bash wrapper's parent, which is usually the harness binary
|
|
913
|
+
* itself). `HARNERY_AGENT_COORD_TEST_ANCHOR_PID` overrides everything so the
|
|
914
|
+
* test sandbox can pin a deterministic PID.
|
|
915
915
|
*/
|
|
916
916
|
function findHarnessAnchorPid(harness?: Harness): number | undefined {
|
|
917
917
|
const override = coordEnv("AGENT_COORD_TEST_ANCHOR_PID");
|
|
@@ -919,27 +919,31 @@ function findHarnessAnchorPid(harness?: Harness): number | undefined {
|
|
|
919
919
|
const n = Number(override);
|
|
920
920
|
if (Number.isFinite(n) && n > 0) return n;
|
|
921
921
|
}
|
|
922
|
-
// Build the ppid chain (nearest → root, up to 20 hops)
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
922
|
+
// Build the ppid chain (nearest → root, up to 20 hops), then hand it to the
|
|
923
|
+
// pure selector. Linux/WSL reads /proc; macOS/BSD (no /proc) falls back to
|
|
924
|
+
// `ps -o ppid=,comm=` parsed by the unit-tested `parsePsChainLine`. Splitting
|
|
925
|
+
// the walk (untestable off a live box) from the comm-matching keeps the
|
|
926
|
+
// cursor `node`-fallback logic verifiable.
|
|
926
927
|
const chain: Array<{ pid: number; comm: string }> = [];
|
|
927
928
|
let pid = process.pid;
|
|
928
929
|
for (let hops = 0; hops < 20; hops++) {
|
|
929
|
-
let comm: string;
|
|
930
|
-
let status: string;
|
|
930
|
+
let hop: { comm: string; ppid: number } | null = null;
|
|
931
931
|
try {
|
|
932
|
-
comm = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
|
|
933
|
-
status = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
932
|
+
const comm = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
|
|
933
|
+
const status = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
934
|
+
const m = status.match(/^PPid:\s+(\d+)/m);
|
|
935
|
+
hop = { comm, ppid: m ? Number(m[1]) : 0 };
|
|
934
936
|
} catch {
|
|
935
|
-
|
|
937
|
+
// no /proc (macOS/BSD) — fall through to ps
|
|
936
938
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
if (!
|
|
942
|
-
pid
|
|
939
|
+
if (!hop) {
|
|
940
|
+
const out = spawnSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { encoding: "utf8" });
|
|
941
|
+
if (out.status === 0) hop = parsePsChainLine(out.stdout);
|
|
942
|
+
}
|
|
943
|
+
if (!hop) break;
|
|
944
|
+
chain.push({ pid, comm: hop.comm });
|
|
945
|
+
if (!Number.isFinite(hop.ppid) || hop.ppid === 0 || hop.ppid === 1) break;
|
|
946
|
+
pid = hop.ppid;
|
|
943
947
|
}
|
|
944
948
|
return selectAnchorPid(chain, harness);
|
|
945
949
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import {
|
|
18
|
+
HARNESS_SPECS,
|
|
19
|
+
type HarnessId,
|
|
20
|
+
type HarnessSpec,
|
|
21
|
+
type HookEntryShape,
|
|
22
|
+
type HookEvent,
|
|
23
|
+
} from "./events.ts";
|
|
24
|
+
|
|
25
|
+
/** Claude Code + Codex entry: `{ hooks: [{ type, command }] }`. */
|
|
26
|
+
export interface ClaudeHookGroup {
|
|
27
|
+
matcher?: string;
|
|
28
|
+
hooks: { type: string; command: string }[];
|
|
29
|
+
}
|
|
30
|
+
/** Cursor entry: a flat `{ command }`. */
|
|
31
|
+
export interface CursorHookGroup {
|
|
32
|
+
command: string;
|
|
33
|
+
type?: string;
|
|
34
|
+
matcher?: string;
|
|
35
|
+
}
|
|
36
|
+
export type HookGroup = ClaudeHookGroup | CursorHookGroup;
|
|
37
|
+
|
|
38
|
+
export interface SettingsFile {
|
|
39
|
+
version?: number;
|
|
40
|
+
hooks?: Record<string, HookGroup[]>;
|
|
41
|
+
[k: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Build a hook entry in the harness's shape. */
|
|
45
|
+
export function makeEntry(shape: HookEntryShape, command: string): HookGroup {
|
|
46
|
+
return shape === "cursor" ? { command } : { hooks: [{ type: "command", command }] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Pull every command string out of a hook entry, regardless of shape. */
|
|
50
|
+
export function groupCommands(group: HookGroup): string[] {
|
|
51
|
+
if ("command" in group && typeof group.command === "string") return [group.command];
|
|
52
|
+
if ("hooks" in group && Array.isArray(group.hooks)) {
|
|
53
|
+
return group.hooks.map((h) => h.command).filter((c): c is string => typeof c === "string");
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether a hook command string wires the given agent-hook subcommand. The
|
|
60
|
+
* trailing space is load-bearing: it keeps `stop` from matching `stop-failure`.
|
|
61
|
+
*/
|
|
62
|
+
export function commandWiresSubcommand(command: string, subcommand: string): boolean {
|
|
63
|
+
return command.includes(`agent-hook ${subcommand} `);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Pull the agent-hook subcommand out of a command string, or null if none. */
|
|
67
|
+
function commandSubcommand(command: string): string | null {
|
|
68
|
+
const m = command.match(/agent-hook\s+([a-z][a-z-]*)\s/);
|
|
69
|
+
return m ? m[1]! : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface WiringDiff {
|
|
73
|
+
/** Spec events not wired in the settings file. */
|
|
74
|
+
missing: HookEvent[];
|
|
75
|
+
/** Spec events already wired. */
|
|
76
|
+
present: HookEvent[];
|
|
77
|
+
/**
|
|
78
|
+
* agent-hook subcommands wired in the file that are NOT in the current spec
|
|
79
|
+
* (e.g. an event renamed/removed by an upgrade). Additive re-init won't clean
|
|
80
|
+
* these — they need explicit removal — so they're surfaced separately.
|
|
81
|
+
*/
|
|
82
|
+
orphans: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pure diff of one settings object against one harness spec. Read-only inverse
|
|
87
|
+
* of `wireHooks`; no fs, so it's unit-testable.
|
|
88
|
+
*/
|
|
89
|
+
export function diffWiring(settings: SettingsFile, spec: HarnessSpec): WiringDiff {
|
|
90
|
+
const missing: HookEvent[] = [];
|
|
91
|
+
const present: HookEvent[] = [];
|
|
92
|
+
const hooks = settings.hooks ?? {};
|
|
93
|
+
|
|
94
|
+
for (const event of spec.events) {
|
|
95
|
+
const groups = hooks[event.settingsKey] ?? [];
|
|
96
|
+
const wired = groups.some((g) =>
|
|
97
|
+
groupCommands(g).some((c) => commandWiresSubcommand(c, event.subcommand)),
|
|
98
|
+
);
|
|
99
|
+
(wired ? present : missing).push(event);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const specSubcommands = new Set(spec.events.map((e) => e.subcommand));
|
|
103
|
+
const orphans = new Set<string>();
|
|
104
|
+
for (const groups of Object.values(hooks)) {
|
|
105
|
+
if (!Array.isArray(groups)) continue;
|
|
106
|
+
for (const g of groups) {
|
|
107
|
+
for (const c of groupCommands(g)) {
|
|
108
|
+
const sub = commandSubcommand(c);
|
|
109
|
+
if (sub && !specSubcommands.has(sub)) orphans.add(sub);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { missing, present, orphans: [...orphans].sort() };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface HarnessWiringStatus {
|
|
118
|
+
harness: HarnessId;
|
|
119
|
+
/** Settings file path, relative to the project root. */
|
|
120
|
+
settingsFile: string;
|
|
121
|
+
missing: HookEvent[];
|
|
122
|
+
orphans: string[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Inspect every harness whose settings file exists under `projectRoot` and
|
|
127
|
+
* return only those with *drift*. Read-only; never writes.
|
|
128
|
+
*
|
|
129
|
+
* Drift is reported only for a harness the project has **already opted into** —
|
|
130
|
+
* i.e. at least one harnery hook is already wired. A settings file with zero
|
|
131
|
+
* harnery hooks just means this harness isn't harnery-wired here (a bare
|
|
132
|
+
* `.claude/settings.json` is a generic Claude Code file); that's `harn init`'s
|
|
133
|
+
* job to surface on first run, not drift to nag about every session. A harness
|
|
134
|
+
* with no settings file at all, or an unparseable one, is skipped.
|
|
135
|
+
*/
|
|
136
|
+
export function loadHarnessWiring(projectRoot: string): HarnessWiringStatus[] {
|
|
137
|
+
const out: HarnessWiringStatus[] = [];
|
|
138
|
+
for (const [id, spec] of Object.entries(HARNESS_SPECS) as [HarnessId, HarnessSpec][]) {
|
|
139
|
+
const settingsPath = resolve(projectRoot, spec.settingsFile);
|
|
140
|
+
if (!existsSync(settingsPath)) continue;
|
|
141
|
+
let settings: SettingsFile;
|
|
142
|
+
try {
|
|
143
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8")) as SettingsFile;
|
|
144
|
+
} catch {
|
|
145
|
+
// Unparseable settings file: can't tell opt-in from noise, and the
|
|
146
|
+
// harness itself will complain about its own malformed config. Skip.
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const diff = diffWiring(settings, spec);
|
|
150
|
+
if (diff.present.length === 0) continue; // not opted in → not drift
|
|
151
|
+
if (diff.missing.length === 0 && diff.orphans.length === 0) continue; // current
|
|
152
|
+
out.push({
|
|
153
|
+
harness: id,
|
|
154
|
+
settingsFile: spec.settingsFile,
|
|
155
|
+
missing: diff.missing,
|
|
156
|
+
orphans: diff.orphans,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the harnery package version for context in nudges/checks. Walks up
|
|
164
|
+
* from this module to the package root (works under Bun from `src/` and Node
|
|
165
|
+
* from `dist/`). Returns "" if unresolved — callers omit it from the message.
|
|
166
|
+
*/
|
|
167
|
+
export function harneryVersion(): string {
|
|
168
|
+
try {
|
|
169
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
170
|
+
for (let i = 0; i < 8; i++) {
|
|
171
|
+
try {
|
|
172
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
173
|
+
if (pkg.name === "harnery" && typeof pkg.version === "string") return pkg.version;
|
|
174
|
+
} catch {
|
|
175
|
+
/* no package.json here, or not ours; keep walking up */
|
|
176
|
+
}
|
|
177
|
+
const parent = dirname(dir);
|
|
178
|
+
if (parent === dir) break;
|
|
179
|
+
dir = parent;
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
/* import.meta.url unavailable or fs error */
|
|
183
|
+
}
|
|
184
|
+
return "";
|
|
185
|
+
}
|
|
@@ -49,3 +49,24 @@ export function selectAnchorPid(
|
|
|
49
49
|
}
|
|
50
50
|
return undefined;
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse one `ps -o ppid=,comm= -p <pid>` line into the same `{ ppid, comm }`
|
|
55
|
+
* shape the `/proc` fast path produces, for the macOS/BSD branch of the anchor
|
|
56
|
+
* walk. `ps` prints `comm` as a full executable path (and some Apple helper
|
|
57
|
+
* names contain spaces, e.g. `Code Helper (Plugin)`), so the comm is reduced to
|
|
58
|
+
* its basename to match the harness comm tokens the way Linux's `/proc/<pid>/comm`
|
|
59
|
+
* basename does. Returns null when the line has no leading numeric ppid.
|
|
60
|
+
*
|
|
61
|
+
* Pure (no I/O) so the parsing — the error-prone part — is unit-testable
|
|
62
|
+
* without a live process tree; the caller owns the `ps` spawn.
|
|
63
|
+
*/
|
|
64
|
+
export function parsePsChainLine(line: string): { ppid: number; comm: string } | null {
|
|
65
|
+
const m = line.trim().match(/^(\d+)\s+(.*)$/);
|
|
66
|
+
if (!m) return null;
|
|
67
|
+
const ppid = Number.parseInt(m[1]!, 10);
|
|
68
|
+
if (!Number.isFinite(ppid)) return null;
|
|
69
|
+
const commPath = m[2]!.trim();
|
|
70
|
+
const comm = commPath.split("/").pop() || commPath;
|
|
71
|
+
return { ppid, comm };
|
|
72
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { coordEnv } from "../../../lib/env.ts";
|
|
@@ -73,18 +74,26 @@ export function resolveOwner(opts: {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
function readPpid(pid: number): number | null {
|
|
76
|
-
// Linux/WSL: /proc/<pid>/status carries `PPid:`.
|
|
77
|
-
// macOS or any read failure; ancestor walk just terminates.
|
|
77
|
+
// Linux/WSL fast path: /proc/<pid>/status carries `PPid:`.
|
|
78
78
|
try {
|
|
79
79
|
const status = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
const m = status.match(/^PPid:\s+(\d+)/m);
|
|
81
|
+
if (m) {
|
|
82
|
+
const n = Number.parseInt(m[1]!, 10);
|
|
83
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
/* no /proc (macOS/BSD) — fall through to ps */
|
|
87
|
+
}
|
|
88
|
+
// Portable fallback: `ps -o ppid= -p <pid>` works on macOS/BSD/Linux.
|
|
89
|
+
try {
|
|
90
|
+
const out = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
|
|
91
|
+
if (out.status === 0) {
|
|
92
|
+
const n = Number.parseInt(out.stdout.trim(), 10);
|
|
93
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
85
94
|
}
|
|
86
95
|
} catch {
|
|
87
|
-
/*
|
|
96
|
+
/* ps unavailable — give up */
|
|
88
97
|
}
|
|
89
98
|
return null;
|
|
90
99
|
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `harn uninstall`: reverse what `harn init` wired into a project.
|
|
3
|
-
*
|
|
4
|
-
* `init` makes two kinds of change outside the harnery package:
|
|
5
|
-
* 1. Merges `agent-hook` entries into the harness settings file
|
|
6
|
-
* (Claude Code `.claude/settings.json`, Cursor `.cursor/hooks.json`, or
|
|
7
|
-
* Codex `.codex/hooks.json`).
|
|
8
|
-
* 2. Creates the `.harnery/` coord root (runtime state: events, councils,
|
|
9
|
-
* identities, scratch) and stamps the host bin name into
|
|
10
|
-
* `.harnery/config.jsonc`.
|
|
11
|
-
*
|
|
12
|
-
* `uninstall` undoes (1) by default: it removes only harnery's hook entries from
|
|
13
|
-
* the settings file, preserving any other hooks the consumer added, and deletes
|
|
14
|
-
* the settings file outright when it's left harnery-only. It does NOT touch the
|
|
15
|
-
* `.harnery/` coord root unless `--purge-state` is passed, because that directory
|
|
16
|
-
* holds session history a consumer may want to keep. Idempotent + `--dry-run`,
|
|
17
|
-
* mirroring `init`.
|
|
18
|
-
*/
|
|
19
|
-
import type { Command } from "commander";
|
|
20
|
-
import type { EmitContext } from "../commander.js";
|
|
21
|
-
export declare function registerUninstallCommand(program: Command, emit: EmitContext): void;
|
|
22
|
-
//# sourceMappingURL=uninstall.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"uninstall.d.ts","sourceRoot":"","sources":["../../src/commands/uninstall.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAWnD,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAoFlF"}
|