@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.
- package/README.md +41 -2
- package/dist/cli.js +1188 -157
- package/dist/index.js +805 -42
- package/dist/lib/api-client.js +3 -1
- package/dist/run-hook-cli.js +742 -0
- package/package.json +4 -3
- package/src/api-client.ts +57 -1
- package/src/auto-session.ts +33 -0
- package/src/cli.ts +104 -0
- package/src/comment-session.ts +149 -0
- package/src/hook-install.ts +388 -0
- package/src/plan-task-link.ts +130 -0
- package/src/run-event-forwarder.ts +363 -0
- package/src/run-hook-cli.ts +55 -0
- package/src/run-hook-main.ts +159 -0
- package/src/run-hook.ts +203 -0
- package/src/run-redaction.ts +461 -0
- package/src/run-state.ts +679 -0
- package/src/server.ts +342 -34
- package/src/tui/setup.ts +3 -0
|
@@ -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
|
+
}
|