@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.
@@ -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 existingConfig = loadLocalConfig(cwd) || {
155
- workspaceId: null,
156
- projectId: null
157
- };
158
- const newConfig = { ...existingConfig, ...config };
159
- const cleanConfig = {};
160
- if (newConfig.workspaceId)
161
- cleanConfig.workspaceId = newConfig.workspaceId;
162
- if (newConfig.projectId)
163
- cleanConfig.projectId = newConfig.projectId;
164
- writeFileSync(localConfigPath, JSON.stringify(cleanConfig, null, 2));
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.8.0",
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
- const existingConfig = loadLocalConfig(cwd) || {
330
- workspaceId: null,
331
- projectId: null,
332
- };
333
- const newConfig = { ...existingConfig, ...config };
334
-
335
- // Remove null values from the saved config for cleaner output
336
- const cleanConfig: Record<string, string> = {};
337
- if (newConfig.workspaceId) cleanConfig.workspaceId = newConfig.workspaceId;
338
- if (newConfig.projectId) cleanConfig.projectId = newConfig.projectId;
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(localConfigPath, JSON.stringify(cleanConfig, null, 2));
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.
@@ -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: Create a detailed implementation plan with code structure, key decisions, and approach. Include pseudocode or skeleton code where helpful. This is for review before full implementation.`,
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 implementation plans created during planning. Returns a URL where the plan can be viewed and edited in Harmony.",
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: { type: "string", description: "Task description" },
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: "Optional list of tasks to create with the plan",
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. Can update title, content, or status.",
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
- * Legacy workflow prompt used by Codex / Cursor agents. Kept inline because
16
- * those agents install via AGENTS.md and not via /v1/skills. Claude Code
17
- * agents use the DB-backed skill_resource registry (see Phase 0 of card #162).
18
- */
19
- export const HARMONY_WORKFLOW_PROMPT = `# Harmony Card Workflow
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.