@gethmy/mcp 3.8.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 +1 -1
- package/dist/cli.js +530 -155
- package/dist/index.js +135 -95
- package/dist/lib/api-client.js +110 -14
- package/dist/lib/config.js +109 -13
- package/dist/lib/oauth-refresh.js +109 -13
- package/package.json +1 -1
- package/src/api-client.ts +9 -0
- package/src/config.ts +243 -12
- package/src/prompt-builder.ts +1 -1
- package/src/server.ts +43 -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
|
@@ -15,12 +15,14 @@ var __export = (target, all) => {
|
|
|
15
15
|
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
16
16
|
|
|
17
17
|
// src/config.ts
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
18
19
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
-
import { homedir } from "node:os";
|
|
20
|
+
import { homedir, tmpdir } from "node:os";
|
|
20
21
|
import { dirname, join, parse, resolve } from "node:path";
|
|
21
22
|
function resetLegacyNoticesForTest() {
|
|
22
23
|
warnedLegacyConfigDir = false;
|
|
23
24
|
warnedLegacyLocalPin = false;
|
|
25
|
+
warnedUntrackedLocalPin = false;
|
|
24
26
|
}
|
|
25
27
|
function noteLegacyConfigDir(path) {
|
|
26
28
|
if (warnedLegacyConfigDir)
|
|
@@ -37,6 +39,23 @@ function noteLegacyLocalPin(path) {
|
|
|
37
39
|
function noteLocalPinRename(from, to) {
|
|
38
40
|
console.error(`Harmony: wrote the repo pin to ${to} (the pre-#1082 ${from} is now ignored and can be deleted).`);
|
|
39
41
|
}
|
|
42
|
+
function noteUntrackedLocalPin(path) {
|
|
43
|
+
if (warnedUntrackedLocalPin)
|
|
44
|
+
return;
|
|
45
|
+
warnedUntrackedLocalPin = true;
|
|
46
|
+
console.error(`Harmony: ${path} is ignored by git, so it will not travel with a branch — ` + `a fresh clone, and every worktree the agent daemon cuts, will not have it. ` + `Commit it if you want it to describe this repo everywhere.`);
|
|
47
|
+
}
|
|
48
|
+
function isGitIgnored(path) {
|
|
49
|
+
try {
|
|
50
|
+
execFileSync("git", ["check-ignore", "--quiet", path], {
|
|
51
|
+
cwd: dirname(path),
|
|
52
|
+
stdio: "ignore"
|
|
53
|
+
});
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
40
59
|
function getHmyRootDir() {
|
|
41
60
|
return join(homedir(), CONFIG_DIR_NAME);
|
|
42
61
|
}
|
|
@@ -151,19 +170,96 @@ function saveLocalConfig(config, cwd) {
|
|
|
151
170
|
if (foundPath !== null && foundPath !== localConfigPath) {
|
|
152
171
|
noteLocalPinRename(foundPath, localConfigPath);
|
|
153
172
|
}
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
173
|
+
const existing = readRawLocalConfig(foundPath ?? localConfigPath);
|
|
174
|
+
const merged = { ...existing };
|
|
175
|
+
if ("workspaceId" in config) {
|
|
176
|
+
if (config.workspaceId)
|
|
177
|
+
merged.workspaceId = config.workspaceId;
|
|
178
|
+
else
|
|
179
|
+
delete merged.workspaceId;
|
|
180
|
+
}
|
|
181
|
+
if ("projectId" in config) {
|
|
182
|
+
if (config.projectId)
|
|
183
|
+
merged.projectId = config.projectId;
|
|
184
|
+
else
|
|
185
|
+
delete merged.projectId;
|
|
186
|
+
}
|
|
187
|
+
writeFileSync(localConfigPath, `${JSON.stringify(merged, null, localIndent(localConfigPath))}
|
|
188
|
+
`);
|
|
189
|
+
if (isGitIgnored(localConfigPath))
|
|
190
|
+
noteUntrackedLocalPin(localConfigPath);
|
|
165
191
|
return localConfigPath;
|
|
166
192
|
}
|
|
193
|
+
function readRawLocalConfig(path) {
|
|
194
|
+
let text;
|
|
195
|
+
try {
|
|
196
|
+
text = readFileSync(path, "utf-8");
|
|
197
|
+
} catch {
|
|
198
|
+
return {};
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const parsed = JSON.parse(text);
|
|
202
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
203
|
+
return parsed;
|
|
204
|
+
}
|
|
205
|
+
} catch {}
|
|
206
|
+
noteUnparsableLocalPin(path, text);
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
function noteUnparsableLocalPin(path, contents) {
|
|
210
|
+
let backup = null;
|
|
211
|
+
try {
|
|
212
|
+
backup = join(tmpdir(), `hmy-pin-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json.bak`);
|
|
213
|
+
writeFileSync(backup, contents);
|
|
214
|
+
} catch {
|
|
215
|
+
backup = null;
|
|
216
|
+
}
|
|
217
|
+
console.error(`Harmony: ${path} could not be parsed as JSON, so the pin write REPLACED it. ` + (backup ? `The previous contents are in ${backup}.` : "The previous contents could not be backed up and are gone.") + ` If it carried a "commands" block, re-add it.`);
|
|
218
|
+
}
|
|
219
|
+
function localIndent(configPath) {
|
|
220
|
+
const root = dirname(configPath);
|
|
221
|
+
for (const name of ["biome.json", "biome.jsonc"]) {
|
|
222
|
+
const parsed = readJsonish(join(root, name));
|
|
223
|
+
if (!parsed || typeof parsed !== "object")
|
|
224
|
+
continue;
|
|
225
|
+
const formatter = parsed.formatter;
|
|
226
|
+
if (formatter?.indentStyle === "space") {
|
|
227
|
+
const width = formatter.indentWidth;
|
|
228
|
+
return typeof width === "number" && width > 0 ? width : 2;
|
|
229
|
+
}
|
|
230
|
+
return "\t";
|
|
231
|
+
}
|
|
232
|
+
const editorconfig = readTextSafely(join(root, ".editorconfig"));
|
|
233
|
+
if (editorconfig) {
|
|
234
|
+
if (/^\s*indent_style\s*=\s*tab\s*$/im.test(editorconfig))
|
|
235
|
+
return "\t";
|
|
236
|
+
const size = editorconfig.match(/^\s*indent_size\s*=\s*(\d+)\s*$/im);
|
|
237
|
+
if (size) {
|
|
238
|
+
const width = Number(size[1]);
|
|
239
|
+
if (width > 0)
|
|
240
|
+
return width;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return 2;
|
|
244
|
+
}
|
|
245
|
+
function readJsonish(path) {
|
|
246
|
+
const text = readTextSafely(path);
|
|
247
|
+
if (text === null)
|
|
248
|
+
return null;
|
|
249
|
+
try {
|
|
250
|
+
const stripped = text.replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1");
|
|
251
|
+
return JSON.parse(stripped);
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function readTextSafely(path) {
|
|
257
|
+
try {
|
|
258
|
+
return readFileSync(path, "utf-8");
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
167
263
|
function hasLocalConfig(cwd) {
|
|
168
264
|
return findLocalConfigPath(cwd) !== null;
|
|
169
265
|
}
|
|
@@ -300,7 +396,7 @@ function getMemoryDir() {
|
|
|
300
396
|
return config.memoryDir;
|
|
301
397
|
return join(homedir(), ".harmony", "memory");
|
|
302
398
|
}
|
|
303
|
-
var DEFAULT_API_URL = "https://app.gethmy.com/api", LOCAL_CONFIG_FILENAME = ".hmy.json", LEGACY_LOCAL_CONFIG_FILENAME = ".harmony-mcp.json", CONFIG_DIR_NAME = ".hmy", CONFIG_DIR_SUBDIR = "agent", LEGACY_CONFIG_DIR_NAME = ".harmony-mcp", warnedLegacyConfigDir = false, warnedLegacyLocalPin = false;
|
|
399
|
+
var DEFAULT_API_URL = "https://app.gethmy.com/api", LOCAL_CONFIG_FILENAME = ".hmy.json", LEGACY_LOCAL_CONFIG_FILENAME = ".harmony-mcp.json", CONFIG_DIR_NAME = ".hmy", CONFIG_DIR_SUBDIR = "agent", LEGACY_CONFIG_DIR_NAME = ".harmony-mcp", warnedLegacyConfigDir = false, warnedLegacyLocalPin = false, warnedUntrackedLocalPin = false;
|
|
304
400
|
var init_config = () => {};
|
|
305
401
|
|
|
306
402
|
// src/oauth-login.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gethmy/mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
4
|
"description": "MCP server for Harmony, the shared surface for human\u2013agent teams \u2014 agents claim cards, report progress, and move work on your board.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
package/src/api-client.ts
CHANGED
|
@@ -2053,12 +2053,21 @@ export class HarmonyApiClient {
|
|
|
2053
2053
|
return this.request("GET", `/cards/${cardId}/plan`);
|
|
2054
2054
|
}
|
|
2055
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
|
+
*/
|
|
2056
2063
|
async updatePlan(
|
|
2057
2064
|
planId: string,
|
|
2058
2065
|
updates: {
|
|
2059
2066
|
title?: string;
|
|
2060
2067
|
content?: string;
|
|
2061
2068
|
status?: "draft" | "active" | "archived";
|
|
2069
|
+
startDate?: string | null;
|
|
2070
|
+
endDate?: string | null;
|
|
2062
2071
|
},
|
|
2063
2072
|
): Promise<{ plan: unknown }> {
|
|
2064
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 {
|
|
@@ -139,6 +144,54 @@ function noteLocalPinRename(from: string, to: string): void {
|
|
|
139
144
|
);
|
|
140
145
|
}
|
|
141
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
|
+
|
|
142
195
|
/**
|
|
143
196
|
* `~/.hmy` — the root the whole `hmy` surface shares. Named separately from
|
|
144
197
|
* `getConfigDir()` because the denylists want the WHOLE tree, not just the
|
|
@@ -326,21 +379,199 @@ export function saveLocalConfig(
|
|
|
326
379
|
noteLocalPinRename(foundPath, localConfigPath);
|
|
327
380
|
}
|
|
328
381
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
+
}
|
|
339
413
|
|
|
340
|
-
writeFileSync(
|
|
414
|
+
writeFileSync(
|
|
415
|
+
localConfigPath,
|
|
416
|
+
`${JSON.stringify(merged, null, localIndent(localConfigPath))}\n`,
|
|
417
|
+
);
|
|
418
|
+
if (isGitIgnored(localConfigPath)) noteUntrackedLocalPin(localConfigPath);
|
|
341
419
|
return localConfigPath;
|
|
342
420
|
}
|
|
343
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
|
+
|
|
344
575
|
export function hasLocalConfig(cwd?: string): boolean {
|
|
345
576
|
// Same walk as `loadLocalConfig`, or this reports "no local config" for a
|
|
346
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
|
@@ -2282,7 +2282,7 @@ export const TOOLS = {
|
|
|
2282
2282
|
|
|
2283
2283
|
harmony_create_plan: {
|
|
2284
2284
|
description:
|
|
2285
|
-
"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.",
|
|
2286
2286
|
inputSchema: {
|
|
2287
2287
|
type: "object",
|
|
2288
2288
|
properties: {
|
|
@@ -2306,7 +2306,11 @@ export const TOOLS = {
|
|
|
2306
2306
|
items: {
|
|
2307
2307
|
type: "object",
|
|
2308
2308
|
properties: {
|
|
2309
|
-
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
|
+
},
|
|
2310
2314
|
priority: {
|
|
2311
2315
|
type: "string",
|
|
2312
2316
|
enum: ["high", "medium", "low"],
|
|
@@ -2320,7 +2324,8 @@ export const TOOLS = {
|
|
|
2320
2324
|
},
|
|
2321
2325
|
required: ["content"],
|
|
2322
2326
|
},
|
|
2323
|
-
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.",
|
|
2324
2329
|
},
|
|
2325
2330
|
},
|
|
2326
2331
|
required: ["title"],
|
|
@@ -2343,7 +2348,11 @@ export const TOOLS = {
|
|
|
2343
2348
|
},
|
|
2344
2349
|
harmony_update_plan: {
|
|
2345
2350
|
description:
|
|
2346
|
-
"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.",
|
|
2347
2356
|
inputSchema: {
|
|
2348
2357
|
type: "object",
|
|
2349
2358
|
properties: {
|
|
@@ -2358,6 +2367,22 @@ export const TOOLS = {
|
|
|
2358
2367
|
enum: ["draft", "active", "archived"],
|
|
2359
2368
|
description: "New status",
|
|
2360
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
|
+
},
|
|
2361
2386
|
},
|
|
2362
2387
|
required: ["planId"],
|
|
2363
2388
|
},
|
|
@@ -5612,6 +5637,8 @@ export async function handleToolCall(
|
|
|
5612
5637
|
title?: string;
|
|
5613
5638
|
content?: string;
|
|
5614
5639
|
status?: "draft" | "active" | "archived";
|
|
5640
|
+
startDate?: string | null;
|
|
5641
|
+
endDate?: string | null;
|
|
5615
5642
|
} = {};
|
|
5616
5643
|
|
|
5617
5644
|
if (args.title !== undefined)
|
|
@@ -5622,6 +5649,18 @@ export async function handleToolCall(
|
|
|
5622
5649
|
.enum(["draft", "active", "archived"])
|
|
5623
5650
|
.parse(args.status);
|
|
5624
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);
|
|
5625
5664
|
|
|
5626
5665
|
const result = await client.updatePlan(planId, updates);
|
|
5627
5666
|
return { success: true, plan: result.plan };
|
package/src/skills.ts
CHANGED
|
@@ -11,86 +11,12 @@ import { getClient } from "./api-client.js";
|
|
|
11
11
|
import { areSkillsInstalled, isConfigured } from "./config.js";
|
|
12
12
|
import { loadHmyConfig } from "./hmy-config.js";
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
Start work on a Harmony card. Card reference: $ARGUMENTS
|
|
22
|
-
|
|
23
|
-
## 1. Find & Fetch Card
|
|
24
|
-
|
|
25
|
-
Parse the reference and fetch the card:
|
|
26
|
-
- \`#42\` or \`42\` → \`harmony_get_card\` with \`shortId: 42\`
|
|
27
|
-
- UUID → \`harmony_get_card\` with \`cardId\`
|
|
28
|
-
- Name/text → \`harmony_search_cards\` with \`query\`
|
|
29
|
-
|
|
30
|
-
## 2. Get Board State
|
|
31
|
-
|
|
32
|
-
Call \`harmony_get_board\` to get columns and labels. From the response:
|
|
33
|
-
- Find the "In Progress" (or "Progress") column ID
|
|
34
|
-
- Find the "agent" label ID
|
|
35
|
-
|
|
36
|
-
## 3. Setup Card for Work
|
|
37
|
-
|
|
38
|
-
Execute these in sequence:
|
|
39
|
-
1. \`harmony_move_card\` → Move to "In Progress" column
|
|
40
|
-
2. \`harmony_add_label_to_card\` → Add "agent" label
|
|
41
|
-
3. \`harmony_start_agent_session\`:
|
|
42
|
-
- \`cardId\`: Card UUID
|
|
43
|
-
- \`agentIdentifier\`: Your agent identifier
|
|
44
|
-
- \`agentName\`: Your agent name
|
|
45
|
-
- \`currentTask\`: "Analyzing card requirements"
|
|
46
|
-
|
|
47
|
-
## 4. Generate Work Prompt
|
|
48
|
-
|
|
49
|
-
Call \`harmony_generate_prompt\` with:
|
|
50
|
-
- \`cardId\` or \`shortId\` (+ \`projectId\` if using shortId)
|
|
51
|
-
- \`variant\`: Select based on task:
|
|
52
|
-
- \`"execute"\` (default) → Clear tasks, bug fixes, well-defined work
|
|
53
|
-
- \`"analysis"\` → Complex features, unclear requirements
|
|
54
|
-
- \`"draft"\` → Medium complexity, want feedback first
|
|
55
|
-
|
|
56
|
-
The generated prompt provides role framing, focus areas, subtasks, linked cards, and suggested outputs.
|
|
57
|
-
|
|
58
|
-
## 5. Display Card Summary
|
|
59
|
-
|
|
60
|
-
Show the user: Card title, short ID, role, priority, labels, due date, description, and subtasks.
|
|
61
|
-
|
|
62
|
-
## 6. Implement Solution
|
|
63
|
-
|
|
64
|
-
Work on the card following the generated prompt's guidance. Update progress at milestones:
|
|
65
|
-
- \`harmony_update_agent_progress\` with \`progressPercent\` (0-100), \`currentTask\`, \`status\`, \`blockers\`
|
|
66
|
-
|
|
67
|
-
**Progress checkpoints:** 20% (exploration), 50% (implementation), 80% (testing), 100% (done)
|
|
68
|
-
|
|
69
|
-
## 7. Complete Work
|
|
70
|
-
|
|
71
|
-
When finished:
|
|
72
|
-
1. \`harmony_end_agent_session\` with \`status: "completed"\`, \`progressPercent: 100\`
|
|
73
|
-
2. \`harmony_move_card\` to "Review" column
|
|
74
|
-
3. Summarize accomplishments
|
|
75
|
-
|
|
76
|
-
If pausing: \`harmony_end_agent_session\` with \`status: "paused"\`
|
|
77
|
-
|
|
78
|
-
## Key Tools Reference
|
|
79
|
-
|
|
80
|
-
**Cards:** \`harmony_get_card\` (by \`cardId\`, \`shortId\`, or \`shortIds\`), \`harmony_search_cards\`, \`harmony_create_card\`, \`harmony_update_card\`, \`harmony_move_card\`, \`harmony_delete_card\`, \`harmony_assign_card\`
|
|
81
|
-
|
|
82
|
-
**Subtasks:** \`harmony_create_subtask\`, \`harmony_toggle_subtask\`, \`harmony_delete_subtask\`
|
|
83
|
-
|
|
84
|
-
**Labels:** \`harmony_add_label_to_card\`, \`harmony_remove_label_from_card\`, \`harmony_create_label\`
|
|
85
|
-
|
|
86
|
-
**Links:** \`harmony_add_link_to_card\`, \`harmony_remove_link_from_card\`, \`harmony_get_card_links\`
|
|
87
|
-
|
|
88
|
-
**Board:** \`harmony_get_board\`, \`harmony_list_projects\`, \`harmony_get_context\`, \`harmony_set_project_context\`
|
|
89
|
-
|
|
90
|
-
**Sessions:** \`harmony_start_agent_session\`, \`harmony_update_agent_progress\`, \`harmony_end_agent_session\`, \`harmony_get_agent_session\`
|
|
91
|
-
|
|
92
|
-
**AI:** \`harmony_generate_prompt\`, \`harmony_process_command\`
|
|
93
|
-
`;
|
|
14
|
+
// The workflow prompt for Codex / Cursor / Windsurf — which install via their
|
|
15
|
+
// own rule files rather than /v1/skills — used to live here. It moved to
|
|
16
|
+
// `tui/agent-instructions.ts` (#1124) so a test can read it alongside the
|
|
17
|
+
// AGENTS.md section and assert every tool it names is actually advertised.
|
|
18
|
+
// Import it from there; this module is not in the package `exports` map, so a
|
|
19
|
+
// re-export would have had no reachable consumer.
|
|
94
20
|
|
|
95
21
|
/**
|
|
96
22
|
* Shape of a /v1/skills/{name} response. Used by buildSkillFile + tests.
|