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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"owner.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/owner.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"owner.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/owner.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE;IACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,KAAK,GAAG,SAAS,GAAG,aAAa,GAAG,iBAAiB,CAAA;CAAE,GAAG,IAAI,CA+ChG;AA2BD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ7E;AAED,wEAAwE;AACxE,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUxF;AAED,2DAA2D;AAC3D,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAgBnF"}
|
|
@@ -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.js";
|
|
@@ -67,19 +68,30 @@ export function resolveOwner(opts) {
|
|
|
67
68
|
return null;
|
|
68
69
|
}
|
|
69
70
|
function readPpid(pid) {
|
|
70
|
-
// Linux/WSL: /proc/<pid>/status carries `PPid:`.
|
|
71
|
-
// macOS or any read failure; ancestor walk just terminates.
|
|
71
|
+
// Linux/WSL fast path: /proc/<pid>/status carries `PPid:`.
|
|
72
72
|
try {
|
|
73
73
|
const status = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
const m = status.match(/^PPid:\s+(\d+)/m);
|
|
75
|
+
if (m) {
|
|
76
|
+
const n = Number.parseInt(m[1], 10);
|
|
77
|
+
if (Number.isFinite(n) && n > 0)
|
|
78
|
+
return n;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* no /proc (macOS/BSD) — fall through to ps */
|
|
83
|
+
}
|
|
84
|
+
// Portable fallback: `ps -o ppid= -p <pid>` works on macOS/BSD/Linux.
|
|
85
|
+
try {
|
|
86
|
+
const out = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
|
|
87
|
+
if (out.status === 0) {
|
|
88
|
+
const n = Number.parseInt(out.stdout.trim(), 10);
|
|
89
|
+
if (Number.isFinite(n) && n > 0)
|
|
90
|
+
return n;
|
|
79
91
|
}
|
|
80
92
|
}
|
|
81
93
|
catch {
|
|
82
|
-
/*
|
|
94
|
+
/* ps unavailable — give up */
|
|
83
95
|
}
|
|
84
96
|
return null;
|
|
85
97
|
}
|
package/package.json
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
"default": "harn",
|
|
20
20
|
"description": "Host CLI bin name used in agent-facing strings (council prompts, end-of-turn nudges, command help). The coord binaries + web UI run as harnery and read this back since they can't see a consumer CLI's name. Stamped by `harn init` for a consumer. Resolution: HARNERY_BIN env -> this field -> 'harn'."
|
|
21
21
|
},
|
|
22
|
+
"hooksSetupHint": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "Host-specific command that (re)installs the project's git hooks (e.g. 'scripts/setup-hooks.sh'). Surfaced verbatim in the SessionStart 'commit guard not wired' nudge. harnery doesn't own git-hook installation (each host wires its own pre-commit to invoke `agent-coord verdict`) and the path/command is host-specific, so the host declares it here. Unset -> a generic, host-agnostic hint."
|
|
25
|
+
},
|
|
22
26
|
"coord": {
|
|
23
27
|
"type": "object",
|
|
24
28
|
"additionalProperties": false,
|
package/src/commander.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { registerCompletionCommand } from "./commands/completion.ts";
|
|
|
27
27
|
import { registerConfigGetCommand } from "./commands/config-get.ts";
|
|
28
28
|
import { registerContextCommand } from "./commands/context.ts";
|
|
29
29
|
import { registerCookiesCommand } from "./commands/cookies.ts";
|
|
30
|
+
import { registerDeinitCommand } from "./commands/deinit.ts";
|
|
30
31
|
import { registerDocsCommand } from "./commands/docs.ts";
|
|
31
32
|
import { registerDoctorCommand } from "./commands/doctor.ts";
|
|
32
33
|
import { registerEditBatchCommand } from "./commands/edit-batch.ts";
|
|
@@ -45,7 +46,6 @@ import { registerSyncCommand } from "./commands/sync.ts";
|
|
|
45
46
|
import { registerSectionCommand, registerTocCommand } from "./commands/toc.ts";
|
|
46
47
|
import { registerTokensCommand } from "./commands/tokens.ts";
|
|
47
48
|
import { registerTunnelCommand } from "./commands/tunnel.ts";
|
|
48
|
-
import { registerUninstallCommand } from "./commands/uninstall.ts";
|
|
49
49
|
import { registerWebCommand } from "./commands/web.ts";
|
|
50
50
|
|
|
51
51
|
export interface HarneryContextOpts {
|
|
@@ -230,7 +230,7 @@ export function createHarneryProgram(opts: HarneryContextOpts = {}): Command {
|
|
|
230
230
|
registerAgentsCommand(program, emit);
|
|
231
231
|
registerDoctorCommand(program, emit);
|
|
232
232
|
registerInitCommand(program, emit, opts.binName);
|
|
233
|
-
|
|
233
|
+
registerDeinitCommand(program, emit, opts.binName);
|
|
234
234
|
registerBackupCommand(program, emit);
|
|
235
235
|
registerSyncCommand(program, emit);
|
|
236
236
|
if (include("web")) registerWebCommand(program, emit);
|
package/src/commands/agents.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
resolveOwnerWithSource,
|
|
39
39
|
} from "../core/agents/index.ts";
|
|
40
40
|
import { resolveBinName } from "../core/config.ts";
|
|
41
|
+
import { parsePsChainLine } from "../core/hooks/resolve/anchor.ts";
|
|
41
42
|
import {
|
|
42
43
|
buildCouncilId,
|
|
43
44
|
buildInviteMarkdown,
|
|
@@ -2692,13 +2693,25 @@ function runHarnessProbe(
|
|
|
2692
2693
|
for (let hops = 0; hops < 20; hops++) {
|
|
2693
2694
|
let comm = "?";
|
|
2694
2695
|
let ppid = 0;
|
|
2696
|
+
let got = false;
|
|
2695
2697
|
try {
|
|
2696
2698
|
comm = readFileSync(`/proc/${walkPid}/comm`, "utf8").trim() || "?";
|
|
2697
2699
|
const status = readFileSync(`/proc/${walkPid}/status`, "utf8");
|
|
2698
2700
|
const m = status.match(/^PPid:\s+(\d+)/m);
|
|
2699
2701
|
ppid = m ? Number(m[1]) : 0;
|
|
2702
|
+
got = true;
|
|
2700
2703
|
} catch {
|
|
2701
|
-
// non-Linux (no /proc)
|
|
2704
|
+
// non-Linux (no /proc): fall through to the portable ps walk below.
|
|
2705
|
+
}
|
|
2706
|
+
if (!got) {
|
|
2707
|
+
const out = spawnSync("ps", ["-o", "ppid=,comm=", "-p", String(walkPid)], {
|
|
2708
|
+
encoding: "utf8",
|
|
2709
|
+
});
|
|
2710
|
+
const parsed = out.status === 0 ? parsePsChainLine(out.stdout) : null;
|
|
2711
|
+
if (parsed) {
|
|
2712
|
+
comm = parsed.comm || "?";
|
|
2713
|
+
ppid = parsed.ppid;
|
|
2714
|
+
}
|
|
2702
2715
|
}
|
|
2703
2716
|
chainParts.push(`${walkPid}:${comm}`);
|
|
2704
2717
|
if (!anchorPid && anchorTokens.has(comm)) anchorPid = String(walkPid);
|
|
@@ -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
|
|
@@ -361,13 +361,26 @@ export function selfDisplayName(): string {
|
|
|
361
361
|
}
|
|
362
362
|
|
|
363
363
|
function readPpid(pid: number): number | null {
|
|
364
|
+
// Linux/WSL fast path: /proc/<pid>/status carries `PPid:`.
|
|
364
365
|
try {
|
|
365
366
|
const status = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
366
367
|
const m = status.match(/^PPid:\s+(\d+)/m);
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
368
|
+
if (m) {
|
|
369
|
+
const parsed = Number.parseInt(m[1]!, 10);
|
|
370
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
371
|
+
}
|
|
370
372
|
} catch {
|
|
371
|
-
|
|
373
|
+
// no /proc (macOS/BSD) — fall through to ps
|
|
372
374
|
}
|
|
375
|
+
// Portable fallback: `ps -o ppid= -p <pid>` works on macOS/BSD/Linux.
|
|
376
|
+
try {
|
|
377
|
+
const out = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
|
|
378
|
+
if (out.status === 0) {
|
|
379
|
+
const parsed = Number.parseInt(out.stdout.trim(), 10);
|
|
380
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
// ps unavailable — give up
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
373
386
|
}
|