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.
@@ -56,11 +56,13 @@ export function statusToDisplay(status) {
56
56
  * The file has always grouped these two this way, and deriving them means a
57
57
  * declined entry cannot end up filed under a phase it is no longer part of.
58
58
  *
59
- * Two, and REVIEW is deliberately not a third: work waiting to be read is still
59
+ * Three since R371 — BACKLOG joined — and REVIEW is deliberately not a fourth: work waiting to be read is still
60
60
  * part of the phase it belongs to, and moving it to a section of its own would
61
61
  * take it out of its phase for a few days and put it back afterwards.
62
62
  */
63
63
  export const STATUS_SECTIONS = {
64
+ // R371: waiting to be brought in, before what is being considered.
65
+ BACKLOG: 'Backlog — waiting to be brought in',
64
66
  CONSIDERING: 'Considering — wanted, not settled',
65
67
  DECLINED: 'Declined — decided against, with the reason',
66
68
  };
@@ -77,6 +79,8 @@ export const STATUS_SECTIONS = {
77
79
  export const ISSUE_SECTIONS = {
78
80
  NEW: 'New — filed, not yet triaged',
79
81
  CONFIRMED: 'Confirmed — real, waiting for somebody',
82
+ // R371: an issue waiting to be brought in — after what needs somebody now.
83
+ BACKLOG: 'Backlog — waiting to be brought in',
80
84
  IN_DEVELOPMENT: 'In development — somebody is fixing it',
81
85
  MERGED: 'Resolved — the fix landed',
82
86
  SHIPPED: 'Released — the fix shipped',
@@ -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
- 'platform decides where a card starts — CONSIDERING — unless you say otherwise; ' +
630
- 'a status that requires something must be given it.',
685
+ 'project decides where a card you file starts — the BACKLOG by default for a card an ' +
686
+ 'agent files, waiting until a person or a sprint brings it in — unless you say ' +
687
+ 'otherwise; a status that requires something must be given it. A card in the BACKLOG ' +
688
+ 'holds no sprint: giving it one moves it to CONSIDERING.',
631
689
  inputSchema: {
632
690
  type: 'object',
633
691
  properties: {
@@ -640,6 +698,7 @@ const TOOLS = [
640
698
  status: {
641
699
  type: 'string',
642
700
  enum: [
701
+ 'BACKLOG',
643
702
  'CONSIDERING',
644
703
  'PLANNED',
645
704
  'IN_DEVELOPMENT',
@@ -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 at NEW — filed and not ' +
772
- 'yet triaged — unless you say CONFIRMED, which claims you have already checked it. The ' +
830
+ 'File an issue: something that is broken, with how badly. Lands where the project says ' +
831
+ 'for an issue an agent files — the BACKLOG by default, waiting to be brought in, or NEW ' +
832
+ '(filed and not yet triaged) — unless you say NEW, or CONFIRMED, which claims you have ' +
833
+ 'already checked it. The ' +
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cawdev-cli",
3
- "version": "1.0.9-beta",
3
+ "version": "1.0.10-beta",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {
package/runner/README.md CHANGED
@@ -510,6 +510,22 @@ then ↑.
510
510
  **`/view`** opens the focused session's page on cawdev — transcript, stages,
511
511
  card — in the browser, on the console this terminal signed in to.
512
512
 
513
+ **On a session waiting for you** — a question, or a permission such as a stage
514
+ gate whose summary is the whole plan — the prompt only has room for the start
515
+ of it, and says so: `/view reads it all · /open answers it on cawdev`.
516
+ **`/view`** then opens the READER: one window over the whole screen with the
517
+ session's output and, under a rule, the request in full (the question and its
518
+ options; the permission's summary, its input and its keys). It opens on the
519
+ request; `↑`/`↓` (and the wheel), `PgUp`/`PgDn`, `g`/`G` scroll it, `esc`
520
+ closes it, and any other key closes it and does what it always does — `a`
521
+ answers, `y`/`s`/`n` decide. Outside advanced mode `/view` prints the request
522
+ whole into the scrollback. When the question has options its list opens by
523
+ itself and hides the banner; the list's own key line says `/view reads it all`,
524
+ and `/` there leaves the list for the command line (`a` brings it back).
525
+ **`/open`** opens the session's run page on cawdev,
526
+ where the question panel and the decision are. `readerFrame` in split.mjs is
527
+ the window, pure.
528
+
513
529
  **`/view R29`** prints a card here instead: its status, section, tags, branch,
514
530
  work item and pull request, who is coding or reviewing it, what it is related
515
531
  to, its page on the web, and the first thirty lines of its body (`cardLines`,
@@ -607,6 +623,21 @@ the whole of removing it. A clone and not a `git worktree`, on purpose: the
607
623
  daemon's reset checks the default branch out, which git refuses in a second
608
624
  worktree. Changed without a restart, like `agentCommands`.
609
625
 
626
+ ### Where a session with no branch reads: `<first>-read`
627
+
628
+ A plan, a verify or an ask cuts no branch and takes no checkout, so it used to
629
+ run in the project's first checkout **as that checkout happened to be** — on
630
+ whatever branch the last run left there. A cancelled CTO Interview left
631
+ `cto-interview` checked out, hundreds of commits behind, and every plan after
632
+ it read that code. Such a session now reads `<first>-read`, beside the first
633
+ checkout: a **detached `git worktree`** of it, brought to `origin/<default
634
+ branch>` at the start of a session whenever no other session is reading there.
635
+ The checkout itself is never moved, because a coding run may be working in it.
636
+ One per project rather than one per run, because the CLI keeps a resumable
637
+ conversation by its folder. Detached, so the default branch is never checked
638
+ out twice. If it cannot be made — no remote, not a repository — the session
639
+ reads the first checkout as before, and the log says why.
640
+
610
641
  ### `/reload`
611
642
 
612
643
  Quitting and starting again, in one word — for when the client's code changed
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(approval.summary, Math.max(8, width - 13))}`,
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 — or, on a finished plan, its card and plan on cawdev"],
836
- ['/view', "a card here in the terminal: /view R29, /view <words of its title> (@project), /view card for the session's; /view alone opens the session on cawdev"],
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
- title: `${this.tagOf(this.current())} wants ${approval.toolName} · ${approval.summary}`,
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
- return rest.length ? void this.viewCard(rest) : this.viewOnCawdev();
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
- this.note(/usage|window/i.test(failure.message)
3839
- ? `${failure.message} — add --anyway to start it regardless`
3840
- : failure.message);
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. ' + ASK_DONT_STOP,
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 prepared`);
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, resolve(path), baseCommit, workspace,
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.