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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `harn
|
|
2
|
+
* `harn deinit`: reverse what `harn init` wired into a project.
|
|
3
3
|
*
|
|
4
4
|
* `init` makes two kinds of change outside the harnery package:
|
|
5
5
|
* 1. Merges `agent-hook` entries into the harness settings file
|
|
@@ -9,42 +9,60 @@
|
|
|
9
9
|
* identities, scratch) and stamps the host bin name into
|
|
10
10
|
* `.harnery/config.jsonc`.
|
|
11
11
|
*
|
|
12
|
-
* `
|
|
12
|
+
* `deinit` undoes (1) by default: it removes only harnery's hook entries from
|
|
13
13
|
* the settings file, preserving any other hooks the consumer added, and deletes
|
|
14
14
|
* the settings file outright when it's left harnery-only. It does NOT touch the
|
|
15
15
|
* `.harnery/` coord root unless `--purge-state` is passed, because that directory
|
|
16
16
|
* holds session history a consumer may want to keep. Idempotent + `--dry-run`,
|
|
17
17
|
* mirroring `init`.
|
|
18
|
+
*
|
|
19
|
+
* For standalone `harn` on a terminal it also handles the destructive extra
|
|
20
|
+
* conversationally: when `--purge-state` wasn't passed and `.harnery/` exists, it
|
|
21
|
+
* asks before deleting it, and afterward it prints how to remove the harnery CLI
|
|
22
|
+
* itself (which a running process can't do to its own package). Both are gated to
|
|
23
|
+
* standalone harn — an embedding host routes output through its own emit and owns
|
|
24
|
+
* its install lifecycle — and the prompt never fires off a TTY, so scripted / CI
|
|
25
|
+
* runs keep the flag-driven behavior untouched. The shell wrapper `scripts/teardown.sh`
|
|
26
|
+
* is the fuller mirror (it also unlinks the PATH bins and can delete the clone).
|
|
18
27
|
*/
|
|
19
28
|
|
|
20
29
|
import { spawnSync } from "node:child_process";
|
|
21
30
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
22
31
|
import { relative, resolve } from "node:path";
|
|
32
|
+
import { createInterface } from "node:readline";
|
|
23
33
|
import type { Command } from "commander";
|
|
24
34
|
import type { EmitContext } from "../commander.ts";
|
|
35
|
+
import { DEFAULT_BIN_NAME } from "../core/config.ts";
|
|
25
36
|
import { HARNESS_SPECS, type HarnessId } from "../core/hooks/harness/events.ts";
|
|
26
37
|
import { type SettingsFile, unwireHooks } from "./init.ts";
|
|
27
38
|
|
|
28
|
-
interface
|
|
39
|
+
interface DeinitOpts {
|
|
29
40
|
harness: string;
|
|
30
41
|
dryRun?: boolean;
|
|
31
42
|
projectRoot?: string;
|
|
32
43
|
purgeState?: boolean;
|
|
33
44
|
}
|
|
34
45
|
|
|
35
|
-
export function
|
|
46
|
+
export function registerDeinitCommand(program: Command, emit: EmitContext, binName?: string): void {
|
|
47
|
+
// The interactive prompt + engine-removal hint are standalone-harn niceties.
|
|
48
|
+
// An embedding host routes output through its own emit and owns its install
|
|
49
|
+
// lifecycle, so for a host (binName set + non-default) deinit stays strictly
|
|
50
|
+
// flag-driven and says nothing about removing "the package".
|
|
51
|
+
const standalone = !binName || binName === DEFAULT_BIN_NAME;
|
|
52
|
+
|
|
36
53
|
program
|
|
37
|
-
.command("
|
|
54
|
+
.command("deinit")
|
|
38
55
|
.description(
|
|
39
56
|
"Reverse `harn init`: remove harnery's hook entries from the harness " +
|
|
40
57
|
"settings file (keeps any others). Pass --purge-state to also delete the " +
|
|
41
|
-
".harnery/ coord root. Idempotent; use
|
|
58
|
+
".harnery/ coord root (on a terminal it asks first). Idempotent; use " +
|
|
59
|
+
"--dry-run to preview.",
|
|
42
60
|
)
|
|
43
61
|
.option("--harness <id>", "claude-code | cursor | codex", "claude-code")
|
|
44
62
|
.option("--dry-run", "Show what would change without writing")
|
|
45
63
|
.option("--project-root <path>", "Project root (default: git toplevel, else cwd)")
|
|
46
64
|
.option("--purge-state", "Also delete the .harnery/ coord root (runtime state, destructive)")
|
|
47
|
-
.action((opts:
|
|
65
|
+
.action(async (opts: DeinitOpts) => {
|
|
48
66
|
const harness = opts.harness as HarnessId;
|
|
49
67
|
const spec = HARNESS_SPECS[harness];
|
|
50
68
|
if (!spec) {
|
|
@@ -55,8 +73,24 @@ export function registerUninstallCommand(program: Command, emit: EmitContext): v
|
|
|
55
73
|
|
|
56
74
|
const projectRoot = resolve(opts.projectRoot ?? gitTopLevel() ?? process.cwd());
|
|
57
75
|
const dryRun = opts.dryRun === true;
|
|
76
|
+
const coordDir = resolve(projectRoot, ".harnery");
|
|
77
|
+
const coordExists = existsSync(coordDir);
|
|
78
|
+
let purgeState = opts.purgeState === true;
|
|
58
79
|
const actions: string[] = [];
|
|
59
80
|
|
|
81
|
+
// ── 0. interactive: offer to delete .harnery/ (standalone harn, TTY) ───
|
|
82
|
+
if (
|
|
83
|
+
shouldPromptForState({
|
|
84
|
+
standalone,
|
|
85
|
+
interactive: process.stdin.isTTY === true,
|
|
86
|
+
dryRun,
|
|
87
|
+
purgeState,
|
|
88
|
+
coordExists,
|
|
89
|
+
})
|
|
90
|
+
) {
|
|
91
|
+
purgeState = await confirmDeleteState(coordDir);
|
|
92
|
+
}
|
|
93
|
+
|
|
60
94
|
// ── 1. unwire harness hooks ────────────────────────────────────────────
|
|
61
95
|
const settingsPath = resolve(projectRoot, spec.settingsFile);
|
|
62
96
|
if (!existsSync(settingsPath)) {
|
|
@@ -100,9 +134,8 @@ export function registerUninstallCommand(program: Command, emit: EmitContext): v
|
|
|
100
134
|
}
|
|
101
135
|
|
|
102
136
|
// ── 2. coord root (opt-in; destructive) ────────────────────────────────
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!existsSync(coordDir)) {
|
|
137
|
+
if (purgeState) {
|
|
138
|
+
if (!coordExists) {
|
|
106
139
|
actions.push("· .harnery/ doesn't exist; nothing to purge");
|
|
107
140
|
} else if (dryRun) {
|
|
108
141
|
actions.push("+ would delete .harnery/ and all coord state (events, councils, scratch)");
|
|
@@ -110,12 +143,65 @@ export function registerUninstallCommand(program: Command, emit: EmitContext): v
|
|
|
110
143
|
rmSync(coordDir, { recursive: true, force: true });
|
|
111
144
|
actions.push("+ deleted .harnery/ and all coord state");
|
|
112
145
|
}
|
|
113
|
-
} else if (
|
|
146
|
+
} else if (coordExists) {
|
|
114
147
|
actions.push("· left .harnery/ coord root in place (pass --purge-state to delete it)");
|
|
115
148
|
}
|
|
116
149
|
|
|
117
|
-
|
|
150
|
+
const hint = standalone && !dryRun ? engineRemovalHint() : null;
|
|
151
|
+
emit.text(render(projectRoot, dryRun, actions, hint));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Whether to interactively ask before deleting `.harnery/`. True only for
|
|
157
|
+
* standalone harn on a TTY, when the user didn't already answer with
|
|
158
|
+
* --purge-state, it isn't a dry run, and there's actually a coord root to
|
|
159
|
+
* delete. Pure so the gating is unit-testable without a terminal.
|
|
160
|
+
*/
|
|
161
|
+
export function shouldPromptForState(o: {
|
|
162
|
+
standalone: boolean;
|
|
163
|
+
interactive: boolean;
|
|
164
|
+
dryRun: boolean;
|
|
165
|
+
purgeState: boolean;
|
|
166
|
+
coordExists: boolean;
|
|
167
|
+
}): boolean {
|
|
168
|
+
return o.standalone && o.interactive && !o.dryRun && !o.purgeState && o.coordExists;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The "harnery itself is still installed" line shown to standalone-harn users
|
|
173
|
+
* after a real deinit. `harn deinit` can't remove the package it's running
|
|
174
|
+
* from, so it points at the two ways to finish the job. Pure + exported for the
|
|
175
|
+
* test.
|
|
176
|
+
*/
|
|
177
|
+
export function engineRemovalHint(): string {
|
|
178
|
+
return (
|
|
179
|
+
"harnery itself is still installed. To remove the CLI too: `npm rm -g harnery` " +
|
|
180
|
+
"(if you installed it with npm/bun); from a git clone, " +
|
|
181
|
+
"`scripts/teardown.sh --remove-clone` removes the checkout."
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Mirror scripts/teardown.sh's wording: explain what .harnery/ holds, then ask. */
|
|
186
|
+
function confirmDeleteState(coordDir: string): Promise<boolean> {
|
|
187
|
+
process.stdout.write(
|
|
188
|
+
"\nharnery saved this project's coordination history in .harnery/\n" +
|
|
189
|
+
"(its event log, councils, agent identities, and scratchpads):\n" +
|
|
190
|
+
` ${coordDir}\n` +
|
|
191
|
+
"Unwiring leaves that in place. Deleting it can't be undone.\n",
|
|
192
|
+
);
|
|
193
|
+
return confirm("Delete this project's harnery history too? [y/N]");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Read one y/N answer from the TTY. Yes only on an explicit y / yes. */
|
|
197
|
+
function confirm(question: string): Promise<boolean> {
|
|
198
|
+
return new Promise((resolveAnswer) => {
|
|
199
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
200
|
+
rl.question(`${question} `, (answer) => {
|
|
201
|
+
rl.close();
|
|
202
|
+
resolveAnswer(/^y(es)?$/i.test(answer.trim()));
|
|
118
203
|
});
|
|
204
|
+
});
|
|
119
205
|
}
|
|
120
206
|
|
|
121
207
|
/** True when an unwired settings object holds nothing but (optionally) `version`. */
|
|
@@ -124,12 +210,18 @@ function harnessOnly(settings: SettingsFile): boolean {
|
|
|
124
210
|
return keys.length === 0 || (keys.length === 1 && keys[0] === "version");
|
|
125
211
|
}
|
|
126
212
|
|
|
127
|
-
function render(
|
|
128
|
-
|
|
213
|
+
function render(
|
|
214
|
+
projectRoot: string,
|
|
215
|
+
dryRun: boolean,
|
|
216
|
+
actions: string[],
|
|
217
|
+
hint: string | null,
|
|
218
|
+
): string {
|
|
219
|
+
const head = dryRun ? "harn deinit (dry run): no changes written" : "harn deinit";
|
|
129
220
|
const tail = dryRun
|
|
130
221
|
? "\nRe-run without --dry-run to apply."
|
|
131
222
|
: "\nDone. harnery hooks are unwired; restart your harness session to drop them.";
|
|
132
|
-
|
|
223
|
+
const hintBlock = hint ? `\n\n${hint}` : "";
|
|
224
|
+
return `${head}\n root: ${projectRoot}\n${actions.map((a) => ` ${a}`).join("\n")}${tail}${hintBlock}`;
|
|
133
225
|
}
|
|
134
226
|
|
|
135
227
|
function gitTopLevel(): string | null {
|
package/src/commands/doctor.ts
CHANGED
|
@@ -17,6 +17,8 @@ import os from "node:os";
|
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import type { Command } from "commander";
|
|
19
19
|
import type { EmitContext } from "../commander.ts";
|
|
20
|
+
import { resolveBinName } from "../core/config.ts";
|
|
21
|
+
import { loadHarnessWiring } from "../core/hooks/harness/wiring.ts";
|
|
20
22
|
|
|
21
23
|
type Severity = "ok" | "warn" | "fail";
|
|
22
24
|
|
|
@@ -79,6 +81,7 @@ export function runChecks(): Check[] {
|
|
|
79
81
|
checkGit(),
|
|
80
82
|
checkBun(),
|
|
81
83
|
checkHarneryDir(),
|
|
84
|
+
checkHarnessHooks(),
|
|
82
85
|
checkRestic(),
|
|
83
86
|
checkRclone(),
|
|
84
87
|
checkPlaywright(),
|
|
@@ -203,21 +206,23 @@ function checkPython(): Check {
|
|
|
203
206
|
return { name: "python3", severity: "ok", detail: r.out };
|
|
204
207
|
}
|
|
205
208
|
|
|
206
|
-
|
|
207
|
-
|
|
209
|
+
/** Walk up from cwd to the nearest dir containing `.harnery/`; null if none. */
|
|
210
|
+
function findCoordProjectRoot(): string | null {
|
|
208
211
|
let dir = process.cwd();
|
|
209
212
|
for (let i = 0; i < 8; i++) {
|
|
210
|
-
if (existsSync(path.join(dir, ".harnery")))
|
|
211
|
-
return {
|
|
212
|
-
name: ".harnery/",
|
|
213
|
-
severity: "ok",
|
|
214
|
-
detail: path.join(dir, ".harnery"),
|
|
215
|
-
};
|
|
216
|
-
}
|
|
213
|
+
if (existsSync(path.join(dir, ".harnery"))) return dir;
|
|
217
214
|
const parent = path.dirname(dir);
|
|
218
215
|
if (parent === dir) break;
|
|
219
216
|
dir = parent;
|
|
220
217
|
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function checkHarneryDir(): Check {
|
|
222
|
+
const root = findCoordProjectRoot();
|
|
223
|
+
if (root) {
|
|
224
|
+
return { name: ".harnery/", severity: "ok", detail: path.join(root, ".harnery") };
|
|
225
|
+
}
|
|
221
226
|
return {
|
|
222
227
|
name: ".harnery/",
|
|
223
228
|
severity: "warn",
|
|
@@ -226,6 +231,39 @@ function checkHarneryDir(): Check {
|
|
|
226
231
|
};
|
|
227
232
|
}
|
|
228
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Compare the project's wired harness hooks against HARNESS_SPECS. Catches the
|
|
236
|
+
* post-upgrade case where a harnery release added (or renamed) a hook event but
|
|
237
|
+
* the consumer's settings file hasn't been re-wired. Only fires for a harness
|
|
238
|
+
* the project has opted into (≥1 harnery hook already wired) — see
|
|
239
|
+
* loadHarnessWiring — so a bare settings file never false-warns. The remedy is
|
|
240
|
+
* always the same: re-run `<bin> init` (idempotent, additive).
|
|
241
|
+
*/
|
|
242
|
+
function checkHarnessHooks(): Check {
|
|
243
|
+
const root = findCoordProjectRoot();
|
|
244
|
+
if (!root) {
|
|
245
|
+
return { name: "harness hooks", severity: "ok", detail: "n/a (no .harnery/ above cwd)" };
|
|
246
|
+
}
|
|
247
|
+
const drift = loadHarnessWiring(root);
|
|
248
|
+
if (drift.length === 0) {
|
|
249
|
+
return { name: "harness hooks", severity: "ok", detail: "wired + current" };
|
|
250
|
+
}
|
|
251
|
+
const bin = resolveBinName(root);
|
|
252
|
+
const parts = drift.map((d) => {
|
|
253
|
+
const bits: string[] = [];
|
|
254
|
+
if (d.missing.length > 0) {
|
|
255
|
+
bits.push(`${d.missing.length} missing (${d.missing.map((m) => m.subcommand).join(", ")})`);
|
|
256
|
+
}
|
|
257
|
+
if (d.orphans.length > 0) bits.push(`${d.orphans.length} orphaned (${d.orphans.join(", ")})`);
|
|
258
|
+
return `${d.settingsFile}: ${bits.join("; ")}`;
|
|
259
|
+
});
|
|
260
|
+
const hasOrphans = drift.some((d) => d.orphans.length > 0);
|
|
261
|
+
const hint = hasOrphans
|
|
262
|
+
? `run \`${bin} init\` to wire missing hooks; remove orphaned entries (renamed/dropped events) with \`${bin} deinit\` then \`${bin} init\``
|
|
263
|
+
: `run \`${bin} init\` to wire the new hook(s) (idempotent)`;
|
|
264
|
+
return { name: "harness hooks", severity: "warn", detail: parts.join(" | "), hint };
|
|
265
|
+
}
|
|
266
|
+
|
|
229
267
|
function macOrLinux(mac: string, linux: string): string {
|
|
230
268
|
return os.platform() === "darwin" ? mac : linux;
|
|
231
269
|
}
|
package/src/commands/init.ts
CHANGED
|
@@ -23,12 +23,18 @@ import { fileURLToPath } from "node:url";
|
|
|
23
23
|
import type { Command } from "commander";
|
|
24
24
|
import type { EmitContext } from "../commander.ts";
|
|
25
25
|
import { DEFAULT_BIN_NAME, stripJsonComments } from "../core/config.ts";
|
|
26
|
+
import { HARNESS_SPECS, type HarnessId, type HarnessSpec } from "../core/hooks/harness/events.ts";
|
|
26
27
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
type
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
commandWiresSubcommand,
|
|
29
|
+
groupCommands,
|
|
30
|
+
type HookGroup,
|
|
31
|
+
makeEntry,
|
|
32
|
+
type SettingsFile,
|
|
33
|
+
} from "../core/hooks/harness/wiring.ts";
|
|
34
|
+
|
|
35
|
+
// Re-exported for back-compat: callers (deinit, tests) import these names
|
|
36
|
+
// from init.ts. The definitions now live in core/hooks/harness/wiring.ts.
|
|
37
|
+
export type { HookGroup, SettingsFile };
|
|
32
38
|
|
|
33
39
|
// This file is src/commands/init.ts → harnery package root is two levels up.
|
|
34
40
|
const HARNERY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
@@ -39,25 +45,6 @@ interface InitOpts {
|
|
|
39
45
|
projectRoot?: string;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
/** Claude Code + Codex entry: `{ hooks: [{ type, command }] }`. */
|
|
43
|
-
interface ClaudeHookGroup {
|
|
44
|
-
matcher?: string;
|
|
45
|
-
hooks: { type: string; command: string }[];
|
|
46
|
-
}
|
|
47
|
-
/** Cursor entry: a flat `{ command }`. */
|
|
48
|
-
interface CursorHookGroup {
|
|
49
|
-
command: string;
|
|
50
|
-
type?: string;
|
|
51
|
-
matcher?: string;
|
|
52
|
-
}
|
|
53
|
-
type HookGroup = ClaudeHookGroup | CursorHookGroup;
|
|
54
|
-
|
|
55
|
-
export interface SettingsFile {
|
|
56
|
-
version?: number;
|
|
57
|
-
hooks?: Record<string, HookGroup[]>;
|
|
58
|
-
[k: string]: unknown;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
48
|
export function registerInitCommand(program: Command, emit: EmitContext, binName?: string): void {
|
|
62
49
|
program
|
|
63
50
|
.command("init")
|
|
@@ -173,7 +160,7 @@ export function wireHooks(
|
|
|
173
160
|
const command = `bash ${agentHookPath} ${subcommand} --harness ${harness}`;
|
|
174
161
|
const groups = settings.hooks[settingsKey] ?? [];
|
|
175
162
|
const present = groups.some((g) =>
|
|
176
|
-
groupCommands(g).some((c) => c
|
|
163
|
+
groupCommands(g).some((c) => commandWiresSubcommand(c, subcommand)),
|
|
177
164
|
);
|
|
178
165
|
if (present) {
|
|
179
166
|
already++;
|
|
@@ -217,20 +204,6 @@ export function unwireHooks(settings: SettingsFile): { removed: number; remainin
|
|
|
217
204
|
return { removed, remaining };
|
|
218
205
|
}
|
|
219
206
|
|
|
220
|
-
/** Build a hook entry in the harness's shape. */
|
|
221
|
-
function makeEntry(shape: HookEntryShape, command: string): HookGroup {
|
|
222
|
-
return shape === "cursor" ? { command } : { hooks: [{ type: "command", command }] };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Pull every command string out of a hook entry, regardless of shape. */
|
|
226
|
-
function groupCommands(group: HookGroup): string[] {
|
|
227
|
-
if ("command" in group && typeof group.command === "string") return [group.command];
|
|
228
|
-
if ("hooks" in group && Array.isArray(group.hooks)) {
|
|
229
|
-
return group.hooks.map((h) => h.command).filter((c): c is string => typeof c === "string");
|
|
230
|
-
}
|
|
231
|
-
return [];
|
|
232
|
-
}
|
|
233
|
-
|
|
234
207
|
/**
|
|
235
208
|
* Idempotently record `binName` in `.harnery/config.jsonc`, preserving any
|
|
236
209
|
* existing JSONC comments and the `files` section. Returns an action line, or
|
|
@@ -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 {
|
|
@@ -101,15 +101,37 @@ export function evaluateClaim(coordRoot: string, req: ClaimRequest): VerdictResu
|
|
|
101
101
|
const hasFreshPeers = otherPeers.some(
|
|
102
102
|
(p) => isFresh(p.last_heartbeat) && p.files_touched.length > 0,
|
|
103
103
|
);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
104
|
+
// Re-editing a path already in our own files_touched acquires no new lock
|
|
105
|
+
// edge, so it can't create a circular wait — the ordering rule must not block
|
|
106
|
+
// it. Without this exemption, an agent that edits a higher-sorting file and
|
|
107
|
+
// then makes a second pass over an already-held lower-sorting file gets a
|
|
108
|
+
// spurious ordering_violation (the dominant friction source under concurrency:
|
|
109
|
+
// both agent-Gibson holding README.md and agent-Ophelia holding AGENTS.md were
|
|
110
|
+
// blocked re-editing those held files after touching a higher path, 2026-07-03).
|
|
111
|
+
const alreadyHeld = myPeer?.files_touched.includes(req.path) ?? false;
|
|
112
|
+
if (hasFreshPeers && myPeer && myPeer.files_touched.length > 0 && !alreadyHeld) {
|
|
113
|
+
// Only ACTIVE (uncommitted) edits should constrain lock ordering. A claim on
|
|
114
|
+
// a committed-clean file is a finished edit, not a held lock, so it must not
|
|
115
|
+
// wall off a lower-sorted acquisition. Without this, a long session
|
|
116
|
+
// accumulates committed claims that block every earlier-sorted path — pure
|
|
117
|
+
// friction, no deadlock risk (the file isn't being touched). Mirrors the
|
|
118
|
+
// peer stale-claim self-heal above. The git probes run only on the
|
|
119
|
+
// would-block path (claims sorting after req.path), staying off the hot path.
|
|
120
|
+
const blockers = myPeer.files_touched.filter((p) => req.path < p);
|
|
121
|
+
if (blockers.length > 0) {
|
|
122
|
+
const activeBlockers = blockers.filter((p) => !isFileCommittedClean(coordRoot, p));
|
|
123
|
+
if (activeBlockers.length > 0) {
|
|
124
|
+
const highest = [...activeBlockers].sort().at(-1)!;
|
|
125
|
+
return {
|
|
126
|
+
allow: false,
|
|
127
|
+
exit_code: 2,
|
|
128
|
+
rule: "claim.ordering_violation",
|
|
129
|
+
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.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Every blocker is a finished (committed-clean) edit: prune them so they
|
|
133
|
+
// stop constraining future acquisitions, then fall through to allow.
|
|
134
|
+
for (const p of blockers) pruneClaimFromPeer(coordRoot, req.instance_id, p);
|
|
113
135
|
}
|
|
114
136
|
}
|
|
115
137
|
|
|
@@ -240,16 +262,19 @@ function isFresh(lastHeartbeat: string): boolean {
|
|
|
240
262
|
* - diff shows non-empty output (genuinely dirty)
|
|
241
263
|
*/
|
|
242
264
|
function isFileCommittedClean(coordRoot: string, relPath: string): boolean {
|
|
243
|
-
|
|
265
|
+
// Tolerate either path form: files_touched can hold absolute-under-coordRoot
|
|
266
|
+
// entries (legacy file-tracking) or canonical monorepo-relative ones.
|
|
267
|
+
const rel = relPath.startsWith(`${coordRoot}/`) ? relPath.slice(coordRoot.length + 1) : relPath;
|
|
268
|
+
const abs = join(coordRoot, rel);
|
|
244
269
|
if (!existsSync(abs)) return false;
|
|
245
270
|
try {
|
|
246
|
-
const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--",
|
|
271
|
+
const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--", rel], {
|
|
247
272
|
cwd: coordRoot,
|
|
248
273
|
encoding: "utf8",
|
|
249
274
|
timeout: 2000,
|
|
250
275
|
});
|
|
251
276
|
if (tracked.status !== 0) return false;
|
|
252
|
-
const result = spawnSync("git", ["diff", "--quiet", "HEAD", "--",
|
|
277
|
+
const result = spawnSync("git", ["diff", "--quiet", "HEAD", "--", rel], {
|
|
253
278
|
cwd: coordRoot,
|
|
254
279
|
encoding: "utf8",
|
|
255
280
|
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
|
+
}
|