@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.
Files changed (91) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +35 -25
  3. package/claude/hooks/assist.ts +9 -35
  4. package/claude/hooks/doors.ts +4 -4
  5. package/claude/hooks/drive.ts +10 -1
  6. package/claude/hooks/git.ts +56 -0
  7. package/claude/hooks/handoff.ts +11 -11
  8. package/claude/hooks/jobs.ts +8 -8
  9. package/claude/hooks/pr.ts +86 -26
  10. package/claude/hooks/register.tsx +35 -82
  11. package/claude/hooks/tools.ts +7 -20
  12. package/claude/hooks/wording.ts +3 -3
  13. package/dist/chunks/{flows-dnIE_qbe.mjs → flows-CmQplvh1.mjs} +146 -35
  14. package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
  15. package/dist/chunks/{testkit-BUX-47TD.mjs → testkit-BJhAAVGt.mjs} +905 -99
  16. package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-xTbzFc26.mjs} +12 -6
  17. package/dist/flows.mjs +1 -1
  18. package/dist/gtd.bundle.mjs +965 -163
  19. package/dist/gtd.core.mjs +150 -134
  20. package/dist/steering.mjs +1 -1
  21. package/dist/testkit/gherkin.mjs +1 -1
  22. package/dist/testkit.mjs +2 -2
  23. package/dist/web/main.css +1 -1
  24. package/dist/web/main.js +185 -18
  25. package/dist/wire.mjs +1 -1
  26. package/dist/workflow-kit.mjs +9 -3
  27. package/package.json +1 -1
  28. package/src/cli/Cli.ts +5 -5
  29. package/src/cli/History.test.ts +10 -8
  30. package/src/cli/Metrics.test.ts +2 -1
  31. package/src/cli/Program.test.ts +4 -2
  32. package/src/cli/Program.ts +105 -7
  33. package/src/edge/Edge.test.ts +37 -16
  34. package/src/edge/Edge.ts +380 -33
  35. package/src/emit/GitScript.ts +116 -15
  36. package/src/emit/index.ts +7 -0
  37. package/src/flows/config.ts +7 -1
  38. package/src/flows/helpers.ts +36 -18
  39. package/src/flows/index.test.ts +4 -0
  40. package/src/flows/index.ts +7 -0
  41. package/src/flows/runtime.ts +153 -15
  42. package/src/install/Install.ts +9 -2
  43. package/src/platform/Git.test.ts +7 -11
  44. package/src/platform/NodeSync.ts +2 -0
  45. package/src/platform/RetainedHistory.ts +3 -0
  46. package/src/platform/SyncIo.ts +2 -0
  47. package/src/platform/Workspace.test.ts +37 -0
  48. package/src/platform/Workspace.ts +36 -0
  49. package/src/platform/index.ts +1 -0
  50. package/src/replay/Replay.test.ts +9 -0
  51. package/src/replay/Replay.ts +92 -16
  52. package/src/replay/Trailers.ts +17 -0
  53. package/src/replay/Tree.ts +5 -0
  54. package/src/replay/index.ts +1 -1
  55. package/src/steering/FORMATS.md +3 -1
  56. package/src/steering/SteeringFormat.ts +13 -0
  57. package/src/steering/fixtures/review/sectioned.md +1 -1
  58. package/src/steering/fixtures/review/sectioned.read.json +0 -1
  59. package/src/steering/index.ts +1 -0
  60. package/src/steering/review.test.ts +181 -5
  61. package/src/steering/review.ts +160 -18
  62. package/src/step/LandStep.ts +12 -0
  63. package/src/step/RepoSnapshot.ts +15 -0
  64. package/src/step/index.ts +1 -0
  65. package/src/step/planStart.test.ts +4 -3
  66. package/src/step/planStart.ts +7 -0
  67. package/src/step/planStep.ts +22 -17
  68. package/src/step/startBranch.ts +54 -0
  69. package/src/twins/EmittedScriptRecognizer.ts +118 -1
  70. package/src/twins/EmittedScripts.test.ts +147 -1
  71. package/src/twins/InMemRepo.ts +140 -3
  72. package/src/twins/Layers.ts +1 -0
  73. package/src/twins/SyncIo.test.ts +19 -4
  74. package/src/twins/SyncIo.ts +13 -1
  75. package/src/wire/BeatDocument.test.ts +15 -0
  76. package/src/wire/BeatDocument.ts +8 -2
  77. package/src/wire/Demand.ts +26 -12
  78. package/src/wire/constants.ts +3 -0
  79. package/src/wire/golden/1/beat-capture.json +1 -0
  80. package/src/wire/golden/1/beat-message.json +1 -0
  81. package/src/wire/golden/1/beat-prompt.json +1 -0
  82. package/src/wire/golden/1/beat-script.json +1 -0
  83. package/src/wire/golden/1/beat-stalled.json +1 -0
  84. package/src/wire/types.ts +4 -0
  85. package/src/workflow/Workflow.ts +3 -0
  86. package/src/workflow/compile.ts +2 -0
  87. package/src/workflow/load.ts +2 -0
  88. package/src/workflow-kit/index.test.ts +1 -0
  89. package/src/workflow-kit/index.ts +1 -0
  90. package/src/workflow-kit/scenarios.ts +8 -0
  91. package/claude/hooks/ship.ts +0 -730
@@ -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
- const m = shellQuote(message)
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
- const renderGitWrite = (write: GitWrite): string => {
174
- switch (write.kind) {
175
- case "commitAll":
176
- return commitAll(write.message)
177
- case "commitPath":
178
- return commitPath(write.path, write.message)
179
- case "commitClean":
180
- return commitClean(write.message)
181
- case "updateRef":
182
- return updateRef(write.ref, write.target)
183
- case "keepTurn":
184
- return keepTurn(write.ref, write.message)
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,
@@ -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) =>
@@ -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 change = changes().get(file)
84
- if (change?.status !== "deleted") return
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 open = [...threadLines(file, "agent"), ...codeThreadLines("agent")]
141
- if (open.length === 0) return
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
- /** Refuse a turn that did not undo the code edits `edited` recorded, back to their content before `base`. */
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
- refuse(
153
- `gtd land: require-revert: ${residue.join(", ")} still differ from ${base}~1 — the revert did not take. Run \`git checkout ${base}~1 -- ${quoted}\`, then \`gtd land\` again.`,
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
@@ -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",
@@ -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,
@@ -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 = (name: string, prompt: string, options: AgentOptions = {}): Promise<void> =>
317
- ctx().step({ kind: "agent", name, prompt, options }) as Promise<void>
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
- ctx().step({
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 }) as Promise<void>
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().step({
358
- kind: "judge",
359
- name,
360
- questions: canonical,
361
- evidence,
362
- options,
363
- }) as Promise<Judgment<Q[number]["id"]>>
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> => ctx().step({ kind: "restart" }) as 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)
@@ -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" <"$log.prompt"; } || {
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\`:
@@ -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 pinned config keeps a global commit.gpgsign or missing identity from failing them.
91
- const cleanEnv = {
92
- ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))),
93
- GIT_CONFIG_COUNT: "3",
94
- GIT_CONFIG_KEY_0: "commit.gpgsign",
95
- GIT_CONFIG_VALUE_0: "false",
96
- GIT_CONFIG_KEY_1: "user.email",
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-")))
@@ -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 = {