@pmelab/gtd 27.7.0 → 27.7.1
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 +22 -12
- package/claude/hooks/assist.ts +35 -2
- package/claude/hooks/delegate.ts +22 -0
- package/claude/hooks/handoff.ts +13 -4
- package/claude/hooks/jobs.ts +67 -0
- package/claude/hooks/paste.ts +17 -0
- package/claude/hooks/register.tsx +126 -65
- package/claude/hooks/ship.ts +7 -2
- package/claude/hooks/tools.ts +32 -0
- package/claude/hooks/wording.ts +11 -0
- package/dist/chunks/{reserved-Df8GeSHw.mjs → reserved-UdSuHDUJ.mjs} +2 -2
- package/dist/chunks/{tail-DmIfEAI4.mjs → tail-fj60rQ3y.mjs} +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +3 -3
- 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 +3 -3
- package/package.json +1 -1
- package/src/workflows/feature/review.test.ts +9 -3
- package/src/workflows/feature/review.ts +3 -3
- package/src/workflows/shared/steps.ts +1 -2
- package/src/workflows/shared/tail.test.ts +18 -9
package/README.md
CHANGED
|
@@ -324,18 +324,26 @@ 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
|
-
|
|
338
|
-
|
|
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` and
|
|
331
|
+
`/gtd review [base]` take the two doors described below. At every human rest
|
|
332
|
+
Claude itself takes over: it walks you through the open questions one at a time,
|
|
333
|
+
answers your own questions about the plan or the diff, records what you decide
|
|
334
|
+
in the steering file the way the step asks, and moves the process on only when
|
|
335
|
+
you say so; a review you leave untouched signs off. While a step runs you can
|
|
336
|
+
keep asking; requests for changes wait for the next rest. When the process
|
|
337
|
+
finishes, Claude offers to squash it into one commit and open its pull request
|
|
338
|
+
(or run `/gtd ship`). A process can change hands at any gate: ask Claude to hand
|
|
339
|
+
it off (or run `/gtd throw @dev`) and it opens a draft pull request assigned to
|
|
340
|
+
them, and `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote
|
|
341
|
+
the requirements can hand the architecture to someone else. Ship, hand-off and
|
|
342
|
+
throw answer at once and run in the background, with progress on the status
|
|
343
|
+
line; they report by waking Claude, a toast, a transcript line and a push
|
|
344
|
+
notification, and a failed push or `gh` call reports as a failure. While a ship
|
|
345
|
+
or hand-off runs or is queued, `/gtd catch`, the doors, `/gtd <requirements>`
|
|
346
|
+
and bare `/gtd` wait for its report. See
|
|
339
347
|
[Inside Claude Code](https://github.com/pmelab/gtd/blob/main/docs/driver.md#inside-claude-code-the-gtd-mod).
|
|
340
348
|
|
|
341
349
|
### Then let an agent build your own
|
|
@@ -577,6 +585,8 @@ Two more bundled workflows retire a release flag:
|
|
|
577
585
|
`gtd --workflow cleanup --var flag=<name>` removes it afterwards. See
|
|
578
586
|
[Feature flags](docs/configuration.md#rolling-out-and-cleaning-up-a-release-flag).
|
|
579
587
|
|
|
588
|
+
The delivery model these workflows implement: [Delivery model](SDLC.md).
|
|
589
|
+
|
|
580
590
|
## Checking a range for stray files
|
|
581
591
|
|
|
582
592
|
```sh
|
package/claude/hooks/assist.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import type { Stop } from "../types"
|
|
7
7
|
import { RELOAD_CUT_SCRIPT } from "./wording"
|
|
8
|
-
import {
|
|
8
|
+
import type { Shipped } from "./ship"
|
|
9
|
+
import { JOB_NAME, TOOL } from "./tools"
|
|
10
|
+
import type { JobName } from "./tools"
|
|
9
11
|
|
|
10
12
|
export type Asked = {
|
|
11
13
|
id: string
|
|
@@ -35,7 +37,13 @@ export function wakes(stop: Stop, branch: string | undefined): boolean {
|
|
|
35
37
|
|
|
36
38
|
const step = (s: Stop) => s.label ?? s.state ?? "the current step"
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
// The text can come from commit messages anyone on the branch wrote: the fence
|
|
41
|
+
// is longer than any run of ~ inside it, so the text cannot close it early.
|
|
42
|
+
const fence = (text: string) => {
|
|
43
|
+
const body = text.trim()
|
|
44
|
+
const bar = "~".repeat(Math.max(4, ...[...body.matchAll(/~+/g)].map((m) => m[0].length + 1)))
|
|
45
|
+
return `${bar}text\n${body}\n${bar}`
|
|
46
|
+
}
|
|
39
47
|
|
|
40
48
|
const RULES = `Ground rules:
|
|
41
49
|
- gtd decides what happens next. Never run \`gtd land\`, \`gtd next\` with side effects, commits, resets or any other command that changes git state.
|
|
@@ -133,3 +141,28 @@ export function wakePrompt(w: Wake): string {
|
|
|
133
141
|
const extras = `Only when the person asks: ${TOOL.handoff} hands the process to someone else, ${TOOL.browser} opens gtd's browser view of this step.`
|
|
134
142
|
return [...body, ...notes, RULES, extras].join("\n\n")
|
|
135
143
|
}
|
|
144
|
+
|
|
145
|
+
// The wake after a background ship or hand-off ended: the model only relays it.
|
|
146
|
+
export function jobPrompt(job: JobName, outcome: Shipped): string {
|
|
147
|
+
const name = JOB_NAME[job]
|
|
148
|
+
if (outcome.ok) {
|
|
149
|
+
return [
|
|
150
|
+
`The ${name} the person asked for finished. gtd's report:`,
|
|
151
|
+
fence(outcome.text),
|
|
152
|
+
"Relay the result to the person in a line or two, with any link in it. Do nothing else.",
|
|
153
|
+
].join("\n\n")
|
|
154
|
+
}
|
|
155
|
+
const lines = [
|
|
156
|
+
`The ${name} the person asked for failed. gtd reports the failed step:`,
|
|
157
|
+
fence(outcome.text),
|
|
158
|
+
]
|
|
159
|
+
if (job === "ship") {
|
|
160
|
+
lines.push(
|
|
161
|
+
`If the commits were already squashed, they stay squashed: running /gtd ship again pushes the already-squashed branch.`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
lines.push(
|
|
165
|
+
"Tell the person what failed in plain words and what to try. Do not retry it yourself.",
|
|
166
|
+
)
|
|
167
|
+
return lines.join("\n\n")
|
|
168
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type AnswerIo = {
|
|
2
|
+
ended: Promise<{ ok: boolean }>
|
|
3
|
+
deadline: Promise<unknown>
|
|
4
|
+
read(): Promise<string>
|
|
5
|
+
remove(): Promise<unknown>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function collectAnswer(io: AnswerIo): Promise<string | undefined> {
|
|
9
|
+
const ended = await Promise.race([io.ended, io.deadline.then(() => undefined)])
|
|
10
|
+
if (!ended) {
|
|
11
|
+
// The agent may still write the file: remove it only once its turn ends.
|
|
12
|
+
void io.ended.then(io.remove).catch(() => undefined)
|
|
13
|
+
return undefined
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return ended.ok ? await io.read() : undefined
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined
|
|
19
|
+
} finally {
|
|
20
|
+
await io.remove()
|
|
21
|
+
}
|
|
22
|
+
}
|
package/claude/hooks/handoff.ts
CHANGED
|
@@ -43,11 +43,17 @@ export async function throwTo(
|
|
|
43
43
|
if (assigned) return fail(assigned)
|
|
44
44
|
}
|
|
45
45
|
const to = handle ? `to @${handle}` : "to anyone"
|
|
46
|
-
await io.run(
|
|
46
|
+
const commented = await io.run(
|
|
47
47
|
["gh", "pr", "comment", String(pr.number), "--body-file", "-"],
|
|
48
48
|
`Thrown ${to} at **${at}**. Catch it with \`/gtd catch ${on.branch}\`.\n`,
|
|
49
49
|
)
|
|
50
|
-
|
|
50
|
+
// The hand-off already happened: failing here would keep the run open locally
|
|
51
|
+
// while the pull request is someone else's.
|
|
52
|
+
const warning =
|
|
53
|
+
commented.code === 0
|
|
54
|
+
? ""
|
|
55
|
+
: `\nWarning: gh pr comment failed; no catch hint was posted.\n${commented.err}`
|
|
56
|
+
return { ok: true, text: `Thrown ${to}: ${pr.url}${warning}` }
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
// The catcher checks out what origin holds, so a throw describing local HEAD
|
|
@@ -141,8 +147,11 @@ async function draft(
|
|
|
141
147
|
// A pull request someone opened for review keeps its own description and
|
|
142
148
|
// stays ready: only ship marks a draft ready again, and only a thrown one.
|
|
143
149
|
if (pr.body?.includes(THROWN)) {
|
|
144
|
-
await io.run(["gh", "pr", "edit", String(pr.number), "--body-file", "-"], body)
|
|
145
|
-
|
|
150
|
+
const edited = await io.run(["gh", "pr", "edit", String(pr.number), "--body-file", "-"], body)
|
|
151
|
+
if (edited.code !== 0) return `gh pr edit failed; the pull request is unchanged.\n${edited.err}`
|
|
152
|
+
const undone = await io.run(["gh", "pr", "ready", String(pr.number), "--undo"])
|
|
153
|
+
if (undone.code !== 0)
|
|
154
|
+
return `gh pr ready --undo failed; ${pr.url} is not a draft again.\n${undone.err}`
|
|
146
155
|
}
|
|
147
156
|
return pr
|
|
148
157
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The one background slot ship and hand-off share: both push the branch, so
|
|
2
|
+
// two at once would race. No global deadline on purpose: a deadline that freed
|
|
3
|
+
// the slot while the work still ran would let a second push overwrite the first.
|
|
4
|
+
|
|
5
|
+
import type { Shipped } from "./ship"
|
|
6
|
+
import type { JobName } from "./tools"
|
|
7
|
+
import { JOB_NAME } from "./tools"
|
|
8
|
+
|
|
9
|
+
export type Jobs = {
|
|
10
|
+
// Claims the slot now; `work` starts at once or, `isInTurn`, at the next
|
|
11
|
+
// `flush`. `done` runs when it ends, either way, and owns the job's effect
|
|
12
|
+
// and report. Answers at once: the started text, or a refusal when a job is
|
|
13
|
+
// running or queued.
|
|
14
|
+
start(
|
|
15
|
+
job: JobName,
|
|
16
|
+
work: () => Promise<Shipped>,
|
|
17
|
+
isInTurn: boolean,
|
|
18
|
+
done: (outcome: Shipped) => Promise<void>,
|
|
19
|
+
): string
|
|
20
|
+
flush(): void
|
|
21
|
+
busy(): JobName | undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function jobs(): Jobs {
|
|
25
|
+
let current:
|
|
26
|
+
| {
|
|
27
|
+
job: JobName
|
|
28
|
+
work: () => Promise<Shipped>
|
|
29
|
+
done: (outcome: Shipped) => Promise<void>
|
|
30
|
+
isRunning: boolean
|
|
31
|
+
}
|
|
32
|
+
| undefined
|
|
33
|
+
|
|
34
|
+
const launch = () => {
|
|
35
|
+
const slot = current
|
|
36
|
+
if (!slot || slot.isRunning) return
|
|
37
|
+
slot.isRunning = true
|
|
38
|
+
void (async () => {
|
|
39
|
+
let outcome: Shipped
|
|
40
|
+
try {
|
|
41
|
+
outcome = await slot.work()
|
|
42
|
+
} catch (err) {
|
|
43
|
+
outcome = { ok: false, text: err instanceof Error ? err.message : String(err) }
|
|
44
|
+
} finally {
|
|
45
|
+
current = undefined
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
await slot.done(outcome)
|
|
49
|
+
} catch {
|
|
50
|
+
// a failed report must not become an unhandled rejection
|
|
51
|
+
}
|
|
52
|
+
})()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
start(job, work, isInTurn, done) {
|
|
57
|
+
if (current) {
|
|
58
|
+
return `A ${JOB_NAME[current.job]} is already being prepared; wait for its report.`
|
|
59
|
+
}
|
|
60
|
+
current = { job, work, done, isRunning: false }
|
|
61
|
+
if (!isInTurn) launch()
|
|
62
|
+
return `The ${JOB_NAME[job]} has started in the background; its outcome is reported here when it ends.`
|
|
63
|
+
},
|
|
64
|
+
flush: launch,
|
|
65
|
+
busy: () => current?.job,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type Pasted = { arg: string } | { lost: string }
|
|
2
|
+
|
|
3
|
+
const PLACEHOLDER = /\[Pasted text #\d+[^\]]*\]/
|
|
4
|
+
|
|
5
|
+
// The mod cannot recover an unexpanded paste: Claude Code's paste cache is keyed
|
|
6
|
+
// by a hash it does not expose.
|
|
7
|
+
export function pastedCommand(text: string): Pasted | undefined {
|
|
8
|
+
const m = /^\/gtd\s+([\s\S]*)$/.exec(text)
|
|
9
|
+
const arg = m?.[1]?.trim()
|
|
10
|
+
if (!arg) return undefined
|
|
11
|
+
if (PLACEHOLDER.test(arg)) {
|
|
12
|
+
return {
|
|
13
|
+
lost: "The pasted requirements did not arrive whole. Write them to .gtd/TODO.md and run /gtd.",
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return { arg }
|
|
17
|
+
}
|
|
@@ -4,22 +4,28 @@ import type { EngineInterface, Register } from "claude-code"
|
|
|
4
4
|
import type { Run, Stop } from "../types"
|
|
5
5
|
import { accessDenial, mainDenial } from "./access"
|
|
6
6
|
import type { AccessDef } from "./access"
|
|
7
|
-
import { wakePrompt, wakes } from "./assist"
|
|
7
|
+
import { jobPrompt, wakePrompt, wakes } from "./assist"
|
|
8
8
|
import { afterReload, drive, isTrue } from "./drive"
|
|
9
9
|
import type { Io, Opening, Turn, TurnEnd } from "./drive"
|
|
10
10
|
import { doors, enter } from "./doors"
|
|
11
11
|
import { subagentModel } from "./models"
|
|
12
|
+
import { pastedCommand } from "./paste"
|
|
12
13
|
import { personaSpec, resumable } from "./persona"
|
|
13
14
|
import { JUDGE_SYSTEM, judgePrompt, judgeQuestions, judgeRunEnv, toVerdicts } from "./judge"
|
|
14
15
|
import type { Judgment } from "./judge"
|
|
15
16
|
import { catchFrom, throwTo } from "./handoff"
|
|
17
|
+
import { jobs } from "./jobs"
|
|
16
18
|
import { documents, readDocument } from "./schema"
|
|
17
19
|
import { READ_ONLY_GIT, ship } from "./ship"
|
|
18
|
-
import type { ShipIo } from "./ship"
|
|
19
|
-
import {
|
|
20
|
+
import type { Shipped, ShipIo } from "./ship"
|
|
21
|
+
import { collectAnswer } from "./delegate"
|
|
22
|
+
import { decideCommand, decideContinue, decideJob, restId, TOOLS } from "./tools"
|
|
23
|
+
import type { JobName } from "./tools"
|
|
20
24
|
import {
|
|
21
25
|
beatLine,
|
|
22
26
|
headline,
|
|
27
|
+
jobHeadline,
|
|
28
|
+
jobPushText,
|
|
23
29
|
pushText,
|
|
24
30
|
restartLine,
|
|
25
31
|
RELOAD_CUT_SCRIPT,
|
|
@@ -52,7 +58,6 @@ let stepStartedAt = Date.now()
|
|
|
52
58
|
// Set before the first await of `start`, so two Continue presses arriving
|
|
53
59
|
// together cannot both pass the isRunning check and start two drivers.
|
|
54
60
|
let isDriving = false
|
|
55
|
-
let isShipping = false
|
|
56
61
|
// bumped by every stop, so a queued continue that was mid-check sees it
|
|
57
62
|
let stopCount = 0
|
|
58
63
|
// subagents answering into a file: what each may touch (see tool.call)
|
|
@@ -219,7 +224,7 @@ async function head($: $) {
|
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
async function start($: $, opening: Opening = {}) {
|
|
222
|
-
if (isDriving) return
|
|
227
|
+
if (isDriving || background.busy()) return
|
|
223
228
|
isDriving = true
|
|
224
229
|
closeUi()
|
|
225
230
|
moveOn($)
|
|
@@ -371,45 +376,75 @@ async function browse($: $) {
|
|
|
371
376
|
|
|
372
377
|
// A gate is news even when nobody watches the terminal: a push reaches the
|
|
373
378
|
// phone over Remote Control, and the engine drops it while the person is here.
|
|
379
|
+
const repoName = () => root.split("/").pop() ?? "gtd"
|
|
380
|
+
|
|
374
381
|
async function push($: $, stop: Stop, url?: string) {
|
|
375
|
-
|
|
382
|
+
await notify($, pushText(stop, repoName(), url))
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function notify($: $, message: string) {
|
|
376
386
|
try {
|
|
377
|
-
await $.tool.call({
|
|
378
|
-
tool: "PushNotification",
|
|
379
|
-
message: pushText(stop, repo, url),
|
|
380
|
-
status: "proactive",
|
|
381
|
-
})
|
|
387
|
+
await $.tool.call({ tool: "PushNotification", message, status: "proactive" })
|
|
382
388
|
} catch {
|
|
383
389
|
// no push channel: the dialog and the band still say it
|
|
384
390
|
}
|
|
385
391
|
}
|
|
386
392
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
(
|
|
393
|
+
const background = jobs()
|
|
394
|
+
|
|
395
|
+
// A job asked from the model's tool call waits for that turn to end; one asked
|
|
396
|
+
// from the command starts right after, off the hook, so the command answers in
|
|
397
|
+
// budget. Either way the slot is claimed now, with the job's own effect.
|
|
398
|
+
function startJob(
|
|
399
|
+
$: $,
|
|
400
|
+
job: JobName,
|
|
401
|
+
isInTurn: boolean,
|
|
402
|
+
work: () => Promise<Shipped>,
|
|
403
|
+
then?: (outcome: Shipped) => Promise<void>,
|
|
404
|
+
) {
|
|
405
|
+
const answer = background.start(job, work, true, async (outcome) => {
|
|
406
|
+
if (then) await then(outcome)
|
|
407
|
+
$.ui.status(undefined)
|
|
408
|
+
for (const line of outcome.text.split("\n")) $.ui.log(line)
|
|
409
|
+
$.ui.toast(jobHeadline(job, outcome))
|
|
410
|
+
await notify($, jobPushText(job, outcome, repoName()))
|
|
411
|
+
const text = jobPrompt(job, outcome)
|
|
412
|
+
$.clock.after(0, () => void $.prompt.submit({ text }))
|
|
413
|
+
})
|
|
414
|
+
if (!isInTurn) $.clock.after(0, () => background.flush())
|
|
415
|
+
return answer
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function throwNow($: $, target: string | undefined, isInTurn: boolean) {
|
|
419
|
+
const refused = decideJob(await read($, run), "handoff")
|
|
420
|
+
if (refused) return refused
|
|
421
|
+
return startJob(
|
|
422
|
+
$,
|
|
423
|
+
"handoff",
|
|
424
|
+
isInTurn,
|
|
425
|
+
async () => {
|
|
426
|
+
closeUi()
|
|
427
|
+
moveOn($)
|
|
428
|
+
const b = await docs($).next()
|
|
429
|
+
$.ui.status("handing off…")
|
|
430
|
+
const rest = {
|
|
431
|
+
state: b.state,
|
|
432
|
+
label: b.label,
|
|
433
|
+
content: b.content,
|
|
434
|
+
isIdle: isTrue(b.idle),
|
|
435
|
+
}
|
|
436
|
+
return throwTo(shipIo($), rest, target, new Date().toISOString())
|
|
437
|
+
},
|
|
438
|
+
async (thrown) => {
|
|
439
|
+
if (!thrown.ok) return
|
|
440
|
+
await update($, run, (r) => ({
|
|
441
|
+
...r,
|
|
442
|
+
url: undefined,
|
|
443
|
+
uiNote: undefined,
|
|
444
|
+
stop: { kind: "stopped" as const, text: thrown.text },
|
|
445
|
+
}))
|
|
446
|
+
},
|
|
401
447
|
)
|
|
402
|
-
$.ui.status(undefined)
|
|
403
|
-
for (const line of thrown.text.split("\n")) $.ui.log(line)
|
|
404
|
-
if (thrown.ok) {
|
|
405
|
-
await update($, run, (r) => ({
|
|
406
|
-
...r,
|
|
407
|
-
url: undefined,
|
|
408
|
-
uiNote: undefined,
|
|
409
|
-
stop: { kind: "stopped" as const, text: thrown.text },
|
|
410
|
-
}))
|
|
411
|
-
}
|
|
412
|
-
return thrown.text
|
|
413
448
|
}
|
|
414
449
|
|
|
415
450
|
async function enterNow($: $, door: string, args: string[]) {
|
|
@@ -499,15 +534,14 @@ async function delegate($: $, who: Delegate, prompt: string, model: string, deli
|
|
|
499
534
|
if (!id) return undefined
|
|
500
535
|
ours.add(id)
|
|
501
536
|
delegates.set(id, { file, mayRun: who.mayRun })
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
}
|
|
537
|
+
// A timed-out agent may still be running: it keeps its tool limits until its
|
|
538
|
+
// turn ends, where turn.complete drops them.
|
|
539
|
+
return collectAnswer({
|
|
540
|
+
ended: turnEnd(id),
|
|
541
|
+
deadline: $.clock.sleep(TEN_MINUTES),
|
|
542
|
+
read: () => $.fs.read(file),
|
|
543
|
+
remove: () => $.process.run(["rm", "-f", file]),
|
|
544
|
+
})
|
|
511
545
|
}
|
|
512
546
|
|
|
513
547
|
async function writer($: $, prompt: string) {
|
|
@@ -544,25 +578,28 @@ function shipIo($: $): ShipIo {
|
|
|
544
578
|
complete: (prompt) => writer($, prompt),
|
|
545
579
|
log: (line) => {
|
|
546
580
|
for (const l of line.split("\n")) $.ui.log(l)
|
|
581
|
+
if (background.busy()) $.ui.status(line.split("\n")[0]!)
|
|
547
582
|
},
|
|
548
583
|
today: () => new Date().toISOString().slice(0, 10),
|
|
549
584
|
}
|
|
550
585
|
}
|
|
551
586
|
|
|
552
|
-
async function shipNow($: $, isDry: boolean) {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
587
|
+
async function shipNow($: $, isDry: boolean, isInTurn: boolean) {
|
|
588
|
+
const refused = decideJob(await read($, run), "ship")
|
|
589
|
+
if (refused) return refused
|
|
590
|
+
return startJob(
|
|
591
|
+
$,
|
|
592
|
+
"ship",
|
|
593
|
+
isInTurn,
|
|
594
|
+
async () => {
|
|
595
|
+
$.ui.status(isDry ? "previewing the pull request…" : "opening the pull request…")
|
|
596
|
+
return ship(shipIo($), isDry)
|
|
597
|
+
},
|
|
598
|
+
async (shipped) => {
|
|
599
|
+
// a failed ship leaves the run as it was; a dry run changes nothing
|
|
600
|
+
if (shipped.ok && !isDry) await dismiss($)
|
|
601
|
+
},
|
|
602
|
+
)
|
|
566
603
|
}
|
|
567
604
|
|
|
568
605
|
// Turns a missing CLI or a wrong directory into an instruction, not a stack trace.
|
|
@@ -641,6 +678,7 @@ async function proceed(
|
|
|
641
678
|
const go = await decideContinue(r, rest, verdicts, {
|
|
642
679
|
current: () => currentRest($, r.stop?.state),
|
|
643
680
|
validate: () => validateRest($),
|
|
681
|
+
busy: background.busy(),
|
|
644
682
|
judgment: async () => {
|
|
645
683
|
const doc = await gtd($, ["judge", "--json"]).catch(() => undefined)
|
|
646
684
|
return doc === undefined ? undefined : readDocument<Judgment>("judge --json", doc)
|
|
@@ -765,6 +803,8 @@ async function stopNow($: $) {
|
|
|
765
803
|
// `/gtd [verb] …`; anything that is not a verb is a new process's requirements.
|
|
766
804
|
async function command($: $, arg: string): Promise<string | undefined> {
|
|
767
805
|
if (arg === "stop") return stopNow($)
|
|
806
|
+
const busy = decideCommand(arg, background.busy())
|
|
807
|
+
if (busy) return busy
|
|
768
808
|
const problem = await preflight($)
|
|
769
809
|
if (problem) return problem
|
|
770
810
|
const [verb = "", ...rest] = arg.split(/\s+/)
|
|
@@ -772,7 +812,7 @@ async function command($: $, arg: string): Promise<string | undefined> {
|
|
|
772
812
|
case "status":
|
|
773
813
|
return gtd($, ["next"])
|
|
774
814
|
case "throw":
|
|
775
|
-
return throwNow($, rest[0])
|
|
815
|
+
return throwNow($, rest[0], false)
|
|
776
816
|
case "ui":
|
|
777
817
|
return browse($)
|
|
778
818
|
case "catch":
|
|
@@ -780,7 +820,7 @@ async function command($: $, arg: string): Promise<string | undefined> {
|
|
|
780
820
|
? catchNow($, rest[0])
|
|
781
821
|
: "Name the pull request or branch: /gtd catch <number|branch>."
|
|
782
822
|
case "ship":
|
|
783
|
-
return shipNow($, rest[0] === "-n" || rest[0] === "--dry-run")
|
|
823
|
+
return shipNow($, rest[0] === "-n" || rest[0] === "--dry-run", false)
|
|
784
824
|
case "":
|
|
785
825
|
return resume($)
|
|
786
826
|
default:
|
|
@@ -912,10 +952,14 @@ export const register: Register = (on) => {
|
|
|
912
952
|
}).catch(($, e, next) => ({ text: next.error.message }))
|
|
913
953
|
|
|
914
954
|
on("turn.complete", ($, e, next) => {
|
|
915
|
-
if (!e.agentId)
|
|
955
|
+
if (!e.agentId) {
|
|
956
|
+
background.flush()
|
|
957
|
+
void launchPending($)
|
|
958
|
+
}
|
|
916
959
|
if (e.agentId && ours.has(e.agentId)) {
|
|
917
960
|
const end: TurnEnd =
|
|
918
961
|
e.reason === "answer" ? { ok: true } : { ok: false, why: `agent turn ended: ${e.reason}` }
|
|
962
|
+
delegates.delete(e.agentId)
|
|
919
963
|
const resolve = waiting.get(e.agentId)
|
|
920
964
|
waiting.delete(e.agentId)
|
|
921
965
|
if (resolve) resolve(end)
|
|
@@ -943,7 +987,24 @@ export const register: Register = (on) => {
|
|
|
943
987
|
// A subagent's hand-back arrives as a prompt to the main session, whose model
|
|
944
988
|
// would then act on it. gtd reads the turn's result from git instead.
|
|
945
989
|
on("prompt.submit", async ($, e, next) => {
|
|
946
|
-
if (!e.origin || e.origin.kind === "composer" || e.origin.kind === "bridge")
|
|
990
|
+
if (!e.origin || e.origin.kind === "composer" || e.origin.kind === "bridge") {
|
|
991
|
+
const pasted = pastedCommand(e.text)
|
|
992
|
+
if (!pasted) return next(e)
|
|
993
|
+
// Off the hook: preflight may install gtd's runtime and outlast its budget.
|
|
994
|
+
const say = (text: string) => {
|
|
995
|
+
for (const line of text.split("\n")) $.ui.log(line)
|
|
996
|
+
$.ui.toast(text.split("\n")[0]!)
|
|
997
|
+
}
|
|
998
|
+
if ("lost" in pasted) say(pasted.lost)
|
|
999
|
+
else {
|
|
1000
|
+
$.clock.after(0, () => {
|
|
1001
|
+
void command($, pasted.arg)
|
|
1002
|
+
.then((text) => text && say(text))
|
|
1003
|
+
.catch((err) => say(err instanceof Error ? err.message : String(err)))
|
|
1004
|
+
})
|
|
1005
|
+
}
|
|
1006
|
+
return { drop: "gtd took the command" }
|
|
1007
|
+
}
|
|
947
1008
|
if ([...ours].some((id) => e.text.includes(`from="${id}"`))) {
|
|
948
1009
|
return { drop: DROPPED }
|
|
949
1010
|
}
|
|
@@ -957,10 +1018,10 @@ export const register: Register = (on) => {
|
|
|
957
1018
|
answer(await note($, String(e.text ?? "")), true),
|
|
958
1019
|
)
|
|
959
1020
|
on("tool.call", { tool: "mcp__gtd__handoff" }, async ($, e) =>
|
|
960
|
-
answer(await throwNow($, typeof e.to === "string" && e.to ? e.to : undefined), true),
|
|
1021
|
+
answer(await throwNow($, typeof e.to === "string" && e.to ? e.to : undefined, true), true),
|
|
961
1022
|
)
|
|
962
1023
|
on("tool.call", { tool: "mcp__gtd__ship" }, async ($, e) =>
|
|
963
|
-
answer(await shipNow($, e.dryRun === true), true),
|
|
1024
|
+
answer(await shipNow($, e.dryRun === true, true), true),
|
|
964
1025
|
)
|
|
965
1026
|
on("tool.call", { tool: "mcp__gtd__browser" }, async ($) => answer(await browse($), true))
|
|
966
1027
|
|
package/claude/hooks/ship.ts
CHANGED
|
@@ -334,13 +334,18 @@ ${await git("diff", "--stat", `${mergeBase}..HEAD`)}`
|
|
|
334
334
|
const head = await git("rev-parse", "HEAD")
|
|
335
335
|
if (thrown) {
|
|
336
336
|
const n = String(thrown.number)
|
|
337
|
+
// Ready first: a failure here leaves the THROWN marker, so a retry comes back.
|
|
338
|
+
const ready = await io.run(["gh", "pr", "ready", n])
|
|
339
|
+
if (ready.code !== 0)
|
|
340
|
+
return fail(`gh pr ready failed; ${thrown.url} is unchanged.\n${ready.err}`)
|
|
337
341
|
const edited = await io.run(
|
|
338
342
|
["gh", "pr", "edit", n, "--title", title, "--body-file", "-"],
|
|
339
343
|
body + "\n",
|
|
340
344
|
)
|
|
341
345
|
if (edited.code !== 0)
|
|
342
|
-
return fail(
|
|
343
|
-
|
|
346
|
+
return fail(
|
|
347
|
+
`gh pr edit failed; ${thrown.url} is marked ready but keeps its thrown title and description.\n${edited.err}`,
|
|
348
|
+
)
|
|
344
349
|
await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, head])
|
|
345
350
|
return { ok: true, text: `Rewrote ${thrown.url} and marked it ready for review.` }
|
|
346
351
|
}
|
package/claude/hooks/tools.ts
CHANGED
|
@@ -71,6 +71,37 @@ export type ContinueIo = {
|
|
|
71
71
|
// the rest's own `gtd validate` script, run; ok when there is nothing to check
|
|
72
72
|
validate(): Promise<{ ok: boolean; out: string }>
|
|
73
73
|
judgment(): Promise<Judgment | undefined>
|
|
74
|
+
// a ship or hand-off running or queued: it writes the tree the driver would
|
|
75
|
+
busy?: JobName
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type JobName = "ship" | "handoff"
|
|
79
|
+
|
|
80
|
+
export const JOB_NAME: Record<JobName, string> = { ship: "ship", handoff: "hand-off" }
|
|
81
|
+
|
|
82
|
+
const busyText = (job: JobName) => `A ${JOB_NAME[job]} is still running: wait for its report.`
|
|
83
|
+
|
|
84
|
+
// Only these leave the tree alone (ship and throw refuse for themselves); any
|
|
85
|
+
// other verb, unknown ones included, may switch branch or write .gtd/. `stop`
|
|
86
|
+
// is not here: `command` takes the bare word itself, and `stop <words>` is a
|
|
87
|
+
// requirement. `ui` is not: its page writes steering files.
|
|
88
|
+
const WHILE_BUSY = new Set(["status", "throw", "ship"])
|
|
89
|
+
|
|
90
|
+
export function decideCommand(arg: string, busy: JobName | undefined): string | undefined {
|
|
91
|
+
if (!busy) return undefined
|
|
92
|
+
const verb = arg.trim().split(/\s+/)[0] ?? ""
|
|
93
|
+
return WHILE_BUSY.has(verb) ? undefined : busyText(busy)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Pure: a refused job leaves the run, and so `continue`, as it was.
|
|
97
|
+
export function decideJob(run: Run, job: JobName): string | undefined {
|
|
98
|
+
if (run.isRunning) return "gtd is running a step: wait for it to rest."
|
|
99
|
+
if (run.pending) return "A continue is already waiting for this turn to end."
|
|
100
|
+
const kind = run.stop?.kind
|
|
101
|
+
if (job === "ship" && (kind === "gate" || kind === "stalled" || kind === "error")) {
|
|
102
|
+
return "gtd still waits on the person at this step: answer it with continue first."
|
|
103
|
+
}
|
|
104
|
+
return undefined
|
|
74
105
|
}
|
|
75
106
|
|
|
76
107
|
// What `continue` may do from here. `rest` undefined is the band's Continue,
|
|
@@ -82,6 +113,7 @@ export async function decideContinue(
|
|
|
82
113
|
io: ContinueIo,
|
|
83
114
|
): Promise<Proceed> {
|
|
84
115
|
if (run.isRunning) return { refuse: "gtd is already running a step." }
|
|
116
|
+
if (io.busy) return { refuse: busyText(io.busy) }
|
|
85
117
|
const stop = run.stop
|
|
86
118
|
if (!stop) return { refuse: "Nothing is waiting: gtd has no rest open. /gtd drives it." }
|
|
87
119
|
if (stop.kind === "done") {
|
package/claude/hooks/wording.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Stop } from "../types"
|
|
4
4
|
import type { Beat } from "./drive"
|
|
5
|
+
import type { Shipped } from "./ship"
|
|
6
|
+
import { JOB_NAME } from "./tools"
|
|
7
|
+
import type { JobName } from "./tools"
|
|
5
8
|
|
|
6
9
|
const step = (s: { label?: string; state?: string }) => s.label ?? s.state ?? "the next step"
|
|
7
10
|
|
|
@@ -53,3 +56,11 @@ export function pushText(stop: Stop, repo: string, url?: string) {
|
|
|
53
56
|
if (stop.kind !== "gate") return at
|
|
54
57
|
return url ? `${at}. Open: ${url}` : at
|
|
55
58
|
}
|
|
59
|
+
|
|
60
|
+
const capitalised = (s: string) => s[0]!.toUpperCase() + s.slice(1)
|
|
61
|
+
|
|
62
|
+
export const jobHeadline = (job: JobName, outcome: Shipped) =>
|
|
63
|
+
`${outcome.ok ? "✔" : "✘"} ${capitalised(JOB_NAME[job])} ${outcome.ok ? "finished" : "failed"}`
|
|
64
|
+
|
|
65
|
+
export const jobPushText = (job: JobName, outcome: Shipped, repo: string) =>
|
|
66
|
+
`${repo}: ${JOB_NAME[job]} ${outcome.ok ? "finished" : "failed"}. ${outcome.text}`.trim()
|
|
@@ -1234,13 +1234,13 @@ const fixRisks = (notes) => agentWithSkills("review.fix.risks.fixing", buildRevi
|
|
|
1234
1234
|
system: reviewerSystem(),
|
|
1235
1235
|
allowEmpty: true
|
|
1236
1236
|
});
|
|
1237
|
-
const awaitReview = (base, message = buildReviewAwaitReviewMessage(base)
|
|
1237
|
+
const awaitReview = (base, message = buildReviewAwaitReviewMessage(base)) => human("review.await-review", {
|
|
1238
1238
|
message,
|
|
1239
1239
|
label: "Awaiting your review",
|
|
1240
1240
|
file: REVIEW,
|
|
1241
1241
|
mode: "review",
|
|
1242
1242
|
base,
|
|
1243
|
-
|
|
1243
|
+
acceptClean: true
|
|
1244
1244
|
});
|
|
1245
1245
|
/** The round at commit `round` left no `.gtd/REVIEW.md`. */
|
|
1246
1246
|
const reviewMissing = (round) => human("review.review-missing", {
|
|
@@ -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 { C as fixRisks, D as reviewing, E as reviewQuality, G as handOffAwaitReviewMessage, S as fixQuality, T as reviewMissing, f as guarded, g as QUALITY, p as healthy, u as escalation, v as REVIEW, x as fixCheck, y as awaitReview } from "./reserved-
|
|
3
|
+
import { C as fixRisks, D as reviewing, E as reviewQuality, G as handOffAwaitReviewMessage, S as fixQuality, T as reviewMissing, f as guarded, g as QUALITY, p as healthy, u as escalation, v as REVIEW, x as fixCheck, y as awaitReview } from "./reserved-UdSuHDUJ.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 { a as flagShipped, b as coder, c as envDefaults, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-
|
|
3
|
-
import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-
|
|
2
|
+
import { a as flagShipped, b as coder, c as envDefaults, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-UdSuHDUJ.mjs";
|
|
3
|
+
import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-fj60rQ3y.mjs";
|
|
4
4
|
import { i as requireFlag, r as registryEntry, t as flagReferences } from "../chunks/registry-CP3YuV9j.mjs";
|
|
5
5
|
//#region src/workflows/cleanup/cleanup.ts
|
|
6
6
|
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.`;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, judgeDiffShape, kept, mark, numeric, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, seamGate, start, vars } from "../flows.mjs";
|
|
2
2
|
import { review as review$1 } from "../workflow-kit.mjs";
|
|
3
|
-
import { $ as refactorFixPrompt, A as architectureAuthorPrompt, B as designGateAnswerMessage, C as fixRisks, D as reviewing, F as buildReviewCollectingPrompt, H as designTriagePrompt, I as buildReviewFixNitsPrompt, J as implementFixPrompt, K as idleMessage, L as buildReviewTriageMessage, M as architectureGateAnswerMessage, N as buildReviewAnswerQuestionsPrompt, O as agentWithSkills, P as buildReviewAwaitAfterDeferMessage, Q as refactorBuildingPrompt, R as builderSystem, T as reviewMissing, V as designSystem, X as packagesItemBuildingPrompt, Y as naivePassPrompt, Z as packagesItemFixSuitePrompt, _ as REQUIREMENTS, at as seamByHandMessage, b as coder, c as envDefaults, ct as unwindFailedMessage, d as gate, et as reviewEditNotesCapture, h as FEEDBACK, i as skills, it as reviewerSystem, j as architectureDecomposePrompt, k as architectSystem, lt as unwindFailure, m as ARCHITECTURE, n as summary, o as shipped, ot as seamSteerMessage, p as healthy, q as implementBuildingPrompt, r as access, rt as reviewNotesCapture, s as defaults, st as startGateBlockedMessage, t as steering, tt as reviewEditsCapture, v as REVIEW, w as planner, x as fixCheck, y as awaitReview, z as deferNotesScript } from "../chunks/reserved-
|
|
4
|
-
import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-
|
|
3
|
+
import { $ as refactorFixPrompt, A as architectureAuthorPrompt, B as designGateAnswerMessage, C as fixRisks, D as reviewing, F as buildReviewCollectingPrompt, H as designTriagePrompt, I as buildReviewFixNitsPrompt, J as implementFixPrompt, K as idleMessage, L as buildReviewTriageMessage, M as architectureGateAnswerMessage, N as buildReviewAnswerQuestionsPrompt, O as agentWithSkills, P as buildReviewAwaitAfterDeferMessage, Q as refactorBuildingPrompt, R as builderSystem, T as reviewMissing, V as designSystem, X as packagesItemBuildingPrompt, Y as naivePassPrompt, Z as packagesItemFixSuitePrompt, _ as REQUIREMENTS, at as seamByHandMessage, b as coder, c as envDefaults, ct as unwindFailedMessage, d as gate, et as reviewEditNotesCapture, h as FEEDBACK, i as skills, it as reviewerSystem, j as architectureDecomposePrompt, k as architectSystem, lt as unwindFailure, m as ARCHITECTURE, n as summary, o as shipped, ot as seamSteerMessage, p as healthy, q as implementBuildingPrompt, r as access, rt as reviewNotesCapture, s as defaults, st as startGateBlockedMessage, t as steering, tt as reviewEditsCapture, v as REVIEW, w as planner, x as fixCheck, y as awaitReview, z as deferNotesScript } from "../chunks/reserved-UdSuHDUJ.mjs";
|
|
4
|
+
import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-fj60rQ3y.mjs";
|
|
5
5
|
//#region src/workflows/feature/tier.ts
|
|
6
6
|
/** GTD_FLAG_NAIVE_PASS, GTD_FLAG_RISK_TIER: both pinned at process start, so a mid-process flip never moves a process. */
|
|
7
7
|
const riskTierOn = () => vars.flag_naive_pass === "1" && vars.flag_risk_tier === "1";
|
|
@@ -355,7 +355,7 @@ const review = (base, escalations = { rounds: 0 }) => {
|
|
|
355
355
|
awaitReview: (b) => {
|
|
356
356
|
const notes = deferred;
|
|
357
357
|
deferred = [];
|
|
358
|
-
return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes)
|
|
358
|
+
return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes)) : awaitReview(b);
|
|
359
359
|
},
|
|
360
360
|
reviewMissing,
|
|
361
361
|
collecting,
|
package/dist/workflows/fix.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
|
|
2
2
|
import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-DVILanVf.mjs";
|
|
3
|
-
import { O as agentWithSkills, U as finisherSystem, W as fixReviewNotesPrompt, b as coder, c as envDefaults, i as skills, l as baseline, n as summary, o as shipped, r as access, s as defaults, t as steering, v as REVIEW } from "../chunks/reserved-
|
|
4
|
-
import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-
|
|
3
|
+
import { O as agentWithSkills, U as finisherSystem, W as fixReviewNotesPrompt, b as coder, c as envDefaults, i as skills, l as baseline, n as summary, o as shipped, r as access, s as defaults, t as steering, v as REVIEW } from "../chunks/reserved-UdSuHDUJ.mjs";
|
|
4
|
+
import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-fj60rQ3y.mjs";
|
|
5
5
|
//#region src/workflows/fix/fix.ts
|
|
6
6
|
/**
|
|
7
7
|
* Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { start } from "../flows.mjs";
|
|
2
|
-
import { c as envDefaults, d as gate, i as skills, n as summary, nt as reviewGateBlockedMessage, o as shipped, r as access, s as defaults, t as steering } from "../chunks/reserved-
|
|
3
|
-
import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-
|
|
2
|
+
import { c as envDefaults, d as gate, i as skills, n as summary, nt as reviewGateBlockedMessage, o as shipped, r as access, s as defaults, t as steering } from "../chunks/reserved-UdSuHDUJ.mjs";
|
|
3
|
+
import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-fj60rQ3y.mjs";
|
|
4
4
|
//#region src/workflows/review/review.ts
|
|
5
5
|
/**
|
|
6
6
|
* Pure review of everything since `reviewBase`: a green gate, the quality
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agent, changesSince, head, read, refuse, scope, vars } from "../flows.mjs";
|
|
2
|
-
import { a as flagShipped, b as coder, c as envDefaults, d as gate, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-
|
|
2
|
+
import { a as flagShipped, b as coder, c as envDefaults, d as gate, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-UdSuHDUJ.mjs";
|
|
3
3
|
import { i as requireFlag, n as flipProblems } from "../chunks/registry-CP3YuV9j.mjs";
|
|
4
4
|
//#region src/workflows/rollout/rollout.ts
|
|
5
5
|
const flippingPrompt = (name, registry) => `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`;
|
package/dist/workflows.mjs
CHANGED
|
@@ -17995,13 +17995,13 @@ const fixRisks = (notes) => agentWithSkills("review.fix.risks.fixing", buildRevi
|
|
|
17995
17995
|
system: reviewerSystem(),
|
|
17996
17996
|
allowEmpty: true
|
|
17997
17997
|
});
|
|
17998
|
-
const awaitReview = (base, message = buildReviewAwaitReviewMessage(base)
|
|
17998
|
+
const awaitReview = (base, message = buildReviewAwaitReviewMessage(base)) => human("review.await-review", {
|
|
17999
17999
|
message,
|
|
18000
18000
|
label: "Awaiting your review",
|
|
18001
18001
|
file: REVIEW,
|
|
18002
18002
|
mode: "review",
|
|
18003
18003
|
base,
|
|
18004
|
-
|
|
18004
|
+
acceptClean: true
|
|
18005
18005
|
});
|
|
18006
18006
|
/** The round at commit `round` left no `.gtd/REVIEW.md`. */
|
|
18007
18007
|
const reviewMissing = (round) => human("review.review-missing", {
|
|
@@ -37911,7 +37911,7 @@ const review$1 = (base, escalations = { rounds: 0 }) => {
|
|
|
37911
37911
|
awaitReview: (b) => {
|
|
37912
37912
|
const notes = deferred;
|
|
37913
37913
|
deferred = [];
|
|
37914
|
-
return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes)
|
|
37914
|
+
return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes)) : awaitReview(b);
|
|
37915
37915
|
},
|
|
37916
37916
|
reviewMissing,
|
|
37917
37917
|
collecting,
|
package/package.json
CHANGED
|
@@ -509,13 +509,19 @@ describe("severity routing", () => {
|
|
|
509
509
|
notes: ["add — quote", "sub", "mul"],
|
|
510
510
|
answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
|
|
511
511
|
})
|
|
512
|
-
expect(run.gates).toEqual([
|
|
512
|
+
expect(run.gates).toEqual([true, true])
|
|
513
513
|
expect(run.gateMessages[1]).toContain("NOT acted on")
|
|
514
514
|
expect(run.gateMessages[1]).toContain("note-1")
|
|
515
515
|
expect(run.gateMessages[0]).not.toContain("NOT acted on")
|
|
516
516
|
})
|
|
517
517
|
|
|
518
|
-
it("
|
|
518
|
+
it("the first round's gate accepts a clean land", async () => {
|
|
519
|
+
const run = await drive({ notes: ["add", "sub", "mul"] })
|
|
520
|
+
expect(run.gates.length).toBeGreaterThan(0)
|
|
521
|
+
expect(run.gates.every((g) => g)).toBe(true)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it("a later gate still accepts a clean land after a deferral round with a nit", async () => {
|
|
519
525
|
const run = await drive({
|
|
520
526
|
notes: ["add — quote", "sub — typo", "mul"],
|
|
521
527
|
answers: {
|
|
@@ -527,7 +533,7 @@ describe("severity routing", () => {
|
|
|
527
533
|
})
|
|
528
534
|
expect(run.log).toContain("review.defer")
|
|
529
535
|
expect(run.log.at(-1)).toBe("review.await-review")
|
|
530
|
-
expect(run.gates).toEqual([
|
|
536
|
+
expect(run.gates).toEqual([true, true])
|
|
531
537
|
})
|
|
532
538
|
|
|
533
539
|
it("a question and a could note in one round: the gate's base is the answering commit", async () => {
|
|
@@ -167,8 +167,8 @@ export const review = (
|
|
|
167
167
|
escalations: EscalationCount = { rounds: 0 },
|
|
168
168
|
): Promise<kit.ReviewOutcome> => {
|
|
169
169
|
const severities = new Map<string, Severity>()
|
|
170
|
-
// Armed by a defer-only round
|
|
171
|
-
// review (`reviewing`) disarms it
|
|
170
|
+
// Armed by a defer-only round to pick the next gate's message; a fresh
|
|
171
|
+
// review (`reviewing`) disarms it.
|
|
172
172
|
let deferred: readonly t.NoteInput[] = []
|
|
173
173
|
return kit.review(base, {
|
|
174
174
|
file: REVIEW,
|
|
@@ -184,7 +184,7 @@ export const review = (
|
|
|
184
184
|
const notes = deferred
|
|
185
185
|
deferred = []
|
|
186
186
|
return notes.length > 0
|
|
187
|
-
? awaitReview(b, t.buildReviewAwaitAfterDeferMessage(b, notes)
|
|
187
|
+
? awaitReview(b, t.buildReviewAwaitAfterDeferMessage(b, notes))
|
|
188
188
|
: awaitReview(b)
|
|
189
189
|
},
|
|
190
190
|
reviewMissing,
|
|
@@ -117,7 +117,6 @@ export const fixRisks = (notes: readonly t.NoteInput[]): Promise<void> =>
|
|
|
117
117
|
export const awaitReview = (
|
|
118
118
|
base: string,
|
|
119
119
|
message = t.buildReviewAwaitReviewMessage(base),
|
|
120
|
-
acceptClean = false,
|
|
121
120
|
): Promise<void> =>
|
|
122
121
|
human("review.await-review", {
|
|
123
122
|
message,
|
|
@@ -125,7 +124,7 @@ export const awaitReview = (
|
|
|
125
124
|
file: REVIEW,
|
|
126
125
|
mode: "review",
|
|
127
126
|
base,
|
|
128
|
-
|
|
127
|
+
acceptClean: true,
|
|
129
128
|
})
|
|
130
129
|
|
|
131
130
|
/** The round at commit `round` left no `.gtd/REVIEW.md`. */
|
|
@@ -38,8 +38,18 @@ const handOff = async (
|
|
|
38
38
|
} = {},
|
|
39
39
|
) => {
|
|
40
40
|
const log: string[] = []
|
|
41
|
+
const gates: boolean[] = []
|
|
41
42
|
const scripts = new Map<string, string>()
|
|
42
43
|
const files = new Map<string, string>()
|
|
44
|
+
const onReviewing = () => {
|
|
45
|
+
if (log.filter((n) => n === "review.reviewing").length > 1) throw new Error("re-review")
|
|
46
|
+
if (after !== "unwritten") files.set(REVIEW, reviewDoc("new add function"))
|
|
47
|
+
}
|
|
48
|
+
const onGate = (request: StepRequest) => {
|
|
49
|
+
if (request.kind === "human") gates.push(request.options.acceptClean === true)
|
|
50
|
+
if (after === "deleted") files.delete(REVIEW)
|
|
51
|
+
else if (after !== "unwritten") files.set(REVIEW, after)
|
|
52
|
+
}
|
|
43
53
|
installContext(
|
|
44
54
|
fixtureContext(
|
|
45
55
|
{ codeThreads: extra.codeThreads ?? [] },
|
|
@@ -48,14 +58,8 @@ const handOff = async (
|
|
|
48
58
|
if (request.kind === "restart") return Promise.resolve()
|
|
49
59
|
log.push(request.name)
|
|
50
60
|
if (request.kind === "run") scripts.set(request.name, request.body)
|
|
51
|
-
if (request.name === "review.reviewing")
|
|
52
|
-
|
|
53
|
-
if (after !== "unwritten") files.set(REVIEW, reviewDoc("new add function"))
|
|
54
|
-
}
|
|
55
|
-
if (request.name === "review.await-review") {
|
|
56
|
-
if (after === "deleted") files.delete(REVIEW)
|
|
57
|
-
else if (after !== "unwritten") files.set(REVIEW, after)
|
|
58
|
-
}
|
|
61
|
+
if (request.name === "review.reviewing") onReviewing()
|
|
62
|
+
if (request.name === "review.await-review") onGate(request)
|
|
59
63
|
return Promise.resolve()
|
|
60
64
|
},
|
|
61
65
|
refuse: (message: string): never => {
|
|
@@ -96,7 +100,7 @@ const handOff = async (
|
|
|
96
100
|
() => undefined,
|
|
97
101
|
(e: unknown) => (e as Error).message,
|
|
98
102
|
)
|
|
99
|
-
return { log, scripts, refusal }
|
|
103
|
+
return { log, scripts, refusal, gates }
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
describe("the hand-off review", () => {
|
|
@@ -105,6 +109,11 @@ describe("the hand-off review", () => {
|
|
|
105
109
|
expect(run.log).toEqual(["review.reviewing", "review.await-review", "review.closing"])
|
|
106
110
|
})
|
|
107
111
|
|
|
112
|
+
it("the review gate accepts a clean landing", async () => {
|
|
113
|
+
const run = await handOff(reviewDoc("new add function"))
|
|
114
|
+
expect(run.gates).toEqual([true])
|
|
115
|
+
})
|
|
116
|
+
|
|
108
117
|
it("a note ends the round with the review kept and the reviewer's version copied beside it", async () => {
|
|
109
118
|
const run = await handOff(reviewDoc("new add function — rename it to sum"))
|
|
110
119
|
expect(run.log).toEqual(["review.reviewing", "review.await-review", "review.handing-off"])
|