@pmelab/gtd 24.0.0 → 24.2.0
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 +42 -13
- package/claude/hooks/access.ts +26 -1
- package/claude/hooks/assist.ts +135 -0
- package/claude/hooks/drive.ts +15 -7
- package/claude/hooks/judge.ts +27 -0
- package/claude/hooks/register.tsx +294 -135
- package/claude/hooks/tools.ts +121 -0
- package/claude/hooks/wording.ts +2 -35
- package/claude/types/index.d.ts +9 -0
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43339 -55575
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +8 -6
- package/package.json +69 -8
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflows/feature/feature.test.ts +27 -33
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +58 -78
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +2 -10
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +85 -29
- package/src/workflows/review/review.ts +3 -1
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/shared/workflows.steps.ts +7 -0
|
@@ -2,39 +2,28 @@ import { atom, read, update } from "claude-code"
|
|
|
2
2
|
import type { EngineInterface, Register } from "claude-code"
|
|
3
3
|
|
|
4
4
|
import type { Run, Stop } from "../types"
|
|
5
|
-
import { accessDenial } from "./access"
|
|
5
|
+
import { accessDenial, mainDenial } from "./access"
|
|
6
6
|
import type { AccessDef } from "./access"
|
|
7
|
+
import { wakePrompt, wakes } from "./assist"
|
|
7
8
|
import { afterReload, drive, isTrue } from "./drive"
|
|
8
|
-
import type { Io, Turn, TurnEnd } from "./drive"
|
|
9
|
+
import type { Io, Opening, Turn, TurnEnd } from "./drive"
|
|
9
10
|
import { doors, enter } from "./doors"
|
|
10
11
|
import { subagentModel } from "./models"
|
|
11
12
|
import { personaSpec, resumable } from "./persona"
|
|
12
|
-
import { JUDGE_SYSTEM, judgePrompt, judgeRunEnv, toVerdicts } from "./judge"
|
|
13
|
+
import { JUDGE_SYSTEM, judgePrompt, judgeQuestions, judgeRunEnv, toVerdicts } from "./judge"
|
|
13
14
|
import type { Judgment } from "./judge"
|
|
14
15
|
import { catchFrom, throwTo } from "./handoff"
|
|
15
|
-
import { documents } from "./schema"
|
|
16
|
+
import { documents, readDocument } from "./schema"
|
|
16
17
|
import { READ_ONLY_GIT, ship } from "./ship"
|
|
17
18
|
import type { ShipIo } from "./ship"
|
|
19
|
+
import { decideContinue, restId, TOOLS } from "./tools"
|
|
18
20
|
import {
|
|
19
|
-
ANYONE,
|
|
20
21
|
beatLine,
|
|
21
|
-
CANCEL,
|
|
22
|
-
CLOSE,
|
|
23
|
-
CONTINUE,
|
|
24
|
-
gateOptions,
|
|
25
|
-
HANDOFF,
|
|
26
|
-
handoffQuestion,
|
|
27
22
|
headline,
|
|
28
|
-
LATER,
|
|
29
23
|
pushText,
|
|
30
|
-
runningLine,
|
|
31
|
-
question,
|
|
32
24
|
restartLine,
|
|
33
25
|
RELOAD_CUT_SCRIPT,
|
|
34
|
-
|
|
35
|
-
SHIP,
|
|
36
|
-
shipQuestion,
|
|
37
|
-
STALE,
|
|
26
|
+
runningLine,
|
|
38
27
|
TONE,
|
|
39
28
|
} from "./wording"
|
|
40
29
|
|
|
@@ -53,11 +42,9 @@ const TEN_MINUTES = 600_000
|
|
|
53
42
|
// gtd refuses outside the repository root; every process runs there.
|
|
54
43
|
let root = "."
|
|
55
44
|
let gateUi: { stop(): void } | undefined
|
|
56
|
-
// bumped per rest, so
|
|
45
|
+
// bumped per rest, so a browser view of an older rest is ignored
|
|
57
46
|
let gateToken = 0
|
|
58
47
|
let lastGate: { state?: string; head: string } | undefined
|
|
59
|
-
// the question dialog this mod has open, to mark it stale once gtd moves on
|
|
60
|
-
let openAsk: { token: number; text: string } | undefined
|
|
61
48
|
|
|
62
49
|
let stopRequested = false
|
|
63
50
|
// when the current step began, for the band's elapsed time
|
|
@@ -66,6 +53,8 @@ let stepStartedAt = Date.now()
|
|
|
66
53
|
// together cannot both pass the isRunning check and start two drivers.
|
|
67
54
|
let isDriving = false
|
|
68
55
|
let isShipping = false
|
|
56
|
+
// bumped by every stop, so a queued continue that was mid-check sees it
|
|
57
|
+
let stopCount = 0
|
|
69
58
|
// subagents answering into a file: what each may touch (see tool.call)
|
|
70
59
|
const delegates = new Map<string, { file: string; mayRun: RegExp | undefined }>()
|
|
71
60
|
const ours = new Set<string>()
|
|
@@ -229,15 +218,16 @@ async function head($: $) {
|
|
|
229
218
|
return (await $.process.run(["git", "rev-parse", "HEAD"], { cwd: root })).stdout.trim()
|
|
230
219
|
}
|
|
231
220
|
|
|
232
|
-
async function start($: $,
|
|
221
|
+
async function start($: $, opening: Opening = {}) {
|
|
233
222
|
if (isDriving) return
|
|
234
223
|
isDriving = true
|
|
235
224
|
closeUi()
|
|
236
225
|
moveOn($)
|
|
237
226
|
stopRequested = false
|
|
238
227
|
await findRoot($)
|
|
239
|
-
|
|
240
|
-
|
|
228
|
+
// Requests not yet handed to a rest outlive a restart.
|
|
229
|
+
await update($, run, (r) => ({ isRunning: true, beat: 0, notes: r.notes }))
|
|
230
|
+
void drive(io($), opening)
|
|
241
231
|
.catch((err: unknown): Stop => ({ kind: "error", text: String(err) }))
|
|
242
232
|
.then(async (stop) => {
|
|
243
233
|
isDriving = false
|
|
@@ -245,14 +235,57 @@ async function start($: $, landTurn?: string) {
|
|
|
245
235
|
const isRepeat =
|
|
246
236
|
stop.kind === "gate" && at.state === lastGate?.state && at.head === lastGate?.head
|
|
247
237
|
lastGate = at
|
|
248
|
-
|
|
238
|
+
const rest = restId(at.head, stop.state)
|
|
239
|
+
await update($, run, (r) => ({ ...r, isRunning: false, stop, rest, isRepeat }))
|
|
249
240
|
$.ui.status(undefined)
|
|
250
241
|
$.ui.toast(headline(stop))
|
|
251
|
-
|
|
252
|
-
|
|
242
|
+
await push($, stop)
|
|
243
|
+
await wake($)
|
|
253
244
|
})
|
|
254
245
|
}
|
|
255
246
|
|
|
247
|
+
// A rest that needs the person hands the session's model a prompt to help them
|
|
248
|
+
// through it; the model moves gtd on only through the mod's tools.
|
|
249
|
+
async function wake($: $) {
|
|
250
|
+
const r = await read($, run)
|
|
251
|
+
const stop = r.stop
|
|
252
|
+
if (!stop || !r.rest) return
|
|
253
|
+
const branch = stop.kind === "done" ? await shippable($) : undefined
|
|
254
|
+
if (!wakes(stop, branch)) return
|
|
255
|
+
const questions = stop.isJudge ? await openQuestions($) : undefined
|
|
256
|
+
const text = wakePrompt({
|
|
257
|
+
stop,
|
|
258
|
+
rest: r.rest,
|
|
259
|
+
notes: r.notes,
|
|
260
|
+
isRepeat: r.isRepeat,
|
|
261
|
+
questions,
|
|
262
|
+
branch,
|
|
263
|
+
})
|
|
264
|
+
await update($, run, (x) => ({ ...x, notes: undefined }))
|
|
265
|
+
// Not from inside the hook that started the run: a prompt submitted there
|
|
266
|
+
// would wait on that hook's own turn.
|
|
267
|
+
$.clock.after(0, () => void $.prompt.submit({ text }))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function openQuestions($: $) {
|
|
271
|
+
try {
|
|
272
|
+
const j = readDocument<Judgment>("judge --json", await gtd($, ["judge", "--json"]))
|
|
273
|
+
return judgeQuestions(j).filter((q) => typeof q !== "string")
|
|
274
|
+
} catch {
|
|
275
|
+
return undefined
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// The branch a finished process can open its pull request from, if any.
|
|
280
|
+
async function shippable($: $) {
|
|
281
|
+
const branch = (
|
|
282
|
+
await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd: root })
|
|
283
|
+
).stdout.trim()
|
|
284
|
+
if (["main", "master", "HEAD"].includes(branch)) return undefined
|
|
285
|
+
const summary = await $.process.run(["gtd", "summary"], { cwd: root })
|
|
286
|
+
return summary.exitCode === 0 ? branch : undefined
|
|
287
|
+
}
|
|
288
|
+
|
|
256
289
|
// A new process starts from the requirements written to the steering file an
|
|
257
290
|
// idle rest names; the opening beat captures them like a hand-edit.
|
|
258
291
|
async function begin($: $, requirements: string) {
|
|
@@ -318,38 +351,22 @@ function serveUi($: $, token: number) {
|
|
|
318
351
|
return isReady
|
|
319
352
|
}
|
|
320
353
|
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
354
|
+
// `gtd ui` on request only: the session's model is the main way through a rest.
|
|
355
|
+
async function browse($: $) {
|
|
356
|
+
const r = await read($, run)
|
|
357
|
+
if (r.isRunning || r.stop?.kind !== "gate") return "gtd is not waiting at a step right now."
|
|
358
|
+
if (r.stop.isJudge) return "This step has no browser view."
|
|
359
|
+
if (r.url) return `Open in your browser: ${r.url}`
|
|
324
360
|
closeUi()
|
|
325
361
|
const token = ++gateToken
|
|
326
|
-
await update($, run, (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (token !== gateToken || !r.stop) return
|
|
333
|
-
await push($, r.stop, r.url)
|
|
334
|
-
// Also a transcript line: Remote Control and the phone show no dialog.
|
|
335
|
-
if (r.url) $.ui.log(`Open in your browser: ${r.url}`)
|
|
336
|
-
const answer = await ask($, token, question(r), gateOptions(r.stop))
|
|
337
|
-
if (token !== gateToken) return
|
|
338
|
-
if (answer === CONTINUE || answer === SAFE) await proceed($)
|
|
339
|
-
if (answer === HANDOFF) await askThrow($)
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// The engine's own dialog; undefined when dismissed. Remembered, so it can be
|
|
343
|
-
// marked out of date once gtd moves on without it (a mod cannot close it).
|
|
344
|
-
async function ask($: $, token: number, text: string, options: string[]) {
|
|
345
|
-
openAsk = { token, text }
|
|
346
|
-
try {
|
|
347
|
-
return await $.ui.ask(text, { header: "gtd", options })
|
|
348
|
-
} catch {
|
|
349
|
-
return undefined
|
|
350
|
-
} finally {
|
|
351
|
-
if (openAsk?.text === text) openAsk = undefined
|
|
362
|
+
await update($, run, (x) => ({ ...x, url: undefined, uiNote: undefined }))
|
|
363
|
+
await Promise.race([serveUi($, token), $.clock.sleep(8000)])
|
|
364
|
+
const now = await read($, run)
|
|
365
|
+
if (now.url) {
|
|
366
|
+
if (now.stop) await push($, now.stop, now.url)
|
|
367
|
+
return `Open in your browser: ${now.url}`
|
|
352
368
|
}
|
|
369
|
+
return `The browser view did not start${now.uiNote ? `: ${now.uiNote}` : "."}`
|
|
353
370
|
}
|
|
354
371
|
|
|
355
372
|
// A gate is news even when nobody watches the terminal: a push reaches the
|
|
@@ -367,13 +384,8 @@ async function push($: $, stop: Stop, url?: string) {
|
|
|
367
384
|
}
|
|
368
385
|
}
|
|
369
386
|
|
|
370
|
-
async function askThrow($: $) {
|
|
371
|
-
const answer = await ask($, gateToken, handoffQuestion, [ANYONE, CANCEL])
|
|
372
|
-
if (answer && answer !== CANCEL) await throwNow($, answer === ANYONE ? undefined : answer.trim())
|
|
373
|
-
}
|
|
374
|
-
|
|
375
387
|
async function throwNow($: $, target: string | undefined) {
|
|
376
|
-
if ((await read($, run)).isRunning) return
|
|
388
|
+
if ((await read($, run)).isRunning) return "Stop the loop first: /gtd stop."
|
|
377
389
|
closeUi()
|
|
378
390
|
moveOn($)
|
|
379
391
|
const b = await docs($).next()
|
|
@@ -396,10 +408,8 @@ async function throwNow($: $, target: string | undefined) {
|
|
|
396
408
|
uiNote: undefined,
|
|
397
409
|
stop: { kind: "stopped" as const, text: thrown.text },
|
|
398
410
|
}))
|
|
399
|
-
} else if ((await read($, run)).stop?.kind === "gate") {
|
|
400
|
-
// Nothing left: the person is still at the gate they tried to throw.
|
|
401
|
-
void openGate($)
|
|
402
411
|
}
|
|
412
|
+
return thrown.text
|
|
403
413
|
}
|
|
404
414
|
|
|
405
415
|
async function enterNow($: $, door: string, args: string[]) {
|
|
@@ -420,26 +430,17 @@ async function catchNow($: $, ref: string) {
|
|
|
420
430
|
if (b.kind === "message") {
|
|
421
431
|
const stop: Stop = { kind: "gate", text: b.content ?? "", state: b.state, label: b.label }
|
|
422
432
|
if (b.judge) stop.isJudge = true
|
|
423
|
-
|
|
424
|
-
|
|
433
|
+
if (b.file) stop.file = b.file
|
|
434
|
+
if (b.mode) stop.mode = b.mode
|
|
435
|
+
const rest = restId(await head($), b.state)
|
|
436
|
+
await update($, run, (r) => ({ ...r, stop, rest, isRepeat: false }))
|
|
437
|
+
void wake($)
|
|
425
438
|
} else {
|
|
426
439
|
await start($)
|
|
427
440
|
}
|
|
428
441
|
return caught.text
|
|
429
442
|
}
|
|
430
443
|
|
|
431
|
-
// A finished process on a feature branch is one answer away from its pull request.
|
|
432
|
-
async function offerShip($: $, token: number) {
|
|
433
|
-
const branch = (
|
|
434
|
-
await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd: root })
|
|
435
|
-
).stdout.trim()
|
|
436
|
-
const summary = await $.process.run(["gtd", "summary"], { cwd: root })
|
|
437
|
-
if (summary.exitCode !== 0 || ["main", "master", "HEAD"].includes(branch)) return
|
|
438
|
-
await push($, { kind: "done", text: "", label: `ready to open a pull request for ${branch}` })
|
|
439
|
-
const answer = await ask($, token, shipQuestion(branch), [SHIP, LATER])
|
|
440
|
-
if (answer === SHIP && token === gateToken) await shipNow($, false)
|
|
441
|
-
}
|
|
442
|
-
|
|
443
444
|
// Ship's text turns need git to read the process, so they run as a subagent.
|
|
444
445
|
// Its answer is a file, not its hand-back: a hand-back never reaches a mod.
|
|
445
446
|
// `mayRun`: the only shell commands the subagent may run; none when absent.
|
|
@@ -549,7 +550,7 @@ function shipIo($: $): ShipIo {
|
|
|
549
550
|
}
|
|
550
551
|
|
|
551
552
|
async function shipNow($: $, isDry: boolean) {
|
|
552
|
-
if (isShipping) return
|
|
553
|
+
if (isShipping) return "A pull request is already being prepared."
|
|
553
554
|
isShipping = true
|
|
554
555
|
await dismiss($)
|
|
555
556
|
$.ui.status(isDry ? "previewing the pull request…" : "opening the pull request…")
|
|
@@ -561,6 +562,7 @@ async function shipNow($: $, isDry: boolean) {
|
|
|
561
562
|
$.ui.status(undefined)
|
|
562
563
|
for (const line of shipped.text.split("\n")) $.ui.log(line)
|
|
563
564
|
$.ui.toast(shipped.ok ? "pull request ready" : "opening the pull request failed")
|
|
565
|
+
return shipped.text
|
|
564
566
|
}
|
|
565
567
|
|
|
566
568
|
// Turns a missing CLI or a wrong directory into an instruction, not a stack trace.
|
|
@@ -624,17 +626,125 @@ function moveOn($: $) {
|
|
|
624
626
|
$.ui.invalidate("ui.render")
|
|
625
627
|
}
|
|
626
628
|
|
|
627
|
-
|
|
629
|
+
// The one way on from a rest, for the model's tool and the band's Continue
|
|
630
|
+
// alike; a string is why it did not move.
|
|
631
|
+
// `isInTurn`: asked from the model's own turn. Its subagents would then count
|
|
632
|
+
// as that turn's background work and keep it open, so the loop starts once the
|
|
633
|
+
// turn has ended; until then the continue waits in the run state.
|
|
634
|
+
async function proceed(
|
|
635
|
+
$: $,
|
|
636
|
+
rest?: string,
|
|
637
|
+
verdicts?: unknown,
|
|
638
|
+
isInTurn = false,
|
|
639
|
+
): Promise<string | undefined> {
|
|
640
|
+
const r = await read($, run)
|
|
641
|
+
const go = await decideContinue(r, rest, verdicts, {
|
|
642
|
+
current: () => currentRest($, r.stop?.state),
|
|
643
|
+
validate: () => validateRest($),
|
|
644
|
+
judgment: async () => {
|
|
645
|
+
const doc = await gtd($, ["judge", "--json"]).catch(() => undefined)
|
|
646
|
+
return doc === undefined ? undefined : readDocument<Judgment>("judge --json", doc)
|
|
647
|
+
},
|
|
648
|
+
})
|
|
649
|
+
if (go.refuse !== undefined) return go.refuse
|
|
650
|
+
const opening = r.stop?.isJudge ? { isAnswered: true, verdict: go.verdict } : {}
|
|
651
|
+
if (isInTurn) {
|
|
652
|
+
await update($, run, (x) => ({ ...x, pending: { rest: r.rest ?? "", ...opening } }))
|
|
653
|
+
return undefined
|
|
654
|
+
}
|
|
655
|
+
await leaveRest($)
|
|
656
|
+
await start($, opening)
|
|
657
|
+
return undefined
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// The rest's own `gtd validate` script, run.
|
|
661
|
+
async function validateRest($: $) {
|
|
662
|
+
const script = await gtd($, ["validate"])
|
|
663
|
+
if (script.trim().startsWith("nothing to validate")) return { ok: true, out: "" }
|
|
664
|
+
const v = await $.process.run(["sh", "-c", script], { cwd: root, timeoutMs: TEN_MINUTES })
|
|
665
|
+
return { ok: v.exitCode === 0, out: `${v.stdout}${v.stderr}`.trim() }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function tellModel($: $, text: string, stops: number) {
|
|
669
|
+
if (stops === stopCount) await $.prompt.submit({ text })
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function leaveRest($: $) {
|
|
628
673
|
moveOn($)
|
|
629
674
|
closeUi()
|
|
630
|
-
await update($, run, (
|
|
631
|
-
|
|
675
|
+
await update($, run, (x) => ({
|
|
676
|
+
...x,
|
|
677
|
+
stop: undefined,
|
|
678
|
+
rest: undefined,
|
|
679
|
+
pending: undefined,
|
|
680
|
+
url: undefined,
|
|
681
|
+
uiNote: undefined,
|
|
682
|
+
}))
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// The rest gtd is at now: HEAD and, when the stop named one, its state.
|
|
686
|
+
// Undefined when gtd cannot say, which checks nothing.
|
|
687
|
+
async function currentRest($: $, state: string | undefined) {
|
|
688
|
+
try {
|
|
689
|
+
const now = state === undefined ? undefined : (await docs($).next()).state
|
|
690
|
+
return restId(await head($), now)
|
|
691
|
+
} catch {
|
|
692
|
+
return undefined
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// A continue the model asked for, once its turn is over: only if gtd still
|
|
697
|
+
// waits where the person answered, its file still validates (it can change
|
|
698
|
+
// outside this session, or across a reload), and no stop came in meanwhile.
|
|
699
|
+
async function launchPending($: $) {
|
|
700
|
+
const stops = stopCount
|
|
701
|
+
const r = await read($, run)
|
|
702
|
+
const pending = r.pending
|
|
703
|
+
if (!pending) return
|
|
704
|
+
await update($, run, (x) => ({ ...x, pending: undefined }))
|
|
705
|
+
const now = await currentRest($, r.stop?.state)
|
|
706
|
+
if (now !== undefined && now !== pending.rest) {
|
|
707
|
+
$.ui.toast("gtd moved on before it could continue: nothing was landed")
|
|
708
|
+
return
|
|
709
|
+
}
|
|
710
|
+
if (r.stop?.kind === "gate" && !r.stop.isJudge) {
|
|
711
|
+
const v = await validateRest($)
|
|
712
|
+
if (!v.ok) {
|
|
713
|
+
const text = `gtd did not continue: the step's file no longer validates.\n${v.out}\nFix it with the person, then call continue again with rest "${pending.rest}".`
|
|
714
|
+
if (stops === stopCount) $.clock.after(0, () => void tellModel($, text, stops))
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// Nothing awaited between this check and `start`'s own reset of the stop
|
|
719
|
+
// flag, so a stop cannot slip in between; `start` also clears the rest.
|
|
720
|
+
if (stops !== stopCount) return
|
|
721
|
+
await start($, pending.isAnswered ? { isAnswered: true, verdict: pending.verdict } : {})
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function note($: $, text: string) {
|
|
725
|
+
const r = await read($, run)
|
|
726
|
+
if (!r.isRunning && !r.stop) return "No gtd process is underway here."
|
|
727
|
+
await update($, run, (x) => ({ ...x, notes: [...(x.notes ?? []), text] }))
|
|
728
|
+
return "Noted for the next step that waits on the person."
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function bandContinue($: $) {
|
|
732
|
+
const refused = await proceed($)
|
|
733
|
+
if (!refused) return
|
|
734
|
+
$.ui.toast(refused.split("\n")[0]!)
|
|
735
|
+
for (const line of refused.split("\n")) $.ui.log(line)
|
|
632
736
|
}
|
|
633
737
|
|
|
634
738
|
async function dismiss($: $) {
|
|
635
739
|
moveOn($)
|
|
636
740
|
closeUi()
|
|
637
|
-
await update($, run, (r) => ({
|
|
741
|
+
await update($, run, (r) => ({
|
|
742
|
+
...r,
|
|
743
|
+
stop: undefined,
|
|
744
|
+
rest: undefined,
|
|
745
|
+
url: undefined,
|
|
746
|
+
uiNote: undefined,
|
|
747
|
+
}))
|
|
638
748
|
}
|
|
639
749
|
|
|
640
750
|
const DROPPED = "gtd subagent notice"
|
|
@@ -643,12 +753,18 @@ async function refreshBand($: $) {
|
|
|
643
753
|
if ((await read($, run)).isRunning) $.ui.invalidate("ui.render")
|
|
644
754
|
}
|
|
645
755
|
|
|
756
|
+
// A continue still waiting for the model's turn to end is dropped too.
|
|
757
|
+
async function stopNow($: $) {
|
|
758
|
+
stopRequested = true
|
|
759
|
+
stopCount++
|
|
760
|
+
if (!(await read($, run)).pending) return "Stopping after the current beat."
|
|
761
|
+
await update($, run, (x) => ({ ...x, pending: undefined }))
|
|
762
|
+
return "Cancelled the pending continue; gtd stays where it is."
|
|
763
|
+
}
|
|
764
|
+
|
|
646
765
|
// `/gtd [verb] …`; anything that is not a verb is a new process's requirements.
|
|
647
766
|
async function command($: $, arg: string): Promise<string | undefined> {
|
|
648
|
-
if (arg === "stop")
|
|
649
|
-
stopRequested = true
|
|
650
|
-
return "Stopping after the current beat."
|
|
651
|
-
}
|
|
767
|
+
if (arg === "stop") return stopNow($)
|
|
652
768
|
const problem = await preflight($)
|
|
653
769
|
if (problem) return problem
|
|
654
770
|
const [verb = "", ...rest] = arg.split(/\s+/)
|
|
@@ -656,15 +772,15 @@ async function command($: $, arg: string): Promise<string | undefined> {
|
|
|
656
772
|
case "status":
|
|
657
773
|
return gtd($, ["next"])
|
|
658
774
|
case "throw":
|
|
659
|
-
|
|
660
|
-
|
|
775
|
+
return throwNow($, rest[0])
|
|
776
|
+
case "ui":
|
|
777
|
+
return browse($)
|
|
661
778
|
case "catch":
|
|
662
779
|
return rest[0]
|
|
663
780
|
? catchNow($, rest[0])
|
|
664
781
|
: "Name the pull request or branch: /gtd catch <number|branch>."
|
|
665
782
|
case "ship":
|
|
666
|
-
|
|
667
|
-
return undefined
|
|
783
|
+
return shipNow($, rest[0] === "-n" || rest[0] === "--dry-run")
|
|
668
784
|
case "":
|
|
669
785
|
return resume($)
|
|
670
786
|
default:
|
|
@@ -703,12 +819,70 @@ async function resumeAfterReload($: $, was: Run) {
|
|
|
703
819
|
state: was.state,
|
|
704
820
|
label: was.label,
|
|
705
821
|
}
|
|
706
|
-
|
|
822
|
+
const rest = restId(await head($), was.state)
|
|
823
|
+
await update($, run, (r) => ({ ...r, stop, rest }))
|
|
707
824
|
$.ui.status(undefined)
|
|
708
825
|
$.ui.toast(headline(stop))
|
|
826
|
+
await wake($)
|
|
709
827
|
return
|
|
710
828
|
}
|
|
711
|
-
await start($, next === "land" ? inflight?.memory :
|
|
829
|
+
await start($, next === "land" ? { landTurn: inflight?.memory } : {})
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Whether an agent is one of gtd's workers or spawned (at any depth) by one;
|
|
833
|
+
// any other agent works for the session's model and shares its limits.
|
|
834
|
+
const lineage = new Map<string, boolean>()
|
|
835
|
+
async function isOurs($: $, id: string) {
|
|
836
|
+
if (ours.has(id)) return true
|
|
837
|
+
const known = lineage.get(id)
|
|
838
|
+
if (known !== undefined) return known
|
|
839
|
+
const byId = new Map((await $.agent.list()).map((a) => [a.id, a]))
|
|
840
|
+
let parent = byId.get(id)?.parentId
|
|
841
|
+
for (let hops = 0; parent && hops < 16; hops++) {
|
|
842
|
+
if (ours.has(parent)) break
|
|
843
|
+
parent = byId.get(parent)?.parentId
|
|
844
|
+
}
|
|
845
|
+
const isWorker = parent !== undefined && ours.has(parent)
|
|
846
|
+
lineage.set(id, isWorker)
|
|
847
|
+
return isWorker
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// What a subagent this mod spawned may not do: write outside its step's
|
|
851
|
+
// access, or, answering into a file, anything but that.
|
|
852
|
+
function subagentDenial(agentId: string, e: Record<string, unknown>) {
|
|
853
|
+
if (!ours.has(agentId)) return undefined
|
|
854
|
+
const access = accesses.get(agentId)
|
|
855
|
+
const denial = access && accessDenial(String(e.tool), e, access, root)
|
|
856
|
+
if (denial) return denial
|
|
857
|
+
const delegated = delegates.get(agentId)
|
|
858
|
+
if (!delegated) return undefined
|
|
859
|
+
if (e.tool === "Write" && e.file_path !== delegated.file) {
|
|
860
|
+
return `Write only your answer, to ${delegated.file}.`
|
|
861
|
+
}
|
|
862
|
+
if (e.tool === "Bash" && !delegated.mayRun?.test(String(e.command).trim())) {
|
|
863
|
+
return "Only read-only git commands, without shell syntax, are allowed here."
|
|
864
|
+
}
|
|
865
|
+
return undefined
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// What the main session may write now (see mainDenial).
|
|
869
|
+
async function phase($: $) {
|
|
870
|
+
const r = await read($, run)
|
|
871
|
+
const stop = r.stop
|
|
872
|
+
const atQa = stop?.kind === "gate" && !stop.isJudge
|
|
873
|
+
// A continue waiting for the turn to end is as good as running: the file
|
|
874
|
+
// it validated must not change before it lands.
|
|
875
|
+
const isRunning = r.isRunning || r.pending !== undefined
|
|
876
|
+
return { isRunning, mode: atQa ? stop.mode : undefined, file: stop?.file }
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// A tool's answer. For `continue`, any text is a refusal the model reads as an
|
|
880
|
+
// error; the other tools report what happened either way.
|
|
881
|
+
function answer(text: string | undefined, isReport = false) {
|
|
882
|
+
if (text === undefined) {
|
|
883
|
+
return { result: "gtd continues as soon as this turn ends: end your turn now." }
|
|
884
|
+
}
|
|
885
|
+
return isReport ? { result: text } : { deny: text }
|
|
712
886
|
}
|
|
713
887
|
|
|
714
888
|
export const register: Register = (on) => {
|
|
@@ -716,16 +890,17 @@ export const register: Register = (on) => {
|
|
|
716
890
|
// The band's elapsed time ticks while a step runs.
|
|
717
891
|
$.clock.every(10_000, () => void refreshBand($))
|
|
718
892
|
for (const id of await read($, agents)) ours.add(id)
|
|
719
|
-
// A reload drops the loop mid-beat
|
|
720
|
-
//
|
|
893
|
+
// A reload drops the loop mid-beat; gtd keeps its state in git, so pick it
|
|
894
|
+
// up again. A rest needs nothing: the model already holds its prompt.
|
|
721
895
|
const was = await read($, run)
|
|
722
896
|
if (was.isRunning) void resumeAfterReload($, was)
|
|
723
|
-
else
|
|
897
|
+
else void findRoot($).then(() => launchPending($))
|
|
898
|
+
for (const tool of TOOLS) await $.tool.register(tool)
|
|
724
899
|
await $.command.register({
|
|
725
900
|
name: "gtd",
|
|
726
901
|
description: "Drive gtd until it rests on you; pass requirements to start a new process",
|
|
727
902
|
argumentHint:
|
|
728
|
-
"[requirements | <door> [args] | stop | status | ship [-n] | throw [@user] | catch <pr|branch>]",
|
|
903
|
+
"[requirements | <door> [args] | stop | status | ui | ship [-n] | throw [@user] | catch <pr|branch>]",
|
|
729
904
|
immediate: true,
|
|
730
905
|
})
|
|
731
906
|
return next(e)
|
|
@@ -737,6 +912,7 @@ export const register: Register = (on) => {
|
|
|
737
912
|
}).catch(($, e, next) => ({ text: next.error.message }))
|
|
738
913
|
|
|
739
914
|
on("turn.complete", ($, e, next) => {
|
|
915
|
+
if (!e.agentId) void launchPending($)
|
|
740
916
|
if (e.agentId && ours.has(e.agentId)) {
|
|
741
917
|
const end: TurnEnd =
|
|
742
918
|
e.reason === "answer" ? { ok: true } : { ok: false, why: `agent turn ended: ${e.reason}` }
|
|
@@ -750,20 +926,13 @@ export const register: Register = (on) => {
|
|
|
750
926
|
|
|
751
927
|
// The loop lands a turn when it ends; work left running in the background
|
|
752
928
|
// would still be writing the tree gtd is committing.
|
|
753
|
-
on("tool.call", ($, e, next) => {
|
|
754
|
-
|
|
755
|
-
const
|
|
756
|
-
|
|
929
|
+
on("tool.call", async ($, e, next) => {
|
|
930
|
+
const isWorker = e.agentId !== undefined && (await isOurs($, e.agentId))
|
|
931
|
+
const denial = isWorker
|
|
932
|
+
? subagentDenial(e.agentId!, e)
|
|
933
|
+
: mainDenial(String(e.tool), e, await phase($), root)
|
|
757
934
|
if (denial) return { deny: denial }
|
|
758
|
-
|
|
759
|
-
if (delegated) {
|
|
760
|
-
if (e.tool === "Write" && e.file_path !== delegated.file) {
|
|
761
|
-
return { deny: `Write only your answer, to ${delegated.file}.` }
|
|
762
|
-
}
|
|
763
|
-
if (e.tool === "Bash" && !delegated.mayRun?.test(String(e.command).trim())) {
|
|
764
|
-
return { deny: "Only read-only git commands, without shell syntax, are allowed here." }
|
|
765
|
-
}
|
|
766
|
-
}
|
|
935
|
+
if (!e.agentId || !ours.has(e.agentId)) return next(e)
|
|
767
936
|
if (e.tool === "Bash") {
|
|
768
937
|
return next({ ...e, run_in_background: false, timeout: e.timeout ?? TEN_MINUTES })
|
|
769
938
|
}
|
|
@@ -781,29 +950,19 @@ export const register: Register = (on) => {
|
|
|
781
950
|
return next(e)
|
|
782
951
|
})
|
|
783
952
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
multiSelect: false,
|
|
798
|
-
options: [
|
|
799
|
-
{ label: CLOSE, description: "Nothing happens." },
|
|
800
|
-
{ label: LATER, description: "Nothing happens." },
|
|
801
|
-
],
|
|
802
|
-
},
|
|
803
|
-
],
|
|
804
|
-
},
|
|
805
|
-
})
|
|
806
|
-
})
|
|
953
|
+
on("tool.call", { tool: "mcp__gtd__continue" }, async ($, e) =>
|
|
954
|
+
answer(await proceed($, String(e.rest ?? ""), e.verdicts, true)),
|
|
955
|
+
)
|
|
956
|
+
on("tool.call", { tool: "mcp__gtd__note" }, async ($, e) =>
|
|
957
|
+
answer(await note($, String(e.text ?? "")), true),
|
|
958
|
+
)
|
|
959
|
+
on("tool.call", { tool: "mcp__gtd__handoff" }, async ($, e) =>
|
|
960
|
+
answer(await throwNow($, typeof e.to === "string" && e.to ? e.to : undefined), true),
|
|
961
|
+
)
|
|
962
|
+
on("tool.call", { tool: "mcp__gtd__ship" }, async ($, e) =>
|
|
963
|
+
answer(await shipNow($, e.dryRun === true), true),
|
|
964
|
+
)
|
|
965
|
+
on("tool.call", { tool: "mcp__gtd__browser" }, async ($) => answer(await browse($), true))
|
|
807
966
|
|
|
808
967
|
on("agent.offer", ($, e, next) => (e.agent.startsWith("gtd:") ? { isOffered: false } : next(e)))
|
|
809
968
|
|
|
@@ -832,7 +991,7 @@ export const register: Register = (on) => {
|
|
|
832
991
|
<Text color={TONE[stop.kind]} bold>
|
|
833
992
|
gtd {headline(stop)}
|
|
834
993
|
</Text>
|
|
835
|
-
<Button key="go" label="Continue" variant="primary" onPress={() =>
|
|
994
|
+
<Button key="go" label="Continue" variant="primary" onPress={() => bandContinue($)} />
|
|
836
995
|
<Button key="dismiss" label="Dismiss" role="dismiss" onPress={() => dismiss($)} />
|
|
837
996
|
</Box>
|
|
838
997
|
{ui && <Text dimColor>{ui}</Text>}
|