cawdev-cli 1.0.10-beta → 1.1.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/runner/split.mjs CHANGED
@@ -661,14 +661,31 @@ export function wordWrap(text, width) {
661
661
  * terminal acts on. MERGED, SHIPPED and DECLINED are not columns: the work
662
662
  * is over, the three only grow, and the console's own board folds the last
663
663
  * two to a count (R68) for the same reason — so here all three are counts
664
- * beside the project's name.
664
+ * beside the project's name. BACKLOG is a count too, at the head of them: it
665
+ * is work not yet taken up rather than work over, but a column of it would be
666
+ * the first to give way, and since R371 it is where a new issue lands — left
667
+ * out, a project's fresh bugs were on no line of the strip at all.
665
668
  */
666
669
  export const BOARD_COLUMNS = [
667
670
  ['CONSIDERING', 'Considering'],
668
671
  ['PLANNED', 'Planned'],
669
672
  ['IN_DEVELOPMENT', 'In development'],
670
673
  ];
671
- const FOLDED = [['MERGED', 'merged'], ['SHIPPED', 'shipped'], ['DECLINED', 'declined']];
674
+ const FOLDED = [['BACKLOG', 'backlog'], ['MERGED', 'merged'], ['SHIPPED', 'shipped'], ['DECLINED', 'declined']];
675
+
676
+ /**
677
+ * The column a card is drawn in. An issue (R85) has statuses of its own for
678
+ * the first two steps — NEW is reported and not yet agreed, CONFIRMED is
679
+ * agreed and waiting for somebody, the platform's `afterPlanning` — and they
680
+ * are Considering's and Planned's counterparts, so they share those columns
681
+ * rather than adding two more to a strip that already gives columns away.
682
+ */
683
+ export function columnOf(status) {
684
+ return { NEW: 'CONSIDERING', CONFIRMED: 'PLANNED' }[status] ?? status;
685
+ }
686
+
687
+ /** Most broken first: the issues board's own order (R85), and what a column cut short keeps. */
688
+ const SEVERITY_ORDER = ['CRITICAL', 'MEDIUM', 'MINOR'];
672
689
 
673
690
  /**
674
691
  * Which column gives way first when the width runs out: the ideas, then what
@@ -701,7 +718,12 @@ const STRIP_HEAD = 2;
701
718
  * column needs, no more — of exactly `width` columns. One line when there is
702
719
  * no project at all.
703
720
  *
704
- * @param cards `[{ ref, title, slug, status, live? }]`
721
+ * ISSUES SHARE THE COLUMNS (`columnOf`) and go first in each, most severe
722
+ * first: a column cut to `+N more` should not hide a critical defect under
723
+ * ideas. Their ref (`i12`) is in the colour of their severity.
724
+ *
725
+ * @param cards `[{ ref, title, slug, status, live?, severity? }]` — a card
726
+ * with a `severity` is an issue
705
727
  * @param projects the slugs to show, in order; defaults to those the cards
706
728
  * come from
707
729
  */
@@ -716,7 +738,7 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
716
738
  slug,
717
739
  folded: FOLDED.map(([status, word]) => [word, mine.filter((c) => c.status === status).length]),
718
740
  columns: BOARD_COLUMNS.map(([status, label]) => ({
719
- status, label, cards: mine.filter((card) => card.status === status),
741
+ status, label, cards: inColumn(mine, status),
720
742
  })),
721
743
  hidden: [],
722
744
  };
@@ -796,7 +818,7 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
796
818
  } else if (column.cards[row]) {
797
819
  const card = column.cards[row];
798
820
  const mark = card.live ? ink.accent('●') : ' ';
799
- cell = `${mark}${ink.bold(card.ref)} ${card.title ?? ''}`;
821
+ cell = `${mark}${refInk(card, ink)(card.ref)} ${card.title ?? ''}`;
800
822
  } else if (row === 0 && !column.cards.length) {
801
823
  cell = ink.muted(' —');
802
824
  }
@@ -806,6 +828,24 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
806
828
  return lines.map((line) => padVisible(clip(line, width), width));
807
829
  }
808
830
 
831
+ /** A column's cards: its issues first, most severe first, then its roadmap cards, each in the order given. */
832
+ function inColumn(cards, status) {
833
+ const here = cards.filter((card) => columnOf(card.status) === status);
834
+ const rank = (card) => {
835
+ const at = SEVERITY_ORDER.indexOf(card.severity);
836
+ return at === -1 ? SEVERITY_ORDER.length : at;
837
+ };
838
+ const issues = here.filter((card) => card.severity).sort((a, b) => rank(a) - rank(b));
839
+ return [...issues, ...here.filter((card) => !card.severity)];
840
+ }
841
+
842
+ /** A card's ref in bold; an issue's in the colour of how broken it is. */
843
+ function refInk(card, ink) {
844
+ if (!card.severity) return ink.bold;
845
+ const hue = card.severity === 'CRITICAL' ? ink.danger : card.severity === 'MEDIUM' ? ink.warn : ink.muted;
846
+ return (text) => ink.bold(hue(text));
847
+ }
848
+
809
849
  /** Clipped to `width`, with `…` where it had to stop — a title cut dead reads as the whole title. */
810
850
  function ellipsed(text, width) {
811
851
  return visibleWidth(text) <= width ? text : `${clip(text, width - 1)}…`;
@@ -923,7 +963,7 @@ export function cardLines(card, ink, url = null) {
923
963
  field('status', [card.statusDisplay ?? card.status, kind].filter(Boolean).join(' · '));
924
964
  field('section', card.section);
925
965
  field('tags', card.tags?.length ? card.tags.join(', ') : null);
926
- field('sprint', card.sprint ? `${card.sprint.ref ?? card.sprint.number} ${card.sprint.name ?? ''}`.trim() : null);
966
+ field('sprint', card.sprint ? `${card.sprint.label ?? card.sprint.ref ?? card.sprint.number} ${card.sprint.name ?? ''}`.trim() : null);
927
967
  const item = card.workItem;
928
968
  field('branch', card.branch ?? item?.branch);
929
969
  field('work', item ? [item.statusDisplay ?? item.status, item.runnerName && `on ${item.runnerName}`]
@@ -0,0 +1,78 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { access, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * Whether a workspace folder can be used at all, and how to say so — R377.
7
+ *
8
+ * Node reports a missing `cwd` as `spawn claude ENOENT`, which reads as "the
9
+ * agent CLI is not installed" and sends a person looking at their PATH. The
10
+ * folder is checked before it gets that far, and said in its own words.
11
+ *
12
+ * `.git` is checked with `access`, never `isDirectory()`: in a worktree —
13
+ * including the `-read` copy — it is a file.
14
+ */
15
+ export async function workspaceProblem(path) {
16
+ try {
17
+ const found = await stat(path);
18
+ if (!found.isDirectory()) return { kind: 'not-a-directory', path };
19
+ } catch (failure) {
20
+ // ENOTDIR: a parent of it is a file, so it is no more there than if absent.
21
+ if (failure.code === 'ENOENT' || failure.code === 'ENOTDIR') return { kind: 'missing', path };
22
+ return { kind: 'unreadable', path, code: failure.code };
23
+ }
24
+ try {
25
+ await access(join(path, '.git'));
26
+ } catch {
27
+ return { kind: 'not-git', path };
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * The sentence for a problem. `first` is the project's `workspaces[0]`.
34
+ * The wording must not change from one poll to the next: `noteQueued` only
35
+ * logs again when the text does.
36
+ */
37
+ export function workspaceProblemSaid(slug, problem, { first = null } = {}) {
38
+ const { kind, path } = problem;
39
+ if (kind === 'missing') {
40
+ const base = `${slug}'s workspace ${path} does not exist on this machine. `
41
+ + 'Clone the repository there, or correct "workspaces" with `cawdev config`.';
42
+ // `createAutoWorkspace` clones FROM the first workspace, so the advice
43
+ // cannot help when that is the one that is missing.
44
+ return path !== first
45
+ ? `${base} Or remove it and set "autoWorkspaces" so the runner clones checkouts beside ${first} itself.`
46
+ : base;
47
+ }
48
+ if (kind === 'not-a-directory') {
49
+ return `${slug}'s workspace ${path} is a file, not a folder. `
50
+ + 'Correct "workspaces" with `cawdev config`.';
51
+ }
52
+ if (kind === 'unreadable') {
53
+ return `${slug}'s workspace ${path} cannot be read (${problem.code ?? 'unknown error'}). `
54
+ + 'Fix its permissions, or correct "workspaces" with `cawdev config`.';
55
+ }
56
+ return `${slug}'s workspace ${path} exists but is not a git checkout (no .git in it). `
57
+ + 'Clone the repository there, or correct "workspaces" with `cawdev config`.';
58
+ }
59
+
60
+ function problemSync(cwd) {
61
+ if (!cwd || !existsSync(cwd)) return { kind: 'missing', path: cwd };
62
+ if (!statSync(cwd).isDirectory()) return { kind: 'not-a-directory', path: cwd };
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * What to tell a person when `spawn` fails. Runs inside an `'error'` handler,
68
+ * hence the synchronous checks. `label` is "the agent", "Antigravity" or "Codex".
69
+ */
70
+ export function spawnFailureSaid({ failure, command, cwd, slug, label, first = null }) {
71
+ if (failure?.code === 'ENOENT') {
72
+ const problem = problemSync(cwd);
73
+ if (problem) return workspaceProblemSaid(slug, problem, { first });
74
+ return `\`${command}\` not found on this runner's PATH — set "agentCommands" to its full path `
75
+ + `(run \`which ${command}\` where it works).`;
76
+ }
77
+ return `Could not spawn ${label}: ${failure?.message}`;
78
+ }