cawdev-cli 1.0.8-beta → 1.0.10-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.