@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
package/src/emit/GitScript.ts
CHANGED
|
@@ -12,8 +12,11 @@ export const pathspec = (paths: ReadonlyArray<string>): string => paths.map(shel
|
|
|
12
12
|
* output rather than the exit code, so a genuinely rejected commit (a lint
|
|
13
13
|
* error, a commit-msg hook) still fails.
|
|
14
14
|
*/
|
|
15
|
-
const commitAllowEmpty = (message: string, only?: string): string =>
|
|
16
|
-
|
|
15
|
+
const commitAllowEmpty = (message: string, only?: string): string =>
|
|
16
|
+
commitWordAllowEmpty(shellQuote(message), only)
|
|
17
|
+
|
|
18
|
+
/** `commitAllowEmpty` over a message that is already a shell word. */
|
|
19
|
+
const commitWordAllowEmpty = (m: string, only?: string): string => {
|
|
17
20
|
const paths = only === undefined ? "" : ` -- ${shellQuote(only)}`
|
|
18
21
|
return [
|
|
19
22
|
`if ! out=$(git commit --allow-empty -m ${m}${paths} 2>&1); then`,
|
|
@@ -36,6 +39,29 @@ export const commitAll = (message: string): string =>
|
|
|
36
39
|
export const commitPath = (path: string, message: string): string =>
|
|
37
40
|
`git add -A -- ${shellQuote(path)} || exit 1\n${commitAllowEmpty(message, path)}`
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* A `run()` body that records its own failure instead of failing the beat: the
|
|
44
|
+
* status and the last 50 output lines go to `path`, and the script exits zero
|
|
45
|
+
* so the turn lands. The file is COMMITTED (and force-pushed with a history
|
|
46
|
+
* remote), hence the cap.
|
|
47
|
+
*/
|
|
48
|
+
export const failRestScript = (body: string, path: string): string => {
|
|
49
|
+
const p = shellQuote(path)
|
|
50
|
+
const slash = path.lastIndexOf("/")
|
|
51
|
+
return [
|
|
52
|
+
`rm -f -- ${p}`,
|
|
53
|
+
`gtd_run_status=0`,
|
|
54
|
+
`gtd_run_out="$( {`,
|
|
55
|
+
body,
|
|
56
|
+
`} 2>&1 )" || gtd_run_status=$?`,
|
|
57
|
+
`printf '%s\\n' "$gtd_run_out"`,
|
|
58
|
+
`if [ "$gtd_run_status" -ne 0 ]; then`,
|
|
59
|
+
` mkdir -p ${shellQuote(path.slice(0, slash))}`,
|
|
60
|
+
` printf 'exit %s\\n%s\\n' "$gtd_run_status" "$(printf '%s\\n' "$gtd_run_out" | tail -n 50)" >${p}`,
|
|
61
|
+
`fi`,
|
|
62
|
+
].join("\n")
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
/** A pin beat's script. A failed `gtd bundle` leaves no half-written pin behind. */
|
|
40
66
|
export const pinScript = (path: string): string => {
|
|
41
67
|
const p = shellQuote(path)
|
|
@@ -123,6 +149,77 @@ export const keepTurn = (refPrefix: string, message: string): string => {
|
|
|
123
149
|
].join("\n")
|
|
124
150
|
}
|
|
125
151
|
|
|
152
|
+
/** `git switch -C <branch> [<start>]`: create or reset the branch, carrying pending changes over. */
|
|
153
|
+
export const switchBranchTo = (branch: string, start?: string): string =>
|
|
154
|
+
`git switch -q -C ${shellQuote(branch)}${start === undefined ? "" : ` ${shellQuote(start)}`}`
|
|
155
|
+
|
|
156
|
+
/** `git switch <branch>`: an existing branch, carrying pending changes over. */
|
|
157
|
+
export const switchTo = (branch: string): string => `git switch -q ${shellQuote(branch)}`
|
|
158
|
+
|
|
159
|
+
/** `switchTo` that, when pending changes block it, stays put and says so: the landing already happened. */
|
|
160
|
+
export const switchBack = (branch: string): string =>
|
|
161
|
+
[
|
|
162
|
+
`if ! git switch -q ${shellQuote(branch)} 2>/dev/null; then`,
|
|
163
|
+
` printf '%s\\n' ${shellQuote(`gtd: pending changes keep HEAD off ${branch}; switch to it once they are committed or stashed`)}`,
|
|
164
|
+
`fi`,
|
|
165
|
+
].join("\n")
|
|
166
|
+
|
|
167
|
+
export const deleteBranch = (branch: string): string => `git branch -q -D ${shellQuote(branch)}`
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Force-pushes one ref to a remote. A failed push exits non-zero with the retry
|
|
171
|
+
* on stderr: it runs after the closing commit has landed, so the commit stands.
|
|
172
|
+
*/
|
|
173
|
+
export const pushRef = (remote: string, ref: string): string => {
|
|
174
|
+
const retry = `git push --force ${shellQuote(remote)} ${shellQuote(`${ref}:${ref}`)}`
|
|
175
|
+
return [
|
|
176
|
+
`if ! ${retry}; then`,
|
|
177
|
+
` printf '%s\\n' ${shellQuote(`gtd: pushing ${ref} failed — retry: ${retry}`)} >&2`,
|
|
178
|
+
` exit 1`,
|
|
179
|
+
`fi`,
|
|
180
|
+
].join("\n")
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A ship step's landing, in one block because the landing commit's
|
|
185
|
+
* `Gtd-Shipped` trailer names the squash it makes first. The squash carries
|
|
186
|
+
* the staged tree with `.gtd/` as `<branch>` has it, built in a scratch index; `<branch>` moves by
|
|
187
|
+
* compare-and-swap against `parent`, so a branch moved since the decision
|
|
188
|
+
* fails the script before the landing commits. `commit-tree` ignores
|
|
189
|
+
* `commit.gpgSign`, so it is read here.
|
|
190
|
+
*/
|
|
191
|
+
export const shipCommit = (
|
|
192
|
+
branch: string,
|
|
193
|
+
parent: string,
|
|
194
|
+
shipMessage: string,
|
|
195
|
+
historyRef: string,
|
|
196
|
+
message: string,
|
|
197
|
+
): string =>
|
|
198
|
+
[
|
|
199
|
+
`gtd_ship_parent=${shellQuote(parent)}`,
|
|
200
|
+
`gtd_ship_branch=${shellQuote(branch)}`,
|
|
201
|
+
`gtd_ship_history=${shellQuote(historyRef)}`,
|
|
202
|
+
`gtd_ship_message=${shellQuote(shipMessage)}`,
|
|
203
|
+
`gtd_land_message=${shellQuote(message)}`,
|
|
204
|
+
`git add -A || exit 1`,
|
|
205
|
+
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
206
|
+
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
207
|
+
`gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
|
|
208
|
+
`gtd_tree=$(git write-tree) || exit 1`,
|
|
209
|
+
`gtd_index=$(git rev-parse --git-path gtd-ship-index) || exit 1`,
|
|
210
|
+
`rm -f -- "$gtd_index"`,
|
|
211
|
+
`GIT_INDEX_FILE="$gtd_index" git read-tree "$gtd_tree" || exit 1`,
|
|
212
|
+
`GIT_INDEX_FILE="$gtd_index" git rm -r -q --cached --ignore-unmatch -- .gtd || exit 1`,
|
|
213
|
+
`git ls-tree -r "$gtd_ship_parent" -- .gtd | GIT_INDEX_FILE="$gtd_index" git update-index --index-info || exit 1`,
|
|
214
|
+
`gtd_ship_tree=$(GIT_INDEX_FILE="$gtd_index" git write-tree) || exit 1`,
|
|
215
|
+
`rm -f -- "$gtd_index"`,
|
|
216
|
+
`gtd_ship_trailers=$(printf "Gtd-History: %s\\nGtd-Tip: %s" "$gtd_ship_history" "$gtd_head")`,
|
|
217
|
+
`gtd_shipped=$(git commit-tree "$gtd_sign" "$gtd_ship_tree" -p "$gtd_ship_parent" -m "$gtd_ship_message" -m "$gtd_ship_trailers") || exit 1`,
|
|
218
|
+
`git update-ref -m "gtd: ship" "refs/heads/$gtd_ship_branch" "$gtd_shipped" "$gtd_ship_parent" || exit 1`,
|
|
219
|
+
`gtd_message=$(printf "%s\\nGtd-Shipped: %s" "$gtd_land_message" "$gtd_shipped")`,
|
|
220
|
+
commitWordAllowEmpty(`"$gtd_message"`),
|
|
221
|
+
].join("\n")
|
|
222
|
+
|
|
126
223
|
export const updateRef = (ref: string, hash: string): string =>
|
|
127
224
|
`git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`
|
|
128
225
|
|
|
@@ -170,21 +267,25 @@ const renderOutcome = (outcome: Outcome): string => {
|
|
|
170
267
|
}
|
|
171
268
|
}
|
|
172
269
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
270
|
+
type Renderers = {
|
|
271
|
+
readonly [K in GitWrite["kind"]]: (write: Extract<GitWrite, { kind: K }>) => string
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const GIT_WRITES: Renderers = {
|
|
275
|
+
commitAll: (w) => commitAll(w.message),
|
|
276
|
+
commitPath: (w) => commitPath(w.path, w.message),
|
|
277
|
+
commitClean: (w) => commitClean(w.message),
|
|
278
|
+
updateRef: (w) => updateRef(w.ref, w.target),
|
|
279
|
+
keepTurn: (w) => keepTurn(w.ref, w.message),
|
|
280
|
+
switchBranch: (w) => switchBranchTo(w.branch),
|
|
281
|
+
switchTo: (w) => switchBack(w.branch),
|
|
282
|
+
pushRef: (w) => pushRef(w.remote, w.ref),
|
|
283
|
+
ship: (w) => shipCommit(w.branch, w.parent, w.shipMessage, w.historyRef, w.message),
|
|
186
284
|
}
|
|
187
285
|
|
|
286
|
+
const renderGitWrite = (write: GitWrite): string =>
|
|
287
|
+
(GIT_WRITES[write.kind] as (write: GitWrite) => string)(write)
|
|
288
|
+
|
|
188
289
|
/**
|
|
189
290
|
* Wraps `command` so a non-zero exit prints `prompt` plus the command's
|
|
190
291
|
* captured output before propagating that exit code — mirrors `Emit.ts`'s
|
package/src/emit/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export {
|
|
|
18
18
|
commitClean,
|
|
19
19
|
commitPath,
|
|
20
20
|
pinScript,
|
|
21
|
+
failRestScript,
|
|
21
22
|
repinScript,
|
|
22
23
|
unpinScript,
|
|
23
24
|
CHANGED_SINCE_LAND,
|
|
@@ -28,6 +29,12 @@ export {
|
|
|
28
29
|
updateRef,
|
|
29
30
|
keepTurn,
|
|
30
31
|
deleteRef,
|
|
32
|
+
switchBranchTo,
|
|
33
|
+
switchBack,
|
|
34
|
+
switchTo,
|
|
35
|
+
deleteBranch,
|
|
36
|
+
pushRef,
|
|
37
|
+
shipCommit,
|
|
31
38
|
fetchHistoryRefs,
|
|
32
39
|
type RunnableScript,
|
|
33
40
|
ScriptSurface,
|
package/src/flows/config.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface GtdConfig {
|
|
|
21
21
|
readonly ui?: UiSettings
|
|
22
22
|
readonly skills?: Readonly<Record<string, readonly string[]>>
|
|
23
23
|
readonly access?: Readonly<Record<string, ScopeAccess>>
|
|
24
|
+
/** A remote name: every finished or abandoned process force-pushes its history ref there. */
|
|
25
|
+
readonly historyRemote?: string
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export interface ConfigProblem {
|
|
@@ -28,7 +30,7 @@ export interface ConfigProblem {
|
|
|
28
30
|
readonly message: string
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
const CONFIG_KEYS = ["modes", "ui", "skills", "access"] as const
|
|
33
|
+
const CONFIG_KEYS = ["modes", "ui", "skills", "access", "historyRemote"] as const
|
|
32
34
|
const MODE_KEYS = ["format", "validate"] as const
|
|
33
35
|
const UI_KEYS = ["port", "host", "cert", "key", "format"] as const
|
|
34
36
|
|
|
@@ -113,6 +115,10 @@ export const configProblems = (value: unknown): readonly ConfigProblem[] => {
|
|
|
113
115
|
...unknownKeys(value, CONFIG_KEYS, []),
|
|
114
116
|
...(value.modes === undefined ? [] : recordOf(value.modes, ["modes"], "modes", modeProblems)),
|
|
115
117
|
...(value.ui === undefined ? [] : uiProblems(value.ui, ["ui"])),
|
|
118
|
+
...(value.historyRemote === undefined ||
|
|
119
|
+
(typeof value.historyRemote === "string" && value.historyRemote.trim() !== "")
|
|
120
|
+
? []
|
|
121
|
+
: [{ path: ["historyRemote"], message: "must be a non-empty remote name" }]),
|
|
116
122
|
...(value.skills === undefined
|
|
117
123
|
? []
|
|
118
124
|
: recordOf(value.skills, ["skills"], "skill-name arrays", (v, path) =>
|
package/src/flows/helpers.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
settings,
|
|
13
13
|
threads,
|
|
14
14
|
type Change,
|
|
15
|
+
type Changes,
|
|
15
16
|
type JudgeAnswer,
|
|
16
17
|
type Judgment,
|
|
17
18
|
} from "./runtime.js"
|
|
@@ -78,15 +79,19 @@ const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".
|
|
|
78
79
|
/** The last step's changes outside `.gtd/`. */
|
|
79
80
|
export const codeChanges = (): readonly Change[] => changes().filter((c) => isCode(c.path))
|
|
80
81
|
|
|
82
|
+
/** Validator form of `requireProgress`: the agent-addressed error, or `undefined` when the turn is valid. */
|
|
83
|
+
export const progressErrors = (file: string, turnChanges: Changes): string | undefined => {
|
|
84
|
+
const change = turnChanges.get(file)
|
|
85
|
+
if (change?.status !== "deleted") return undefined
|
|
86
|
+
if (turnChanges.some((c) => isCode(c.path))) return undefined
|
|
87
|
+
if ((change.before ?? "").trim().startsWith("NOTHING ACTIONABLE")) return undefined
|
|
88
|
+
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 ?? ""}`
|
|
89
|
+
}
|
|
90
|
+
|
|
81
91
|
/** Refuse a turn whose only change deletes `file` — unless it says it found nothing to do. */
|
|
82
92
|
export const requireProgress = (file: string): void => {
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
if (codeChanges().length > 0) return
|
|
86
|
-
if ((change.before ?? "").trim().startsWith("NOTHING ACTIONABLE")) return
|
|
87
|
-
refuse(
|
|
88
|
-
`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.`,
|
|
89
|
-
)
|
|
93
|
+
const error = progressErrors(file, changes())
|
|
94
|
+
if (error !== undefined) refuse(`gtd land: feedback-progress: ${error}`)
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
/** Refuse a turn that leaves a question in `file` unanswered. An untouched tree is silence, and allowed. */
|
|
@@ -135,23 +140,36 @@ export const requireThreadsClosed = (file: string): void => {
|
|
|
135
140
|
)
|
|
136
141
|
}
|
|
137
142
|
|
|
143
|
+
/** Validator form of `requireReplies`: the agent-addressed error, or `undefined` when every thread is answered. */
|
|
144
|
+
export const repliesErrors = (file: string): string | undefined => {
|
|
145
|
+
const open = [...threadLines(file, "agent"), ...codeThreadLines("agent")]
|
|
146
|
+
if (open.length === 0) return undefined
|
|
147
|
+
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")}`
|
|
148
|
+
}
|
|
149
|
+
|
|
138
150
|
/** 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. */
|
|
139
151
|
export const requireReplies = (file: string): void => {
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
142
|
-
refuse(
|
|
143
|
-
`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")}`,
|
|
144
|
-
)
|
|
152
|
+
const error = repliesErrors(file)
|
|
153
|
+
if (error !== undefined) refuse(`gtd land: unanswered-threads: ${error}`)
|
|
145
154
|
}
|
|
146
155
|
|
|
147
|
-
|
|
148
|
-
export const requireRevert = (edited: readonly Change[], base: string): void => {
|
|
156
|
+
const revertFix = (edited: readonly Change[], base: string): string | undefined => {
|
|
149
157
|
const residue = edited.filter((c) => read(c.path) !== c.before).map((c) => c.path)
|
|
150
|
-
if (residue.length === 0) return
|
|
158
|
+
if (residue.length === 0) return undefined
|
|
151
159
|
const quoted = residue.map((path) => `'${path.replace(/'/g, "'\\''")}'`).join(" ")
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
160
|
+
return `${residue.join(", ")} still differ from ${base}~1 — the revert did not take. Run \`git checkout ${base}~1 -- ${quoted}\``
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Validator form of `requireRevert`: reads the tree, not the turn's changes. */
|
|
164
|
+
export const revertErrors = (edited: readonly Change[], base: string): string | undefined => {
|
|
165
|
+
const fix = revertFix(edited, base)
|
|
166
|
+
return fix === undefined ? undefined : `${fix}.`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Refuse a turn that did not undo the code edits `edited` recorded, back to their content before `base`. */
|
|
170
|
+
export const requireRevert = (edited: readonly Change[], base: string): void => {
|
|
171
|
+
const fix = revertFix(edited, base)
|
|
172
|
+
if (fix !== undefined) refuse(`gtd land: require-revert: ${fix}, then \`gtd land\` again.`)
|
|
155
173
|
}
|
|
156
174
|
|
|
157
175
|
const SHAPES = ["land", "refactor-first", "reject"] as const
|
package/src/flows/index.test.ts
CHANGED
|
@@ -34,10 +34,12 @@ describe("@pmelab/gtd/flows", () => {
|
|
|
34
34
|
"moveScript",
|
|
35
35
|
"numeric",
|
|
36
36
|
"openQuestions",
|
|
37
|
+
"progressErrors",
|
|
37
38
|
"quote",
|
|
38
39
|
"read",
|
|
39
40
|
"refuse",
|
|
40
41
|
"removeScript",
|
|
42
|
+
"repliesErrors",
|
|
41
43
|
"requireAnswers",
|
|
42
44
|
"requireProgress",
|
|
43
45
|
"requireReplies",
|
|
@@ -45,6 +47,7 @@ describe("@pmelab/gtd/flows", () => {
|
|
|
45
47
|
"requireThreadsClosed",
|
|
46
48
|
"restart",
|
|
47
49
|
"restoreScript",
|
|
50
|
+
"revertErrors",
|
|
48
51
|
"revertScript",
|
|
49
52
|
"run",
|
|
50
53
|
"scope",
|
|
@@ -53,6 +56,7 @@ describe("@pmelab/gtd/flows", () => {
|
|
|
53
56
|
"sections",
|
|
54
57
|
"settings",
|
|
55
58
|
"settingsOf",
|
|
59
|
+
"ship",
|
|
56
60
|
"showScript",
|
|
57
61
|
"skillsFor",
|
|
58
62
|
"start",
|
package/src/flows/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
sections,
|
|
29
29
|
settings,
|
|
30
30
|
settingsOf,
|
|
31
|
+
ship,
|
|
31
32
|
skillsFor,
|
|
32
33
|
start,
|
|
33
34
|
threads,
|
|
@@ -47,6 +48,8 @@ export {
|
|
|
47
48
|
type JudgeQuestion,
|
|
48
49
|
type JudgeQuestionInput,
|
|
49
50
|
type JudgeSpec,
|
|
51
|
+
type ValidateResult,
|
|
52
|
+
type ValidateTurn,
|
|
50
53
|
type Judgment,
|
|
51
54
|
type KeptTip,
|
|
52
55
|
type Mark,
|
|
@@ -58,6 +61,7 @@ export {
|
|
|
58
61
|
type ScopeOptions,
|
|
59
62
|
type Section,
|
|
60
63
|
type Settings,
|
|
64
|
+
type ShipOptions,
|
|
61
65
|
type SkillsExport,
|
|
62
66
|
type Steering,
|
|
63
67
|
type SteeringOptions,
|
|
@@ -76,11 +80,14 @@ export {
|
|
|
76
80
|
hasThreadFor,
|
|
77
81
|
judgeDiffShape,
|
|
78
82
|
numeric,
|
|
83
|
+
progressErrors,
|
|
84
|
+
repliesErrors,
|
|
79
85
|
requireAnswers,
|
|
80
86
|
requireProgress,
|
|
81
87
|
requireReplies,
|
|
82
88
|
requireRevert,
|
|
83
89
|
requireThreadsClosed,
|
|
90
|
+
revertErrors,
|
|
84
91
|
seamGate,
|
|
85
92
|
wrote,
|
|
86
93
|
type Check,
|
package/src/flows/runtime.ts
CHANGED
|
@@ -37,9 +37,25 @@ type SteeringFile =
|
|
|
37
37
|
/** A steering-file declaration shared by the steps that rest on one. `mode` requires `file`. */
|
|
38
38
|
export type SteeringOptions = SteeringCommon & SteeringFile
|
|
39
39
|
|
|
40
|
+
/** What a validator is told about the work so far. */
|
|
41
|
+
export interface ValidateTurn {
|
|
42
|
+
/** Every change since before the first try, minus the files the validator's own steps wrote. */
|
|
43
|
+
readonly changes: Changes
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ValidateResult = string | readonly string[] | undefined
|
|
47
|
+
|
|
40
48
|
interface AgentCommon {
|
|
41
49
|
readonly model?: string | undefined
|
|
42
50
|
readonly system?: string | undefined
|
|
51
|
+
/** Runs after each landed turn; errors it returns become the correction the next try gets. Only `check`, `run` and `judge` may run inside. */
|
|
52
|
+
readonly validate?: ((turn: ValidateTurn) => Promise<ValidateResult>) | undefined
|
|
53
|
+
/** Retries before `exhausted` runs. Default 3. */
|
|
54
|
+
readonly cap?: number | undefined
|
|
55
|
+
/** Runs once `cap` retries failed; default rests at a `<name>.invalid` gate. The validator then runs again. */
|
|
56
|
+
readonly exhausted?: ((errors: readonly string[]) => Promise<void>) | undefined
|
|
57
|
+
/** The retry's `content`, instead of the default lead-in plus the errors. */
|
|
58
|
+
readonly correction?: ((errors: readonly string[]) => string) | undefined
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
export type AgentOptions = SteeringOptions &
|
|
@@ -89,7 +105,20 @@ export type HumanOptions = HumanCommon &
|
|
|
89
105
|
})
|
|
90
106
|
)
|
|
91
107
|
|
|
92
|
-
export type RunOptions = SteeringOptions
|
|
108
|
+
export type RunOptions = SteeringOptions & {
|
|
109
|
+
/**
|
|
110
|
+
* `"rest"`: a non-zero exit is recorded in the tree and the process rests at
|
|
111
|
+
* the step as `stalled`; any tree change runs the script again, and only a
|
|
112
|
+
* zero exit advances. Without it a failing body lands and the flow carries on.
|
|
113
|
+
*/
|
|
114
|
+
readonly onFail?: "rest" | undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A ship step rests on no steering file. */
|
|
118
|
+
export type ShipOptions = SteeringCommon & {
|
|
119
|
+
readonly file?: undefined
|
|
120
|
+
readonly mode?: undefined
|
|
121
|
+
}
|
|
93
122
|
|
|
94
123
|
export type JudgePrimitive = "noul" | "choice" | "score"
|
|
95
124
|
|
|
@@ -196,6 +225,8 @@ export type StepRequest =
|
|
|
196
225
|
readonly name: string
|
|
197
226
|
readonly prompt: string
|
|
198
227
|
readonly options: AgentOptions
|
|
228
|
+
/** On a validator retry: the step's original prompt (`prompt` is then the correction). */
|
|
229
|
+
readonly original?: string
|
|
199
230
|
}
|
|
200
231
|
| { readonly kind: "human"; readonly name: string; readonly options: HumanOptions }
|
|
201
232
|
| {
|
|
@@ -204,6 +235,12 @@ export type StepRequest =
|
|
|
204
235
|
readonly body: string
|
|
205
236
|
readonly options: RunOptions
|
|
206
237
|
}
|
|
238
|
+
| {
|
|
239
|
+
readonly kind: "ship"
|
|
240
|
+
readonly name: string
|
|
241
|
+
readonly message: string
|
|
242
|
+
readonly options: ShipOptions
|
|
243
|
+
}
|
|
207
244
|
| {
|
|
208
245
|
readonly kind: "judge"
|
|
209
246
|
readonly name: string
|
|
@@ -312,9 +349,84 @@ const ctx = (): FlowContext => {
|
|
|
312
349
|
|
|
313
350
|
// ── Steps ───────────────────────────────────────────────────────────────────
|
|
314
351
|
|
|
352
|
+
// While a validator runs: its step's name, and the paths its own steps changed. Keyed by the
|
|
353
|
+
// replay's context: a validator suspended at a rest never unwinds, so a global would leak.
|
|
354
|
+
type Validating = { readonly name: string; readonly paths: Set<string> }
|
|
355
|
+
const validatingBy = new WeakMap<FlowContext, Validating>()
|
|
356
|
+
|
|
357
|
+
const DEFAULT_CAP = 3
|
|
358
|
+
|
|
359
|
+
const noValidatorStep = (kind: string, name: string): void => {
|
|
360
|
+
const validating = validatingBy.get(ctx())
|
|
361
|
+
if (validating !== undefined) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`gtd: step "${name}": ${kind}() cannot run inside the validator of "${validating.name}" — a validator may only run check, run and judge steps`,
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const noteValidatorPaths = (): void => {
|
|
369
|
+
const validating = validatingBy.get(ctx())
|
|
370
|
+
if (validating !== undefined) for (const c of ctx().changes()) validating.paths.add(c.path)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const errorList = (result: ValidateResult): readonly string[] =>
|
|
374
|
+
result === undefined ? [] : typeof result === "string" ? [result] : result
|
|
375
|
+
|
|
315
376
|
/** An agent turn: `prompt` is emitted, the driver lands the diff. */
|
|
316
|
-
export const agent = (
|
|
317
|
-
|
|
377
|
+
export const agent = async (
|
|
378
|
+
name: string,
|
|
379
|
+
prompt: string,
|
|
380
|
+
options: AgentOptions = {},
|
|
381
|
+
): Promise<void> => {
|
|
382
|
+
noValidatorStep("agent", name)
|
|
383
|
+
const context = ctx()
|
|
384
|
+
const { validate, cap = DEFAULT_CAP, exhausted, correction } = options
|
|
385
|
+
const base = typeof validate === "function" ? context.head() : ""
|
|
386
|
+
await context.step({ kind: "agent", name, prompt, options })
|
|
387
|
+
if (typeof validate !== "function") return
|
|
388
|
+
|
|
389
|
+
const own = new Set<string>()
|
|
390
|
+
// Paths an agent turn or a person changed. Whatever changed since the last validator returned
|
|
391
|
+
// is theirs — even on a path a validator `run` (lint --fix) wrote in an earlier round.
|
|
392
|
+
const agentPaths = new Set<string>()
|
|
393
|
+
let validated = base
|
|
394
|
+
const validateNow = async (): Promise<readonly string[]> => {
|
|
395
|
+
for (const c of context.changesSince(validated)) agentPaths.add(c.path)
|
|
396
|
+
const turn: ValidateTurn = {
|
|
397
|
+
get changes() {
|
|
398
|
+
return toChanges(
|
|
399
|
+
context.changesSince(base).filter((c) => !own.has(c.path) || agentPaths.has(c.path)),
|
|
400
|
+
)
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
validatingBy.set(context, { name, paths: own })
|
|
404
|
+
try {
|
|
405
|
+
return errorList(await scope(`${name}.validate`, () => validate(turn)))
|
|
406
|
+
} finally {
|
|
407
|
+
validatingBy.delete(context)
|
|
408
|
+
validated = context.head()
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let errors = await validateNow()
|
|
413
|
+
let retries = 0
|
|
414
|
+
while (errors.length > 0) {
|
|
415
|
+
if (retries >= cap) {
|
|
416
|
+
if (exhausted !== undefined) await exhausted(errors)
|
|
417
|
+
else await human(`${name}.invalid`, errors.join("\n"), { acceptClean: true })
|
|
418
|
+
retries = 0
|
|
419
|
+
} else {
|
|
420
|
+
retries++
|
|
421
|
+
const text =
|
|
422
|
+
correction !== undefined
|
|
423
|
+
? correction(errors)
|
|
424
|
+
: `The result did not pass validation. Fix these errors:\n\n${errors.join("\n")}\n`
|
|
425
|
+
await context.step({ kind: "agent", name, prompt: text, options, original: prompt })
|
|
426
|
+
}
|
|
427
|
+
errors = await validateNow()
|
|
428
|
+
}
|
|
429
|
+
}
|
|
318
430
|
|
|
319
431
|
// `Omit` over a union collapses it; this keeps each member's own rules.
|
|
320
432
|
type OmitEach<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
|
|
@@ -330,8 +442,9 @@ export const human: Human = (
|
|
|
330
442
|
name: string,
|
|
331
443
|
messageOrOptions?: string | HumanOptions,
|
|
332
444
|
options?: OmitEach<HumanOptions, "message">,
|
|
333
|
-
): Promise<void> =>
|
|
334
|
-
|
|
445
|
+
): Promise<void> => {
|
|
446
|
+
noValidatorStep("human", name)
|
|
447
|
+
return ctx().step({
|
|
335
448
|
kind: "human",
|
|
336
449
|
name,
|
|
337
450
|
options:
|
|
@@ -339,10 +452,27 @@ export const human: Human = (
|
|
|
339
452
|
? { ...options, message: messageOrOptions }
|
|
340
453
|
: (messageOrOptions ?? {}),
|
|
341
454
|
}) as Promise<void>
|
|
455
|
+
}
|
|
342
456
|
|
|
343
457
|
/** A check: `body` runs at the edge and its effect on the tree is committed. */
|
|
344
|
-
export const run = (name: string, body: string, options: RunOptions = {}): Promise<void> =>
|
|
345
|
-
ctx().step({ kind: "run", name, body, options })
|
|
458
|
+
export const run = async (name: string, body: string, options: RunOptions = {}): Promise<void> => {
|
|
459
|
+
await ctx().step({ kind: "run", name, body, options })
|
|
460
|
+
noteValidatorPaths()
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Squashes HEAD's tree, with `.gtd/` as the user's branch has it, into one commit on the user's branch
|
|
465
|
+
* with `message`, and moves that branch there; HEAD stays on `gtd/<b>`.
|
|
466
|
+
* Resolves to the new commit's hash, or `undefined` when nothing shipped.
|
|
467
|
+
*/
|
|
468
|
+
export const ship = (
|
|
469
|
+
name: string,
|
|
470
|
+
message: string,
|
|
471
|
+
options: ShipOptions = {},
|
|
472
|
+
): Promise<string | undefined> => {
|
|
473
|
+
noValidatorStep("ship", name)
|
|
474
|
+
return ctx().step({ kind: "ship", name, message, options }) as Promise<string | undefined>
|
|
475
|
+
}
|
|
346
476
|
|
|
347
477
|
/** A judge gate: resolves to what the judge answered, for the flow to decide on. */
|
|
348
478
|
export const judge = <const Q extends readonly JudgeQuestionInput[] = readonly JudgeQuestion[]>(
|
|
@@ -354,17 +484,25 @@ export const judge = <const Q extends readonly JudgeQuestionInput[] = readonly J
|
|
|
354
484
|
const canonical = questions.map((q) =>
|
|
355
485
|
q.primitive === "yesno" ? { ...q, primitive: "noul" as const } : (q as JudgeQuestion),
|
|
356
486
|
)
|
|
357
|
-
return ctx()
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
487
|
+
return ctx()
|
|
488
|
+
.step({
|
|
489
|
+
kind: "judge",
|
|
490
|
+
name,
|
|
491
|
+
questions: canonical,
|
|
492
|
+
evidence,
|
|
493
|
+
options,
|
|
494
|
+
})
|
|
495
|
+
.then((judgment) => {
|
|
496
|
+
noteValidatorPaths()
|
|
497
|
+
return judgment as Judgment<Q[number]["id"]>
|
|
498
|
+
})
|
|
364
499
|
}
|
|
365
500
|
|
|
366
501
|
/** End the episode from any depth. The next episode starts the flow afresh. */
|
|
367
|
-
export const restart = (): Promise<never> =>
|
|
502
|
+
export const restart = (): Promise<never> => {
|
|
503
|
+
noValidatorStep("restart", "restart")
|
|
504
|
+
return ctx().step({ kind: "restart" }) as Promise<never>
|
|
505
|
+
}
|
|
368
506
|
|
|
369
507
|
/** Refuse the pending turn: nothing lands, the process stays where it rests. */
|
|
370
508
|
export const refuse = (message: string): never => ctx().refuse(message)
|
package/src/install/Install.ts
CHANGED
|
@@ -83,6 +83,7 @@ while :; do
|
|
|
83
83
|
model="$(gtd next --json=model)"
|
|
84
84
|
system="$(gtd next --json=system)"
|
|
85
85
|
validate="$(gtd next --json=validate)"
|
|
86
|
+
fresh="$(gtd next --json=fresh)"
|
|
86
87
|
log="$(gtd next --json=log)"
|
|
87
88
|
# \`model\` is a hint, not a model name — gtd-workflows' default ships
|
|
88
89
|
# \`smart\` and \`base\`, which \`claude --model\` rejects. Resolve them
|
|
@@ -125,8 +126,14 @@ while :; do
|
|
|
125
126
|
then first=--resume second=--session-id
|
|
126
127
|
else first=--session-id second=--resume
|
|
127
128
|
fi
|
|
129
|
+
# A validator retry resumes with the correction alone; a session that
|
|
130
|
+
# cannot resume starts over with \`fresh\`, the original prompt plus it.
|
|
131
|
+
second_prompt="$log.prompt"
|
|
132
|
+
if [ "$second" = --session-id ] && [ -n "\${fresh:-}" ]
|
|
133
|
+
then printf '%s\\n' "$fresh" >"$log.fresh" && second_prompt="$log.fresh"
|
|
134
|
+
fi
|
|
128
135
|
turn "$first" "$session_id" <"$log.prompt" ||
|
|
129
|
-
{ [ "$ran" = false ] && turn "$second" "$session_id" <"$
|
|
136
|
+
{ [ "$ran" = false ] && turn "$second" "$session_id" <"$second_prompt"; } || {
|
|
130
137
|
echo "agent turn failed — see $log" >&2
|
|
131
138
|
exit 1
|
|
132
139
|
}
|
|
@@ -140,7 +147,7 @@ while :; do
|
|
|
140
147
|
exit 1
|
|
141
148
|
}
|
|
142
149
|
done
|
|
143
|
-
rm -f "$log.prompt" ;;
|
|
150
|
+
rm -f "$log.prompt" "$log.fresh" ;;
|
|
144
151
|
esac
|
|
145
152
|
|
|
146
153
|
# Read every value the landing needs BEFORE piping the script to \`sh\`:
|
package/src/platform/Git.test.ts
CHANGED
|
@@ -87,17 +87,13 @@ describe("diffPaths [Live]", () => {
|
|
|
87
87
|
})
|
|
88
88
|
|
|
89
89
|
// An exported GIT_DIR/GIT_WORK_TREE would send this test's commits to the real repo;
|
|
90
|
-
// the
|
|
91
|
-
const cleanEnv =
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
GIT_CONFIG_VALUE_1: "test@test.com",
|
|
98
|
-
GIT_CONFIG_KEY_2: "user.name",
|
|
99
|
-
GIT_CONFIG_VALUE_2: "Test",
|
|
100
|
-
}
|
|
90
|
+
// the suite's isolated global config keeps a developer's signing setup out.
|
|
91
|
+
const cleanEnv = Object.fromEntries(
|
|
92
|
+
Object.entries(process.env).filter(
|
|
93
|
+
([key]) =>
|
|
94
|
+
!key.startsWith("GIT_") || key === "GIT_CONFIG_GLOBAL" || key === "GIT_CONFIG_NOSYSTEM",
|
|
95
|
+
),
|
|
96
|
+
)
|
|
101
97
|
|
|
102
98
|
it("reports a submodule pointer bump whatever diff.ignoreSubmodules and .gitmodules ignore say", async () => {
|
|
103
99
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-diffpaths-sm-")))
|
package/src/platform/NodeSync.ts
CHANGED
|
@@ -39,6 +39,8 @@ const infoOf = (stats: Stats): FileInfo => ({
|
|
|
39
39
|
? "file"
|
|
40
40
|
: "other",
|
|
41
41
|
uid: process.platform === "win32" ? undefined : stats.uid,
|
|
42
|
+
// git records 100755 from the owner execute bit alone.
|
|
43
|
+
...(process.platform === "win32" ? {} : { executable: (stats.mode & 0o100) !== 0 }),
|
|
42
44
|
})
|
|
43
45
|
|
|
44
46
|
export const nodeSyncIo: SyncIo = {
|