@pmelab/gtd 65.0.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 (63) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -2
  3. package/claude/hooks/drive.ts +10 -1
  4. package/claude/hooks/git.ts +2 -2
  5. package/claude/hooks/pr.ts +80 -20
  6. package/claude/hooks/register.tsx +19 -4
  7. package/dist/chunks/{flows-DGs0W3HI.mjs → flows-CmQplvh1.mjs} +134 -40
  8. package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
  9. package/dist/chunks/{testkit-CgrLmpdJ.mjs → testkit-BJhAAVGt.mjs} +179 -34
  10. package/dist/chunks/{wire-BNS8ciNu.mjs → wire-xTbzFc26.mjs} +7 -3
  11. package/dist/flows.mjs +1 -1
  12. package/dist/gtd.bundle.mjs +430 -100
  13. package/dist/gtd.core.mjs +147 -134
  14. package/dist/steering.mjs +1 -1
  15. package/dist/testkit/gherkin.mjs +1 -1
  16. package/dist/testkit.mjs +2 -2
  17. package/dist/web/main.css +1 -1
  18. package/dist/web/main.js +185 -18
  19. package/dist/wire.mjs +1 -1
  20. package/dist/workflow-kit.mjs +9 -3
  21. package/package.json +1 -1
  22. package/src/cli/Cli.ts +1 -1
  23. package/src/cli/Program.ts +18 -2
  24. package/src/edge/Edge.ts +93 -11
  25. package/src/flows/helpers.ts +36 -18
  26. package/src/flows/index.test.ts +3 -0
  27. package/src/flows/index.ts +5 -0
  28. package/src/flows/runtime.ts +123 -16
  29. package/src/install/Install.ts +9 -2
  30. package/src/platform/Git.test.ts +7 -11
  31. package/src/platform/NodeSync.ts +2 -0
  32. package/src/platform/SyncIo.ts +2 -0
  33. package/src/platform/Workspace.test.ts +37 -0
  34. package/src/platform/Workspace.ts +36 -0
  35. package/src/replay/Replay.ts +33 -1
  36. package/src/replay/Trailers.ts +3 -0
  37. package/src/replay/Tree.ts +5 -0
  38. package/src/steering/FORMATS.md +3 -1
  39. package/src/steering/SteeringFormat.ts +13 -0
  40. package/src/steering/fixtures/review/sectioned.md +1 -1
  41. package/src/steering/fixtures/review/sectioned.read.json +0 -1
  42. package/src/steering/index.ts +1 -0
  43. package/src/steering/review.test.ts +181 -5
  44. package/src/steering/review.ts +160 -18
  45. package/src/step/RepoSnapshot.ts +4 -0
  46. package/src/step/planStep.ts +6 -6
  47. package/src/twins/EmittedScriptRecognizer.ts +1 -1
  48. package/src/twins/InMemRepo.ts +24 -3
  49. package/src/twins/Layers.ts +1 -0
  50. package/src/twins/SyncIo.test.ts +19 -4
  51. package/src/twins/SyncIo.ts +13 -1
  52. package/src/wire/BeatDocument.test.ts +15 -0
  53. package/src/wire/BeatDocument.ts +8 -2
  54. package/src/wire/Demand.ts +4 -2
  55. package/src/wire/golden/1/beat-capture.json +1 -0
  56. package/src/wire/golden/1/beat-message.json +1 -0
  57. package/src/wire/golden/1/beat-prompt.json +1 -0
  58. package/src/wire/golden/1/beat-script.json +1 -0
  59. package/src/wire/golden/1/beat-stalled.json +1 -0
  60. package/src/workflow/Workflow.ts +2 -0
  61. package/src/workflow-kit/index.test.ts +1 -0
  62. package/src/workflow-kit/index.ts +1 -0
  63. package/src/workflow-kit/scenarios.ts +8 -0
package/src/edge/Edge.ts CHANGED
@@ -761,6 +761,7 @@ const promptDef = (
761
761
  ...optional("system", request.options.system),
762
762
  ...optional("allowEmpty", request.options.allowEmpty),
763
763
  ...optional("keep", request.options.keep),
764
+ ...optional("original", request.original),
764
765
  ...optional("skills", skills),
765
766
  ...optional("access", access),
766
767
  })
@@ -1288,6 +1289,7 @@ export interface RenderedRest extends RestHints {
1288
1289
  readonly content: string
1289
1290
  readonly memory?: string
1290
1291
  readonly memoryResumed: boolean
1292
+ readonly original?: string
1291
1293
  readonly pin?: PinChange
1292
1294
  readonly failure?: string
1293
1295
  readonly blocked?: string
@@ -1306,6 +1308,7 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
1306
1308
  ...(rest.shipBlocked !== undefined ? { blocked: rest.shipBlocked } : {}),
1307
1309
  ...(rest.memory !== undefined ? { memory: rest.memory } : {}),
1308
1310
  memoryResumed: rest.memoryResumed,
1311
+ ...(rest.stepDef.original !== undefined ? { original: rest.stepDef.original } : {}),
1309
1312
  ...(rest.pin !== undefined ? { pin: rest.pin } : {}),
1310
1313
  })
1311
1314
 
@@ -1383,7 +1386,7 @@ const cleanLanding = (rest: Rest): Landing | undefined => {
1383
1386
  return undefined
1384
1387
  }
1385
1388
 
1386
- /** At the human review gate the pending tree lands with every tick cleared, as the landing script will. */
1389
+ /** At the human review gate the pending tree lands with every tick cleared; an agent-bound landing keeps them only if the unrewritten tree replays to the same step. */
1387
1390
  const reviewGateRewrite = (def: StepDef): Rewrite | undefined => {
1388
1391
  const reviewFormat = steeringFormatFor("review")
1389
1392
  if (!isHumanReviewGate(def) || def.file === undefined || reviewFormat === undefined) {
@@ -1392,6 +1395,16 @@ const reviewGateRewrite = (def: StepDef): Rewrite | undefined => {
1392
1395
  return { path: def.file, apply: (content) => reviewFormat.clearTicks(content) }
1393
1396
  }
1394
1397
 
1398
+ /** Whether the pending review file differs from HEAD's once both have their ticks cleared. */
1399
+ const reviewEditedBeyondTicks = (rest: Rest): boolean => {
1400
+ const rewrite = reviewGateRewrite(rest.stepDef)
1401
+ if (rewrite === undefined) return false
1402
+ const pending = rest.setup.workspace.readSync(rewrite.path)
1403
+ const committed = rest.setup.workspace.readCommittedSync(rewrite.path)
1404
+ if (pending === undefined || committed === undefined) return true
1405
+ return rewrite.apply(pending) !== rewrite.apply(committed)
1406
+ }
1407
+
1395
1408
  const multilineRefusal = (vars: Record<string, string>): Landing | undefined => {
1396
1409
  const multiline = multilineSetting(vars)
1397
1410
  return multiline === undefined
@@ -1579,7 +1592,28 @@ const decideLanding = (
1579
1592
  )
1580
1593
  const to = yield* landingTarget(outcome, rest)
1581
1594
  if (typeof to !== "string") return to
1582
- return landingTo(rest, to, branch, approver, outcomeMarks(outcome))
1595
+ const landed = landingTo(rest, to, branch, approver, outcomeMarks(outcome))
1596
+ // Ticks survive an agent-bound landing only when the review file changed beyond ticks
1597
+ // (a tick-only sign-off lands clean) and the unrewritten tree replays to the same step.
1598
+ if (
1599
+ landed.kind !== "commit" ||
1600
+ !isHumanReviewGate(rest.stepDef) ||
1601
+ outcome.kind !== "rest" ||
1602
+ outcome.rest.request.kind !== "agent" ||
1603
+ !reviewEditedBeyondTicks(rest)
1604
+ ) {
1605
+ return landed
1606
+ }
1607
+ const ticked = yield* Effect.promise(() =>
1608
+ replayFor(rest.setup, {
1609
+ tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), undefined),
1610
+ ...(verdicts !== undefined ? { verdicts } : {}),
1611
+ ...(shipped !== undefined ? { shipped } : {}),
1612
+ }),
1613
+ )
1614
+ return ticked.kind === "rest" && ticked.rest.name === outcome.rest.name
1615
+ ? { ...landed, keepTicks: true }
1616
+ : landed
1583
1617
  })
1584
1618
 
1585
1619
  /** The user's branch: `<b>` for a process on `gtd/<b>` while `<b>` still exists; any other branch as is. */
@@ -1620,7 +1654,13 @@ const withHistory = (landing: Landing & { kind: "commit" }) =>
1620
1654
  let out = landing
1621
1655
  const back = landing.keepHistory === undefined ? undefined : yield* returnBranch
1622
1656
  if (back !== undefined) out = { ...out, leaveBranch: back }
1623
- if (landing.keepHistory === undefined && landing.ship === undefined) return out
1657
+ if (
1658
+ landing.keepHistory === undefined &&
1659
+ landing.ship === undefined &&
1660
+ landing.recordedHistory === undefined
1661
+ ) {
1662
+ return out
1663
+ }
1624
1664
  const remote = (yield* (yield* ConfigService).load).historyRemote
1625
1665
  return remote === undefined ? out : { ...out, pushHistory: remote }
1626
1666
  })
@@ -1659,9 +1699,16 @@ const SHIP_EXCLUDED = ".gtd/"
1659
1699
 
1660
1700
  type ShipDecision =
1661
1701
  | { readonly kind: "ship"; readonly branch: string; readonly parent: string }
1702
+ | { readonly kind: "recorded"; readonly hash: string }
1662
1703
  | { readonly kind: "skip"; readonly note: string }
1663
1704
  | { readonly kind: "blocked"; readonly message: string }
1664
1705
 
1706
+ /** `tree` with file modes read from `ref` (the working tree when `undefined`), for the ship decision only: reading them costs a git call. */
1707
+ const withModes = (workspace: WorkspaceOps, tree: TreeView, ref: string | undefined): TreeView => {
1708
+ let modes: ReadonlyMap<string, string> | undefined
1709
+ return { ...tree, mode: (path) => (modes ??= workspace.modeSync(ref)).get(path) }
1710
+ }
1711
+
1665
1712
  const shippedPaths = (before: TreeView, after: TreeView): readonly string[] => {
1666
1713
  const diff = diffTrees(before, after)
1667
1714
  return [...diff.added, ...diff.modified, ...diff.deleted].filter(
@@ -1700,6 +1747,7 @@ const revertedPaths = (
1700
1747
  parent: string,
1701
1748
  tree: TreeView,
1702
1749
  changed: ReadonlySet<string>,
1750
+ treeRef: string | undefined,
1703
1751
  ) =>
1704
1752
  Effect.gen(function* () {
1705
1753
  const head = yield* git.resolveRef("HEAD")
@@ -1708,9 +1756,18 @@ const revertedPaths = (
1708
1756
  const fork = yield* git.mergeBase(parent, head)
1709
1757
  const foreign = Option.isNone(fork)
1710
1758
  ? [...changed]
1711
- : shippedPaths(commitTree(workspace, fork.value), commitTree(workspace, parent))
1712
- const committed = commitTree(workspace, head)
1713
- return foreign.filter((path) => changed.has(path) && tree.read(path) === committed.read(path))
1759
+ : shippedPaths(
1760
+ withModes(workspace, commitTree(workspace, fork.value), fork.value),
1761
+ withModes(workspace, commitTree(workspace, parent), parent),
1762
+ )
1763
+ const committed = withModes(workspace, commitTree(workspace, head), head)
1764
+ const moded = withModes(workspace, tree, treeRef)
1765
+ return foreign.filter(
1766
+ (path) =>
1767
+ changed.has(path) &&
1768
+ moded.read(path) === committed.read(path) &&
1769
+ moded.mode!(path) === committed.mode!(path),
1770
+ )
1714
1771
  })
1715
1772
 
1716
1773
  /**
@@ -1722,14 +1779,31 @@ const revertedPaths = (
1722
1779
  const decideShip = (
1723
1780
  workspace: WorkspaceOps,
1724
1781
  tree: TreeView,
1782
+ /** The commit `tree` is of; `undefined` for the working tree. */
1783
+ treeRef: string | undefined,
1725
1784
  ): Effect.Effect<ShipDecision, Error, GitService> =>
1726
1785
  Effect.gen(function* () {
1727
1786
  const git = yield* GitService
1728
1787
  const target = yield* shipTarget(git)
1729
1788
  if (target.kind !== "ship") return target
1730
1789
  const { branch, parent } = target
1731
- const changed = new Set(shippedPaths(commitTree(workspace, parent), tree))
1732
- const reverted = yield* revertedPaths(git, workspace, parent, tree, changed)
1790
+ const changed = new Set(
1791
+ shippedPaths(
1792
+ withModes(workspace, commitTree(workspace, parent), parent),
1793
+ withModes(workspace, tree, treeRef),
1794
+ ),
1795
+ )
1796
+ // A landing whose hook rejected after the ship moved `<b>`: its tip is a ship of this very HEAD.
1797
+ // An edit since (say, to satisfy that hook) ships again rather than reusing the stale ship.
1798
+ const pending = parseCommitMessage(yield* git.lastCommitMessage(parent)).tip
1799
+ if (
1800
+ changed.size === 0 &&
1801
+ pending !== undefined &&
1802
+ pending === (yield* git.resolveRef("HEAD"))
1803
+ ) {
1804
+ return { kind: "recorded", hash: parent }
1805
+ }
1806
+ const reverted = yield* revertedPaths(git, workspace, parent, tree, changed, treeRef)
1733
1807
  if (reverted.length > 0) {
1734
1808
  return {
1735
1809
  kind: "blocked",
@@ -1750,7 +1824,7 @@ const shipBlockedAt = (
1750
1824
  Effect.gen(function* () {
1751
1825
  if (pin !== undefined || step.request.kind !== "ship" || changes.length > 0) return undefined
1752
1826
  const head = yield* (yield* GitService).resolveRef("HEAD")
1753
- const decision = yield* decideShip(setup.workspace, commitTree(setup.workspace, head))
1827
+ const decision = yield* decideShip(setup.workspace, commitTree(setup.workspace, head), head)
1754
1828
  return decision.kind === "blocked" ? decision.message : undefined
1755
1829
  })
1756
1830
 
@@ -1763,7 +1837,7 @@ const shipSnapshot = (
1763
1837
  ) =>
1764
1838
  Effect.gen(function* () {
1765
1839
  const tree = pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), undefined)
1766
- const decision = yield* decideShip(rest.setup.workspace, tree)
1840
+ const decision = yield* decideShip(rest.setup.workspace, tree, undefined)
1767
1841
  if (decision.kind === "blocked") return refusedSnapshot(rest, decision.message)
1768
1842
  const shipping = decision.kind === "ship"
1769
1843
  const decided = yield* decideLanding(
@@ -1771,9 +1845,17 @@ const shipSnapshot = (
1771
1845
  verdicts,
1772
1846
  branch,
1773
1847
  approver,
1774
- shipping ? SHIPPED_STAND_IN : undefined,
1848
+ decision.kind === "recorded" ? decision.hash : shipping ? SHIPPED_STAND_IN : undefined,
1775
1849
  )
1776
1850
  if (decided.kind !== "commit") return snapshotOf(rest, decided)
1851
+ if (decision.kind === "recorded") {
1852
+ const recorded = {
1853
+ ...decided,
1854
+ spec: { ...decided.spec, shipped: decision.hash },
1855
+ recordedHistory: processHistoryRef(rest.run.startHash),
1856
+ }
1857
+ return snapshotOf(rest, yield* withBranchAndPush(recorded))
1858
+ }
1777
1859
  const landing: Landing = shipping
1778
1860
  ? {
1779
1861
  ...decided,
@@ -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",
@@ -48,6 +48,8 @@ export {
48
48
  type JudgeQuestion,
49
49
  type JudgeQuestionInput,
50
50
  type JudgeSpec,
51
+ type ValidateResult,
52
+ type ValidateTurn,
51
53
  type Judgment,
52
54
  type KeptTip,
53
55
  type Mark,
@@ -78,11 +80,14 @@ export {
78
80
  hasThreadFor,
79
81
  judgeDiffShape,
80
82
  numeric,
83
+ progressErrors,
84
+ repliesErrors,
81
85
  requireAnswers,
82
86
  requireProgress,
83
87
  requireReplies,
84
88
  requireRevert,
85
89
  requireThreadsClosed,
90
+ revertErrors,
86
91
  seamGate,
87
92
  wrote,
88
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 &
@@ -209,6 +225,8 @@ export type StepRequest =
209
225
  readonly name: string
210
226
  readonly prompt: string
211
227
  readonly options: AgentOptions
228
+ /** On a validator retry: the step's original prompt (`prompt` is then the correction). */
229
+ readonly original?: string
212
230
  }
213
231
  | { readonly kind: "human"; readonly name: string; readonly options: HumanOptions }
214
232
  | {
@@ -331,9 +349,84 @@ const ctx = (): FlowContext => {
331
349
 
332
350
  // ── Steps ───────────────────────────────────────────────────────────────────
333
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
+
334
376
  /** An agent turn: `prompt` is emitted, the driver lands the diff. */
335
- export const agent = (name: string, prompt: string, options: AgentOptions = {}): Promise<void> =>
336
- 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
+ }
337
430
 
338
431
  // `Omit` over a union collapses it; this keeps each member's own rules.
339
432
  type OmitEach<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
@@ -349,8 +442,9 @@ export const human: Human = (
349
442
  name: string,
350
443
  messageOrOptions?: string | HumanOptions,
351
444
  options?: OmitEach<HumanOptions, "message">,
352
- ): Promise<void> =>
353
- ctx().step({
445
+ ): Promise<void> => {
446
+ noValidatorStep("human", name)
447
+ return ctx().step({
354
448
  kind: "human",
355
449
  name,
356
450
  options:
@@ -358,10 +452,13 @@ export const human: Human = (
358
452
  ? { ...options, message: messageOrOptions }
359
453
  : (messageOrOptions ?? {}),
360
454
  }) as Promise<void>
455
+ }
361
456
 
362
457
  /** A check: `body` runs at the edge and its effect on the tree is committed. */
363
- export const run = (name: string, body: string, options: RunOptions = {}): Promise<void> =>
364
- 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
+ }
365
462
 
366
463
  /**
367
464
  * Squashes HEAD's tree, with `.gtd/` as the user's branch has it, into one commit on the user's branch
@@ -372,8 +469,10 @@ export const ship = (
372
469
  name: string,
373
470
  message: string,
374
471
  options: ShipOptions = {},
375
- ): Promise<string | undefined> =>
376
- ctx().step({ kind: "ship", name, message, options }) as Promise<string | undefined>
472
+ ): Promise<string | undefined> => {
473
+ noValidatorStep("ship", name)
474
+ return ctx().step({ kind: "ship", name, message, options }) as Promise<string | undefined>
475
+ }
377
476
 
378
477
  /** A judge gate: resolves to what the judge answered, for the flow to decide on. */
379
478
  export const judge = <const Q extends readonly JudgeQuestionInput[] = readonly JudgeQuestion[]>(
@@ -385,17 +484,25 @@ export const judge = <const Q extends readonly JudgeQuestionInput[] = readonly J
385
484
  const canonical = questions.map((q) =>
386
485
  q.primitive === "yesno" ? { ...q, primitive: "noul" as const } : (q as JudgeQuestion),
387
486
  )
388
- return ctx().step({
389
- kind: "judge",
390
- name,
391
- questions: canonical,
392
- evidence,
393
- options,
394
- }) 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
+ })
395
499
  }
396
500
 
397
501
  /** End the episode from any depth. The next episode starts the flow afresh. */
398
- 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
+ }
399
506
 
400
507
  /** Refuse the pending turn: nothing lands, the process stays where it rests. */
401
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 = {
@@ -4,6 +4,8 @@ export interface FileInfo {
4
4
  readonly type: "file" | "directory" | "symlink" | "other"
5
5
  /** `undefined` where the platform has no POSIX user ids. */
6
6
  readonly uid: number | undefined
7
+ /** Any execute bit set; `undefined` where the platform has none. */
8
+ readonly executable?: boolean
7
9
  }
8
10
 
9
11
  /**