@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
@@ -27,6 +27,11 @@ function makeHash(message: string, parent: string | null, tree: Map<string, stri
27
27
  return sha1(`${message}\n${parent ?? "null"}\n${treeStr}`)
28
28
  }
29
29
 
30
+ /** An executable file's content carries this prefix inside the twin, so every tree copy keeps the mode for free; only the read edges strip it. */
31
+ const EXEC = "\u0000exec\u0000"
32
+ const plain = (content: string): string =>
33
+ content.startsWith(EXEC) ? content.slice(EXEC.length) : content
34
+
30
35
  export class InMemRepo {
31
36
  // Data-only marker read by no code path: embeds the sentinel in every
32
37
  // instance so a leaked object still carries it.
@@ -180,6 +185,89 @@ export class InMemRepo {
180
185
  this.index = new Map(tree)
181
186
  }
182
187
 
188
+ /** `git switch -C <name> [<start>]`: pending changes ride along only when the tip's tree is unchanged. */
189
+ switchBranchTo(name: string, start?: string): void {
190
+ const hash = start === undefined ? this.head : this.resolveRef(start)
191
+ if (hash === null) throw new Error(`invalid reference: ${start ?? "HEAD"}`)
192
+ this.moveToBranch(name, hash, true)
193
+ }
194
+
195
+ /** `git switch <name>`: a pending change rides along when its path is the same in both trees. */
196
+ switchTo(name: string): void {
197
+ const hash = this.branches.get(name)
198
+ if (hash === undefined) throw new Error(`invalid reference: ${name}`)
199
+ if (hash === this.head) return this.moveToBranch(name, hash, false)
200
+ const from = this.headTree()
201
+ const to = this.getCommit(hash)?.files ?? new Map<string, string>()
202
+ const pending = [
203
+ ...new Set([...from.keys(), ...this.index.keys(), ...this.worktree.keys()]),
204
+ ].filter(
205
+ (path) =>
206
+ this.index.get(path) !== from.get(path) || this.worktree.get(path) !== from.get(path),
207
+ )
208
+ if (pending.some((path) => to.get(path) !== from.get(path))) {
209
+ throw new Error(`cannot switch to '${name}': local changes would be overwritten`)
210
+ }
211
+ const carry = (side: Map<string, string>) => {
212
+ const out = new Map(to)
213
+ for (const path of pending) {
214
+ const content = side.get(path)
215
+ if (content === undefined) out.delete(path)
216
+ else out.set(path, content)
217
+ }
218
+ return out
219
+ }
220
+ this.worktree = carry(this.worktree)
221
+ this.index = carry(this.index)
222
+ this.head = hash
223
+ this.currentBranch = name
224
+ }
225
+
226
+ private moveToBranch(name: string, hash: string, reset: boolean): void {
227
+ if (hash !== this.head) {
228
+ if (this.statusPorcelain().trim() !== "") {
229
+ throw new Error(`cannot switch to '${name}': the working tree has uncommitted changes`)
230
+ }
231
+ const tree = this.getCommit(hash)?.files ?? new Map<string, string>()
232
+ this.head = hash
233
+ this.worktree = new Map(tree)
234
+ this.index = new Map(tree)
235
+ }
236
+ if (reset || !this.branches.has(name)) this.branches.set(name, hash)
237
+ this.currentBranch = name
238
+ }
239
+
240
+ deleteBranch(name: string): void {
241
+ if (name === this.currentBranch) throw new Error(`cannot delete branch '${name}' checked out`)
242
+ if (!this.branches.delete(name)) throw new Error(`branch '${name}' not found`)
243
+ }
244
+
245
+ private remotes: Map<string, Map<string, string>> = new Map()
246
+
247
+ /** Test setup: a remote that accepts pushes. */
248
+ // fallow-ignore-next-line unused-class-member
249
+ addRemote(name: string): void {
250
+ this.remotes.set(name, new Map())
251
+ }
252
+
253
+ /** What a remote holds after pushes: ref → hash. */
254
+ // fallow-ignore-next-line unused-class-member
255
+ remoteRefs(name: string): ReadonlyMap<string, string> {
256
+ return this.remotes.get(name) ?? new Map()
257
+ }
258
+
259
+ /** `git push --force <remote> <ref>:<ref>`; returns git's complaint, or `undefined`. */
260
+ pushRef(remote: string, ref: string): string | undefined {
261
+ const there = this.remotes.get(remote)
262
+ if (there === undefined) {
263
+ return `fatal: '${remote}' does not appear to be a git repository`
264
+ }
265
+ const hash = this.resolveRef(ref)
266
+ if (hash === null) return `error: src refspec ${ref} does not match any`
267
+ there.set(ref, hash)
268
+ return undefined
269
+ }
270
+
183
271
  /** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
184
272
  listRefs(prefix: string): Array<{ ref: string; hash: string; date: string }> {
185
273
  return [...this.refs.entries()]
@@ -284,7 +372,8 @@ export class InMemRepo {
284
372
  if (!hash) return null
285
373
  const c = this.getCommit(hash)
286
374
  if (!c) return null
287
- return c.files.get(path) ?? null
375
+ const content = c.files.get(path)
376
+ return content === undefined ? null : plain(content)
288
377
  }
289
378
 
290
379
  /**
@@ -317,7 +406,20 @@ export class InMemRepo {
317
406
 
318
407
  /** The worktree content of `path`, or `undefined` when absent. A LIVE read — never a snapshot. */
319
408
  readFile(path: string): string | undefined {
320
- return this.worktree.get(path)
409
+ const content = this.worktree.get(path)
410
+ return content === undefined ? undefined : plain(content)
411
+ }
412
+
413
+ /** `chmod +x` / `-x` on a working-tree file. */
414
+ setExecutable(path: string, on: boolean): void {
415
+ const content = this.readFile(path)
416
+ if (content !== undefined) this.worktree.set(path, on ? EXEC + content : content)
417
+ }
418
+
419
+ /** Every path's file mode at `ref`, or in the working tree when `ref` is `undefined`. */
420
+ modesAt(ref: string | undefined): Map<string, string> {
421
+ const tree = ref === undefined ? this.worktree : this.treeAt(ref)
422
+ return new Map([...tree].map(([path, c]) => [path, c.startsWith(EXEC) ? "100755" : "100644"]))
321
423
  }
322
424
 
323
425
  /** True when `path` is exactly a worktree file, or a directory prefix of one (any worktree key starts with `path/`). */
@@ -473,6 +575,39 @@ export class InMemRepo {
473
575
  return undefined
474
576
  }
475
577
 
578
+ /** Twin of `GitScript.shipCommit`: the staged tree, with `.gtd/` as `branch` has it, onto `branch`, then the landing commit naming it. */
579
+ shipCommit(
580
+ branch: string,
581
+ parent: string,
582
+ shipMessage: string,
583
+ historyRef: string,
584
+ message: string,
585
+ ): string | undefined {
586
+ this.stageAll()
587
+ if (this.head === null) return "fatal: Needed a single revision"
588
+ if (this.signingFails) return "error: gpg failed to sign the data"
589
+ const kept = [...(this.commits.get(parent)?.files ?? [])]
590
+ const tree = new Map([
591
+ ...[...this.index].filter(([path]) => !path.startsWith(".gtd/")),
592
+ ...kept.filter(([path]) => path.startsWith(".gtd/")),
593
+ ])
594
+ const shipped = `${shipMessage}\n\nGtd-History: ${historyRef}\nGtd-Tip: ${this.head}\n`
595
+ const hash = makeHash(shipped, parent, tree)
596
+ if (this.branches.get(branch) !== parent) {
597
+ return `fatal: update_ref failed for ref 'refs/heads/${branch}': cannot lock ref 'refs/heads/${branch}': is at ${this.branches.get(branch) ?? "nothing"} but expected ${parent}`
598
+ }
599
+ this.commits.set(hash, {
600
+ hash,
601
+ message: shipped,
602
+ files: tree,
603
+ parent,
604
+ seq: this.nextSeq++,
605
+ signed: this.signingEnabled,
606
+ })
607
+ this.branches.set(branch, hash)
608
+ return this.commitAsIs(`${message}\nGtd-Shipped: ${hash}`)
609
+ }
610
+
476
611
  /** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
477
612
  discardPending(): void {
478
613
  this.index = new Map(this.worktree)
@@ -557,7 +692,9 @@ export class InMemRepo {
557
692
  }
558
693
 
559
694
  writeFile(path: string, content: string): void {
560
- this.worktree.set(path, content)
695
+ // A live overwrite keeps the mode.
696
+ const was = this.worktree.get(path)
697
+ this.worktree.set(path, was?.startsWith(EXEC) ? EXEC + content : content)
561
698
  }
562
699
 
563
700
  deleteFile(path: string): void {
@@ -119,6 +119,7 @@ export const makeInMemoryWorkspaceOps = (repo: InMemRepo, root: string): Workspa
119
119
  Effect.succeed(
120
120
  new Map([...(base !== undefined ? [base] : []), ...commits].map((h) => [h, treeOf(h)])),
121
121
  ),
122
+ modeSync: (ref) => repo.modesAt(ref),
122
123
  worktreeSync: () =>
123
124
  new Map(
124
125
  repo
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process"
2
- import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"
2
+ import { chmodSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"
3
3
  import { tmpdir } from "node:os"
4
4
  import { join } from "node:path"
5
5
  import { afterEach, describe, expect, it } from "vitest"
@@ -12,6 +12,7 @@ interface Tier {
12
12
  readonly root: string
13
13
  readonly uid: number | undefined
14
14
  readonly symlink: (path: string, target: string) => void
15
+ readonly chmod: (path: string, executable: boolean) => void
15
16
  /** Commits `files`, repository-relative, as the only change. */
16
17
  readonly commit: (files: Record<string, string>) => void
17
18
  }
@@ -29,6 +30,7 @@ const liveTier = (): Tier => {
29
30
  root,
30
31
  uid: process.getuid?.(),
31
32
  symlink: (path, target) => symlinkSync(target, path),
33
+ chmod: (path, executable) => chmodSync(path, executable ? 0o755 : 0o644),
32
34
  commit: (files) => {
33
35
  for (const [path, content] of Object.entries(files))
34
36
  nodeSyncIo.writeText(join(root, path), content)
@@ -48,6 +50,7 @@ const inMemTier = (): Tier => {
48
50
  root,
49
51
  uid: 501,
50
52
  symlink: io.symlink,
53
+ chmod: io.setExecutable,
51
54
  commit: (files) => {
52
55
  for (const [path, content] of Object.entries(files)) {
53
56
  io.writeText(`${root}/${path}`, content)
@@ -106,9 +109,9 @@ describe.each([
106
109
  symlink(`${root}/link`, "dir")
107
110
  symlink(`${root}/dangling`, "nowhere")
108
111
 
109
- expect(io.stat(`${root}/dir/f`)).toEqual({ type: "file", uid })
110
- expect(io.stat(`${root}/link`)).toEqual({ type: "directory", uid })
111
- expect(io.lstat(`${root}/link`)).toEqual({ type: "symlink", uid })
112
+ expect(io.stat(`${root}/dir/f`)).toMatchObject({ type: "file", uid })
113
+ expect(io.stat(`${root}/link`)).toMatchObject({ type: "directory", uid })
114
+ expect(io.lstat(`${root}/link`)).toMatchObject({ type: "symlink", uid })
112
115
  expect(io.lstat(`${root}/missing`)).toBeUndefined()
113
116
  expect(io.stat(`${root}/dangling`)).toBeUndefined()
114
117
  expect(io.readLink(`${root}/link`)).toBe("dir")
@@ -117,6 +120,18 @@ describe.each([
117
120
  expect(() => io.realPath(`${root}/dangling`)).toThrow()
118
121
  })
119
122
 
123
+ it("reports the executable bit of files, kept across an overwrite", () => {
124
+ const { io, root, chmod } = makeTier()
125
+ io.writeText(`${root}/f`, "x")
126
+ expect(io.stat(`${root}/f`)?.executable).toBe(false)
127
+ chmod(`${root}/f`, true)
128
+ expect(io.stat(`${root}/f`)?.executable).toBe(true)
129
+ io.writeText(`${root}/f`, "y")
130
+ expect(io.lstat(`${root}/f`)?.executable).toBe(true)
131
+ chmod(`${root}/f`, false)
132
+ expect(io.stat(`${root}/f`)?.executable).toBe(false)
133
+ })
134
+
120
135
  it("reads committed content through git", () => {
121
136
  const { io, root, commit } = makeTier()
122
137
  commit({ "a.txt": "committed\n" })
@@ -15,6 +15,8 @@ const decoder = new TextDecoder("utf-8", { ignoreBOM: true })
15
15
 
16
16
  export interface InMemorySyncIo extends SyncIo {
17
17
  readonly symlink: (path: string, target: string) => void
18
+ /** `chmod +x` / `-x` on a file. */
19
+ readonly setExecutable: (path: string, on: boolean) => void
18
20
  }
19
21
 
20
22
  /**
@@ -27,6 +29,7 @@ export const inMemorySyncIo = (
27
29
  const files = new Map<string, Uint8Array>()
28
30
  const links = new Map<string, string>()
29
31
  const dirs = new Set<string>(["/"])
32
+ const execs = new Set<string>()
30
33
 
31
34
  /** `path` with every symlink resolved, the last component's only when `followLast`. */
32
35
  const resolve = (path: string, followLast: boolean): string => {
@@ -68,7 +71,9 @@ export const inMemorySyncIo = (
68
71
 
69
72
  const info = (path: string): FileInfo | undefined => {
70
73
  const type = typeOf(path)
71
- return type === undefined ? undefined : { type, uid: opts.uid }
74
+ return type === undefined
75
+ ? undefined
76
+ : { type, uid: opts.uid, ...(type === "file" ? { executable: execs.has(path) } : {}) }
72
77
  }
73
78
 
74
79
  const readFile = (path: string): Uint8Array | undefined => {
@@ -136,6 +141,8 @@ export const inMemorySyncIo = (
136
141
  links.delete(target)
137
142
  files.set(target, bytes)
138
143
  files.delete(source)
144
+ execs.delete(target)
145
+ if (execs.delete(source)) execs.add(target)
139
146
  },
140
147
  stat: (path) => info(resolve(path, true)),
141
148
  lstat: (path) => info(resolve(path, false)),
@@ -152,6 +159,11 @@ export const inMemorySyncIo = (
152
159
  git,
153
160
  gitBytes: (cwd, args) => encoder.encode(git(cwd, args)),
154
161
  hash: pureHash,
162
+ setExecutable: (path, on) => {
163
+ const real = resolve(path, true)
164
+ if (on) execs.add(real)
165
+ else execs.delete(real)
166
+ },
155
167
  symlink: (path, target) => {
156
168
  links.set(resolve(path, false), target)
157
169
  },
@@ -31,6 +31,7 @@ interface DocumentInput {
31
31
  readonly log?: string
32
32
  readonly session?: { readonly id: string; readonly resume: boolean }
33
33
  readonly validate?: string
34
+ readonly fresh?: string | null
34
35
  readonly changes?: readonly StatusChange[]
35
36
  readonly next?: NextMatch | null
36
37
  readonly cost?: number
@@ -47,6 +48,7 @@ const demandInput = (
47
48
  kind: input.kind,
48
49
  ...(input.session !== undefined ? { session: input.session } : {}),
49
50
  ...(input.validate !== undefined ? { validate: input.validate } : {}),
51
+ ...(input.fresh !== undefined ? { fresh: input.fresh } : {}),
50
52
  })
51
53
 
52
54
  /** `statusOf`'s own input, isolated for the same reason as `demandInput` above. */
@@ -84,6 +86,7 @@ describe("beatDocument / renderBeatJson", () => {
84
86
  content: "fix it",
85
87
  idle: false,
86
88
  initial: false,
89
+ fresh: null,
87
90
  log: ".git/gtd-loop.log",
88
91
  state: "build.fixing",
89
92
  actor: "agent",
@@ -138,6 +141,16 @@ describe("beatDocument / renderBeatJson", () => {
138
141
  expect(parsed.validate).toBe("gtd check qa 'TODO.md'")
139
142
  })
140
143
 
144
+ it("carries fresh at kind prompt and null at every other kind", () => {
145
+ const parsed = JSON.parse(renderJsonLine({ kind: "prompt", fresh: "p\n\nc" })) as {
146
+ fresh: unknown
147
+ }
148
+ expect(parsed.fresh).toBe("p\n\nc")
149
+ for (const kind of BEAT_KINDS.filter((k) => k !== "prompt")) {
150
+ expect((JSON.parse(renderJsonLine({ kind })) as { fresh: unknown }).fresh).toBeNull()
151
+ }
152
+ })
153
+
141
154
  it("omits session/validate at kind prompt when the caller supplied none, never emitting null", () => {
142
155
  const line = renderJsonLine({ kind: "prompt" })
143
156
  const parsed = JSON.parse(line) as Record<string, unknown>
@@ -256,6 +269,7 @@ describe("beatDocument / renderBeatJson", () => {
256
269
  "model",
257
270
  "system",
258
271
  "validate",
272
+ "fresh",
259
273
  "log",
260
274
  "state",
261
275
  "actor",
@@ -395,6 +409,7 @@ describe("golden bytes", () => {
395
409
  content: "fix it",
396
410
  idle: false,
397
411
  initial: false,
412
+ fresh: null,
398
413
  log: ".git/gtd-loop.log",
399
414
  state: "build.fixing",
400
415
  actor: "agent",
@@ -33,6 +33,7 @@ const dispatchFieldsOf = (
33
33
  ): {
34
34
  readonly session: DemandSession | undefined
35
35
  readonly validate: string | undefined
36
+ readonly fresh: string | null
36
37
  readonly skills: readonly string[] | undefined
37
38
  readonly access: StepAccess | undefined
38
39
  readonly pin: PinMark | undefined
@@ -40,6 +41,7 @@ const dispatchFieldsOf = (
40
41
  const none = {
41
42
  session: undefined,
42
43
  validate: undefined,
44
+ fresh: null,
43
45
  skills: undefined,
44
46
  access: undefined,
45
47
  pin: undefined,
@@ -50,6 +52,7 @@ const dispatchFieldsOf = (
50
52
  ...none,
51
53
  session: demand.session,
52
54
  validate: demand.validate,
55
+ fresh: demand.fresh,
53
56
  skills: demand.skills,
54
57
  access: demand.access,
55
58
  }
@@ -75,6 +78,8 @@ export interface BeatDocument {
75
78
  readonly model: string | undefined
76
79
  readonly system: string | undefined
77
80
  readonly validate: string | undefined
81
+ /** A validator retry with `session.resume`: the original prompt plus the correction, for a driver that starts a new session. `null` otherwise. */
82
+ readonly fresh: string | null
78
83
  /** On a pin or unpin beat only: what its script does to `.gtd/process.gtdpin`. */
79
84
  readonly pin: PinMark | undefined
80
85
  readonly log: string
@@ -96,13 +101,13 @@ export interface BeatDocument {
96
101
  }
97
102
 
98
103
  /**
99
- * Flatten a `Demand` plus its `BeatStatus` into the single 26-key document
104
+ * Flatten a `Demand` plus its `BeatStatus` into the single 27-key document
100
105
  * `gtd next --json` emits — the ONLY place the two are joined. `cost`/
101
106
  * `costByModel` are omitted together, exactly when no cost was recorded
102
107
  * (`cost <= 0`).
103
108
  */
104
109
  export const beatDocument = (demand: Demand, status: BeatStatus): BeatDocument => {
105
- const { session, validate, skills, access, pin } = dispatchFieldsOf(demand)
110
+ const { session, validate, fresh, skills, access, pin } = dispatchFieldsOf(demand)
106
111
  const hasCost = status.cost > 0
107
112
  return {
108
113
  schema: WIRE_SCHEMA,
@@ -114,6 +119,7 @@ export const beatDocument = (demand: Demand, status: BeatStatus): BeatDocument =
114
119
  model: status.model,
115
120
  system: status.system,
116
121
  validate,
122
+ fresh,
117
123
  pin,
118
124
  log: status.log,
119
125
  state: status.state,
@@ -54,14 +54,24 @@ export const beatKindOf = (input: {
54
54
  * `stalled at "<state>"` stays a stable substring (see
55
55
  * `tests/integration/features/driver-doc.feature`).
56
56
  */
57
- export const stallDiagnosis = (state: StateName, actor: Actor): string =>
58
- `stalled at "${state}": the last gtd(${actor}): ${state} turn landed an empty ` +
59
- `attempt, the tree is clean, and another dispatch would repeat it.\n\n` +
60
- `Two ways out:\n` +
61
- ` - sharpen the step's prompt so the turn has something concrete to author\n` +
62
- ` - pass allowEmpty: true to the step, so an empty turn completes it and the\n` +
63
- ` flow decides what follows — finishing, or an escalation step after N\n` +
64
- ` empty turns\n`
57
+ export const stallDiagnosis = (
58
+ state: StateName,
59
+ actor: Actor,
60
+ failure?: string,
61
+ blocked?: string,
62
+ ): string =>
63
+ blocked !== undefined
64
+ ? `stalled at "${state}": landing this ship step is refused.\n\n${blocked}\n`
65
+ : failure !== undefined
66
+ ? `stalled at "${state}": the script exited non-zero and its failure is recorded in the tree. ` +
67
+ `Change the tree to run the script again; the step advances only on a zero exit.\n\n${failure}`
68
+ : `stalled at "${state}": the last gtd(${actor}): ${state} turn landed an empty ` +
69
+ `attempt, the tree is clean, and another dispatch would repeat it.\n\n` +
70
+ `Two ways out:\n` +
71
+ ` - sharpen the step's prompt so the turn has something concrete to author\n` +
72
+ ` - pass allowEmpty: true to the step, so an empty turn completes it and the\n` +
73
+ ` flow decides what follows — finishing, or an escalation step after N\n` +
74
+ ` empty turns\n`
65
75
 
66
76
  /** One dispatch session — `resume`'s own meaning lives with `src/wire/Sessions.ts`'s `resolveSession`, which produces it. */
67
77
  export interface DemandSession {
@@ -86,6 +96,7 @@ export type Demand =
86
96
  readonly content: string
87
97
  readonly session: DemandSession | undefined
88
98
  readonly validate: string | undefined
99
+ readonly fresh: string | null
89
100
  readonly skills: readonly string[] | undefined
90
101
  readonly access: StepAccess
91
102
  }
@@ -100,19 +111,22 @@ export type Demand =
100
111
  export const demandOf = (input: {
101
112
  readonly rendered: Pick<
102
113
  RenderedDemandSource,
103
- "actor" | "content" | "state" | "skills" | "access" | "pin"
114
+ "actor" | "content" | "state" | "skills" | "access" | "pin" | "failure" | "blocked"
104
115
  >
105
116
  readonly kind: BeatKind
106
117
  readonly session?: DemandSession
107
118
  readonly validate?: string
119
+ readonly fresh?: string | null
108
120
  }): Demand => {
109
- const { rendered, kind, session, validate } = input
121
+ const { rendered, kind, session, validate, fresh = null } = input
110
122
  const content =
111
- kind === "stalled" ? stallDiagnosis(rendered.state, rendered.actor) : rendered.content
123
+ kind === "stalled"
124
+ ? stallDiagnosis(rendered.state, rendered.actor, rendered.failure, rendered.blocked)
125
+ : rendered.content
112
126
  const skills =
113
127
  rendered.skills !== undefined && rendered.skills.length > 0 ? rendered.skills : undefined
114
128
  const access = rendered.access ?? { read: null, write: null }
115
- if (kind === "prompt") return { kind, content, session, validate, skills, access }
129
+ if (kind === "prompt") return { kind, content, session, validate, fresh, skills, access }
116
130
  if (kind === "script" && rendered.pin !== undefined) return { kind, content, pin: rendered.pin }
117
131
  return { kind, content }
118
132
  }
@@ -23,3 +23,6 @@ export const ACCESS_REFUSAL =
23
23
 
24
24
  /** Where a process keeps its pin: the engine and workflow it runs on, as `gtd bundle` prints them. */
25
25
  export const PIN_PATH = ".gtd/process.gtdpin"
26
+
27
+ /** Where a `run(…, { onFail: "rest" })` body's failure is recorded in the tree: the exit status and the output. */
28
+ export const RUN_FAILED_PATH = ".gtd/run-failed.txt"
@@ -6,6 +6,7 @@
6
6
  "initial": false,
7
7
  "model": "opus",
8
8
  "system": "You are a careful senior engineer.",
9
+ "fresh": null,
9
10
  "log": ".git/gtd-loop.log",
10
11
  "state": "build.fixing",
11
12
  "actor": "agent",
@@ -6,6 +6,7 @@
6
6
  "initial": false,
7
7
  "model": "opus",
8
8
  "system": "You are a careful senior engineer.",
9
+ "fresh": null,
9
10
  "log": ".git/gtd-loop.log",
10
11
  "state": "build.fixing",
11
12
  "actor": "agent",
@@ -11,6 +11,7 @@
11
11
  "model": "opus",
12
12
  "system": "You are a careful senior engineer.",
13
13
  "validate": "gtd check qa 'TODO.md'",
14
+ "fresh": null,
14
15
  "log": ".git/gtd-loop.log",
15
16
  "state": "build.fixing",
16
17
  "actor": "agent",
@@ -6,6 +6,7 @@
6
6
  "initial": false,
7
7
  "model": "opus",
8
8
  "system": "You are a careful senior engineer.",
9
+ "fresh": null,
9
10
  "log": ".git/gtd-loop.log",
10
11
  "state": "build.fixing",
11
12
  "actor": "agent",
@@ -6,6 +6,7 @@
6
6
  "initial": false,
7
7
  "model": "opus",
8
8
  "system": "You are a careful senior engineer.",
9
+ "fresh": null,
9
10
  "log": ".git/gtd-loop.log",
10
11
  "state": "build.fixing",
11
12
  "actor": "agent",
package/src/wire/types.ts CHANGED
@@ -55,4 +55,8 @@ export interface RenderedDemandSource {
55
55
  readonly skills?: readonly string[]
56
56
  readonly access?: StepAccess
57
57
  readonly pin?: PinMark
58
+ /** The recorded output of a failed `onFail: "rest"` run, shown at the stalled beat. */
59
+ readonly failure?: string
60
+ /** Why landing a ship step is refused, shown at the stalled beat. */
61
+ readonly blocked?: string
58
62
  }
@@ -42,6 +42,9 @@ export interface StepDef {
42
42
  readonly requireRevert?: boolean
43
43
  readonly allowEmpty?: boolean
44
44
  readonly keep?: boolean
45
+ /** A validator retry: the step's original prompt, `content` being the correction. */
46
+ readonly original?: string
47
+ readonly onFail?: "rest"
45
48
  readonly acceptClean?: boolean
46
49
  readonly approve?: boolean
47
50
  readonly skills?: readonly string[]
@@ -8,6 +8,7 @@ export interface CompiledConfig {
8
8
  /** The built-in modes (qa/review, with gtd's own validators) merged with `config.modes`, per half. */
9
9
  readonly modes: Record<string, ModeDef>
10
10
  readonly ui?: UiSettings
11
+ readonly historyRemote?: string
11
12
  readonly skills: Readonly<Record<string, readonly string[]>>
12
13
  readonly access: GtdConfig["access"] & {}
13
14
  readonly diagnostics: readonly Diagnostic[]
@@ -48,6 +49,7 @@ export const compileConfig = (value: unknown, origin: string): CompiledConfig =>
48
49
  return {
49
50
  modes,
50
51
  ...(config.ui !== undefined ? { ui: config.ui } : {}),
52
+ ...(config.historyRemote !== undefined ? { historyRemote: config.historyRemote } : {}),
51
53
  // Null prototype: a `__proto__` key must stay an ordinary (reportable) entry.
52
54
  skills: Object.assign(Object.create(null), config.skills),
53
55
  access: Object.assign(Object.create(null), config.access),
@@ -40,6 +40,7 @@ export interface ConfigOperations {
40
40
  readonly doors: ReadonlyMap<string, Door>
41
41
  /** `config.ui` (absent when unconfigured) — `gtd ui` and its CLI flags read it. */
42
42
  readonly ui?: flows.UiSettings
43
+ readonly historyRemote?: string
43
44
  /** Non-fatal findings, formatted through `formatDiagnostic` like an error. */
44
45
  readonly warnings: readonly Diagnostic[]
45
46
  /** The content hash of every file `gtd.config.ts`'s evaluation touched — itself plus any relative import — by path relative to the root. */
@@ -197,6 +198,7 @@ export const load: Effect.Effect<
197
198
  return entry === undefined ? undefined : resolve(name, entry)
198
199
  },
199
200
  ...(compiled.ui !== undefined ? { ui: compiled.ui } : {}),
201
+ ...(compiled.historyRemote !== undefined ? { historyRemote: compiled.historyRemote } : {}),
200
202
  warnings: diagnostics.filter((d) => d.severity === "warning"),
201
203
  fingerprint:
202
204
  pinned?.fingerprint ??
@@ -5,6 +5,7 @@ describe("@pmelab/gtd/workflow-kit", () => {
5
5
  it("publishes its helpers by name", () => {
6
6
  expect(Object.keys(kit).sort()).toEqual(
7
7
  [
8
+ "assertionErrors",
8
9
  "assertionLines",
9
10
  "escalation",
10
11
  "fixFindings",
@@ -23,6 +23,7 @@ export {
23
23
  } from "./quality.js"
24
24
  export {
25
25
  assertionLines,
26
+ assertionErrors,
26
27
  removedAssertions,
27
28
  type Assertion,
28
29
  type RemovedAssertions,
@@ -68,3 +68,11 @@ export const removedAssertions = (changes: readonly Change[]): readonly RemovedA
68
68
  return { path: c.path, lines }
69
69
  })
70
70
  .filter((r) => r.lines.length > 0)
71
+
72
+ /** Validator form of `removedAssertions`: lists each lost line by path and scenario, or `undefined` when none. */
73
+ export const assertionErrors = (changes: readonly Change[]): string | undefined => {
74
+ const lost = removedAssertions(changes)
75
+ if (lost.length === 0) return undefined
76
+ const lines = lost.flatMap((r) => r.lines.map((a) => ` - ${r.path}: ${a.scenario}: ${a.line}`))
77
+ return `Restore the assertions these .feature files lost:\n${lines.join("\n")}`
78
+ }