@gethmy/mcp 3.7.0 → 3.9.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 +3 -3
- package/dist/cli.js +623 -157
- package/dist/index.js +228 -97
- package/dist/lib/api-client.js +181 -16
- package/dist/lib/config.js +110 -14
- package/dist/lib/oauth-refresh.js +110 -14
- package/dist/run-hook-cli.js +54 -0
- package/package.json +2 -2
- package/src/api-client.ts +91 -1
- package/src/config.ts +262 -14
- package/src/prompt-builder.ts +1 -1
- package/src/server.ts +70 -4
- package/src/skills.ts +6 -80
- package/src/tui/agent-instructions.ts +335 -0
- package/src/tui/setup.ts +144 -63
- package/src/tui/writer.ts +118 -2
package/src/api-client.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type PlaybookVersionDef,
|
|
7
7
|
type StageGateEvidenceInsert,
|
|
8
8
|
type StageGateEvidenceRow,
|
|
9
|
+
sanitizeRunEventDraft,
|
|
9
10
|
serializeCommentThread,
|
|
10
11
|
untrustedDataBlock,
|
|
11
12
|
type WorkspaceAgent,
|
|
@@ -19,6 +20,16 @@ export interface ApiResponse<T = unknown> {
|
|
|
19
20
|
[key: string]: T | boolean | string | undefined;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** One `model_catalog` row (#1104). See `getModelCatalog`. */
|
|
24
|
+
export interface ModelCatalogRow {
|
|
25
|
+
id: string;
|
|
26
|
+
display_name?: string | null;
|
|
27
|
+
status: "available" | "deprecated" | "withdrawn";
|
|
28
|
+
replaced_by?: string | null;
|
|
29
|
+
sort_order?: number;
|
|
30
|
+
notes?: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
// Retry configuration
|
|
23
34
|
const RETRY_CONFIG = {
|
|
24
35
|
maxRetries: 3,
|
|
@@ -559,6 +570,46 @@ export class HarmonyApiClient {
|
|
|
559
570
|
return this.request("POST", `/workspaces/${workspaceId}/agents`, data);
|
|
560
571
|
}
|
|
561
572
|
|
|
573
|
+
/**
|
|
574
|
+
* Report the effective model config this daemon resolved (#1104). Advisory:
|
|
575
|
+
* the board renders it, nothing routes on it, so a failure here must never
|
|
576
|
+
* stop a daemon from starting.
|
|
577
|
+
*/
|
|
578
|
+
async reportAgentConfig(
|
|
579
|
+
workspaceId: string,
|
|
580
|
+
agentId: string,
|
|
581
|
+
config: unknown,
|
|
582
|
+
): Promise<{ agent: WorkspaceAgent }> {
|
|
583
|
+
return this.request(
|
|
584
|
+
"POST",
|
|
585
|
+
`/workspaces/${workspaceId}/agents/${agentId}/reported-config`,
|
|
586
|
+
{ config },
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The workspace's own model layer (#1104) — `GET /v1/workspaces/:id/model-config`.
|
|
592
|
+
* `config` is `null` when the workspace has not set one. Deliberately untyped
|
|
593
|
+
* (`Record<string, unknown>`, not `WorkspaceModelConfig`): this client has no
|
|
594
|
+
* dependency on `@gethmy/agent`, so the caller (`workspace-model-cache.ts`)
|
|
595
|
+
* parses the blob into its own shape.
|
|
596
|
+
*/
|
|
597
|
+
async getWorkspaceModelConfig(
|
|
598
|
+
workspaceId: string,
|
|
599
|
+
): Promise<{ config: Record<string, unknown> | null }> {
|
|
600
|
+
return this.request("GET", `/workspaces/${workspaceId}/model-config`);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The platform-owned supported-model catalog (#1104) — `GET /v1/model-catalog`.
|
|
605
|
+
* Open to any authenticated caller; a model id is not a secret. The daemon
|
|
606
|
+
* derives a withdrawn→replacement map from the `withdrawn` rows here and
|
|
607
|
+
* hands it to `chooseImplementModel`'s `catalog` parameter.
|
|
608
|
+
*/
|
|
609
|
+
async getModelCatalog(): Promise<{ models: ModelCatalogRow[] }> {
|
|
610
|
+
return this.request("GET", "/model-catalog");
|
|
611
|
+
}
|
|
612
|
+
|
|
562
613
|
// ============ PROJECT OPERATIONS ============
|
|
563
614
|
|
|
564
615
|
async listProjects(workspaceId: string): Promise<{ projects: unknown[] }> {
|
|
@@ -924,6 +975,13 @@ export class HarmonyApiClient {
|
|
|
924
975
|
});
|
|
925
976
|
}
|
|
926
977
|
|
|
978
|
+
async removeExternalLink(
|
|
979
|
+
cardId: string,
|
|
980
|
+
linkId: string,
|
|
981
|
+
): Promise<{ success: boolean }> {
|
|
982
|
+
return this.request("DELETE", `/cards/${cardId}/external-links/${linkId}`);
|
|
983
|
+
}
|
|
984
|
+
|
|
927
985
|
// ============ ARTIFACTS (hosted HTML documents) ============
|
|
928
986
|
|
|
929
987
|
async uploadArtifact(data: {
|
|
@@ -1409,6 +1467,26 @@ export class HarmonyApiClient {
|
|
|
1409
1467
|
/**
|
|
1410
1468
|
* Append events to a run's agent_run_events stream (card #417). Send drafts in
|
|
1411
1469
|
* chronological order — the server's seq trigger assigns the monotonic per-run order.
|
|
1470
|
+
*
|
|
1471
|
+
* **Every draft is sanitized here, because this is the one place all three
|
|
1472
|
+
* writers meet (#1110).** `agent_run_events.payload` is JSONB, and Postgres
|
|
1473
|
+
* refuses a JSON string carrying U+0000 or a lone surrogate — it rejects the
|
|
1474
|
+
* whole statement rather than truncating. Measured 75 times in the 2026-09-06
|
|
1475
|
+
* daemon log as `unsupported Unicode escape sequence`, and tool output is
|
|
1476
|
+
* exactly where such a byte comes from (a `grep` over a binary, a compiler
|
|
1477
|
+
* dumping a fixture).
|
|
1478
|
+
*
|
|
1479
|
+
* Three callers reach this method and they fail in different ways, which is
|
|
1480
|
+
* why the repair belongs here rather than at any one of them:
|
|
1481
|
+
* - `CliAgentRunner.enqueue` (harmony-agent) retries three times, bisects,
|
|
1482
|
+
* and drops the single bad event — correct handling, still a lost row.
|
|
1483
|
+
* - `RunEventForwarder.flush` (the MCP `PostToolUse` hook) leaves the batch
|
|
1484
|
+
* on disk and retries with backoff for ~10 minutes, BLOCKING every later
|
|
1485
|
+
* tool call queued behind it, then drops the pair.
|
|
1486
|
+
* - `ci-repair.ts` posts a `detail` built from CI output.
|
|
1487
|
+
* The runner sanitizes again on its own path, above its size check, so a NUL
|
|
1488
|
+
* never costs bytes against the ceiling; that is defence in depth, not a
|
|
1489
|
+
* duplicate — this is the boundary a fourth writer gets for free.
|
|
1412
1490
|
*/
|
|
1413
1491
|
async appendAgentRunEvents(
|
|
1414
1492
|
cardId: string,
|
|
@@ -1417,7 +1495,10 @@ export class HarmonyApiClient {
|
|
|
1417
1495
|
events: (AgentRunEventDraft & { createdAt?: string })[];
|
|
1418
1496
|
},
|
|
1419
1497
|
): Promise<{ inserted: number }> {
|
|
1420
|
-
return this.request("POST", `/cards/${cardId}/agent-run-events`,
|
|
1498
|
+
return this.request("POST", `/cards/${cardId}/agent-run-events`, {
|
|
1499
|
+
...data,
|
|
1500
|
+
events: data.events.map((event) => sanitizeRunEventDraft(event)),
|
|
1501
|
+
});
|
|
1421
1502
|
}
|
|
1422
1503
|
|
|
1423
1504
|
/**
|
|
@@ -1972,12 +2053,21 @@ export class HarmonyApiClient {
|
|
|
1972
2053
|
return this.request("GET", `/cards/${cardId}/plan`);
|
|
1973
2054
|
}
|
|
1974
2055
|
|
|
2056
|
+
/**
|
|
2057
|
+
* `startDate` / `endDate` are the plan's own timeline dates (card #1119),
|
|
2058
|
+
* `YYYY-MM-DD` or `null` to return the plan to its cards' derived span. They
|
|
2059
|
+
* are pinned together or not at all — sending one on an unpinned plan is
|
|
2060
|
+
* refused by the route, with a reason that names the missing half. Absent from
|
|
2061
|
+
* a harmony-api older than #1119, where they are silently dropped.
|
|
2062
|
+
*/
|
|
1975
2063
|
async updatePlan(
|
|
1976
2064
|
planId: string,
|
|
1977
2065
|
updates: {
|
|
1978
2066
|
title?: string;
|
|
1979
2067
|
content?: string;
|
|
1980
2068
|
status?: "draft" | "active" | "archived";
|
|
2069
|
+
startDate?: string | null;
|
|
2070
|
+
endDate?: string | null;
|
|
1981
2071
|
},
|
|
1982
2072
|
): Promise<{ plan: unknown }> {
|
|
1983
2073
|
return this.request("PATCH", `/plans/${planId}`, updates);
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
4
|
import { dirname, join, parse, resolve } from "node:path";
|
|
4
5
|
|
|
5
6
|
export interface HarmonyConfig {
|
|
@@ -93,6 +94,10 @@ let warnedLegacyLocalPin = false;
|
|
|
93
94
|
export function resetLegacyNoticesForTest(): void {
|
|
94
95
|
warnedLegacyConfigDir = false;
|
|
95
96
|
warnedLegacyLocalPin = false;
|
|
97
|
+
// #1115's notice latches the same way and for the same reason, so it is
|
|
98
|
+
// cleared here too — a "said once" contract nothing can reset is a contract
|
|
99
|
+
// no test can check.
|
|
100
|
+
warnedUntrackedLocalPin = false;
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
function noteLegacyConfigDir(path: string): void {
|
|
@@ -105,13 +110,30 @@ function noteLegacyConfigDir(path: string): void {
|
|
|
105
110
|
);
|
|
106
111
|
}
|
|
107
112
|
|
|
108
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Said once when a repo pin is read under the old name.
|
|
115
|
+
*
|
|
116
|
+
* It names the COMMAND, not the outcome (#1108). "Rename it to `.hmy.json`"
|
|
117
|
+
* described a result and left every operator doing it by hand in every repo —
|
|
118
|
+
* eight of them on the author's machine, none migrated — while
|
|
119
|
+
* `harmony-agent doctor --fix` had to be found by reading the source. A notice
|
|
120
|
+
* for a temporary fallback is worth nothing if acting on it is the hard part.
|
|
121
|
+
*
|
|
122
|
+
* The manual rename stays beside it, for two readers the command does not
|
|
123
|
+
* serve: `harmony-agent` is the bin of `@gethmy/agent`, so a client running
|
|
124
|
+
* `@gethmy/mcp` alone does not have it — and an INSTALLED agent older than
|
|
125
|
+
* #1108 accepts the unknown `--fix`, prints `preflight ok` and exits 0 without
|
|
126
|
+
* migrating anything, which is a false green the notice must not lead a reader
|
|
127
|
+
* into trusting.
|
|
128
|
+
*/
|
|
109
129
|
export function noteLegacyLocalPin(path: string): void {
|
|
110
130
|
if (warnedLegacyLocalPin) return;
|
|
111
131
|
warnedLegacyLocalPin = true;
|
|
112
132
|
console.error(
|
|
113
133
|
`Harmony: this repo is pinned by ${path}, the pre-#1082 name. ` +
|
|
114
|
-
`
|
|
134
|
+
`Run \`harmony-agent doctor --fix\` in this repo to write ` +
|
|
135
|
+
`${LOCAL_CONFIG_FILENAME}, or rename the file yourself. ` +
|
|
136
|
+
`The fallback that finds it is temporary.`,
|
|
115
137
|
);
|
|
116
138
|
}
|
|
117
139
|
|
|
@@ -122,6 +144,54 @@ function noteLocalPinRename(from: string, to: string): void {
|
|
|
122
144
|
);
|
|
123
145
|
}
|
|
124
146
|
|
|
147
|
+
let warnedUntrackedLocalPin = false;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Say once that the file we just wrote will not travel with the branch (#1115).
|
|
151
|
+
*
|
|
152
|
+
* The whole argument for putting a repo's commands in the repo is that a
|
|
153
|
+
* BRANCH carries them: what the file says is then true of the code beside it,
|
|
154
|
+
* with nothing to keep in sync. A git-ignored file has none of that property —
|
|
155
|
+
* a fresh clone does not have it, a daemon worktree does not have it, and a
|
|
156
|
+
* working directory does. That exact difference is what made FinPunk's build
|
|
157
|
+
* failure so hard to find: the file existed where a person looked and nowhere
|
|
158
|
+
* the daemon ran.
|
|
159
|
+
*
|
|
160
|
+
* A warning rather than a refusal, because it is the repo's `.gitignore` and
|
|
161
|
+
* not ours to change, and because a pin holding only workspace ids is a
|
|
162
|
+
* perfectly reasonable thing to keep out of version control. The sentence names
|
|
163
|
+
* the consequence rather than issuing an instruction.
|
|
164
|
+
*/
|
|
165
|
+
function noteUntrackedLocalPin(path: string): void {
|
|
166
|
+
if (warnedUntrackedLocalPin) return;
|
|
167
|
+
warnedUntrackedLocalPin = true;
|
|
168
|
+
console.error(
|
|
169
|
+
`Harmony: ${path} is ignored by git, so it will not travel with a branch — ` +
|
|
170
|
+
`a fresh clone, and every worktree the agent daemon cuts, will not have it. ` +
|
|
171
|
+
`Commit it if you want it to describe this repo everywhere.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Is `path` ignored by git? `false` on any doubt.
|
|
177
|
+
*
|
|
178
|
+
* `git check-ignore` exits 1 for "not ignored" and 128 outside a repository,
|
|
179
|
+
* and `execFileSync` throws on both, so every uncertain answer becomes "not
|
|
180
|
+
* ignored" — the direction that stays quiet. A warning nobody can act on is
|
|
181
|
+
* worse than no warning.
|
|
182
|
+
*/
|
|
183
|
+
function isGitIgnored(path: string): boolean {
|
|
184
|
+
try {
|
|
185
|
+
execFileSync("git", ["check-ignore", "--quiet", path], {
|
|
186
|
+
cwd: dirname(path),
|
|
187
|
+
stdio: "ignore",
|
|
188
|
+
});
|
|
189
|
+
return true;
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
125
195
|
/**
|
|
126
196
|
* `~/.hmy` — the root the whole `hmy` surface shares. Named separately from
|
|
127
197
|
* `getConfigDir()` because the denylists want the WHOLE tree, not just the
|
|
@@ -309,21 +379,199 @@ export function saveLocalConfig(
|
|
|
309
379
|
noteLocalPinRename(foundPath, localConfigPath);
|
|
310
380
|
}
|
|
311
381
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
382
|
+
// Start from the file's RAW contents, not from `LocalConfig` (#1115).
|
|
383
|
+
//
|
|
384
|
+
// This function used to rebuild the file from two fields it understands, so
|
|
385
|
+
// every other key was silently deleted on the next `set_project_context`.
|
|
386
|
+
// That was survivable while `.hmy.json` held nothing else. It stopped being
|
|
387
|
+
// survivable the moment the file grew a `commands` block a person writes by
|
|
388
|
+
// hand: a pin update would have thrown away the repo's own account of how to
|
|
389
|
+
// build itself, hours or days later, with nothing in the output to connect
|
|
390
|
+
// the two events.
|
|
391
|
+
//
|
|
392
|
+
// The writer deliberately does NOT learn what `commands` means. It is the
|
|
393
|
+
// daemon that reads them (`repo-commands.ts` in `@gethmy/harness`), and
|
|
394
|
+
// teaching this package the schema would mean a workspace dependency on a
|
|
395
|
+
// published package, kept in lockstep, to gain nothing: preserving a key
|
|
396
|
+
// needs no understanding of it.
|
|
397
|
+
// Read from where the pin was FOUND, which on a legacy rename is not where
|
|
398
|
+
// it is about to be written — otherwise the rename would drop every key the
|
|
399
|
+
// old file carried, which is the same deletion one paragraph up.
|
|
400
|
+
const existing = readRawLocalConfig(foundPath ?? localConfigPath);
|
|
401
|
+
const merged: Record<string, unknown> = { ...existing };
|
|
402
|
+
// Null still means "drop it" for the two fields this function owns — that is
|
|
403
|
+
// what `set_project_context` with a cleared value has always meant — but it
|
|
404
|
+
// now says so field by field instead of by omission from a rebuild.
|
|
405
|
+
if ("workspaceId" in config) {
|
|
406
|
+
if (config.workspaceId) merged.workspaceId = config.workspaceId;
|
|
407
|
+
else delete merged.workspaceId;
|
|
408
|
+
}
|
|
409
|
+
if ("projectId" in config) {
|
|
410
|
+
if (config.projectId) merged.projectId = config.projectId;
|
|
411
|
+
else delete merged.projectId;
|
|
412
|
+
}
|
|
322
413
|
|
|
323
|
-
writeFileSync(
|
|
414
|
+
writeFileSync(
|
|
415
|
+
localConfigPath,
|
|
416
|
+
`${JSON.stringify(merged, null, localIndent(localConfigPath))}\n`,
|
|
417
|
+
);
|
|
418
|
+
if (isGitIgnored(localConfigPath)) noteUntrackedLocalPin(localConfigPath);
|
|
324
419
|
return localConfigPath;
|
|
325
420
|
}
|
|
326
421
|
|
|
422
|
+
/**
|
|
423
|
+
* The file's own contents, as written, or `{}` when there is nothing readable.
|
|
424
|
+
*
|
|
425
|
+
* Deliberately untyped: the point is to carry keys this package does not
|
|
426
|
+
* model. A malformed file yields `{}` rather than throwing — the pin write is
|
|
427
|
+
* the user's immediate intent and must not fail because of something they did
|
|
428
|
+
* to the file earlier; the daemon's reader reports the malformed file
|
|
429
|
+
* separately, and loudly.
|
|
430
|
+
*/
|
|
431
|
+
function readRawLocalConfig(path: string): Record<string, unknown> {
|
|
432
|
+
let text: string;
|
|
433
|
+
try {
|
|
434
|
+
text = readFileSync(path, "utf-8");
|
|
435
|
+
} catch {
|
|
436
|
+
// No file yet. Nothing is being discarded, so nothing to say.
|
|
437
|
+
return {};
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const parsed = JSON.parse(text);
|
|
441
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
442
|
+
return parsed as Record<string, unknown>;
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
// Fall through to the notice below.
|
|
446
|
+
}
|
|
447
|
+
// The write that follows REPLACES this file. An earlier version of this
|
|
448
|
+
// comment excused that with "the daemon's reader reports the malformed file
|
|
449
|
+
// separately, and loudly" — which is false: after the overwrite the file
|
|
450
|
+
// parses cleanly and carries no `commands`, so the daemon sees a perfectly
|
|
451
|
+
// good pin and nobody is ever told. Hand-written JSON is exactly where a
|
|
452
|
+
// syntax error happens, and hand-writing this file is the case #1115
|
|
453
|
+
// creates.
|
|
454
|
+
//
|
|
455
|
+
// A copy and a warning rather than a refusal: the pin write is the user's
|
|
456
|
+
// immediate intent and must not fail because of something they did to the
|
|
457
|
+
// file earlier — but the bytes they lose have to land somewhere they can
|
|
458
|
+
// get them back from.
|
|
459
|
+
noteUnparsableLocalPin(path, text);
|
|
460
|
+
return {};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Keep a copy of an unparsable pin, and say so, before it is overwritten
|
|
465
|
+
* (#1115).
|
|
466
|
+
*
|
|
467
|
+
* The copy goes to the system temp directory, NOT beside the file. A `.bak` in
|
|
468
|
+
* the repo root is an untracked file nobody asked for, that no `.gitignore`
|
|
469
|
+
* covers, that a later failure silently overwrites, and that this code has no
|
|
470
|
+
* way to clean up. The temp directory is reaped by the OS and the warning names
|
|
471
|
+
* the exact path, which is all a person needs to get their bytes back.
|
|
472
|
+
*
|
|
473
|
+
* A unique name per call, so two failures in one session do not overwrite one
|
|
474
|
+
* another's copy — the second is likelier to be the interesting one.
|
|
475
|
+
*
|
|
476
|
+
* Best-effort on the copy: if it cannot be written, the warning still goes out
|
|
477
|
+
* and still says the contents are gone. Silence is the one outcome this
|
|
478
|
+
* function exists to prevent.
|
|
479
|
+
*/
|
|
480
|
+
function noteUnparsableLocalPin(path: string, contents: string): void {
|
|
481
|
+
let backup: string | null = null;
|
|
482
|
+
try {
|
|
483
|
+
backup = join(
|
|
484
|
+
tmpdir(),
|
|
485
|
+
`hmy-pin-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json.bak`,
|
|
486
|
+
);
|
|
487
|
+
writeFileSync(backup, contents);
|
|
488
|
+
} catch {
|
|
489
|
+
backup = null;
|
|
490
|
+
}
|
|
491
|
+
console.error(
|
|
492
|
+
`Harmony: ${path} could not be parsed as JSON, so the pin write REPLACED it. ` +
|
|
493
|
+
(backup
|
|
494
|
+
? `The previous contents are in ${backup}.`
|
|
495
|
+
: "The previous contents could not be backed up and are gone.") +
|
|
496
|
+
` If it carried a "commands" block, re-add it.`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The indentation this repo formats with (#1115).
|
|
502
|
+
*
|
|
503
|
+
* `JSON.stringify(_, null, 2)` was hardcoded, and that is a real failure and
|
|
504
|
+
* not a nicety: FinPunk's `biome.json` sets `indentStyle: "tab"`, so the file
|
|
505
|
+
* the Harmony CLI wrote turned `bun run lint` red in every working directory
|
|
506
|
+
* that had one — a repo's own gate failing on a file the tool wrote for it. The
|
|
507
|
+
* card's rule is that a file the CLI writes should follow the repo's formatter.
|
|
508
|
+
*
|
|
509
|
+
* Read in the order a formatter itself would: `biome.json` (and `biome.jsonc`),
|
|
510
|
+
* then `.editorconfig`, then the two-space default. `.prettierrc` is
|
|
511
|
+
* deliberately not read — Prettier does not format `.hmy.json` unless somebody
|
|
512
|
+
* configures it to, and guessing from a config that does not govern this file
|
|
513
|
+
* would be worse than the default.
|
|
514
|
+
*
|
|
515
|
+
* Best-effort by construction: any read or parse failure falls through to the
|
|
516
|
+
* next source. A wrong guess costs a formatter diff, which is exactly what the
|
|
517
|
+
* old hardcoded value cost every tab-indented repo.
|
|
518
|
+
*/
|
|
519
|
+
function localIndent(configPath: string): string | number {
|
|
520
|
+
const root = dirname(configPath);
|
|
521
|
+
for (const name of ["biome.json", "biome.jsonc"]) {
|
|
522
|
+
const parsed = readJsonish(join(root, name));
|
|
523
|
+
if (!parsed || typeof parsed !== "object") continue;
|
|
524
|
+
const formatter = (parsed as { formatter?: Record<string, unknown> })
|
|
525
|
+
.formatter;
|
|
526
|
+
if (formatter?.indentStyle === "space") {
|
|
527
|
+
const width = formatter.indentWidth;
|
|
528
|
+
return typeof width === "number" && width > 0 ? width : 2;
|
|
529
|
+
}
|
|
530
|
+
// Biome's OWN default is `tab`, so a config that exists and says nothing
|
|
531
|
+
// about `indentStyle` — no `formatter` block at all, or a linter-only
|
|
532
|
+
// file — still formats with tabs. Reading a present biome config as "two
|
|
533
|
+
// spaces" reproduces the exact AC-5 failure one config shape away: this
|
|
534
|
+
// repo passes only because it pins `space` explicitly.
|
|
535
|
+
return "\t";
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const editorconfig = readTextSafely(join(root, ".editorconfig"));
|
|
539
|
+
if (editorconfig) {
|
|
540
|
+
if (/^\s*indent_style\s*=\s*tab\s*$/im.test(editorconfig)) return "\t";
|
|
541
|
+
const size = editorconfig.match(/^\s*indent_size\s*=\s*(\d+)\s*$/im);
|
|
542
|
+
if (size) {
|
|
543
|
+
const width = Number(size[1]);
|
|
544
|
+
if (width > 0) return width;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return 2;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Parse JSON that may carry comments (biome accepts them). `null` on failure. */
|
|
552
|
+
function readJsonish(path: string): unknown {
|
|
553
|
+
const text = readTextSafely(path);
|
|
554
|
+
if (text === null) return null;
|
|
555
|
+
try {
|
|
556
|
+
// Strip line comments and trailing commas — enough for a config file, and
|
|
557
|
+
// a parse failure just falls through to the next source.
|
|
558
|
+
const stripped = text
|
|
559
|
+
.replace(/^\s*\/\/.*$/gm, "")
|
|
560
|
+
.replace(/,(\s*[}\]])/g, "$1");
|
|
561
|
+
return JSON.parse(stripped);
|
|
562
|
+
} catch {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function readTextSafely(path: string): string | null {
|
|
568
|
+
try {
|
|
569
|
+
return readFileSync(path, "utf-8");
|
|
570
|
+
} catch {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
327
575
|
export function hasLocalConfig(cwd?: string): boolean {
|
|
328
576
|
// Same walk as `loadLocalConfig`, or this reports "no local config" for a
|
|
329
577
|
// session an ancestor file actually governs.
|
package/src/prompt-builder.ts
CHANGED
|
@@ -249,7 +249,7 @@ const DEFAULT_ROLE_FRAMINGS: Record<LabelCategory, RoleFraming> = {
|
|
|
249
249
|
// Variant-specific instructions
|
|
250
250
|
const VARIANT_INSTRUCTIONS: Record<PromptVariant, string> = {
|
|
251
251
|
analysis: `ANALYSIS MODE: Analyze this task thoroughly. Identify requirements, constraints, edge cases, and potential challenges. Do NOT implement anything yet - focus on understanding and planning.`,
|
|
252
|
-
draft: `DRAFT MODE:
|
|
252
|
+
draft: `DRAFT MODE: Draft the approach for review before implementing. Cover the key decisions with their reasons, the data model and the API contracts, and success criteria a test can check. A short signature or schema sketch is fine wherever an interpretation gap would otherwise remain; function bodies, control flow and test code are not - an implementer transcribes a plan faithfully, defects included.`,
|
|
253
253
|
execute: `EXECUTE MODE: Implement this task completely. Write production-ready code following best practices. Include necessary tests and documentation.`,
|
|
254
254
|
};
|
|
255
255
|
|
package/src/server.ts
CHANGED
|
@@ -1299,6 +1299,25 @@ export const TOOLS = {
|
|
|
1299
1299
|
required: ["cardId", "url"],
|
|
1300
1300
|
},
|
|
1301
1301
|
},
|
|
1302
|
+
harmony_remove_external_link: {
|
|
1303
|
+
description:
|
|
1304
|
+
"Remove an external reference URL from a card — the counterpart to harmony_add_external_link. Takes the link id from harmony_get_card_external_links, not the URL.",
|
|
1305
|
+
inputSchema: {
|
|
1306
|
+
type: "object",
|
|
1307
|
+
properties: {
|
|
1308
|
+
cardId: {
|
|
1309
|
+
type: "string",
|
|
1310
|
+
description: "Card UUID",
|
|
1311
|
+
},
|
|
1312
|
+
linkId: {
|
|
1313
|
+
type: "string",
|
|
1314
|
+
description:
|
|
1315
|
+
"External link UUID, as returned by harmony_get_card_external_links",
|
|
1316
|
+
},
|
|
1317
|
+
},
|
|
1318
|
+
required: ["cardId", "linkId"],
|
|
1319
|
+
},
|
|
1320
|
+
},
|
|
1302
1321
|
|
|
1303
1322
|
// Subtask operations
|
|
1304
1323
|
harmony_create_subtask: {
|
|
@@ -2263,7 +2282,7 @@ export const TOOLS = {
|
|
|
2263
2282
|
|
|
2264
2283
|
harmony_create_plan: {
|
|
2265
2284
|
description:
|
|
2266
|
-
"Create a new project plan. Use this to upload
|
|
2285
|
+
"Create a new project plan. Use this to upload a plan written during planning. Returns a URL where the plan can be viewed and edited in Harmony.",
|
|
2267
2286
|
inputSchema: {
|
|
2268
2287
|
type: "object",
|
|
2269
2288
|
properties: {
|
|
@@ -2287,7 +2306,11 @@ export const TOOLS = {
|
|
|
2287
2306
|
items: {
|
|
2288
2307
|
type: "object",
|
|
2289
2308
|
properties: {
|
|
2290
|
-
content: {
|
|
2309
|
+
content: {
|
|
2310
|
+
type: "string",
|
|
2311
|
+
description:
|
|
2312
|
+
'One success criterion, as a statement about the finished product that a test can check ("the mirror matches the migration chain"), never a work package ("write the mirror script"). One criterion may take several cards.',
|
|
2313
|
+
},
|
|
2291
2314
|
priority: {
|
|
2292
2315
|
type: "string",
|
|
2293
2316
|
enum: ["high", "medium", "low"],
|
|
@@ -2301,7 +2324,8 @@ export const TOOLS = {
|
|
|
2301
2324
|
},
|
|
2302
2325
|
required: ["content"],
|
|
2303
2326
|
},
|
|
2304
|
-
description:
|
|
2327
|
+
description:
|
|
2328
|
+
"The plan's success criteria, one entry each - what must be true when the plan is done, not a breakdown of the work to do it.",
|
|
2305
2329
|
},
|
|
2306
2330
|
},
|
|
2307
2331
|
required: ["title"],
|
|
@@ -2324,7 +2348,11 @@ export const TOOLS = {
|
|
|
2324
2348
|
},
|
|
2325
2349
|
harmony_update_plan: {
|
|
2326
2350
|
description:
|
|
2327
|
-
"Update an existing plan
|
|
2351
|
+
"Update an existing plan: its title, content, status, or the timeline dates its bar spans. " +
|
|
2352
|
+
"`startDate`/`endDate` are the plan's OWN schedule, the same pair a person sets by dragging the bar in the timeline view. " +
|
|
2353
|
+
"A plan is pinned on both or on neither: send both to schedule it, or both as null to return it to the span derived from its linked cards. " +
|
|
2354
|
+
"Sending one alone is refused unless the plan is already pinned. " +
|
|
2355
|
+
"They are never adjusted automatically — a card running past `endDate` is drawn as an overrun, and only a person extends the plan.",
|
|
2328
2356
|
inputSchema: {
|
|
2329
2357
|
type: "object",
|
|
2330
2358
|
properties: {
|
|
@@ -2339,6 +2367,22 @@ export const TOOLS = {
|
|
|
2339
2367
|
enum: ["draft", "active", "archived"],
|
|
2340
2368
|
description: "New status",
|
|
2341
2369
|
},
|
|
2370
|
+
// `nullable: true` rather than a union `type`, matching every other
|
|
2371
|
+
// nullable argument in this file. A client that does not accept an
|
|
2372
|
+
// array-valued `type` would otherwise drop the argument entirely — and
|
|
2373
|
+
// reaching agents is the whole point of this half of the card.
|
|
2374
|
+
startDate: {
|
|
2375
|
+
type: "string",
|
|
2376
|
+
nullable: true,
|
|
2377
|
+
description:
|
|
2378
|
+
"Timeline start as YYYY-MM-DD, or null to unpin (send endDate null too).",
|
|
2379
|
+
},
|
|
2380
|
+
endDate: {
|
|
2381
|
+
type: "string",
|
|
2382
|
+
nullable: true,
|
|
2383
|
+
description:
|
|
2384
|
+
"Timeline end as YYYY-MM-DD, or null to unpin (send startDate null too). Must not precede startDate.",
|
|
2385
|
+
},
|
|
2342
2386
|
},
|
|
2343
2387
|
required: ["planId"],
|
|
2344
2388
|
},
|
|
@@ -3775,6 +3819,14 @@ export async function handleToolCall(
|
|
|
3775
3819
|
return { success: true, ...result };
|
|
3776
3820
|
}
|
|
3777
3821
|
|
|
3822
|
+
case "harmony_remove_external_link": {
|
|
3823
|
+
const cardId = z.string().uuid().parse(args.cardId);
|
|
3824
|
+
const linkId = z.string().uuid().parse(args.linkId);
|
|
3825
|
+
// The route already answers `{ success: true }`, so this returns it
|
|
3826
|
+
// as-is rather than re-spreading a second `success` over it.
|
|
3827
|
+
return await client.removeExternalLink(cardId, linkId);
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3778
3830
|
// Removed — dropped from the advertised TOOLS list; the case remains so an
|
|
3779
3831
|
// older `hmy-new` install that still calls it gets a legible notice rather
|
|
3780
3832
|
// than an unknown-tool error.
|
|
@@ -5585,6 +5637,8 @@ export async function handleToolCall(
|
|
|
5585
5637
|
title?: string;
|
|
5586
5638
|
content?: string;
|
|
5587
5639
|
status?: "draft" | "active" | "archived";
|
|
5640
|
+
startDate?: string | null;
|
|
5641
|
+
endDate?: string | null;
|
|
5588
5642
|
} = {};
|
|
5589
5643
|
|
|
5590
5644
|
if (args.title !== undefined)
|
|
@@ -5595,6 +5649,18 @@ export async function handleToolCall(
|
|
|
5595
5649
|
.enum(["draft", "active", "archived"])
|
|
5596
5650
|
.parse(args.status);
|
|
5597
5651
|
}
|
|
5652
|
+
// The plan's own timeline dates (card #1119). Shape only: whether the
|
|
5653
|
+
// RESULTING pair is legal depends on what the plan already carries, so the
|
|
5654
|
+
// both-or-neither and ordering rules are decided server-side in
|
|
5655
|
+
// `_shared/plan-timeline-dates.ts` — the one place that can see the row.
|
|
5656
|
+
// `null` is meaningful here (it unpins), so it must survive the parse.
|
|
5657
|
+
const planDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
|
|
5658
|
+
message: "expected a date as YYYY-MM-DD",
|
|
5659
|
+
});
|
|
5660
|
+
if (args.startDate !== undefined)
|
|
5661
|
+
updates.startDate = planDate.nullable().parse(args.startDate);
|
|
5662
|
+
if (args.endDate !== undefined)
|
|
5663
|
+
updates.endDate = planDate.nullable().parse(args.endDate);
|
|
5598
5664
|
|
|
5599
5665
|
const result = await client.updatePlan(planId, updates);
|
|
5600
5666
|
return { success: true, plan: result.plan };
|