@pmelab/gtd 29.2.1 → 29.2.3

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "29.2.1",
3
+ "version": "29.2.3",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -324,35 +324,38 @@ Then, in any repository:
324
324
  /gtd Add a --json flag to the export command
325
325
  ```
326
326
 
327
- That starts a process from your requirements and drives it until it needs you. A
328
- long, multi-line requirements paste still starts a process; if Claude Code never
329
- expanded the paste, gtd says it could not read it, and you write the
330
- requirements to `.gtd/TODO.md` and run `/gtd`. `/gtd fix`, `/gtd review [base]`,
331
- `/gtd rollout <flag>` and `/gtd cleanup <flag>` (where the config exports them)
332
- take the doors described below; text whose word count does not fit a door's args
333
- (bare `/gtd rollout` included) starts a process from that text as requirements.
334
- At every human rest Claude itself takes over: it walks you through the open
335
- questions one at a time, answers your own questions about the plan or the diff,
336
- records what you decide in the steering file the way the step asks, and moves
337
- the process on only when you say so; a review you leave untouched signs off.
338
- While a step runs you can keep asking; requests for changes wait for the next
339
- rest. When the process finishes, Claude offers to squash it and open its pull
340
- request(s) (or run `/gtd ship`; `GTD_SHIP_MODE` picks one stacked pull request
341
- per slice or one commit per slice, see the [driver doc](docs/driver.md));
342
- re-running `/gtd ship` after a lower stacked pull request merged restacks the
343
- rest (it refuses, with nothing changed, when that pull request merged anywhere
344
- but the default branch (unless into a lower slice branch before that one merged
345
- into it), a slice branch is checked out in another worktree, an unrelated local
346
- branch sits inside the rebased commits, or a slice's change is already upstream;
347
- a failed rebase is rolled back). A process can change hands at any gate: ask
348
- Claude to hand it off (or run `/gtd throw @dev`) and it opens a draft pull
349
- request assigned to them, and `/gtd catch <pr>` picks it up exactly where it
350
- waits, so whoever wrote the requirements can hand the architecture to someone
351
- else. Ship, hand-off and throw answer at once and run in the background, with
352
- progress on the status line; they report by waking Claude, a toast, a transcript
353
- line and a push notification, and a failed push or `gh` call reports as a
354
- failure. While a ship or hand-off runs or is queued, `/gtd catch`, the doors,
355
- `/gtd <requirements>` and bare `/gtd` wait for its report. See
327
+ That starts a process from your requirements and drives it until it needs you.
328
+ The requirements go to the top of `.gtd/TODO.md`; deferred review notes already
329
+ there stay. A long, multi-line requirements paste still starts a process; if
330
+ Claude Code never expanded the paste, gtd says it could not read it, and you add
331
+ the requirements to the top of `.gtd/TODO.md` and run `/gtd`. `/gtd fix`,
332
+ `/gtd review [base]`, `/gtd rollout <flag>` and `/gtd cleanup <flag>` (where the
333
+ config exports them) take the doors described below; text whose word count does
334
+ not fit a door's args (bare `/gtd rollout` included) starts a process from that
335
+ text as requirements. At every human rest Claude itself takes over: it walks you
336
+ through the open questions one at a time, answers your own questions about the
337
+ plan or the diff, records what you decide in the steering file the way the step
338
+ asks, and moves the process on only when you say so; a review you leave
339
+ untouched signs off. While a step runs you can keep asking; requests for changes
340
+ wait for the next rest. When the process finishes, Claude offers to squash it
341
+ and open its pull request(s) (or run `/gtd ship`; `GTD_SHIP_MODE` picks one
342
+ stacked pull request per slice or one commit per slice, see the
343
+ [driver doc](docs/driver.md)); a process started for an issue ships with
344
+ `Closes #N` on its top pull request; re-running `/gtd ship` after a lower
345
+ stacked pull request merged restacks the rest (it refuses, with nothing changed,
346
+ when that pull request merged anywhere but the default branch (unless into a
347
+ lower slice branch before that one merged into it), a slice branch is checked
348
+ out in another worktree, an unrelated local branch sits inside the rebased
349
+ commits, or a slice's change is already upstream; a failed rebase is rolled
350
+ back). A process can change hands at any gate: ask Claude to hand it off (or run
351
+ `/gtd throw @dev`) and it opens a draft pull request assigned to them, and
352
+ `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
353
+ requirements can hand the architecture to someone else. Ship, hand-off and throw
354
+ answer at once and run in the background, with progress on the status line; they
355
+ report by waking Claude, a toast, a transcript line and a push notification, and
356
+ a failed push or `gh` call reports as a failure. While a ship or hand-off runs
357
+ or is queued, `/gtd catch`, the doors, `/gtd <requirements>` and bare `/gtd`
358
+ wait for its report. See
356
359
  [Inside Claude Code](https://github.com/pmelab/gtd/blob/main/docs/driver.md#inside-claude-code-the-gtd-mod).
357
360
 
358
361
  ### Then let an agent build your own
@@ -44,7 +44,8 @@ export type Turn = {
44
44
  access?: AccessDef
45
45
  }
46
46
 
47
- export type TurnEnd = { ok: true } | { ok: false; why: string }
47
+ // `isLost`: the scope's subagent no longer takes messages.
48
+ export type TurnEnd = { ok: true } | { ok: false; why: string; isLost?: true }
48
49
 
49
50
  export type Io = {
50
51
  next(): Promise<Beat>
@@ -138,39 +139,53 @@ async function message(io: Io, b: Beat, beat: number, judged: string | undefined
138
139
  }
139
140
 
140
141
  async function agent(io: Io, b: Beat, landTurn: string | undefined): Promise<Acted> {
141
- const memory = memoryOf(b)
142
- const t =
143
- landTurn === memory
144
- ? ({ ok: true } as const)
145
- : await io.turn({
146
- memory,
147
- resume: isTrue(b.session?.resume),
148
- prompt: b.content ?? "",
149
- label: b.label,
150
- model: b.model || undefined,
151
- system: b.system || undefined,
152
- skills: b.skills ?? [],
153
- scope: memory.split("#")[0]!,
154
- access: b.access,
155
- })
142
+ const turn = turnOf(b)
143
+ const t = landTurn === turn.memory ? ({ ok: true } as const) : await io.turn(turn)
156
144
  if (!t.ok) return failed(b, t.why)
157
- return b.validate ? validated(io, b, b.validate, memory) : {}
145
+ return b.validate ? validated(io, b, b.validate) : {}
158
146
  }
159
147
 
160
148
  // The step's own validator; its findings go back to the same conversation.
161
- async function validated(io: Io, b: Beat, validate: string, memory: string): Promise<Acted> {
149
+ async function validated(io: Io, b: Beat, validate: string): Promise<Acted> {
162
150
  for (let fixes = 0; ; fixes++) {
163
151
  const v = await io.check(validate)
164
152
  if (v.ok) return {}
165
153
  if (fixes >= MAX_FIXES)
166
154
  return failed(b, `validation still failing after ${fixes} fixes\n${v.out}`)
167
- const f = await io.resume(memory, v.out)
155
+ const f = await followUp(io, b, v.out)
168
156
  if (!f.ok) return failed(b, f.why)
169
157
  }
170
158
  }
171
159
 
160
+ // A conversation whose subagent is gone starts over with the step's prompt
161
+ // and the follow-up, rather than failing a step a fresh subagent can finish.
162
+ async function followUp(io: Io, b: Beat, text: string): Promise<TurnEnd> {
163
+ const turn = turnOf(b)
164
+ const r = await io.resume(turn.memory, text)
165
+ if (r.ok || !r.isLost) return r
166
+ return io.turn({ ...turn, resume: false, prompt: `${turn.prompt}\n\n${LOST}\n\n${text}` })
167
+ }
168
+
169
+ const LOST =
170
+ "An earlier subagent working on this step was lost; what it did is in the working tree. Continue from there with this:"
171
+
172
172
  const memoryOf = (b: Beat) => b.memory ?? b.session?.id ?? b.state ?? "root"
173
173
 
174
+ function turnOf(b: Beat): Turn {
175
+ const memory = memoryOf(b)
176
+ return {
177
+ memory,
178
+ resume: isTrue(b.session?.resume),
179
+ prompt: b.content ?? "",
180
+ label: b.label,
181
+ model: b.model || undefined,
182
+ system: b.system || undefined,
183
+ skills: b.skills ?? [],
184
+ scope: memory.split("#")[0]!,
185
+ access: b.access,
186
+ }
187
+ }
188
+
174
189
  // A prompt beat whose turn wrote outside its access gets one recovery turn
175
190
  // with the refusal text; a second refusal is an error, not another loop.
176
191
  async function land(
@@ -201,11 +216,10 @@ async function recover(
201
216
  verdict: string | undefined,
202
217
  text: string,
203
218
  ): Promise<Stop | undefined> {
204
- const memory = memoryOf(b)
205
- const r = await io.resume(memory, text)
219
+ const r = await followUp(io, b, text)
206
220
  if (!r.ok) return { kind: "error", text: r.why, ...where(b) }
207
221
  if (b.validate) {
208
- const v = await validated(io, b, b.validate, memory)
222
+ const v = await validated(io, b, b.validate)
209
223
  if (v.stop) return v.stop
210
224
  }
211
225
  return land(io, b, verdict, false)
@@ -10,7 +10,7 @@ export function pastedCommand(text: string): Pasted | undefined {
10
10
  if (!arg) return undefined
11
11
  if (PLACEHOLDER.test(arg)) {
12
12
  return {
13
- lost: "The pasted requirements did not arrive whole. Write them to .gtd/TODO.md and run /gtd.",
13
+ lost: "The pasted requirements did not arrive whole. Add them to the top of .gtd/TODO.md and run /gtd.",
14
14
  }
15
15
  }
16
16
  return { arg }
@@ -17,6 +17,7 @@ import { catchFrom, throwTo } from "./handoff"
17
17
  import { jobs } from "./jobs"
18
18
  import { documents, readDocument } from "./schema"
19
19
  import { READ_ONLY_GIT, ship } from "./ship"
20
+ import { withRequirements } from "./sketch"
20
21
  import type { Shipped, ShipIo } from "./ship"
21
22
  import { collectAnswer } from "./delegate"
22
23
  import { decideCommand, decideContinue, decideJob, restId, TOOLS } from "./tools"
@@ -27,6 +28,7 @@ import {
27
28
  jobHeadline,
28
29
  jobPushText,
29
30
  pushText,
31
+ lostLine,
30
32
  restartLine,
31
33
  RELOAD_CUT_SCRIPT,
32
34
  runningLine,
@@ -114,6 +116,26 @@ async function send($: $, agentId: string, text: string) {
114
116
  return undefined
115
117
  }
116
118
 
119
+ // A subagent that no longer takes messages can leave its task listed with a
120
+ // frozen timer: stop it, and forget it as the scope's conversation. It must
121
+ // not stay `inflight` either, or a reload would land a turn it never ran.
122
+ async function forget($: $, memory: string, agentId: string) {
123
+ $.ui.log(lostLine(memory.split("#")[0]!))
124
+ accesses.delete(agentId)
125
+ await update($, scopes, (s) => {
126
+ const rest = { ...s }
127
+ if (rest[memory]?.agentId === agentId) delete rest[memory]
128
+ return rest
129
+ })
130
+ await update($, run, (r) => (r.inflight?.agentId === agentId ? { ...r, inflight: undefined } : r))
131
+ try {
132
+ await $.tool.call({ tool: "TaskStop", task_id: agentId })
133
+ } catch {
134
+ // already gone from the task list
135
+ }
136
+ ended.delete(agentId)
137
+ }
138
+
117
139
  async function settle($: $, end: TurnEnd) {
118
140
  await update($, run, (r) => ({ ...r, inflight: undefined }))
119
141
  return end
@@ -175,6 +197,7 @@ function io($: $): Io {
175
197
  await update($, run, (r) => ({ ...r, inflight: { agentId: known, memory: t.memory } }))
176
198
  const ended = await end
177
199
  if (ended) return settle($, ended)
200
+ await forget($, t.memory, known)
178
201
  }
179
202
  // No live conversation for this scope (fresh scope, or this session never
180
203
  // held it): start one, as the sh driver falls back to `--session-id`.
@@ -189,7 +212,7 @@ function io($: $): Io {
189
212
  cwd: root,
190
213
  })
191
214
  const id = spawned.agentId
192
- if (!id) return { ok: false, why: spawned.deny ?? "subagent spawn refused" }
215
+ if (!id) return settle($, { ok: false, why: spawned.deny ?? "subagent spawn refused" })
193
216
  ours.add(id)
194
217
  setAccess(id, t.access)
195
218
  await update($, agents, (list) => [...list, id].slice(-200))
@@ -200,7 +223,10 @@ function io($: $): Io {
200
223
  resume: async (memory, text) => {
201
224
  const id = (await read($, scopes))[memory]?.agentId
202
225
  const end = id ? await send($, id, text) : undefined
203
- return end ?? { ok: false, why: `no live subagent for ${memory}` }
226
+ if (end) return end
227
+ if (id) await forget($, memory, id)
228
+ else $.ui.log(lostLine(memory.split("#")[0]!))
229
+ return { ok: false, isLost: true, why: `no live subagent for ${memory}` }
204
230
  },
205
231
  progress: (beat, b) => {
206
232
  // The band is not drawn everywhere (Remote Control, the mobile app):
@@ -306,7 +332,8 @@ async function begin($: $, requirements: string) {
306
332
  const file = b.file ?? ".gtd/TODO.md"
307
333
  const path = `${root}/${file}`
308
334
  await $.process.run(["mkdir", "-p", path.slice(0, path.lastIndexOf("/"))])
309
- await $.fs.write(path, requirements + "\n")
335
+ const existing = (await $.fs.exists(path)) ? await $.fs.read(path) : ""
336
+ await $.fs.write(path, withRequirements(existing, requirements))
310
337
  await start($)
311
338
  return `Started a new gtd process from ${file}.`
312
339
  }
@@ -116,16 +116,30 @@ in a terminal. Plain prose is the right answer too when there is no shape to
116
116
  draw; then keep it to a paragraph, wrapped at 72 columns.
117
117
 
118
118
  No markdown headings, no code fences, no bullet list of changed files, no
119
- sign-off, and no footer trailers except one: when the instructions above
119
+ sign-off, and no footer trailers except two: when the instructions above
120
120
  say the change is breaking, end with a literal 'BREAKING CHANGE: <what
121
121
  breaks>' footer after a blank line. The release tooling reads only that
122
- footer, so a breaking change without it is never released as one.
122
+ footer, so a breaking change without it is never released as one. And when
123
+ the instructions above ask for a 'Closes #N' footer, end with it too — the
124
+ pull request is linked to its issue only through that line.
123
125
 
124
126
  NOTHING REVIEWS THIS BEFORE IT IS COMMITTED — no editor opens on it. What you
125
127
  print is the commit message verbatim.
126
128
 
127
129
  Print the message and nothing else — no code fences, no preamble.`
128
130
 
131
+ // `gtd summary --slice` asks only the process's last slice to close its issue;
132
+ // a group that absorbed that slice is the last commit all the same.
133
+ const ABSORBED = `
134
+ --- THIS IS THE PROCESS'S LAST COMMIT ---
135
+
136
+ The slices after this one changed nothing outside .gtd/ and fold into it. If
137
+ the process was started for an issue — its entry commit or
138
+ .gtd/REQUIREMENTS.md names one as #N, owner/repo#N or an issue URL — end the
139
+ message with a literal 'Closes #N' footer per issue. Name only an issue the
140
+ work resolves, never one it merely mentions.
141
+ `
142
+
129
143
  const DESCRIPTION = `The description is a STANDING statement, not a changelog. It covers exactly three
130
144
  things, and a reviewer reads it once:
131
145
 
@@ -247,7 +261,8 @@ function withWarning(r: Shipped, warning?: string): Shipped {
247
261
  type Git = (...args: string[]) => Promise<string>
248
262
  type Ok = (...args: string[]) => Promise<boolean>
249
263
 
250
- type Group = { name: string; base: string; tip: string }
264
+ // `absorbed`: the group took in a trailing .gtd/-only slice, so it closes the process.
265
+ type Group = { name: string; base: string; tip: string; absorbed?: boolean }
251
266
 
252
267
  // Slices that changed a file outside .gtd/ each land a commit; one that did not
253
268
  // folds into the next landed slice, or the previous when it is last.
@@ -263,7 +278,8 @@ async function landed(ok: Ok, slices: Slice[]) {
263
278
  groups.push({ name: s.name, base: carry ?? s.base, tip: s.tip })
264
279
  carry = undefined
265
280
  }
266
- if (carry && groups.length) groups.at(-1)!.tip = slices.at(-1)!.tip
281
+ if (carry && groups.length)
282
+ Object.assign(groups.at(-1)!, { tip: slices.at(-1)!.tip, absorbed: true })
267
283
  return groups
268
284
  }
269
285
 
@@ -304,7 +320,9 @@ async function commitMessages(io: ShipIo, git: Git, groups: Group[], tip: string
304
320
  return fail(
305
321
  `gtd summary --slice=${g.name} failed; nothing changed.\n${summary.err}`.trimEnd(),
306
322
  )
307
- const reply = await io.complete(summary.out + "\n" + COMMIT_FORMAT)
323
+ const reply = await io.complete(
324
+ summary.out + "\n" + (g.absorbed ? ABSORBED : "") + COMMIT_FORMAT,
325
+ )
308
326
  const message = reply && cleanReply(reply)
309
327
  if (!message) return fail("The commit-message turn produced nothing.")
310
328
  parts.push({ message, trailers })
@@ -1500,7 +1518,14 @@ async function shipLink(
1500
1518
  const pushed = await pushLink(io, git, l, i < links.length - 1)
1501
1519
  if (pushed) return { step: "push", why: pushed }
1502
1520
  const lowerRef = lower?.branch ?? baseRef
1503
- const ctx: Ctx = { to: l.commit, from: lower?.commit ?? mergeBase, below, stacked: true }
1521
+ const ctx: Ctx = {
1522
+ to: l.commit,
1523
+ from: lower?.commit ?? mergeBase,
1524
+ below,
1525
+ stacked: true,
1526
+ lower: i < links.length - 1,
1527
+ floor: mergeBase,
1528
+ }
1504
1529
  const done = await sliceStep(io, git, ok, l, view, base, lowerRef, ctx)
1505
1530
  if ("step" in done) return done
1506
1531
  if (done.number === undefined && i < links.length - 1)
@@ -1861,7 +1886,49 @@ const TITLE = /^(feat|fix|refactor|perf|docs|test|build|ci|chore)(\([\w./-]+\))?
1861
1886
  export const THROWN = "<!-- gtd:thrown -->"
1862
1887
 
1863
1888
  // unstacked: this run took the PR off a stack, so nothing new is still a success.
1864
- type Ctx = { from: string; to: string; stacked?: boolean; below?: number; unstacked?: boolean }
1889
+ // lower: a stacked slice below the top, whose PR merges first and must close nothing.
1890
+ type Ctx = {
1891
+ from: string
1892
+ to: string
1893
+ stacked?: boolean
1894
+ below?: number
1895
+ unstacked?: boolean
1896
+ lower?: boolean
1897
+ // The stack's floor: the top PR closes what every slice's commits close.
1898
+ floor?: string
1899
+ }
1900
+
1901
+ // [ \t], never \s: a reference on the next line is not what the keyword closes.
1902
+ const KEYWORD = String.raw`(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?[ \t]+`
1903
+ const URL = String.raw`https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/issues\/(\d+)`
1904
+ const REF = String.raw`(?:(?:[\w.-]+\/[\w.-]+)?#\d+|${URL})`
1905
+ const NEXT = String.raw`(?:[ \t]*,[ \t]*|[ \t]+and[ \t]+)(?:${KEYWORD})?`
1906
+ // A whole line of closing references, bulleted or not: `Closes #1.`,
1907
+ // `- Fixes #1, #2`, `Resolves o/r#3 and fixes <issue URL>`.
1908
+ const CLOSING = new RegExp(
1909
+ String.raw`^[ \t]*(?:[-*][ \t]+)?${KEYWORD}${REF}(?:${NEXT}${REF})*[ \t]*\.?[ \t]*$`,
1910
+ "gim",
1911
+ )
1912
+ const REFS = new RegExp(REF, "g")
1913
+
1914
+ const closed = (text: string) => {
1915
+ const refs = new Map<string, string>()
1916
+ for (const [line] of text.matchAll(CLOSING))
1917
+ for (const [whole, repo, n] of line.matchAll(REFS)) {
1918
+ const ref = repo ? `${repo}#${n}` : whole
1919
+ if (!refs.has(ref.toLowerCase())) refs.set(ref.toLowerCase(), ref)
1920
+ }
1921
+ return refs
1922
+ }
1923
+
1924
+ // The description prompt forbids footers, so the issues the commits close are
1925
+ // carried over here: a PR body without the keyword never links its issue.
1926
+ export function withCloses(body: string, log: string) {
1927
+ const has = closed(body)
1928
+ const add = [...closed(log)].filter(([key]) => !has.has(key)).map(([, ref]) => ref)
1929
+ if (!add.length) return body
1930
+ return [body.trimEnd(), add.map((ref) => `Closes ${ref}`).join("\n")].filter(Boolean).join("\n\n")
1931
+ }
1865
1932
 
1866
1933
  async function createPr(
1867
1934
  io: ShipIo,
@@ -1869,7 +1936,7 @@ async function createPr(
1869
1936
  branch: string,
1870
1937
  baseBranch: string,
1871
1938
  baseRef: string,
1872
- { from: mergeBase, to, stacked, below }: Ctx,
1939
+ { from: mergeBase, to, stacked, below, lower, floor }: Ctx,
1873
1940
  thrown?: { number: number; url: string },
1874
1941
  ): Promise<Shipped & { number?: number }> {
1875
1942
  const count = await git("rev-list", "--count", `${mergeBase}..${to}`)
@@ -1878,6 +1945,13 @@ async function createPr(
1878
1945
  ? `#${thrown.number} was thrown as a draft: describing ${count} commit(s) properly…`
1879
1946
  : `No pull request for ${branch} yet: describing ${count} commit(s)…`,
1880
1947
  )
1948
+ const log = await git(
1949
+ "log",
1950
+ "--no-merges",
1951
+ "--reverse",
1952
+ "--format=%H%n%B%n---",
1953
+ `${mergeBase}..${to}`,
1954
+ )
1881
1955
  const prompt = `Write the title and description for a pull request, from the commits below.
1882
1956
 
1883
1957
  Format, exactly:
@@ -1903,7 +1977,7 @@ Print the title and description and nothing else — no preamble, and no code
1903
1977
  fence wrapped around the whole reply.
1904
1978
 
1905
1979
  --- COMMITS ON ${branch} (against ${baseRef}), oldest first ---
1906
- ${await git("log", "--no-merges", "--reverse", "--format=%H%n%B%n---", `${mergeBase}..${to}`)}
1980
+ ${log}
1907
1981
 
1908
1982
  --- DIFF STAT ---
1909
1983
  ${await git("diff", "--stat", `${mergeBase}..${to}`)}`
@@ -1915,7 +1989,8 @@ ${await git("diff", "--stat", `${mergeBase}..${to}`)}`
1915
1989
  // a reply shaped like the title it was asked for.
1916
1990
  if (!TITLE.test(title))
1917
1991
  return fail(`The pull-request turn returned no usable title: ${title.slice(0, 120)}`)
1918
- const written = rest.join("\n").replace(/^\s*\n/, "")
1992
+ const text = rest.join("\n").replace(/^\s*\n/, "")
1993
+ const written = lower ? text : withCloses(text, floor ? await closingLog(git, floor, to) : log)
1919
1994
  const body = stacked ? stackLine(written, below) : written
1920
1995
  const head = await git("rev-parse", to)
1921
1996
  if (thrown) {
@@ -1974,39 +2049,63 @@ async function sinceHead(git: Git, ok: Ok, branch: string, mergeBase: string, to
1974
2049
  return isSynced ? synced : mergeBase
1975
2050
  }
1976
2051
 
1977
- async function updatePr(
2052
+ const closingLog = (git: Git, from: string, to: string) =>
2053
+ git("log", "--no-merges", "--format=%B%n---", `${from}..${to}`)
2054
+
2055
+ type Existing = { number: number; body?: string; url: string }
2056
+
2057
+ // The standing description re-derived: the stack line, and the issues the
2058
+ // branch closes, re-applied on every refresh but only written when changed.
2059
+ async function refresher(
1978
2060
  io: ShipIo,
1979
2061
  git: Git,
1980
- ok: Ok,
1981
- branch: string,
1982
- { from: mergeBase, to, stacked, below, unstacked }: Ctx,
1983
- existing: { number: number; body?: string; url: string },
1984
- ): Promise<Shipped & { number?: number }> {
1985
- const restack = (body: string) => (stacked ? stackLine(body, below) : body)
2062
+ { from: mergeBase, to, stacked, below, lower, floor }: Ctx,
2063
+ existing: Existing,
2064
+ ) {
2065
+ // The whole branch, not just what is new: a PR shipped before its commits
2066
+ // closed anything, or whose edit failed after the sync head moved, still gains them.
2067
+ const log = lower ? "" : await closingLog(git, floor ?? mergeBase, to)
2068
+ const restack = (raw: string) => {
2069
+ const body = withCloses(raw, log)
2070
+ return stacked ? stackLine(body, below) : body
2071
+ }
1986
2072
  const edit = async (body: string) =>
1987
2073
  io.run(["gh", "pr", "edit", String(existing.number), "--body-file", "-"], body)
1988
- // The stack line is re-applied on every refresh, but only written when it changed.
1989
- const restacked = async (pr: { body?: string }, text: string) => {
1990
- const fixed = restack(pr.body ?? "")
1991
- if (fixed === (pr.body ?? "")) return { ok: true, text, number: existing.number }
2074
+ const was = existing.body ?? ""
2075
+ const restacked = async (text: string) => {
2076
+ const fixed = restack(was)
2077
+ if (fixed === was) return { ok: true, text, number: existing.number }
1992
2078
  const edited = await edit(fixed)
1993
2079
  if (edited.code !== 0)
1994
2080
  return {
1995
2081
  ...fail(
1996
- `gh pr edit failed; the stack line of ${existing.url} is unchanged.\n${edited.err}`,
2082
+ `gh pr edit failed; the description of ${existing.url} is unchanged.\n${edited.err}`,
1997
2083
  ),
1998
2084
  number: existing.number,
1999
2085
  }
2000
2086
  return { ok: true, text, number: existing.number }
2001
2087
  }
2088
+ return { restack, edit, restacked, stale: restack(was) !== was }
2089
+ }
2090
+
2091
+ async function updatePr(
2092
+ io: ShipIo,
2093
+ git: Git,
2094
+ ok: Ok,
2095
+ branch: string,
2096
+ ctx: Ctx,
2097
+ existing: Existing,
2098
+ ): Promise<Shipped & { number?: number }> {
2099
+ const { from: mergeBase, to, stacked, unstacked } = ctx
2100
+ const { restack, edit, restacked, stale } = await refresher(io, git, ctx, existing)
2002
2101
  // Only what landed since the last run. A stored head a squash left behind
2003
2102
  // falls back to the whole branch, the only honest range left.
2004
2103
  const since = await sinceHead(git, ok, branch, mergeBase, to)
2005
2104
  // ^mergeBase keeps a synced head from before a main merge from re-weighing
2006
2105
  // the commits that merge brought in.
2007
2106
  const count = Number(await git("rev-list", "--count", `${since}..${to}`, `^${mergeBase}`))
2008
- if (!count && (stacked || unstacked))
2009
- return restacked(existing, `Nothing new since the last ship: ${existing.url}`)
2107
+ if (!count && (stacked || unstacked || stale))
2108
+ return restacked(`Nothing new since the last ship: ${existing.url}`)
2010
2109
  if (!count) return fail(`No commits since the last ship: ${existing.url}`)
2011
2110
  io.log(`#${existing.number}: weighing ${count} commit(s) against the standing description…`)
2012
2111
 
@@ -2051,7 +2150,6 @@ ${await git("diff", "--stat", `${mergeBase}..${to}`)}`
2051
2150
  if (/^\s*NO-UPDATE\s*$/.test(out)) {
2052
2151
  await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, sha])
2053
2152
  return restacked(
2054
- existing,
2055
2153
  `Nothing at that level changed; the description of ${existing.url} is left alone.`,
2056
2154
  )
2057
2155
  }
@@ -0,0 +1,6 @@
1
+ // New requirements go above what the steering file already holds, untouched:
2
+ // a deferred review note the start diff alters reads as the human's edit.
3
+ export function withRequirements(existing: string, requirements: string): string {
4
+ const head = `${requirements.trimEnd()}\n`
5
+ return existing.trim() ? `${head}\n${existing}` : head
6
+ }
@@ -50,6 +50,8 @@ export const beatLine = (beat: number, b: Beat) => `▸ ${step(b)} · step ${bea
50
50
  export const restartLine = (scope: string) =>
51
51
  `${scope}: skills or system changed — started a fresh subagent`
52
52
 
53
+ export const lostLine = (scope: string) => `${scope}: subagent lost`
54
+
53
55
  // What a push to a phone says when gtd stops: the step, and where to act on it.
54
56
  export function pushText(stop: Stop, repo: string, url?: string) {
55
57
  const at = `${repo}: ${headline(stop).replace(/^\S+ /, "")}`
@@ -770,9 +770,13 @@ ${fixFeedbackPrompt}
770
770
  - Leave everything uncommitted and finish your turn — do not commit
771
771
  `;
772
772
  const refactorRules = (strays, pattern) => `- Behaviour-neutral: the suite's outcome must not change
773
- - Change only these paths, nothing else, and never a test file even
774
- when it is one of them:
773
+ - Change only these paths, nothing else:
775
774
  ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
775
+ - An existing test file only where a changed call shape reaches it:
776
+ update its call sites, inside an assertion too;
777
+ never add or delete a test file,
778
+ never add, remove or alter a test or an assertion, and never
779
+ change what an assertion checks, an expected value or a snapshot
776
780
  - Introduce no new flag read and add no second read of an existing one:
777
781
  every match of \`${pattern}\` may move, never increase in number`;
778
782
  const withRules = (rules) => rules === void 0 ? "" : `${rules}\n`;
@@ -782,8 +786,13 @@ ${feedback ? `- The package carries a \`## Review feedback\` section: a human's
782
786
  last refactor. Act on it, within the rules below
783
787
  ` : ""}${refactorRules(strays, pattern)}
784
788
  ${reference === void 0 ? "" : `- Reference only: \`git show ${reference} -- ${strays.map(quote).join(" ")}\` shows an
785
- earlier attempt at this change. Read it for ideas; never apply,
786
- cherry-pick or check it out — write a fresh refactor
789
+ earlier attempt at this change, and \`git show --stat ${reference}\`
790
+ lists every file it touched, the test call sites included.
791
+ Read it; never apply, cherry-pick or check it out
792
+ - Its changes to these paths' existing code are the seam: write that
793
+ restructuring afresh here, minus the feature's own behaviour. A
794
+ refactor that touches none of it is wrong, however clean — never
795
+ swap in an unrelated tidy-up
787
796
  `}- Leave everything uncommitted and finish your turn — do not commit
788
797
  `;
789
798
  const refactorFixPrompt = (strays, pattern) => `${stateFileRules}
@@ -1099,11 +1108,11 @@ const closedFold = (round, target) => round === "edits" ? `- Fold every hand-edi
1099
1108
  /** `refactor` retargets the prompt at `.gtd/REFACTOR.md` and fences it to the strays. A concern outside the fence is not folded; if nothing at all is written the landing is refused, but a partly buildable round drops the rest silently. */
1100
1109
  const buildReviewCollectingPrompt = (capture, refactor) => {
1101
1110
  const target = refactor ? ".gtd/REFACTOR.md" : ".gtd/REQUIREMENTS.md";
1102
- const fence = refactor ? `- The next refactor may change only these paths, and never a test file
1103
- or a flag read:
1111
+ const fence = refactor ? `- The next refactor may change only these paths, plus an existing test
1112
+ file's call sites, and never a flag read:
1104
1113
  ${refactor.strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
1105
- A concern needing any other path, a test file, a new or added flag
1106
- read, or a change in behaviour (that belongs to the feature, not this
1114
+ A concern needing any other path, a new or changed test or assertion,
1115
+ a new or added flag read, or a change in behaviour (that belongs to the feature, not this
1107
1116
  refactor) cannot be built: do not fold it
1108
1117
  ` : "";
1109
1118
  const actionable = refactor ? `The round is actionable if the human left a note on
@@ -1207,7 +1216,7 @@ ${removed.map(({ path, lines }) => lines.map((a) => ` ${path} — ${a.scenari
1207
1216
  const lapRange = {
1208
1217
  first: "it covers everything since the base.",
1209
1218
  feedback: "it starts at the previous review's boundary, so it covers only what is new since the previous review.",
1210
- refactor: "it covers the whole refactor since it started, on every round."
1219
+ refactor: "it covers the whole refactor since it started, on every round. A refactor may update an existing test's call sites only: give any test-file hunk its own chunk, and say so if it changes a test, an assertion or an expected value."
1211
1220
  };
1212
1221
  const buildReviewReviewingPrompt = (base, lap, carry, removed = []) => `${styleBlock}
1213
1222
 
@@ -1324,7 +1333,12 @@ ${slice === void 0 ? `- Write the closing message for the process HEAD closes or
1324
1333
  the exact escape or rewrite a committed config needs. A \`!\` on the
1325
1334
  type prefix alone (\`feat!:\`/\`refactor!:\`) is NOT enough: this repo's
1326
1335
  own commit-analyzer has missed that marker before, cutting no release
1327
- until a follow-up commit carried the literal footer instead
1336
+ until a follow-up commit carried the literal footer instead${slice === void 0 || slice.index === slice.count ? `
1337
+ - If the process was started for an issue — its entry commit or
1338
+ \`.gtd/REQUIREMENTS.md\` names one as \`#N\`, \`owner/repo#N\` or an issue
1339
+ URL — end the message with a literal \`Closes #N\` footer per issue
1340
+ (\`Closes owner/repo#N\` for another repository). Name only an issue the
1341
+ work resolves, never one it merely mentions` : ""}
1328
1342
 
1329
1343
  ${slice !== void 0 && slice.index > 1 ? `The process's entry commit is \`${it.entryCommit}\` — outside this slice's range, context
1330
1344
  that states the motivation. ` : `The process's entry commit is \`${it.entryCommit}\`. `}${human}Inspect the range: \`git log ${it.processBase}..${it.processTip}\`
@@ -1,6 +1,6 @@
1
1
  import { read, refuse, removeScript, run, scope, showScript, vars } from "../flows.mjs";
2
2
  import { fixFindings, lensesOf, qualityLap as qualityLap$1, reviewToHandOff } from "../workflow-kit.mjs";
3
- import { E as reviewing, S as fixRisks, T as reviewQuality, W as handOffAwaitReviewMessage, _ as REVIEW, b as fixCheck, c as escalation, d as healthy, m as QUALITY, u as guarded, v as awaitReview, w as reviewMissing, x as fixQuality } from "./reserved-BzeofpoC.mjs";
3
+ import { E as reviewing, S as fixRisks, T as reviewQuality, W as handOffAwaitReviewMessage, _ as REVIEW, b as fixCheck, c as escalation, d as healthy, m as QUALITY, u as guarded, v as awaitReview, w as reviewMissing, x as fixQuality } from "./reserved-BduvUQxQ.mjs";
4
4
  //#region src/workflows/shared/tail.ts
5
5
  /** The reviewer's own `.gtd/REVIEW.md`, kept beside the human's notes so `fix` can tell them apart. */
6
6
  const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
@@ -1,6 +1,6 @@
1
1
  import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
2
- import { _t as registryEntry, a as flagShipped, dt as defaults, ft as envDefaults, ht as flagReferences, i as skills, n as summary, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-BzeofpoC.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-eS_ZSImR.mjs";
2
+ import { _t as registryEntry, a as flagShipped, dt as defaults, ft as envDefaults, ht as flagReferences, i as skills, n as summary, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-BduvUQxQ.mjs";
3
+ import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-pgq1Fbbv.mjs";
4
4
  //#region src/workflows/cleanup/cleanup.ts
5
5
  const removingPrompt = (name, registry) => `Clean up the release flag "${name}", which is default-on: delete its entry in ${registry}, and remove every \`GTD_FLAG_${name.toUpperCase()}\` check from the shipped sources. Keep the flag-on path; delete code only the off path reached, including tests that pin the off path.`;
6
6
  const cleanupFlow = async () => {