@pmelab/gtd 24.1.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 +11 -8
- package/claude/hooks/access.ts +25 -0
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -329,14 +329,17 @@ Then, in any repository:
|
|
|
329
329
|
```
|
|
330
330
|
|
|
331
331
|
That starts a process from your requirements and drives it until it needs you.
|
|
332
|
-
`/gtd fix` and `/gtd review [base]` take the two doors described below.
|
|
333
|
-
human rest
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
332
|
+
`/gtd fix` and `/gtd review [base]` take the two doors described below. At every
|
|
333
|
+
human rest Claude itself takes over: it walks you through the open questions one
|
|
334
|
+
at a time, answers your own questions about the plan or the diff, records what
|
|
335
|
+
you decide in the steering file the way the step asks, and moves the process on
|
|
336
|
+
only when you say so. While a step runs you can keep asking; requests for
|
|
337
|
+
changes wait for the next rest. When the process finishes, Claude offers to
|
|
338
|
+
squash it into one commit and open its pull request (or run `/gtd ship`). A
|
|
339
|
+
process can change hands at any gate: ask Claude to hand it off (or run
|
|
340
|
+
`/gtd throw @dev`) and it opens a draft pull request assigned to them, and
|
|
341
|
+
`/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
|
|
342
|
+
requirements can hand the architecture to someone else. See
|
|
340
343
|
[Inside Claude Code](https://github.com/pmelab/gtd/blob/main/docs/driver.md#inside-claude-code-the-gtd-mod).
|
|
341
344
|
|
|
342
345
|
### Then let an agent build your own
|
package/claude/hooks/access.ts
CHANGED
|
@@ -63,3 +63,28 @@ function searchDenial(raw: unknown, globs: string[] | null, root: string) {
|
|
|
63
63
|
})
|
|
64
64
|
return inside ? undefined : deny("read", rel || ".", globs)
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
// What the main session may write while gtd drives: nothing while a step runs
|
|
68
|
+
// (gtd commits the tree when it ends), only the steering file at a rest whose
|
|
69
|
+
// mode is `qa`, anything otherwise. A review rest takes code edits as feedback.
|
|
70
|
+
export type MainPhase = { isRunning: boolean; mode?: string; file?: string }
|
|
71
|
+
|
|
72
|
+
export const RUNNING_DENIAL =
|
|
73
|
+
"gtd is running a step and commits the working tree when it ends: don't edit files now. Answer read-only, or record the request with mcp__gtd__note for the next step that waits on the person. /gtd stop stops the run."
|
|
74
|
+
|
|
75
|
+
export function mainDenial(
|
|
76
|
+
tool: string,
|
|
77
|
+
input: Record<string, unknown>,
|
|
78
|
+
phase: MainPhase,
|
|
79
|
+
root: string,
|
|
80
|
+
): string | undefined {
|
|
81
|
+
const key = WRITE[tool]
|
|
82
|
+
if (!key) return undefined
|
|
83
|
+
if (phase.isRunning) return RUNNING_DENIAL
|
|
84
|
+
if (phase.mode !== "qa" || !phase.file) return undefined
|
|
85
|
+
const raw = input[key]
|
|
86
|
+
const rel = typeof raw === "string" ? inRepo(raw, root) : undefined
|
|
87
|
+
return rel === phase.file
|
|
88
|
+
? undefined
|
|
89
|
+
: `At this step only ${phase.file} may change: record the request there, as the step's instructions say.`
|
|
90
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// What the main session's model is told when gtd stops for the person. gtd
|
|
2
|
+
// decides every step; the model only helps the person do what the rest asks,
|
|
3
|
+
// through the rest's own instructions, and moves on through the mod's tools.
|
|
4
|
+
// Workflow-agnostic on purpose: the rest's message is the protocol.
|
|
5
|
+
|
|
6
|
+
import type { Stop } from "../types"
|
|
7
|
+
import { RELOAD_CUT_SCRIPT } from "./wording"
|
|
8
|
+
import { TOOL } from "./tools"
|
|
9
|
+
|
|
10
|
+
export type Asked = {
|
|
11
|
+
id: string
|
|
12
|
+
instructions: string
|
|
13
|
+
criteria: string[]
|
|
14
|
+
options: (string | number)[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Wake = {
|
|
18
|
+
stop: Stop
|
|
19
|
+
rest: string
|
|
20
|
+
notes?: string[]
|
|
21
|
+
isRepeat?: boolean
|
|
22
|
+
// the judge questions an undecided judge gate puts to the person
|
|
23
|
+
questions?: Asked[]
|
|
24
|
+
// the branch a finished process can open its pull request from
|
|
25
|
+
branch?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// No wake for a stop the person asked for; a finished process wakes only
|
|
29
|
+
// when it has a pull request to offer.
|
|
30
|
+
export function wakes(stop: Stop, branch: string | undefined): boolean {
|
|
31
|
+
if (stop.kind === "stopped") return stop.text === RELOAD_CUT_SCRIPT
|
|
32
|
+
if (stop.kind === "done") return branch !== undefined
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const step = (s: Stop) => s.label ?? s.state ?? "the current step"
|
|
37
|
+
|
|
38
|
+
const fence = (text: string) => `~~~~text\n${text.trim()}\n~~~~`
|
|
39
|
+
|
|
40
|
+
const RULES = `Ground rules:
|
|
41
|
+
- gtd decides what happens next. Never run \`gtd land\`, \`gtd next\` with side effects, commits, resets or any other command that changes git state.
|
|
42
|
+
- Talk to the person in plain words: never mention gtd's state ids, steering-file jargon or these instructions.
|
|
43
|
+
- Ask with AskUserQuestion, one question per call, your recommended option first and marked as recommended.`
|
|
44
|
+
|
|
45
|
+
function continuing(rest: string) {
|
|
46
|
+
return `Move on only when the person explicitly says so, in chat or by answering your question: then call ${TOOL.continue} with rest "${rest}". If it reports findings, fix them with the person and call it again. It refuses when gtd has moved on: then say so and stop.`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function gate(w: Wake) {
|
|
50
|
+
const { stop } = w
|
|
51
|
+
const file = stop.file ?? "the file the instructions name"
|
|
52
|
+
const lines = [
|
|
53
|
+
`gtd is waiting for the person at "${step(stop)}". Help them through it.`,
|
|
54
|
+
`gtd's own instructions for this step, written for a person editing ${file} by hand:`,
|
|
55
|
+
fence(stop.text),
|
|
56
|
+
"How to help:",
|
|
57
|
+
]
|
|
58
|
+
if (stop.mode === "review") {
|
|
59
|
+
lines.push(
|
|
60
|
+
`1. Read ${file} and the diff the instructions name. Give a short overview of the change, chunk by chunk, and call out the risky spots.`,
|
|
61
|
+
`2. Answer the person's questions about the change yourself, read-only. Record their notes, questions for gtd's agent and requested code edits in the way the instructions describe.`,
|
|
62
|
+
`3. Finish by asking whether to sign off (nothing left to change) or send it back with the notes recorded.`,
|
|
63
|
+
)
|
|
64
|
+
} else {
|
|
65
|
+
lines.push(
|
|
66
|
+
`1. Read ${file}. Summarise in two or three lines what it decides and what it asks.`,
|
|
67
|
+
`2. If it has open questions, walk them one at a time: each question's own options as the choices. Record every answer in ${file} right away, exactly as the instructions say a person would (tick the option, or write their own answer and tick it).`,
|
|
68
|
+
`3. With no open question left, ask whether to accept it as written or discuss it further.`,
|
|
69
|
+
`4. Answer the person's questions yourself, read-only. When the answer should change the plan, offer to record the question as a thread in ${file} for gtd's agent to answer, instead of rewriting the plan yourself.`,
|
|
70
|
+
`5. Write only what the instructions let a person write, and only to ${file}.`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
if (w.isRepeat) {
|
|
74
|
+
lines.push(
|
|
75
|
+
"The person continued without changing anything and gtd stopped at this same step again: tell them what it still needs.",
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
lines.push(continuing(w.rest))
|
|
79
|
+
return lines
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function judged(w: Wake) {
|
|
83
|
+
const lines = [
|
|
84
|
+
`gtd could not decide "${step(w.stop)}" on its own and needs the person's judgment.`,
|
|
85
|
+
"What gtd says about this step:",
|
|
86
|
+
fence(w.stop.text),
|
|
87
|
+
]
|
|
88
|
+
for (const q of w.questions ?? []) {
|
|
89
|
+
lines.push(
|
|
90
|
+
`Question "${q.id}": ${q.instructions}\n${q.criteria.map((c) => ` - ${c}`).join("\n")}\n Answers: ${q.options.map((o) => JSON.stringify(o)).join(", ")}`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
lines.push(
|
|
94
|
+
"Read whatever the questions are about, then put each one to the person, one at a time, with its answers as the choices and your own reading first.",
|
|
95
|
+
`Once all are answered and the person agrees, call ${TOOL.continue} with rest "${w.rest}" and verdicts mapping each question id to the chosen answer, exactly as listed. If the person would rather not decide, call it without verdicts: gtd then takes the cautious route.`,
|
|
96
|
+
)
|
|
97
|
+
return lines
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function trouble(w: Wake) {
|
|
101
|
+
const { stop } = w
|
|
102
|
+
const why = stop.kind === "stalled" ? "is stuck" : "stopped on a problem"
|
|
103
|
+
return [
|
|
104
|
+
`gtd ${why} at "${step(stop)}":`,
|
|
105
|
+
fence(stop.text),
|
|
106
|
+
"Find the cause (read the error, the log it names, the working tree) and explain it in plain words. Propose a fix and make it only with the person's consent.",
|
|
107
|
+
continuing(w.rest),
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function finished(w: Wake) {
|
|
112
|
+
return [
|
|
113
|
+
`gtd finished the process on branch ${w.branch}.`,
|
|
114
|
+
`Tell the person in two or three lines what was built (read the branch's commits), then ask whether to combine it into one commit and open its pull request. On yes, call ${TOOL.ship}.`,
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function wakePrompt(w: Wake): string {
|
|
119
|
+
const { stop } = w
|
|
120
|
+
const body =
|
|
121
|
+
stop.kind === "done"
|
|
122
|
+
? finished(w)
|
|
123
|
+
: stop.kind === "gate"
|
|
124
|
+
? stop.isJudge
|
|
125
|
+
? judged(w)
|
|
126
|
+
: gate(w)
|
|
127
|
+
: trouble(w)
|
|
128
|
+
const notes = w.notes?.length
|
|
129
|
+
? [
|
|
130
|
+
`While gtd ran, the person asked for the following. Fold each in now in the way this step allows, or tell them why it does not fit here:\n${w.notes.map((n) => `- ${n}`).join("\n")}`,
|
|
131
|
+
]
|
|
132
|
+
: []
|
|
133
|
+
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
|
+
return [...body, ...notes, RULES, extras].join("\n\n")
|
|
135
|
+
}
|
package/claude/hooks/drive.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type Beat = {
|
|
|
11
11
|
state?: string
|
|
12
12
|
label?: string
|
|
13
13
|
file?: string
|
|
14
|
+
mode?: string
|
|
14
15
|
content?: string
|
|
15
16
|
log?: string
|
|
16
17
|
memory?: string
|
|
@@ -69,11 +70,15 @@ type Acted = { stop: Stop } | { stop?: undefined; verdict?: string }
|
|
|
69
70
|
const where = (b: Beat) => ({ state: b.state, label: b.label })
|
|
70
71
|
const failed = (b: Beat, text: string): Acted => ({ stop: { kind: "error", text, ...where(b) } })
|
|
71
72
|
|
|
73
|
+
// How the opening beat is taken. `landTurn` names a memory scope whose agent
|
|
74
|
+
// turn already ran (a reload cut the loop off while it did): the opening prompt
|
|
75
|
+
// beat lands it, not repeats it. `isAnswered` is a person's answer to an opening
|
|
76
|
+
// judge gate: their `verdict`, or none to land it untouched (the cautious default).
|
|
77
|
+
export type Opening = { landTurn?: string; isAnswered?: boolean; verdict?: string }
|
|
78
|
+
|
|
72
79
|
// The reference sh driver from docs/driver.md, beat for beat. gtd decides;
|
|
73
80
|
// this only executes what `gtd next` and `gtd land` print.
|
|
74
|
-
|
|
75
|
-
// the loop off while it did): the opening prompt beat lands it, not repeats it.
|
|
76
|
-
export async function drive(io: Io, landTurn?: string): Promise<Stop> {
|
|
81
|
+
export async function drive(io: Io, opening: Opening = {}): Promise<Stop> {
|
|
77
82
|
// A judge gate answered on the previous beat that rests on the same state
|
|
78
83
|
// again moved nothing: hand it to the human rather than pay for it twice.
|
|
79
84
|
let judged: string | undefined
|
|
@@ -85,7 +90,7 @@ export async function drive(io: Io, landTurn?: string): Promise<Stop> {
|
|
|
85
90
|
// decision, and an `acceptClean` first step must get to fire.
|
|
86
91
|
if (beat > 1 && isTrue(b.idle)) return { kind: "done", text: await io.plain(), ...where(b) }
|
|
87
92
|
|
|
88
|
-
const acted = await act(io, b, beat, judged, beat === 1 ?
|
|
93
|
+
const acted = await act(io, b, beat, judged, beat === 1 ? opening : {})
|
|
89
94
|
if (acted.stop) return acted.stop
|
|
90
95
|
judged = acted.verdict ? b.state : undefined
|
|
91
96
|
const landed = await land(io, b, acted.verdict, true)
|
|
@@ -98,20 +103,22 @@ async function act(
|
|
|
98
103
|
b: Beat,
|
|
99
104
|
beat: number,
|
|
100
105
|
judged: string | undefined,
|
|
101
|
-
|
|
106
|
+
opening: Opening,
|
|
102
107
|
): Promise<Acted> {
|
|
103
108
|
switch (b.kind) {
|
|
104
109
|
case "stalled":
|
|
105
110
|
return { stop: { kind: "stalled", text: b.content ?? "", ...where(b) } }
|
|
106
111
|
case "message":
|
|
107
|
-
return
|
|
112
|
+
return opening.isAnswered && b.judge
|
|
113
|
+
? { verdict: opening.verdict }
|
|
114
|
+
: message(io, b, beat, judged)
|
|
108
115
|
case "capture":
|
|
109
116
|
return {}
|
|
110
117
|
case "script":
|
|
111
118
|
await io.sh(b.content ?? "", b.log)
|
|
112
119
|
return {}
|
|
113
120
|
case "prompt":
|
|
114
|
-
return agent(io, b, landTurn)
|
|
121
|
+
return agent(io, b, opening.landTurn)
|
|
115
122
|
default:
|
|
116
123
|
return failed(b, `unknown beat kind '${b.kind}'`)
|
|
117
124
|
}
|
|
@@ -126,6 +133,7 @@ async function message(io: Io, b: Beat, beat: number, judged: string | undefined
|
|
|
126
133
|
const stop: Stop = { kind: "gate", text: b.content ?? "", ...where(b) }
|
|
127
134
|
if (b.judge) stop.isJudge = true
|
|
128
135
|
if (b.file) stop.file = b.file
|
|
136
|
+
if (b.mode) stop.mode = b.mode
|
|
129
137
|
return { stop }
|
|
130
138
|
}
|
|
131
139
|
|
package/claude/hooks/judge.ts
CHANGED
|
@@ -111,3 +111,30 @@ export function toVerdicts(j: Judgment, reply: unknown): Verdict[] | string {
|
|
|
111
111
|
}
|
|
112
112
|
return out
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
// Each question as a person is asked it: its instructions, criteria and the
|
|
116
|
+
// answers it takes; a string is why it cannot be asked.
|
|
117
|
+
export function judgeQuestions(j: Judgment) {
|
|
118
|
+
return j.questions.map((q) => {
|
|
119
|
+
const levels = levelsOf(q)
|
|
120
|
+
if (typeof levels === "string") return levels
|
|
121
|
+
const meaning = levels.map(([label, text], i) =>
|
|
122
|
+
q.primitive === "score" ? `${i + 1}: ${text}` : `${label}: ${text}`,
|
|
123
|
+
)
|
|
124
|
+
return {
|
|
125
|
+
id: q.id,
|
|
126
|
+
instructions: q.instructions,
|
|
127
|
+
criteria: q.primitive === "noul" ? [q.criteria] : meaning,
|
|
128
|
+
options: optionsOf(q, levels),
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A person's `{ <id>: <answer> }` as gtd's verdicts, held with certainty.
|
|
134
|
+
export function humanVerdicts(j: Judgment, answers: unknown): Verdict[] | string {
|
|
135
|
+
if (typeof answers !== "object" || answers === null) return "verdicts must be an object"
|
|
136
|
+
const reply = Object.fromEntries(
|
|
137
|
+
Object.entries(answers).map(([id, answer]) => [id, { answer, p: 1 }]),
|
|
138
|
+
)
|
|
139
|
+
return toVerdicts(j, reply)
|
|
140
|
+
}
|
|
@@ -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>}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The only way the main session's model moves gtd: tools the mod serves. Each
|
|
2
|
+
// checks the run before it acts, so a model cannot skip a rest or answer one
|
|
3
|
+
// gtd has already left.
|
|
4
|
+
|
|
5
|
+
import type { Run } from "../types"
|
|
6
|
+
import { humanVerdicts } from "./judge"
|
|
7
|
+
import type { Judgment } from "./judge"
|
|
8
|
+
|
|
9
|
+
export const TOOL = {
|
|
10
|
+
continue: "mcp__gtd__continue",
|
|
11
|
+
note: "mcp__gtd__note",
|
|
12
|
+
handoff: "mcp__gtd__handoff",
|
|
13
|
+
ship: "mcp__gtd__ship",
|
|
14
|
+
browser: "mcp__gtd__browser",
|
|
15
|
+
} as const
|
|
16
|
+
|
|
17
|
+
export const TOOLS = [
|
|
18
|
+
{
|
|
19
|
+
name: "continue",
|
|
20
|
+
description:
|
|
21
|
+
"Moves the gtd process on from the step it waits at. Call it ONLY after the person explicitly said to continue. `rest` is the rest id gtd gave you for this step. `verdicts` only at a step gtd's judge could not decide: each question id mapped to the person's chosen answer. Refused when gtd has moved on, while a step runs, or when the step's file does not validate; the result says why.",
|
|
22
|
+
inputSchema: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
rest: { type: "string" },
|
|
26
|
+
verdicts: { type: "object", additionalProperties: { type: ["string", "number"] } },
|
|
27
|
+
},
|
|
28
|
+
required: ["rest"],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "note",
|
|
33
|
+
description:
|
|
34
|
+
"Records a request the person makes while gtd runs a step (a change, something to consider), for the next step that waits on them. Use it instead of editing files while gtd runs.",
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: { text: { type: "string" } },
|
|
38
|
+
required: ["text"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "handoff",
|
|
43
|
+
description:
|
|
44
|
+
"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
|
+
inputSchema: { type: "object", properties: { to: { type: "string" } } },
|
|
46
|
+
},
|
|
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
|
+
{
|
|
54
|
+
name: "browser",
|
|
55
|
+
description:
|
|
56
|
+
"Starts gtd's browser view of the step it waits at and returns its address. Call it only when the person wants it.",
|
|
57
|
+
inputSchema: { type: "object", properties: {} },
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
// A rest is the state gtd waits at on a given HEAD: once either moves, an
|
|
62
|
+
// answer meant for it no longer applies. Survives a mod reload, unlike a counter.
|
|
63
|
+
export const restId = (head: string, state: string | undefined) =>
|
|
64
|
+
`${head.slice(0, 12)}:${state ?? "?"}`
|
|
65
|
+
|
|
66
|
+
export type Proceed = { refuse: string } | { refuse?: undefined; verdict?: string }
|
|
67
|
+
|
|
68
|
+
export type ContinueIo = {
|
|
69
|
+
// the rest gtd is at now; undefined when it cannot say
|
|
70
|
+
current(): Promise<string | undefined>
|
|
71
|
+
// the rest's own `gtd validate` script, run; ok when there is nothing to check
|
|
72
|
+
validate(): Promise<{ ok: boolean; out: string }>
|
|
73
|
+
judgment(): Promise<Judgment | undefined>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// What `continue` may do from here. `rest` undefined is the band's Continue,
|
|
77
|
+
// which always means the rest on screen.
|
|
78
|
+
export async function decideContinue(
|
|
79
|
+
run: Run,
|
|
80
|
+
rest: string | undefined,
|
|
81
|
+
verdicts: unknown,
|
|
82
|
+
io: ContinueIo,
|
|
83
|
+
): Promise<Proceed> {
|
|
84
|
+
if (run.isRunning) return { refuse: "gtd is already running a step." }
|
|
85
|
+
const stop = run.stop
|
|
86
|
+
if (!stop) return { refuse: "Nothing is waiting: gtd has no rest open. /gtd drives it." }
|
|
87
|
+
if (stop.kind === "done") {
|
|
88
|
+
return { refuse: `The process is finished: ${TOOL.ship} opens its pull request.` }
|
|
89
|
+
}
|
|
90
|
+
if (rest !== undefined && rest !== run.rest) {
|
|
91
|
+
return {
|
|
92
|
+
refuse: "gtd has moved on from that step; it no longer waits there. Nothing was changed.",
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Another terminal or a branch switch can move gtd under a waiting answer.
|
|
96
|
+
const now = await io.current()
|
|
97
|
+
if (now !== undefined && now !== run.rest) {
|
|
98
|
+
return {
|
|
99
|
+
refuse:
|
|
100
|
+
"gtd is no longer at that step (it changed outside this session): /gtd shows where it is.",
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (stop.kind === "gate" && stop.isJudge) return judged(verdicts, io)
|
|
104
|
+
if (verdicts !== undefined) return { refuse: "This step takes no verdicts." }
|
|
105
|
+
if (stop.kind === "gate") {
|
|
106
|
+
const v = await io.validate()
|
|
107
|
+
if (!v.ok) return { refuse: `The step's file does not validate yet:\n${v.out}` }
|
|
108
|
+
}
|
|
109
|
+
return {}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// No verdicts lands the gate untouched: the cautious default.
|
|
113
|
+
async function judged(verdicts: unknown, io: ContinueIo): Promise<Proceed> {
|
|
114
|
+
if (verdicts === undefined) return {}
|
|
115
|
+
const j = await io.judgment()
|
|
116
|
+
if (!j) return { refuse: "gtd no longer has a judgment open here." }
|
|
117
|
+
const v = humanVerdicts(j, verdicts)
|
|
118
|
+
return typeof v === "string"
|
|
119
|
+
? { refuse: `Those verdicts don't fit: ${v}` }
|
|
120
|
+
: { verdict: JSON.stringify(v) }
|
|
121
|
+
}
|
package/claude/hooks/wording.ts
CHANGED
|
@@ -1,46 +1,13 @@
|
|
|
1
|
-
// Everything the mod
|
|
2
|
-
// names, and options that say what choosing them does.
|
|
1
|
+
// Everything the mod itself shows a person. Plain words: no gtd state ids.
|
|
3
2
|
|
|
4
|
-
import type {
|
|
3
|
+
import type { Stop } from "../types"
|
|
5
4
|
import type { Beat } from "./drive"
|
|
6
5
|
|
|
7
|
-
export const CONTINUE = "I'm done, continue"
|
|
8
|
-
export const SAFE = "Continue with the safe choice"
|
|
9
|
-
export const HANDOFF = "Hand off to someone else"
|
|
10
|
-
export const LATER = "Not now"
|
|
11
|
-
export const SHIP = "Yes, open the pull request"
|
|
12
|
-
export const ANYONE = "Anyone on the team"
|
|
13
|
-
export const CANCEL = "Cancel"
|
|
14
|
-
export const CLOSE = "Close"
|
|
15
|
-
|
|
16
|
-
export const gateOptions = (stop: Stop) => [stop.isJudge ? SAFE : CONTINUE, HANDOFF, LATER]
|
|
17
|
-
|
|
18
6
|
const step = (s: { label?: string; state?: string }) => s.label ?? s.state ?? "the next step"
|
|
19
7
|
|
|
20
|
-
export function question(r: Run) {
|
|
21
|
-
const stop = r.stop as Stop
|
|
22
|
-
if (stop.isJudge) {
|
|
23
|
-
return `gtd can't decide this step on its own: ${step(stop)}. Continue with the safe choice, or hand it to someone who can decide?`
|
|
24
|
-
}
|
|
25
|
-
const how = r.url
|
|
26
|
-
? `Open it in your browser: ${r.url}`
|
|
27
|
-
: `Make your changes in ${stop.file ?? "the files it names"}.`
|
|
28
|
-
const again = r.isRepeat ? "You haven't changed anything yet, so gtd is still waiting. " : ""
|
|
29
|
-
return `${again}gtd needs your input: ${step(stop)}. ${how} Ready to continue?`
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export const STALE =
|
|
33
|
-
"This question is out of date: gtd has already moved on. Choose Close to dismiss it."
|
|
34
|
-
|
|
35
8
|
export const RELOAD_CUT_SCRIPT =
|
|
36
9
|
"a reload cut a script off mid-run — check the working tree, then Continue to run it again"
|
|
37
10
|
|
|
38
|
-
export const handoffQuestion =
|
|
39
|
-
"Who should take over from here? Type their GitHub username, or let anyone on the team pick it up."
|
|
40
|
-
|
|
41
|
-
export const shipQuestion = (branch: string) =>
|
|
42
|
-
`gtd has finished the work on ${branch}. Combine it into a single commit and open (or update) its pull request for review?`
|
|
43
|
-
|
|
44
11
|
export function headline(s: Stop) {
|
|
45
12
|
const where = step(s)
|
|
46
13
|
switch (s.kind) {
|
package/claude/types/index.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type Stop = {
|
|
|
6
6
|
isJudge?: boolean
|
|
7
7
|
// the steering file the rest asks a person to edit
|
|
8
8
|
file?: string
|
|
9
|
+
// the steering file's mode (`qa`, `review`), which bounds what may be written at the rest
|
|
10
|
+
mode?: string
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export type Run = {
|
|
@@ -14,6 +16,13 @@ export type Run = {
|
|
|
14
16
|
state?: string
|
|
15
17
|
label?: string
|
|
16
18
|
stop?: Stop
|
|
19
|
+
// the rest a person is helped at: HEAD and state, so an answer to an older one is refused
|
|
20
|
+
rest?: string
|
|
21
|
+
// a continue the model asked for, started once its turn ends (and only if
|
|
22
|
+
// gtd still rests at `rest`); kept here so a reload does not lose a verdict
|
|
23
|
+
pending?: { rest: string; isAnswered?: boolean; verdict?: string | undefined }
|
|
24
|
+
// requests a person made while a step ran, for the next rest's assistant
|
|
25
|
+
notes?: string[]
|
|
17
26
|
// `gtd ui`'s address while it serves the current rest
|
|
18
27
|
url?: string
|
|
19
28
|
// why `gtd ui` is not serving it (refused, crashed)
|