harnery 0.3.2 → 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/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 +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/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/package.json +1 -1
- package/schemas/config.schema.json +4 -0
- package/src/commander.ts +2 -2
- 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 +28 -11
- package/src/core/agents/state/heartbeat-writer.ts +8 -1
- package/src/core/config.ts +21 -0
- package/src/core/hooks/harness/wiring.ts +185 -0
- package/dist/commands/uninstall.d.ts +0 -22
- package/dist/commands/uninstall.d.ts.map +0 -1
|
@@ -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
|
+
}
|
|
@@ -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"}
|