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 +6 -1
- package/lib/roadmap-format.mjs +101 -2
- package/lib/runner-config.mjs +1 -1
- package/mcp/README.md +11 -2
- package/mcp/server.mjs +189 -13
- package/package.json +1 -1
- package/runner/README.md +31 -0
- package/runner/attach.mjs +192 -17
- package/runner/runner.mjs +214 -10
- package/runner/split.mjs +32 -0
package/runner/attach.mjs
CHANGED
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
|
|
61
61
|
import { restartNeededFor } from '../lib/runner-config.mjs';
|
|
62
62
|
import {
|
|
63
|
-
BOARD_COLUMNS, MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, tabsLine, visibleSessions, actionsFor, composeFrame,
|
|
63
|
+
BOARD_COLUMNS, MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, readerFrame, tabsLine, visibleSessions, actionsFor, composeFrame,
|
|
64
64
|
carriesOnHere, builtNothing, cardLines, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, splitLayout, titleFor, toneFor,
|
|
65
65
|
windowLines,
|
|
66
66
|
} from './split.mjs';
|
|
@@ -298,8 +298,14 @@ export function permissionBanner(pending, width, ink = painter(3), machine = {})
|
|
|
298
298
|
.find((lines) => lines.length <= 2)
|
|
299
299
|
?? rows(without(short));
|
|
300
300
|
|
|
301
|
+
// A stage gate's summary is the whole PLAN, many lines long: one row here,
|
|
302
|
+
// and how much more there is — `/view` reads it whole. Clipped as one
|
|
303
|
+
// string, its newlines went through and filled the prompt.
|
|
304
|
+
const [first = '', ...more] = String(approval.summary ?? '').split('\n');
|
|
305
|
+
const rest = more.filter((line) => line.trim()).length;
|
|
306
|
+
const tail = rest ? ` (+${rest} lines)` : '';
|
|
301
307
|
return [
|
|
302
|
-
`${ink.bold(ink.warn(' permission '))} ${clip(
|
|
308
|
+
`${ink.bold(ink.warn(' permission '))} ${clip(first, Math.max(8, width - 13 - tail.length))}${ink.muted(tail)}`,
|
|
303
309
|
ink.muted(` ${approval.toolName} · waiting since ${approval.askedAt?.slice(11, 19) ?? ''}`),
|
|
304
310
|
...keys,
|
|
305
311
|
];
|
|
@@ -832,8 +838,8 @@ const COMMANDS = [
|
|
|
832
838
|
['/continue', 'pick a failed or stopped session back up on this machine — the console\'s Carry on'],
|
|
833
839
|
['/projects', 'which projects advanced mode shows — tick one or several'],
|
|
834
840
|
['/refresh', "read the cards and their statuses from cawdev again — advanced mode's roadmap"],
|
|
835
|
-
['/open', "open the session's pull request in your browser —
|
|
836
|
-
['/view', "a card here in the terminal: /view R29, /view <words of its title> (@project), /view card for the session's; /view alone
|
|
841
|
+
['/open', "open the session's pull request in your browser — on a finished plan, its card and plan; on one waiting for you, its page on cawdev"],
|
|
842
|
+
['/view', "a card here in the terminal: /view R29, /view <words of its title> (@project), /view card for the session's; /view alone reads what a session waits on in full, or opens it on cawdev"],
|
|
837
843
|
['/drop', 'delete the checkout a finished session was made on demand for'],
|
|
838
844
|
['/close', "close an ended session's window"],
|
|
839
845
|
['/reload', 'quit and start again on the code on disk — the runner too, when nothing is running'],
|
|
@@ -1625,6 +1631,9 @@ export class Attached {
|
|
|
1625
1631
|
}
|
|
1626
1632
|
this.interrupting = false;
|
|
1627
1633
|
|
|
1634
|
+
if (this.reader && this.mode === 'keys') {
|
|
1635
|
+
return this.onReaderKey(key);
|
|
1636
|
+
}
|
|
1628
1637
|
if (this.mode === 'typing') {
|
|
1629
1638
|
return this.onTyping(key);
|
|
1630
1639
|
}
|
|
@@ -1755,7 +1764,8 @@ export class Attached {
|
|
|
1755
1764
|
|
|
1756
1765
|
/** Everything open, closed. Returns whether there was anything. */
|
|
1757
1766
|
closeEverything() {
|
|
1758
|
-
const open = this.mode !== 'keys';
|
|
1767
|
+
const open = this.mode !== 'keys' || Boolean(this.reader);
|
|
1768
|
+
this.reader = null;
|
|
1759
1769
|
this.mode = 'keys';
|
|
1760
1770
|
this.select = null;
|
|
1761
1771
|
this.input = null;
|
|
@@ -2082,6 +2092,16 @@ export class Attached {
|
|
|
2082
2092
|
this.select = null;
|
|
2083
2093
|
return this.note('');
|
|
2084
2094
|
}
|
|
2095
|
+
// A question's or a permission's list holds only the start of what is
|
|
2096
|
+
// asked; `/` leaves it for a command — `/view` reads it whole, `/open`
|
|
2097
|
+
// answers it on cawdev — and `a` (or `y`/`n`) brings the choice back.
|
|
2098
|
+
if (key === '/' && (select.kind === 'question' || select.kind === 'permission')) {
|
|
2099
|
+
this.mode = 'keys';
|
|
2100
|
+
this.select = null;
|
|
2101
|
+
this.answering = null;
|
|
2102
|
+
this.deciding = null;
|
|
2103
|
+
return this.type('command', 'cawdev ▸', '/');
|
|
2104
|
+
}
|
|
2085
2105
|
|
|
2086
2106
|
const outcome = select.key(key);
|
|
2087
2107
|
if (!outcome) {
|
|
@@ -2287,8 +2307,9 @@ export class Attached {
|
|
|
2287
2307
|
}
|
|
2288
2308
|
return this.openPicker(new Select({
|
|
2289
2309
|
kind: 'question',
|
|
2290
|
-
title: clip(`${this.tagOf(this.current())} asks: ${asking.question.question}`,
|
|
2310
|
+
title: clip(`${this.tagOf(this.current())} asks: ${String(asking.question.question).split('\n')[0]}`,
|
|
2291
2311
|
Math.max(20, this.screen.width - 2)),
|
|
2312
|
+
hint: '/view reads it all',
|
|
2292
2313
|
rows: [
|
|
2293
2314
|
...options.map((option) => ({ id: option, label: option })),
|
|
2294
2315
|
// Always last, and always there.
|
|
@@ -2316,7 +2337,10 @@ export class Attached {
|
|
|
2316
2337
|
kind: 'permission',
|
|
2317
2338
|
// The call, on the title, as well as printed in full above: the picker
|
|
2318
2339
|
// may still be on screen when the transcript under it has moved on.
|
|
2319
|
-
|
|
2340
|
+
// The first line only: a stage gate's summary is the whole plan, and
|
|
2341
|
+
// `/view` is where it is read.
|
|
2342
|
+
title: `${this.tagOf(this.current())} wants ${approval.toolName} · ${String(approval.summary ?? '').split('\n')[0]}`,
|
|
2343
|
+
hint: '/view reads it all',
|
|
2320
2344
|
rows: [
|
|
2321
2345
|
{ id: 'y', label: 'Allow once', hint: 'this call and no more' },
|
|
2322
2346
|
// R78: the middle grant NAMES what it covers, and it says so in the
|
|
@@ -2831,7 +2855,8 @@ export class Attached {
|
|
|
2831
2855
|
case 'open':
|
|
2832
2856
|
return void this.openInBrowser();
|
|
2833
2857
|
case 'view':
|
|
2834
|
-
|
|
2858
|
+
if (rest.length) return void this.viewCard(rest);
|
|
2859
|
+
return this.waitingOn(this.commandTarget()) ? this.openReader() : this.viewOnCawdev();
|
|
2835
2860
|
case 'drop':
|
|
2836
2861
|
return void this.dropCheckout();
|
|
2837
2862
|
case 'close':
|
|
@@ -3250,6 +3275,9 @@ export class Attached {
|
|
|
3250
3275
|
splitFrame() {
|
|
3251
3276
|
const { width, height } = this.split.screen;
|
|
3252
3277
|
const ink = this.ink;
|
|
3278
|
+
if (this.reader) {
|
|
3279
|
+
return composeFrame(width, height, [{ rect: { top: 0, left: 0, width, height }, lines: this.readerScreen(width, height) }]);
|
|
3280
|
+
}
|
|
3253
3281
|
const windows = this.windows();
|
|
3254
3282
|
const inner = Math.max(8, width - 2);
|
|
3255
3283
|
|
|
@@ -3269,6 +3297,11 @@ export class Attached {
|
|
|
3269
3297
|
+ ' · / commands · tab next window');
|
|
3270
3298
|
// A question or a request on screen says which window it came from.
|
|
3271
3299
|
const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
|
|
3300
|
+
// Under the banner and right above the line, so a long one never pushes
|
|
3301
|
+
// it out of the window: the way to read all of it, and the other door.
|
|
3302
|
+
// Also while its list is open, which hides the banner.
|
|
3303
|
+
const whole = banner.length || this.waitingOn(this.commandTarget())
|
|
3304
|
+
? [ink.muted(' /view reads it all · /open answers it on cawdev')] : [];
|
|
3272
3305
|
// The commands matching what is typed, above the line as in the footer —
|
|
3273
3306
|
// advanced mode drew none, so enter took a highlighted command nobody saw.
|
|
3274
3307
|
const matches = this.mode === 'typing' && this.input?.showing !== false ? this.matching() : { rows: [], at: 0 };
|
|
@@ -3278,7 +3311,7 @@ export class Attached {
|
|
|
3278
3311
|
const completing = completionLines(
|
|
3279
3312
|
matches.rows.slice(first, first + SPLIT_COMPLETIONS), matches.at - first, inner, ink,
|
|
3280
3313
|
);
|
|
3281
|
-
const fixed = [...from, ...banner, ...overlay, ...completing, input];
|
|
3314
|
+
const fixed = [...from, ...banner, ...whole, ...overlay, ...completing, input];
|
|
3282
3315
|
const promptRows = Math.max(PROMPT_ROWS, fixed.length + 5);
|
|
3283
3316
|
// The roadmap across the top: as tall as its fullest column needs, never
|
|
3284
3317
|
// more than a third of the screen, and never so tall that no window fits
|
|
@@ -3499,17 +3532,20 @@ export class Attached {
|
|
|
3499
3532
|
if (shown.length === 1 && !words.some((word) => word.startsWith('@'))) {
|
|
3500
3533
|
words = [...words, `@${shown[0]}`];
|
|
3501
3534
|
}
|
|
3502
|
-
const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway');
|
|
3535
|
+
const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway' && word !== '--again');
|
|
3503
3536
|
if (bare.length === 1 && ['dev', 'code'].includes(bare[0].toLowerCase())) {
|
|
3504
3537
|
const named = words.find((word) => word.startsWith('@'));
|
|
3505
3538
|
await this.pickPlanned(named ? named.slice(1) : null);
|
|
3506
3539
|
return;
|
|
3507
3540
|
}
|
|
3508
3541
|
const anyway = words.includes('--anyway');
|
|
3542
|
+
// R369: the answer to "a session already finished here" — `--anyway`'s
|
|
3543
|
+
// shape, for a different question.
|
|
3544
|
+
const again = words.includes('--again');
|
|
3509
3545
|
// `--codex`, `--claude`, `--agy`: this session's agent, over `/agent`'s.
|
|
3510
3546
|
const flagged = words.find((word) => AGENT_FLAGS.has(word));
|
|
3511
3547
|
let agent = flagged ? flagged.slice(2) : this.agent;
|
|
3512
|
-
const parsed = parseNew(words.filter((word) => word !== '--anyway' && !AGENT_FLAGS.has(word)), {
|
|
3548
|
+
const parsed = parseNew(words.filter((word) => word !== '--anyway' && word !== '--again' && !AGENT_FLAGS.has(word)), {
|
|
3513
3549
|
// In advanced mode, among the projects it shows.
|
|
3514
3550
|
projects: this.shownProjects(),
|
|
3515
3551
|
});
|
|
@@ -3557,7 +3593,7 @@ export class Attached {
|
|
|
3557
3593
|
// R150: the session writes the card first, then plans it.
|
|
3558
3594
|
: ['runs/plan', { prompt: parsed.text }, 'a plan — it writes its card first']
|
|
3559
3595
|
: ['runs', { entryNumber: parsed.ref }, `the development of ${parsed.ref}`];
|
|
3560
|
-
const started = await this.startSession(parsed.slug, path, withAgent(body, agent), withWho(what, agent), anyway);
|
|
3596
|
+
const started = await this.startSession(parsed.slug, path, withAgent(body, agent), withWho(what, agent), anyway, again);
|
|
3561
3597
|
if (started && plan) {
|
|
3562
3598
|
this.closeWindowOf(plan);
|
|
3563
3599
|
this.focusOnArrival = plan.projectSlug;
|
|
@@ -3819,9 +3855,10 @@ export class Attached {
|
|
|
3819
3855
|
* One POST that starts a session, aimed at THIS machine so its window opens
|
|
3820
3856
|
* here. A usage window over the machine's threshold is refused by the
|
|
3821
3857
|
* platform in a sentence; `--anyway` is the same decision the console's
|
|
3822
|
-
* panel asks for.
|
|
3858
|
+
* panel asks for. A card whose last session of that kind finished asks too
|
|
3859
|
+
* (R369), and `--again` answers it.
|
|
3823
3860
|
*/
|
|
3824
|
-
async startSession(slug, path, body, what, anyway) {
|
|
3861
|
+
async startSession(slug, path, body, what, anyway, again = false) {
|
|
3825
3862
|
try {
|
|
3826
3863
|
await this.session.request(`/api/projects/${slug}/${path}`, {
|
|
3827
3864
|
method: 'POST',
|
|
@@ -3829,15 +3866,20 @@ export class Attached {
|
|
|
3829
3866
|
...body,
|
|
3830
3867
|
...(this.runner?.id ? { targetRunnerId: this.runner.id } : {}),
|
|
3831
3868
|
...(anyway ? { acknowledgeUsage: true } : {}),
|
|
3869
|
+
...(again ? { again: true } : {}),
|
|
3832
3870
|
},
|
|
3833
3871
|
});
|
|
3834
3872
|
this.say(this.ink.success(` started ${what} on ${slug} — its window opens when this machine takes it`));
|
|
3835
3873
|
this.note('');
|
|
3836
3874
|
return true;
|
|
3837
3875
|
} catch (failure) {
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3876
|
+
// R369's sentence is checked first: it is the one that names a session,
|
|
3877
|
+
// and the usage test is loose enough to match words in other sentences.
|
|
3878
|
+
this.note(/session that finished/.test(failure.message)
|
|
3879
|
+
? `${failure.message} — add --again to start it regardless`
|
|
3880
|
+
: /usage|window/i.test(failure.message)
|
|
3881
|
+
? `${failure.message} — add --anyway to start it regardless`
|
|
3882
|
+
: failure.message);
|
|
3841
3883
|
return false;
|
|
3842
3884
|
}
|
|
3843
3885
|
}
|
|
@@ -3948,6 +3990,16 @@ export class Attached {
|
|
|
3948
3990
|
if (!meta) {
|
|
3949
3991
|
return this.note('/open needs a session — focus one');
|
|
3950
3992
|
}
|
|
3993
|
+
if (this.waitingOn(meta)) {
|
|
3994
|
+
// Stopped on a question or a permission: the run page on cawdev, where
|
|
3995
|
+
// the question panel and the decision are, whole.
|
|
3996
|
+
const url = this.consoleUrl(meta.projectSlug ?? meta.view?.projectSlug, `runs/${meta.id}`);
|
|
3997
|
+
if (!url) {
|
|
3998
|
+
return this.note('cannot tell where this session is on cawdev');
|
|
3999
|
+
}
|
|
4000
|
+
this.openLink(url);
|
|
4001
|
+
return this.note(`opened ${url}`);
|
|
4002
|
+
}
|
|
3951
4003
|
if (this.finishedPlan(meta)) {
|
|
3952
4004
|
const ref = meta.view.entryRef;
|
|
3953
4005
|
const board = /^i/i.test(ref) ? 'issues' : 'roadmap';
|
|
@@ -3989,6 +4041,129 @@ export class Attached {
|
|
|
3989
4041
|
opener(url);
|
|
3990
4042
|
}
|
|
3991
4043
|
|
|
4044
|
+
/** What a session is stopped on — a question or a permission request — or null. */
|
|
4045
|
+
waitingOn(meta) {
|
|
4046
|
+
const run = meta ? this.runs.find((each) => each.id === meta.id) : null;
|
|
4047
|
+
if (!run) return null;
|
|
4048
|
+
const pending = this.pendingOn(run);
|
|
4049
|
+
if (pending) return { kind: 'permission', pending };
|
|
4050
|
+
const asking = this.askingOn(run);
|
|
4051
|
+
return asking ? { kind: 'question', asking } : null;
|
|
4052
|
+
}
|
|
4053
|
+
|
|
4054
|
+
/**
|
|
4055
|
+
* `/view` on a session waiting for you: what it is waiting on, in FULL. In
|
|
4056
|
+
* advanced mode the reader — the session's output and the request under
|
|
4057
|
+
* it, over the whole screen, scrolled with the arrows (the wheel arrives as
|
|
4058
|
+
* arrows there), PgUp/PgDn, g/G; esc closes it, and any other key closes it
|
|
4059
|
+
* and does what it does (`a` answers, `y`/`n` decide). In the scrollback
|
|
4060
|
+
* the request is printed whole, where the terminal scrolls it.
|
|
4061
|
+
*/
|
|
4062
|
+
openReader() {
|
|
4063
|
+
const meta = this.commandTarget();
|
|
4064
|
+
if (!this.split) {
|
|
4065
|
+
this.say('');
|
|
4066
|
+
for (const line of this.requestLines(this.waitingOn(meta), this.screen.width)) this.say(line);
|
|
4067
|
+
this.say('');
|
|
4068
|
+
return this.note('');
|
|
4069
|
+
}
|
|
4070
|
+
this.reader = { runId: meta.id, top: null, room: 1, max: 0 };
|
|
4071
|
+
this.dirty = true;
|
|
4072
|
+
return this.note('');
|
|
4073
|
+
}
|
|
4074
|
+
|
|
4075
|
+
/** The request a session waits on, every word of it. */
|
|
4076
|
+
requestLines(waiting, width) {
|
|
4077
|
+
const ink = this.ink;
|
|
4078
|
+
if (!waiting) {
|
|
4079
|
+
return [ink.muted(' no longer waiting — answered, decided, or gone')];
|
|
4080
|
+
}
|
|
4081
|
+
const room = Math.max(20, width - 4);
|
|
4082
|
+
if (waiting.kind === 'question') {
|
|
4083
|
+
const { question, yours, waitingOn } = waiting.asking;
|
|
4084
|
+
return [
|
|
4085
|
+
ink.bold(ink.accent(' question ')),
|
|
4086
|
+
...String(question.question ?? '').split('\n').flatMap((part) => wordWrap(part, room)).map((line) => ` ${line}`),
|
|
4087
|
+
...(question.options?.length
|
|
4088
|
+
? ['', ...question.options.map((option, i) => ` ${ink.success(String(i + 1))} ${option}`)]
|
|
4089
|
+
: []),
|
|
4090
|
+
'',
|
|
4091
|
+
yours
|
|
4092
|
+
? ` ${ink.success('a')} ${ink.muted('answers it here · /open answers it on cawdev')}`
|
|
4093
|
+
: ink.muted(` waiting on ${waitingOn ?? 'somebody else'} — theirs to answer`),
|
|
4094
|
+
];
|
|
4095
|
+
}
|
|
4096
|
+
const approval = waiting.pending.approval ?? waiting.pending;
|
|
4097
|
+
return [
|
|
4098
|
+
`${ink.bold(ink.warn(' permission '))} ${ink.muted(`${approval.toolName ?? ''} · waiting since ${approval.askedAt?.slice(11, 19) ?? ''}`)}`,
|
|
4099
|
+
...String(approval.summary ?? '').split('\n').flatMap((part) => wordWrap(part, room)).map((line) => ` ${line}`),
|
|
4100
|
+
...(approval.toolInput && approval.toolInput !== approval.summary
|
|
4101
|
+
? ['', ink.muted(' input'), ...String(approval.toolInput).split('\n').map((line) => ` ${ink.text(line)}`)]
|
|
4102
|
+
: []),
|
|
4103
|
+
'',
|
|
4104
|
+
...permissionBanner(waiting.pending, width, ink, this.runner ?? {}).slice(2),
|
|
4105
|
+
];
|
|
4106
|
+
}
|
|
4107
|
+
|
|
4108
|
+
/** The reader, drawn over the whole screen — see `readerFrame` in split.mjs. */
|
|
4109
|
+
readerScreen(width, height) {
|
|
4110
|
+
const ink = this.ink;
|
|
4111
|
+
const meta = this.windows().find((each) => each.id === this.reader.runId)
|
|
4112
|
+
?? this.sessions.get(this.reader.runId) ?? { id: this.reader.runId };
|
|
4113
|
+
const output = (this.lines.get(this.reader.runId) ?? []).map((line) => this.render(line));
|
|
4114
|
+
const waiting = this.waitingOn(meta);
|
|
4115
|
+
const request = this.requestLines(waiting, width - 2);
|
|
4116
|
+
const n = this.windowNumberOf(meta.id);
|
|
4117
|
+
const keys = waiting?.kind === 'permission'
|
|
4118
|
+
? 'y once · s session · n refuse'
|
|
4119
|
+
: waiting?.asking?.yours ? 'a answer' : '';
|
|
4120
|
+
const drawn = readerFrame(width, height, {
|
|
4121
|
+
title: `${n ? `${n} ` : ''}${titleFor(meta, waiting ? `waiting for ${waiting.kind === 'question' ? 'an answer' : 'permission'}` : null)}`,
|
|
4122
|
+
lines: [...output, ink.muted('─'.repeat(Math.min(40, width - 2))), ...request],
|
|
4123
|
+
requestAt: output.length,
|
|
4124
|
+
top: this.reader.top,
|
|
4125
|
+
footer: keyList(['↑↓ scroll', 'PgUp/PgDn page', 'g/G top/end', 'esc close',
|
|
4126
|
+
...(keys ? [keys] : []), '/open on cawdev'], '', width - 2, ink),
|
|
4127
|
+
}, ink);
|
|
4128
|
+
Object.assign(this.reader, { top: drawn.top, max: drawn.max, room: drawn.room });
|
|
4129
|
+
return drawn.lines;
|
|
4130
|
+
}
|
|
4131
|
+
|
|
4132
|
+
/** Keys while the reader is open. */
|
|
4133
|
+
onReaderKey(key) {
|
|
4134
|
+
const reader = this.reader;
|
|
4135
|
+
const move = (to) => {
|
|
4136
|
+
reader.top = Math.min(Math.max(0, to), reader.max);
|
|
4137
|
+
this.dirty = true;
|
|
4138
|
+
return undefined;
|
|
4139
|
+
};
|
|
4140
|
+
const at = reader.top ?? 0;
|
|
4141
|
+
switch (key) {
|
|
4142
|
+
case `${ESC}[A`: case `${ESC}OA`: case 'k':
|
|
4143
|
+
return move(at - 1);
|
|
4144
|
+
case `${ESC}[B`: case `${ESC}OB`: case 'j':
|
|
4145
|
+
return move(at + 1);
|
|
4146
|
+
case `${ESC}[5~`: case 'b':
|
|
4147
|
+
return move(at - reader.room);
|
|
4148
|
+
case `${ESC}[6~`: case ' ':
|
|
4149
|
+
return move(at + reader.room);
|
|
4150
|
+
case `${ESC}[H`: case `${ESC}[1~`: case 'g':
|
|
4151
|
+
return move(0);
|
|
4152
|
+
case `${ESC}[F`: case `${ESC}[4~`: case 'G':
|
|
4153
|
+
return move(reader.max);
|
|
4154
|
+
case ESC: case 'q':
|
|
4155
|
+
this.reader = null;
|
|
4156
|
+
this.dirty = true;
|
|
4157
|
+
return this.note('');
|
|
4158
|
+
default:
|
|
4159
|
+
// Anything else is what it always is — `a` answers, `y`/`n` decide,
|
|
4160
|
+
// `/` a command — with the reader closed first.
|
|
4161
|
+
this.reader = null;
|
|
4162
|
+
this.dirty = true;
|
|
4163
|
+
return this.onKey(key);
|
|
4164
|
+
}
|
|
4165
|
+
}
|
|
4166
|
+
|
|
3992
4167
|
/**
|
|
3993
4168
|
* `/view` alone — the focused session's page on cawdev (its transcript,
|
|
3994
4169
|
* stages, card and checkout). On the console this terminal signed in to:
|
package/runner/runner.mjs
CHANGED
|
@@ -219,6 +219,10 @@ const DEFAULTS = {
|
|
|
219
219
|
// and is not sure it deserves one. A writer, like issue_file: a read-only
|
|
220
220
|
// stage is refused it by shape and not by this list.
|
|
221
221
|
'mcp__cawdev__backlog_file',
|
|
222
|
+
// R368. Ticks one item of the card's Done-when checklist as built. A
|
|
223
|
+
// writer, like the three above: it is not in CAWDEV_READS, so a read-only
|
|
224
|
+
// stage is refused it by shape and not by this list.
|
|
225
|
+
'mcp__cawdev__done_when_tick',
|
|
222
226
|
// R96's rounds. An interview is the one profile that cannot work without
|
|
223
227
|
// these, and they were missing for the same reason as the two above.
|
|
224
228
|
'mcp__cawdev__ask_group',
|
|
@@ -1632,6 +1636,59 @@ async function stashAfterCancel(config, run, cwd) {
|
|
|
1632
1636
|
* request will target the wrong place with nothing saying so. With no
|
|
1633
1637
|
* `baseBranch` every path here is byte for byte what it was before R260.
|
|
1634
1638
|
*/
|
|
1639
|
+
/** Read-only copies in use: path → how many sessions are reading in it. */
|
|
1640
|
+
const reading = new Map();
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* Where a session with no branch reads the code — a plan, a verify, an ask.
|
|
1644
|
+
*
|
|
1645
|
+
* It takes no checkout (R70), so it used to run in the project's FIRST
|
|
1646
|
+
* checkout as that checkout happened to be: on whatever branch the last run
|
|
1647
|
+
* left there, however old — a cancelled CTO Interview left `cto-interview`
|
|
1648
|
+
* checked out two hundred commits behind, and every plan after it read that
|
|
1649
|
+
* code. Moving that checkout is not the answer: a coding run may be working
|
|
1650
|
+
* in it this minute. So a session with no branch reads a copy of its own:
|
|
1651
|
+
* `<first checkout>-read`, a DETACHED worktree of it — sharing its objects,
|
|
1652
|
+
* touching neither its branch nor its tree — brought to origin's default
|
|
1653
|
+
* branch whenever nobody is reading in it. One per project rather than one
|
|
1654
|
+
* per run, because the CLI keys a resumable conversation on its directory
|
|
1655
|
+
* (R69) and an ASK carried on in a different folder would start over.
|
|
1656
|
+
* Detached, because `resetToOrigin` checks the default branch out in the
|
|
1657
|
+
* checkout and git refuses a branch checked out twice. Anything failing here
|
|
1658
|
+
* leaves the session where it always ran, and says why.
|
|
1659
|
+
*/
|
|
1660
|
+
async function readingCopy(source, defaultBranch) {
|
|
1661
|
+
const main = defaultBranch || 'main';
|
|
1662
|
+
const tip = `origin/${main}`;
|
|
1663
|
+
const read = `${source.replace(/\/+$/, '')}-read`;
|
|
1664
|
+
const inUse = reading.get(read) ?? 0;
|
|
1665
|
+
try {
|
|
1666
|
+
const there = await access(join(read, '.git')).then(() => true, () => false);
|
|
1667
|
+
await git(source, ['fetch', '--quiet', 'origin', main]).catch(() => undefined);
|
|
1668
|
+
if (!there) {
|
|
1669
|
+
await git(source, ['worktree', 'prune']).catch(() => undefined);
|
|
1670
|
+
await git(source, ['worktree', 'add', '--quiet', '--detach', read, tip]);
|
|
1671
|
+
} else if (!inUse) {
|
|
1672
|
+
await git(read, ['checkout', '--quiet', '--force', '--detach', tip]);
|
|
1673
|
+
}
|
|
1674
|
+
reading.set(read, inUse + 1);
|
|
1675
|
+
const at = (await git(read, ['rev-parse', '--short', 'HEAD'])).trim();
|
|
1676
|
+
return {
|
|
1677
|
+
path: read,
|
|
1678
|
+
said: inUse
|
|
1679
|
+
? `reading ${read} at ${at} — another session is reading there, so it was not moved`
|
|
1680
|
+
: `reading ${tip} at ${at}, in ${read}`,
|
|
1681
|
+
release: () => reading.set(read, Math.max(0, (reading.get(read) ?? 1) - 1)),
|
|
1682
|
+
};
|
|
1683
|
+
} catch (failure) {
|
|
1684
|
+
return {
|
|
1685
|
+
path: source,
|
|
1686
|
+
said: `reading ${source} as it is — no reading copy: ${failure.message.split('\n')[0]}`,
|
|
1687
|
+
release: () => undefined,
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1635
1692
|
async function prepareWorkingCopy(path, branch, defaultBranch, allowDirty, baseBranch = null) {
|
|
1636
1693
|
await access(join(path, '.git')).catch(() => {
|
|
1637
1694
|
throw new Error(`${path} is not a git repository.`);
|
|
@@ -4492,6 +4549,63 @@ async function askGitHub(cwd, branch, prUrl) {
|
|
|
4492
4549
|
};
|
|
4493
4550
|
}
|
|
4494
4551
|
|
|
4552
|
+
/**
|
|
4553
|
+
* What `gh` says about a hand-taken branch's pull request — R346.
|
|
4554
|
+
*
|
|
4555
|
+
* `pr list --head`, not `pr view <branch>`: the latter stops finding the pull
|
|
4556
|
+
* request once the branch is deleted, and this matches on the pull request's own
|
|
4557
|
+
* head branch name, which does not change. Fork pull requests are dropped: a
|
|
4558
|
+
* fork's branch of the same name is not this branch.
|
|
4559
|
+
*
|
|
4560
|
+
* A merged pull request opened-after `openedAt` wins (latest merge first); a
|
|
4561
|
+
* reused branch name can bring back an older one, which the platform also
|
|
4562
|
+
* refuses. Otherwise the highest-numbered row speaks.
|
|
4563
|
+
*/
|
|
4564
|
+
async function askGitHubForBranch(cwd, branch, openedAt) {
|
|
4565
|
+
if (!branch) {
|
|
4566
|
+
return null;
|
|
4567
|
+
}
|
|
4568
|
+
const out = await new Promise((resolvePromise) => {
|
|
4569
|
+
const child = spawn(
|
|
4570
|
+
'gh',
|
|
4571
|
+
['pr', 'list', '--head', branch, '--state', 'all', '--limit', '10', '--json',
|
|
4572
|
+
'number,url,state,mergedAt,mergeCommit,baseRefName,headRefName,isCrossRepository'],
|
|
4573
|
+
{ cwd, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
4574
|
+
);
|
|
4575
|
+
let text = '';
|
|
4576
|
+
child.stdout.on('data', (chunk) => (text += chunk));
|
|
4577
|
+
child.on('error', () => resolvePromise(null));
|
|
4578
|
+
child.on('close', (code) => resolvePromise(code === 0 ? text.trim() : null));
|
|
4579
|
+
});
|
|
4580
|
+
const parsed = out ? safeJson(out) : null;
|
|
4581
|
+
if (!Array.isArray(parsed)) {
|
|
4582
|
+
return null;
|
|
4583
|
+
}
|
|
4584
|
+
const own = parsed.filter((row) => row && !row.isCrossRepository && row.headRefName === branch);
|
|
4585
|
+
if (!own.length) {
|
|
4586
|
+
return null;
|
|
4587
|
+
}
|
|
4588
|
+
const since = openedAt ? Date.parse(openedAt) : Number.NEGATIVE_INFINITY;
|
|
4589
|
+
const merged = own
|
|
4590
|
+
.filter((row) => row.state === 'MERGED' && Date.parse(row.mergedAt) >= since)
|
|
4591
|
+
.sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt));
|
|
4592
|
+
const chosen = merged[0] ?? [...own].sort((a, b) => b.number - a.number)[0];
|
|
4593
|
+
if (chosen.state === 'MERGED' && !merged.length) {
|
|
4594
|
+
return null; // Only a merge from before the item existed: not this item's.
|
|
4595
|
+
}
|
|
4596
|
+
const state = { OPEN: 'OPEN', MERGED: 'MERGED', CLOSED: 'CLOSED' }[chosen.state];
|
|
4597
|
+
if (!state) {
|
|
4598
|
+
return null;
|
|
4599
|
+
}
|
|
4600
|
+
return {
|
|
4601
|
+
state,
|
|
4602
|
+
prUrl: chosen.url ?? null,
|
|
4603
|
+
mergeCommit: chosen.mergeCommit?.oid ?? null,
|
|
4604
|
+
mergedAt: chosen.mergedAt ?? null,
|
|
4605
|
+
baseRefName: chosen.baseRefName ?? null,
|
|
4606
|
+
};
|
|
4607
|
+
}
|
|
4608
|
+
|
|
4495
4609
|
/**
|
|
4496
4610
|
* Whether the run's head is on the default branch, for any remote at all.
|
|
4497
4611
|
*
|
|
@@ -4609,13 +4723,18 @@ async function checkMerges(config) {
|
|
|
4609
4723
|
log(`could not ask which branches to check: ${failure.message}`);
|
|
4610
4724
|
return null;
|
|
4611
4725
|
});
|
|
4612
|
-
if (!pending?.length) {
|
|
4613
|
-
return;
|
|
4614
|
-
}
|
|
4615
|
-
|
|
4616
4726
|
// One fetch per project, not one per branch: twenty-five branches in one
|
|
4617
4727
|
// repository are twenty-five questions about the same set of refs.
|
|
4618
4728
|
const fetched = new Set();
|
|
4729
|
+
if (pending?.length) {
|
|
4730
|
+
await checkRunMerges(config, pending, fetched);
|
|
4731
|
+
}
|
|
4732
|
+
// R346: the hand-taken items, even when no run is pending.
|
|
4733
|
+
await checkWorkItemMerges(config, fetched);
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4736
|
+
/** The run pass of `checkMerges`: one reading per pending run branch. */
|
|
4737
|
+
async function checkRunMerges(config, pending, fetched) {
|
|
4619
4738
|
const readings = [];
|
|
4620
4739
|
|
|
4621
4740
|
for (const branch of pending) {
|
|
@@ -4666,6 +4785,64 @@ async function checkMerges(config) {
|
|
|
4666
4785
|
}
|
|
4667
4786
|
}
|
|
4668
4787
|
|
|
4788
|
+
/**
|
|
4789
|
+
* R346: the hand-taken items. A squash or rebase merge leaves no head on the
|
|
4790
|
+
* default branch for the git reading to find, so ask `gh` about the pull
|
|
4791
|
+
* request. An older platform answers 404 and this returns in silence.
|
|
4792
|
+
*/
|
|
4793
|
+
async function checkWorkItemMerges(config, fetched) {
|
|
4794
|
+
const pending = await api(config, `/api/runners/${config.runnerId}/work-item-branches`)
|
|
4795
|
+
.catch(() => null);
|
|
4796
|
+
if (!Array.isArray(pending) || !pending.length) {
|
|
4797
|
+
return;
|
|
4798
|
+
}
|
|
4799
|
+
const readings = [];
|
|
4800
|
+
for (const item of pending) {
|
|
4801
|
+
const project = config.projects[item.projectSlug];
|
|
4802
|
+
if (!project) {
|
|
4803
|
+
// Not ours, but say so: an UNKNOWN only stamps the platform's rotation,
|
|
4804
|
+
// and silence would leave these rows first in the queue for ever.
|
|
4805
|
+
readings.push({ workItemId: item.workItemId, state: 'UNKNOWN' });
|
|
4806
|
+
continue;
|
|
4807
|
+
}
|
|
4808
|
+
const heldNow = heldWorkspaces();
|
|
4809
|
+
const cwd = project.workspaces.find((candidate) => !heldNow.has(candidate))
|
|
4810
|
+
?? resolve(project.path);
|
|
4811
|
+
try {
|
|
4812
|
+
await access(join(cwd, '.git'));
|
|
4813
|
+
} catch {
|
|
4814
|
+
readings.push({ workItemId: item.workItemId, state: 'UNKNOWN' });
|
|
4815
|
+
continue;
|
|
4816
|
+
}
|
|
4817
|
+
if (!fetched.has(cwd)) {
|
|
4818
|
+
fetched.add(cwd);
|
|
4819
|
+
await git(cwd, ['fetch', '--prune', 'origin']).catch(() => null);
|
|
4820
|
+
}
|
|
4821
|
+
const reading = await askGitHubForBranch(cwd, item.branch, item.openedAt).catch(() => null);
|
|
4822
|
+
readings.push({
|
|
4823
|
+
workItemId: item.workItemId,
|
|
4824
|
+
state: reading?.state ?? 'UNKNOWN',
|
|
4825
|
+
prUrl: reading?.prUrl ?? null,
|
|
4826
|
+
mergeCommit: reading?.mergeCommit ?? null,
|
|
4827
|
+
mergedAt: reading?.mergedAt ?? null,
|
|
4828
|
+
baseRefName: reading?.baseRefName ?? null,
|
|
4829
|
+
});
|
|
4830
|
+
}
|
|
4831
|
+
if (!readings.length) {
|
|
4832
|
+
return;
|
|
4833
|
+
}
|
|
4834
|
+
const recorded = await api(config, `/api/runners/${config.runnerId}/work-item-merges`, {
|
|
4835
|
+
method: 'POST',
|
|
4836
|
+
body: { readings },
|
|
4837
|
+
}).catch((failure) => {
|
|
4838
|
+
log(`could not record what became of the hand-taken branches: ${failure.message}`);
|
|
4839
|
+
return null;
|
|
4840
|
+
});
|
|
4841
|
+
if (recorded?.merged?.length) {
|
|
4842
|
+
log(`${recorded.merged.length} hand-taken branch(es) have merged`);
|
|
4843
|
+
}
|
|
4844
|
+
}
|
|
4845
|
+
|
|
4669
4846
|
/** Reads the run's history out of git and tells the platform. */
|
|
4670
4847
|
async function reportCommits(config, run, cwd, base) {
|
|
4671
4848
|
const commits = await readCommits(cwd, base).catch((failure) => {
|
|
@@ -5197,6 +5374,19 @@ function argsForProfile(agentArgs, profile, run, expertAgents = [], skills = [])
|
|
|
5197
5374
|
/** Longer than ask_user's own ten-minute wait — see the cawdev server of spawnCodex. */
|
|
5198
5375
|
const CAWDEV_TOOL_TIMEOUT_SEC = 660;
|
|
5199
5376
|
|
|
5377
|
+
/**
|
|
5378
|
+
* R368. cawdev reads a plan's `**Done when:**` list into the card's review
|
|
5379
|
+
* checklist, numbered in order, so a plan has to end in one that can be ticked.
|
|
5380
|
+
*/
|
|
5381
|
+
const DONE_WHEN_PLAN = 'End the plan with a `**Done when:**` list of `- [ ]` items, each one '
|
|
5382
|
+
+ 'condition somebody could check on its own — cawdev turns that list into the card\'s '
|
|
5383
|
+
+ 'review checklist, numbered in order.';
|
|
5384
|
+
|
|
5385
|
+
/** R368. The other half: the session that builds it says which items it built. */
|
|
5386
|
+
const DONE_WHEN_TICK = 'When an item under the plan\'s **Done when** is built and you have '
|
|
5387
|
+
+ 'checked it, call `done_when_tick` with its number and one line of evidence. Do not tick '
|
|
5388
|
+
+ 'what you did not check.';
|
|
5389
|
+
|
|
5200
5390
|
/**
|
|
5201
5391
|
* A decision that is the person's is ASKED, and the stage goes on with the
|
|
5202
5392
|
* answer. R19 and R24 (Codex) each met one — a plan the code had moved away
|
|
@@ -5230,7 +5420,8 @@ function stagePrompt(stage, carried, hasPlan = true) {
|
|
|
5230
5420
|
+ 'concretely: which files, what change in each, and how you will know it worked. '
|
|
5231
5421
|
+ 'What you report becomes the card\'s plan, and a DIFFERENT session — another '
|
|
5232
5422
|
+ 'process, with none of your context — will be handed it and told to carry it out. '
|
|
5233
|
-
+ 'Write it for them — with every decision in it already made. ' +
|
|
5423
|
+
+ 'Write it for them — with every decision in it already made. ' + DONE_WHEN_PLAN + ' '
|
|
5424
|
+
+ ASK_DONT_STOP,
|
|
5234
5425
|
VERIFY: 'Check the plan against what is actually there. Does every file it names exist? '
|
|
5235
5426
|
+ 'Does every interface it assumes still look like that? You cannot change anything — '
|
|
5236
5427
|
+ 'say what is wrong with the plan, or say plainly that it holds.',
|
|
@@ -5242,7 +5433,7 @@ function stagePrompt(stage, carried, hasPlan = true) {
|
|
|
5242
5433
|
? 'Do the work in the plan — it is under "The plan for this card" above, '
|
|
5243
5434
|
+ 'written by the plan phase and agreed. Do not quietly improvise a different change: '
|
|
5244
5435
|
+ 'somebody approved that plan. If it turns out to be wrong or incomplete, ask what to '
|
|
5245
|
-
+ 'do instead of stopping — and stop only if the answer is to stop. '
|
|
5436
|
+
+ 'do instead of stopping — and stop only if the answer is to stop. ' + DONE_WHEN_TICK + ' '
|
|
5246
5437
|
: 'This card has no plan, and it does not need one: it was started straight into '
|
|
5247
5438
|
+ 'development, so the CARD\'s text is the task. Work out what it asks — read the code '
|
|
5248
5439
|
+ 'it touches first — build it, and commit it on this branch. Never stop because a plan '
|
|
@@ -5267,7 +5458,8 @@ function stagePrompt(stage, carried, hasPlan = true) {
|
|
|
5267
5458
|
+ 'is one call away — `quality_get`, which reads this run\'s branch by default; a red '
|
|
5268
5459
|
+ 'gate with new vulnerabilities is a finding to report with the output, not a failure '
|
|
5269
5460
|
+ 'to hide. ' + WAIT_FOR_COMMANDS + ' Then commit the branch\'s changes — no stage '
|
|
5270
|
-
+ 'after this one writes code, and work left uncommitted is work nothing can push.'
|
|
5461
|
+
+ 'after this one writes code, and work left uncommitted is work nothing can push. '
|
|
5462
|
+
+ DONE_WHEN_TICK,
|
|
5271
5463
|
MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
|
|
5272
5464
|
+ 'tried and rejected and why, and anything that turned out to matter and would not be '
|
|
5273
5465
|
+ 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
|
|
@@ -5681,6 +5873,8 @@ card you created a moment ago. cawdev stores what you report as the card's plan
|
|
|
5681
5873
|
record when this phase ends, and that is the only copy anybody wants: two plans in
|
|
5682
5874
|
two places is how one of them goes stale.
|
|
5683
5875
|
|
|
5876
|
+
**End with a Done-when list.** ${DONE_WHEN_PLAN}
|
|
5877
|
+
|
|
5684
5878
|
Then \`report\` kind "done" with the plan itself.${asked}`;
|
|
5685
5879
|
}
|
|
5686
5880
|
|
|
@@ -6395,13 +6589,22 @@ async function startRun(config, offered, workspace) {
|
|
|
6395
6589
|
return;
|
|
6396
6590
|
}
|
|
6397
6591
|
|
|
6592
|
+
// Where the session runs: the checkout, or for a session with no branch
|
|
6593
|
+
// the project's reading copy (`readingCopy`).
|
|
6594
|
+
let cwd = path ? resolve(path) : path;
|
|
6595
|
+
let reader = null;
|
|
6398
6596
|
try {
|
|
6399
6597
|
let branch = run.branch;
|
|
6400
6598
|
if (!writesCodeProfile(run)) {
|
|
6401
6599
|
// Nothing is prepared. It cuts no branch, and a dirty tree does not stop
|
|
6402
6600
|
// it, because it is not going to write to one — refusing here would make
|
|
6403
6601
|
// "what is R12 about?" unanswerable while somebody has edits open.
|
|
6404
|
-
log(` a ${run.profile.toLowerCase()} session: no branch, nothing
|
|
6602
|
+
log(` a ${run.profile.toLowerCase()} session: no branch, nothing cut`);
|
|
6603
|
+
if (path) {
|
|
6604
|
+
reader = await readingCopy(resolve(path), defaultBranch);
|
|
6605
|
+
cwd = reader.path;
|
|
6606
|
+
log(` ${reader.said}`);
|
|
6607
|
+
}
|
|
6405
6608
|
} else {
|
|
6406
6609
|
// Cleared BEFORE the checkout, and never when somebody deliberately
|
|
6407
6610
|
// started on top of their own uncommitted work — there the untracked
|
|
@@ -6439,7 +6642,7 @@ async function startRun(config, offered, workspace) {
|
|
|
6439
6642
|
}).catch((failure) => log(` could not report the merge: ${failure.message}`));
|
|
6440
6643
|
}
|
|
6441
6644
|
}
|
|
6442
|
-
log(` working copy ${path} is on ${branch}`);
|
|
6645
|
+
log(` working copy ${cwd ?? path} is on ${branch ?? 'no branch of its own'}`);
|
|
6443
6646
|
|
|
6444
6647
|
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
|
|
6445
6648
|
method: 'POST',
|
|
@@ -6461,7 +6664,7 @@ async function startRun(config, offered, workspace) {
|
|
|
6461
6664
|
return;
|
|
6462
6665
|
}
|
|
6463
6666
|
|
|
6464
|
-
const said = await walkLifecycle(config, run, runToken,
|
|
6667
|
+
const said = await walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
6465
6668
|
resume, mcpServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield);
|
|
6466
6669
|
|
|
6467
6670
|
if (merge) {
|
|
@@ -6480,6 +6683,7 @@ async function startRun(config, offered, workspace) {
|
|
|
6480
6683
|
body: { state: 'FAILED', summary: failure.message },
|
|
6481
6684
|
}).catch((second) => log(` could not record the failure: ${second.message}`));
|
|
6482
6685
|
} finally {
|
|
6686
|
+
reader?.release();
|
|
6483
6687
|
running.delete(run.id);
|
|
6484
6688
|
taken.delete(run.id);
|
|
6485
6689
|
// The transcript of a run that is over is on the platform, where it belongs.
|