cawdev-cli 1.0.8-beta → 1.0.9-beta

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 CHANGED
@@ -5,4 +5,9 @@ and spawning processes are Node's home turf, and a file with no dependencies is
5
5
  one you can read before running it against your repositories.
6
6
  visit https://cawdev.com to learn more
7
7
 
8
- https://github.com/4dali/cawdev-cli
8
+ https://github.com/4dali/cawdev-cli
9
+ ## jira/
10
+
11
+ `jira/import.mjs` brings a Jira Cloud project's issues, epics, sprints and
12
+ comments into a cawdev project, once and again without duplicates (R337). Run from a checkout; see
13
+ "Leaving Jira" in GUIDE.md. Its tests: `node --test "tools/jira/*.test.mjs"`.
@@ -232,6 +232,32 @@ export function parseRoadmap(text) {
232
232
  }
233
233
  continue;
234
234
  }
235
+ // R351. The date shape is checked here, where the line is read: a
236
+ // `Due:` that is not a date is a hand edit, and --live would otherwise
237
+ // report it as drift from a database that never held it.
238
+ const due = /^Due:\s*(.+)$/.exec(line.trim());
239
+ if (due) {
240
+ entry.due = due[1].trim();
241
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.due) || Number.isNaN(Date.parse(entry.due))) {
242
+ problems.push(`${block.heading}: Due "${due[1].trim()}" is not a date written 2026-11-01.`);
243
+ }
244
+ continue;
245
+ }
246
+ // R358. The version the card means to ship in; a free string, as `Version:` is.
247
+ const target = /^Target:\s*(.+)$/.exec(line.trim());
248
+ if (target) {
249
+ entry.targetVersion = target[1].trim();
250
+ continue;
251
+ }
252
+ const estimate = /^Estimate:\s*(.+)$/.exec(line.trim());
253
+ if (estimate) {
254
+ const n = Number(estimate[1].trim());
255
+ entry.estimate = n;
256
+ if (!Number.isInteger(n) || n <= 0) {
257
+ problems.push(`${block.heading}: Estimate "${estimate[1].trim()}" is not a positive whole number.`);
258
+ }
259
+ continue;
260
+ }
235
261
  const development = /^Development:\s*(.+)$/.exec(line.trim());
236
262
  if (development) {
237
263
  entry.development = statusFromDisplay(development[1].trim());
@@ -262,6 +288,16 @@ export function parseRoadmap(text) {
262
288
  entry.afterRefs = tokens.map(({ ref }) => ref);
263
289
  continue;
264
290
  }
291
+ const parent = /^Parent:\s*(\S+)\s*$/.exec(line.trim());
292
+ if (parent) {
293
+ // R336. One ref, always written in full (R300, i12). A token that is
294
+ // not a card is left as ordinary body text rather than eaten.
295
+ const token = parseRefToken(parent[1]);
296
+ if (token) {
297
+ entry.parentRef = token.ref;
298
+ continue;
299
+ }
300
+ }
265
301
  const tags = /^Tags:\s*(.+)$/.exec(line.trim());
266
302
  if (tags) {
267
303
  // R315. Already normalised by the server (lowercase, no `#`) by the
@@ -356,6 +392,45 @@ function* splitBlocks(text) {
356
392
  if (entry) yield entry;
357
393
  }
358
394
 
395
+ /** The ref of the card this one sits under, from either shape — R336. */
396
+ function parentRefOf(entry) {
397
+ const ref = entry.parent?.ref ?? entry.parentRef;
398
+ return ref ? (parseRefToken(ref)?.ref ?? null) : null;
399
+ }
400
+
401
+ /**
402
+ * Files each child directly after its parent, within one section — R336.
403
+ *
404
+ * Pure. A child keeps its place among its siblings (they stay in the order
405
+ * given), and a child whose parent is in some other section stays exactly
406
+ * where it was: the file groups by section first, and an epic spread over
407
+ * phases is still one epic by its `Parent:` lines.
408
+ */
409
+ export function nestUnderParents(list) {
410
+ const refs = new Map(list.map((entry) => [entryRef(entry), entry]));
411
+ const nestedUnder = (entry) => {
412
+ const parent = parentRefOf(entry);
413
+ const card = parent && refs.get(parent);
414
+ // A parent that is itself a child is bad data; leave the child where it is
415
+ // rather than lose it behind a card that is not a root.
416
+ return card && !parentRefOf(card) ? parent : null;
417
+ };
418
+ const children = new Map();
419
+ for (const entry of list) {
420
+ const parent = nestedUnder(entry);
421
+ if (parent) {
422
+ if (!children.has(parent)) children.set(parent, []);
423
+ children.get(parent).push(entry);
424
+ }
425
+ }
426
+ const out = [];
427
+ for (const entry of list) {
428
+ if (nestedUnder(entry)) continue;
429
+ out.push(entry, ...(children.get(entryRef(entry)) ?? []));
430
+ }
431
+ return out;
432
+ }
433
+
359
434
  /**
360
435
  * Writes entries as ROADMAP.md's entry sections.
361
436
  *
@@ -385,7 +460,7 @@ export function renderRoadmap(entries, { preamble, sectionOrder = [] }) {
385
460
  const parts = [preamble.trimEnd(), ''];
386
461
  for (const section of ordered) {
387
462
  parts.push('---', '', `## ${section}`, '');
388
- for (const entry of bySection.get(section)) {
463
+ for (const entry of nestUnderParents(bySection.get(section))) {
389
464
  parts.push(renderEntry(entry), '');
390
465
  }
391
466
  }
@@ -443,6 +518,19 @@ function renderEntry(entry) {
443
518
  lines.push(`Priority: ${entry.priority}`);
444
519
  }
445
520
 
521
+ // R351. Two facts, written only when the card carries them, after the
522
+ // priority: when, then how big.
523
+ if (entry.due) {
524
+ lines.push(`Due: ${entry.due}`);
525
+ }
526
+ if (entry.estimate != null && entry.estimate > 0) {
527
+ lines.push(`Estimate: ${entry.estimate}`);
528
+ }
529
+ // R358. Where it means to ship; dropped once it has, because `Version:` then says.
530
+ if (entry.targetVersion && !entry.version) {
531
+ lines.push(`Target: ${entry.targetVersion}`);
532
+ }
533
+
446
534
  // Each status writes only what it currently claims. A MERGED entry keeps its
447
535
  // branch in the database as history, but the file must not print it: the
448
536
  // branch is expected to be deleted, and `--live` would then be reading a claim
@@ -492,6 +580,13 @@ function renderEntry(entry) {
492
580
  .map(({ ref }) => ref);
493
581
  lines.push(`After: ${written.join(', ')}`);
494
582
  }
583
+ {
584
+ // R336. The API gives `parent` as {number, ref, title}; the parser gives
585
+ // `parentRef`. Always the full ref, and no line at all without a parent, so
586
+ // no existing card's text changes.
587
+ const parent = parentRefOf(entry);
588
+ if (parent) lines.push(`Parent: ${parent}`);
589
+ }
495
590
  if (entry.tags?.length) {
496
591
  // R315. Sorted, so the export does not churn on the set's own order.
497
592
  lines.push(`Tags: ${[...entry.tags].sort().join(', ')}`);
package/mcp/README.md CHANGED
@@ -163,7 +163,7 @@ what you are allowed to do.
163
163
  | `roadmap_list` | Entries, optionally by status. `brief` omits bodies — use it to survey. |
164
164
  | `roadmap_get` | One entry in full: its body, **the plan agreed for it**, and the discussion on it. |
165
165
  | `roadmap_create` | Create an entry; the platform allocates its permanent number. |
166
- | `roadmap_update` | Title, body, section, related ids, the cards it starts coding after (`after` — R181), and who it is assigned to (`assignee` — R331: a username, `""` clears; `roadmap_list`/`issue_list` take `assignee` too, and `me` means the token's owner). |
166
+ | `roadmap_update` | Title, body, section, related ids, the cards it starts coding after (`after` — R181), when it is due and how big it is (`due` — R351: an ISO date, `""` clears; `estimate`: a positive number, `0` clears), the version it is meant for (`targetVersion` — R358, `""` clears; since R362 it must be one the project defined under Project settings → Versions, and any other name is refused), and who it is assigned to (`assignee` — R331: a username, `""` clears; `roadmap_list`/`issue_list` take `assignee` too, and `me` means the token's owner). |
167
167
  | `roadmap_comment` | Say something *beside* an entry rather than inside it. |
168
168
  | `roadmap_set_status` | Move an entry, carrying whatever the status requires. |
169
169
  | `roadmap_decline` | Decline with a reason. The only exit. |
package/mcp/server.mjs CHANGED
@@ -692,6 +692,26 @@ const TOOLS = [
692
692
  description:
693
693
  'How much it matters; it does not reorder the board. Leave out for MEDIUM.',
694
694
  },
695
+ due: {
696
+ type: 'string',
697
+ description: 'When it is due, written 2026-11-01. A fact on the card; it moves nothing. Leave out for none.',
698
+ },
699
+ estimate: {
700
+ type: 'integer',
701
+ minimum: 1,
702
+ description: "How big it is, in the team's own unit (points, days — the platform does not say). Leave out for none.",
703
+ },
704
+ targetVersion: {
705
+ type: 'string',
706
+ description: 'The version it is meant to ship in — one the project DEFINED under Project settings → Versions (R362); any other name is refused. The release corrects it if it lands elsewhere. Leave out for none.',
707
+ },
708
+ parent: {
709
+ type: 'string',
710
+ description:
711
+ 'The card this one sits under (an epic), e.g. "R300". One level only: the parent ' +
712
+ 'must be the same kind and have no parent itself, and this card must have no ' +
713
+ 'children. An open card cannot be filed under a SHIPPED or DECLINED one.',
714
+ },
695
715
  },
696
716
  required: ['title'],
697
717
  },
@@ -701,7 +721,7 @@ const TOOLS = [
701
721
  method: 'POST',
702
722
  body: pick(args, [
703
723
  'title', 'body', 'status', 'branch', 'merge', 'version', 'reason', 'section', 'related',
704
- 'after', 'sprint', 'tags', 'assignee', 'priority',
724
+ 'after', 'sprint', 'tags', 'assignee', 'priority', 'due', 'estimate', 'targetVersion', 'parent',
705
725
  ]),
706
726
  });
707
727
  return `Created ${refOf(entry)} in ${slug}.\n\n${formatEntry(entry, {})}`;
@@ -830,8 +850,8 @@ const TOOLS = [
830
850
  {
831
851
  name: 'roadmap_update',
832
852
  description:
833
- 'Edit an entry\'s title, body, section, sprint, tags, related refs or the cards it starts ' +
834
- 'after. Use roadmap_set_status to move it.',
853
+ 'Edit an entry\'s title, body, section, sprint, tags, parent, related refs or the cards it ' +
854
+ 'starts after. Use roadmap_set_status to move it.',
835
855
  inputSchema: {
836
856
  type: 'object',
837
857
  properties: {
@@ -882,6 +902,25 @@ const TOOLS = [
882
902
  description:
883
903
  'How much it matters; it does not reorder the board. Leave out for MEDIUM (leave out to leave it alone).',
884
904
  },
905
+ due: {
906
+ type: 'string',
907
+ description: 'When it is due, written 2026-11-01. Leave out to leave it alone; "" clears it.',
908
+ },
909
+ estimate: {
910
+ type: 'integer',
911
+ minimum: 0,
912
+ description: "How big it is, in the team's own unit. Leave out to leave it alone; 0 clears it.",
913
+ },
914
+ targetVersion: {
915
+ type: 'string',
916
+ description: 'The version it is meant to ship in — one the project defined (R362); any other name is refused. Leave out to leave it alone; "" clears it.',
917
+ },
918
+ parent: {
919
+ type: 'string',
920
+ description:
921
+ 'The card this one sits under (an epic), e.g. "R300". "" takes the card out of its ' +
922
+ 'parent; leave out to leave it alone. Same rules as roadmap_create.',
923
+ },
885
924
  },
886
925
  required: ['number'],
887
926
  },
@@ -890,7 +929,10 @@ const TOOLS = [
890
929
  const entry = await api(config, `/api/projects/${slug}/roadmap/${args.number}`, {
891
930
  method: 'PATCH',
892
931
  // pick keeps a present 0, which is how a card leaves its sprint.
893
- body: pick(args, ['title', 'body', 'section', 'related', 'after', 'sprint', 'tags', 'assignee', 'priority']),
932
+ body: pick(args, [
933
+ 'title', 'body', 'section', 'related', 'after', 'sprint', 'tags', 'assignee', 'priority',
934
+ 'due', 'estimate', 'targetVersion', 'parent',
935
+ ]),
894
936
  });
895
937
  return `Updated ${refOf(entry)}.\n\n${formatEntry(entry, {})}`;
896
938
  },
@@ -1154,8 +1196,22 @@ const TOOLS = [
1154
1196
  `run: ${run.state}${run.runnerName ? ` on ${run.runnerName}` : ''}`,
1155
1197
  `started by ${run.startedByEmail}`,
1156
1198
  '',
1157
- formatEntry(entry, { comments, plan: plans?.[0] ?? null }),
1158
1199
  );
1200
+ // R336. The epic this card was cut from, so a session sees the context.
1201
+ // Its own try, like the history above: a parent that cannot be read (a
1202
+ // 404, no grant) costs the paragraph and never the call.
1203
+ if (entry.parent) {
1204
+ const epic = await api(config, `/api/projects/${project}/roadmap/${entry.parent.ref}`)
1205
+ .catch(() => null);
1206
+ if (epic) {
1207
+ lines.push(
1208
+ `This card is part of ${entry.parent.ref} — ${epic.title}:`,
1209
+ epicExcerpt(epic.body),
1210
+ '',
1211
+ );
1212
+ }
1213
+ }
1214
+ lines.push(formatEntry(entry, { comments, plan: plans?.[0] ?? null }));
1159
1215
 
1160
1216
  // What happened the other times. A session is told the entry and the
1161
1217
  // branch but not that two previous runs on this card failed, which is
@@ -2173,6 +2229,15 @@ function originWord(origin) {
2173
2229
  }
2174
2230
  }
2175
2231
 
2232
+ /** The first 600 characters of an epic's body, cut at a word — R336. */
2233
+ function epicExcerpt(body) {
2234
+ const text = (body ?? '').trim();
2235
+ if (text.length <= 600) return text;
2236
+ const cut = text.slice(0, 600);
2237
+ const space = cut.lastIndexOf(' ');
2238
+ return `${(space > 0 ? cut.slice(0, space) : cut).trimEnd()}…`;
2239
+ }
2240
+
2176
2241
  function formatEntry(entry, { brief, comments, plan }) {
2177
2242
  const lines = [`${refOf(entry)} — ${entry.title}`, ` status: ${entry.statusDisplay}`];
2178
2243
  if (entry.branch) lines.push(` branch: ${entry.branch}`);
@@ -2189,6 +2254,11 @@ function formatEntry(entry, { brief, comments, plan }) {
2189
2254
  if (entry.priority && entry.priority !== 'MEDIUM') {
2190
2255
  lines.push(` priority: ${entry.priority}`);
2191
2256
  }
2257
+ // R351. Both printed only when the card carries them, as the export does.
2258
+ if (entry.due) lines.push(` due: ${entry.due}`);
2259
+ if (entry.estimate) lines.push(` estimate: ${entry.estimate}`);
2260
+ // R358. Printed until the card ships; `version` says where it did.
2261
+ if (entry.targetVersion && !entry.version) lines.push(` target: ${entry.targetVersion}`);
2192
2262
  // R257. Always printed when the card has one, so a reader's shape does not
2193
2263
  // depend on which tool printed the card; a closed sprint says so.
2194
2264
  if (entry.sprint) {
@@ -2214,6 +2284,14 @@ function formatEntry(entry, { brief, comments, plan }) {
2214
2284
  .join(', ')}`,
2215
2285
  );
2216
2286
  }
2287
+ // R336. The epic this card sits under, and the cards under this one.
2288
+ if (entry.parent) lines.push(` parent: ${entry.parent.ref} — ${entry.parent.title}`);
2289
+ if (entry.children?.length) {
2290
+ const progress = entry.progress ? `${entry.progress.done} of ${entry.progress.total} done — ` : '';
2291
+ lines.push(
2292
+ ` children: ${progress}${entry.children.map((c) => `${c.ref} (${c.status})`).join(', ')}`,
2293
+ );
2294
+ }
2217
2295
  // Said in a survey, where the comments themselves are not fetched: an entry
2218
2296
  // with an argument attached should be visibly different from one without,
2219
2297
  // even in a list. Omitted at zero rather than written as "comments: 0".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cawdev-cli",
3
- "version": "1.0.8-beta",
3
+ "version": "1.0.9-beta",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {