cawdev-cli 1.0.9-beta → 1.0.11-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/lib/background-shells.mjs +45 -3
- package/lib/roadmap-format.mjs +15 -1
- package/lib/runner-config.mjs +1 -1
- package/mcp/README.md +12 -2
- package/mcp/server.mjs +124 -10
- package/package.json +1 -1
- package/runner/README.md +38 -2
- package/runner/attach.mjs +211 -25
- package/runner/git-host.mjs +263 -0
- package/runner/runner.mjs +521 -126
- package/runner/split.mjs +77 -5
- package/runner/workspace-check.mjs +78 -0
package/runner/split.mjs
CHANGED
|
@@ -168,6 +168,38 @@ export function tailOf(lines, width, rows) {
|
|
|
168
168
|
return out.reverse();
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The reader — `/view` on a session waiting for an answer or a permission.
|
|
173
|
+
*
|
|
174
|
+
* A window's body is the TAIL of what was said and a question's banner fits
|
|
175
|
+
* the prompt's rows, so a long plan or a long question was cut and could not
|
|
176
|
+
* be scrolled. The reader is one window over the whole screen, showing
|
|
177
|
+
* `lines` from row `top` (wrapped to its width), with where it is in the
|
|
178
|
+
* title. `top` null means "start where the request starts" (`requestAt`);
|
|
179
|
+
* whatever is asked for is clamped, and the answer says what was used so the
|
|
180
|
+
* keys can page from it.
|
|
181
|
+
*/
|
|
182
|
+
export function readerFrame(width, height, { title = '', lines = [], requestAt = 0, top = null, footer = '' }, ink) {
|
|
183
|
+
const inner = Math.max(1, width - 2);
|
|
184
|
+
const rows = [];
|
|
185
|
+
let requestRow = 0;
|
|
186
|
+
lines.forEach((line, i) => {
|
|
187
|
+
if (i === requestAt) requestRow = rows.length;
|
|
188
|
+
rows.push(...wrap(String(line ?? ''), inner));
|
|
189
|
+
});
|
|
190
|
+
const room = Math.max(1, height - 2 - (footer ? 1 : 0));
|
|
191
|
+
const max = Math.max(0, rows.length - room);
|
|
192
|
+
const at = Math.min(Math.max(0, top ?? requestRow), max);
|
|
193
|
+
const shown = rows.slice(at, at + room);
|
|
194
|
+
const where = rows.length > room ? ` · ${at + 1}–${at + shown.length} of ${rows.length}` : '';
|
|
195
|
+
return {
|
|
196
|
+
lines: windowLines({ width, height }, { title: `${title}${where}`, body: shown, footer, focused: true }, ink),
|
|
197
|
+
top: at,
|
|
198
|
+
max,
|
|
199
|
+
room,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
171
203
|
/**
|
|
172
204
|
* The whole screen, from placed pieces: `height` rows, each the pieces that
|
|
173
205
|
* cross it laid left to right, and blank where nothing is.
|
|
@@ -629,14 +661,31 @@ export function wordWrap(text, width) {
|
|
|
629
661
|
* terminal acts on. MERGED, SHIPPED and DECLINED are not columns: the work
|
|
630
662
|
* is over, the three only grow, and the console's own board folds the last
|
|
631
663
|
* two to a count (R68) for the same reason — so here all three are counts
|
|
632
|
-
* 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.
|
|
633
668
|
*/
|
|
634
669
|
export const BOARD_COLUMNS = [
|
|
635
670
|
['CONSIDERING', 'Considering'],
|
|
636
671
|
['PLANNED', 'Planned'],
|
|
637
672
|
['IN_DEVELOPMENT', 'In development'],
|
|
638
673
|
];
|
|
639
|
-
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'];
|
|
640
689
|
|
|
641
690
|
/**
|
|
642
691
|
* Which column gives way first when the width runs out: the ideas, then what
|
|
@@ -669,7 +718,12 @@ const STRIP_HEAD = 2;
|
|
|
669
718
|
* column needs, no more — of exactly `width` columns. One line when there is
|
|
670
719
|
* no project at all.
|
|
671
720
|
*
|
|
672
|
-
*
|
|
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
|
|
673
727
|
* @param projects the slugs to show, in order; defaults to those the cards
|
|
674
728
|
* come from
|
|
675
729
|
*/
|
|
@@ -684,7 +738,7 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
|
|
|
684
738
|
slug,
|
|
685
739
|
folded: FOLDED.map(([status, word]) => [word, mine.filter((c) => c.status === status).length]),
|
|
686
740
|
columns: BOARD_COLUMNS.map(([status, label]) => ({
|
|
687
|
-
status, label, cards: mine
|
|
741
|
+
status, label, cards: inColumn(mine, status),
|
|
688
742
|
})),
|
|
689
743
|
hidden: [],
|
|
690
744
|
};
|
|
@@ -764,7 +818,7 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
|
|
|
764
818
|
} else if (column.cards[row]) {
|
|
765
819
|
const card = column.cards[row];
|
|
766
820
|
const mark = card.live ? ink.accent('●') : ' ';
|
|
767
|
-
cell = `${mark}${ink
|
|
821
|
+
cell = `${mark}${refInk(card, ink)(card.ref)} ${card.title ?? ''}`;
|
|
768
822
|
} else if (row === 0 && !column.cards.length) {
|
|
769
823
|
cell = ink.muted(' —');
|
|
770
824
|
}
|
|
@@ -774,6 +828,24 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
|
|
|
774
828
|
return lines.map((line) => padVisible(clip(line, width), width));
|
|
775
829
|
}
|
|
776
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
|
+
|
|
777
849
|
/** Clipped to `width`, with `…` where it had to stop — a title cut dead reads as the whole title. */
|
|
778
850
|
function ellipsed(text, width) {
|
|
779
851
|
return visibleWidth(text) <= width ? text : `${clip(text, width - 1)}…`;
|
|
@@ -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
|
+
}
|