cawdev-cli 1.0.8-beta → 1.0.10-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"`.
@@ -56,11 +56,13 @@ export function statusToDisplay(status) {
56
56
  * The file has always grouped these two this way, and deriving them means a
57
57
  * declined entry cannot end up filed under a phase it is no longer part of.
58
58
  *
59
- * Two, and REVIEW is deliberately not a third: work waiting to be read is still
59
+ * Three since R371 — BACKLOG joined — and REVIEW is deliberately not a fourth: work waiting to be read is still
60
60
  * part of the phase it belongs to, and moving it to a section of its own would
61
61
  * take it out of its phase for a few days and put it back afterwards.
62
62
  */
63
63
  export const STATUS_SECTIONS = {
64
+ // R371: waiting to be brought in, before what is being considered.
65
+ BACKLOG: 'Backlog — waiting to be brought in',
64
66
  CONSIDERING: 'Considering — wanted, not settled',
65
67
  DECLINED: 'Declined — decided against, with the reason',
66
68
  };
@@ -77,6 +79,8 @@ export const STATUS_SECTIONS = {
77
79
  export const ISSUE_SECTIONS = {
78
80
  NEW: 'New — filed, not yet triaged',
79
81
  CONFIRMED: 'Confirmed — real, waiting for somebody',
82
+ // R371: an issue waiting to be brought in — after what needs somebody now.
83
+ BACKLOG: 'Backlog — waiting to be brought in',
80
84
  IN_DEVELOPMENT: 'In development — somebody is fixing it',
81
85
  MERGED: 'Resolved — the fix landed',
82
86
  SHIPPED: 'Released — the fix shipped',
@@ -232,6 +236,32 @@ export function parseRoadmap(text) {
232
236
  }
233
237
  continue;
234
238
  }
239
+ // R351. The date shape is checked here, where the line is read: a
240
+ // `Due:` that is not a date is a hand edit, and --live would otherwise
241
+ // report it as drift from a database that never held it.
242
+ const due = /^Due:\s*(.+)$/.exec(line.trim());
243
+ if (due) {
244
+ entry.due = due[1].trim();
245
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.due) || Number.isNaN(Date.parse(entry.due))) {
246
+ problems.push(`${block.heading}: Due "${due[1].trim()}" is not a date written 2026-11-01.`);
247
+ }
248
+ continue;
249
+ }
250
+ // R358. The version the card means to ship in; a free string, as `Version:` is.
251
+ const target = /^Target:\s*(.+)$/.exec(line.trim());
252
+ if (target) {
253
+ entry.targetVersion = target[1].trim();
254
+ continue;
255
+ }
256
+ const estimate = /^Estimate:\s*(.+)$/.exec(line.trim());
257
+ if (estimate) {
258
+ const n = Number(estimate[1].trim());
259
+ entry.estimate = n;
260
+ if (!Number.isInteger(n) || n <= 0) {
261
+ problems.push(`${block.heading}: Estimate "${estimate[1].trim()}" is not a positive whole number.`);
262
+ }
263
+ continue;
264
+ }
235
265
  const development = /^Development:\s*(.+)$/.exec(line.trim());
236
266
  if (development) {
237
267
  entry.development = statusFromDisplay(development[1].trim());
@@ -262,6 +292,16 @@ export function parseRoadmap(text) {
262
292
  entry.afterRefs = tokens.map(({ ref }) => ref);
263
293
  continue;
264
294
  }
295
+ const parent = /^Parent:\s*(\S+)\s*$/.exec(line.trim());
296
+ if (parent) {
297
+ // R336. One ref, always written in full (R300, i12). A token that is
298
+ // not a card is left as ordinary body text rather than eaten.
299
+ const token = parseRefToken(parent[1]);
300
+ if (token) {
301
+ entry.parentRef = token.ref;
302
+ continue;
303
+ }
304
+ }
265
305
  const tags = /^Tags:\s*(.+)$/.exec(line.trim());
266
306
  if (tags) {
267
307
  // R315. Already normalised by the server (lowercase, no `#`) by the
@@ -356,6 +396,45 @@ function* splitBlocks(text) {
356
396
  if (entry) yield entry;
357
397
  }
358
398
 
399
+ /** The ref of the card this one sits under, from either shape — R336. */
400
+ function parentRefOf(entry) {
401
+ const ref = entry.parent?.ref ?? entry.parentRef;
402
+ return ref ? (parseRefToken(ref)?.ref ?? null) : null;
403
+ }
404
+
405
+ /**
406
+ * Files each child directly after its parent, within one section — R336.
407
+ *
408
+ * Pure. A child keeps its place among its siblings (they stay in the order
409
+ * given), and a child whose parent is in some other section stays exactly
410
+ * where it was: the file groups by section first, and an epic spread over
411
+ * phases is still one epic by its `Parent:` lines.
412
+ */
413
+ export function nestUnderParents(list) {
414
+ const refs = new Map(list.map((entry) => [entryRef(entry), entry]));
415
+ const nestedUnder = (entry) => {
416
+ const parent = parentRefOf(entry);
417
+ const card = parent && refs.get(parent);
418
+ // A parent that is itself a child is bad data; leave the child where it is
419
+ // rather than lose it behind a card that is not a root.
420
+ return card && !parentRefOf(card) ? parent : null;
421
+ };
422
+ const children = new Map();
423
+ for (const entry of list) {
424
+ const parent = nestedUnder(entry);
425
+ if (parent) {
426
+ if (!children.has(parent)) children.set(parent, []);
427
+ children.get(parent).push(entry);
428
+ }
429
+ }
430
+ const out = [];
431
+ for (const entry of list) {
432
+ if (nestedUnder(entry)) continue;
433
+ out.push(entry, ...(children.get(entryRef(entry)) ?? []));
434
+ }
435
+ return out;
436
+ }
437
+
359
438
  /**
360
439
  * Writes entries as ROADMAP.md's entry sections.
361
440
  *
@@ -385,7 +464,7 @@ export function renderRoadmap(entries, { preamble, sectionOrder = [] }) {
385
464
  const parts = [preamble.trimEnd(), ''];
386
465
  for (const section of ordered) {
387
466
  parts.push('---', '', `## ${section}`, '');
388
- for (const entry of bySection.get(section)) {
467
+ for (const entry of nestUnderParents(bySection.get(section))) {
389
468
  parts.push(renderEntry(entry), '');
390
469
  }
391
470
  }
@@ -443,6 +522,19 @@ function renderEntry(entry) {
443
522
  lines.push(`Priority: ${entry.priority}`);
444
523
  }
445
524
 
525
+ // R351. Two facts, written only when the card carries them, after the
526
+ // priority: when, then how big.
527
+ if (entry.due) {
528
+ lines.push(`Due: ${entry.due}`);
529
+ }
530
+ if (entry.estimate != null && entry.estimate > 0) {
531
+ lines.push(`Estimate: ${entry.estimate}`);
532
+ }
533
+ // R358. Where it means to ship; dropped once it has, because `Version:` then says.
534
+ if (entry.targetVersion && !entry.version) {
535
+ lines.push(`Target: ${entry.targetVersion}`);
536
+ }
537
+
446
538
  // Each status writes only what it currently claims. A MERGED entry keeps its
447
539
  // branch in the database as history, but the file must not print it: the
448
540
  // branch is expected to be deleted, and `--live` would then be reading a claim
@@ -492,6 +584,13 @@ function renderEntry(entry) {
492
584
  .map(({ ref }) => ref);
493
585
  lines.push(`After: ${written.join(', ')}`);
494
586
  }
587
+ {
588
+ // R336. The API gives `parent` as {number, ref, title}; the parser gives
589
+ // `parentRef`. Always the full ref, and no line at all without a parent, so
590
+ // no existing card's text changes.
591
+ const parent = parentRefOf(entry);
592
+ if (parent) lines.push(`Parent: ${parent}`);
593
+ }
495
594
  if (entry.tags?.length) {
496
595
  // R315. Sorted, so the export does not churn on the set's own order.
497
596
  lines.push(`Tags: ${[...entry.tags].sort().join(', ')}`);
@@ -76,7 +76,7 @@ function canonical(value) {
76
76
  if (value === undefined) return 'undefined';
77
77
  return JSON.stringify(value, (_, inner) => (
78
78
  inner && typeof inner === 'object' && !Array.isArray(inner)
79
- ? Object.fromEntries(Object.keys(inner).sort().map((key) => [key, inner[key]]))
79
+ ? Object.fromEntries(Object.keys(inner).sort((a, b) => a.localeCompare(b)).map((key) => [key, inner[key]]))
80
80
  : inner
81
81
  ));
82
82
  }
package/mcp/README.md CHANGED
@@ -81,6 +81,14 @@ commit, or a sha — `SHIPPED` needs a version that is a real git tag, and
81
81
  request it would otherwise carry belongs to the run, and a project that reviews
82
82
  without opening pull requests has none to give it.
83
83
 
84
+ **A card you file may land in the `BACKLOG`** (R371). Where a new card goes
85
+ when you name no status is the project's rule, per door, and the default for a
86
+ card or issue an agent files is the backlog: waiting until a person or a sprint
87
+ brings it in, in no sprint and on no board column. Its origin names your
88
+ session and the card your run was on. Name a status (`CONSIDERING`, `NEW`) when
89
+ you mean the pipeline. Giving a backlog card a sprint moves it to the first
90
+ status of its kind; moving a card to `BACKLOG` takes it out of its sprint.
91
+
84
92
  **Any status may move to any other.** There is no transition diagram — an entry
85
93
  really can go from `CONSIDERING` straight to `SHIPPED` if that is what happened.
86
94
  The rules are about what a status *carries*, not the path it took.
@@ -163,11 +171,12 @@ what you are allowed to do.
163
171
  | `roadmap_list` | Entries, optionally by status. `brief` omits bodies — use it to survey. |
164
172
  | `roadmap_get` | One entry in full: its body, **the plan agreed for it**, and the discussion on it. |
165
173
  | `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). |
174
+ | `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
175
  | `roadmap_comment` | Say something *beside* an entry rather than inside it. |
176
+ | `done_when_tick` | Tick (or untick) one numbered item of the card's Done-when checklist as built, with one line of evidence (R368). `task_current` lists the items. Defaults to the run's own card and refuses another; the reviewer's column is a person's and no token can set it. |
168
177
  | `roadmap_set_status` | Move an entry, carrying whatever the status requires. |
169
178
  | `roadmap_decline` | Decline with a reason. The only exit. |
170
- | `backlog_file` | File **feedback**, not a card — something a person should decide about. A WRITER accepts it as a card or an issue, or refuses it (R199). |
179
+ | `backlog_file` | File **feedback**, not a card — something a person should decide about. A WRITER accepts it as a card or an issue, or refuses it (R199). Tagged `agent` as a source, plus any `tags` you pass — `customer` when you relay what a user said (R372). |
171
180
  | `changelog_list` | The changelog, grouped by release, newest first. |
172
181
  | `changelog_get` | One entry. |
173
182
  | `changelog_add` | Add an entry; no version means `Unreleased`. |
package/mcp/server.mjs CHANGED
@@ -412,6 +412,7 @@ const TOOLS = [
412
412
  status: {
413
413
  type: 'string',
414
414
  enum: [
415
+ 'BACKLOG',
415
416
  'CONSIDERING',
416
417
  'PLANNED',
417
418
  'NEW',
@@ -621,13 +622,70 @@ const TOOLS = [
621
622
  },
622
623
  },
623
624
 
625
+ {
626
+ name: 'done_when_tick',
627
+ description:
628
+ "Tick (or untick) one item of the card's Done-when checklist as built. Items are " +
629
+ "numbered from 1 as task_current lists them. Call it when you have BUILT the item and " +
630
+ 'CHECKED it, with one line of evidence — a test name, a command, a file:line. Do not ' +
631
+ 'tick what you did not check: the reviewer ticks their own column separately and sees ' +
632
+ 'yours. Defaults to the card this run is working on, and a run cannot tick another.',
633
+ inputSchema: {
634
+ type: 'object',
635
+ properties: {
636
+ ...PROJECT_ARGUMENT,
637
+ number: {
638
+ type: ['string', 'integer'],
639
+ description:
640
+ 'The card by ref — R91, i91. Optional inside a run: defaults to the run\'s own card.',
641
+ },
642
+ item: { type: 'integer', minimum: 1, description: "The item's number, from 1." },
643
+ done: { type: 'boolean', description: 'false takes the tick back. Default true.' },
644
+ note: {
645
+ type: 'string',
646
+ description: 'One line of evidence: a test name, a command, a file:line.',
647
+ },
648
+ },
649
+ required: ['item'],
650
+ },
651
+ handler: async (config, args) => {
652
+ let slug;
653
+ let ref = args.number;
654
+ if (ref === undefined || ref === null || ref === '') {
655
+ const { runId, project } = await requireRun(config);
656
+ const run = await api(config, `/api/projects/${project}/runs/${runId}`);
657
+ ref = run.entryRef ?? run.entryNumber;
658
+ slug = project;
659
+ } else {
660
+ slug = await resolveProject(config, args.project);
661
+ }
662
+ if (ref === undefined || ref === null || ref === '') {
663
+ throw new CawdevError('This run has no card, so there is no Done-when list to tick.');
664
+ }
665
+ if (!Number.isInteger(args.item) || args.item < 1) {
666
+ throw new CawdevError('item must be a whole number from 1, as task_current lists them.');
667
+ }
668
+ const done = args.done !== false;
669
+ const row = await api(config, `/api/projects/${slug}/roadmap/${encodeURIComponent(String(ref))}/done-when/${args.item}/built`, {
670
+ method: 'PUT',
671
+ body: { done, note: args.note },
672
+ });
673
+ return (
674
+ `${done ? 'Ticked' : 'Unticked'} item ${row.position} of ${ref} in ${slug} as built: ` +
675
+ row.text
676
+ );
677
+ },
678
+ },
679
+
624
680
  {
625
681
  name: 'roadmap_create',
626
682
  description:
627
683
  'Create a roadmap entry. The platform allocates its permanent number — written R91 on ' +
628
684
  'a roadmap card and i91 on an issue, one sequence across both — and the ' +
629
- 'platform decides where a card starts — CONSIDERING — unless you say otherwise; ' +
630
- 'a status that requires something must be given it.',
685
+ 'project decides where a card you file starts — the BACKLOG by default for a card an ' +
686
+ 'agent files, waiting until a person or a sprint brings it in — unless you say ' +
687
+ 'otherwise; a status that requires something must be given it. A card in the BACKLOG ' +
688
+ 'holds no sprint: giving it one moves it to CONSIDERING.',
631
689
  inputSchema: {
632
690
  type: 'object',
633
691
  properties: {
@@ -640,6 +698,7 @@ const TOOLS = [
640
698
  status: {
641
699
  type: 'string',
642
700
  enum: [
701
+ 'BACKLOG',
643
702
  'CONSIDERING',
644
703
  'PLANNED',
645
704
  'IN_DEVELOPMENT',
@@ -692,6 +751,26 @@ const TOOLS = [
692
751
  description:
693
752
  'How much it matters; it does not reorder the board. Leave out for MEDIUM.',
694
753
  },
754
+ due: {
755
+ type: 'string',
756
+ description: 'When it is due, written 2026-11-01. A fact on the card; it moves nothing. Leave out for none.',
757
+ },
758
+ estimate: {
759
+ type: 'integer',
760
+ minimum: 1,
761
+ description: "How big it is, in the team's own unit (points, days — the platform does not say). Leave out for none.",
762
+ },
763
+ targetVersion: {
764
+ type: 'string',
765
+ 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.',
766
+ },
767
+ parent: {
768
+ type: 'string',
769
+ description:
770
+ 'The card this one sits under (an epic), e.g. "R300". One level only: the parent ' +
771
+ 'must be the same kind and have no parent itself, and this card must have no ' +
772
+ 'children. An open card cannot be filed under a SHIPPED or DECLINED one.',
773
+ },
695
774
  },
696
775
  required: ['title'],
697
776
  },
@@ -701,7 +780,7 @@ const TOOLS = [
701
780
  method: 'POST',
702
781
  body: pick(args, [
703
782
  'title', 'body', 'status', 'branch', 'merge', 'version', 'reason', 'section', 'related',
704
- 'after', 'sprint', 'tags', 'assignee', 'priority',
783
+ 'after', 'sprint', 'tags', 'assignee', 'priority', 'due', 'estimate', 'targetVersion', 'parent',
705
784
  ]),
706
785
  });
707
786
  return `Created ${refOf(entry)} in ${slug}.\n\n${formatEntry(entry, {})}`;
@@ -713,7 +792,7 @@ const TOOLS = [
713
792
  description:
714
793
  'What is broken in a project, and how badly — R85. The same numbering as the roadmap, ' +
715
794
  'written with an i: card 91 filed as an issue is i91, and there is no R91 as well. ' +
716
- 'Statuses are NEW, CONFIRMED, IN_DEVELOPMENT, MERGED (drawn as ' +
795
+ 'Statuses are BACKLOG, NEW, CONFIRMED, IN_DEVELOPMENT, MERGED (drawn as ' +
717
796
  'Resolved) and DECLINED (Won\'t fix).',
718
797
  inputSchema: {
719
798
  type: 'object',
@@ -721,7 +800,7 @@ const TOOLS = [
721
800
  ...PROJECT_ARGUMENT,
722
801
  status: {
723
802
  type: 'string',
724
- enum: ['NEW', 'CONFIRMED', 'IN_DEVELOPMENT', 'MERGED', 'SHIPPED', 'DECLINED'],
803
+ enum: ['BACKLOG', 'NEW', 'CONFIRMED', 'IN_DEVELOPMENT', 'MERGED', 'SHIPPED', 'DECLINED'],
725
804
  },
726
805
  assignee: {
727
806
  type: 'string',
@@ -748,8 +827,10 @@ const TOOLS = [
748
827
  {
749
828
  name: 'issue_file',
750
829
  description:
751
- 'File an issue: something that is broken, with how badly. Lands at NEW — filed and not ' +
752
- 'yet triaged — unless you say CONFIRMED, which claims you have already checked it. The ' +
830
+ 'File an issue: something that is broken, with how badly. Lands where the project says ' +
831
+ 'for an issue an agent files — the BACKLOG by default, waiting to be brought in, or NEW ' +
832
+ '(filed and not yet triaged) — unless you say NEW, or CONFIRMED, which claims you have ' +
833
+ 'already checked it. The ' +
753
834
  'severity is required: it is how the board is ordered. Use `related` to name the card it ' +
754
835
  'was found on, by ref (R84). Issues count on their own sequence — R221 — and are ' +
755
836
  'written with an i: i91 is the ninety-first issue, and R91 is another card.',
@@ -760,7 +841,7 @@ const TOOLS = [
760
841
  title: { type: 'string' },
761
842
  body: { type: 'string', description: 'Markdown. What happens, and what should instead.' },
762
843
  severity: { type: 'string', enum: ['CRITICAL', 'MEDIUM', 'MINOR'] },
763
- status: { type: 'string', enum: ['NEW', 'CONFIRMED'] },
844
+ status: { type: 'string', enum: ['BACKLOG', 'NEW', 'CONFIRMED'] },
764
845
  related: {
765
846
  type: 'array',
766
847
  items: { type: ['string', 'integer'] },
@@ -813,14 +894,25 @@ const TOOLS = [
813
894
  kind: { type: 'string', enum: ['ISSUE', 'FEATURE'] },
814
895
  title: { type: 'string' },
815
896
  body: { type: 'string', description: 'Markdown. What you noticed, and what should happen instead.' },
897
+ tags: {
898
+ type: 'array',
899
+ items: { type: 'string' },
900
+ description:
901
+ 'Who it came from, beyond you — e.g. "customer" when you are passing on what a user ' +
902
+ 'said. "agent" is always added: you filed it. Same word rule as card tags.',
903
+ },
816
904
  },
817
905
  required: ['kind', 'title', 'body'],
818
906
  },
819
907
  handler: async (config, args) => {
820
908
  const slug = await resolveProject(config, args.project);
909
+ // R372: feedback says who it came from, and an agent filing it is one
910
+ // of the sources. Added here, at the agent's own door, not by the API:
911
+ // an application's token files through the same endpoint and is not an agent.
912
+ const tags = [...new Set([...(Array.isArray(args.tags) ? args.tags : []), 'agent'])];
821
913
  const item = await api(config, `/api/projects/${slug}/backlog`, {
822
914
  method: 'POST',
823
- body: pick(args, ['kind', 'title', 'body']),
915
+ body: { ...pick(args, ['kind', 'title', 'body']), tags },
824
916
  });
825
917
  return `Filed in ${slug}'s backlog as pending: "${item.title}". Somebody will accept it ` +
826
918
  'as a card or an issue, or refuse it.';
@@ -830,8 +922,8 @@ const TOOLS = [
830
922
  {
831
923
  name: 'roadmap_update',
832
924
  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.',
925
+ 'Edit an entry\'s title, body, section, sprint, tags, parent, related refs or the cards it ' +
926
+ 'starts after. Use roadmap_set_status to move it.',
835
927
  inputSchema: {
836
928
  type: 'object',
837
929
  properties: {
@@ -882,6 +974,25 @@ const TOOLS = [
882
974
  description:
883
975
  'How much it matters; it does not reorder the board. Leave out for MEDIUM (leave out to leave it alone).',
884
976
  },
977
+ due: {
978
+ type: 'string',
979
+ description: 'When it is due, written 2026-11-01. Leave out to leave it alone; "" clears it.',
980
+ },
981
+ estimate: {
982
+ type: 'integer',
983
+ minimum: 0,
984
+ description: "How big it is, in the team's own unit. Leave out to leave it alone; 0 clears it.",
985
+ },
986
+ targetVersion: {
987
+ type: 'string',
988
+ 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.',
989
+ },
990
+ parent: {
991
+ type: 'string',
992
+ description:
993
+ 'The card this one sits under (an epic), e.g. "R300". "" takes the card out of its ' +
994
+ 'parent; leave out to leave it alone. Same rules as roadmap_create.',
995
+ },
885
996
  },
886
997
  required: ['number'],
887
998
  },
@@ -890,7 +1001,10 @@ const TOOLS = [
890
1001
  const entry = await api(config, `/api/projects/${slug}/roadmap/${args.number}`, {
891
1002
  method: 'PATCH',
892
1003
  // 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']),
1004
+ body: pick(args, [
1005
+ 'title', 'body', 'section', 'related', 'after', 'sprint', 'tags', 'assignee', 'priority',
1006
+ 'due', 'estimate', 'targetVersion', 'parent',
1007
+ ]),
894
1008
  });
895
1009
  return `Updated ${refOf(entry)}.\n\n${formatEntry(entry, {})}`;
896
1010
  },
@@ -917,6 +1031,7 @@ const TOOLS = [
917
1031
  status: {
918
1032
  type: 'string',
919
1033
  enum: [
1034
+ 'BACKLOG',
920
1035
  'CONSIDERING',
921
1036
  'PLANNED',
922
1037
  'NEW',
@@ -1128,6 +1243,12 @@ const TOOLS = [
1128
1243
  config,
1129
1244
  `/api/projects/${project}/roadmap/${cardRef}/plans`,
1130
1245
  ).catch(() => []);
1246
+ // R368. The plan's Done-when list, numbered, with the ticks so far. Its
1247
+ // own try, like the plan above: a platform without it answers 404.
1248
+ const criteria = await api(
1249
+ config,
1250
+ `/api/projects/${project}/roadmap/${cardRef}/done-when`,
1251
+ ).catch(() => []);
1131
1252
 
1132
1253
  const lines = [];
1133
1254
 
@@ -1154,8 +1275,25 @@ const TOOLS = [
1154
1275
  `run: ${run.state}${run.runnerName ? ` on ${run.runnerName}` : ''}`,
1155
1276
  `started by ${run.startedByEmail}`,
1156
1277
  '',
1157
- formatEntry(entry, { comments, plan: plans?.[0] ?? null }),
1158
1278
  );
1279
+ // R336. The epic this card was cut from, so a session sees the context.
1280
+ // Its own try, like the history above: a parent that cannot be read (a
1281
+ // 404, no grant) costs the paragraph and never the call.
1282
+ if (entry.parent) {
1283
+ const epic = await api(config, `/api/projects/${project}/roadmap/${entry.parent.ref}`)
1284
+ .catch(() => null);
1285
+ if (epic) {
1286
+ lines.push(
1287
+ `This card is part of ${entry.parent.ref} — ${epic.title}:`,
1288
+ epicExcerpt(epic.body),
1289
+ '',
1290
+ );
1291
+ }
1292
+ }
1293
+ lines.push(formatEntry(entry, { comments, plan: plans?.[0] ?? null }));
1294
+ if (criteria?.length) {
1295
+ lines.push('', formatDoneWhen(criteria));
1296
+ }
1159
1297
 
1160
1298
  // What happened the other times. A session is told the entry and the
1161
1299
  // branch but not that two previous runs on this card failed, which is
@@ -2173,6 +2311,15 @@ function originWord(origin) {
2173
2311
  }
2174
2312
  }
2175
2313
 
2314
+ /** The first 600 characters of an epic's body, cut at a word — R336. */
2315
+ function epicExcerpt(body) {
2316
+ const text = (body ?? '').trim();
2317
+ if (text.length <= 600) return text;
2318
+ const cut = text.slice(0, 600);
2319
+ const space = cut.lastIndexOf(' ');
2320
+ return `${(space > 0 ? cut.slice(0, space) : cut).trimEnd()}…`;
2321
+ }
2322
+
2176
2323
  function formatEntry(entry, { brief, comments, plan }) {
2177
2324
  const lines = [`${refOf(entry)} — ${entry.title}`, ` status: ${entry.statusDisplay}`];
2178
2325
  if (entry.branch) lines.push(` branch: ${entry.branch}`);
@@ -2189,6 +2336,11 @@ function formatEntry(entry, { brief, comments, plan }) {
2189
2336
  if (entry.priority && entry.priority !== 'MEDIUM') {
2190
2337
  lines.push(` priority: ${entry.priority}`);
2191
2338
  }
2339
+ // R351. Both printed only when the card carries them, as the export does.
2340
+ if (entry.due) lines.push(` due: ${entry.due}`);
2341
+ if (entry.estimate) lines.push(` estimate: ${entry.estimate}`);
2342
+ // R358. Printed until the card ships; `version` says where it did.
2343
+ if (entry.targetVersion && !entry.version) lines.push(` target: ${entry.targetVersion}`);
2192
2344
  // R257. Always printed when the card has one, so a reader's shape does not
2193
2345
  // depend on which tool printed the card; a closed sprint says so.
2194
2346
  if (entry.sprint) {
@@ -2214,6 +2366,14 @@ function formatEntry(entry, { brief, comments, plan }) {
2214
2366
  .join(', ')}`,
2215
2367
  );
2216
2368
  }
2369
+ // R336. The epic this card sits under, and the cards under this one.
2370
+ if (entry.parent) lines.push(` parent: ${entry.parent.ref} — ${entry.parent.title}`);
2371
+ if (entry.children?.length) {
2372
+ const progress = entry.progress ? `${entry.progress.done} of ${entry.progress.total} done — ` : '';
2373
+ lines.push(
2374
+ ` children: ${progress}${entry.children.map((c) => `${c.ref} (${c.status})`).join(', ')}`,
2375
+ );
2376
+ }
2217
2377
  // Said in a survey, where the comments themselves are not fetched: an entry
2218
2378
  // with an argument attached should be visibly different from one without,
2219
2379
  // even in a list. Omitted at zero rather than written as "comments: 0".
@@ -2262,6 +2422,22 @@ function formatPlan(plan) {
2262
2422
  return [`--- the plan (${written}) ---`, plan.body].join('\n');
2263
2423
  }
2264
2424
 
2425
+ /**
2426
+ * R368. The Done-when list as an agent reads it: numbered as done_when_tick
2427
+ * takes them, with each tick and its evidence. A reviewer's confirmation is
2428
+ * shown too — it is the other column, and the agent should see it.
2429
+ */
2430
+ export function formatDoneWhen(criteria) {
2431
+ const lines = ['--- done when (tick each with done_when_tick once you have checked it) ---'];
2432
+ for (const item of criteria) {
2433
+ const mark = item.built ? '[built]' : '[ ]';
2434
+ const note = item.built && item.builtNote ? ` — ${item.builtNote}` : '';
2435
+ const confirmed = item.confirmed ? ' (confirmed by reviewer)' : '';
2436
+ lines.push(`${String(item.position).padStart(2)}. ${mark} ${item.text}${note}${confirmed}`);
2437
+ }
2438
+ return lines.join('\n');
2439
+ }
2440
+
2265
2441
  /**
2266
2442
  * One earlier attempt at this card — R38.
2267
2443
  *
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.10-beta",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {
package/runner/README.md CHANGED
@@ -510,6 +510,22 @@ then ↑.
510
510
  **`/view`** opens the focused session's page on cawdev — transcript, stages,
511
511
  card — in the browser, on the console this terminal signed in to.
512
512
 
513
+ **On a session waiting for you** — a question, or a permission such as a stage
514
+ gate whose summary is the whole plan — the prompt only has room for the start
515
+ of it, and says so: `/view reads it all · /open answers it on cawdev`.
516
+ **`/view`** then opens the READER: one window over the whole screen with the
517
+ session's output and, under a rule, the request in full (the question and its
518
+ options; the permission's summary, its input and its keys). It opens on the
519
+ request; `↑`/`↓` (and the wheel), `PgUp`/`PgDn`, `g`/`G` scroll it, `esc`
520
+ closes it, and any other key closes it and does what it always does — `a`
521
+ answers, `y`/`s`/`n` decide. Outside advanced mode `/view` prints the request
522
+ whole into the scrollback. When the question has options its list opens by
523
+ itself and hides the banner; the list's own key line says `/view reads it all`,
524
+ and `/` there leaves the list for the command line (`a` brings it back).
525
+ **`/open`** opens the session's run page on cawdev,
526
+ where the question panel and the decision are. `readerFrame` in split.mjs is
527
+ the window, pure.
528
+
513
529
  **`/view R29`** prints a card here instead: its status, section, tags, branch,
514
530
  work item and pull request, who is coding or reviewing it, what it is related
515
531
  to, its page on the web, and the first thirty lines of its body (`cardLines`,
@@ -607,6 +623,21 @@ the whole of removing it. A clone and not a `git worktree`, on purpose: the
607
623
  daemon's reset checks the default branch out, which git refuses in a second
608
624
  worktree. Changed without a restart, like `agentCommands`.
609
625
 
626
+ ### Where a session with no branch reads: `<first>-read`
627
+
628
+ A plan, a verify or an ask cuts no branch and takes no checkout, so it used to
629
+ run in the project's first checkout **as that checkout happened to be** — on
630
+ whatever branch the last run left there. A cancelled CTO Interview left
631
+ `cto-interview` checked out, hundreds of commits behind, and every plan after
632
+ it read that code. Such a session now reads `<first>-read`, beside the first
633
+ checkout: a **detached `git worktree`** of it, brought to `origin/<default
634
+ branch>` at the start of a session whenever no other session is reading there.
635
+ The checkout itself is never moved, because a coding run may be working in it.
636
+ One per project rather than one per run, because the CLI keeps a resumable
637
+ conversation by its folder. Detached, so the default branch is never checked
638
+ out twice. If it cannot be made — no remote, not a repository — the session
639
+ reads the first checkout as before, and the log says why.
640
+
610
641
  ### `/reload`
611
642
 
612
643
  Quitting and starting again, in one word — for when the client's code changed