@gethmy/mcp 3.2.0 → 3.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.
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Installing the `PostToolUse` hook — and why only into the USER layer (#874).
3
+ *
4
+ * ## The project layer is not a preference, it is refused
5
+ *
6
+ * `assertNoProjectSandboxOverride` (`packages/harmony-harness/src/run-containment.ts`)
7
+ * reads a worktree's `.claude/settings.json` and `.claude/settings.local.json`
8
+ * before every contained spawn and refuses ANY key outside an eight-key inert
9
+ * allow-list (`$schema`, `cleanupPeriodDays`, `includeCoAuthoredBy`,
10
+ * `language`, `outputStyle`, `spinnerTipsEnabled`, `theme`, `verbose`).
11
+ *
12
+ * `hooks` is not merely missing from that list — it is the key that BROKE the
13
+ * previous denylist and forced the inversion. A hook block in a run's own
14
+ * worktree executed as the daemon user, outside the sandbox, and read
15
+ * `~/.ssh/config` and `~/.harmony-mcp/config.json`. So writing this hook into a
16
+ * project settings file would throw `ProjectSandboxOverrideError` on every
17
+ * contained implement run in that repo — it would not degrade, it would stop
18
+ * the daemon.
19
+ *
20
+ * ## The user layer is also what makes it CORRECT
21
+ *
22
+ * `implementRunContainment` sets `settingSources: readOnly ? [] : ["project"]`.
23
+ * `"user"` appears in neither branch. A daemon run therefore never loads
24
+ * `~/.claude/settings.json` and never fires this hook, while an operator's own
25
+ * `/hmy` terminal session — which runs under their own settings — does.
26
+ *
27
+ * That is the whole of the card's "a daemon run is untouched: still exactly one
28
+ * stream, no doubled events" criterion, satisfied by construction rather than
29
+ * by a runtime toggle that could be set wrong. There is no flag to get right,
30
+ * because the two layers are already disjoint.
31
+ *
32
+ * ## Merging, not overwriting
33
+ *
34
+ * `~/.claude/settings.json` is the operator's file and usually already has
35
+ * hooks in it. Every function here merges into a parsed object and leaves
36
+ * unrelated entries untouched, and the install is idempotent — running it twice
37
+ * yields one entry, not two. The pure functions are separated from the file I/O
38
+ * so the merge is table-tested rather than verified by writing to a real home
39
+ * directory.
40
+ */
41
+
42
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
43
+ import { homedir } from "node:os";
44
+ import { dirname, join } from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+
47
+ /**
48
+ * A marker argument the hook binary ignores.
49
+ *
50
+ * Identifying our entry by its PATH would be fragile — it differs between a
51
+ * global npm install, a bunx cache and a dev checkout — so the command carries
52
+ * a stable sentinel instead. It doubles as a note to whoever reads the settings
53
+ * file and wonders what the entry is.
54
+ */
55
+ export const HOOK_MARKER = "--harmony-post-tool-use";
56
+
57
+ /** Seconds. Generous: the hook does no network I/O, so it should never hit it. */
58
+ const HOOK_TIMEOUT_SECONDS = 10;
59
+
60
+ /** Matches every tool. The redaction rules, not the matcher, decide what is sent. */
61
+ const HOOK_MATCHER = "*";
62
+
63
+ interface HookCommandEntry {
64
+ type?: string;
65
+ command?: string;
66
+ timeout?: number;
67
+ }
68
+
69
+ interface HookMatcherEntry {
70
+ matcher?: string;
71
+ hooks?: HookCommandEntry[];
72
+ }
73
+
74
+ /** `~/.claude/settings.json` — the user layer, never the project layer. */
75
+ export function userSettingsPath(home: string = homedir()): string {
76
+ return join(home, ".claude", "settings.json");
77
+ }
78
+
79
+ /**
80
+ * Absolute path to the hook binary.
81
+ *
82
+ * Resolved from this module's own location rather than looked up on `PATH`, for
83
+ * the reason `harmonyMcpServer` resolves the MCP CLI the same way: a `PATH`
84
+ * lookup or an `npx` resolution step can find a different install than the one
85
+ * that wrote the settings entry. The `.ts` fallback is for a dev checkout run
86
+ * straight from source.
87
+ */
88
+ export function hookBinaryPath(moduleUrl: string = import.meta.url): string {
89
+ const here = dirname(fileURLToPath(moduleUrl));
90
+ const candidates = [
91
+ // Built package: `bun build src/run-hook-cli.ts --outdir dist`.
92
+ join(here, "run-hook-cli.js"),
93
+ join(here, "..", "dist", "run-hook-cli.js"),
94
+ // Dev checkout running straight from source.
95
+ join(here, "run-hook-cli.ts"),
96
+ ];
97
+ return (
98
+ candidates.find((path) => existsSync(path)) ?? (candidates[0] as string)
99
+ );
100
+ }
101
+
102
+ /**
103
+ * The exact command string written into the settings file.
104
+ *
105
+ * Both absolute paths are pinned deliberately (see `hookBinaryPath`), and both
106
+ * can go away under the user's feet: an `npx`/`bunx` cache is hash- or
107
+ * version-scoped, so upgrading `@gethmy/mcp` or clearing the cache moves the
108
+ * binary, and a Node upgrade through a version manager moves `execPath`. The
109
+ * entry lives in the USER layer with a `*` matcher, so a bare command would
110
+ * then fail on every tool call of every session on the machine — `exit 1` with
111
+ * `Cannot find module`, or `exit 127` with no such file. That is the exact
112
+ * inverse of this card's "the hook is a no-op — never an error" criterion, and
113
+ * `hook status` reported `installed` throughout, because it only looked for the
114
+ * marker.
115
+ *
116
+ * So the command guards its own preconditions and exits 0 when either path is
117
+ * gone. The trailing `|| exit 0` also swallows a non-zero exit from the hook
118
+ * itself, which is the same promise stated once more at the shell level: this
119
+ * hook never breaks the tool call it observes. `hook status` reports the stale
120
+ * path so the user knows to re-run `hook install`.
121
+ *
122
+ * This is POSIX shell (`sh -c`), which is what the harness runs a hook command
123
+ * with on macOS and Linux — the only platforms `~/.claude/settings.json`
124
+ * installs are supported on.
125
+ */
126
+ export function hookCommand(
127
+ binary: string,
128
+ execPath = process.execPath,
129
+ ): string {
130
+ return `[ -x "${execPath}" ] && [ -f "${binary}" ] && "${execPath}" "${binary}" ${HOOK_MARKER} || exit 0`;
131
+ }
132
+
133
+ /**
134
+ * The hook binary an installed command points at, or null.
135
+ *
136
+ * Parsed back out of the `[ -f "…" ]` guard `hookCommand` writes. Reading it
137
+ * from the command string keeps the settings entry to the three keys the
138
+ * harness's hook schema defines, rather than smuggling a path into a fourth.
139
+ */
140
+ export function hookCommandBinary(command: string): string | null {
141
+ const match = /\[ -f "([^"]+)" \]/.exec(command);
142
+ return match?.[1] ?? null;
143
+ }
144
+
145
+ /** Is this settings entry ours? */
146
+ function isHarmonyHook(entry: HookCommandEntry | undefined): boolean {
147
+ return (
148
+ typeof entry?.command === "string" && entry.command.includes(HOOK_MARKER)
149
+ );
150
+ }
151
+
152
+ type Settings = Record<string, unknown>;
153
+
154
+ /**
155
+ * Add (or update) the Harmony `PostToolUse` entry in a parsed settings object.
156
+ *
157
+ * Idempotent: an existing Harmony entry has its command REPLACED, so upgrading
158
+ * to a new install path fixes the entry rather than adding a second one that
159
+ * would double every tool row. Other people's hooks are never touched.
160
+ */
161
+ export function addHarmonyHook(
162
+ settings: Settings,
163
+ command: string,
164
+ ): { settings: Settings; changed: boolean } {
165
+ const next: Settings = { ...settings };
166
+ const hooks: Record<string, unknown> = {
167
+ ...((next.hooks as Record<string, unknown>) ?? {}),
168
+ };
169
+ const postToolUse: HookMatcherEntry[] = Array.isArray(hooks.PostToolUse)
170
+ ? [...(hooks.PostToolUse as HookMatcherEntry[])]
171
+ : [];
172
+
173
+ const ours: HookCommandEntry = {
174
+ type: "command",
175
+ command,
176
+ timeout: HOOK_TIMEOUT_SECONDS,
177
+ };
178
+
179
+ let changed = false;
180
+ let placed = false;
181
+
182
+ for (let i = 0; i < postToolUse.length; i++) {
183
+ const group = postToolUse[i] as HookMatcherEntry;
184
+ const inner = Array.isArray(group?.hooks) ? group.hooks : [];
185
+ const index = inner.findIndex(isHarmonyHook);
186
+ if (index === -1) continue;
187
+ placed = true;
188
+ if (inner[index]?.command !== command) {
189
+ const updated = [...inner];
190
+ updated[index] = ours;
191
+ postToolUse[i] = { ...group, hooks: updated };
192
+ changed = true;
193
+ }
194
+ }
195
+
196
+ if (!placed) {
197
+ postToolUse.push({ matcher: HOOK_MATCHER, hooks: [ours] });
198
+ changed = true;
199
+ }
200
+
201
+ hooks.PostToolUse = postToolUse;
202
+ next.hooks = hooks;
203
+ return { settings: next, changed };
204
+ }
205
+
206
+ /**
207
+ * Remove the Harmony entry, leaving every other hook alone.
208
+ *
209
+ * A matcher group left with no hooks is dropped, and a `PostToolUse` array left
210
+ * empty is dropped with it, so uninstalling returns the file to the shape it
211
+ * had before — an empty `"PostToolUse": []` is litter that outlives the reason
212
+ * for it.
213
+ */
214
+ export function removeHarmonyHook(settings: Settings): {
215
+ settings: Settings;
216
+ changed: boolean;
217
+ } {
218
+ const hooksValue = settings.hooks;
219
+ if (hooksValue === null || typeof hooksValue !== "object") {
220
+ return { settings, changed: false };
221
+ }
222
+ const hooks: Record<string, unknown> = {
223
+ ...(hooksValue as Record<string, unknown>),
224
+ };
225
+ if (!Array.isArray(hooks.PostToolUse)) {
226
+ return { settings, changed: false };
227
+ }
228
+
229
+ let changed = false;
230
+ const groups: HookMatcherEntry[] = [];
231
+ for (const group of hooks.PostToolUse as HookMatcherEntry[]) {
232
+ const inner = Array.isArray(group?.hooks) ? group.hooks : [];
233
+ const kept = inner.filter((entry) => !isHarmonyHook(entry));
234
+ if (kept.length !== inner.length) changed = true;
235
+ if (kept.length === 0 && inner.length > 0) continue;
236
+ groups.push(
237
+ kept.length === inner.length ? group : { ...group, hooks: kept },
238
+ );
239
+ }
240
+
241
+ if (!changed) return { settings, changed: false };
242
+
243
+ if (groups.length > 0) {
244
+ hooks.PostToolUse = groups;
245
+ } else {
246
+ delete hooks.PostToolUse;
247
+ }
248
+
249
+ const next: Settings = { ...settings };
250
+ if (Object.keys(hooks).length > 0) {
251
+ next.hooks = hooks;
252
+ } else {
253
+ delete next.hooks;
254
+ }
255
+ return { settings: next, changed: true };
256
+ }
257
+
258
+ /** Is the Harmony hook present in a parsed settings object? */
259
+ export function hasHarmonyHook(settings: Settings): boolean {
260
+ return harmonyHookCommand(settings) !== null;
261
+ }
262
+
263
+ /** The Harmony hook's command string as installed, or null when absent. */
264
+ export function harmonyHookCommand(settings: Settings): string | null {
265
+ const hooks = settings?.hooks as Record<string, unknown> | undefined;
266
+ const groups = hooks?.PostToolUse;
267
+ if (!Array.isArray(groups)) return null;
268
+ for (const group of groups as HookMatcherEntry[]) {
269
+ for (const entry of Array.isArray(group?.hooks) ? group.hooks : []) {
270
+ if (isHarmonyHook(entry)) return entry.command as string;
271
+ }
272
+ }
273
+ return null;
274
+ }
275
+
276
+ /**
277
+ * What `hook status` needs to say something true.
278
+ *
279
+ * `installed` on its own was misleading: the entry can name a binary that an
280
+ * upgrade or a cache purge has moved, in which case the hook is inert and only
281
+ * re-running `hook install` fixes it. The guard in `hookCommand` keeps that
282
+ * inert rather than broken; this is how the user finds out.
283
+ */
284
+ export function hookInstallStatus(settings: Settings): {
285
+ installed: boolean;
286
+ binary: string | null;
287
+ binaryExists: boolean;
288
+ } {
289
+ const command = harmonyHookCommand(settings);
290
+ if (command === null) {
291
+ return { installed: false, binary: null, binaryExists: false };
292
+ }
293
+ const binary = hookCommandBinary(command);
294
+ return {
295
+ installed: true,
296
+ binary,
297
+ binaryExists: binary !== null && existsSync(binary),
298
+ };
299
+ }
300
+
301
+ function readSettings(path: string): Settings {
302
+ try {
303
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
304
+ return parsed !== null &&
305
+ typeof parsed === "object" &&
306
+ !Array.isArray(parsed)
307
+ ? (parsed as Settings)
308
+ : {};
309
+ } catch {
310
+ // A missing file is the common case. A CORRUPT one is not ours to silently
311
+ // replace, so the callers below check `existsSync` and refuse instead.
312
+ return {};
313
+ }
314
+ }
315
+
316
+ function writeSettings(path: string, settings: Settings): void {
317
+ mkdirSync(dirname(path), { recursive: true });
318
+ writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
319
+ }
320
+
321
+ export type HookInstallResult =
322
+ | { ok: true; path: string; changed: boolean; command: string }
323
+ | { ok: false; path: string; error: string };
324
+
325
+ /** Write the hook into the user settings layer. */
326
+ export function installUserHook(options?: {
327
+ settingsPath?: string;
328
+ binary?: string;
329
+ }): HookInstallResult {
330
+ const path = options?.settingsPath ?? userSettingsPath();
331
+ const command = hookCommand(options?.binary ?? hookBinaryPath());
332
+ if (existsSync(path)) {
333
+ try {
334
+ JSON.parse(readFileSync(path, "utf-8"));
335
+ } catch (err) {
336
+ return {
337
+ ok: false,
338
+ path,
339
+ error: `${path} is not valid JSON, so it was left untouched: ${
340
+ err instanceof Error ? err.message : String(err)
341
+ }`,
342
+ };
343
+ }
344
+ }
345
+ try {
346
+ const { settings, changed } = addHarmonyHook(readSettings(path), command);
347
+ if (changed) writeSettings(path, settings);
348
+ return { ok: true, path, changed, command };
349
+ } catch (err) {
350
+ return {
351
+ ok: false,
352
+ path,
353
+ error: err instanceof Error ? err.message : String(err),
354
+ };
355
+ }
356
+ }
357
+
358
+ /** Remove the hook from the user settings layer. */
359
+ export function uninstallUserHook(options?: {
360
+ settingsPath?: string;
361
+ }): HookInstallResult {
362
+ const path = options?.settingsPath ?? userSettingsPath();
363
+ if (!existsSync(path)) {
364
+ return { ok: true, path, changed: false, command: "" };
365
+ }
366
+ try {
367
+ JSON.parse(readFileSync(path, "utf-8"));
368
+ } catch (err) {
369
+ return {
370
+ ok: false,
371
+ path,
372
+ error: `${path} is not valid JSON, so it was left untouched: ${
373
+ err instanceof Error ? err.message : String(err)
374
+ }`,
375
+ };
376
+ }
377
+ try {
378
+ const { settings, changed } = removeHarmonyHook(readSettings(path));
379
+ if (changed) writeSettings(path, settings);
380
+ return { ok: true, path, changed, command: "" };
381
+ } catch (err) {
382
+ return {
383
+ ok: false,
384
+ path,
385
+ error: err instanceof Error ? err.message : String(err),
386
+ };
387
+ }
388
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Plan criterion ↔ card linking (card #1038, plan "Harmony SDLC — ein Rückgrat").
3
+ *
4
+ * A `plan_task` row is a plan's **success criterion**, not a work item. `cards.plan_id`
5
+ * has always carried the plan → card direction; `plan_tasks.card_id` is the return leg
6
+ * ("Linked card ID if task has been converted to a card", 20260127100000). It shipped
7
+ * with the table and no client ever wrote it, so a card outcome had nowhere to land and
8
+ * plan progress fell back to matching board-column NAMES. The number now counts these
9
+ * criteria instead (`computePlanProgress`, `src/lib/planProgress.ts`, card #1042), so a
10
+ * criterion left unlinked here is a criterion the plan reports as unmet.
11
+ *
12
+ * Everything here is pure and total, because the interesting cases are the ones a live
13
+ * call cannot cheaply produce: a criterion that belongs to a different plan, a malformed
14
+ * task list off the wire, and the half-written state where the card exists but the return
15
+ * leg did not get written.
16
+ *
17
+ * WHY NOTHING HERE RESOLVES A PLAN FROM A TASK ID ALONE: there is no
18
+ * `GET /plan-tasks/:id` route, and adding one to save the caller a field would be a new
19
+ * server surface for a lookup the caller already has. `planId` is therefore required
20
+ * alongside `planTaskId`, which also makes the two impossible to contradict — the plan is
21
+ * named once and used for both the card's `plan_id` and the criterion's scope.
22
+ */
23
+
24
+ /** A `plan_tasks` row as it comes back from `GET /plans/:id`. */
25
+ export interface PlanTaskRow {
26
+ id: string;
27
+ plan_id?: string | null;
28
+ card_id?: string | null;
29
+ content?: string | null;
30
+ status?: string | null;
31
+ }
32
+
33
+ export type PlanTaskLookup =
34
+ | { ok: true; task: PlanTaskRow }
35
+ | { ok: false; reason: string };
36
+
37
+ /**
38
+ * Find `taskId` among a plan's criteria.
39
+ *
40
+ * Fails CLOSED on every shape it does not recognise. The caller uses this to decide
41
+ * whether to create a card at all, so "I could not read the list" must never be reported
42
+ * as "the criterion is fine" — a card created against a criterion that does not exist is
43
+ * a card created on a false premise.
44
+ */
45
+ export function findPlanTask(tasks: unknown, taskId: string): PlanTaskLookup {
46
+ if (!taskId) {
47
+ return { ok: false, reason: "No plan task id was given." };
48
+ }
49
+ if (!Array.isArray(tasks)) {
50
+ return {
51
+ ok: false,
52
+ reason: "The plan returned no readable criteria list.",
53
+ };
54
+ }
55
+
56
+ for (const row of tasks) {
57
+ if (!row || typeof row !== "object") continue;
58
+ const candidate = row as PlanTaskRow;
59
+ if (candidate.id === taskId) {
60
+ return { ok: true, task: candidate };
61
+ }
62
+ }
63
+
64
+ return {
65
+ ok: false,
66
+ reason:
67
+ `Plan task ${taskId} is not one of this plan's criteria. ` +
68
+ `Read the plan with harmony_get_plan and use an id from its \`tasks\`.`,
69
+ };
70
+ }
71
+
72
+ /** What happened to the return leg after a card was created. */
73
+ export interface PlanTaskLinkReport {
74
+ planId: string;
75
+ taskId: string;
76
+ /** True once `plan_tasks.card_id` points at the new card. */
77
+ linked: boolean;
78
+ /** The criterion text, echoed so the caller sees what the card now answers for. */
79
+ criterion?: string | null;
80
+ /** Present only when `linked` is false. */
81
+ error?: string;
82
+ /** Set when the criterion already pointed at a different card. */
83
+ replacedCardId?: string | null;
84
+ }
85
+
86
+ /**
87
+ * Build the report for a criterion whose return leg was written.
88
+ *
89
+ * `replacedCardId` is reported rather than refused: re-pointing a criterion at a new card
90
+ * is legitimate (the first card was abandoned, or the criterion moved), and a silent
91
+ * overwrite is the part that would be wrong.
92
+ */
93
+ export function linkedReport(
94
+ planId: string,
95
+ task: PlanTaskRow,
96
+ newCardId: string,
97
+ ): PlanTaskLinkReport {
98
+ const previous = task.card_id ?? null;
99
+ return {
100
+ planId,
101
+ taskId: task.id,
102
+ linked: true,
103
+ criterion: task.content ?? null,
104
+ ...(previous && previous !== newCardId ? { replacedCardId: previous } : {}),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Build the report for a return leg that did NOT get written.
110
+ *
111
+ * The card is deliberately kept. A card without its return leg is repairable with
112
+ * `harmony_link_plan_task`; a card thrown away because a second write failed is not.
113
+ * The message says exactly that, so the caller does not retry the create.
114
+ */
115
+ export function unlinkedReport(
116
+ planId: string,
117
+ task: PlanTaskRow,
118
+ error: unknown,
119
+ ): PlanTaskLinkReport {
120
+ const message = error instanceof Error ? error.message : String(error);
121
+ return {
122
+ planId,
123
+ taskId: task.id,
124
+ linked: false,
125
+ criterion: task.content ?? null,
126
+ error:
127
+ `The card was created, but the plan criterion still does not point at it: ${message}. ` +
128
+ `Repair it with harmony_link_plan_task — do not create the card again.`,
129
+ };
130
+ }