@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +32 -29
- package/claude/hooks/drive.ts +36 -22
- package/claude/hooks/paste.ts +1 -1
- package/claude/hooks/register.tsx +30 -3
- package/claude/hooks/ship.ts +123 -25
- package/claude/hooks/sketch.ts +6 -0
- package/claude/hooks/wording.ts +2 -0
- package/dist/chunks/{reserved-BzeofpoC.mjs → reserved-BduvUQxQ.mjs} +24 -10
- package/dist/chunks/{tail-eS_ZSImR.mjs → tail-pgq1Fbbv.mjs} +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +44 -8
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +66 -16
- package/package.json +1 -1
- package/src/workflows/feature/refactor-review.feature +37 -3
- package/src/workflows/feature/refactor.test.ts +183 -16
- package/src/workflows/feature/refactor.ts +66 -8
- package/src/workflows/shared/text.test.ts +30 -7
- package/src/workflows/shared/text.ts +31 -10
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.
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
the
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
package/claude/hooks/drive.ts
CHANGED
|
@@ -44,7 +44,8 @@ export type Turn = {
|
|
|
44
44
|
access?: AccessDef
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
package/claude/hooks/paste.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/claude/hooks/ship.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
const
|
|
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
|
-
|
|
1989
|
-
const restacked = async (
|
|
1990
|
-
const fixed = restack(
|
|
1991
|
-
if (fixed ===
|
|
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
|
|
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(
|
|
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
|
+
}
|
package/claude/hooks/wording.ts
CHANGED
|
@@ -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
|
|
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
|
|
786
|
-
|
|
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,
|
|
1103
|
-
|
|
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
|
|
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-
|
|
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-
|
|
3
|
-
import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-
|
|
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 () => {
|