@pmelab/gtd 64.3.0 → 66.0.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 +35 -25
- package/claude/hooks/assist.ts +9 -35
- package/claude/hooks/doors.ts +4 -4
- package/claude/hooks/drive.ts +10 -1
- package/claude/hooks/git.ts +56 -0
- package/claude/hooks/handoff.ts +11 -11
- package/claude/hooks/jobs.ts +8 -8
- package/claude/hooks/pr.ts +86 -26
- package/claude/hooks/register.tsx +35 -82
- package/claude/hooks/tools.ts +7 -20
- package/claude/hooks/wording.ts +3 -3
- package/dist/chunks/{flows-dnIE_qbe.mjs → flows-CmQplvh1.mjs} +146 -35
- package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
- package/dist/chunks/{testkit-BUX-47TD.mjs → testkit-BJhAAVGt.mjs} +905 -99
- package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-xTbzFc26.mjs} +12 -6
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +965 -163
- package/dist/gtd.core.mjs +150 -134
- package/dist/steering.mjs +1 -1
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +2 -2
- package/dist/web/main.css +1 -1
- package/dist/web/main.js +185 -18
- package/dist/wire.mjs +1 -1
- package/dist/workflow-kit.mjs +9 -3
- package/package.json +1 -1
- package/src/cli/Cli.ts +5 -5
- package/src/cli/History.test.ts +10 -8
- package/src/cli/Metrics.test.ts +2 -1
- package/src/cli/Program.test.ts +4 -2
- package/src/cli/Program.ts +105 -7
- package/src/edge/Edge.test.ts +37 -16
- package/src/edge/Edge.ts +380 -33
- package/src/emit/GitScript.ts +116 -15
- package/src/emit/index.ts +7 -0
- package/src/flows/config.ts +7 -1
- package/src/flows/helpers.ts +36 -18
- package/src/flows/index.test.ts +4 -0
- package/src/flows/index.ts +7 -0
- package/src/flows/runtime.ts +153 -15
- package/src/install/Install.ts +9 -2
- package/src/platform/Git.test.ts +7 -11
- package/src/platform/NodeSync.ts +2 -0
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/SyncIo.ts +2 -0
- package/src/platform/Workspace.test.ts +37 -0
- package/src/platform/Workspace.ts +36 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Replay.test.ts +9 -0
- package/src/replay/Replay.ts +92 -16
- package/src/replay/Trailers.ts +17 -0
- package/src/replay/Tree.ts +5 -0
- package/src/replay/index.ts +1 -1
- package/src/steering/FORMATS.md +3 -1
- package/src/steering/SteeringFormat.ts +13 -0
- package/src/steering/fixtures/review/sectioned.md +1 -1
- package/src/steering/fixtures/review/sectioned.read.json +0 -1
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +181 -5
- package/src/steering/review.ts +160 -18
- package/src/step/LandStep.ts +12 -0
- package/src/step/RepoSnapshot.ts +15 -0
- package/src/step/index.ts +1 -0
- package/src/step/planStart.test.ts +4 -3
- package/src/step/planStart.ts +7 -0
- package/src/step/planStep.ts +22 -17
- package/src/step/startBranch.ts +54 -0
- package/src/twins/EmittedScriptRecognizer.ts +118 -1
- package/src/twins/EmittedScripts.test.ts +147 -1
- package/src/twins/InMemRepo.ts +140 -3
- package/src/twins/Layers.ts +1 -0
- package/src/twins/SyncIo.test.ts +19 -4
- package/src/twins/SyncIo.ts +13 -1
- package/src/wire/BeatDocument.test.ts +15 -0
- package/src/wire/BeatDocument.ts +8 -2
- package/src/wire/Demand.ts +26 -12
- package/src/wire/constants.ts +3 -0
- package/src/wire/golden/1/beat-capture.json +1 -0
- package/src/wire/golden/1/beat-message.json +1 -0
- package/src/wire/golden/1/beat-prompt.json +1 -0
- package/src/wire/golden/1/beat-script.json +1 -0
- package/src/wire/golden/1/beat-stalled.json +1 -0
- package/src/wire/types.ts +4 -0
- package/src/workflow/Workflow.ts +3 -0
- package/src/workflow/compile.ts +2 -0
- package/src/workflow/load.ts +2 -0
- package/src/workflow-kit/index.test.ts +1 -0
- package/src/workflow-kit/index.ts +1 -0
- package/src/workflow-kit/scenarios.ts +8 -0
- package/claude/hooks/ship.ts +0 -730
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
decided,
|
|
16
16
|
missingReasons,
|
|
17
17
|
reply,
|
|
18
|
+
processTip,
|
|
18
19
|
REVIEW_FILE,
|
|
19
20
|
unresolvedThreads,
|
|
20
21
|
withThreads,
|
|
@@ -25,9 +26,8 @@ import type { Judgment } from "./judge"
|
|
|
25
26
|
import { catchFrom, throwTo } from "./handoff"
|
|
26
27
|
import { jobs } from "./jobs"
|
|
27
28
|
import { documents, readDocument } from "./schema"
|
|
28
|
-
import { READ_ONLY_GIT, ship } from "./ship"
|
|
29
29
|
import { withRequirements } from "./sketch"
|
|
30
|
-
import type {
|
|
30
|
+
import type { Done, GitIo } from "./git"
|
|
31
31
|
import { collectAnswer } from "./delegate"
|
|
32
32
|
import { decideCommand, decideContinue, decideJob, restId, TOOLS } from "./tools"
|
|
33
33
|
import type { JobName } from "./tools"
|
|
@@ -72,7 +72,7 @@ let isDriving = false
|
|
|
72
72
|
// bumped by every stop, so a queued continue that was mid-check sees it
|
|
73
73
|
let stopCount = 0
|
|
74
74
|
// subagents answering into a file: what each may touch (see tool.call)
|
|
75
|
-
const delegates = new Map<string, { file: string
|
|
75
|
+
const delegates = new Map<string, { file: string }>()
|
|
76
76
|
const ours = new Set<string>()
|
|
77
77
|
// What each turn agent may read and write; reset on every spawn and resume,
|
|
78
78
|
// since the steering file can differ per step.
|
|
@@ -218,7 +218,7 @@ function io($: $): Io {
|
|
|
218
218
|
// held it): start one, as the sh driver falls back to `--session-id`.
|
|
219
219
|
const spawned = await $.agent.spawn({
|
|
220
220
|
subagentType: await persona($, t),
|
|
221
|
-
prompt: t.prompt,
|
|
221
|
+
prompt: t.fresh ?? t.prompt,
|
|
222
222
|
description: t.label || "gtd",
|
|
223
223
|
model: await workerModel($, t.model),
|
|
224
224
|
cwd: root,
|
|
@@ -295,8 +295,7 @@ async function wake($: $) {
|
|
|
295
295
|
const r = await read($, run)
|
|
296
296
|
const stop = r.stop
|
|
297
297
|
if (!stop || !r.rest) return
|
|
298
|
-
|
|
299
|
-
if (!wakes(stop, branch)) return
|
|
298
|
+
if (!wakes(stop)) return
|
|
300
299
|
const questions = stop.isJudge ? await openQuestions($) : undefined
|
|
301
300
|
const text = wakePrompt({
|
|
302
301
|
stop,
|
|
@@ -304,7 +303,6 @@ async function wake($: $) {
|
|
|
304
303
|
notes: r.notes,
|
|
305
304
|
isRepeat: r.isRepeat,
|
|
306
305
|
questions,
|
|
307
|
-
branch,
|
|
308
306
|
})
|
|
309
307
|
await update($, run, (x) => ({ ...x, notes: undefined }))
|
|
310
308
|
// Not from inside the hook that started the run: a prompt submitted there
|
|
@@ -321,16 +319,6 @@ async function openQuestions($: $) {
|
|
|
321
319
|
}
|
|
322
320
|
}
|
|
323
321
|
|
|
324
|
-
// The branch a finished process can open its pull request from, if any.
|
|
325
|
-
async function shippable($: $) {
|
|
326
|
-
const branch = (
|
|
327
|
-
await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd: root })
|
|
328
|
-
).stdout.trim()
|
|
329
|
-
if (["main", "master", "HEAD"].includes(branch)) return undefined
|
|
330
|
-
const summary = await $.process.run(["gtd", "summary"], { cwd: root })
|
|
331
|
-
return summary.exitCode === 0 ? branch : undefined
|
|
332
|
-
}
|
|
333
|
-
|
|
334
322
|
// A new process starts from the requirements written to the steering file an
|
|
335
323
|
// idle rest names; the opening beat captures them like a hand-edit.
|
|
336
324
|
async function begin($: $, requirements: string) {
|
|
@@ -440,8 +428,8 @@ function startJob(
|
|
|
440
428
|
$: $,
|
|
441
429
|
job: JobName,
|
|
442
430
|
isInTurn: boolean,
|
|
443
|
-
work: () => Promise<
|
|
444
|
-
then?: (outcome:
|
|
431
|
+
work: () => Promise<Done>,
|
|
432
|
+
then?: (outcome: Done) => Promise<void>,
|
|
445
433
|
) {
|
|
446
434
|
const answer = background.start(job, work, true, async (outcome) => {
|
|
447
435
|
if (then) await then(outcome)
|
|
@@ -457,7 +445,7 @@ function startJob(
|
|
|
457
445
|
}
|
|
458
446
|
|
|
459
447
|
async function throwNow($: $, target: string | undefined, isInTurn: boolean) {
|
|
460
|
-
const refused = decideJob(await read($, run)
|
|
448
|
+
const refused = decideJob(await read($, run))
|
|
461
449
|
if (refused) return refused
|
|
462
450
|
return startJob(
|
|
463
451
|
$,
|
|
@@ -474,7 +462,7 @@ async function throwNow($: $, target: string | undefined, isInTurn: boolean) {
|
|
|
474
462
|
content: b.content,
|
|
475
463
|
isIdle: isTrue(b.idle),
|
|
476
464
|
}
|
|
477
|
-
return throwTo(
|
|
465
|
+
return throwTo(gitIo($), rest, target, new Date().toISOString())
|
|
478
466
|
},
|
|
479
467
|
async (thrown) => {
|
|
480
468
|
if (!thrown.ok) return
|
|
@@ -490,7 +478,7 @@ async function throwNow($: $, target: string | undefined, isInTurn: boolean) {
|
|
|
490
478
|
|
|
491
479
|
async function enterNow($: $, door: string, args: string[]) {
|
|
492
480
|
if ((await read($, run)).isRunning) return "Stop the loop first: /gtd stop."
|
|
493
|
-
const entered = await enter(
|
|
481
|
+
const entered = await enter(gitIo($), door, args)
|
|
494
482
|
if (entered.ok) await start($)
|
|
495
483
|
return entered.text
|
|
496
484
|
}
|
|
@@ -511,7 +499,7 @@ async function prNow($: $) {
|
|
|
511
499
|
if (r.pr && !isReviewGate(r.stop)) {
|
|
512
500
|
return "Review threads from an earlier /gtd pr wait for the review's first review gate."
|
|
513
501
|
}
|
|
514
|
-
const io =
|
|
502
|
+
const io = gitIo($)
|
|
515
503
|
const pr = await currentPr(io)
|
|
516
504
|
if (typeof pr === "string") return pr
|
|
517
505
|
const threads = await unresolvedThreads(io, pr)
|
|
@@ -585,11 +573,13 @@ async function prRest($: $, stop: Stop) {
|
|
|
585
573
|
async function replyThreads($: $, stop: Stop) {
|
|
586
574
|
const pr = (await read($, run)).pr
|
|
587
575
|
if (!pr?.importedAt) return
|
|
588
|
-
const io =
|
|
576
|
+
const io = gitIo($)
|
|
577
|
+
const { tip } = await processTip(io)
|
|
589
578
|
const found = await decided(
|
|
590
579
|
io,
|
|
591
580
|
pr.importedAt,
|
|
592
581
|
pr.threads.map((t) => t.id),
|
|
582
|
+
tip,
|
|
593
583
|
)
|
|
594
584
|
if (found === "gone") {
|
|
595
585
|
$.ui.log("gtd pr: the history moved since the review threads were added; they get no replies.")
|
|
@@ -605,12 +595,24 @@ async function replyThreads($: $, stop: Stop) {
|
|
|
605
595
|
$.ui.status("replying to the review threads…")
|
|
606
596
|
let replied
|
|
607
597
|
try {
|
|
608
|
-
replied = await reply(io, pr.host, found.gate, found.answers)
|
|
598
|
+
replied = await reply(io, pr.host, found.gate, found.answers, stop.kind === "done")
|
|
609
599
|
} finally {
|
|
610
600
|
$.ui.status(undefined)
|
|
611
601
|
}
|
|
612
|
-
|
|
613
|
-
|
|
602
|
+
if (replied.text) {
|
|
603
|
+
for (const line of replied.text.split("\n")) $.ui.log(line)
|
|
604
|
+
$.ui.toast(replied.text.split("\n")[0]!)
|
|
605
|
+
}
|
|
606
|
+
if (replied.waiting.length > 0) {
|
|
607
|
+
// Unshipped fixes keep their threads; answered ones are not answered twice.
|
|
608
|
+
if (replied.isPushed) {
|
|
609
|
+
const keep = new Set(replied.waiting.map((a) => a.thread))
|
|
610
|
+
await update($, run, (x) =>
|
|
611
|
+
x.pr ? { ...x, pr: { ...x.pr, threads: x.pr.threads.filter((t) => keep.has(t.id)) } } : x,
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
return
|
|
615
|
+
}
|
|
614
616
|
if (replied.isPushed) await forgetPr($)
|
|
615
617
|
}
|
|
616
618
|
|
|
@@ -618,7 +620,7 @@ async function replyThreads($: $, stop: Stop) {
|
|
|
618
620
|
// shown to the catcher first, never landed unseen.
|
|
619
621
|
async function catchNow($: $, ref: string) {
|
|
620
622
|
if ((await read($, run)).isRunning) return "Stop the loop first: /gtd stop."
|
|
621
|
-
const caught = await catchFrom(
|
|
623
|
+
const caught = await catchFrom(gitIo($), ref)
|
|
622
624
|
if (!caught.ok) return caught.text
|
|
623
625
|
await findRoot($)
|
|
624
626
|
const b = await docs($).next()
|
|
@@ -636,27 +638,11 @@ async function catchNow($: $, ref: string) {
|
|
|
636
638
|
return caught.text
|
|
637
639
|
}
|
|
638
640
|
|
|
639
|
-
// Ship's text turns need git to read the process, so they run as a subagent.
|
|
640
|
-
// Its answer is a file, not its hand-back: a hand-back never reaches a mod.
|
|
641
|
-
// `mayRun`: the only shell commands the subagent may run; none when absent.
|
|
642
641
|
type Delegate = {
|
|
643
642
|
name: string
|
|
644
643
|
description: string
|
|
645
644
|
system: string
|
|
646
645
|
tools: string[]
|
|
647
|
-
mayRun?: RegExp
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
const SHIPPER: Delegate = {
|
|
651
|
-
name: "shipper",
|
|
652
|
-
description:
|
|
653
|
-
"Writes commit messages and pull requests for gtd's ship, spawned by the gtd mod only",
|
|
654
|
-
system:
|
|
655
|
-
"You write commit messages and pull-request descriptions for gtd's ship command. Read the repository with git and the file tools as the instructions ask. Never modify the repository: no commits, no checkouts, no pushes.",
|
|
656
|
-
tools: ["Bash", "Read", "Grep", "Glob", "Write", "Skill"],
|
|
657
|
-
// It reads commit messages anyone on the branch wrote: read-only git, and
|
|
658
|
-
// no shell syntax to chain anything else onto it.
|
|
659
|
-
mayRun: READ_ONLY_GIT,
|
|
660
646
|
}
|
|
661
647
|
|
|
662
648
|
// gtd's llm judge persona; writing its answer file is its one tool.
|
|
@@ -693,7 +679,7 @@ async function delegate($: $, who: Delegate, prompt: string, model: string, deli
|
|
|
693
679
|
const id = spawned.agentId
|
|
694
680
|
if (!id) return undefined
|
|
695
681
|
ours.add(id)
|
|
696
|
-
delegates.set(id, { file
|
|
682
|
+
delegates.set(id, { file })
|
|
697
683
|
// A timed-out agent may still be running: it keeps its tool limits until its
|
|
698
684
|
// turn ends, where turn.complete drops them.
|
|
699
685
|
return collectAnswer({
|
|
@@ -704,11 +690,6 @@ async function delegate($: $, who: Delegate, prompt: string, model: string, deli
|
|
|
704
690
|
})
|
|
705
691
|
}
|
|
706
692
|
|
|
707
|
-
async function writer($: $, prompt: string) {
|
|
708
|
-
const model = (await $.env.get("GTD_PLANNERMODEL")) ?? "opus"
|
|
709
|
-
return delegate($, SHIPPER, prompt, model, "Your final answer, exactly as you would print it.")
|
|
710
|
-
}
|
|
711
|
-
|
|
712
693
|
// Where `gtd judge run` would pick its llm, a judge gate is put to a small
|
|
713
694
|
// model as that provider does, but as a subagent of this session. Any doubt leaves the
|
|
714
695
|
// gate to the person, which routes the workflow its cautious way.
|
|
@@ -729,39 +710,19 @@ async function judgeInSession($: $, j: Judgment) {
|
|
|
729
710
|
}
|
|
730
711
|
}
|
|
731
712
|
|
|
732
|
-
function
|
|
713
|
+
function gitIo($: $): GitIo {
|
|
733
714
|
return {
|
|
734
715
|
run: async (argv, stdin) => {
|
|
735
716
|
const r = await $.process.run(argv, { cwd: root, stdin, timeoutMs: TEN_MINUTES })
|
|
736
717
|
return { code: r.exitCode, out: r.stdout, err: r.stderr }
|
|
737
718
|
},
|
|
738
|
-
complete: (prompt) => writer($, prompt),
|
|
739
719
|
log: (line) => {
|
|
740
720
|
for (const l of line.split("\n")) $.ui.log(l)
|
|
741
721
|
if (background.busy()) $.ui.status(line.split("\n")[0]!)
|
|
742
722
|
},
|
|
743
|
-
today: () => new Date().toISOString().slice(0, 10),
|
|
744
723
|
}
|
|
745
724
|
}
|
|
746
725
|
|
|
747
|
-
async function shipNow($: $, isDry: boolean, isInTurn: boolean) {
|
|
748
|
-
const refused = decideJob(await read($, run), "ship")
|
|
749
|
-
if (refused) return refused
|
|
750
|
-
return startJob(
|
|
751
|
-
$,
|
|
752
|
-
"ship",
|
|
753
|
-
isInTurn,
|
|
754
|
-
async () => {
|
|
755
|
-
$.ui.status(isDry ? "previewing the pull request…" : "opening the pull request…")
|
|
756
|
-
return ship(shipIo($), isDry)
|
|
757
|
-
},
|
|
758
|
-
async (shipped) => {
|
|
759
|
-
// a failed ship leaves the run as it was; a dry run changes nothing
|
|
760
|
-
if (shipped.ok && !isDry) await dismiss($)
|
|
761
|
-
},
|
|
762
|
-
)
|
|
763
|
-
}
|
|
764
|
-
|
|
765
726
|
// Turns a missing CLI or a wrong directory into an instruction, not a stack trace.
|
|
766
727
|
// The plugin is gtd's own npm package, so it runs the gtd it ships and the mod
|
|
767
728
|
// and the CLI never disagree. A checkout without a build falls back to PATH.
|
|
@@ -986,8 +947,6 @@ async function command($: $, arg: string): Promise<string | undefined> {
|
|
|
986
947
|
return rest[0]
|
|
987
948
|
? catchNow($, rest[0])
|
|
988
949
|
: "Name the pull request or branch: /gtd catch <number|branch>."
|
|
989
|
-
case "ship":
|
|
990
|
-
return shipNow($, rest[0] === "-n" || rest[0] === "--dry-run", false)
|
|
991
950
|
case "":
|
|
992
951
|
return resume($)
|
|
993
952
|
default:
|
|
@@ -997,7 +956,7 @@ async function command($: $, arg: string): Promise<string | undefined> {
|
|
|
997
956
|
|
|
998
957
|
// A door wins over the bare `pr` verb; anything else is requirements.
|
|
999
958
|
async function routed($: $, verb: string, rest: string[], arg: string) {
|
|
1000
|
-
if (doorFor(await doors(
|
|
959
|
+
if (doorFor(await doors(gitIo($)), verb, rest)) return enterNow($, verb, rest)
|
|
1001
960
|
return verb === "pr" && rest.length === 0 ? prNow($) : begin($, arg)
|
|
1002
961
|
}
|
|
1003
962
|
|
|
@@ -1067,12 +1026,9 @@ function subagentDenial(agentId: string, e: Record<string, unknown>) {
|
|
|
1067
1026
|
if (denial) return denial
|
|
1068
1027
|
const delegated = delegates.get(agentId)
|
|
1069
1028
|
if (!delegated) return undefined
|
|
1070
|
-
if (e.tool === "Write" && e.file_path !== delegated.file) {
|
|
1029
|
+
if ((e.tool === "Write" && e.file_path !== delegated.file) || e.tool === "Bash") {
|
|
1071
1030
|
return `Write only your answer, to ${delegated.file}.`
|
|
1072
1031
|
}
|
|
1073
|
-
if (e.tool === "Bash" && !delegated.mayRun?.test(String(e.command).trim())) {
|
|
1074
|
-
return "Only read-only git commands, without shell syntax, are allowed here."
|
|
1075
|
-
}
|
|
1076
1032
|
return undefined
|
|
1077
1033
|
}
|
|
1078
1034
|
|
|
@@ -1111,7 +1067,7 @@ export const register: Register = (on) => {
|
|
|
1111
1067
|
name: "gtd",
|
|
1112
1068
|
description: "Drive gtd until it rests on you; pass requirements to start a new process",
|
|
1113
1069
|
argumentHint:
|
|
1114
|
-
"[requirements | <door> [args] | stop | status | ui |
|
|
1070
|
+
"[requirements | <door> [args] | stop | status | ui | pr | throw [@user] | catch <pr|branch>]",
|
|
1115
1071
|
immediate: true,
|
|
1116
1072
|
})
|
|
1117
1073
|
return next(e)
|
|
@@ -1191,9 +1147,6 @@ export const register: Register = (on) => {
|
|
|
1191
1147
|
on("tool.call", { tool: "mcp__gtd__handoff" }, async ($, e) =>
|
|
1192
1148
|
answer(await throwNow($, typeof e.to === "string" && e.to ? e.to : undefined, true), true),
|
|
1193
1149
|
)
|
|
1194
|
-
on("tool.call", { tool: "mcp__gtd__ship" }, async ($, e) =>
|
|
1195
|
-
answer(await shipNow($, e.dryRun === true, true), true),
|
|
1196
|
-
)
|
|
1197
1150
|
on("tool.call", { tool: "mcp__gtd__browser" }, async ($) => answer(await browse($), true))
|
|
1198
1151
|
|
|
1199
1152
|
on("agent.offer", ($, e, next) => (e.agent.startsWith("gtd:") ? { isOffered: false } : next(e)))
|
package/claude/hooks/tools.ts
CHANGED
|
@@ -10,7 +10,6 @@ export const TOOL = {
|
|
|
10
10
|
continue: "mcp__gtd__continue",
|
|
11
11
|
note: "mcp__gtd__note",
|
|
12
12
|
handoff: "mcp__gtd__handoff",
|
|
13
|
-
ship: "mcp__gtd__ship",
|
|
14
13
|
browser: "mcp__gtd__browser",
|
|
15
14
|
} as const
|
|
16
15
|
|
|
@@ -44,12 +43,6 @@ export const TOOLS = [
|
|
|
44
43
|
"Hands the gtd process to someone else: pushes the branch and opens or refreshes a draft pull request assigned to them. Call it only when the person asks. `to`: their GitHub username; omit it to leave the process for anyone.",
|
|
45
44
|
inputSchema: { type: "object", properties: { to: { type: "string" } } },
|
|
46
45
|
},
|
|
47
|
-
{
|
|
48
|
-
name: "ship",
|
|
49
|
-
description:
|
|
50
|
-
"Combines the finished gtd process into one commit, pushes it and opens or updates its pull request. Call it only after the person agreed. `dryRun` previews without writing anything.",
|
|
51
|
-
inputSchema: { type: "object", properties: { dryRun: { type: "boolean" } } },
|
|
52
|
-
},
|
|
53
46
|
{
|
|
54
47
|
name: "browser",
|
|
55
48
|
description:
|
|
@@ -71,21 +64,21 @@ export type ContinueIo = {
|
|
|
71
64
|
// the rest's own `gtd validate` script, run; ok when there is nothing to check
|
|
72
65
|
validate(): Promise<{ ok: boolean; out: string }>
|
|
73
66
|
judgment(): Promise<Judgment | undefined>
|
|
74
|
-
// a
|
|
67
|
+
// a hand-off running or queued: it writes the tree the driver would
|
|
75
68
|
busy?: JobName
|
|
76
69
|
}
|
|
77
70
|
|
|
78
|
-
export type JobName = "
|
|
71
|
+
export type JobName = "handoff"
|
|
79
72
|
|
|
80
|
-
export const JOB_NAME: Record<JobName, string> = {
|
|
73
|
+
export const JOB_NAME: Record<JobName, string> = { handoff: "hand-off" }
|
|
81
74
|
|
|
82
75
|
const busyText = (job: JobName) => `A ${JOB_NAME[job]} is still running: wait for its report.`
|
|
83
76
|
|
|
84
|
-
// Only these leave the tree alone (
|
|
77
|
+
// Only these leave the tree alone (throw refuses for itself); any
|
|
85
78
|
// other verb, unknown ones included, may switch branch or write .gtd/. `stop`
|
|
86
79
|
// is not here: `command` takes the bare word itself, and `stop <words>` is a
|
|
87
80
|
// requirement. `ui` is not: its page writes steering files.
|
|
88
|
-
const WHILE_BUSY = new Set(["status", "throw"
|
|
81
|
+
const WHILE_BUSY = new Set(["status", "throw"])
|
|
89
82
|
|
|
90
83
|
export function decideCommand(arg: string, busy: JobName | undefined): string | undefined {
|
|
91
84
|
if (!busy) return undefined
|
|
@@ -94,13 +87,9 @@ export function decideCommand(arg: string, busy: JobName | undefined): string |
|
|
|
94
87
|
}
|
|
95
88
|
|
|
96
89
|
// Pure: a refused job leaves the run, and so `continue`, as it was.
|
|
97
|
-
export function decideJob(run: Run
|
|
90
|
+
export function decideJob(run: Run): string | undefined {
|
|
98
91
|
if (run.isRunning) return "gtd is running a step: wait for it to rest."
|
|
99
92
|
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
93
|
return undefined
|
|
105
94
|
}
|
|
106
95
|
|
|
@@ -116,9 +105,7 @@ export async function decideContinue(
|
|
|
116
105
|
if (io.busy) return { refuse: busyText(io.busy) }
|
|
117
106
|
const stop = run.stop
|
|
118
107
|
if (!stop) return { refuse: "Nothing is waiting: gtd has no rest open. /gtd drives it." }
|
|
119
|
-
if (stop.kind === "done") {
|
|
120
|
-
return { refuse: `The process is finished: ${TOOL.ship} opens its pull request.` }
|
|
121
|
-
}
|
|
108
|
+
if (stop.kind === "done") return { refuse: "The process is finished: nothing waits here." }
|
|
122
109
|
if (rest !== undefined && rest !== run.rest) {
|
|
123
110
|
return {
|
|
124
111
|
refuse: "gtd has moved on from that step; it no longer waits there. Nothing was changed.",
|
package/claude/hooks/wording.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Stop } from "../types"
|
|
4
4
|
import type { Beat } from "./drive"
|
|
5
|
-
import type {
|
|
5
|
+
import type { Done } from "./git"
|
|
6
6
|
import { JOB_NAME } from "./tools"
|
|
7
7
|
import type { JobName } from "./tools"
|
|
8
8
|
|
|
@@ -61,8 +61,8 @@ export function pushText(stop: Stop, repo: string, url?: string) {
|
|
|
61
61
|
|
|
62
62
|
const capitalised = (s: string) => s[0]!.toUpperCase() + s.slice(1)
|
|
63
63
|
|
|
64
|
-
export const jobHeadline = (job: JobName, outcome:
|
|
64
|
+
export const jobHeadline = (job: JobName, outcome: Done) =>
|
|
65
65
|
`${outcome.ok ? "✔" : "✘"} ${capitalised(JOB_NAME[job])} ${outcome.ok ? "finished" : "failed"}`
|
|
66
66
|
|
|
67
|
-
export const jobPushText = (job: JobName, outcome:
|
|
67
|
+
export const jobPushText = (job: JobName, outcome: Done, repo: string) =>
|
|
68
68
|
`${repo}: ${JOB_NAME[job]} ${outcome.ok ? "finished" : "failed"}. ${outcome.text}`.trim()
|
|
@@ -37,29 +37,106 @@ const ctx = () => {
|
|
|
37
37
|
}
|
|
38
38
|
return context;
|
|
39
39
|
};
|
|
40
|
+
const validatingBy = /* @__PURE__ */ new WeakMap();
|
|
41
|
+
const DEFAULT_CAP = 3;
|
|
42
|
+
const noValidatorStep = (kind, name) => {
|
|
43
|
+
const validating = validatingBy.get(ctx());
|
|
44
|
+
if (validating !== void 0) throw new Error(`gtd: step "${name}": ${kind}() cannot run inside the validator of "${validating.name}" — a validator may only run check, run and judge steps`);
|
|
45
|
+
};
|
|
46
|
+
const noteValidatorPaths = () => {
|
|
47
|
+
const validating = validatingBy.get(ctx());
|
|
48
|
+
if (validating !== void 0) for (const c of ctx().changes()) validating.paths.add(c.path);
|
|
49
|
+
};
|
|
50
|
+
const errorList = (result) => result === void 0 ? [] : typeof result === "string" ? [result] : result;
|
|
40
51
|
/** An agent turn: `prompt` is emitted, the driver lands the diff. */
|
|
41
|
-
const agent = (name, prompt, options = {}) =>
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
const agent = async (name, prompt, options = {}) => {
|
|
53
|
+
noValidatorStep("agent", name);
|
|
54
|
+
const context = ctx();
|
|
55
|
+
const { validate, cap = DEFAULT_CAP, exhausted, correction } = options;
|
|
56
|
+
const base = typeof validate === "function" ? context.head() : "";
|
|
57
|
+
await context.step({
|
|
58
|
+
kind: "agent",
|
|
59
|
+
name,
|
|
60
|
+
prompt,
|
|
61
|
+
options
|
|
62
|
+
});
|
|
63
|
+
if (typeof validate !== "function") return;
|
|
64
|
+
const own = /* @__PURE__ */ new Set();
|
|
65
|
+
const agentPaths = /* @__PURE__ */ new Set();
|
|
66
|
+
let validated = base;
|
|
67
|
+
const validateNow = async () => {
|
|
68
|
+
for (const c of context.changesSince(validated)) agentPaths.add(c.path);
|
|
69
|
+
const turn = { get changes() {
|
|
70
|
+
return toChanges(context.changesSince(base).filter((c) => !own.has(c.path) || agentPaths.has(c.path)));
|
|
71
|
+
} };
|
|
72
|
+
validatingBy.set(context, {
|
|
73
|
+
name,
|
|
74
|
+
paths: own
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
return errorList(await scope(`${name}.validate`, () => validate(turn)));
|
|
78
|
+
} finally {
|
|
79
|
+
validatingBy.delete(context);
|
|
80
|
+
validated = context.head();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
let errors = await validateNow();
|
|
84
|
+
let retries = 0;
|
|
85
|
+
while (errors.length > 0) {
|
|
86
|
+
if (retries >= cap) {
|
|
87
|
+
if (exhausted !== void 0) await exhausted(errors);
|
|
88
|
+
else await human(`${name}.invalid`, errors.join("\n"), { acceptClean: true });
|
|
89
|
+
retries = 0;
|
|
90
|
+
} else {
|
|
91
|
+
retries++;
|
|
92
|
+
const text = correction !== void 0 ? correction(errors) : `The result did not pass validation. Fix these errors:\n\n${errors.join("\n")}\n`;
|
|
93
|
+
await context.step({
|
|
94
|
+
kind: "agent",
|
|
95
|
+
name,
|
|
96
|
+
prompt: text,
|
|
97
|
+
options,
|
|
98
|
+
original: prompt
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
errors = await validateNow();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
47
104
|
/** A human gate: the process rests until a person lands a turn. */
|
|
48
|
-
const human = (name, messageOrOptions, options) =>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
105
|
+
const human = (name, messageOrOptions, options) => {
|
|
106
|
+
noValidatorStep("human", name);
|
|
107
|
+
return ctx().step({
|
|
108
|
+
kind: "human",
|
|
109
|
+
name,
|
|
110
|
+
options: typeof messageOrOptions === "string" ? {
|
|
111
|
+
...options,
|
|
112
|
+
message: messageOrOptions
|
|
113
|
+
} : messageOrOptions ?? {}
|
|
114
|
+
});
|
|
115
|
+
};
|
|
56
116
|
/** A check: `body` runs at the edge and its effect on the tree is committed. */
|
|
57
|
-
const run = (name, body, options = {}) =>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
117
|
+
const run = async (name, body, options = {}) => {
|
|
118
|
+
await ctx().step({
|
|
119
|
+
kind: "run",
|
|
120
|
+
name,
|
|
121
|
+
body,
|
|
122
|
+
options
|
|
123
|
+
});
|
|
124
|
+
noteValidatorPaths();
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Squashes HEAD's tree, with `.gtd/` as the user's branch has it, into one commit on the user's branch
|
|
128
|
+
* with `message`, and moves that branch there; HEAD stays on `gtd/<b>`.
|
|
129
|
+
* Resolves to the new commit's hash, or `undefined` when nothing shipped.
|
|
130
|
+
*/
|
|
131
|
+
const ship = (name, message, options = {}) => {
|
|
132
|
+
noValidatorStep("ship", name);
|
|
133
|
+
return ctx().step({
|
|
134
|
+
kind: "ship",
|
|
135
|
+
name,
|
|
136
|
+
message,
|
|
137
|
+
options
|
|
138
|
+
});
|
|
139
|
+
};
|
|
63
140
|
/** A judge gate: resolves to what the judge answered, for the flow to decide on. */
|
|
64
141
|
const judge = (name, spec) => {
|
|
65
142
|
const { questions, evidence, ...options } = spec;
|
|
@@ -73,10 +150,16 @@ const judge = (name, spec) => {
|
|
|
73
150
|
questions: canonical,
|
|
74
151
|
evidence,
|
|
75
152
|
options
|
|
153
|
+
}).then((judgment) => {
|
|
154
|
+
noteValidatorPaths();
|
|
155
|
+
return judgment;
|
|
76
156
|
});
|
|
77
157
|
};
|
|
78
158
|
/** End the episode from any depth. The next episode starts the flow afresh. */
|
|
79
|
-
const restart = () =>
|
|
159
|
+
const restart = () => {
|
|
160
|
+
noValidatorStep("restart", "restart");
|
|
161
|
+
return ctx().step({ kind: "restart" });
|
|
162
|
+
};
|
|
80
163
|
/** Refuse the pending turn: nothing lands, the process stays where it rests. */
|
|
81
164
|
const refuse = (message) => ctx().refuse(message);
|
|
82
165
|
/**
|
|
@@ -348,13 +431,18 @@ const check = async (name, command, report, options = {}) => {
|
|
|
348
431
|
const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
|
|
349
432
|
/** The last step's changes outside `.gtd/`. */
|
|
350
433
|
const codeChanges = () => changes().filter((c) => isCode(c.path));
|
|
434
|
+
/** Validator form of `requireProgress`: the agent-addressed error, or `undefined` when the turn is valid. */
|
|
435
|
+
const progressErrors = (file, turnChanges) => {
|
|
436
|
+
const change = turnChanges.get(file);
|
|
437
|
+
if (change?.status !== "deleted") return void 0;
|
|
438
|
+
if (turnChanges.some((c) => isCode(c.path))) return void 0;
|
|
439
|
+
if ((change.before ?? "").trim().startsWith("NOTHING ACTIONABLE")) return void 0;
|
|
440
|
+
return `${file} was deleted without addressing its instructions — implement the changes it lists (then delete it), don't just remove the file. Its content was:\n${change.before ?? ""}`;
|
|
441
|
+
};
|
|
351
442
|
/** Refuse a turn whose only change deletes `file` — unless it says it found nothing to do. */
|
|
352
443
|
const requireProgress = (file) => {
|
|
353
|
-
const
|
|
354
|
-
if (
|
|
355
|
-
if (codeChanges().length > 0) return;
|
|
356
|
-
if ((change.before ?? "").trim().startsWith("NOTHING ACTIONABLE")) return;
|
|
357
|
-
refuse(`gtd land: feedback-progress: ${file} was deleted without addressing its instructions — implement the changes it lists (then delete it), don't just remove the file.`);
|
|
444
|
+
const error = progressErrors(file, changes());
|
|
445
|
+
if (error !== void 0) refuse(`gtd land: feedback-progress: ${error}`);
|
|
358
446
|
};
|
|
359
447
|
/** Refuse a turn that leaves a question in `file` unanswered. An untouched tree is silence, and allowed. */
|
|
360
448
|
const requireAnswers = (file) => {
|
|
@@ -377,18 +465,32 @@ const requireThreadsClosed = (file) => {
|
|
|
377
465
|
if (open.length === 0) return;
|
|
378
466
|
refuse(`gtd land: open-threads: ${countOf(open)} thread(s) in ${file} or code comments wait on you — reply with a conclusion, or delete the thread:\n${open.join("\n")}`);
|
|
379
467
|
};
|
|
468
|
+
/** Validator form of `requireReplies`: the agent-addressed error, or `undefined` when every thread is answered. */
|
|
469
|
+
const repliesErrors = (file) => {
|
|
470
|
+
const open = [...threadLines(file, "agent"), ...codeThreadLines("agent")];
|
|
471
|
+
if (open.length === 0) return void 0;
|
|
472
|
+
return `${countOf(open)} thread(s) in ${file} or code comments still wait on the agent — append one "- A:" reply to each (one "A:" comment line, same token and indentation, directly below a code thread's last line), or fold a concluded thread and delete it:\n${open.join("\n")}`;
|
|
473
|
+
};
|
|
380
474
|
/** Refuse an agent turn that leaves a thread in `file`, or a code thread in a changed file, waiting on the agent (its last entry is `H:`) or with a syntax fault. */
|
|
381
475
|
const requireReplies = (file) => {
|
|
382
|
-
const
|
|
383
|
-
if (
|
|
384
|
-
refuse(`gtd land: unanswered-threads: ${countOf(open)} thread(s) in ${file} or code comments still wait on the agent — append one "- A:" reply to each (one "A:" comment line, same token and indentation, directly below a code thread's last line), or fold a concluded thread and delete it:\n${open.join("\n")}`);
|
|
476
|
+
const error = repliesErrors(file);
|
|
477
|
+
if (error !== void 0) refuse(`gtd land: unanswered-threads: ${error}`);
|
|
385
478
|
};
|
|
386
|
-
|
|
387
|
-
const requireRevert = (edited, base) => {
|
|
479
|
+
const revertFix = (edited, base) => {
|
|
388
480
|
const residue = edited.filter((c) => read(c.path) !== c.before).map((c) => c.path);
|
|
389
|
-
if (residue.length === 0) return;
|
|
481
|
+
if (residue.length === 0) return void 0;
|
|
390
482
|
const quoted = residue.map((path) => `'${path.replace(/'/g, "'\\''")}'`).join(" ");
|
|
391
|
-
|
|
483
|
+
return `${residue.join(", ")} still differ from ${base}~1 — the revert did not take. Run \`git checkout ${base}~1 -- ${quoted}\``;
|
|
484
|
+
};
|
|
485
|
+
/** Validator form of `requireRevert`: reads the tree, not the turn's changes. */
|
|
486
|
+
const revertErrors = (edited, base) => {
|
|
487
|
+
const fix = revertFix(edited, base);
|
|
488
|
+
return fix === void 0 ? void 0 : `${fix}.`;
|
|
489
|
+
};
|
|
490
|
+
/** Refuse a turn that did not undo the code edits `edited` recorded, back to their content before `base`. */
|
|
491
|
+
const requireRevert = (edited, base) => {
|
|
492
|
+
const fix = revertFix(edited, base);
|
|
493
|
+
if (fix !== void 0) refuse(`gtd land: require-revert: ${fix}, then \`gtd land\` again.`);
|
|
392
494
|
};
|
|
393
495
|
const SHAPES = [
|
|
394
496
|
"land",
|
|
@@ -551,7 +653,8 @@ const CONFIG_KEYS = [
|
|
|
551
653
|
"modes",
|
|
552
654
|
"ui",
|
|
553
655
|
"skills",
|
|
554
|
-
"access"
|
|
656
|
+
"access",
|
|
657
|
+
"historyRemote"
|
|
555
658
|
];
|
|
556
659
|
const MODE_KEYS = ["format", "validate"];
|
|
557
660
|
const UI_KEYS = [
|
|
@@ -622,6 +725,10 @@ const configProblems = (value) => {
|
|
|
622
725
|
...unknownKeys(value, CONFIG_KEYS, []),
|
|
623
726
|
...value.modes === void 0 ? [] : recordOf(value.modes, ["modes"], "modes", modeProblems),
|
|
624
727
|
...value.ui === void 0 ? [] : uiProblems(value.ui, ["ui"]),
|
|
728
|
+
...value.historyRemote === void 0 || typeof value.historyRemote === "string" && value.historyRemote.trim() !== "" ? [] : [{
|
|
729
|
+
path: ["historyRemote"],
|
|
730
|
+
message: "must be a non-empty remote name"
|
|
731
|
+
}],
|
|
625
732
|
...value.skills === void 0 ? [] : recordOf(value.skills, ["skills"], "skill-name arrays", (v, path) => isStringArray(v) ? [] : [{
|
|
626
733
|
path,
|
|
627
734
|
message: "must be an array of skill names"
|
|
@@ -671,10 +778,12 @@ var flows_exports = /* @__PURE__ */ __exportAll({
|
|
|
671
778
|
moveScript: () => moveScript,
|
|
672
779
|
numeric: () => numeric,
|
|
673
780
|
openQuestions: () => openQuestions,
|
|
781
|
+
progressErrors: () => progressErrors,
|
|
674
782
|
quote: () => quote,
|
|
675
783
|
read: () => read,
|
|
676
784
|
refuse: () => refuse,
|
|
677
785
|
removeScript: () => removeScript,
|
|
786
|
+
repliesErrors: () => repliesErrors,
|
|
678
787
|
requireAnswers: () => requireAnswers,
|
|
679
788
|
requireProgress: () => requireProgress,
|
|
680
789
|
requireReplies: () => requireReplies,
|
|
@@ -682,6 +791,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
|
|
|
682
791
|
requireThreadsClosed: () => requireThreadsClosed,
|
|
683
792
|
restart: () => restart,
|
|
684
793
|
restoreScript: () => restoreScript,
|
|
794
|
+
revertErrors: () => revertErrors,
|
|
685
795
|
revertScript: () => revertScript,
|
|
686
796
|
run: () => run,
|
|
687
797
|
scope: () => scope,
|
|
@@ -690,6 +800,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
|
|
|
690
800
|
sections: () => sections,
|
|
691
801
|
settings: () => settings,
|
|
692
802
|
settingsOf: () => settingsOf,
|
|
803
|
+
ship: () => ship,
|
|
693
804
|
showScript: () => showScript,
|
|
694
805
|
skillsFor: () => skillsFor,
|
|
695
806
|
start: () => start,
|
|
@@ -698,4 +809,4 @@ var flows_exports = /* @__PURE__ */ __exportAll({
|
|
|
698
809
|
writeScript: () => writeScript,
|
|
699
810
|
wrote: () => wrote
|
|
700
811
|
});
|
|
701
|
-
export {
|
|
812
|
+
export { read as $, revertScript as A, envSettings as B, wrote as C, quote as D, moveScript as E, accessFor as F, installContext as G, glob as H, agent as I, mark as J, judge as K, changes as L, writeScript as M, FLOWS_PROTOCOL as N, removeScript as O, FlowsProtocolError as P, protocolMismatch as Q, changesSince as R, seamGate as S, checkScript as T, head as U, envSettingsOf as V, human as W, matches as X, markedOptions as Y, openQuestions as Z, requireProgress as _, defineWorkflow as a, sections as at, requireThreadsClosed as b, check as c, ship as ct, hasThreadFor as d, threads as dt, refuse as et, judgeDiffShape as f, workflow as ft, requireAnswers as g, repliesErrors as h, configure as i, sectionBodies as it, showScript as j, restoreScript as k, codeChanges as l, skillsFor as lt, progressErrors as m, configProblems as n, run as nt, isFactoryWorkflow as o, settings as ot, numeric as p, kept as q, defineConfig as r, scope as rt, answered as s, settingsOf as st, flows_exports as t, restart as tt, displayPath as u, start as ut, requireReplies as v, applyScript as w, revertErrors as x, requireRevert as y, codeThreads as z };
|