cawdev-cli 1.0.9-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/lib/roadmap-format.mjs +5 -1
- package/lib/runner-config.mjs +1 -1
- package/mcp/README.md +10 -1
- package/mcp/server.mjs +106 -8
- 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/lib/roadmap-format.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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',
|
package/lib/runner-config.mjs
CHANGED
|
@@ -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.
|
|
@@ -165,9 +173,10 @@ what you are allowed to do.
|
|
|
165
173
|
| `roadmap_create` | Create an entry; the platform allocates its permanent number. |
|
|
166
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
|
-
'
|
|
630
|
-
'a
|
|
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',
|
|
@@ -733,7 +792,7 @@ const TOOLS = [
|
|
|
733
792
|
description:
|
|
734
793
|
'What is broken in a project, and how badly — R85. The same numbering as the roadmap, ' +
|
|
735
794
|
'written with an i: card 91 filed as an issue is i91, and there is no R91 as well. ' +
|
|
736
|
-
'Statuses are NEW, CONFIRMED, IN_DEVELOPMENT, MERGED (drawn as ' +
|
|
795
|
+
'Statuses are BACKLOG, NEW, CONFIRMED, IN_DEVELOPMENT, MERGED (drawn as ' +
|
|
737
796
|
'Resolved) and DECLINED (Won\'t fix).',
|
|
738
797
|
inputSchema: {
|
|
739
798
|
type: 'object',
|
|
@@ -741,7 +800,7 @@ const TOOLS = [
|
|
|
741
800
|
...PROJECT_ARGUMENT,
|
|
742
801
|
status: {
|
|
743
802
|
type: 'string',
|
|
744
|
-
enum: ['NEW', 'CONFIRMED', 'IN_DEVELOPMENT', 'MERGED', 'SHIPPED', 'DECLINED'],
|
|
803
|
+
enum: ['BACKLOG', 'NEW', 'CONFIRMED', 'IN_DEVELOPMENT', 'MERGED', 'SHIPPED', 'DECLINED'],
|
|
745
804
|
},
|
|
746
805
|
assignee: {
|
|
747
806
|
type: 'string',
|
|
@@ -768,8 +827,10 @@ const TOOLS = [
|
|
|
768
827
|
{
|
|
769
828
|
name: 'issue_file',
|
|
770
829
|
description:
|
|
771
|
-
'File an issue: something that is broken, with how badly. Lands
|
|
772
|
-
'
|
|
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 ' +
|
|
773
834
|
'severity is required: it is how the board is ordered. Use `related` to name the card it ' +
|
|
774
835
|
'was found on, by ref (R84). Issues count on their own sequence — R221 — and are ' +
|
|
775
836
|
'written with an i: i91 is the ninety-first issue, and R91 is another card.',
|
|
@@ -780,7 +841,7 @@ const TOOLS = [
|
|
|
780
841
|
title: { type: 'string' },
|
|
781
842
|
body: { type: 'string', description: 'Markdown. What happens, and what should instead.' },
|
|
782
843
|
severity: { type: 'string', enum: ['CRITICAL', 'MEDIUM', 'MINOR'] },
|
|
783
|
-
status: { type: 'string', enum: ['NEW', 'CONFIRMED'] },
|
|
844
|
+
status: { type: 'string', enum: ['BACKLOG', 'NEW', 'CONFIRMED'] },
|
|
784
845
|
related: {
|
|
785
846
|
type: 'array',
|
|
786
847
|
items: { type: ['string', 'integer'] },
|
|
@@ -833,14 +894,25 @@ const TOOLS = [
|
|
|
833
894
|
kind: { type: 'string', enum: ['ISSUE', 'FEATURE'] },
|
|
834
895
|
title: { type: 'string' },
|
|
835
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
|
+
},
|
|
836
904
|
},
|
|
837
905
|
required: ['kind', 'title', 'body'],
|
|
838
906
|
},
|
|
839
907
|
handler: async (config, args) => {
|
|
840
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'])];
|
|
841
913
|
const item = await api(config, `/api/projects/${slug}/backlog`, {
|
|
842
914
|
method: 'POST',
|
|
843
|
-
body: pick(args, ['kind', 'title', 'body']),
|
|
915
|
+
body: { ...pick(args, ['kind', 'title', 'body']), tags },
|
|
844
916
|
});
|
|
845
917
|
return `Filed in ${slug}'s backlog as pending: "${item.title}". Somebody will accept it ` +
|
|
846
918
|
'as a card or an issue, or refuse it.';
|
|
@@ -959,6 +1031,7 @@ const TOOLS = [
|
|
|
959
1031
|
status: {
|
|
960
1032
|
type: 'string',
|
|
961
1033
|
enum: [
|
|
1034
|
+
'BACKLOG',
|
|
962
1035
|
'CONSIDERING',
|
|
963
1036
|
'PLANNED',
|
|
964
1037
|
'NEW',
|
|
@@ -1170,6 +1243,12 @@ const TOOLS = [
|
|
|
1170
1243
|
config,
|
|
1171
1244
|
`/api/projects/${project}/roadmap/${cardRef}/plans`,
|
|
1172
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(() => []);
|
|
1173
1252
|
|
|
1174
1253
|
const lines = [];
|
|
1175
1254
|
|
|
@@ -1212,6 +1291,9 @@ const TOOLS = [
|
|
|
1212
1291
|
}
|
|
1213
1292
|
}
|
|
1214
1293
|
lines.push(formatEntry(entry, { comments, plan: plans?.[0] ?? null }));
|
|
1294
|
+
if (criteria?.length) {
|
|
1295
|
+
lines.push('', formatDoneWhen(criteria));
|
|
1296
|
+
}
|
|
1215
1297
|
|
|
1216
1298
|
// What happened the other times. A session is told the entry and the
|
|
1217
1299
|
// branch but not that two previous runs on this card failed, which is
|
|
@@ -2340,6 +2422,22 @@ function formatPlan(plan) {
|
|
|
2340
2422
|
return [`--- the plan (${written}) ---`, plan.body].join('\n');
|
|
2341
2423
|
}
|
|
2342
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
|
+
|
|
2343
2441
|
/**
|
|
2344
2442
|
* One earlier attempt at this card — R38.
|
|
2345
2443
|
*
|
package/package.json
CHANGED
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
|
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.
|
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.
|