@gethmy/mcp 3.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "MCP server for Harmony, the shared surface for human–agent teams — agents claim cards, report progress, and move work on your board.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -24,7 +24,8 @@
24
24
  },
25
25
  "bin": {
26
26
  "harmony-mcp": "dist/cli.js",
27
- "gethmy-mcp": "dist/cli.js"
27
+ "gethmy-mcp": "dist/cli.js",
28
+ "harmony-run-hook": "dist/run-hook-cli.js"
28
29
  },
29
30
  "files": [
30
31
  "dist",
@@ -59,7 +60,7 @@
59
60
  "bun": ">=1.0.0"
60
61
  },
61
62
  "scripts": {
62
- "build": "rm -rf dist && bun build src/index.ts src/cli.ts --outdir dist --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod && bun build src/api-client.ts src/config.ts src/oauth-refresh.ts --outdir dist/lib --root src --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod",
63
+ "build": "rm -rf dist && bun build src/index.ts src/cli.ts src/run-hook-cli.ts --outdir dist --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod && bun build src/api-client.ts src/config.ts src/oauth-refresh.ts --outdir dist/lib --root src --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod",
63
64
  "build:bun": "bun build src/index.ts src/http.ts src/remote.ts src/cli.ts --outdir dist --target bun",
64
65
  "serve:remote": "bun src/remote.ts",
65
66
  "dev": "bun --watch src/index.ts",
package/src/api-client.ts CHANGED
@@ -1213,6 +1213,12 @@ export class HarmonyApiClient {
1213
1213
  data: {
1214
1214
  decision: "continue" | "stop";
1215
1215
  extraTurns: number;
1216
+ /**
1217
+ * Consumption granted with a Continue, in USD (#1058). Optional — an
1218
+ * omitted value makes the daemon fall back to its own configured grant
1219
+ * (`budget.pause.extraBudgetUsd`, else the per-spawn `sdk.maxBudgetUsd`).
1220
+ */
1221
+ extraBudgetUsd?: number;
1216
1222
  message?: string;
1217
1223
  },
1218
1224
  ): Promise<{ id: string; seq: number; createdAt: string }> {
@@ -1232,6 +1238,8 @@ export class HarmonyApiClient {
1232
1238
  decisions: Array<{
1233
1239
  decision: "continue" | "stop";
1234
1240
  extraTurns: number;
1241
+ /** Consumption granted with the Continue, in USD (#1058) — absent on a pre-#1058 row. */
1242
+ extraBudgetUsd?: number;
1235
1243
  message?: string;
1236
1244
  createdAt: string;
1237
1245
  }>;
@@ -1930,9 +1938,19 @@ export class HarmonyApiClient {
1930
1938
  return this.request("GET", `/plans/${planId}`);
1931
1939
  }
1932
1940
 
1941
+ /**
1942
+ * The plan a card belongs to, and the criteria that point back at it from elsewhere.
1943
+ *
1944
+ * `plan`/`tasks` are MEMBERSHIP (`cards.plan_id`). `foreign_criteria` is DELIVERY
1945
+ * (`plan_tasks.card_id`) for the rows where the two disagree — a card in no plan, or a
1946
+ * card in a different one (#1054). It is optional because a harmony-api older than
1947
+ * #1054 does not send it, so a reader must treat its absence as "unknown", never as
1948
+ * "there are none".
1949
+ */
1933
1950
  async getPlanByCardId(cardId: string): Promise<{
1934
1951
  plan: unknown;
1935
1952
  tasks: unknown[];
1953
+ foreign_criteria?: unknown[];
1936
1954
  } | null> {
1937
1955
  return this.request("GET", `/cards/${cardId}/plan`);
1938
1956
  }
@@ -1948,6 +1966,13 @@ export class HarmonyApiClient {
1948
1966
  return this.request("PATCH", `/plans/${planId}`, updates);
1949
1967
  }
1950
1968
 
1969
+ /**
1970
+ * Point a criterion at a card and/or set its status.
1971
+ *
1972
+ * `cardPlanAdopted` reports the second write this route makes (#1054): linking a card
1973
+ * that belongs to no plan also writes `cards.plan_id`, so the two columns cannot drift.
1974
+ * Absent from a harmony-api older than #1054 — read it as "unknown", never as "no".
1975
+ */
1951
1976
  async updatePlanTask(
1952
1977
  planId: string,
1953
1978
  taskId: string,
@@ -1955,7 +1980,7 @@ export class HarmonyApiClient {
1955
1980
  cardId?: string;
1956
1981
  status?: "pending" | "in_progress" | "completed";
1957
1982
  },
1958
- ): Promise<{ task: unknown }> {
1983
+ ): Promise<{ task: unknown; cardPlanAdopted?: boolean }> {
1959
1984
  return this.request("PATCH", `/plans/${planId}/tasks/${taskId}`, updates);
1960
1985
  }
1961
1986
 
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import type { HarmonyApiClient } from "./api-client.js";
27
+ import { beginHookTimeline, endHookTimeline } from "./run-event-forwarder.js";
27
28
 
28
29
  /**
29
30
  * Status reported for a tracked session. Drives the heartbeat decision: only
@@ -43,6 +44,17 @@ export interface TrackedSession {
43
44
  isExplicit: boolean;
44
45
  agentIdentifier: string;
45
46
  agentName: string;
47
+ /**
48
+ * `card_agent_context.id` for this session, when the start endpoint returned
49
+ * one. Recorded so the session can be PUBLISHED on disk for the `PostToolUse`
50
+ * hook to find (#874) — a hook is a separate process and cannot read this
51
+ * map, so the id has to travel through the filesystem.
52
+ *
53
+ * Optional because an older API build may return no id, and because a start
54
+ * that threw is still tracked locally. Absent means "no tool-call rows for
55
+ * this session", never an error.
56
+ */
57
+ agentSessionId?: string;
46
58
  /**
47
59
  * Last status reported for the session (default `working`). Only `working`
48
60
  * sessions are heartbeated by the sweep; see `heartbeatActiveSessions`.
@@ -290,6 +302,7 @@ export async function trackActivity(
290
302
  // rather than letting a surviving client walk the run back to life one cooldown
291
303
  // later (card #770). An explicit `/hmy` re-run is unaffected — it starts the
292
304
  // session by name and only faces the 10-minute #663 window.
305
+ let agentSessionId: string | undefined;
293
306
  try {
294
307
  const started = await client.startAgentSession(cardId, {
295
308
  agentIdentifier,
@@ -300,6 +313,7 @@ export async function trackActivity(
300
313
  // Refused: don't track it locally either, or the sweep would heartbeat a
301
314
  // session that does not exist and `checkInactivity` would later "end" it.
302
315
  if (started?.session === null) return;
316
+ agentSessionId = (started?.session as { id?: string } | undefined)?.id;
303
317
  } catch {
304
318
  // Session start failed (might already have one), still track locally
305
319
  }
@@ -311,8 +325,20 @@ export async function trackActivity(
311
325
  isExplicit: false,
312
326
  agentIdentifier,
313
327
  agentName,
328
+ agentSessionId,
314
329
  status: "working",
315
330
  });
331
+
332
+ // Publish for the `PostToolUse` hook. An auto-session is a real session with
333
+ // a real timeline, so it gets tool rows for the same reason an explicit one
334
+ // does. Best-effort by construction — `beginHookTimeline` returns null rather
335
+ // than throwing, so a failure here cannot break the tool call that triggered
336
+ // the auto-start.
337
+ beginHookTimeline({
338
+ cardId,
339
+ agentSessionId,
340
+ getClient: () => client,
341
+ });
316
342
  }
317
343
 
318
344
  /**
@@ -551,6 +577,13 @@ async function autoEndSession(
551
577
  // cardId concurrently; whichever claims it first runs the end + pipeline,
552
578
  // the loser bails so endAgentSession / runEndSessionPipeline fire exactly once.
553
579
  if (!scope.sessions.delete(cardId)) return;
580
+ // Drain and unpublish BEFORE the session row is ended — an append to an ended
581
+ // session is refused, so the tail of the tool log would be lost otherwise.
582
+ try {
583
+ await endHookTimeline(cardId);
584
+ } catch {
585
+ // Best-effort telemetry; never blocks the end.
586
+ }
554
587
  try {
555
588
  await client.endAgentSession(cardId, { status });
556
589
  } catch {
package/src/cli.ts CHANGED
@@ -211,4 +211,108 @@ program
211
211
  });
212
212
  });
213
213
 
214
+ const hook = program
215
+ .command("hook")
216
+ .description(
217
+ "Manage the PostToolUse hook that streams tool calls to a card's run timeline",
218
+ );
219
+
220
+ hook
221
+ .command("install")
222
+ .description(
223
+ "Install the hook into ~/.claude/settings.json (the user layer, never a project)",
224
+ )
225
+ .action(async () => {
226
+ // Imported lazily so `serve` — the hot path — does not parse this module.
227
+ const { installUserHook } = await import("./hook-install.js");
228
+ const result = installUserHook();
229
+ if (!result.ok) {
230
+ console.error(`Could not install the hook: ${result.error}`);
231
+ process.exit(1);
232
+ }
233
+ console.log(
234
+ result.changed
235
+ ? `Installed the Harmony PostToolUse hook in ${result.path}`
236
+ : `The Harmony PostToolUse hook is already installed in ${result.path}`,
237
+ );
238
+ console.log(` command: ${result.command}`);
239
+ console.log(
240
+ "\nTool calls from an MCP session will now appear on the card's run timeline.",
241
+ );
242
+ console.log(
243
+ "The user settings layer is deliberate: a daemon run never loads it, so it",
244
+ );
245
+ console.log("cannot double-report the stream it already sends itself.");
246
+ });
247
+
248
+ hook
249
+ .command("uninstall")
250
+ .description("Remove the hook from ~/.claude/settings.json")
251
+ .action(async () => {
252
+ const { uninstallUserHook } = await import("./hook-install.js");
253
+ const result = uninstallUserHook();
254
+ if (!result.ok) {
255
+ console.error(`Could not remove the hook: ${result.error}`);
256
+ process.exit(1);
257
+ }
258
+ console.log(
259
+ result.changed
260
+ ? `Removed the Harmony PostToolUse hook from ${result.path}`
261
+ : "The Harmony PostToolUse hook was not installed.",
262
+ );
263
+ });
264
+
265
+ hook
266
+ .command("status")
267
+ .description("Report whether the hook is installed, and any live sessions")
268
+ .action(async () => {
269
+ const { hookInstallStatus, userSettingsPath } = await import(
270
+ "./hook-install.js"
271
+ );
272
+ const { readPublishedSessions, runStateDir } = await import(
273
+ "./run-state.js"
274
+ );
275
+ const { readFileSync } = await import("node:fs");
276
+ const path = userSettingsPath();
277
+ let status = {
278
+ installed: false,
279
+ binary: null as string | null,
280
+ binaryExists: false,
281
+ };
282
+ try {
283
+ status = hookInstallStatus(JSON.parse(readFileSync(path, "utf-8")));
284
+ } catch {
285
+ // A missing or corrupt settings file reads as "not installed".
286
+ }
287
+ console.log(
288
+ `Hook: ${status.installed ? "installed" : "not installed"} (${path})`,
289
+ );
290
+ if (status.installed && !status.binaryExists) {
291
+ console.log(
292
+ ` ! its hook binary is gone: ${status.binary ?? "unparseable command"}`,
293
+ );
294
+ console.log(
295
+ " The hook is INERT until you re-run `npx @gethmy/mcp hook install`.",
296
+ );
297
+ console.log(
298
+ " An upgrade or a cleared npx/bunx cache moves the binary; the installed",
299
+ );
300
+ console.log(
301
+ " command guards its own paths, so nothing errors in the meantime.",
302
+ );
303
+ }
304
+ console.log(`State: ${runStateDir()}`);
305
+ const sessions = readPublishedSessions();
306
+ if (sessions.length === 0) {
307
+ console.log("Live sessions: none");
308
+ return;
309
+ }
310
+ console.log(`Live sessions: ${sessions.length}`);
311
+ for (const session of sessions) {
312
+ console.log(
313
+ ` card ${session.cardId} · session ${session.agentSessionId} · pid ${session.publisherPid} · ${session.cwd}`,
314
+ );
315
+ }
316
+ });
317
+
214
318
  program.parse();
@@ -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
+ }
@@ -5,7 +5,9 @@
5
5
  * has always carried the plan → card direction; `plan_tasks.card_id` is the return leg
6
6
  * ("Linked card ID if task has been converted to a card", 20260127100000). It shipped
7
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 (`src/lib/planProgress.ts`).
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.
9
11
  *
10
12
  * Everything here is pure and total, because the interesting cases are the ones a live
11
13
  * call cannot cheaply produce: a criterion that belongs to a different plan, a malformed